Webhooks
A payment succeeds after the payer has left your page, so the result reaches you as a signed POST. This guide covers how to register an endpoint, what a delivery carries, how to verify it, how retries run and how to make your handler safe against a repeat.
Register an endpoint
Endpoints are per mode. Create one on the test surface of the dashboard, https://dash.lemak.cash/test, under Developers, and it receives test events only. Create one on the live surface and it receives live events only. The dashboard calls POST /dashboard/v1/webhook_endpoints?mode=test on your session; there is no API-key route for this.
- url
- An absolute
httpsaddress with a public host name. Plainhttp, an IP literal andlocalhostare refused with400 invalid_url. At delivery time we also refuse to connect to a loopback, private, link-local or multicast address, whatever the name resolved to. For development, expose a local server through an https tunnel. - enabled_events
- The event types this endpoint receives. An empty list, the default, receives every type.
- secret
- Returned once by the create call and by nothing else: 64 hex characters, and the key of the
v1signature below is the 32 bytes they encode. We store no copy: the value is derived from our master key at every delivery, so nothing we hold can leak it. If you lose it, delete the endpoint and create a new one, or verify withv2, which needs no secret. - api_version
- The version every delivery to this endpoint is rendered at. Set at creation to your account's pinned version. See Versioning.
- status
enabledordisabled. A disabled endpoint is queued nothing. Delete tombstones the row, so the deliveries already made keep the address they were made to.
The event
Every delivery is one event, as JSON, with Content-Type: application/json; charset=utf-8:
{
"id": "evt_AGQHB2K3ZT6RXM4EQ7WPOILG5A",
"type": "payment_intent.succeeded",
"created": "2026-10-16T08:14:09Z",
"livemode": false,
"data": {
"id": "pi_AGQHAVEX7J327MRWOPILG46G6Y",
"merchant": "mch_AF4K2Q7NZP3TJWB6RE5LXDM2CY",
"mode": "test",
"object": "payment_intent",
"status": "succeeded"
}
}
- id
- Stable and globally unique. The same event delivered twice carries the same id. It is the key to de-duplicate on.
- type
- The object and what happened, joined by a dot. A payment intent emits
payment_intent.<status>on every status change:requires_payment,processing,succeeded,channel_settled,failed,expired,canceled. A dispute emitsdispute.opened,dispute.updatedanddispute.closed. A settlement emitssettlement.<status>:pending,processing,paid,failed. These are the only types we deliver. - created
- RFC 3339 in UTC with a
Z, to whole seconds. - livemode
falsefor a test event. A test event reaches only a test endpoint, and the body says so as well, so a handler can refuse the wrong book even if an endpoint was registered in the wrong place.- data
- A notice, not the object: the object's
id, itsobjectname, the newstatus, your merchant id and themode. A settlement event also carriesamount. Read the full object with the API, for exampleGET /v1/payment_intents/{id}, before you ship goods or update a balance.
Events are not guaranteed to arrive in order. Two status changes seconds apart may be delivered the other way round, so act on the status you read from the API, not on the order of arrival.
Verify the signature
One header carries two signatures over the same bytes:
X-Webhook-Signature: t=1760602449,v1=<hex HMAC-SHA256>,v2=<base64 Ed25519>,v1_kid=1,v2_kid=key_…
The signed message is the t value as sent, one dot, and the request body exactly as it arrived on the wire. Read the body as bytes and sign those bytes; a body that has been parsed and re-encoded will not match.
- t is a Unix timestamp in seconds. Reject a delivery whose
tis more than 300 seconds from your clock, in either direction, however good the signature is. A captured body is otherwise a replay for ever. - v1 is HMAC-SHA256 under the endpoint secret, hex encoded. Compare in constant time. v1_kid is the version of the endpoint secret that signed it.
- v2 is an Ed25519 signature under our platform key, standard base64. The public keys are published at
GET /v1/webhook_public_keys: each is{id, algorithm: "ed25519", public_key}, withpublic_keythe raw 32 bytes in base64. v2_kid is theidof the key that signed, so verify with that key. Fetch the list at start-up and again when you meet av2_kidyou do not hold, because a rotation publishes a new key here first.
Ignore any field you do not know: a field may be added to this header, as the two key ids were. Checking either signature is enough. v1 proves the delivery was made to this endpoint; v2 proves it came from us and needs nothing you have to keep secret.
import { createHmac, createPublicKey, timingSafeEqual, verify } from "node:crypto";
// Ed25519 raw public key to a Node KeyObject: prepend the fixed SPKI header.
const SPKI_ED25519 = Buffer.from("302a300506032b6570032100", "hex");
const rawToKey = (b64) =>
createPublicKey({ key: Buffer.concat([SPKI_ED25519, Buffer.from(b64, "base64")]), format: "der", type: "spki" });
function parse(header) {
return Object.fromEntries(header.split(",").map((f) => {
const i = f.indexOf("="); // base64 ends in "=", so no split("=")
return [f.slice(0, i).trim(), f.slice(i + 1)];
}));
}
// body: Buffer of the raw request body. secret: the endpoint secret (hex), or null to skip v1.
// publicKeys: GET /v1/webhook_public_keys as a map of id to public_key, or {} to skip v2.
export function verifyWebhook(header, body, { secret = null, publicKeys = {} }, now = Date.now() / 1000) {
const f = parse(header);
if (!/^\d+$/.test(f.t ?? "") || Math.abs(now - Number(f.t)) > 300) return false;
const msg = Buffer.concat([Buffer.from(f.t + "."), body]);
if (secret !== null) {
const want = Buffer.from(f.v1 ?? "", "hex");
const got = createHmac("sha256", Buffer.from(secret, "hex")).update(msg).digest();
return want.length === got.length && timingSafeEqual(want, got);
}
const pk = publicKeys[f.v2_kid];
return pk !== undefined && verify(null, msg, rawToKey(pk), Buffer.from(f.v2 ?? "", "base64"));
}
A handler answers a bad signature with a 4xx and does nothing else. It never logs the body of a request it could not verify as if it were ours.
Delivery, retries and the log
A delivery is a POST with a 10-second timeout. Any 2xx status marks it delivered. Anything else, including a timeout or a connection failure, schedules the next attempt:
| After attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
| 6 | 24 hours |
| 7 | none: the delivery stops and waits for a resend |
Seven attempts over about 35 hours, then the delivery stops rather than retrying for ever. The dashboard's Developers screen lists every delivery, newest first, with the attempt count, the status your endpoint answered and the next retry time, and offers Resend. A resend re-arms the delivery for the next worker pass; it is signed and logged like any other attempt. Fix the endpoint first, then resend, and if a long outage has stopped many deliveries, prefer reading the event list to resending each one.
Answer fast. Store the event, acknowledge with 200, and do the work after the response. A handler that calls your own systems before it answers will time out under load and be retried, which is more work, not less.
At-least-once delivery
A retry after a timeout may deliver an event your endpoint had in fact received. An event is therefore delivered at least once, never exactly once. Make the handler idempotent on event.id: insert the id into a table with a unique key before you act, and treat a conflict as "already handled". The id is the same on every attempt and on every resend.
Your endpoint is not the only copy of the record. GET /v1/events returns the events behind the deliveries, newest first, with the same ids and bodies, under your API key and in that key's mode. Use it to fill a gap after an outage: page with starting_after=<evt_id> and limit (1 to 100, default 20) until has_more is false.
curl "https://api.lemak.cash/v1/events?limit=100" \
-H "Authorization: Bearer sk_test_MFWZLFDEM5X3WDBPM7ULRVDKCE"
Versioning of payloads
An endpoint carries an api_version, set when you create it to the version your account is pinned to. Every delivery is rendered at that version before it is signed, so the signature always covers the bytes you receive. The stored event keeps the current shape; the rendering is on the way out. To move an endpoint to a newer version, change api_version with PATCH /dashboard/v1/webhook_endpoints/{id} from the dashboard, and your handler changes with it.
One version exists today, 2026-09-01. Adding a field to an event is not a new version; a handler must ignore fields it does not know. See Api-Version for how versions work on requests.