Webhook events
The complete catalog of delivered events, their data payloads, the signature recipe, headers, retry schedule, and the interactive verifier.
This is the exhaustive reference for Acute's outbound webhooks: every event type
we deliver, the exact data it carries, the signature recipe, the headers, and
the retry/dead-letter behaviour. For the integration walkthrough, see the
Webhooks guide.
Every delivery is an HTTP POST to your registered endpoint with this body:
{
"id": "acuinf7h3k9q2x8m4evt",
"type": "payment.settled",
"createdAt": "2026-06-24T09:41:12.004Z",
"data": { /* the resource snapshot, shaped like its API response */ }
}| Field | Type | Notes |
|---|---|---|
id | string | The event reference (acuinf…evt). Stable across retries: dedupe on it. |
type | WebhookEventType | The event name. |
createdAt | string (ISO 8601) | When the event was recorded (not the send time). |
data | object | The resource snapshot, shaped like the resource's GET response. |
The body is snapshotted as immutable text when the event is created and replayed byte-for-byte on every retry, which is why the signature stays valid and why you must verify over the raw bytes.
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON. |
X-Acute-Signature | t=1750758072,v1=8f3c… | Timestamp + HMAC-SHA256 hex digest. |
X-Acute-Timestamp | 1750758072 | The same unix timestamp, broken out. |
These are the event types Acute delivers to merchant endpoints: the canonical set
from the WebhookEventType enum (the SSOT, mirroring the database
webhook_event_type_enum). Each fires exactly when its resource reaches the named
state.
type | Fires when | data shape |
|---|---|---|
payment.settled | A payment is fully paid (amountReceived ≥ payableAmount) and the ledger posting + fee billing complete. | PaymentResponseData |
payment.expired | A bank_transfer payment passes its expiresAt without full settlement. | PaymentResponseData |
payment.refunded | A payment is refunded (its status becomes refunded / partially_refunded). | PaymentResponseData |
transfer.completed | A wallet→wallet transfer posts successfully. | TransferResponseData |
withdrawal.completed | A wallet→bank withdrawal confirms on the rail (the provider transfer succeeded). | WithdrawalResponseData |
withdrawal.failed | A withdrawal fails or is returned by the rail; funds are restored to the wallet. | WithdrawalResponseData |
payout.completed | Every item in a batch payout completes. | PayoutResponseData |
payout.partially_completed | A batch payout finishes with some items completed and some failed. | PayoutResponseData |
refund.completed | A refund confirms (bank refund settles, or wallet refund posts). | RefundResponseData |
refund.failed | A bank refund is rejected/returned by the rail. | RefundResponseData |
Lifecycle vs delivered events
The platform records additional lifecycle events internally (e.g.
wallet.created, kyc.verified, payment.created, kyb.approved). The list
above is the set delivered to merchant webhook endpoints. Build against
these; treat any other type defensively (ack and ignore unknown types).
payment.settled, payment.expired, and payment.refunded carry a full
PaymentResponseData:
{
"id": "acuinf7h3k9q2x8m4evt",
"type": "payment.settled",
"createdAt": "2026-06-24T09:41:12.004Z",
"data": {
"id": "acuinf7h3k9q2x8m4npay",
"method": "bank_transfer",
"status": "settled",
"baseAmount": 150000,
"fee": 2250,
"payableAmount": 152250,
"amountReceived": 152250,
"currency": "NGN",
"targetWalletId": "acuinf2c5v8b1n4m7kwlt",
"description": "Order #4821",
"expiresAt": "2026-06-24T10:11:12.004Z",
"settledAt": "2026-06-24T09:41:12.004Z",
"createdAt": "2026-06-24T09:11:12.004Z",
"virtualAccount": {
"accountNumber": "9912345678",
"bankName": "Providus Bank",
"accountName": "ACUTE/Your Merchant Ltd",
"expiresAt": "2026-06-24T10:11:12.004Z"
}
}
}status is one of pending · partial · settled · expired · failed · refunded · partially_refunded. See status reference.
{
"id": "acuinf3d6w9c2o5n8levt",
"type": "transfer.completed",
"createdAt": "2026-06-24T11:02:31.220Z",
"data": {
"id": "acuinf3d6w9c2o5n8ltrf",
"sourceWalletId": "acuinf2c5v8b1n4m7kwlt",
"destinationWalletId": "acuinf9k2j5h8g1f4dwlt",
"amount": 50000,
"fee": 1000,
"status": "completed",
"description": "Payout to vendor",
"currency": "NGN",
"createdAt": "2026-06-24T11:02:31.100Z"
}
}status is completed or failed.
{
"id": "acuinf4e7x0d3p6o9levt",
"type": "withdrawal.completed",
"createdAt": "2026-06-24T12:15:08.500Z",
"data": {
"id": "acuinf4e7x0d3p6o9lwth",
"sourceWalletId": "acuinf2c5v8b1n4m7kwlt",
"amount": 200000,
"fee": 4000,
"totalAmount": 204000,
"status": "completed",
"counterparty": {
"accountNumber": "0123456789",
"accountName": "Adaeze Okafor",
"bankCode": "058",
"bankName": "GTBank"
},
"failureReason": null,
"currency": "NGN",
"createdAt": "2026-06-24T12:14:50.000Z",
"completedAt": "2026-06-24T12:15:08.400Z"
}
}On withdrawal.failed, status is failed (or returned) and failureReason
is populated. The async resolver restores the debited amount to the source wallet:
withdrawals are non-lossy. status is one of
processing · completed · failed · returned.
{
"id": "acuinf5f8y1e4q7p0levt",
"type": "payout.partially_completed",
"createdAt": "2026-06-24T13:40:19.770Z",
"data": {
"id": "acuinf5f8y1e4q7p0lpyo",
"sourceWalletId": "acuinf2c5v8b1n4m7kwlt",
"totalAmount": 300000,
"totalFee": 2000,
"itemCount": 2,
"status": "partially_completed",
"items": [
{
"id": "acuinf6g9z2f5r8q1lpoi",
"amount": 200000,
"fee": 1000,
"status": "completed",
"counterparty": { "accountNumber": "0123456789", "accountName": "Adaeze Okafor", "bankCode": "058" },
"failureReason": null
},
{
"id": "acuinf7h0a3g6s9r2lpoi",
"amount": 100000,
"fee": 1000,
"status": "failed",
"counterparty": { "accountNumber": "9876543210", "accountName": null, "bankCode": "044" },
"failureReason": "Account not found"
}
],
"currency": "NGN",
"createdAt": "2026-06-24T13:39:55.000Z"
}
}Payout status is processing · completed · partially_completed · failed; each
item is pending · processing · completed · failed. A payout.completed fires
when all items complete; payout.partially_completed when some fail.
{
"id": "acuinf8i1b4h7t0s3levt",
"type": "refund.completed",
"createdAt": "2026-06-24T14:05:44.010Z",
"data": {
"id": "acuinf8i1b4h7t0s3lrfd",
"paymentId": "acuinf7h3k9q2x8m4npay",
"amount": 150000,
"destination": "bank",
"status": "completed",
"failureReason": null,
"currency": "NGN",
"createdAt": "2026-06-24T14:05:20.000Z",
"completedAt": "2026-06-24T14:05:43.900Z"
}
}Refund status is processing · completed · failed. Bank refunds settle
asynchronously (hence refund.completed / refund.failed); wallet refunds post
synchronously.
Acute signs each delivery with the endpoint's signing secret:
signature = HMAC_SHA256( `${timestamp}.${rawBody}`, signingSecret ) // hex
header = `t=${timestamp},v1=${signature}`- Algorithm: HMAC with SHA-256.
- Input: the unix
timestamp, a literal., then the raw body bytes. - Encoding: lowercase hex.
- Header: Stripe-style
t=<unix>,v1=<hex>, sent asX-Acute-Signature.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((s) => s.split("=").map((v) => v.trim())));
const { t, v1 } = parts;
if (!t || !v1) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(v1);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}async function verify(rawBody: string, header: string, secret: string): Promise<boolean> {
const parts = Object.fromEntries(header.split(",").map((s) => s.split("=").map((v) => v.trim())));
const { t, v1 } = parts;
if (!t || !v1) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${t}.${rawBody}`));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
return expected.length === v1.length && expected === v1; // use a constant-time compare in production
}Verify over the raw bytes, constant-time
Re-serializing the parsed JSON will change the bytes and break the signature.
Capture the raw body, and compare digests with a constant-time function
(timingSafeEqual), never === on the hot path.
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.
A delivery that does not get a 2xx (including timeouts and connection errors) is
retried with exponential backoff, 6 attempts total:
| Attempt | Backoff before send |
|---|---|
| 1 | immediate |
| 2 | 1 min |
| 3 | 2 min |
| 4 | 4 min |
| 5 | 8 min |
| 6 | 16 min |
The formula is min(3600s, 60 · 2^(attempt-1)) (capped at 1 hour). After the 6th
failure the delivery is marked dead. Delivery states:
| State | Meaning |
|---|---|
pending | Queued, not yet attempted (or awaiting its next retry). |
success | A 2xx was received. Done. |
failed | An attempt failed; a retry is scheduled. |
dead | All 6 attempts exhausted. No more automatic retries. |
A dead event can be redelivered manually from the console (Developers →
Webhooks → the event) or by support; redelivery resets the attempt counter and
re-queues immediately.