Skip to content

Webhooks overview

When a transaction settles, the gateway POSTs a JSON payload to the notify_url you supplied on /payments/collect (or /payments/send). This is how you find out about asynchronous outcomes without polling.

POST /pdirect/webhook HTTP/1.1
Host: merchant.example.com
Content-Type: application/json
User-Agent: Pdirect-Gateway/1.0
{
"transaction_id": "txn_8f3a4c2e9b1d7a6f5c0e8d",
"customer_reference": "cust_abc123",
"status": "approved",
"amount": "12.50",
"currency": "usd",
"completed_at": "2026-05-05T10:15:08Z"
}

Respond with 2xx (typically 200 OK) within 10 seconds to acknowledge. Anything else — 3xx, 4xx, 5xx, timeout — is treated as a delivery failure.

Your notify_url host must be on a per-merchant allow-list configured in the dashboard. Empty list rejects all (fail-closed) — until you populate it, the gateway will refuse to send webhooks for any of your transactions and /payments/collect will return error_code: "1110" for the field.

The allow-list rejects:

  • http:// URLs in production environments (only https:// allowed)
  • Hosts not on the explicit allow-list
  • Schemes other than http / https (javascript:, data:, etc.)

Sandbox accepts http:// for local-development convenience.

If your endpoint doesn’t respond 2xx within 10 seconds, the gateway retries with exponential backoff:

AttemptDelay
1(immediate)
25 seconds
315 seconds
460 seconds

After the fourth attempt, the gateway logs the failure and gives up. There is no dead-letter queue or notification of delivery failure today — your endpoint must be reliable, or you must poll GET /payments/status/{transaction_id} for definitive state.

The gateway sends one webhook per state transition, but networks fail and retries can deliver the same event more than once. Make your handler idempotent:

import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
const seenEvents = new Map<string, true>(); // production: use Redis
app.post("/pdirect/webhook", async (req, res) => {
const id = req.body.transaction_id;
const status = req.body.status;
const eventKey = `${id}:${status}`;
if (seenEvents.has(eventKey)) {
res.status(200).send("ok"); // already processed
return;
}
seenEvents.set(eventKey, true);
// do work — credit customer, update order, etc.
await updateOrderStatus(id, status, req.body);
res.status(200).send("ok");
});

For production, use Redis with a TTL of at least 24 hours and key on (transaction_id, status, completed_at) — the gateway emits distinct events per state transition, so you may legitimately get two for the same transaction (processing then approved).

Plan for this. The retry budget is 4 attempts over ~80 seconds. If you’re down longer, you’ll lose those events and must reconcile via polling GET /payments/status/{transaction_id} for every transaction whose state you can’t determine.

For high-stakes flows, a queue between the webhook receiver and your business logic is a good idea: the receiver acks fast, the queue processes when the worker pool is healthy.

import express from "express";
const app = express();
app.use(express.json({ limit: "1mb" }));
app.post("/pdirect/webhook", async (req, res) => {
// TODO: signature verification, when shipped
await processEvent(req.body);
res.status(200).send("ok");
});
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/pdirect/webhook")
async def webhook(req: Request):
body = await req.json()
# TODO: signature verification, when shipped
await process_event(body)
return {"ok": True}
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/pdirect/webhook")
def webhook():
body = request.get_json()
# TODO: signature verification, when shipped
process_event(body)
return jsonify({"ok": True}), 200