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
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:
{
"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.
- Read the raw request body before any JSON parsing.
- Take
tandv1from the header. - Compute the HMAC of
t.bodywith your secret and compare it tov1in constant time. - Reject the request if
tis more than 5 minutes away from your clock.
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:
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:
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:
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
idyou handle and skip the ones you have seen. - Events can arrive out of order, for example after a retry. Trust
data.status, or callGET /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.