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

# Payin callbacks

> We POST to your URL when a payment settles. Signed, retried until delivered, and the only event you should fulfil an order on.

<Info>
  This page covers **payin** callbacks. [Payout callbacks](/callbacks) use the same URL, the same
  headers and the same signature scheme — so your verification code is shared — but they carry a
  different body and, unlike payins, they are **not** retried.
</Info>

A payin callback is the moment your customer's money became your balance. Everything else in the
flow is a hint: the browser redirect can be lost, the status endpoint is a snapshot you have to ask
for. The callback is the fact, and it is pushed to you.

<Warning>
  **Never fulfil an order on the browser redirect to `success_url`.** A customer can close the tab,
  lose signal, or pay and never come back. Fulfil here.
</Warning>

## When we send a callback

We send one when a payment reaches a settled state — meaning the funds arrived and your balance has
already moved:

| `event`                     | `status`    | What it means                                        |
| --------------------------- | ----------- | ---------------------------------------------------- |
| `gateway_payment.paid`      | `paid`      | Settled for the expected amount                      |
| `gateway_payment.underpaid` | `underpaid` | Settled for less than expected. **Credited anyway**  |
| `gateway_payment.overpaid`  | `overpaid`  | Settled for more than expected. **Credited in full** |

We do **not** send callbacks for in-progress states (`created`, `awaiting_payment`) or for `expired`
and `failed`. Nothing moved, so there is nothing to fulfil.

