Alphabet BridgePayments Docs
Concepts

Webhooks

Signed events on every payment status change, how to verify them, and retries for 24 hours.

Every time a payment changes status, the bridge sends a signed POST to your application's webhook URL. Set the URL in the application dashboard; the signing secret, which starts with whsec_, is shown there once and can be regenerated. Test and live events go to the same URL: read data.mode to tell them apart.

Events

Event Sent when the payment becomes
payment.paid paid
payment.failed failed
payment.cancelled cancelled
payment.expired expired
payment.partially_refunded partially_refunded (again for each further partial refund)
payment.refunded refunded

Creating a payment sends no event. A live refund request sends no event either; only the confirmed refund does. See Refunds.

What we send

text
POST /webhooks/bridge HTTP/1.1
Host: shop.example
Content-Type: application/json
User-Agent: AlphabetPaymentBridge-Webhooks/1.0
X-Bridge-Event: payment.paid
X-Bridge-Signature: t=1792017204,v1=17cfa378e474d1c57e8707e60fcbc369329a5bf15981d2ed81472a8d5188cfa2

The body has the event id, the event name, its created_at, and under data the payment exactly as GET /api/v1/payments/{id} returned it at the moment of the change:

json
{
  "id": "evt_0b8f7c52-4a51-4f0e-9a3e-2c7d1e9b6a10",
  "event": "payment.paid",
  "created_at": "2026-10-14T22:35:02+00:00",
  "data": {
    "id": "01a13c8c-8592-70f4-9ef9-d3767e74d54c",
    "status": "paid",
    "mode": "test",
    "amount": 25000,
    "refunded_amount": 0,
    "currency": "IQD",
    "method": "aps_card",
    "order_reference": "ORD-1001",
    "description": "Two coffees",
    "customer": {"name": "Ali", "phone": "+9647700000000", "email": "ali@example.com"},
    "payer": {"name": "Ali Hassan", "phone": null, "email": null, "ip": "203.0.113.10", "user_agent": "GuzzleHttp/7"},
    "locale": "en",
    "return_url": "https://shop.example/return",
    "metadata": {"cart_id": "c-42"},
    "checkout_url": "https://<your-bridge-domain>/checkout/01a13c8c-8592-70f4-9ef9-d3767e74d54c",
    "expires_at": "2026-10-14T23:03:24+00:00",
    "paid_at": "2026-10-14T22:35:02+00:00",
    "created_at": "2026-10-14T22:33:24+00:00",
    "refunds": []
  }
}

Verify the signature

X-Bridge-Signature looks like t=1792017204,v1=17cf…. t is when the bridge signed, in Unix seconds. v1 is the hex HMAC-SHA256 of the string t + "." + raw body, keyed with your signing secret.

  1. Read the raw request body before any JSON parsing.
  2. Take t and v1 from the header.
  3. Compute the HMAC of t.body with your secret and compare it to v1 in constant time.
  4. Reject the request if t is more than 5 minutes away from your clock.

PHP:

php
<?php

function verifyBridgeSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    if (! preg_match('/(?:^|,)t=(\d+)/', $header, $t) || ! preg_match('/(?:^|,)v1=([0-9a-f]{64})/', $header, $v1)) {
        return false;
    }

    if (abs(time() - (int) $t[1]) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $t[1].'.'.$rawBody, $secret);

    return hash_equals($expected, $v1[1]);
}

In a Laravel route:

php
Route::post('/webhooks/bridge', function (Illuminate\Http\Request $request) {
    $valid = verifyBridgeSignature(
        $request->getContent(),
        (string) $request->header('X-Bridge-Signature'),
        config('services.bridge.webhook_secret'),
    );

    abort_unless($valid, 400);

    $event = $request->json()->all();
    // Skip $event['id'] if you have handled it already, then queue the real work.

    return response()->noContent();
});

Node:

js
const crypto = require('node:crypto');

function verifyBridgeSignature(rawBody, header, secret, tolerance = 300) {
  const t = /(?:^|,)t=(\d+)/.exec(header || '')?.[1];
  const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(header || '')?.[1];
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

With Express, keep the body raw for this route:

js
app.post('/webhooks/bridge', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyBridgeSignature(req.body, req.get('X-Bridge-Signature'), process.env.BRIDGE_WEBHOOK_SECRET);
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  // Skip event.id if you have handled it already, then queue the real work.
  res.sendStatus(204);
});

Verify against the raw body. Re-encoding parsed JSON changes spacing and escaping, and the signature will not match.

Respond quickly

Answer with any 2xx within 10 seconds. Anything else, a redirect loop, or no answer counts as a failed delivery. Put slow work, such as emails and invoices, on a queue and answer first.

Retries

The first attempt is sent straight away. If it fails, the bridge tries again after each of these waits, for 7 attempts over about 24 hours:

Attempt Sent
1 At once
2 1 minute after attempt 1
3 5 minutes after attempt 2
4 30 minutes after attempt 3
5 2 hours after attempt 4
6 6 hours after attempt 5
7 15 hours after attempt 6, about 23.6 hours after the first

After the seventh failure, or once 24 hours have passed, the delivery is marked failed and the bridge stops. Every retry sends the same body with the same event id, signed again with a fresh t. The URL and secret are read when each attempt is sent, so fixing them in the dashboard helps retries that are still to come.

Duplicates and order

  • The same event can arrive more than once. Store each event id you handle and skip the ones you have seen.
  • Events can arrive out of order, for example after a retry. Trust data.status, or call GET /api/v1/payments/{id} when in doubt.

Resend from the dashboard

Every delivery attempt is listed in the admin and application dashboards, with the response code and the start of the response body. Press Resend to send the same event again, with a fresh signature, for example after you fix a bug in your handler.