When we send a callback
We send a callback whenever a live transaction reaches one of these final states:
status | Why |
|---|
completed | Payment was successfully delivered |
failed | Payment could not be delivered |
reversed | A previously delivered transaction was returned |
rejected | Transaction was rejected before processing |
canceled | Transaction was canceled before processing |
We do not send callbacks for in-progress states (pending, pending_approval, processing) — poll getPayoutStatus to observe those.
Callbacks are not sent for sandbox transactions.
Registering a URL
Set your Callback URL in Developer Tools.
Requirements:
https:// only
- Must resolve to a public IP (private, loopback, link-local, and cloud-metadata addresses are rejected — at save time and again at delivery time)
The request we send
POST https://your-callback-url HTTP/1.1
content-type: application/json
user-agent: Pontis-Callback/1
x-pontis-timestamp: 1748023400
x-pontis-signature: sha256=2f8a9b…
x-pontis-event-id: 3f2a-1b9c-...
{
"transaction_id": "029b2038-6166-4bea-80a9-f1a2425a85eb",
"status": "completed",
"status_message": null
}
| Field | Notes |
|---|
transaction_id | The same ID returned when the transaction was created |
status | One of completed, failed, reversed, rejected, canceled |
status_message | Short reason on failure or cancellation, null on success |
Verify the signature
Recompute the HMAC and compare in constant time. Reject anything stale or mismatched.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(req, rawBody) {
const ts = req.headers['x-pontis-timestamp']
const sig = req.headers['x-pontis-signature'] // "sha256=<hex>"
if (typeof sig !== 'string' || !sig.startsWith('sha256=')) return false
// 1) freshness — reject if older than 5 minutes
const age = Math.floor(Date.now() / 1000) - Number(ts)
if (!Number.isFinite(age) || age > 300) return false
// 2) signature
const key = Buffer.from(HMAC_SECRET, 'base64url')
const expected = createHmac('sha256', key).update(`${ts}.${rawBody}`).digest('hex')
const a = Buffer.from(sig.slice(7), 'hex')
const b = Buffer.from(expected, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
Sign over the raw bytes you received, not a re-serialized JSON object. Re-serializing changes whitespace/ordering and the signature won’t match.
Idempotency
Every callback carries a unique x-pontis-event-id. We do not currently retry, but you should still:
- Store recent event IDs (e.g. last 24 hours)
- Reject duplicates silently with a
200 OK
- Always respond with 2xx even on duplicates — non-2xx is logged as a delivery failure
Delivery failures
If your endpoint returns non-2xx or times out (10 s), the failure is logged on our side and the callback is not retried. Fall back to polling getPayoutStatus if you suspect a missed callback.
Local testing
While you’re developing, point the callback URL at a public service like webhook.site or a tunnel (cloudflared, ngrok) in front of your local server. We do not accept private IPs or localhost URLs — the address must resolve to a public endpoint over HTTPS.