Webhooks
Webhooks allow 4pay.online to push real-time transaction status updates to your server — no polling required.
How it works
- Your platform operator sets a notification URL on your terminal
- When the transaction status changes, the platform sends a signed
POSTto that URL - Your server answers with any
2xxto acknowledge receipt - Anything else — a non-2xx, a timeout, a connection error — triggers a retry
Setting up a webhook endpoint
The notification URL is a terminal setting, configured once in the Admin
Console (notify_url), and it applies to every transaction on that terminal.
There is no per-request notification field: a notifyUrl in the create body is
ignored. If you need different destinations for different traffic, use
different terminals.
Alongside the URL, set a webhook secret on the terminal. Without it the platform sends no signature, and you have no way to tell a genuine notification from a forged one.
A secret can also be set on the partner account rather than the terminal; the terminal's own secret wins where both exist. Ask your operator which one signs your deliveries — verifying against the wrong secret fails exactly like a forgery.
Webhook payload
The transaction data arrives wrapped in an envelope. Read your fields from
data, not from the top level.
{
"event_id": "4d9d0e6c-1d0a-4a2e-9a2f-0c9a1b0e8f21",
"timestamp": 1772668800,
"type": "transaction.charged",
"data": {
"type": "payment",
"txid": "order-12345",
"id": "550e8400-e29b-41d4-a716-446655440000",
"amount": "1000",
"amount_dest": "950",
"amount_with_fee": "1050",
"status": "charged",
"error_description": ""
}
}
Envelope
| Field | Type | Description |
|---|---|---|
event_id | string | Unique per event. Use it as your deduplication key |
timestamp | integer | Unix seconds when the delivery was formed |
type | string | transaction.<status> |
data | object | The transaction payload |
data fields
| Field | Type | Description |
|---|---|---|
type | string | Transaction type: payment, payout |
txid | string | Your original order ID |
id | string | Platform transaction UUID |
amount | string | Original amount in smallest currency unit |
amount_dest | string | Amount credited to you (after fees) |
amount_with_fee | string | Amount including platform fee |
status | string | New transaction status |
error_description | string | Error description (empty on success) |
Amounts are decimal strings, not numbers — an 18-decimal asset does not fit a JSON number safely.
Status values in webhooks
| Status | Final | Meaning |
|---|---|---|
created | no | Transaction created, awaiting processing |
charging | no | Processing in progress |
action_required | no | Payer action required |
charged | yes | Settled successfully |
rejected | yes | Declined by limits, anti-fraud or the provider |
failed | yes | Processing error, including a cancellation before settlement |
reversed | yes | A settled transaction was returned |
refunded | no | Refund completed — finality is still under review |
The list above is not closed: rails carry their own intermediate states
(cancelled, payment_suspended, pre_authorized_3ds and others), and one may
reach you. Treat any status you do not recognise as non-final and keep
waiting — a strict enum will fail on the first uncommon rail.
Run your business logic only on a final status. The three final outcomes differ in what happened to the money:
charged— settlement happened, funds are yours;rejectedandfailed— no settlement, the payer's funds were released;reversed— settlement happened and was returned. It can only followcharged, so your business logic has already run: onreversedyou need to undo it (recall the delivery, disable the service).
A cancellation before settlement arrives as failed, never as reversed.
Verifying the signature
Every signed delivery carries three headers:
x-webhook-event-id: 4d9d0e6c-1d0a-4a2e-9a2f-0c9a1b0e8f21
x-webhook-timestamp: 1772668800
x-webhook-signature: 6b1f… HMAC-SHA256, hex
The signature is HMAC-SHA256 over the string <timestamp>.<raw body>, keyed
with your terminal's webhook secret. Two details decide whether your check
works:
- sign the raw request body exactly as received — re-serialising the JSON changes the bytes and breaks the comparison;
- sign the whole envelope, not
data.
Reject a delivery whose timestamp is more than five minutes away from your
own clock, and compare signatures in constant time.
Responding to a webhook
Answer with any 2xx. The body is ignored — an empty 200 is a valid
acknowledgement.
Anything else — a non-2xx status, a timeout, a refused connection — counts as a failure and schedules a retry.
Process the webhook asynchronously. Acknowledge receipt immediately and handle business logic in a background job. Long-running handlers may time out and cause duplicate deliveries.
Retry policy
Failed deliveries are retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. After the sixth attempt the delivery is parked for manual inspection and no longer retried automatically.
Make your handler idempotent: the same event may arrive more than once.
Deduplicate on event_id, not on the transaction UUID — one transaction
produces several events over its life, and they are legitimately different.
Deliveries are not ordered. If ordering matters to you, read the transaction
back with GET /api/v1/transactions/:id instead of trusting arrival order.
Testing webhooks locally
Use a tunnel tool (ngrok, localtunnel, Cloudflare Tunnel) to expose your local server to the internet during development:
ngrok http 3000
# → Forwarding: https://abc123.ngrok.io → localhost:3000
Ask your operator to set https://abc123.ngrok.io/webhooks/payment as the
notify_url on your test terminal.
See Sandbox & Testing for test transaction instructions.
Example handler (Node.js)
const express = require('express');
const crypto = require('crypto');
const app = express();
app.post(
'/webhooks/payment',
express.raw({ type: 'application/json' }), // raw body — needed for the signature
(req, res) => {
const timestamp = req.get('x-webhook-timestamp');
const signature = req.get('x-webhook-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
// Compare lengths BEFORE timingSafeEqual: it throws a RangeError on buffers
// of different sizes, so a forged header would produce a 500 with a stack
// trace instead of a 401.
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!ok) return res.sendStatus(401);
const { event_id, data } = JSON.parse(req.body);
// Deduplicate on event_id, then hand off to a background job
// processPaymentEvent(event_id, data);
console.log(`Transaction ${data.txid} (${data.id}): ${data.status}`);
res.sendStatus(200);
}
);
app.listen(3000);
Example handler (Python)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/payment', methods=['POST'])
def webhook():
raw = request.get_data() # raw body — needed for the signature
timestamp = request.headers.get('x-webhook-timestamp', '')
signature = request.headers.get('x-webhook-signature', '')
expected = hmac.new(
os.environ['WEBHOOK_SECRET'].encode(),
f"{timestamp}.{raw.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return '', 401
if abs(time.time() - int(timestamp or 0)) > 300:
return '', 401
envelope = json.loads(raw)
data = envelope['data']
# Deduplicate on envelope['event_id'], then hand off to a background job
print(f"Transaction {data['txid']} ({data['id']}): {data['status']}")
return '', 200
The formal contract
This page is the practical guide. The normative definition of the envelope, the signature, the retry schedule and the status vocabulary is the Universal Transaction Protocol.