The request Payment Labs sends to your endpoint for every webhook event, and how to verify and handle it.
A webhook is an HTTPS POST that Payment Labs sends to your endpoint when something you subscribed to happens. This page describes the request every event shares. Each event's page describes its data. For a step-by-step setup, see the webhooks guide.
Subscribe
- Register an endpoint with Add Webhook. Each registration covers one event. To receive several events, register each one; they can share the same URL.
- Each registration has its own secret,
hmacKey. Read it with List Webhooks. - Registering the same event and URL twice does nothing; you keep one registration.
- Stop a subscription with Delete Webhook.
- Use an
https://URL that acceptsPOSTrequests with a JSON body.
Payin, payout and wallet events are all registered with these operations, which live in the Payout API reference.
Events
Add Webhook may accept event names that are not listed here. Subscribe only to the events listed on this page.
The request
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Payment Labs Webhook |
x-signature | Signature of data, as a lowercase hex string. See Verify the signature. |
x-environment | Environment that sent the request: sandbox or production. Reject requests from the wrong environment. |
The User-Agent header is easy to fake. Use it for logging, never to decide whether to trust a request.
Body
| Field | Type | Description |
|---|---|---|
payorId | string | Your payor or merchant ID. |
event | string | Name of the event, as you registered it, for example payment-status-changed. |
timestamp | string | When this delivery was first attempted, in ISO 8601 UTC. It is not when the change happened. A delivery resent from the webhook logs keeps its original timestamp. |
data | object or null | The event's payload. For most events it is the object that changed, read when the webhook is sent. See each event's page. It is null if the object no longer exists by then. |
{
"payorId": "C-01JBX2Q8F4H6K8M0N2P4R6T8VW",
"event": "batch-changed",
"timestamp": "2026-03-20T08:15:44.903Z",
"data": {
"id": "B-01JC8Z1B2C3D4E5F6G7H8J9K0M",
"name": "March contractor payouts",
"referenceId": "march-2026-contractors",
"archived": false
}
}Verify the signature
x-signature is an HMAC-SHA512 of the JSON text of data, keyed with the hmacKey of the registration that sent it, and encoded as lowercase hex. Only data is signed. payorId, event and timestamp are not.
To verify a request:
- Find the
hmacKeyfor the registration. If one URL receives several events, look the key up by theeventfield. - Serialize
datathe way JavaScript'sJSON.stringifydoes: no spaces, keys in the order received. - Compute the HMAC-SHA512 of that text with the key, as hex.
- Compare it with
x-signaturein constant time. If they differ, return401and ignore the request.
In Node.js, after parsing the body as JSON:
import crypto from 'node:crypto';
import express from 'express';
// hmacKey of each registration, from List Webhooks, keyed by event name.
const hmacKeys = {
'payment-status-changed': process.env.PL_HMAC_PAYMENT_STATUS_CHANGED,
};
function isValidSignature(body, signature) {
const key = hmacKeys[body?.event];
if (!key || typeof signature !== 'string' || !('data' in body)) return false;
const expected = crypto.createHmac('sha512', key).update(JSON.stringify(body.data)).digest('hex');
const received = Buffer.from(signature, 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
async function handleEvent(body) {
// Your processing. Make it safe to run twice for the same delivery.
}
const app = express();
app.post('/webhooks/payment-labs', express.json({ limit: '1mb' }), (req, res) => {
if (!isValidSignature(req.body, req.get('x-signature'))) {
res.sendStatus(401);
return;
}
res.sendStatus(204);
handleEvent(req.body).catch((error) => console.error('Webhook processing failed', error));
});
app.listen(3000);In other languages, re-serializing data may not reproduce the exact text, for example because of spacing or character escaping. The safe way is to take the raw request body and sign the exact text of the data value, from its opening { to its matching } (or null).
Signatures do not cover the delivery time, so they do not stop a recorded request from being sent again. Your handler must be idempotent (see below), which makes a replay harmless.
Respond
- Return any
2xxstatus as soon as the signature checks out, then do the work. Any other status, or a network error, counts as a failure. - Keep the response fast: a few seconds at most. Queue the work rather than doing it before you answer.
Delivery
- At least once. The same event can arrive more than once.
- No ordering. Events can arrive in any order, including events about the same object.
- Current state, not a snapshot. For most events,
datais the object as it is when the webhook is sent, not as it was when the event happened. Two deliveries can carry the same state, and a delivery can already show a later status than the one that triggered it. Some events can arrive later than the change, such as a payment enteringWAITING_COMPLIANCE. - One retry. If a delivery fails, Payment Labs retries once, a few seconds later. If that also fails, the delivery is logged as
failedand is not retried again. Use the webhook logs to recover it.
Recover missed deliveries
- List deliveries with List Webhook Logs. Each log has a
statusofsuccessorfailed, and newest come first. - Send one again with Resend Webhook. It sends the same
datathat was sent originally, with the originaltimestamp, to the original URL, signed with the registration's currenthmacKey. If you deleted that registration, nothing is sent. - A resent delivery is not refreshed, so its
datacan be older than what you already hold. Your handler must not let it overwrite newer state. - When you only need the current state, call the object's
GEToperation instead.
Build an idempotent handler
Deliveries do not carry an event ID, so you cannot de-duplicate on one. Treat each delivery as a signal that an object may have changed:
- Identify the object from
data: for exampledata.idfor a payment, batch, checkout, refund or subscription,data.userIdfor a payee. - If
datahas anupdatedAt(data.auditInfo.updatedAt, ordata.updatedAtfor disputes), compare it with the copy you hold. Ignore the delivery if it is not newer. - Act on the state in
data, not on the event name. Make each action conditional on your own record: for example, mark an order paid only if it is not paid yet. - When in doubt, fetch the object with its
GEToperation and act on that.
A handler built this way gives the same result whether a delivery arrives once, twice, late or out of order.
Money object
Every amount in a payload has this shape.
| Field | Type | Description |
|---|---|---|
value | number | Amount as a decimal number, for example 1250.5. |
wholeValue | integer | Amount in the currency's smallest unit, for example 125050 cents. Use this for arithmetic. |
currency | string | Three-letter ISO 4217 currency code, for example USD. |
formattedValue | string | Amount formatted for display, for example $1,250.50. |
digits | integer | Decimal places the currency uses, for example 2 for USD and 0 for JPY. |
{
"value": 1250.5,
"wholeValue": 125050,
"currency": "USD",
"formattedValue": "$1,250.50",
"digits": 2
}Expect changes
Payment Labs may add new fields to payloads and new values to enums at any time. Ignore fields you do not know, and handle unknown enum values without failing, for example by fetching the object.
