Event types TagPay can fire, with example payloads. Configure which to receive in Dashboard → Developers → Webhooks.
charge.success
Fired whenever a pending charge settles successfully, across every inflow channel (card, USSD, bank transfer, direct NIP deposit). This example shows a static virtual-account inflow — a permanent or per-checkout collection account credited directly, reference prefixed COLL_ (standalone permanent/EXPIRING account) or SUBWALLET_ (direct account_funded deposit) — with no sessionId/sessionKey key on the wire, since no checkout session is involved. The same event fired for a checkout-flow bank-transfer charge adds two extra keys: sessionId and sessionKey. Trap: top-level data.accountNumber is rail-dependent, not a fixed "sender" field. On the COLL_ rail (standalone permanent/EXPIRING account credited) it's the SENDER's account, matching senderAccountNumber. On the SUBWALLET_ rail (direct account_funded deposit) it's the opposite — the BENEFICIARY's own NUBAN (your collection account), not the sender's. Don't rely on top-level accountNumber for either meaning on either rail — always read destinationAccountNumber or collectionAccount.accountNumber for the beneficiary side, and senderAccountNumber for the payer side. Static virtual-account inflows (COLL_/SUBWALLET_) only ever fire charge.success — there is no pending charge to time out or fail on that rail.
Fired when a pending charge does not complete. Two families: a synchronous decline (status: 'failed' — card/USSD reported a hard decline) or a timeout/expiry sweep (status: 'abandoned' — checkout-session TTL, USSD 10-minute timeout, or the universal pending-transaction reaper). The 'abandoned' case is the more common one in practice — don't assume data.status is always 'failed'. fee/netAmount/paidAt/processedAt are null on every abandoned delivery (no payment ever arrived), and null on most decline deliveries too — only the USSD-callback decline path stamps processedAt.
Fired when a previously successful charge is compensated after its post-success wallet credit failed (provider-reversal compensation). Treat this as the inverse of a charge.success you may already have received: refund the customer in your own books and roll back any fulfilment. fee, netAmount, and paidAt survive unchanged from the original success — the reversal only flips status to 'reversed'. Sandbox charges never reach this path.
Registered as a subscribable event but not currently delivered — no code path emits it today. Do not build integrations around this event; listen for refund.success / refund.failed instead.
{
"event": "refund.created",
"data": {}
}
refund.success
Fired after a refund's provider dispatch (Interswitch reversal or NIBSS transfer) is confirmed and committed. Critical trap: the payload is the ORIGINAL CHARGE re-snapshotted through the same builder as charge.success — there is no refund id, no refunded amount, no refund type (full/partial), and no reason. amount is the original charge amount, not what was refunded. To learn the refund amount or id, call GET /v1/refunds/:id — never assume a second refund against the same charge is distinguishable from the first by this payload alone.
Fired when a refund's provider dispatch throws (Interswitch or NIBSS rejects the reversal). Same structural trap as refund.success: the payload is the ORIGINAL charge, unchanged — status still reads 'success', not 'failed'. The actual failure reason is persisted on the Refund record but never included on the wire; call GET /v1/refunds/:id to see why it failed.
Registered as subscribable, but not currently delivered — settlement events do not reach merchant endpoints today, and no settlement-shaped payload is defined yet. Do not depend on this event; poll GET /v1/settlements instead.
{
"event": "settlement.completed",
"data": {}
}
settlement.failed
Registered as subscribable but, like settlement.completed, not currently delivered. Poll GET /v1/settlements for settlement status instead of relying on this event.
{
"event": "settlement.failed",
"data": {}
}
checkout.session.completed
Registered as subscribable, but no code path in the API currently emits it. A checkout session settling successfully is instead communicated via charge.success (carrying sessionId/sessionKey when the settlement traces back to a session). Do not build integrations around this event — it will never arrive.
{
"event": "checkout.session.completed",
"data": {}
}
checkout.session.expired
Registered as subscribable, but not reliably delivered today — treat it as non-functional and use charge.failed (status: 'abandoned'), which fires within minutes of expiry, as the dependable signal for an abandoned checkout. Note that despite the session-scoped name, the payload is transaction-shaped: data.id is the underlying Transaction's id, not a checkout-session id, and there is no sessionKey/expiresAt/sessionStatus on the wire.
Fired immediately after the merchant-wallet credit leg of a successful inflow commits — a separate event from charge.success, but NOT a guaranteed pair with it: the two legs are deliberately non-atomic, and there are real paths (platform configuration, post-success failure compensation, or a merchant split that resolves to exactly 0 — e.g. a 100%-to-subaccount split) where charge.success arrives with no wallet.credit at all. Don't build reconciliation logic that assumes 1:1 pairing between charge.success and wallet.credit. amount is the merchant's net split, not the gross transaction amount. There is no timestamp field; correlate via reference or transactionId.
Fired only when an admin resolves a dispute with resolution=customer_won against a live-mode transaction — sandbox disputes never emit it. Unlike wallet.credit, there is no transactionId or reference field here; correlate via disputeCode. newBalance falls back to 0 (not the debited amount) if the wallet lookup misses. Transfers/withdrawals do NOT currently emit wallet.debit — a transfer's lifecycle is communicated only via transfer.success / transfer.failed / transfer.reversed.
Fired when a merchant-initiated transfer is confirmed delivered. Three independent triggers converge on the same confirmSuccess path: the disbursement provider's synchronous response reports success, TagPay's vendor rail later confirms via a transfer.completed webhook, or the stuck-transfer resolver actively requeries the vendor's status endpoint for a transfer stranded in 'processing' and finds it actually succeeded — this last path is not vendor-pushed at all, and in practice accounts for a meaningful share of delayed settlements. Transfers only exist for live merchants; this event never fires for sandbox. amount is what the recipient receives (excludes the separate withdrawal fee, which never appears on the wire). walletBalanceBefore/walletBalanceAfter are captured once at debit time, not re-read later.
{
"event": "transfer.success",
"data": {
"id": "6f1e0c34-9b7a-4e21-8b6a-2f5c9a7d1234",
"reference": "trf_a1b2c3d4e5f60718",
"status": "success",
"amount": 750000,
"currency": "NGN",
"narration": "Payout to vendor - July settlement",
"providerReference": "NIP20260720173045938211",
"failureCode": null,
"failureReason": null,
"walletBalanceBefore": 5758500,
"walletBalanceAfter": 5000000,
"recipient": {
"id": "998c3fd1-2a44-4d0b-9e2d-7c1a5f6b8e90",
"accountName": "ADAEZE UGOCHUKWU",
"accountNumber": "0123456789",
"bankCode": "058"
},
"processedAt": "2026-07-20T17:30:46.000Z",
"createdAt": "2026-07-20T17:29:58.000Z"
}
}
transfer.failed
Fired as soon as the provider or vendor rejects the transfer — deliberately before the wallet is re-credited (that correction is the separate, later transfer.reversed event). failureCode/failureReason are always the merchant-safe mapped values (one of invalid_account, account_closed, account_blocked, name_mismatch, or the generic transfer_failed fallback) — the raw internal failure reason is never sent. In practice the vendor-webhook-driven path almost always shows transfer_failed.
{
"event": "transfer.failed",
"data": {
"id": "a13d5e77-4c2b-4f19-8e3d-1b9c7a2f5d60",
"reference": "TRF_20260720_9WX7L1",
"status": "failed",
"amount": 1200000,
"currency": "NGN",
"narration": "Payout to supplier",
"providerReference": "NIP20260720161200552013",
"failureCode": "transfer_failed",
"failureReason": "This transfer could not be completed. Any amount debited is automatically returned to your wallet.",
"walletBalanceBefore": 3450000,
"walletBalanceAfter": 2237500,
"recipient": {
"id": "7c2e9f31-5b6a-4d8c-9e1f-3a7b5c9d2e40",
"accountName": "CHIOMA EZEKWESILI",
"accountNumber": "0234567891",
"bankCode": "011"
},
"processedAt": "2026-07-20T16:12:05.000Z",
"createdAt": "2026-07-20T16:11:40.000Z"
}
}
transfer.reversed
Fired only after the wallet has actually been re-credited. Two triggers converge here: the ordinary fail-then-refund tail of a transfer that never reported success, or a vendor-side post-success clawback — in that second case you already received transfer.success earlier and no transfer.failed is ever sent, so transfer.reversed is the correction. Every field except status is carried over unchanged from the preceding failed state: failureCode/failureReason remain non-null, walletBalanceBefore/walletBalanceAfter are still the pre-reversal debit-time snapshot (not the post-refund balance), and processedAt still reflects the original failure time — there is no reversedAt field.
{
"event": "transfer.reversed",
"data": {
"id": "a13d5e77-4c2b-4f19-8e3d-1b9c7a2f5d60",
"reference": "TRF_20260720_9WX7L1",
"status": "reversed",
"amount": 1200000,
"currency": "NGN",
"narration": "Payout to supplier",
"providerReference": "NIP20260720161200552013",
"failureCode": "transfer_failed",
"failureReason": "This transfer could not be completed. Any amount debited is automatically returned to your wallet.",
"walletBalanceBefore": 3450000,
"walletBalanceAfter": 2237500,
"recipient": {
"id": "7c2e9f31-5b6a-4d8c-9e1f-3a7b5c9d2e40",
"accountName": "CHIOMA EZEKWESILI",
"accountNumber": "0234567891",
"bankCode": "011"
},
"processedAt": "2026-07-20T16:12:05.000Z",
"createdAt": "2026-07-20T16:11:40.000Z"
}
}
charge.dispute_created
Fired the instant an admin raises a chargeback against a successful, live-mode transaction — the transaction-shaped sibling of dispute.created, same trigger, same instant. The payload is byte-identical to the charge.success payload already sent for this transaction; the only new information is the event name itself. The disputed amount, reason, and due date are not here — they live on the paired dispute.created event.
Fires alongside charge.dispute_created, same instant, live-mode only. amount is the DISPUTED amount from the admin's request — it may be less than the original charge's own amount — and currency is a hardcoded 'NGN' literal, not read off the transaction.
Fired after an admin resolves a dispute (merchant-won or customer-won), live-mode only; idempotent replays of an already-resolved dispute do not re-fire it. resolution is the hyphenated wire form ('customer-won' / 'merchant-won'), not the underscored database enum. This payload does not say whether the merchant's wallet was actually debited — for a customer-won resolution, check the separately-fired wallet.debit event (source: 'dispute') to see if money moved.
"resolutionMessage": "Insufficient evidence of delivery provided within SLA",
"resolvedAt": "2026-07-25T14:02:33.000Z"
}
}
subscription.created
Fired when a subscription starts. This example shows the flow that actually delivers on the wire today: a customer's first successful card charge against a subscription-type payment link (source: 'payment_link', paymentLinkCode present; inTrial/trialPeriodDays/trialEndsAt additionally appear only when the link has a trial period). Direct creation via POST /v1/subscriptions also registers this event, with a DIFFERENT shape (subscriptionCode/planCode/customerEmail/status/amount/nextChargeAt/chargeCount, no source/paymentLinkCode) — but deliveries from that path are currently unreliable; do not depend on receiving them. Do not assume a fixed key set for this event.
{
"event": "subscription.created",
"data": {
"subscriptionCode": "SUB_3b7e1f9c2d5a8b0e",
"planCode": "PLN_7a1c9e3f5b2d0a8c",
"amount": 1500000,
"source": "payment_link",
"paymentLinkCode": "PLK_2f8a4c6e1b9d3f7a",
"nextChargeAt": "2026-08-19T10:15:00.000Z"
}
}
subscription.cancelled
Fired when a merchant calls POST /v1/subscriptions/:code/cancel — but not reliably delivered today; poll GET /v1/subscriptions/:code after cancelling rather than waiting for it. The cancellation reason and the real cancelledAt timestamp are set on the record but are not in this payload — nextChargeAt here is just the stale pre-cancellation value, not recomputed.
Registered as subscribable, but pausing a subscription never enqueues a webhook — only status and pausedAt are updated on the row. No webhook currently fires for this event.
{
"event": "subscription.paused",
"data": {}
}
subscription.resumed
Registered as subscribable, but resuming a subscription never enqueues a webhook — only status is updated and pausedAt cleared on the row. No webhook currently fires for this event.
{
"event": "subscription.resumed",
"data": {}
}
subscription.charge_success
Fired by the hourly subscription-billing cron after a tokenised recurring charge succeeds — but not reliably delivered today; reconcile recurring charges via GET /v1/subscriptions/:code or the charge records themselves rather than depending on this event. nextChargeAt is null when completed is true (a finite-term plan just hit its final charge).
Fired by the same hourly cron when a tokenised recurring charge throws — but not reliably delivered today. failureReason is the raw caught error message (truncated to 500 characters), not a stable error code. nextChargeAt is null once exhausted is true — three retries burned, the subscription flips to past_due and the cron stops picking it up.
"failureReason": "Interswitch tokenised charge failed: card declined by issuer",
"nextChargeAt": "2026-07-23T14:00:00.000Z",
"exhausted": false
}
}
invoice.created
Fired on invoice creation — whether it lands pending (autoSend, the default) or draft — and again on an invoice's first send (draft to pending). It does not re-fire on a plain re-send of an already-sent invoice. transactionId and paidAt are always null here since no payment has happened yet; invoiceNumber is null while status is draft. Note: invoice.* events are not reliably delivered today — poll GET /v1/invoices/:code rather than depending on them.
Enqueued exactly once, the instant an invoice fully settles — online (a real charge, transactionId is the settling transaction) or offline (merchant manually records money received outside the platform; source: 'offline' and note appear only on that path — entirely absent, not null, on the online path shown here). However, invoice.* events are not reliably delivered today — do not depend on them reaching your endpoint; poll GET /v1/invoices/:code for status. Idempotent: re-applying payment to an already-paid invoice is a no-op and does not re-fire this event.
Fired when a merchant voids an invoice (or calls the .../cancel alias) — any non-paid, non-cancelled invoice is voidable. transactionId and paidAt are always null: paid invoices are guarded off from voiding, so a cancelled invoice never had a settling transaction. Note: invoice.* events are not reliably delivered today.
Enqueued by the cron sweep that flips a pending invoice with a past dueDate to overdue — one per whole-invoice transition (individual installment flips fire no event). Like the other invoice.* events, not reliably delivered today — poll GET /v1/invoices for status instead of relying on it.
Fired once per installment settled inside a single payment — once for a single-installment checkout charge, or N times (same transactionId on every one) for a 'pay full balance' charge that settles every remaining installment at once. invoiceStatus reports the PARENT invoice's status after this installment: 'pending' if more remain, or 'paid' if this was the last one — in the 'paid' case, invoice.paid also fires separately right after, from the same call. Note: invoice.* events are not reliably delivered today — reconcile installments via the charge.success fired per payment, or GET /v1/invoices/:code.
{
"event": "invoice.installment_paid",
"data": {
"invoiceCode": "INV_7f3a9c2e1b6d4f80",
"invoiceNumber": "INV-2026-00042",
"installment": {
"id": "c4a1e2b3-9f56-4d1a-8b3e-1a2b3c4d5e6f",
"ordinal": 1,
"amount": 10000000,
"dueDate": "2026-08-01T00:00:00.000Z",
"paidAt": "2026-08-01T10:05:44.000Z"
},
"invoiceStatus": "pending"
}
}
paymentlink.paid
Fired on every successful payment against a fixed- or open-type payment link. It is intended to skip subscription-type links (which signal via subscription.created instead), but that exclusion is not consistently enforced on every settlement path — treat it as best-effort and be prepared to also receive paymentlink.paid for a subscription-type link. It fires on EVERY payment against a reusable, non-single-use link, not only ones that exhaust it; paymentLink.active tells you whether this specific payment deactivated the link. The data body is nested — { paymentLink, transaction } — not a flat transaction object like charge.success. The nested transaction always comes from the plain transaction builder, so it never carries sessionId/sessionKey even when the underlying charge was a checkout-session bank transfer, and the same rail-dependent accountNumber trap from charge.success applies inside it.
Registered and shown here for completeness, but not currently observable in production: a blocked fraud outcome is surfaced to the caller as an HTTP 403 error at initialization instead, and the webhook never fires. Do not depend on this event.
Fired during transaction initialization when the fraud engine matches a flag-action rule but no block-action rule (block always wins if both match). blockedByRuleName is ABSENT — not null — on this event; it's only ever set for a blocked outcome. factors lists only matched rules, and every entry's action is 'flag' here.