Webhooks
Register an endpoint, verify the signature, handle events idempotently, and survive retries: the right way to learn about money moving.
Money moves asynchronously. A payer sends a bank transfer at 2am; a withdrawal settles on the rail minutes later; a payout batch finishes one item at a time. You do not poll for any of this; Acute tells you. Webhooks are how a change in our ledger becomes an event in your system.
You configure one endpoint per environment (one URL for test, one for
live). Every lifecycle event for that environment is signed, delivered, and
retried until your server says 2xx. This guide is the senior-engineer version:
register, verify, dedupe, ack fast, and let the retry machine do the rest.
One URL, per environment
An organization has exactly one active webhook endpoint per environment. Point it at a stable, fast handler, not a Lambda that cold-starts for 8 seconds. Register and rotate the URL + signing secret from the console's Developers → Webhooks screen.
ledger change ──▶ webhook_events row ──▶ delivery queued ──▶ POST your URL
(the exact body (signed with (2xx → done;
snapshotted as HMAC-SHA256) else retry)
text, immutable)The body Acute records is snapshotted as text the instant the event is created and never re-rendered. Retries replay that exact byte sequence, which is why the signature stays valid across every attempt, and why you must verify the signature over the raw bytes you receive, not a re-serialized object.
Every delivery is a POST with this JSON body:
{
"id": "acuinf7h3k9q2x8m4evt",
"type": "payment.settled",
"createdAt": "2026-06-24T09:41:12.004Z",
"data": {
"id": "acuinf7h3k9q2x8m4npay",
"status": "settled",
"baseAmount": 150000,
"amountReceived": 150000,
"currency": "NGN"
}
}idstringrequiredThe event reference (acuinf…evt). This is your idempotency key: dedupe
on it.
typeWebhookEventTyperequiredThe event name, e.g. payment.settled. See the full
event catalog.
createdAtstring (ISO 8601)requiredWhen the event was recorded, not when this delivery attempt was sent.
dataobjectrequiredThe resource snapshot, shaped like the resource's API response (a
PaymentResponseData, WithdrawalResponseData, …). Same fields you'd get from
a GET.
And two headers ride along on every request:
| Header | Example | What it is |
|---|---|---|
X-Acute-Signature | t=1750758072,v1=8f3c… | The Stripe-style signature: the unix timestamp t and the HMAC digest v1. |
X-Acute-Timestamp | 1750758072 | The same unix timestamp, broken out for convenience. |
The signature is HMAC-SHA256 over the string `${timestamp}.${rawBody}`,
keyed by your endpoint's signing secret, hex-encoded. The header carries both
the timestamp and the digest so you can bind a signature to a moment and reject
stale replays.
Verify before you parse. Always.
An unverified webhook is an anonymous internet stranger telling you a payment
settled. Compute the HMAC over the raw request body and compare it
constant-time before you trust a single field. No exceptions, not even in
test.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyAcuteSignature(
rawBody: string,
header: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
header.split(",").map((s) => s.split("=").map((v) => v.trim())),
);
const timestamp = parts["t"];
const provided = parts["v1"];
if (!timestamp || !provided) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}This is the exact algorithm Acute signs with: sha256, hex, t.body, a
constant-time compare. Use timingSafeEqual (or your language's equivalent): a
naive === leaks timing and is a real, if exotic, attack surface.
Capture the RAW body
Express's express.json() parses and discards the original bytes, and a
re-stringified object will not match the signature (key order, whitespace, and
number formatting all differ). Mount a raw-body parser on your webhook route:
express.raw({ type: "application/json" }), verify against req.body (a
Buffer), then JSON.parse it yourself.
Paste a body, a timestamp, and a secret below: it computes the expected
X-Acute-Signature in your browser (Web Crypto, nothing leaves the page) and
checks it against a header you paste. This is the same math your server runs.
Signature verifier
Computed in your browser with Web Crypto. Nothing is sent anywhere.
Sign the bytes exactly as received. Do not re-serialize the JSON.
Acute guarantees at-least-once delivery, not exactly-once. The same event
will arrive twice eventually: a retry that crossed your slow 2xx, a manual
redelivery from support. Your handler must be safe to run twice.
Dedupe on event.id. Record it before you act; if you've seen it, ack and stop.
async function handleWebhook(event: AcuteWebhookEvent) {
// 1. Have we processed this exact event before? Ack and bail if so.
const seen = await db.webhookEvents.exists(event.id);
if (seen) return; // already handled: a duplicate delivery
// 2. Record it + do the work in one transaction, so a crash can't half-apply.
await db.transaction(async (tx) => {
await tx.webhookEvents.insert({ id: event.id, type: event.type });
await applySideEffect(tx, event); // credit the order, email the customer…
});
}Order is not guaranteed
Events for the same resource can arrive out of order under retry. Don't assume
payment.settled lands before a later state change: treat data.status (and
the resource's own GET) as the source of truth, and make transitions
idempotent.
Return a 2xx as soon as you've durably recorded the event. Do the slow work
(emails, downstream calls, fulfilment) after you've acked, on your own queue.
2xx→ delivery markedsuccess. Done.- Anything else (or a timeout / connection error) → marked
failed, retried.
If you do the heavy lifting before acking, a slow handler turns into a retry, which turns into a duplicate. Ack first, work second.
A non-2xx (or a network failure) is retried with exponential backoff, up to
6 attempts:
| Attempt | Delay before it | Cumulative |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 1 min | 1 min |
| 3 | 2 min | 3 min |
| 4 | 4 min | 7 min |
| 5 | 8 min | 15 min |
| 6 | 16 min | 31 min |
The backoff is min(1h, 60s · 2^(attempt-1)), so later attempts cap at one hour.
After the 6th failed attempt the delivery is marked dead: Acute stops
trying. It does not vanish: you can redeliver any event manually from the
console (Developers → Webhooks → the event) or ask support to. A redelivery
resets the attempt counter and re-queues it immediately.
A delivery has four states
pending → success on a 2xx; failed while it's still retrying; dead
once attempts are exhausted. You can watch every attempt (response status,
body snippet, next retry time) in the console's event-delivery log.
Register one HTTPS endpoint per environment
From the console. You'll get a signing secret: store it as a secret, never in source.
Capture the raw body
Mount a raw-body parser on the webhook route so the bytes you verify are the bytes Acute signed.
Verify the signature, constant-time
HMAC-SHA256 over t.body, compare with timingSafeEqual. Reject mismatches
with a 400.
Dedupe on event.id, then ack fast
Record the id, return 2xx, and run side effects on your own queue.