Reconciliation Guide
Match webhook events to API charges reliably using Beys' canonical charge ID. Handle duplicates, replays, and idempotency the way the gateway does.
Why a canonical ID?
Every charge Beys creates is stored as a single row and exposed through one canonical identifier — ch_ followed by the full 32-character hex form of the charge's UUID, with dashes removed. This exact string appears in both the REST API response and every webhook event that references the charge. Treating it as the single source of truth is what lets you reconcile an incomingcharge.succeeded event against a charge you fetched from GET /v1/charges.
Where the ID appears
POST /v1/chargesresponse body —{ id: "ch_…", status, amount, currency }GET /v1/charges/:idandGET /v1/chargeslist responses- The
data.idfield of everycharge.*webhook event - Hosted-checkout webhooks emitted by payment-link payments — same full-length ID, same format
Because the hosted checkout and the API write to the same transaction record, an event fired from a payment-link checkout carries the identical ID you'd get by retrieving that charge over the API. Never substring or truncate the ID for storage — keep the full string.
The matching workflow
Follow this sequence for each incoming webhook:
- Verify the signature. Recompute
HMAC-SHA256(endpointSecret, `${timestamp}.${rawBody}`)and compare it to theBeys-Signatureheader using a constant-time comparison. - Enforce the timestamp window. Reject events whose
Beys-Timestampdiffers from your server clock by more than 5 minutes to prevent replay. - Extract the canonical charge ID. Read
event.data.id(e.g.ch_1a2b…) — this is the key you will match on. - Idempotently apply the event. Look up the charge in your system by that ID. If it's already marked paid from a prior event, return 2xx and do nothing. Otherwise update your record and commit.
- Confirm via the API (optional). For high-value orders, fetch
GET /v1/charges/{id}and assert the API status agrees with the event before fulfilling.
Idempotent event handling
Beys may deliver the same event more than once — during retries, or because your endpoint returned a non-2xx. Design your handler so that processing the same event twice has the same effect as processing it once. Track the id at the top level of the webhook payload (the event ID, distinct from the charge ID) in a processed-events table, and skip events you've already handled.
// Pseudocode for an idempotent webhook handler
const eventId = body.id; // e.g. "evt_8f3…"
const chargeId = body.data.id; // e.g. "ch_1a2b…" — canonical charge id
if (await alreadyProcessed(eventId)) return res.status(200).end();
await db.tx(async (t) => {
await t.markEventProcessed(eventId);
await t.updateOrder(chargeId, { status: body.type === "charge.succeeded" ? "paid" : "failed" });
});
res.status(200).end();Do not match on partial or legacy IDs
Earlier responses from some endpoints returned a 24-character truncated form of the ID. If your database still holds those, treat them as a prefix of the canonical 32-character ID — the full ID always begins with the truncated one. When backfilling, fetch each charge via the API to obtain the canonical ID and update your records; do not continue storing the truncated form for new charges.
Reconciliation checklist
- Store the full ch_ + 32-hex ID — never a truncated or reformatted copy.
- Use the event-level id (evt_…) for deduplication, and data.id (ch_…) to find the charge.
- Verify signatures and enforce the 5-minute timestamp window before trusting any event.
- Make every handler idempotent so redelivery is safe.
- Reconcile webhook-derived state against GET /v1/charges for audit and dispute defense.
- Log mismatches to an alert channel so silent reconciliation failures surface quickly.
Need the request/response shapes? See the full API reference.