Webhooks
Payments are asynchronous. A dispute arrives weeks later, a subscription renews at 3am, a payout settles on a bank's schedule — webhooks are how your system finds out.
Registering an endpoint
Add an HTTPS endpoint in the dashboard or through the API, choose the events you care about, and we will POST a signed JSON body to it whenever one occurs. Subscribe narrowly — an endpoint that receives every event type spends most of its life ignoring things.
curl https://api.bearmerchant.com/v1/webhook_endpoints \
-u sk_live_…: \
-d '{
"url": "https://yourapp.com/hooks/bearmerchant",
"enabled_events": [
"charge.succeeded",
"charge.failed",
"dispute.created"
],
"description": "Order fulfilment service"
}'
Event catalogue
Payments
| Event | Fires when |
|---|---|
charge.succeeded | A charge was authorised and captured |
charge.failed | A charge was declined by the issuer or blocked by risk |
charge.captured | A previously authorised charge was captured |
charge.refunded | A full or partial refund completed |
charge.pending | An ACH debit was submitted and is awaiting clearing |
Subscriptions
| Event | Fires when |
|---|---|
subscription.created | A subscription started |
subscription.renewed | A billing period renewed successfully |
subscription.payment_failed | A renewal charge failed and dunning has begun |
subscription.recovered | A retry succeeded after an earlier failure |
subscription.canceled | The subscription ended, by request or after dunning gave up |
Risk and settlement
| Event | Fires when |
|---|---|
dispute.created | A chargeback was filed — the deadline clock starts here |
dispute.alert | A pre-dispute alert arrived; refunding now avoids the chargeback |
dispute.won / dispute.lost | The issuer ruled on your representment |
payout.paid | Funds left our account for your bank |
payout.failed | The bank rejected the settlement — usually stale bank details |
Event payload
Every event has the same envelope. The data.object is the full resource in the state it reached when the event fired.
{
"id": "evt_1PkX9a2eZvKYlo2C",
"object": "event",
"type": "charge.succeeded",
"api_version": "2026-04-01",
"created": "2026-07-27T09:14:22Z",
"livemode": true,
"data": {
"object": {
"id": "ch_3PkX9a2eZvKYlo2C",
"object": "charge",
"amount": 4280,
"currency": "usd",
"status": "succeeded",
"metadata": { "order_id": "1042" }
}
}
}
Verifying signatures
Your endpoint URL is effectively public. Anything can POST to it, so an unverified handler will happily fulfil an order that nobody paid for. Verification is not optional.
Each delivery carries a BM-Signature header containing a timestamp and an HMAC-SHA256 of timestamp.rawBody, keyed with your endpoint's signing secret.
BM-Signature: t=1785164692,v1=5257a869e7bcfa5c9e4d2b7f1a8c3e6d4b9f0a2c8e1d7b3f6a4c9e2d5b8f1a3c
<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_BM_SIGNATURE'] ?? '';
$secret = getenv('BM_WEBHOOK_SECRET');
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, $secret);
// Constant-time compare — == leaks timing information
if (!hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
// Reject anything older than five minutes to stop replays
if (abs(time() - (int) $parts['t']) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
enqueue($event); // hand off, do not process inline
http_response_code(200);
Delivery and retries
Any response outside 2xx — or no response within 20 seconds — counts as a failure and we retry with exponential backoff over roughly three days.
| Attempt | Sent after |
|---|---|
| 1 | Immediately |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 (final) | 48 hours |
- Return
200as soon as you have verified and stored the event. Do the real work in a queue. - Never return an error because your business logic rejected the event — that triggers days of pointless retries.
- An endpoint failing continuously for three days is disabled automatically, and we email you before that happens.
Handling duplicates
Delivery is at-least-once. A network blip after your handler committed but before your 200 arrived means you will see that event again. Handlers must be idempotent — this is the single most common source of double-fulfilled orders.
-- Store the event id and let the database enforce uniqueness
CREATE TABLE processed_events (
event_id VARCHAR(64) PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
handled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Insert first; if it conflicts, this event is already done
INSERT INTO processed_events (event_id, event_type)
VALUES ('evt_1PkX9a2eZvKYlo2C', 'charge.succeeded')
ON CONFLICT (event_id) DO NOTHING;
created, or re-fetch the object from the API if the current state is what matters.Testing and replay
- Trigger any event on demand from the sandbox dashboard, without creating a real transaction.
- Every delivery attempt is logged for 30 days with the request, your response and the timing.
- Replay any past event to any endpoint — useful when a deploy dropped events for an hour.
- Use the CLI to forward live sandbox events to
localhostso you can debug behind a firewall.
# Stream sandbox events straight to your dev machine
bm listen --forward-to http://localhost:3000/hooks/bearmerchant
# Fire a specific event without making a payment
bm trigger dispute.created
# Re-send an event you have already received
bm events resend evt_1PkX9a2eZvKYlo2C