Skip to main content

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:

Adding an endpoint
ParameterDescription
urlRequired. Live endpoints must use https; test endpoints may use http.
enabled_eventsRequired. Event types, or ["*"] for all of them.
descriptionFor you.
api_versionThe 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

Event types
TypeWhen
payment.createdA payment was created (API, dashboard, a payment link or a plugin).
payment.processingThe customer sent a receipt, or said they paid.
payment.partially_paidDeposits arrived but are below the amount.
payment.requires_reviewThe payment needs a person.
payment.succeededThe payment is paid.
payment.overpaidMore than the amount arrived, or money arrived on a canceled payment.
payment.overpayment_resolvedYou recorded a refund, credit or keep.
payment.canceledThe payment was canceled.
payment.expiredThe checkout closed with nothing received.
payment.updatedDescription, metadata or customer details changed.
receipt.createdA receipt arrived, from the checkout, your dashboard or the API.
receipt.approvedA member, automatic approval or the API approved a receipt.
receipt.rejectedA member or the API rejected a receipt.
deposit.createdA transfer was detected or recorded.
deposit.matchedA 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.unmatchedA deposit was detached, or left unmatched.
deposit.ignoredA deposit was marked as not a customer payment.
payment_link.created, payment_link.updatedA payment link changed.
customer.created, customer.updated, customer.deletedA customer changed.
device.online, device.offline, device.revokedA phone of the optional SMS app came back, went quiet for an hour, or was removed.
card.created, card.updatedA destination card or account changed.

The event object

JSON
{
  "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:

HTTP
Bezanbecard-Signature: t=1793000000,v1=0b7a0c3ee135084e8ffaa56e2ebe4f4ea6062ba462e8e92b3e705fda3089b0ef
User-Agent: Bezanbecard/1.0 (+https://bezanbecard.com/docs/webhooks)

To verify it:

  1. Read the raw request body, before any JSON parsing. Re-serialized JSON does not match.
  2. Split the header on , into t and one or more v1 values. A header without t or without any v1 fails.
  3. Compute the lowercase hex HMAC-SHA256 of `${t}.${rawBody}`, keyed with the whole endpoint secret string, whsec_ included.
  4. Compare it with each v1 in constant time; any match passes. During a secret roll the header carries two v1 values.
  5. Refuse when t is 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.

Text
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}}
Test vectors
CaseHeaderResult
Validt=1793000000,v1=0b7a0c3ee135084e8ffaa56e2ebe4f4ea6062ba462e8e92b3e705fda3089b0efvalid
Two v1 during a roll, the second matchest=1793000000,v1=6739d862bae1405a4d5c5cdfc7169d22c315366fc966c6eedb2c5ccbdd31b7fe,v1=0b7a0c3ee135084e8ffaa56e2ebe4f4ea6062ba462e8e92b3e705fda3089b0efvalid
Wrong secrett=1793000000,v1=24e6795b5099d83284613681e1ee80acf4de695f0e9eaedc9816af7bb29e1fd2invalid
Tampered payload ("amount":12500471) with the valid headert=1793000000,v1=0b7a0c3e…b0efinvalid
Too old (now = 1793000301)the valid headerinvalid
Too far in the future (now = 1792999699)the valid headerinvalid
No v1t=1793000000invalid
No tv1=0b7a0c3e…b0efinvalid
Unknown scheme onlyt=1793000000,v0=0b7a0c3e…b0efinvalid
Upper-case hext=1793000000,v1=0B7A0C3E…B0EFinvalid

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 id of 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 /v1/events?type=payment.succeeded&created[gte]=….

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.