Webhooks
Receive signed events when payments change, verify the signature exactly, and handle retries and duplicates.
A webhook endpoint is a URL on your server that Bezanbecard calls when something happens in your account: a receipt arrives, your team approves it, a payment succeeds. Use webhooks instead of polling, so an order is updated even when the customer closes the checkout before coming back.
Adding an endpoint
Add endpoints under Developers → Webhooks, or with POST /v1/webhook_endpoints:
| Parameter | Description |
|---|---|
url | Required. Live endpoints must use https; test endpoints may use http. |
enabled_events | Required. Event types, or ["*"] for all of them. |
description | For you. |
api_version | The version of the objects in the events; defaults to the current one. |
The answer carries secret (whsec_…) once. Store it with your other secrets; an owner or admin can reveal it again in the dashboard. Test and live endpoints have different secrets.
curl https://a.bezanbecard.com/v1/webhook_endpoints \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: endpoint-shop-1" \
-H "Content-Type: application/json" \
-d '{"url":"https://shop.example/webhooks/bezanbecard","enabled_events":["payment.succeeded","payment.partially_paid","payment.overpaid"]}'An endpoint that resolves to a private or loopback address is refused in production (webhook_destination_private), and checked again before every delivery.
Event types
| Type | When |
|---|---|
payment.created | A payment was created (API, dashboard, a payment link or a plugin). |
payment.processing | The customer sent a receipt, or said they paid. |
payment.partially_paid | Deposits arrived but are below the amount. |
payment.requires_review | The payment needs a person. |
payment.succeeded | The payment is paid. |
payment.overpaid | More than the amount arrived, or money arrived on a canceled payment. |
payment.overpayment_resolved | You recorded a refund, credit or keep. |
payment.canceled | The payment was canceled. |
payment.expired | The checkout closed with nothing received. |
payment.updated | Description, metadata or customer details changed. |
receipt.created | A receipt arrived, from the checkout, your dashboard or the API. |
receipt.approved | A member, automatic approval or the API approved a receipt. |
receipt.rejected | A member or the API rejected a receipt. |
deposit.created | A transfer was detected or recorded. |
deposit.matched | A deposit was attached to a payment. A bank deposit that took the place of a receipt deposit names it in replaces: the same transfer, never new money. |
deposit.unmatched | A deposit was detached, or left unmatched. |
deposit.ignored | A deposit was marked as not a customer payment. |
payment_link.created, payment_link.updated | A payment link changed. |
customer.created, customer.updated, customer.deleted | A customer changed. |
device.online, device.offline, device.revoked | A phone of the optional SMS app came back, went quiet for an hour, or was removed. |
card.created, card.updated | A destination card or account changed. |
The event object
{
"id": "evt_TestVector000001",
"object": "event",
"type": "payment.succeeded",
"livemode": false,
"api_version": "2026-10-01",
"created": 1793000000,
"data": {
"object": { "id": "pay_TestVector000001", "object": "payment", "status": "succeeded", "amount_received": 12500470 },
"previous_attributes": { "status": "processing" }
},
"request": { "id": null, "idempotency_key": null }
}data.object is the object as the API returns it, at the moment of the event. Events never carry a card number or an IBAN, and receipt events describe the files by id, size and SHA-256 only, never with a link to them. previous_attributes holds the fields that changed and their old values. request names the API request that caused the event, and is null for changes no request caused (a bank SMS, a scheduled expiry).
Verifying the signature
Every delivery carries:
Bezanbecard-Signature: t=1793000000,v1=0b7a0c3ee135084e8ffaa56e2ebe4f4ea6062ba462e8e92b3e705fda3089b0ef
User-Agent: Bezanbecard/1.0 (+https://bezanbecard.com/docs/webhooks)To verify it:
- Read the raw request body, before any JSON parsing. Re-serialized JSON does not match.
- Split the header on
,intotand one or morev1values. A header withouttor without anyv1fails. - Compute the lowercase hex HMAC-SHA256 of
`${t}.${rawBody}`, keyed with the whole endpoint secret string,whsec_included. - Compare it with each
v1in constant time; any match passes. During a secret roll the header carries twov1values. - Refuse when
tis more than 300 seconds away from your clock, in either direction.
The libraries do all of this:
import express from 'express';
import Bezanbecard from '@bezanbecard/node';
const app = express();
app.post('/webhooks/bezanbecard', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = Bezanbecard.webhooks.constructEvent(req.body, req.headers['bezanbecard-signature'], process.env.BEZANBECARD_WEBHOOK_SECRET);
} catch (error) {
return res.status(400).send('Invalid signature');
}
if (event.type === 'payment.succeeded') fulfilOrder(event.data.object.reference);
res.json({ received: true });
});Without a library
import crypto from 'node:crypto';
export function verifyBezanbecardSignature(rawBody, header, secret, tolerance = 300, now = Math.floor(Date.now() / 1000)) {
const pairs = String(header ?? '').split(',').map((part) => part.trim().split('='));
const t = pairs.find(([key]) => key === 't')?.[1];
const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => value ?? '');
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
if (Math.abs(now - Number(t)) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return signatures.some((signature) => Buffer.byteLength(signature) === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)));
}Test vectors
Check your verifier against these values, shared by every official library. Evaluate each case at now = 1793000100 unless it says otherwise.
secret whsec_dGVzdF9zZWNyZXRfZm9yX3ZlY3RvcnNfMDEyMzQ1Njc4OQ
payload {"id":"evt_TestVector000001","object":"event","type":"payment.succeeded","livemode":false,"api_version":"2026-10-01","created":1793000000,"data":{"object":{"id":"pay_TestVector000001","object":"payment","amount":12500470,"amount_received":12500470,"currency":"irr","status":"succeeded"},"previous_attributes":{"status":"processing"}},"request":{"id":null,"idempotency_key":null}}| Case | Header | Result |
|---|---|---|
| Valid | t=1793000000,v1=0b7a0c3ee135084e8ffaa56e2ebe4f4ea6062ba462e8e92b3e705fda3089b0ef | valid |
Two v1 during a roll, the second matches | t=1793000000,v1=6739d862bae1405a4d5c5cdfc7169d22c315366fc966c6eedb2c5ccbdd31b7fe,v1=0b7a0c3ee135084e8ffaa56e2ebe4f4ea6062ba462e8e92b3e705fda3089b0ef | valid |
| Wrong secret | t=1793000000,v1=24e6795b5099d83284613681e1ee80acf4de695f0e9eaedc9816af7bb29e1fd2 | invalid |
Tampered payload ("amount":12500471) with the valid header | t=1793000000,v1=0b7a0c3e…b0ef | invalid |
Too old (now = 1793000301) | the valid header | invalid |
Too far in the future (now = 1792999699) | the valid header | invalid |
No v1 | t=1793000000 | invalid |
No t | v1=0b7a0c3e…b0ef | invalid |
| Unknown scheme only | t=1793000000,v0=0b7a0c3e…b0ef | invalid |
| Upper-case hex | t=1793000000,v1=0B7A0C3E…B0EF | invalid |
Answering and retries
Answer with any 2xx within 10 seconds, then do slow work afterwards. Anything else is a failure, and so is a redirect: Bezanbecard never follows one.
A failed delivery is sent again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, then every 12 hours, for up to 3 days from the first attempt (11 attempts in all). An endpoint that fails for 3 days straight is disabled and its owners, admins and developers are notified; enable it again in the dashboard once it is fixed.
You can resend any delivery by hand from the endpoint's page, and send a test event that is never stored.
Duplicates and order
Delivery is at least once and not ordered. The same event can arrive twice, and payment.succeeded can arrive before payment.processing. So:
- Store the
idof each event you handle and skip ids you have seen. - When order matters, read the object again (
GET /v1/payments/:id) and act on what it says now, not on the event alone.
Missed something while your server was down? Events are kept for 30 days: list them with GET /.
Rolling the secret
Roll an endpoint's secret from its page in the dashboard. The old secret keeps signing for up to 24 hours (you choose; 0 stops it at once), and during that time each delivery carries two v1 signatures, the new one first. Deploy the new secret, then let the old one expire.