Webhooks
A webhook is an HTTPS POST that Payments AI sends to your server when something happens in your merchant account — a payment completes, a subscription starts, a dispute opens. You register a public URL and subscribe to event types. We send a signed JSON payload each time one of those events occurs.
What a request looks like
Each delivery is one POST:
POST /webhooks/payments-ai HTTP/1.1
Content-Type: application/json
X-Webhook-Timestamp: 1786381404
X-Webhook-Signature: sha256=a1b2c3d4e5f6...
Business event bodies are the payload fields only. There is no type, id, or data envelope on those deliveries. Event type is the one you subscribed to on the destination.
{
"merchantId": "0193a1b2-c3d4-7e5f-8000-000000000001",
"transactionId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3b",
"amount": 42.5,
"currency": "usd",
"occurredAt": "2026-08-10T17:03:24.291Z"
}
The test ping is the exception — it includes type. See Test your endpoint.
Payloads contain ids, amounts, and emails. They never include raw card data.
Create a destination
- Sign in to the Payments AI dashboard.
- Open Developer Tools → Webhooks.
- Enter a public HTTPS URL, select the events you want, and optionally add custom headers.
- Copy the HMAC signing secret from the modal after create. Store it as
PAI_WEBHOOK_SECRET.
Creating, deleting, testing, and resetting the circuit require Owner or Admin. Any team member with merchant access can view destinations.
Register the destination on the same sandbox or live deployment where you want events.
The URL must be HTTPS and resolve to a public address. localhost and private networks are rejected. See Webhook URL forbidden.
Parent merchants and sub-merchants
If you created sub-merchants from your parent merchant, you can also receive webhooks for activity on those sub-merchants on your endpoints — without configuring a URL on every sub-merchant.
- On the parent merchant, open Developer Tools → Webhooks.
- When you add an endpoint, enable Receive sub-merchant events. You can also turn this on later from the Webhooks table.
- Subscribe to the event types you still want on that endpoint.
This is opt-in per endpoint. We only send a sub-merchant event when the endpoint is subscribed to that type. Each JSON body includes merchantId for the merchant where the activity happened, so you can tell sub-merchants apart. Signing still uses that endpoint’s HMAC secret.
Sub-merchant dashboards do not show this option; set it on the parent merchant.
Verify and handle events
Always verify the signature on the raw request body before you parse JSON. Re-serializing the body after JSON.parse changes the bytes and the check fails.
We sign {timestamp}.{rawBody} with HMAC-SHA256. The secret is the 64-character hex value from destination create. The X-Webhook-Signature header is sha256= plus the lowercase hex digest. Compare with a constant-time function.
import { createHmac, timingSafeEqual } from 'node:crypto';
const WEBHOOK_SECRET = process.env.PAI_WEBHOOK_SECRET!;
export function verifyPaymentsAiWebhook(params: {
rawBody: string;
timestamp: string;
signatureHeader: string;
}): boolean {
const { rawBody, timestamp, signatureHeader } = params;
const expected =
'sha256=' +
createHmac('sha256', WEBHOOK_SECRET).update(`${timestamp}.${rawBody}`).digest('hex');
const actual = Buffer.from(signatureHeader);
const expectedBuf = Buffer.from(expected);
if (actual.length !== expectedBuf.length) {
return false;
}
return timingSafeEqual(actual, expectedBuf);
}
// Read the raw request body as a string before JSON.parse.
// Headers: X-Webhook-Timestamp, X-Webhook-Signature
Read X-Webhook-Timestamp and X-Webhook-Signature from the request headers. You may reject timestamps that are too far from now (for example five minutes) to limit replay; we do not enforce that window on our side.
Respond with 2xx quickly. Verify, enqueue your work, and return. Heavy fulfillment should run in a background job.
Event type is not in the JSON body for business events. Subscribe only the events you need. If one URL receives several types, tell them apart by field shape — do not use subscriptionId or customerId alone, because payment events may include those correlation ids too. Prefer discriminators that stay unique per family: transactionId (payments), externalMembershipId without transactionId (subscriptions), email (customer.created), alertId (dispute alerts), payoutId (payouts), ownerUserId (merchant.owner_attached). That is still imperfect when two events share a shape (see payment.completed and payment.refunded below). Prefer one destination per event type if you need a hard split.
Custom headers you configured are sent on every delivery. We then set Content-Type, X-Webhook-Timestamp, and X-Webhook-Signature. A custom Content-Type is overwritten.
Test your endpoint
- In Developer Tools → Webhooks, choose Test Connection (Owner or Admin) to send a signed ping. It does not write delivery history and does not trip the circuit breaker.
{
"type": "webhook.test",
"createdAt": "2026-08-27T12:00:00.000Z"
}
- Notification Center lists deliveries for real events (request outcome, status).
- For local development, expose a public HTTPS URL with ngrok or Cloudflare Tunnel. We reject
localhost.
Test ping timeout is 15 seconds. Live delivery timeout is 10 seconds.
Delivery
- Success is any 2xx within 10 seconds. Timeouts, non-2xx, and network errors are failed attempts.
- Live delivery does not follow redirects (
redirect: error). - Each event is attempted once. We do not retry failed deliveries.
- After 5 consecutive 5xx or network failures, the destination circuit opens: further events are skipped until you reset. 4xx does not increment the circuit.
- When the circuit opens we email the merchant owner. In Developer Tools → Webhooks, choose Reset circuit (Owner or Admin).
- Events that occurred while the circuit was open are not replayed. Read Notification Center for what you missed.
If deliveries stop, see Getting help and confirm the URL is still public (Webhook URL forbidden).
Event catalog
Amounts are JSON numbers in major units (for example 42.5), not cents. Currency is typically a lowercase ISO code.
The primary id field on every event is a Payments AI id — the same id you'd see in the dashboard or the Merchant Public API. Where the underlying record originated from an upstream provider, that provider's reference id is also included as a separate external* field for reconciliation. Treat the Payments AI id as the one to key your system on; the external* field is optional.
There are no invoice webhooks.
Payments
| Event | When it fires | Payload |
|---|---|---|
payment.completed | Payment succeeds | transactionId (Payments AI id), externalTransactionId, amount, currency, occurredAt, orderId (nullable), customerId (nullable), subscriptionId (nullable) |
payment.failed | Payment fails | transactionId (Payments AI id), externalTransactionId, occurredAt, orderId (nullable), customerId (nullable), subscriptionId (nullable) |
payment.refunded | A refund reaches status refunded | Same shape as payment.completed. amount is the gross payment amount, not a separate refund amount. Dashboard and API refunds do not send this event. |
payment.completed and payment.refunded bodies look the same. Subscribe to one type per destination if you must tell them apart from the HTTP body alone.
orderId, customerId, and subscriptionId are JSON null when Payments AI cannot resolve a link (for example plan-only checkouts without an order, or a payment that arrives before the membership webhook creates the subscription row).
{
"transactionId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3b",
"externalTransactionId": "pay_XXXXXXXX",
"amount": 42.5,
"currency": "usd",
"occurredAt": "2026-08-10T17:03:24.291Z",
"orderId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3c",
"customerId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3d",
"subscriptionId": null
}
Customers
| Event | When it fires | Payload |
|---|---|---|
customer.created | A new customer is created as a side effect of payment or membership ingest | customerId (Payments AI id), email. Creating a customer in the dashboard or API does not send this event. |
{
"customerId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"email": "customer@example.com"
}
Subscriptions
| Event | When it fires | Payload |
|---|---|---|
subscription.created | Membership activates and local status is active | subscriptionId (Payments AI id), externalMembershipId, orderId (nullable), customerId (nullable) |
subscription.cancelled | Membership maps to local status inactive | subscriptionId, externalMembershipId, cancelAtPeriodEnd, orderId (nullable), customerId (nullable) |
{
"subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"externalMembershipId": "mem_XXXXXXXX",
"orderId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"customerId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
subscription.cancelled adds cancelAtPeriodEnd:
{
"subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"externalMembershipId": "mem_XXXXXXXX",
"orderId": null,
"customerId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cancelAtPeriodEnd": false
}
Disputes
| Event | When it fires | Payload |
|---|---|---|
dispute.created | A dispute is opened | disputeId (Payments AI id), externalDisputeId, amount, currency, reason |
dispute.updated | Dispute updates and status is not won/lost | Same as created, plus status |
dispute.won | Dispute status becomes won | disputeId, externalDisputeId, amount, currency |
dispute.lost | Dispute status becomes lost | disputeId, externalDisputeId, amount, currency |
dispute.alert_created | A dispute alert is created | alertId (Payments AI id), externalAlertId, transactionId (Payments AI id, nullable), externalTransactionId, alertType (Dispute / RDR / Fraud), reason, amount, currency |
{
"disputeId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3b",
"externalDisputeId": "dp_XXXXXXXX",
"amount": 25,
"currency": "usd",
"reason": "duplicate"
}
dispute.alert_created has a distinct shape. transactionId is null when the alert can't be linked to a known transaction yet:
{
"alertId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3c",
"externalAlertId": "alrt_XXXXXXXX",
"transactionId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3b",
"externalTransactionId": "pay_XXXXXXXX",
"alertType": "Dispute",
"reason": "duplicate",
"amount": 25,
"currency": "usd"
}
Payouts
| Event | When it fires | Payload |
|---|---|---|
payout.sent | Withdrawal status is in_transit | payoutId (Payments AI id), externalWithdrawalId, status, amount, currency |
{
"payoutId": "018f2b1a-9c3e-7d21-8b4a-2f6e1c9a0d3d",
"externalWithdrawalId": "wth_XXXXXXXX",
"status": "in_transit",
"amount": 100.0,
"currency": "usd"
}
Merchants
| Event | When it fires | Payload |
|---|---|---|
merchant.owner_attached | A seller claims a merchant through its owner-claim join link | merchantId (the claimed merchant), ownerUserId (Payments AI id), email (the owner's sign-in email) |
Only a join link claim sends this event. An owner attached when the merchant is created, or assigned through the Merchant Public API members route, does not — those responses already say so. To receive it for sub-merchants you created, subscribe an endpoint on your parent merchant and enable Receive sub-merchant events. See Partner Onboarding.
{
"merchantId": "0193a1b2-c3d4-7e5f-8000-0000000000ab",
"ownerUserId": "0193a1b2-c3d4-7e5f-8000-0000000000cd",
"email": "founder@acmecoaching.com"
}