Webhooks & Events
Payscribe sends HTTP callbacks to your endpoint when events occur. Webhooks are configured in the dashboard at Settings > Webhooks.
Endpoint requirements
- Must accept
POSTrequests - Must return
200 OKwithin 5 seconds - Must be publicly accessible (HTTPS)
- Respond quickly — process events asynchronously
Signature verification
Each webhook includes these headers:
X-Payscribe-Signature—v1={hmac_sha256(timestamp + "." + event_id + "." + raw_body, secret)}X-Payscribe-Event-Id— UUID for deduplicationX-Payscribe-Timestamp— Unix timestamp (reject if older than 5 minutes)
The signed value is timestamp + "." + event_id + "." + raw_body. cURL is not shown because it cannot receive an incoming webhook; choose the server language your application uses.
- Node.js
- Python
- Go
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Register before express.json(); req.body must remain a Buffer.
app.post('/webhooks/payscribe', express.raw({type: 'application/json'}), async (req, res) => {
const payload = req.body;
const signature = req.headers['x-payscribe-signature'];
const timestamp = req.headers['x-payscribe-timestamp'];
const eventId = req.headers['x-payscribe-event-id'];
if (!signature || !timestamp || !eventId) return res.sendStatus(401);
const expected = 'v1=' + crypto
.createHmac('sha256', process.env.PAYSCRIBE_WEBHOOK_SECRET)
.update(`${timestamp}.${eventId}.${payload.toString('utf8')}`)
.digest('hex');
const received = Buffer.from(signature);
const calculated = Buffer.from(expected);
if (received.length !== calculated.length || !crypto.timingSafeEqual(calculated, received)) {
return res.sendStatus(401);
}
// Store eventId with a unique database constraint, queue work, then acknowledge.
return res.sendStatus(200);
});
import hashlib
import hmac
import os
from flask import Flask, request
app = Flask(__name__)
@app.post('/webhooks/payscribe')
def payscribe_webhook():
payload = request.get_data(cache=True)
timestamp = request.headers.get('X-Payscribe-Timestamp')
event_id = request.headers.get('X-Payscribe-Event-Id')
signature = request.headers.get('X-Payscribe-Signature')
if not timestamp or not event_id or not signature:
return ('', 401)
signed_payload = f'{timestamp}.{event_id}.'.encode() + payload
expected = 'v1=' + hmac.new(
os.environ['PAYSCRIBE_WEBHOOK_SECRET'].encode(), signed_payload, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return ('', 401)
# Insert event_id under a unique constraint, queue work, then return 200.
return ('', 200)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strings"
)
func payscribeWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil { http.Error(w, "invalid body", http.StatusBadRequest); return }
timestamp := r.Header.Get("X-Payscribe-Timestamp")
eventID := r.Header.Get("X-Payscribe-Event-Id")
signature := r.Header.Get("X-Payscribe-Signature")
if timestamp == "" || eventID == "" || signature == "" { http.Error(w, "invalid signature", http.StatusUnauthorized); return }
mac := hmac.New(sha256.New, []byte(os.Getenv("PAYSCRIBE_WEBHOOK_SECRET")))
mac.Write([]byte(timestamp + "." + eventID + "."))
mac.Write(rawBody)
received, err := hex.DecodeString(strings.TrimPrefix(signature, "v1="))
if err != nil || !hmac.Equal(mac.Sum(nil), received) { http.Error(w, "invalid signature", http.StatusUnauthorized); return }
// Insert eventID under a unique constraint, queue work, then acknowledge.
w.WriteHeader(http.StatusOK)
}
Webhook IP addresses
Whitelist these IPs in your firewall to receive webhooks from Payscribe:
18.133.55.102
Retry policy
Payscribe retries failed deliveries using exponential backoff:
| Attempt | Delay before retry |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 60 seconds |
| 3rd retry | 120 seconds |
| 4th retry | 240 seconds |
| 5th retry | 480 seconds (8 minutes) |
Maximum retries: 6 (configurable in Settings > Webhooks > Advanced). After exhausting all retries, the webhook is marked as dead.
Stuck webhooks: If a webhook stays in sending state for more than 5 minutes, it is automatically reset and re-queued.
Alerting: If more than 5 webhooks fail within an hour (configurable threshold), Payscribe sends an alert email to the address configured in webhook settings.
Delivery-safe processing
Webhooks can be retried. Build consumers to be duplicate-safe and do not depend on delivery order across event types. Persist X-Payscribe-Event-Id with a unique constraint before performing fulfilment, ledger updates, or customer notifications. A duplicate insert should be treated as a successful no-op and still return 200 OK.
Return 200 OK only after the event is durably accepted for processing. Queue slow work, then use API and dashboard records to reconcile the final transaction state.
Decide what to do with an event
Use the event name and its verified payload to select an action. Do not treat every event as a fulfilment signal.
| Event family | Treat it as | Safe consumer action |
|---|---|---|
accounts.payment.status, payment_link.paid, invoice.paid | A collection outcome | Reconcile the payment against your internal order or invoice, then fulfil once when the final state is confirmed. |
payouts.created | A payout lifecycle update | Persist the reference and provider state. Reconcile before telling a recipient that a payout is final. |
bills.created, bills.status, bills.updated | A bill fulfilment update | Match the event to the original ref; display a token, PIN, or success only after the relevant final outcome. |
cards.auth.*, cards.adjusted.* | A card authorization or adjustment | Record the event and reconcile card/transaction state. An authorization and a settlement are distinct lifecycle stages. |
savings.plans.* | A savings-plan lifecycle update | Update the plan state and reconcile any related contribution or withdrawal before showing a completed balance. |
stablecoin.deposit.settled | A confirmed deposit settlement | Match the address, asset, network, and transaction data before crediting the corresponding wallet state once. |
For an unknown event type, persist it safely, alert or log for review, and avoid fulfilment until the event is documented. New event types can be added without changing an existing integration.
Sample payload
{
"event_id": "evt_001",
"event_type": "payouts.created",
"reference": "ref_abc123",
"amount": 10000,
"status": "processing",
"bank_code": "058",
"account_number": "0123456789"
}
Complete event catalog
Collections
| Event | Description |
|---|---|
accounts.payment.status | Inbound payment detected, credited to virtual account |
accounts.simulation | Simulate transfer executed on the live test path (the sandbox simulate-transfer endpoint returns without dispatching) |
accounts.payment.status payload
{
"event_id": "evt_002",
"event_type": "accounts.payment.status",
"trans_id": "txn_a1b2c3d4",
"amount": 5000,
"fee": 32.5,
"currency": "NGN",
"transaction": {
"session_id": "sess_xyz",
"date": "2026-07-29 10:30:00",
"bank_name": "GTBank",
"bank_code": "058",
"sender_account": "0123456789",
"sender_name": "John Doe",
"amount": 5000,
"currency": "NGN",
"description": "Payment for invoice INV-001"
},
"customer": {
"id": "cus_abc123",
"name": "John Doe",
"number": "1234567890",
"bank": "9psb",
"account_id": "va_001",
"account_type": "static"
},
"transaction_hash": "ABC123DEF...",
"created_at": "2026-07-29 10:30:00"
}
Transfers
| Event | Description |
|---|---|
payouts.created | Transfer initiated and pending processing |
payouts.created payload (single transfer)
{
"event_id": "evt_003",
"event_type": "payouts.created",
"ref": "ref_001",
"trans_id": "txn_pay_001",
"session_id": "sess_abc",
"amount": 50000,
"fee": 250,
"total": 50250,
"beneficiary": {
"bank": "058",
"bank_name": "GTBank",
"account_number": "0123456789",
"account_name": "Jane Doe"
},
"currency": "NGN",
"narration": "Payment for services",
"status": "success",
"created_at": "2026-07-29 10:30:00"
}
payouts.created payload (batch)
{
"event_id": "evt_004",
"event_type": "payouts.created",
"batch_id": "batch_001",
"status": "processing",
"gross": 500000,
"count": 10,
"created_at": "2026-07-29 10:30:00"
}
Bills
| Event | Description |
|---|---|
bills.created | Bill payment request created |
bills.status | Bill payment status updated |
bills.updated | Bill payment updated (e.g., delivery receipt) |
bills.batch.status | Batch bill payment status update |
bills.status payload
{
"event_id": "evt_005",
"event_type": "bills.status",
"trans_id": "txn_bill_001",
"transaction_status": "success",
"remark": "MTN VTU PIN: 12345678901234567890",
"service": "Airtime",
"product": "MTN",
"ref_id": "ref_001",
"amount": 500,
"total_charge": 522.25,
"discount": 22.25,
"created_at": "2026-07-29 10:30:00",
"updated_at": "2026-07-29 10:30:05"
}
Cards
| Event | Description |
|---|---|
cards.auth.activation | Contactless card activated at POS |
cards.auth.approved | Card authorization approved |
cards.auth.settled | Card authorization settled (captured) |
cards.auth.declined | Card authorization declined |
cards.auth.verified | Card verification ($1 auth) approved |
cards.adjusted.refund | Card transaction refunded |
cards.adjusted.crossborder | Cross-border transaction settled |
cards.adjusted.terminated | Card terminated |
cards.adjusted.{status} | Card status changed (frozen / unfrozen) |
cards.auth.approved payload
{
"event_id": "evt_006",
"event_type": "cards.auth.approved",
"trans_id": "txn_card_auth_001",
"customer": {
"id": 42
},
"auth_currency": "USD",
"auth_country": "US",
"auth_amount": 49.99,
"mcc": "5812",
"acceptor_name": "AMAZON.COM",
"card": {
"id": "card_abc123",
"first_six": "411111",
"last_four": "1111",
"prev_balance": 500,
"balance": 450.01
},
"created_at": "2026-07-29 10:30:00"
}
cards.auth.declined payload
{
"event_id": "evt_007",
"event_type": "cards.auth.declined",
"trans_id": "txn_card_dec_001",
"customer": {
"id": 42
},
"acceptor_name": "NETFLIX.COM",
"auth_currency": "USD",
"auth_amount": 15.99,
"auth_country": "US",
"reason": "No sufficient funds",
"card": {
"id": "card_abc123",
"first_six": "411111",
"last_four": "1111",
"prev_balance": 10,
"balance": 10
},
"created_at": "2026-07-29 10:30:00"
}
cards.adjusted.refund payload
{
"event_id": "evt_008",
"event_type": "cards.adjusted.refund",
"trans_id": "txn_ref_001",
"customer": {
"id": 42
},
"amount": 49.99,
"acceptor_name": "AMAZON.COM",
"auth_currency": "USD",
"auth_country": "US",
"reason": "Refund",
"card": {
"id": "card_abc123",
"first_six": "411111",
"last_four": "1111",
"prev_balance": 450.01,
"balance": 500
},
"created_at": "2026-07-29 10:30:00"
}
Payment Links
| Event | Description |
|---|---|
payment_link.paid | Payment link has been paid |
payment_link.paid payload
{
"event_id": "evt_009",
"event_type": "payment_link.paid",
"link_id": "link_abc123",
"trans_id": "txn_pl_001",
"amount": 25000,
"currency": "NGN",
"customer": {
"name": "John Doe",
"email": "john@example.com",
"phone": "08012345678"
},
"created_at": "2026-07-29 10:30:00"
}
Invoices
| Event | Description |
|---|---|
invoice.sent | Invoice emailed to customer |
invoice.paid | Invoice paid in full |
invoice.partially_paid | Partial payment received |
invoice.overdue | Invoice past due date |
invoice.paid payload
{
"event_id": "evt_010",
"event_type": "invoice.paid",
"invoice_id": "inv_abc123",
"trans_id": "txn_inv_001",
"amount_paid": 50000,
"total_paid": 50000,
"total_amount": 50000,
"currency": "NGN",
"customer": {
"name": "John Doe",
"email": "john@example.com"
},
"created_at": "2026-07-29 10:30:00"
}
Savings
| Event | Description |
|---|---|
savings.plans.created | Savings plan created |
savings.plans.paused | Savings plan paused / resumed / cancelled |
savings.plans.created payload
{
"event_id": "evt_011",
"event_type": "savings.plans.created",
"id": "plan_abc123",
"saving_id": 42,
"title": "Vacation Fund",
"type": "target",
"currency": "NGN",
"frequency": "weekly",
"status": "active",
"next_run_at": "2026-08-05 10:00:00",
"customer": {
"id": "cus_abc123",
"name": "John Doe",
"email": "john@example.com"
}
}
savings.plans.paused payload
{
"event_id": "evt_012",
"event_type": "savings.plans.paused",
"id": "plan_abc123",
"status": "paused",
"updated_at": "2026-07-29T10:30:00Z"
}
Issuing
| Event | Description |
|---|---|
issuing.created.successful | Card created successfully (including stablecoin-funded orders, once the deposit is confirmed) |
issuing.created.successful payload
{
"event_id": "evt_013",
"event_type": "issuing.created.successful",
"trans_id": "txn_iss_001",
"ref": "ref_001",
"card": {
"id": "card_abc123",
"card_type": "virtual",
"currency": "USD",
"brand": "VISA",
"name": "John Doe",
"first_six": "411111",
"last_four": "1111",
"masked": "411111 **** **** 1111",
"expiry": "06/28",
"billing": {
"street": "123 Main St",
"city": "Lagos",
"country": "NG"
},
"created_at": "2026-07-29 10:30:00"
},
"customer": {
"id": "cus_abc123",
"name": "John Doe"
}
}
Stablecoin
| Event | Description |
|---|---|
stablecoin.deposit.settled | Stablecoin deposit settled and credited |
stablecoin.card.created | Card funded via stablecoin and created |
stablecoin.card.topped_up | Existing card topped up via stablecoin |
stablecoin.deposit.settled payload
{
"event_id": "evt_014",
"event_type": "stablecoin.deposit.settled",
"address": "0xabc123...",
"txid": "0xblockchain_tx_hash",
"trans_id": "txn_stable_001",
"asset": "USDT",
"network": "BEP20",
"chain": "BSC",
"amount": 1000,
"amount_cleared": 997.5,
"rate": 1500,
"usd_wallet_settlement": false,
"off_ramp_fee": 2.5,
"fee": 14.96,
"amount_credited": 1473810,
"customer_id": 42,
"settled_at": "2026-07-29 10:30:00",
"created_at": "2026-07-29 10:30:00"
}
Common mistakes
| Mistake | Fix |
|---|---|
| Re-serializing the payload before verification | Use the exact raw body: file_get_contents('php://input') (PHP) or req.body with express.raw() (Express) |
| Using API key instead of webhook secret | Use the secret from Settings > Webhooks, not your API key |
| Not returning 200 within 5 seconds | Acknowledge immediately, process asynchronously |
| Not checking timestamp staleness | Reject events older than 5 minutes to prevent replay attacks |
Best practices
- Acknowledge quickly — Return
200 OKimmediately, process asynchronously - Idempotency — Check for duplicate
X-Payscribe-Event-Idvalues; webhooks may be delivered more than once - Log everything — Log the raw payload, signature, and verification result for debugging
- Monitor failures — Check webhook logs in the dashboard for failed deliveries
- Reject stale events — Check
X-Payscribe-Timestampand reject events older than 5 minutes
Was this page helpful?