Webhooks
Receive real-time payment notifications on your server
Webhooks are HTTP POST requests Nylon Pay sends to your server when a transaction status changes. Use them to update your database, fulfill orders, or reconcile ledgers without polling.
Quick Start
- Set your webhook URL and secret in Dashboard > API Settings, under Webhook Configuration on the API key
- Your endpoint receives POST requests for transaction events
- Return any 2xx status within 10 seconds to acknowledge delivery
- Verify the signature before processing the event
The webhook URL and secret belong to an API key, not to your account. If you use more than one key, give each one its own URL, or point them at the same endpoint and configure every secret you generated. Each delivery is signed with the secret of the key that created the transaction.
Event Types
| Event | When it happens | What to do |
|---|---|---|
transaction.successful | Transaction completed successfully | Fulfill order, send receipt, credit account |
transaction.failed | Transaction was rejected or timed out | Notify customer, offer retry |
transaction.processing | Transaction is being processed | Update status in your system |
transaction.cancelled | Transaction was cancelled | Update order state |
These four are the only webhook events. Collections and payouts both use them —
read payload.type to tell them apart.
A payout that is parked for review (on_hold or under_review) sends nothing
until it resolves to one of the four events above. In rare cases a transaction
we resolve internally as failed also sends no transaction.failed. Treat
webhooks as the fast path, not the only path: reconcile anything you have not
heard about with getStatus().
Payload Format
Each webhook is a POST request with a JSON body. The signature is in the x-nylon-signature request header, not in the body.
The payload shape is identical for every event. Your handler reads the same fields regardless of which status triggered it.
{
"delivery_id": "d7f3a1b2-...",
"event": "transaction.successful",
"payload": {
"transactionId": "8e4f2c1a-...",
"reference": "550e8400-e29b-41d4-a716-446655440000",
"amount": "50000",
"currency": "UGX",
"status": "successful",
"previousStatus": "processing",
"type": "charge",
"method": "mobileMoney",
"mode": "live",
"failureReason": null,
"operatorTid": "MPS20240530123456"
},
"timestamp": "2026-05-30T10:30:15.000Z"
}| Field | Description |
|---|---|
delivery_id | Unique ID for this delivery attempt. Use it for idempotency. |
event | Event name. See Event Types. |
payload.transactionId | Internal transaction ID |
payload.reference | Your reference UUID passed when creating the transaction |
payload.amount | Transaction amount as a string |
payload.currency | Three-letter currency code |
payload.status | New transaction status |
payload.previousStatus | Status before this transition |
payload.type | charge for collections, payout for disbursements |
payload.method | Payment method, for example mobileMoney |
payload.mode | live or test |
payload.failureReason | Human-readable reason for failed or cancelled events, otherwise null |
payload.operatorTid | Mobile operator or bank transaction ID, or null if unavailable |
Every key is always present. type, method, and mode are null when the
transaction has no value stored for them, and amount and currency are
null only if we could not read the transaction record while dispatching.
transactionId and status are always set.
Request Headers
| Header | Description |
|---|---|
x-nylon-signature | Signature of the raw request body. The one you must verify. |
x-nylon-event | Same event name as event in the body |
x-nylon-delivery-id | Same delivery ID as delivery_id in the body |
x-nylon-timestamp | Same timestamp as timestamp in the body |
Only the signature header carries security meaning. The other three are
conveniences for logging and routing — they sit outside the signed body, so
never make a trust decision from them. Read event, delivery_id, and
timestamp from the body once the signature checks out.
Signature Verification
Every webhook includes an x-nylon-signature header with a signature of the raw request body. Verify it before processing the event using the SDK's built-in verifyWebhookSignature method.
The signature is lowercase hex, and that is the only form we send or accept. If you verify by hand rather than with the SDK, compare against it exactly — do not uppercase it or otherwise reformat it first.
verifyWebhookSignature returns true only when the signature is authentic and the webhook is fresh. See Replay protection below.
import { createNylonPay } from '@nile-squad/nylonpay-ts'
const nylonpay = createNylonPay({
apiKey: 'npk_test_your_key',
apiSecret: 'nps_test_your_secret',
})Express Example
import express from 'express'
const app = express()
// Capture raw body. Verification must run against the raw bytes, not parsed JSON.
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf.toString()
},
})
)
app.post('/webhooks', (req, res) => {
const webhookSecret = process.env.NYLONPAY_WEBHOOK_SECRET
const signature = req.headers['x-nylon-signature'] as string
const isValid = nylonpay.verifyWebhookSignature({
payload: req.rawBody,
signature,
secret: webhookSecret,
})
if (!isValid) {
return res.status(401).send('Invalid signature')
}
// All events carry the same payload shape, so one handler covers every status.
const { event, payload } = req.body
switch (event) {
case 'transaction.successful':
fulfillOrder(payload.reference)
break
case 'transaction.failed':
case 'transaction.cancelled':
notifyCustomer(payload.reference, payload.failureReason)
break
case 'transaction.processing':
updateOrderStatus(payload.reference, 'processing')
break
}
res.status(200).send('OK')
})PHP Example
<?php
use function NileSquad\NylonPay\createNylonPay;
use function NileSquad\NylonPay\verifyWebhookSignature;
$nylon = createNylonPay([
'apiKey' => getenv('NYLONPAY_API_KEY'),
'apiSecret' => getenv('NYLONPAY_API_SECRET'),
]);
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_NYLON_SIGNATURE'] ?? '';
$valid = verifyWebhookSignature([
'payload' => $rawBody,
'signature' => $signature,
'secret' => getenv('NYLONPAY_WEBHOOK_SECRET'),
]);
if (!$valid) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$body = json_decode($rawBody, true);
$event = $body['event'] ?? '';
$payload = $body['payload'] ?? [];
if ($event === 'transaction.successful') {
// fulfill_order($payload['reference']);
}
http_response_code(200);
echo 'OK';Replay protection
A signature proves who sent a webhook, not when. Without an expiry, anyone who captures a valid delivery could replay it forever to re-trigger fulfilment. verifyWebhookSignature prevents this: after the signature checks out, it confirms the timestamp inside the signed body is recent, within 5 minutes by default. A stale or replayed delivery fails verification.
This never rejects legitimate traffic. Every delivery, including retries hours later, is re-stamped and re-signed by Nylon Pay, so a retry always looks fresh. Only a replay of an older captured payload is rejected.
Tune or disable the window with toleranceSeconds:
// Widen to 15 minutes for a slow or queued consumer
nylonpay.verifyWebhookSignature({
payload: req.rawBody,
signature: req.headers['x-nylon-signature'],
secret: webhookSecret,
toleranceSeconds: 900,
})
// `0` means ZERO tolerance (strictest), not "off" — it rejects almost
// everything. To opt out, pass the sentinel deliberately (not recommended).
import { DISABLE_FRESHNESS_CHECK } from '@nile-squad/nylonpay-ts'
nylonpay.verifyWebhookSignature({
/* ... */ toleranceSeconds: DISABLE_FRESHNESS_CHECK,
})Replay protection is defence in depth. Still apply your own idempotency, for example by deduplicating on delivery_id or the transaction reference, before acting on an event.
Delivery Guarantees
Nylon Pay delivers webhooks on an at-least-once basis. A single event may be sent more than once. Make your handler idempotent by tracking processed delivery IDs or transaction references.
async function handleWebhook(event, payload, deliveryId) {
const processed = await db.get(`processed:${deliveryId}`)
if (processed) return
await processEvent(event, payload)
await db.set(`processed:${deliveryId}`, true)
}Retry Behavior
If your endpoint does not return a 2xx status, Nylon Pay retries. A delivery also counts as failed when your endpoint does not answer within 10 seconds.
Retries come in two phases. The first five attempts happen immediately, with a short backoff between them:
| Attempt | Delay before it |
|---|---|
| 1 | None, sent as the status changes |
| 2 | About a minute |
| 3 | About 2 minutes |
| 4 | About 4 minutes |
| 5 | About 8 minutes |
Each wait is roughly double the one before, so the five attempts span about fifteen minutes. That is enough to ride out a restart or a short deploy on your side.
If all five fail, the delivery is not dropped. It moves to a slower phase: one more attempt every night, for up to five more nights. Only after that, at ten attempts total, is the delivery finally marked as failed and left alone.
So a brief outage recovers on its own within minutes, and a longer one still recovers on its own once your endpoint is back, as long as it returns within five days. You can also retry a delivery yourself at any time from the dashboard, using the Retry webhook action in the transaction's menu.
Best Practices
- Return a 2xx status immediately, process asynchronously
- Use
delivery_idor the transactionreferencefor idempotency - Read the signature from the
x-nylon-signatureheader, not the body - Log raw payloads for debugging
- Store your webhook secret in environment variables, not in code
- Rotate the webhook secret periodically with Generate in the key's Webhook Configuration, then update it on your server
Testing Webhooks
In sandbox mode, webhooks work the same as in production. Use a tool like ngrok to expose your local server:
ngrok http 3000Set your webhook URL to https://your-ngrok-url.ngrok.io/webhook in the dashboard.