> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pontisglobe.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Callbacks

> We POST to your URL when a transaction reaches a final state. Signed, replay-protected, and ready to verify.

## 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.

<Note>
  Callbacks are not sent for sandbox transactions.
</Note>

## Registering a URL

Set your **Callback URL** in [Developer Tools](https://app.pontisglobe.com/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.

<CodeGroup>
  ```js Node theme={null}
  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)
  }
  ```

  ```python Python theme={null}
  import base64, hmac, hashlib, time

  def b64url_decode(s):
      pad = '=' * (-len(s) % 4)
      return base64.urlsafe_b64decode(s + pad)

  def verify(headers, raw_body, hmac_secret):
      ts = headers.get('x-pontis-timestamp', '')
      sig = headers.get('x-pontis-signature', '')
      if not sig.startswith('sha256='):
          return False

      # 1) freshness — reject if older than 5 minutes
      try:
          age = int(time.time()) - int(ts)
      except ValueError:
          return False
      if age > 300:
          return False

      # 2) signature
      key = b64url_decode(hmac_secret)
      expected = hmac.new(key, f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest()
      return hmac.compare_digest(sig[7:], expected)
  ```
</CodeGroup>

<Warning>
  Sign over the **raw bytes** you received, not a re-serialized JSON object. Re-serializing changes whitespace/ordering and the signature won't match.
</Warning>

## 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](https://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.
