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 in Dashboard > Settings > Webhooks
- Your endpoint receives POST requests for transaction events
- Return HTTP 200 within 5 seconds to acknowledge delivery
- Verify the signature before processing the event
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 |
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 |
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.
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')
})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,
})
// Disable freshness entirely (not recommended)
nylonpay.verifyWebhookSignature({ /* ... */ toleranceSeconds: 0 })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 HTTP 200, Nylon Pay retries with increasing delays.
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | About 1 minute |
| 3 | About 5 minutes |
| 4 | About 30 minutes |
| 5 | About 2 hours |
After 5 failures the delivery is marked as failed. You can retry from the dashboard.
Best Practices
- Return HTTP 200 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 from the dashboard
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.