Developers

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.

register.sh bash
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"
  }'
Your endpoint must be reachable over HTTPS with a valid certificate, and must answer within 20 seconds. Do the minimum inline — verify, enqueue, respond — and process the work elsewhere.

Event catalogue

Payments

EventFires when
charge.succeededA charge was authorised and captured
charge.failedA charge was declined by the issuer or blocked by risk
charge.capturedA previously authorised charge was captured
charge.refundedA full or partial refund completed
charge.pendingAn ACH debit was submitted and is awaiting clearing

Subscriptions

EventFires when
subscription.createdA subscription started
subscription.renewedA billing period renewed successfully
subscription.payment_failedA renewal charge failed and dunning has begun
subscription.recoveredA retry succeeded after an earlier failure
subscription.canceledThe subscription ended, by request or after dunning gave up

Risk and settlement

EventFires when
dispute.createdA chargeback was filed — the deadline clock starts here
dispute.alertA pre-dispute alert arrived; refunding now avoids the chargeback
dispute.won / dispute.lostThe issuer ruled on your representment
payout.paidFunds left our account for your bank
payout.failedThe 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.

event.json json
{
  "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.

header text
BM-Signature: t=1785164692,v1=5257a869e7bcfa5c9e4d2b7f1a8c3e6d4b9f0a2c8e1d7b3f6a4c9e2d5b8f1a3c
webhook.php php
<?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);
Verify against the raw request body, byte for byte. Frameworks that parse and re-encode JSON before your handler sees it will change whitespace or key order and every signature will fail.

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.

AttemptSent after
1Immediately
25 minutes
330 minutes
42 hours
56 hours
612 hours
724 hours
8 (final)48 hours
  • Return 200 as 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.

dedupe.sql sql
-- 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;
Events can also arrive out of order. Do not infer state from arrival sequence — read 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 localhost so you can debug behind a firewall.
cli.sh bash
# 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