Accept payments
Webhooks
We send a signed HTTPS POST to your server every time an invoice changes status. Webhooks are the source of truth for granting access: verify the signature, store the event ID, answer 2xx.
Setup
Send us one HTTPS endpoint. We give you a secret whsec_…; it is shown once. Test and live events go to the same endpoint: check event.mode and ignore "test" events in production if you share the endpoint between environments. The header may carry more than one v1 signature; any matching one is valid.
Events
| Event | Meaning | What to do |
|---|---|---|
invoice.payment_detected | A transfer to the address was found and is waiting for confirmations. Sent once per counted transfer. | Show "payment received, confirming". Do not grant access yet. |
invoice.paid | Paid in full (within the tolerance). With resolution: "manual" our operator confirmed it by hand. | Grant access. |
invoice.paid_overpaid | Received more than the amount plus tolerance. Can follow invoice.paid when a second transfer arrives. | Grant access; credit received_usd_cents − amount_usd_cents if your policy says so. |
invoice.paid_late | Paid in full, but the transfer was sent after the invoice expired. | Grant access (or refund, by your policy). |
invoice.partially_paid | Less than the amount minus tolerance. The customer can send the rest to the same address within 24 hours after expiry. | Wait. Do not grant access. |
invoice.needs_review | A case we do not decide automatically: a late payment that is not enough, a different coin sent to the address, a native-coin price we could not confirm. | Wait for our operator or your support to resolve it; a paid or cancelled event follows. |
invoice.expired | No payment within the lifetime. | Mark the order unpaid. A transfer after this point arrives as paid_late or goes to review. |
invoice.cancelled | Closed: cancel_reason is merchant (your API call), operator, or network_changed. | If replaced_by is set, the customer switched network: the order continues on the new invoice. |
Payload
The body is { id, type, created, mode, data: { invoice } }, where invoice is the full invoice object, including your metadata. created is a Unix timestamp in seconds.
POST /webhooks/axiomispay HTTP/1.1
Content-Type: application/json
User-Agent: AxiomisPay/0.1.0
AxiomisPay-Event-Id: evt_3kP9…
AxiomisPay-Signature: t=1760260792,v1=5f0c2a…9be1
{
"id": "evt_3kP9…",
"type": "invoice.paid",
"created": 1760260792,
"mode": "live",
"data": {
"invoice": {
"id": "inv_7Qx2mK9aLp3RtV8sNc4WbY1z",
"status": "paid",
"amount_usd_cents": 2900,
"received_usd_cents": 2900,
"external_order_id": "1042",
"customer_ref": "user-77",
"metadata": { "plan": "pro" },
"network": "tron",
"asset": "tron:usdt",
"resolution": "auto",
"transfers": [ … ]
}
}
}Verify the signature
AxiomisPay-Signature: t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, t + "." + raw_body). Reject the request if the timestamp is more than 5 minutes off or no v1 matches. Compute the HMAC over the raw bytes of the body: parsing and re-serializing JSON changes them and breaks the signature.
import express from 'express';
import { verifyWebhook, WebhookVerificationError } from '@axiomis-pay/sdk';
app.post('/webhooks/axiomispay', express.raw({ type: '*/*' }), async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers['axiomispay-signature'], process.env.AXIOMISPAY_WEBHOOK_SECRET);
} catch (e) {
if (e instanceof WebhookVerificationError) return res.status(400).send(e.code);
throw e;
}
// Store event.id first: a duplicate delivery must not grant access twice.
if (await db.webhookSeen(event.id)) return res.sendStatus(200);
await handle(event);
res.sendStatus(200);
});Delivery and retries
- Any
2xxwithin 10 seconds counts as delivered. Redirects are not followed. Answer first, do slow work in a queue. - Otherwise we retry after 30 s, 1, 2, 5, 10 and 30 minutes, then 1, 2, 4 and 6 hours, for up to 24 hours. After that the event is marked failed and our operator is alerted; it can be resent from the console.
event.idstays the same across retries. Events can arrive more than once and, rarely, out of order: rely ondata.invoice.status, and when in doubt fetchGET /v1/invoices/:id.
Make the handler idempotent
Write event.id to a table with a unique constraint in the same transaction that grants access. A duplicate delivery then fails the insert and changes nothing.
Test the handler without a payment: signWebhook(secret, body) from the SDK builds a valid header for your own tests. See SDK.