Webhooks
The events Aliquot sends, their payloads, how to verify the signature, the retry schedule, how to resend a delivery, and how to manage endpoints and rotate the signing secret.
Last updated Oct 27, 2026
Aliquot notifies your server of invoice, payout and refund changes with HTTPS POST requests to the endpoints you register. Each request is signed with the endpoint's secret.
Events
| Event | Sent when |
|---|---|
invoice.created | An invoice is created, through the API or from a payment link. |
invoice.detected | The first transfer to the invoice is seen on chain (also when an underpaid invoice receives a top-up). The payment is confirming. |
invoice.paid | The payment reached the confirmation threshold and covers the total. Also sent for overpaid invoices, with "status": "OVERPAID". Fulfil the order on this event. |
invoice.overpaid | Sent together with invoice.paid when the buyer sent more than the total. The excess becomes a refund. |
invoice.underpaid | The confirmed amount is short of the total. The buyer can still send the rest before the window ends. |
invoice.expired | The payment window ended: status is EXPIRED (nothing received) or EXPIRED_UNDERPAID (a partial payment, held as a refund). |
payout.completed | A payout transaction reached the confirmation threshold. |
payout.failed | A payout transaction reverted or was dropped. The amount is back in the available balance. |
refund.claimable | Money is held for a buyer and can be claimed. Carries claim_url. |
refund.completed | A claimed refund was sent and confirmed. |
Voiding an invoice sends no event.
Delivery format
Every delivery is a POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
X-Webhook-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-Webhook-Timestamp | The same t, on its own. |
X-Webhook-Event | The event name, such as invoice.paid. |
The body is an envelope around the event data:
{
"id": "evt_9b1f4c2a7d3e8f6a5b0c1d2e",
"type": "invoice.paid",
"occurredAt": "2026-10-27T14:03:11.482Z",
"data": {
"organization_id": "cm2k8x1qv0000l708h3d9f2ab",
"invoice_id": "inv_01JB7Q8M3X4K9TPZ2R6WVN5HDC",
"status": "PAID",
"livemode": false,
"merchant_order_id": "order-77310",
"payment_link_id": null,
"network": "POLYGON_AMOY",
"token": "USDC",
"amount_total": "102.50",
"amount_received": "102.50"
}
}| Field | Description |
|---|---|
id | Identifies the event. It is the same on every retry of the same event, so use it to process each event once. |
type | The event name. |
occurredAt | When the change happened (not when the delivery was sent). |
data | The event payload, below. Amounts are decimal strings in token units. |
Every data object uses snake_case field names and carries organization_id (your organization) and livemode (false in Sandbox, true in Live). Ignore fields you do not use; new fields may be added.
Payloads
invoice.created
| Field | Description |
|---|---|
invoice_id | The invoice. |
status | PENDING, or AWAITING_SELECTION when the buyer picks the network. |
livemode | false in Sandbox (mode: "test" in the API), true in Live. |
network, token | The pair, or null while AWAITING_SELECTION. |
amount_base | The USD price. |
amount_total | What the buyer must send, or null while AWAITING_SELECTION. |
merchant_order_id | As sent on the invoice, or null. |
payment_link_id | The payment link the invoice came from, or null. |
invoice.detected, invoice.paid, invoice.overpaid, invoice.underpaid, invoice.expired
| Field | Description |
|---|---|
invoice_id | The invoice. |
status | The invoice status after the change. |
livemode | false in Sandbox, true in Live. |
merchant_order_id | As sent on the invoice, or null. |
payment_link_id | The payment link the invoice came from, or null. |
network, token | The pair. null for an invoice that expired before the buyer picked one. |
amount_total | What the buyer had to send. |
amount_received | What the invoice has received in its token. |
Match the event to your order with merchant_order_id, or with the invoice id you stored when you created it (invoice_id). Invoice events do not carry metadata: read the invoice with GET /api/v1/crypto/invoices/{id} if you need it.
payout.completed, payout.failed
{
"organization_id": "cm2k8x1qv0000l708h3d9f2ab",
"payout_id": "po_01JB7SA2K9D4F6H8J0L2N4P6RT",
"status": "COMPLETED",
"livemode": false,
"account": "halden_type",
"network": "POLYGON_AMOY",
"token": "USDC",
"amount": "50.00",
"fee": "0.10",
"net_amount": "49.90",
"destination_address": "0x7a3B9c1D5e2F4a6B8c0D1e3F5a7B9c2D4e6F8a0B",
"tx_hash": "0x5c8e2f1a9b7d3c6e4f0a2b8d1c9e7f3a5b6c4d2e0f1a3b5c7d9e2f4a6b8c0d1e"
}account is the reference of the account paid out. payout.failed adds failure_reason.
refund.claimable
| Field | Description |
|---|---|
refund_id, invoice_id | The refund and the invoice it belongs to. |
status | CLAIMABLE. |
reason | OVERPAYMENT, EXPIRED_UNDERPAID, LATE_PAYMENT or WRONG_TOKEN. |
livemode, network, token | Where the refund will be paid, in this token. |
amount | The amount held, before the network fee. |
claim_url | The buyer's private claim link. Forward it to the buyer; do not publish it. |
buyer_email | The invoice's buyer_email. When set, Aliquot has already emailed the link. |
merchant_order_id | The invoice's merchant_order_id, or null. |
refund.completed
refund_id, invoice_id, status (COMPLETED), reason, livemode, network, token, gross_amount, network_fee, net_amount (what the buyer received), destination_address and tx_hash.
Verify the signature
The signature is an HMAC-SHA256, keyed with the endpoint's whole secret (including the whsec_ prefix), over the string <t>.<raw body>, where t is the timestamp from the header and the raw body is the exact bytes you received. Compute it over the raw body before parsing the JSON: re-serializing a parsed object can change the bytes.
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* rawBody: the request body as received (string).
* header: the X-Webhook-Signature header.
* secret: the endpoint secret, "whsec_…".
*/
export function verifySignature(rawBody, header, secret, toleranceSeconds = 300) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
if (!match) return false;
const [, t, v1] = match;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}With Express, read the body with express.raw({ type: 'application/json' }) and pass req.body.toString('utf8'). The Quickstart has a complete receiver.
Each attempt is signed with the time it is sent, so a retry carries a fresh t. Rejecting timestamps older than a few minutes protects you from replayed requests without rejecting retries.
Responding and retries
- Answer with any
2xxstatus within 10 seconds. Anything else, a timeout or a connection error counts as a failure. - Redirects are not followed: a
3xxis a failure. - A failed delivery is retried with growing delays, up to 7 attempts in total: each retry comes 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and then 24 hours after the previous attempt. The last attempt is about 32 and a half hours after the first; if it fails too, the delivery stays failed. You can still resend it.
- Each event is a separate delivery, and retries can interleave, so events can arrive out of order. Decide from
data.status, or read the object from the API, rather than from arrival order. - Process each
idonce: the same event can arrive more than once.
Retries that fall due while the endpoint is disabled count as failed attempts. Do not rely on webhooks alone: if your endpoint is down for longer than the schedule, or an event is lost on your side, reconcile from the API. GET /api/v1/crypto/invoices?status=PAID lists paid invoices, and GET /api/v1/crypto/invoices/{id} returns the current state of one.
Resend a delivery
Every attempt is recorded. Developers → Webhooks in the dashboard lists them with their HTTP status and a Resend action. A resend sends the same event to the same endpoint again: same id, type, occurredAt and data, signed with the endpoint's current secret and a fresh timestamp. It is a single attempt, without the retry schedule, and appears in the list as a new delivery. Because the id is the same, a receiver that already processed the event skips it.
The API equivalent is POST …/webhooks/deliveries/{id}/resend (see below), which answers 202 once the attempt is queued. It answers 409 CONFLICT when the endpoint is disabled, or for an attempt recorded before resends were available.
Manage endpoints
Add endpoints in the dashboard under Developers → Webhooks: the endpoint's URL and the events it receives. invoice.paid, invoice.underpaid and refund.claimable are preselected. The URL must use https:// and resolve to a public address. The signing secret (whsec_ followed by 64 hex characters) is shown once, when the endpoint is created.
An endpoint receives only the events it lists, and only while it is enabled. Each event is delivered to every enabled endpoint that subscribes to it.
On the same page, each endpoint has Edit (URL, events, enabled or disabled), Rotate secret and Delete. Deleting an endpoint deletes its delivery history and drops its pending retries.
The same operations are available as API endpoints under /api/v1/organizations/{organizationId}/webhooks. They take a dashboard session or an API key, and require the webhooks:manage permission (see Authentication):
| Endpoint | Description |
|---|---|
GET …/webhooks/endpoints | List endpoints. |
POST …/webhooks/endpoints | Create an endpoint. Body { "url", "events", "enabled" }. The response includes secret, once. |
PATCH …/webhooks/endpoints/{id} | Change url, events or enabled. |
DELETE …/webhooks/endpoints/{id} | Delete an endpoint. |
POST …/webhooks/endpoints/{id}/rotate-secret | Replace the signing secret. The response includes the new secret, once. |
GET …/webhooks/deliveries | Delivery attempts, newest first. Filters: endpointId, status (pending, success, failed), page, pageSize. |
POST …/webhooks/deliveries/{id}/resend | Send that attempt's event again, one attempt. 202, no body. |
An endpoint object has id, organizationId, url, events, enabled, lastDeliveryAt, lastDeliveryStatus, createdAt and updatedAt. A delivery attempt has id, endpointId, eventType, payloadHash (SHA-256 of the body that was sent), status, httpStatus, attemptCount, deliveredAt and createdAt. These management objects use camelCase, unlike the payment API and the webhook payloads.
Rotate the secret
Rotating replaces the secret at once: from that moment every delivery, including retries of earlier events, is signed with the new secret, and the old one no longer verifies. To rotate without rejecting deliveries:
- Deploy your receiver so it accepts a signature that verifies with either the old or the new secret.
- Rotate: Rotate secret on the endpoint in the dashboard, or
POST …/webhooks/endpoints/{id}/rotate-secret. Store the newsecret, shown once, as the new one. - Once deliveries verify with the new secret, remove the old one from your receiver.