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:

the delivered body
json
{
  "id": "acuinf7h3k9q2x8m4evt",
  "type": "payment.settled",
  "createdAt": "2026-06-24T09:41:12.004Z",
  "data": { /* the resource snapshot, shaped like its API response */ }
}
FieldTypeNotes
idstringThe event reference (acuinf…evt). Stable across retries: dedupe on it.
typeWebhookEventTypeThe event name.
createdAtstring (ISO 8601)When the event was recorded (not the send time).
dataobjectThe 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.

HeaderExampleDescription
Content-Typeapplication/jsonAlways JSON.
X-Acute-Signaturet=1750758072,v1=8f3c…Timestamp + HMAC-SHA256 hex digest.
X-Acute-Timestamp1750758072The 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.

typeFires whendata shape
payment.settledA payment is fully paid (amountReceived ≥ payableAmount) and the ledger posting + fee billing complete.PaymentResponseData
payment.expiredA bank_transfer payment passes its expiresAt without full settlement.PaymentResponseData
payment.refundedA payment is refunded (its status becomes refunded / partially_refunded).PaymentResponseData
transfer.completedA wallet→wallet transfer posts successfully.TransferResponseData
withdrawal.completedA wallet→bank withdrawal confirms on the rail (the provider transfer succeeded).WithdrawalResponseData
withdrawal.failedA withdrawal fails or is returned by the rail; funds are restored to the wallet.WithdrawalResponseData
payout.completedEvery item in a batch payout completes.PayoutResponseData
payout.partially_completedA batch payout finishes with some items completed and some failed.PayoutResponseData
refund.completedA refund confirms (bank refund settles, or wallet refund posts).RefundResponseData
refund.failedA 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:

payment.settled
json
{
  "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.

transfer.completed
json
{
  "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.

withdrawal.completed
json
{
  "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.

payout.partially_completed
json
{
  "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.

refund.completed
json
{
  "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:

the signed input
text
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 as X-Acute-Signature.

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

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

AttemptBackoff before send
1immediate
21 min
32 min
44 min
58 min
616 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:

StateMeaning
pendingQueued, not yet attempted (or awaiting its next retry).
successA 2xx was received. Done.
failedAn attempt failed; a retry is scheduled.
deadAll 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.