Webhooks
Receive Payment Labs events at your endpoint, verify their signatures, and handle retries, duplicates and missed deliveries.
A webhook is an HTTPS endpoint on your server that Payment Labs calls when something changes: a payment completes, a checkout is paid, a payee becomes payable. Use webhooks instead of polling to keep your system in step with Payment Labs.
Before you start
- A credential with the payout Administrator role. Every webhook operation needs it, including for payin events. See Permissions.
- A public HTTPS endpoint that accepts
POSTrequests with a JSON body. Payment Labs does not check that the URL uses HTTPS, but you must: the body carries payment and personal data.
Events
You subscribe to one event per registration. Each event's page shows the data it carries.
| Event | Sent when | data |
|---|---|---|
payment-created | A payment is created in a batch. | The payment |
payment-changed | Any change to a payment: an update, a fee change, and every status change. | The payment |
payment-status-changed | A payment reaches SUBMITTED, PROCESSING, COMPLETED, FAILED, RETURNED, WAITING_COMPLIANCE, WAITING_PAYMENT_METHOD or RESTRICTED, or is cancelled or expires. Other status changes, such as to WAITING_PAYABILITY, send only payment-changed. | The payment |
batch-created | A batch is created. | The batch |
batch-changed | A batch is updated with Patch Batch. Changes to batch totals do not send it. | The batch |
payee-payability-changed | A payee becomes payable or stops being payable. | The payee |
ledger-account-credited | Funds arrive in one of your ledger accounts. | Amount credited, new balance, ledger account ID |
wallet-dispute-opened | A wallet holder opens a dispute. | The dispute |
wallet-dispute-status-changed | A wallet dispute changes status. | The dispute |
wallet-dispute-message-added | A message is added to a wallet dispute. | The dispute and messageId |
checkout-status-changed | A checkout changes status. Checkouts created by subscriptions do not send it; use the subscription events. | The checkout |
refund-status-changed | A refund changes status. | The refund |
subscription-status-changed | A subscription changes status. | The subscription |
subscription-payment-status-changed | A subscription billing cycle changes status. | The subscription payment |
Virtual payments send payment events only when they reach COMPLETED.
Register only the events listed here. Add Webhook may accept other event names, but they are not part of this documentation.
Step 1: Register your endpoint
Add Webhook registers one event for one URL. Call it once per event you need.
for event in payment-status-changed checkout-status-changed; do
curl -s -o /dev/null -w "$event %{http_code}\n" -X POST "$BASE_URL/payors/me/webhooks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"event\": \"$event\", \"destination\": \"https://hooks.example.com/payment-labs\"}"
donefor (const event of ["payment-status-changed", "checkout-status-changed"]) {
const response = await fetch(`${BASE_URL}/payors/me/webhooks`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ event, destination: "https://hooks.example.com/payment-labs" }),
});
if (response.status !== 204) throw new Error(`Add webhook ${event} failed with ${response.status}`);
}The response is 204 with no body. Registering the same event and URL again does nothing and also returns 204.
Step 2: Get the signing key
Each registration has its own signing key, hmacKey. List Webhooks returns your registrations with their webhookId and hmacKey:
[
{
"payorId": "C-01JBX2Q8F4H6K8M0N2P4R6T8VW",
"webhookId": "WH-01JBX8Z3P7D5F2K9H4C6T0Q1RS",
"event": "payment-status-changed",
"destination": "https://hooks.example.com/payment-labs",
"hmacKey": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f1a3c5e7b9d0f"
}
]Store each hmacKey as a secret, keyed by event name. When one endpoint receives several events, pick the key using the event field of the body.
To change a URL or rotate a key, register the new one, then delete the old one with Delete Webhook. There is no update operation. Deleting returns 204, also for an ID that no longer exists.
Step 3: Receive and verify events
Payment Labs sends a POST to your URL with a JSON body:
{
"payorId": "C-01JBX2Q8F4H6K8M0N2P4R6T8VW",
"event": "payment-status-changed",
"timestamp": "2026-10-28T14:03:11.482Z",
"data": {
"id": "P-01JBX7S9QK3M8T2N6R4W0Y5ZDE",
"batchId": "B-01JBX7R2M4Q8T6N3K5W9Y1Z0AC",
"referenceId": "invoice-7781",
"status": "COMPLETED"
}
}| Body field | Description |
|---|---|
payorId | Your account ID. |
event | The event you registered. |
timestamp | When Payment Labs created this delivery, in ISO 8601 UTC. It is not the time of the change. |
data | The object, read when the delivery was created. See the event's page for its fields. |
| Header | Description |
|---|---|
x-signature | Hex-encoded HMAC-SHA512 of the data member, keyed with the registration's hmacKey. |
x-environment | sandbox or production. Reject events from the wrong environment. |
User-Agent | Payment Labs Webhook. Anyone can send this header; it is not proof of origin. |
Verify the signature
The signature covers only data, serialized as compact JSON in the order it was sent. payorId, event and timestamp are not signed. To verify:
- Parse the body.
- Serialize
databack to compact JSON: no spaces, keys in their received order, non-ASCII characters left as they are. - Compute HMAC-SHA512 of that string with the
hmacKeyfor the body'sevent, as a hex string. - Compare it with
x-signatureusing a constant-time comparison. Reject the request with401if they differ.
Node.js with Express:
import crypto from "node:crypto";
import express from "express";
const signingKeys: Record<string, string> = {
"payment-status-changed": process.env.PL_HOOK_KEY_PAYMENT_STATUS!,
"checkout-status-changed": process.env.PL_HOOK_KEY_CHECKOUT_STATUS!,
};
const app = express();
app.post("/payment-labs", express.json({ limit: "1mb" }), (req, res) => {
const { event, data } = req.body as { event: string; data: unknown };
const key = signingKeys[event];
const received = req.header("x-signature") ?? "";
if (!key || req.header("x-environment") !== process.env.PL_ENVIRONMENT) {
return res.sendStatus(401);
}
const expected = crypto.createHmac("sha512", key).update(JSON.stringify(data)).digest("hex");
const valid =
received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge first
void enqueueForProcessing(event, data); // then process asynchronously
});
async function enqueueForProcessing(event: string, data: unknown): Promise<void> {
// write to your queue or database; see "Handle duplicates and ordering"
}Python:
import hashlib
import hmac
import json
def is_valid(body: bytes, signature: str, keys: dict[str, str]) -> bool:
payload = json.loads(body) # dicts keep their received key order
key = keys.get(payload.get("event", ""))
if key is None:
return False
signed = json.dumps(payload["data"], separators=(",", ":"), ensure_ascii=False)
expected = hmac.new(key.encode(), signed.encode("utf-8"), hashlib.sha512).hexdigest()
return hmac.compare_digest(expected, signature)The key is used as the UTF-8 string hmacKey, exactly as List Webhooks returns it. Do not hex-decode it.
Respond quickly
Return any 2xx status as soon as you have verified and stored the event, then process it in the background. Anything else (a 4xx, a 5xx, a timeout or a closed connection) counts as a failed delivery. Keep your response under a few seconds.
Delivery behavior
- Retries. Payment Labs makes one attempt and, if it fails for any reason, one retry a few seconds later. After that the delivery is marked
failedand is not retried again automatically. Use the delivery logs and Resend Webhook to recover, and run a periodic reconciliation (see below). - At least once. The same event can arrive more than once, for example after a resend. Deliveries carry no unique delivery ID.
- No ordering. Events can arrive in any order. Two events for the same payment can arrive reversed.
- Current state, not a diff.
datais the object as it was when the delivery was created, not a record of what changed. If several changes happen close together, more than one delivery can carry the latest state. - Delays. Payment events for the
WAITING_COMPLIANCEstatus can arrive later than the status change.
Handle duplicates and ordering
- Use the object's ID as the key, for example
data.idfor a payment or checkout. - Before acting, compare the incoming
statuswith the one you stored. Ignore events that do not move the object forward in its lifecycle. For example, never move a payment fromCOMPLETEDback toPROCESSINGbecause an older event arrived late. - Make side effects (emails, fulfilment, ledger entries) depend on reaching a status for the first time, recorded in your database in the same transaction.
- When in doubt, read the object from the API, for example Get Payment or Get Checkout, and act on that.
Reconcile
Because a delivery that fails twice is not retried, do not rely on webhooks alone for money-critical state. Run a job, for example every hour, that searches for objects still in a non-final status in your system and reads their current status from the API. See Search, Exports and Pagination.
Step 4: Inspect and resend deliveries
List Webhook Logs lists delivery attempts, newest first, with status success or failed and the error of a failed attempt. Page with exclusiveStartId set to the previous response's nextPage, until nextPage is absent.
Resend Webhook delivers a logged event again:
- It sends the same
dataandtimestampas the original delivery, not the object's current state. - It signs with the registration's current
hmacKey. - It returns
204once the attempt is made. Check the log again to see whether it succeeded. - If you deleted the registration, it returns
204and sends nothing.
curl -s "$BASE_URL/webhooks/logs?limit=50" -H "Authorization: Bearer $TOKEN" \
| jq -r '.items[] | select(.status == "failed") | .logId' \
| while read -r logId; do
curl -s -o /dev/null -X POST "$BASE_URL/webhooks/logs/$logId/resend" -H "Authorization: Bearer $TOKEN"
doneTest in sandbox
- Register your endpoint in sandbox. To reach a local server, use a tunnelling tool that gives you a public HTTPS URL.
- Run the Payouts Quickstart or the Payins Quickstart. Each step sends events.
- Check List Webhook Logs for each delivery's status and error.
Errors and edge cases
| Operation | Status | Meaning | What to do |
|---|---|---|---|
| All webhook operations | 403 FORBIDDEN_INSUFFICIENT_TAGS | Your credential does not have the payout Administrator role. | Use a credential with that role. |
| Add Webhook | 400 VALIDATION_ERROR | event is not a known event name, or destination is missing. | Use an event from the table above. |
| Resend Webhook | 404 NOT_FOUND | No log has this logId. | Take the logId from List Webhook Logs. |
Next steps
- Webhook Events: the payload of every event.
- Idempotency and Retries: retrying your own requests safely.
Updated 1 day ago
