Skip to main content

Webhooks

Webhooks allow 4pay.online to push real-time transaction status updates to your server — no polling required.


How it works​

  1. Your platform operator sets a notification URL on your terminal
  2. When the transaction status changes, the platform sends a signed POST to that URL
  3. Your server answers with any 2xx to acknowledge receipt
  4. 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​

FieldTypeDescription
event_idstringUnique per event. Use it as your deduplication key
timestampintegerUnix seconds when the delivery was formed
typestringtransaction.<status>
dataobjectThe transaction payload

data fields​

FieldTypeDescription
typestringTransaction type: payment, payout
txidstringYour original order ID
idstringPlatform transaction UUID
amountstringOriginal amount in smallest currency unit
amount_deststringAmount credited to you (after fees)
amount_with_feestringAmount including platform fee
statusstringNew transaction status
error_descriptionstringError 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​

StatusFinalMeaning
creatednoTransaction created, awaiting processing
chargingnoProcessing in progress
action_requirednoPayer action required
chargedyesSettled successfully
rejectedyesDeclined by limits, anti-fraud or the provider
failedyesProcessing error, including a cancellation before settlement
reversedyesA settled transaction was returned
refundednoRefund 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;
  • rejected and failed — no settlement, the payer's funds were released;
  • reversed — settlement happened and was returned. It can only follow charged, so your business logic has already run: on reversed you 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.

Respond quickly

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.