Webhook Events

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 accepts POST requests 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

HeaderValue
Content-Typeapplication/json
User-AgentPayment Labs Webhook
x-signatureSignature of data, as a lowercase hex string. See Verify the signature.
x-environmentEnvironment 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

FieldTypeDescription
payorIdstringYour payor or merchant ID.
eventstringName of the event, as you registered it, for example payment-status-changed.
timestampstringWhen 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.
dataobject or nullThe 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:

  1. Find the hmacKey for the registration. If one URL receives several events, look the key up by the event field.
  2. Serialize data the way JavaScript's JSON.stringify does: no spaces, keys in the order received.
  3. Compute the HMAC-SHA512 of that text with the key, as hex.
  4. Compare it with x-signature in constant time. If they differ, return 401 and 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 2xx status 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, data is 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 entering WAITING_COMPLIANCE.
  • One retry. If a delivery fails, Payment Labs retries once, a few seconds later. If that also fails, the delivery is logged as failed and is not retried again. Use the webhook logs to recover it.

Recover missed deliveries

  • List deliveries with List Webhook Logs. Each log has a status of success or failed, and newest come first.
  • Send one again with Resend Webhook. It sends the same data that was sent originally, with the original timestamp, to the original URL, signed with the registration's current hmacKey. If you deleted that registration, nothing is sent.
  • A resent delivery is not refreshed, so its data can 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 GET operation 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:

  1. Identify the object from data: for example data.id for a payment, batch, checkout, refund or subscription, data.userId for a payee.
  2. If data has an updatedAt (data.auditInfo.updatedAt, or data.updatedAt for disputes), compare it with the copy you hold. Ignore the delivery if it is not newer.
  3. 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.
  4. When in doubt, fetch the object with its GET operation 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.

FieldTypeDescription
valuenumberAmount as a decimal number, for example 1250.5.
wholeValueintegerAmount in the currency's smallest unit, for example 125050 cents. Use this for arithmetic.
currencystringThree-letter ISO 4217 currency code, for example USD.
formattedValuestringAmount formatted for display, for example $1,250.50.
digitsintegerDecimal 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.