<Note>
  `expired` is not final. If a customer pays a lapsed link late, the payment still settles and you
  still get this callback — see [expiry is not final](/payins#expiry-is-not-final).
</Note>

## Registering a URL

Set your **Callback URL** in [Developer Tools](https://app.pontisglobe.com/developer-tools). This is
the same field payout callbacks use — one URL receives both products. **Tell them apart by the
`event` field**, which is present on payin callbacks and absent on payout ones.

Requirements:

* `https://` only
* Must resolve to a **public** address. Private and internal addresses are rejected
* Redirects are not followed. A `3xx` is a failed delivery, not a hop

<Note>
  Your callback host is validated when you save it **and** again at delivery time, so a URL that
  stops meeting these requirements later simply stops receiving callbacks.
</Note>

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

{
  "event": "gateway_payment.paid",
  "gateway_payment_id": "0f8b2c1e-4a77-4d31-9f0e-2b6c8d5a1e33",
  "merchant_reference": "ORDER-1042",
  "status": "paid",
  "requested_amount": "100.00",
  "requested_currency": "USDT",
  "received_amount": "99.80",
  "merchant_fee_amount": "0.50",
  "credited_amount": "99.50",
  "credited_currency": "USDC",
  "paid_at": "2026-08-14T09:12:44.000Z"
}
```

| Field                 | Type           | Description                                                                      | Example                    |
| --------------------- | -------------- | -------------------------------------------------------------------------------- | -------------------------- |
| `event`               | string         | Which of the three events this is                                                | `gateway_payment.paid`     |
| `gateway_payment_id`  | string (uuid)  | Our id for the payment                                                           | `0f8b2c1e-…`               |
| `merchant_reference`  | string         | The order id **you** created it with. Key your fulfilment on this                | `ORDER-1042`               |
| `status`              | string         | The payment status. Always matches `event`                                       | `paid`                     |
| `requested_amount`    | string         | What you asked for                                                               | `100.00`                   |
| `requested_currency`  | string         | What you priced the order in                                                     | `USDT`                     |
| `received_amount`     | string \| null | Our internal settlement figure. Traceability only — **do not reconcile on it**   | `99.80`                    |
| `merchant_fee_amount` | string \| null | Total we charged you. Denominated in `credited_currency`                         | `0.50`                     |
| `credited_amount`     | string \| null | **What reached your balance**, after our fee. Denominated in `credited_currency` | `99.50`                    |
| `credited_currency`   | string         | The asset your customer sent, and what the two amounts above are in              | `USDC`                     |
| `paid_at`             | string \| null | ISO 8601 UTC timestamp of settlement                                             | `2026-08-14T09:12:44.000Z` |

<Warning>
  **Reconcile against `credited_amount` and `credited_currency` together.** `requested_currency` is
  what you invoiced in; `credited_currency` is what you were actually paid in, and the two differ
  whenever your customer picks an asset other than the one you priced in. `credited_amount` alone is
  ambiguous the moment you accept more than one asset — see [the three
  currencies](/payins#the-three-currencies-on-a-payment).
</Warning>

All amounts are **decimal strings**, never numbers. Parse them with a decimal type, not a float.

<Note>
  The payload carries only what you need to fulfil the order. Our fee reaches you as a single total.
</Note>

## Verify the signature

Recompute the HMAC over `${timestamp}.${rawBody}` and compare in constant time. Reject anything
stale or mismatched.

The scheme is **byte-for-byte identical to payout callbacks**, so if you already handle those,
reuse that code unchanged — the full snippets in Node and Python are on
[Payout callbacks → Verify the signature](/callbacks#verify-the-signature).

```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)
}
```

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

## Retries and delivery failures

Unlike payouts, payin callbacks are **retried**:

|                     | Payout callbacks | Payin callbacks               |
| ------------------- | ---------------- | ----------------------------- |
| Retried?            | No               | **Yes — up to 8 attempts**    |
| Backoff             | —                | Exponential, capped at 1 hour |
| Timeout per attempt | 10 s             | 10 s                          |
| Sent for sandbox?   | No               | **Yes**                       |

Any non-2xx response, a timeout, a redirect, or a transport failure is a failed delivery and is
retried. Respond **2xx as soon as you have durably recorded the event** — do your fulfilment work
afterwards, not while we are waiting on the socket.

We stop early, without using the remaining attempts, when retrying cannot change the answer:

| Reason                        | Why we stop                                              |
| ----------------------------- | -------------------------------------------------------- |
| No callback URL registered    | Nothing to deliver to                                    |
| API consumer account inactive | Deliveries are suspended with the account                |
| URL fails our network checks  | It resolved to a private or otherwise disallowed address |

<Info>
  A missed callback is not a lost payment. If we cannot reach you, the payment is still settled and
  still credited — read it back with [the status endpoint](/api-reference/get-payin-status), which
  is exactly what it is there for.
</Info>

## Idempotency

Because deliveries are retried, your handler will eventually see the same event twice. Assume **at
least once**, never exactly once.

* Key on `merchant_reference` (your own order id) or `gateway_payment_id`
* Every callback also carries a unique `x-pontis-event-id` you can store to dedupe on
* On a duplicate, do nothing and still return **2xx** — a non-2xx schedules another retry

```js Node theme={null}
async function handleCallback(event) {
  // Deduplicate before doing anything with side effects.
  const alreadyHandled = await orders.hasSettlement(event.merchant_reference)
  if (alreadyHandled) return 200

  await orders.recordSettlement({
    reference: event.merchant_reference,
    amount: event.credited_amount, // decimal string
    currency: event.credited_currency, // NOT requested_currency
  })
  return 200
}
```

## Handle all three settled statuses

All three moved your balance, but they do **not** mean the same thing and must not share a branch.

| Event                       | Did the customer pay in full? | Safe to fulfil?                       |
| --------------------------- | ----------------------------- | ------------------------------------- |
| `gateway_payment.paid`      | Yes                           | **Yes**                               |
| `gateway_payment.overpaid`  | Yes, and then some            | **Yes** — you owe them the difference |
| `gateway_payment.underpaid` | **No — they paid less**       | **No** — your decision, not ours      |

<Warning>
  **Never auto-fulfil on `underpaid`.** There is no minimum: a customer who sends 10 USDT against a
  100 USDT order produces an `underpaid` callback and moves your balance by roughly 10 USDT. If that
  shares a branch with `paid`, you ship the order for a tenth of the price. Compare
  `credited_amount` against `requested_amount` before you act.
</Warning>

We credit an underpayment rather than refusing it because the money genuinely arrived and is yours;
refusing the ledger entry would hide a real balance, not prevent one. What to do about the shortfall
is yours to decide — part-ship, hold, ask for the remainder, or refund.

```js Node theme={null}
switch (event.event) {
  case 'gateway_payment.paid':
    return fulfil(event.merchant_reference)

  case 'gateway_payment.overpaid':
    // Paid in full and then some. Fulfil, and settle the excess with them.
    return fulfil(event.merchant_reference)

  case 'gateway_payment.underpaid':
    // NOT paid in full. Never fulfil here without your own check — the shortfall
    // can be any size, from rounding dust to 90% of the order.
    return holdForReview(event.merchant_reference, {
      requested: event.requested_amount,
      credited: event.credited_amount,
      currency: event.credited_currency,
    })
}
```

## Testing your handler

<Warning>
  **Callbacks are not sent in sandbox** — for payins or payouts. Sandbox simulates the create and
  status endpoints without storing anything, so there is no settlement event to notify you of.
</Warning>

You can still build and verify most of the handler before going live:

* **The signature check** is pure — feed it a body and a header you construct yourself using your
  HMAC secret, and assert it accepts a good one and rejects a tampered one. No network needed.
* **Idempotency** is testable the same way: deliver the same event twice to your own endpoint and
  assert the second is a no-op.
* **The settled statuses** — `paid`, `underpaid`, `overpaid` — can be exercised through
  [sandbox trigger codes](/sandbox-payins#trigger-codes) on `getPayinStatus`, which is enough to prove your
  branching before a real payment reaches it.

While developing, point your callback URL at any publicly reachable HTTPS endpoint — a request-bin
service or an HTTPS tunnel in front of your local server both work. We do not accept `localhost` or
private addresses.
