Webhooks overview
Webhooks let your server receive real-time notifications when events happen in your PaymentsAI account. PaymentsAI sends an HTTP POST request to your registered endpoint every time a relevant event occurs. No polling is required.
How it works
- You register a webhook destination (a URL on your server).
- You subscribe that destination to one or more event types.
- When an event fires, PaymentsAI sends a JSON payload to your URL.
- Your server must respond with HTTP
200within the timeout window. - If your server does not respond with
200, PaymentsAI retries delivery.
See How to set up a webhook for step-by-step registration instructions.
Event structure
Every webhook payload shares the same envelope:
type Event = {
deduplicationId: string;
type: string;
organizationId: string;
metadata: Transaction | Subscription | Dispute;
};
| Field | Type | Description |
|---|---|---|
deduplicationId | string | Unique identifier for this delivery. Use it to deduplicate retries — PAI may deliver the same event more than once. |
type | string | The event type. See Event catalog for all values. |
organizationId | string | The organization this event belongs to. |
metadata | object | Event-specific payload. Shape varies by event type. See Schemas. |
Signature verification
PaymentsAI signs every payload so you can verify it actually came from PAI and was not tampered with.
How it works: PAI computes HMAC-SHA256 of the raw request body using your webhook secret and sends the result in a signature header.
To verify:
- Read the raw request body before any JSON parsing.
- Compute
HMAC-SHA256of that raw body using your webhook secret. - Compare your result to the value in the signature header using a timing-safe comparison.
Always use the raw request body. Parsing and re-serializing JSON may change whitespace or key order and will produce a different HMAC.
Code examples
const crypto = require('crypto');
function verifySignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
import hmac
import hashlib
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
function verifySignature(string $rawBody, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}
require 'openssl'
def verify_signature(raw_body, signature, secret)
expected = OpenSSL::HMAC.hexdigest('sha256', secret, raw_body)
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
Delivery guarantees
- At-least-once delivery: PAI may deliver the same event more than once. Use
deduplicationIdto skip duplicates. - Out-of-order delivery: Events are not guaranteed to arrive in the order they were generated.
- Retry on non-200: Any response other than HTTP
200triggers a retry with exponential backoff (see below).
To avoid timeouts, respond with 200 immediately and process the event asynchronously.
Retry mechanism
If your endpoint does not return HTTP 200 within 20 seconds, PaymentsAI retries delivery automatically using exponential backoff.
| Attempt | Delay after previous |
|---|---|
| 1st retry | 30 minutes |
| 2nd retry | 1 hour |
| 3rd retry | 2 hours |
| 4th retry | 4 hours |
| 5th retry | 8 hours |
| 6th retry | 16 hours |
After all 6 retries are exhausted the event is marked as failed and no further attempts are made. Use deduplicationId to handle retries safely — your handler should be idempotent.