Testing webhooks
Local testing with a public tunnel
Your local server is not reachable from the internet, so PAI cannot deliver webhooks to localhost. Use a tunneling tool to expose your local port via a public HTTPS URL.
Using ngrok
-
Install ngrok: ngrok.com/download
-
Start your local server (e.g. on port 3000).
-
Open a tunnel:
ngrok http 3000 -
ngrok prints a public HTTPS URL:
Forwarding https://abc123.ngrok.io -> http://localhost:3000 -
Register that URL as a webhook destination in PAI:
curl -X POST "https://staging-api.payments.ai/v1/webhook-destinations" \-H "Authorization: ApiKey ${API_KEY}" \-H "Content-Type: application/json" \-d '{"url": "https://abc123.ngrok.io/webhooks","auth": {"type": "basic","username": "user","password": "pass"}}' -
Trigger an action in PAI (e.g. create a transaction) and watch events arrive in your local server logs and in the ngrok inspector at
http://localhost:4040.
The ngrok URL changes every time you restart ngrok. Update the webhook destination URL in PAI whenever you restart.
Common errors
Wrong signature
Symptom: signature verification rejects every incoming request.
Cause: HMAC computed on the parsed/re-serialized JSON body instead of the raw bytes.
Fix: read the raw request body before any JSON parsing:
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks', (req, res) => {
const rawBody = req.body; // Buffer, not a parsed object
const signature = req.headers['x-pai-signature'];
if (!verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const payload = JSON.parse(rawBody);
// …
});
PAI keeps retrying even though the event was processed
Cause: the endpoint returned 201, 204, or a redirect instead of exactly 200.
Fix: explicitly return 200:
res.status(200).send();
Handler processes the same event multiple times
Cause: PAI retried delivery because an earlier request timed out.
Fix: deduplicate on deduplicationId before processing. See Best practices.