Payments

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

EventSent when
invoice.createdAn invoice is created, through the API or from a payment link.
invoice.detectedThe first transfer to the invoice is seen on chain (also when an underpaid invoice receives a top-up). The payment is confirming.
invoice.paidThe payment reached the confirmation threshold and covers the total. Also sent for overpaid invoices, with "status": "OVERPAID". Fulfil the order on this event.
invoice.overpaidSent together with invoice.paid when the buyer sent more than the total. The excess becomes a refund.
invoice.underpaidThe confirmed amount is short of the total. The buyer can still send the rest before the window ends.
invoice.expiredThe payment window ended: status is EXPIRED (nothing received) or EXPIRED_UNDERPAID (a partial payment, held as a refund).
payout.completedA payout transaction reached the confirmation threshold.
payout.failedA payout transaction reverted or was dropped. The amount is back in the available balance.
refund.claimableMoney is held for a buyer and can be claimed. Carries claim_url.
refund.completedA 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:

HeaderValue
X-Webhook-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-Webhook-TimestampThe same t, on its own.
X-Webhook-EventThe 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"
  }
}
FieldDescription
idIdentifies the event. It is the same on every retry of the same event, so use it to process each event once.
typeThe event name.
occurredAtWhen the change happened (not when the delivery was sent).
dataThe 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

FieldDescription
invoice_idThe invoice.
statusPENDING, or AWAITING_SELECTION when the buyer picks the network.
livemodefalse in Sandbox (mode: "test" in the API), true in Live.
network, tokenThe pair, or null while AWAITING_SELECTION.
amount_baseThe USD price.
amount_totalWhat the buyer must send, or null while AWAITING_SELECTION.
merchant_order_idAs sent on the invoice, or null.
payment_link_idThe payment link the invoice came from, or null.

invoice.detected, invoice.paid, invoice.overpaid, invoice.underpaid, invoice.expired

FieldDescription
invoice_idThe invoice.
statusThe invoice status after the change.
livemodefalse in Sandbox, true in Live.
merchant_order_idAs sent on the invoice, or null.
payment_link_idThe payment link the invoice came from, or null.
network, tokenThe pair. null for an invoice that expired before the buyer picked one.
amount_totalWhat the buyer had to send.
amount_receivedWhat 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

FieldDescription
refund_id, invoice_idThe refund and the invoice it belongs to.
statusCLAIMABLE.
reasonOVERPAYMENT, EXPIRED_UNDERPAID, LATE_PAYMENT or WRONG_TOKEN.
livemode, network, tokenWhere the refund will be paid, in this token.
amountThe amount held, before the network fee.
claim_urlThe buyer's private claim link. Forward it to the buyer; do not publish it.
buyer_emailThe invoice's buyer_email. When set, Aliquot has already emailed the link.
merchant_order_idThe 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 2xx status within 10 seconds. Anything else, a timeout or a connection error counts as a failure.
  • Redirects are not followed: a 3xx is 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 id once: 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):

EndpointDescription
GET …/webhooks/endpointsList endpoints.
POST …/webhooks/endpointsCreate 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-secretReplace the signing secret. The response includes the new secret, once.
GET …/webhooks/deliveriesDelivery attempts, newest first. Filters: endpointId, status (pending, success, failed), page, pageSize.
POST …/webhooks/deliveries/{id}/resendSend 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:

  1. Deploy your receiver so it accepts a signature that verifies with either the old or the new secret.
  2. Rotate: Rotate secret on the endpoint in the dashboard, or POST …/webhooks/endpoints/{id}/rotate-secret. Store the new secret, shown once, as the new one.
  3. Once deliveries verify with the new secret, remove the old one from your receiver.