# Callbacks

A callback is a JSON `POST` that the API sends to your server when a payment is paid or
canceled. It tells you the result without polling. Every callback is signed with the
project secret: verify the signature before trusting the content.

## How it works { #how-it-works }

<div class="pp-seq" data-lanes="Your server|API" markdown>

1. **API.** The payment is closed — `completed` or `canceled`. An event with a new `event_id` is created
2. **API → Your server.** `POST` to the callback URL, signed in `X-Callback-Signature`
3. **Your server.** Verifies the signature and time, looks the `event_id` up among processed ones
4. **Your server ⇢ API.** Answers `2xx` at once, before handling the order
5. **Your server.** Updates the order by `id` and `status` in the background
6. **API → Your server.** No `2xx` — the same event again after 30 and after 60 seconds

</div>

Key points:

- A callback is an **event notification**. The source of truth is the payment in the API:
  `GET /api/v1/payments/{id}`.
- One event may arrive **more than once**; events of one payment may arrive **out of order**.
- One payment may get **several different events**: for example `canceled` and then
  `completed` — see [Status changes after closing](#status-changes).
- There are few attempts — 3 within a minute and a half. Answer `2xx` quickly and keep a
  [reconciliation](#missing) in case a callback does not get through.

## When it arrives { #when }

A callback arrives when a live payment moves to one of two statuses:

| Status | What happened | What to do |
| --- | --- | --- |
| `completed` | Payment confirmed | Fulfil the order for `amount` |
| `canceled` | Not paid: its lifetime ran out, it was canceled, or the payment could not be confirmed after requisites were issued | Do not fulfil; offer to pay again — with a new payment |

The callback does not carry the cancellation reason: for you every case is the same — the
requisites are no longer valid.

A callback **does not arrive**:

- for a payment in `error`. `error` happens only before requisites are issued, and you learn
  about it from the create response or from `GET /api/v1/payments/{id}`. Once requisites are
  issued, a payment ends only as `completed` or `canceled`, and either one brings a callback;
- for intermediate statuses `created`, `processing` and `appeal` (the payment is being
  re-checked — the callback comes with the outcome);
- for test payments (`is_test: true`) — see [Test payments](#test-payments);
- if no callback URL is set on the payment, the project or the merchant — see
  [Where it goes](#url).

### Status changes after closing { #status-changes }

`completed` and `canceled` are final, but a payment re-check or a support decision can
change them. Every change is a **new event with a new `event_id`** and brings a new
callback:

| Was | Received | When |
| --- | --- | --- |
| `canceled` | `completed` | The payer paid after the payment lifetime, or the payment was confirmed later |
| `completed` | `completed` with another `amount` | The payer sent a different amount and it was confirmed. `initial_amount` does not change |
| `completed` | `canceled` | The payment was not confirmed on re-check |
| any | the same status again | The re-check kept the status, or support resent the callback |

Your handler must be able to:

- fulfil an order of a canceled payment if `completed` arrives later;
- recalculate the order if a new `completed` has another `amount`;
- revoke or hold the order if `canceled` arrives after `completed`.

!!! warning "Events may arrive out of order"
    If a callback changes what you have already stored for the payment, re-read it with
    `GET /api/v1/payments/{id}` and apply **the status and amount from the API response**.
    This way a late event cannot roll the order back.

## Where it goes { #url }

The first URL that is set wins:

1. `callback_url` from the create request;
2. the project callback URL (cabinet, Projects and API);
3. the merchant's common callback URL (cabinet, merchant settings).

If no URL is set at any level, no callback is sent: read the result with
`GET /api/v1/payments/{id}`.

URL requirements:

- absolute `https://`, no credentials, up to 2048 characters, not `localhost`;
- the host resolves to public IPs only. If any of its addresses is internal (private
  networks, loopback, link-local, CGNAT `100.64.0.0/10`), no connection is made;
- **redirects are not followed**: a `3xx` response is a failure. Use the final URL.

## Body { #body }

The body is an event about the payment's transition to a status: the main payment fields,
the rate and fee, `event_id` and `project_id`. It has no requisites or USDT amounts — the
full payment is returned by `GET /api/v1/payments/{id}` ([fields](sbp.md#response)).

```json
{
  "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42",
  "event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21",
  "amount": 3400,
  "initial_amount": 3400,
  "status": "completed",
  "currency": "RUB",
  "payment_method": "sbp",
  "bank_code": "sber",
  "merchant_payment_id": "order-42",
  "rate": 11.00,
  "course": 100.00,
  "is_intrabank": false,
  "is_test": false,
  "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"
}
```

| Field | Meaning |
| --- | --- |
| `id` | Payment `id` from the create response |
| `event_id` | Event id. The same in every retry of one event and equal to the `X-Callback-Event-Id` header |
| `status` | Event status: `completed` or `canceled`. On a manual resend, the payment's current closed status, `error` included |
| `amount` | Credited amount in major currency units. After a payment re-check it may differ from `initial_amount` — fulfil the order by `amount` |
| `initial_amount` | Amount from the create request, never changes |
| `merchant_payment_id` | Your order id |
| `rate`, `course` | Your fee, %, and rate — payment currency per 1 USDT. Numbers with two decimals, the same as in [`merchant_information`](sbp.md#merchant-information); `null` if no requisites were issued |
| `is_test` | `true` for a project test payment |
| `project_id` | Project the payment belongs to |
| other fields | As in the [create response](sbp.md#response): `currency`, `payment_method`, `bank_code`, `is_intrabank` |

- There is no error code in a callback, even on a manual resend of a payment in `error`:
  read the reason with `GET`.
- Parse `amount`, `initial_amount`, `rate` and `course` as decimals, not floats.
- New fields may be added: ignore unknown fields.
- The body is built once, when the event is queued, and is byte-for-byte the same in every
  delivery retry. When in doubt about freshness, re-read the payment with `GET`.

## Headers { #headers }

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Callback-Event-Id` | The event's `event_id` |
| `X-Callback-Timestamp` | Unix time of this attempt, seconds |
| `X-Callback-Signature` | `v1=<hex>`; for 24 hours after a secret rotation, `v1=<hex>,v1=<hex>` |

## Signature verification { #signature }

```text
signed_payload = X-Callback-Timestamp + "." + raw_request_body
signature      = hex( HMAC-SHA256( key = the whole project secret, message = signed_payload ) )
```

- The secret is a `whsec_…` string. The HMAC key is **the whole string** as UTF-8,
  `whsec_` included. Nothing needs decoding.
- `hex` is 64 lower-case characters.
- Compute over the **raw body bytes** before parsing JSON. Parsing and re-serializing may
  change the bytes.
- The request is authentic if at least one `v1=` value matches your signature. Compare in
  constant time. Skip values with another scheme (not `v1`).

### Replay protection { #replay }

1. Reject the request if `X-Callback-Timestamp` differs from your clock by more than
   5 minutes. Keep your clock synced with NTP. The timestamp is part of the signature and
   cannot be forged.
2. Keep processed `event_id` values for at least a day. Do not process a repeated
   `event_id` again — just answer `2xx`.

`event_id` identifies an **event**, not a payment and not an attempt:

| What happened | `event_id` | Body | Timestamp and signature |
| --- | --- | --- | --- |
| Delivery retry after an error or a non-`2xx` answer | same | byte-for-byte same | new |
| New payment status | new | new | new |
| Manual resend by support | new, even if the status did not change | new | new |

Decide the payment outcome by `id` and `status`; use `event_id` only to avoid processing
one delivery twice.

### Verification functions { #verify-functions }

The functions check the signature and freshness and are tested on the vector below.

=== "curl"

    For debugging in a terminal. In your application use your language's function:
    shell cannot compare strings in constant time.

    ```bash
    # Payment API callback signature check (HMAC-SHA256) for debugging in a terminal.
    # In your application verify with code that compares in constant time.
    # POSIX sh + openssl. Load with: . ./verify_callback.sh
    
    # api_callback_signature SECRET TIMESTAMP BODY_FILE — the expected signature (hex).
    api_callback_signature() {
      { printf '%s.' "$2"; cat "$3"; } | openssl dgst -sha256 -hmac "$1" -r | cut -d' ' -f1
    }
    
    # api_verify_callback SECRET TIMESTAMP SIGNATURE_HEADER BODY_FILE [NOW]
    # Exit code 0: the signature matches and the timestamp is fresh (±300 seconds).
    api_verify_callback() {
      api_now=${5:-$(date +%s)}
      api_skew=$((api_now - $2)); [ "$api_skew" -lt 0 ] && api_skew=$((-api_skew))
      [ "$api_skew" -le 300 ] || return 1
      api_expected=$(api_callback_signature "$1" "$2" "$4")
      for api_part in $(printf '%s' "$3" | tr ',' ' '); do
        [ "$api_part" = "v1=$api_expected" ] && return 0
      done
      return 1
    }
    
    ```

=== "PHP"

    ```php
    <?php
    // Payment API callback signature check: HMAC-SHA256. PHP 8.1+, no extensions.
    
    declare(strict_types=1);
    
    const CALLBACK_MAX_SKEW = 300; // reject callbacks more than 5 minutes old or ahead
    
    /**
     * true if the callback is authentic and fresh.
     * $secret    — the whole project secret, including the whsec_ prefix;
     * $timestamp — the X-Callback-Timestamp header, $signature — X-Callback-Signature;
     * $body      — the raw body: file_get_contents('php://input'), before json_decode.
     */
    function api_verify_callback(string $secret, string $timestamp, string $signature, string $body, ?int $now = null): bool
    {
        if (!ctype_digit($timestamp)) {
            return false;
        }
        if (abs(($now ?? time()) - (int) $timestamp) > CALLBACK_MAX_SKEW) {
            return false;
        }
        $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
        foreach (explode(',', $signature) as $part) {
            [$scheme, $value] = array_pad(explode('=', trim($part), 2), 2, '');
            if ($scheme === 'v1' && hash_equals($expected, $value)) {
                return true;
            }
        }
        return false;
    }
    
    ```

=== "Python"

    ```python
    """Payment API callback signature check: HMAC-SHA256. Standard library only."""
    import hashlib
    import hmac
    import time
    
    MAX_SKEW_SECONDS = 300  # reject callbacks more than 5 minutes old or ahead
    
    
    def verify_callback(secret: str, headers, body: bytes, now: float | None = None) -> bool:
        """True if the callback is authentic and fresh.
    
        secret  — the whole project secret, including the whsec_ prefix;
        headers — request headers (any object with .get);
        body    — raw body bytes, before JSON parsing.
        """
        timestamp = headers.get("X-Callback-Timestamp", "")
        signature = headers.get("X-Callback-Signature", "")
        if not timestamp.isdigit():
            return False
        now = time.time() if now is None else now
        if abs(now - int(timestamp)) > MAX_SKEW_SECONDS:
            return False
        signed = timestamp.encode() + b"." + body
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        for part in signature.split(","):
            scheme, _, value = part.strip().partition("=")
            if scheme == "v1" and hmac.compare_digest(value, expected):
                return True
        return False
    
    ```

=== "Node.js"

    ```javascript
    // Payment API callback signature check: HMAC-SHA256. Node.js 20+, no dependencies.
    import { createHmac, timingSafeEqual } from 'node:crypto';
    
    const MAX_SKEW_SECONDS = 300; // reject callbacks more than 5 minutes old or ahead
    
    /**
     * true if the callback is authentic and fresh.
     * secret  — the whole project secret, including the whsec_ prefix;
     * headers — request headers with lower-case names (as in node:http);
     * body    — raw body bytes (Buffer), before JSON parsing.
     */
    export function verifyCallback(secret, headers, body, now = Date.now() / 1000) {
      const timestamp = String(headers['x-callback-timestamp'] ?? '');
      const signature = String(headers['x-callback-signature'] ?? '');
      if (!/^\d+$/.test(timestamp)) return false;
      if (Math.abs(now - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
    
      const expected = Buffer.from(
        createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'),
      );
      return signature.split(',').some((part) => {
        const [scheme, value = ''] = part.trim().split('=', 2);
        const candidate = Buffer.from(value);
        return scheme === 'v1' && candidate.length === expected.length && timingSafeEqual(candidate, expected);
      });
    }
    
    ```

=== "Go"

    ```go
    package paymentapi
    
    import (
    	"crypto/hmac"
    	"crypto/sha256"
    	"encoding/hex"
    	"net/http"
    	"strconv"
    	"strings"
    	"time"
    )
    
    // maxSkew: callbacks more than 5 minutes old or ahead are rejected.
    const maxSkew = 5 * time.Minute
    
    // VerifyCallback checks the callback signature and freshness. secret is the whole project
    // secret including whsec_; body is the raw request body, before JSON parsing.
    func VerifyCallback(secret string, header http.Header, body []byte, now time.Time) bool {
    	timestamp := header.Get("X-Callback-Timestamp")
    	sent, err := strconv.ParseInt(timestamp, 10, 64)
    	if err != nil {
    		return false
    	}
    	if skew := now.Sub(time.Unix(sent, 0)); skew > maxSkew || skew < -maxSkew {
    		return false
    	}
    	mac := hmac.New(sha256.New, []byte(secret))
    	mac.Write([]byte(timestamp + "."))
    	mac.Write(body)
    	expected := []byte(hex.EncodeToString(mac.Sum(nil)))
    	for _, part := range strings.Split(header.Get("X-Callback-Signature"), ",") {
    		scheme, value, ok := strings.Cut(strings.TrimSpace(part), "=")
    		if ok && scheme == "v1" && hmac.Equal([]byte(value), expected) {
    			return true
    		}
    	}
    	return false
    }
    
    ```

### Test vector { #test-vector }

`body` is exactly one line with no line breaks and no spaces between fields (the body
from the example above as the API sends it), 362 bytes:

```text
secret    = whsec_MfKQ9r2vXz
timestamp = 1700000000
body      = {"id":"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42","event_id":"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21","amount":3400,"initial_amount":3400,"status":"completed","currency":"RUB","payment_method":"sbp","bank_code":"sber","merchant_payment_id":"order-42","rate":11.00,"course":100.00,"is_intrabank":false,"is_test":false,"project_id":"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"}
signature = e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f
```

With the previous secret `whsec_previous` the same body gives
`54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4`, and during the
overlap the header looks like
`v1=e3e688ba…90220f,v1=54b9a97a…99f3b4`.

## How to handle it { #handling }

1. Read the **raw body** and headers.
2. Verify the signature and `X-Callback-Timestamp`. If they fail, answer `401` and change
   nothing.
3. Insert `event_id` into a table with a unique index. Already there — answer `2xx` and stop.
4. Queue the event in your own queue and answer `2xx` at once.
5. In the background find the order by `merchant_payment_id` or the stored `id` and apply
   `status` and `amount` following the [status change rules](#status-changes).

`2xx` means "event accepted", not "order fulfilled". If your processing fails after the
answer, re-read the payment with `GET`: the callback will not come again by itself.

### Minimal receiver { #minimal-receiver }

Verifies the signature, drops duplicates and answers `2xx` at once. In production store
`event_id` in a database with a unique index and queue the processing.

=== "PHP"

    ```php
    <?php
    // Minimal callback receiver: put this file behind your web server (or php -S 0.0.0.0:8080).
    // The secret comes from the CALLBACK_SECRET environment variable.
    
    declare(strict_types=1);
    
    require __DIR__ . '/callback.php';
    
    $body = file_get_contents('php://input');
    $valid = api_verify_callback(
        getenv('CALLBACK_SECRET') ?: '',
        $_SERVER['HTTP_X_CALLBACK_TIMESTAMP'] ?? '',
        $_SERVER['HTTP_X_CALLBACK_SIGNATURE'] ?? '',
        $body,
    );
    if (!$valid) {
        http_response_code(401);
        exit;
    }
    $event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
    // Store event_id with a unique index: never process the same event twice.
    // Enqueue the work here: mark the order as paid, etc.
    error_log("payment {$event['merchant_payment_id']} -> {$event['status']}");
    http_response_code(204); // answer 2xx at once, process asynchronously
    
    ```

=== "Python"

    ```python
    """Minimal callback receiver on the standard library.
    
        export CALLBACK_SECRET=whsec_...
        python callback_server.py   # listens on :8080, any path
    """
    import json
    import os
    from http.server import BaseHTTPRequestHandler, HTTPServer
    
    from callback import verify_callback
    
    SECRET = os.environ.get("CALLBACK_SECRET", "")
    seen_events: set[str] = set()  # in production: a database table with a unique event_id
    
    
    class CallbackHandler(BaseHTTPRequestHandler):
        def do_POST(self) -> None:
            body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
            if not verify_callback(SECRET, self.headers, body):
                self.send_response(401)
                self.end_headers()
                return
            event = json.loads(body)
            if event["event_id"] not in seen_events:
                seen_events.add(event["event_id"])
                # Enqueue the work here: mark the order as paid, etc.
                print("payment", event["merchant_payment_id"], "->", event["status"])
            self.send_response(204)  # answer 2xx at once, process asynchronously
            self.end_headers()
    
    
    if __name__ == "__main__":
        HTTPServer(("", 8080), CallbackHandler).serve_forever()
    
    ```

=== "Node.js"

    ```javascript
    // Minimal callback receiver on node:http.
    //
    //   export CALLBACK_SECRET=whsec_...
    //   node callback-server.mjs   # listens on :8080, any path
    import { createServer } from 'node:http';
    import { verifyCallback } from './callback.mjs';
    
    const secret = process.env.CALLBACK_SECRET ?? '';
    const seenEvents = new Set(); // in production: a database table with a unique event_id
    
    createServer((req, res) => {
      const chunks = [];
      req.on('data', (chunk) => chunks.push(chunk));
      req.on('end', () => {
        const body = Buffer.concat(chunks);
        if (req.method !== 'POST' || !verifyCallback(secret, req.headers, body)) {
          res.writeHead(401).end();
          return;
        }
        const event = JSON.parse(body);
        if (!seenEvents.has(event.event_id)) {
          seenEvents.add(event.event_id);
          // Enqueue the work here: mark the order as paid, etc.
          console.log('payment', event.merchant_payment_id, '->', event.status);
        }
        res.writeHead(204).end(); // answer 2xx at once, process asynchronously
      });
    }).listen(8080);
    
    ```

=== "Go"

    ```go
    // Minimal callback receiver on net/http.
    //
    //	export CALLBACK_SECRET=whsec_...
    //	go run ./cmd/callback-server   # listens on :8080, any path
    package main
    
    import (
    	"encoding/json"
    	"io"
    	"log"
    	"net/http"
    	"os"
    	"sync"
    	"time"
    
    	"example.com/paymentapi"
    )
    
    func main() {
    	secret := os.Getenv("CALLBACK_SECRET")
    	var mu sync.Mutex
    	seen := map[string]bool{} // in production: a database table with a unique event_id
    
    	http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
    		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
    		if err != nil || !paymentapi.VerifyCallback(secret, r.Header, body, time.Now()) {
    			w.WriteHeader(http.StatusUnauthorized)
    			return
    		}
    		var event struct {
    			EventID           string `json:"event_id"`
    			MerchantPaymentID string `json:"merchant_payment_id"`
    			Status            string `json:"status"`
    		}
    		if err = json.Unmarshal(body, &event); err != nil {
    			w.WriteHeader(http.StatusBadRequest)
    			return
    		}
    		mu.Lock()
    		first := !seen[event.EventID]
    		seen[event.EventID] = true
    		mu.Unlock()
    		if first {
    			// Enqueue the work here: mark the order as paid, etc.
    			log.Printf("payment %s -> %s", event.MerchantPaymentID, event.Status)
    		}
    		w.WriteHeader(http.StatusNoContent) // answer 2xx at once, process asynchronously
    	})
    	log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
    ```

## Delivery and retries { #delivery }

- **Success** is any `2xx`. The response body is not read beyond 4 KB.
- **Failure** is a network error, a timeout or any other code: `3xx`, `4xx`, `5xx`.
- **Timeout** — 10 seconds per request. Answer `2xx` at once and process asynchronously.
- **Retries.** An event gets 3 attempts: at once, after 30 seconds and after another
  60 seconds — about a minute and a half. All attempts carry the same `event_id` and body.
  After the third failure delivery of this event stops.
- **Manual resend.** Support can send a callback for a closed payment again, including a
  payment in `error`. It is a new event with a new `event_id` and a single attempt, without
  automatic retries.
- **No ordering guarantee.** A retry of an old event may arrive after a new one. Make the
  handler idempotent.

| Attempt | When | If no `2xx` |
| --- | --- | --- |
| 1 | right after the event | attempt 2 in 30 seconds |
| 2 | after 30 seconds | attempt 3 in 60 seconds |
| 3 | after 90 seconds | delivery stops |

!!! tip "When in doubt, re-read the payment"
    If a callback contradicts what you know about the order, read the current state with
    `GET /api/v1/payments/{id}`. The API answer beats delivery order.

## If a callback did not arrive { #missing }

There are few attempts, so do not rely on callbacks alone:

- **Reconciliation.** Every few minutes select your orders that have waited for payment
  longer than the project payment lifetime (15 minutes by default) and read them with
  `GET /api/v1/payments/{id}`. Handle a closed payment the same way as a received callback.
  Do not poll more often than once per 5–10 seconds per payment: the project shares one
  [rate limit](idempotency.md#rate-limits).
- **Check the URL.** It is set on at least one level, reachable from the internet, answers
  `2xx` without a redirect, and its certificate is valid.
- **Resend.** If your server was down, send support the payment `id` values or their
  `merchant_payment_id` — the callbacks will be sent again. Each comes with a new
  `event_id`.

### Common problems { #troubleshooting }

| Symptom | Cause |
| --- | --- |
| Signature does not match | Signed over parsed and re-serialized JSON instead of the raw body; key taken without `whsec_`; secret from another project or environment |
| Signature is right, but the request is rejected by time | Server clock is off by more than 5 minutes |
| No callbacks at all | URL not set; URL answers `301` or `302`; host resolves to an internal IP; payments are test ones |
| Order processed twice | Retries are not dropped by `event_id`, or the outcome is decided by `event_id` instead of `id` and `status` |
| Order "rolled back" | A late event was applied over a newer one. Re-read the payment with `GET` when the status changes |

## Test payments { #test-payments }

Payments with `is_test: true` — project test payments and test payments from the cabinet —
do not get callbacks automatically. Read their outcome with `GET /api/v1/payments/{id}`.

To test your handler:

- run your verification function on the [test vector](#test-vector);
- ask support to resend the callback of a closed test payment — a real signed request with
  `is_test: true` will reach your URL.

More in [Sandbox](sandbox.md).

## Secret and rotation { #secret }

- The secret is issued in the cabinet: Projects and API → External API access → Callback.
  It is shown **once** — keep it in a secret store.
- Each project has its own secret; sandbox and production secrets differ.
- If no secret was issued, the platform creates one on first delivery but shows it to
  nobody. To verify signatures, issue a new one.
- **Lossless rotation.** After a new secret is issued, the previous one keeps signing for
  24 hours as a second `v1` value. Steps: issue the new secret → add it next to the old one
  (or replace at once — any matching value is fine) → remove the old one within 24 hours.
  Rotating again within those 24 hours drops the oldest secret immediately: at most two are
  valid at a time.
- If the secret leaks, issue a new one and remove the old one from your check right away.

## Checklist { #checklist }

- [ ] The callback URL is set, reachable from the internet, `https://`, no redirect.
- [ ] The signature is checked over the raw body with a constant-time comparison.
- [ ] Requests older than 5 minutes are rejected; the clock is synced.
- [ ] `event_id` is stored with a unique index for at least a day.
- [ ] `2xx` goes out at once; processing runs in the background.
- [ ] The order is fulfilled by `amount`, not `initial_amount`.
- [ ] `canceled` → `completed`, `completed` → `canceled` and a new `amount` are handled.
- [ ] Orders without a callback are reconciled with `GET /api/v1/payments/{id}`.