Webhooks
Receive signed payment events, verify them, and handle retries.
Webhooks tell your server when money moves, so you don't need to poll.
Set up an endpoint
In the dashboard, open Settings > API Keys & Webhooks and choose the environment. Each environment has one webhook with:
- URL: your HTTPS endpoint
- Secret token: a long random value that you create and use to verify signatures
- Events: the events you want, a wildcard such as
transaction.*, or*for all
Saving a webhook needs your transaction PIN.
Events
| Event | Sent when | Use it to |
|---|---|---|
checkout.payment.succeeded | A verified bank transfer or card checkout payment succeeds | Fulfill checkout orders |
virtual_account.transfer.received | A virtual account deposit is credited | Credit a customer's balance |
transaction.credit.succeeded | A credit is recorded on your account | Keep your ledger up to date |
transaction.debit.succeeded | A debit is recorded on your account | Keep your ledger up to date |
bulk_transfer.finished | Every recipient in a bulk transfer has reached a final status | Reconcile a payout |
One payment can send several events
A checkout bank transfer can send the first three events for a single payment. Fulfill checkout orders from checkout.payment.succeeded only. Pending, failed, expired, and refunded checkout events aren't sent.
Delivery format
Every delivery is a POST with these headers:
Content-Type: application/json
X-Payinto-Webhook-Id: 01J1234567890ABCDEFGHJKMNP
X-Payinto-Event: virtual_account.transfer.received
X-Payinto-Timestamp: 1793404800
X-Payinto-Signature: t=1793404800,v1=5f2b8c...The body uses a stable envelope:
{
"event": "virtual_account.transfer.received",
"id": "01J1234567890ABCDEFGHJKMNP",
"created_at": "2026-10-31T09:00:00.000000Z",
"api_version": "2026-05-27",
"data": {
"id": "0196431e-1b44-7d10-a2c9-8e5f7b1c2d3e",
"reference": "PIF01EXAMPLE",
"merchant_reference": "order-10001",
"currency": "NGN",
"amount": 150000,
"settled_amount": 150000,
"status": "successful"
}
}The API reference lists the full data schema for each event. All five events include merchant_reference, which can be null.
Verify the signature
Verify every delivery before you trust it. To do this:
- Read the raw request body, before any JSON parsing.
- Split
X-Payinto-Signatureintotandv1. - Compute HMAC-SHA256 of
{t}.{raw_body}with your secret token, as lowercase hex. - Compare it with
v1using a constant-time function. - Reject deliveries where
tis more than five minutes from your clock.
# Check a signature by hand from a saved delivery.
TIMESTAMP=1793404800
SIGNATURE="5f2b8c..."
EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$(cat body.json)" \
| openssl dgst -sha256 -hmac "$PAYINTO_WEBHOOK_SECRET" -hex \
| awk '{print $NF}')
[ "$EXPECTED" = "$SIGNATURE" ] && echo "valid" || echo "invalid"Sign the raw bytes. If you parse the JSON and encode it again, the signature won't match.
The PHP SDK includes webhook verification.
Retries
Respond with any 2xx status to acknowledge a delivery. Any other status, a connection failure, or a slow response counts as a failure. Payinto waits up to 10 seconds to connect and 60 seconds for a response.
A failed webhook is retried up to 14 attempts in total, over about 11 days:
| After attempt | Wait before the next attempt |
|---|---|
| 1 | Within seconds |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 30 minutes |
| 6 | 1 hour |
| 7 | 6 hours |
| 8 | 12 hours |
| 9 | 1 day |
| 10 | 1.5 days |
| 11 | 2 days |
| 12 | 2.5 days |
| 13 | 3 days |
After the 14th attempt, the delivery is marked as failed. You can review deliveries and replay them from the webhook logs in the dashboard.
Each attempt is signed again with a new timestamp, so a retry still passes your timestamp check. The X-Payinto-Webhook-Id stays the same.
Handle events reliably
- Respond quickly. Save the event, return
200, and do slow work in a background job. - Process events once. Retries and replays can deliver an event again. Store the
idof processed events, or usedata.idordata.referenceas a unique key. - Handle events in any order. Delivery order isn't guaranteed.
- Check the amount. Before you give value, match
merchant_reference,currency, and the expected amount. - Fall back to a status query. If you're unsure about a payment, call
GET /transactions/tsq/{reference}.
Test in the sandbox
Register a webhook in the test environment, then simulate a deposit from the dashboard or create a sandbox transfer. Test and live webhooks are separate, and each has its own secret.