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.

how an event reaches you
text
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:

the webhook body
json
{
  "id": "acuinf7h3k9q2x8m4evt",
  "type": "payment.settled",
  "createdAt": "2026-06-24T09:41:12.004Z",
  "data": {
    "id": "acuinf7h3k9q2x8m4npay",
    "status": "settled",
    "baseAmount": 150000,
    "amountReceived": 150000,
    "currency": "NGN"
  }
}
idstringrequired

The event reference (acuinf…evt). This is your idempotency key: dedupe on it.

typeWebhookEventTyperequired

The event name, e.g. payment.settled. See the full event catalog.

createdAtstring (ISO 8601)required

When the event was recorded, not when this delivery attempt was sent.

dataobjectrequired

The 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:

HeaderExampleWhat it is
X-Acute-Signaturet=1750758072,v1=8f3c…The Stripe-style signature: the unix timestamp t and the HMAC digest v1.
X-Acute-Timestamp1750758072The 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.

verify-webhook.ts
ts
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.

idempotent-handler.ts
ts
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 marked success. 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:

AttemptDelay before itCumulative
1immediate0
21 min1 min
32 min3 min
44 min7 min
58 min15 min
616 min31 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

pendingsuccess 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.

1

Register one HTTPS endpoint per environment

From the console. You'll get a signing secret: store it as a secret, never in source.

2

Capture the raw body

Mount a raw-body parser on the webhook route so the bytes you verify are the bytes Acute signed.

3

Verify the signature, constant-time

HMAC-SHA256 over t.body, compare with timingSafeEqual. Reject mismatches with a 400.

4

Dedupe on event.id, then ack fast

Record the id, return 2xx, and run side effects on your own queue.