Skip to main content

When we send a callback

We send a callback whenever a live transaction reaches one of these final states:
statusWhy
completedPayment was successfully delivered
failedPayment could not be delivered
reversedA previously delivered transaction was returned
rejectedTransaction was rejected before processing
canceledTransaction 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
}
FieldNotes
transaction_idThe same ID returned when the transaction was created
statusOne of completed, failed, reversed, rejected, canceled
status_messageShort 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.