> For the complete documentation index, see [llms.txt](https://docs.omypayments.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.omypayments.com/api/callbacks.md).

# Callbacks

**Requirements**

* Your endpoint must be publicly accessible and return a `200` HTTP status code on receipt.
* The same endpoint receives every callback type. Route by payload shape: invoice callbacks carry `usdAmount` / `productTitle`; swap callbacks carry `status` in the form `new | in_progress | finished | rejected` together with `cryptoFrom` / `cryptoTo`.

***

### Invoice Callbacks

Sent every time an invoice changes status.

**Payload**

```json
{
    "id": "UUID", // Invoice ID
    "usdAmount": "100.00", // Invoice amount in USD
    "status": "pending", // Invoice status
    "productTitle": "Test Product", // Product title
    "extraData": null, // Additional invoice information.
    "cryptoAmount": "0", // Amount in selected cryptocurrency (or null if not chosen)
    "crypto": { // Cryptocurrency data (or null if not chosen)
        "id": "uuid", // Crypto UUID
        "title": "Tether USD", // Crypto title
        "symbol": "USDT", // Crypto symbol
        "contractAddress": "0x0000000000000000000000000000000000000000", // Crypto contract address
        "decimals": 6, // Crypto decimals
        "logoURI": "https://..." // Crypto logo URI
    },
    "network": { // Network data (or null if not chosen)
        "id": "UUID", // Network UUID
        "chainId": "56", // Network chain ID
        "symbol": "BSC", // Network symbol
        "name": "BNB Chain", // Network title
        "networkType": "evm" // Network type
    },
    "address": "0x0000000000000000000000000000000000000000", // Payment address (or null if not chosen)
    "expiredAt": "2025-01-01 00:00:00", // Invoice expiration datetime (UTC+0)
    "transfer": { // Payment transaction details (or null if not available)
        "from": "0x0000000000000000000000000000000000000000", // Transfer from address
        "hash": "0x0000000000000000000000000000000000000000000000000000000000000000" // Transfer hash
    },
    "signature": "sha256=<hex>" // HMAC-SHA256 signature - see "Verifying webhook signatures" below
}
```

***

### Swap Callbacks

Sent every time a swap changes status. Each callback carries an event type in the form `swap.<status>`:

| Event              | Sent when                                                           |
| ------------------ | ------------------------------------------------------------------- |
| `swap.new`         | A swap was created and funds were reserved.                         |
| `swap.in_progress` | The swap started executing on-chain / via the routing provider.     |
| `swap.finished`    | The swap completed successfully (`amountTo`, `hashTo` populated).   |
| `swap.rejected`    | The swap failed; reserved funds were returned to available balance. |

**Payload**

```json
{
    "id": "UUID", // Swap ID
    "amountFrom": "100000000", // Source amount in base units
    "amountFromInUsd": "100.00", // Source amount in USD (or null)
    "amountTo": "99500000", // Received amount in base units (or null)
    "amountToInUsd": "99.50", // Received amount in USD (or null)
    "hashFrom": "0x...", // Source transaction hash (or null)
    "hashTo": "0x...", // Target transaction hash (or null)
    "status": "finished", // Swap status: new | in_progress | finished | rejected
    "createdAt": "2025-03-11T14:30:16+00:00", // Swap creation datetime (RFC3339, UTC+0)
    "cryptoFrom": { // Source cryptocurrency data
        "id": "UUID", // Crypto UUID
        "title": "Tether USD", // Crypto title
        "symbol": "USDT", // Crypto symbol
        "decimals": 6, // Crypto decimals
        "contractAddress": "0x0000000000000000000000000000000000000000", // Contract address (or null for native)
        "logoUrl": "https://..." // Crypto logo URL (or null)
    },
    "cryptoTo": { // Target cryptocurrency data
        "id": "UUID",
        "title": "USD Coin",
        "symbol": "USDC",
        "decimals": 6,
        "contractAddress": "0x0000000000000000000000000000000000000000",
        "logoUrl": "https://..."
    },
    "networkFrom": { // Source network data
        "id": "UUID", // Network UUID
        "title": "BNB Chain", // Network title
        "symbol": "BSC" // Network symbol
    },
    "networkTo": { // Target network data
        "id": "UUID",
        "title": "Polygon",
        "symbol": "MATIC"
    },
    "signature": "sha256=<hex>" // HMAC-SHA256 signature - see "Verifying webhook signatures" below
}
```

> Amounts (`amountFrom`, `amountTo`) are returned in the cryptocurrency's base units. Use the matching `decimals` value to convert to a human-readable amount. The `amountTo` / `hashTo` fields are populated once the swap reaches the `finished` status.

***

### Withdrawal Callbacks

Sent every time a withdrawal changes its **main** status. Each callback carries an event type in the form `withdrawal.<status>`:

| Event                | Sent when                                                                                                                     |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `withdrawal.pending` | A withdrawal was created and funds were reserved from your asset balance.                                                     |
| `withdrawal.success` | The withdrawal was sent on-chain successfully (`txHash` populated).                                                           |
| `withdrawal.failed`  | The withdrawal was declined, or its transaction reverted / timed out; reserved funds were returned to your available balance. |

> **Only main-status changes are delivered.** Intermediate states of a withdrawal (e.g. `accepted`, `sending`, `sent`) do **not** trigger a callback, and the payload does **not** include a sub-status field. A given withdrawal therefore emits at most one `withdrawal.pending`, followed by exactly one terminal `withdrawal.success` **or** `withdrawal.failed`.

**Payload**

```json
{
    "id": "UUID", // Withdrawal ID
    "status": "pending", // Withdrawal main status: pending | success | failed
    "address": "0x0000000000000000000000000000000000000000", // Destination address
    "amount": "60000000000000000000", // Amount in base units
    "amountInUsd": "60.00", // Amount in USD
    "payer": "sender", // Fee payer (always "sender" for API withdrawals)
    "isPrivate": false, // Whether this is a private withdrawal
    "txHash": null, // On-chain transaction hash (null until sent; returned as-is, no masking)
    "crypto": { // Cryptocurrency data
        "id": "UUID", // Crypto UUID
        "title": "Tether USD", // Crypto title
        "symbol": "USDT", // Crypto symbol
        "decimals": 18, // Crypto decimals
        "contractAddress": "0x0000000000000000000000000000000000000000", // Contract address (or null for native)
        "logoUrl": "https://..." // Crypto logo URL (or null)
    },
    "network": { // Network data
        "id": "UUID", // Network UUID
        "title": "BNB Chain", // Network title
        "symbol": "BSC" // Network symbol
    },
    "createdAt": "2025-03-11T14:30:16+00:00", // Withdrawal creation datetime (RFC3339, UTC+0)
    "signature": "sha256=<hex>" // HMAC-SHA256 signature — see "Verifying webhook signatures" below
}
```

> `amount` is returned in the cryptocurrency's base units — use the matching `crypto.decimals` value to convert to a human-readable amount. The payload is intentionally lean; for the full withdrawal details (fees, gas breakdown) query Get a withdrawal by `id`.

***

### Verifying webhook signatures

Every callback includes a `signature` field in the root of the payload:

```
sha256=79aa19a4d3bf6691205e5c6506530135484207234bf4a66cd265a1437f2ef3a4
```

Verifying this field lets you confirm that the callback was sent by OmyPayments and that no field was modified in transit.

#### Getting your webhook secret

Find your webhook secret in the merchant dashboard on the **Profile** page, in the **API keys** section. Use the **Regenerate** button if you need to rotate it. Keep the secret server-side - never expose it to a browser or include it in client code.

#### How the signature is computed

The signature is `HMAC-SHA256` over the **canonicalized payload** (the full callback body with the `signature` field removed), prefixed with `sha256=`:

```
signature = "sha256=" + lowercase_hex( HMAC_SHA256(key=webhook_secret, msg=canonical(payload)) )
```

**Canonicalization algorithm:**

1. Remove the `signature` field from the payload.
2. Recursively drop `null` values from both objects and arrays:
   * **Objects:** remove every key whose value is `null`; recurse into non-null values. Drop the entire object if all keys are removed.
   * **Arrays:** remove every `null` element; recurse into non-null elements. Element order is preserved; the array is re-indexed after removal.
3. Serialize the result to JSON with:
   * Keys sorted by Unicode code point (ascending); all keys are ASCII, so this is plain lexicographic order.
   * Compact format - separators `,` and `:`, no spaces.
   * Strings in raw UTF-8: non-ASCII characters are **not** escaped to `\uXXXX`; only `"` → `\"`, `\` → `\\`, and C0 control characters are escaped. The characters `/`, `<`, `>`, `&` are **not** escaped.
   * Integer fields (e.g., `decimals`) serialized as-is (`6`, `18`); all monetary amounts are strings.

#### Test vectors

Use these vectors to validate your implementation. Secret for all three:

```
whsec_test_2c5f9b8e4a1d7063f5e2c9a0b3d6e8f1a4c7b0d3
```

**Vector 1 - `invoice.completed`, full payload (Unicode in `productTitle`, JSON string in `extraData`)**

Canonical string:

```
{"address":"0x1234567890AbCdEf1234567890aBcDeF12345678","createdAt":"2026-06-15 12:00:00","crypto":{"contractAddress":"0xdAC17F958D2ee523a2206206994597C13D831ec7","decimals":6,"id":"2c1d8f6a-7b3e-4d9c-a1f0-5e2b8c4d7a90","logoURI":"https://assets.omypayments.com/usdt.png","name":"Tether USD","symbol":"USDT"},"cryptoAmount":"100.500000","expiredAt":"2026-06-15 12:30:00","extraData":"{\"orderId\":42}","id":"9b7e2c41-0f3a-4c8e-bb11-2f0a9d6e1234","network":{"chainId":"1","id":"1f0e3a2b-4c5d-6e7f-8a9b-0c1d2e3f4a5b","name":"Ethereum","networkType":"evm","symbol":"ETH"},"productTitle":"Café Münchén — Order #42","status":"completed","transfer":{"from":"0xAbC0000000000000000000000000000000000001","hash":"0xdeadbeefcafebabe0000000000000000000000000000000000000000deadbeef"},"usdAmount":"100.50"}
```

Expected signature:

```
sha256=79aa19a4d3bf6691205e5c6506530135484207234bf4a66cd265a1437f2ef3a4
```

**Vector 2 - `invoice.pending`, minimal (all nullable fields are null → dropped)**

Canonical string:

```
{"createdAt":"2026-06-15 12:45:00","expiredAt":"2026-06-15 13:00:00","id":"3d2c1b0a-9f8e-7d6c-5b4a-3c2d1e0f9a8b","status":"pending","usdAmount":"49.99"}
```

Expected signature:

```
sha256=364b6d1f542907e1845e2bf78af2a7aa33a37f0233ff200e139f05df2c79ef8d
```

**Vector 3 - `swap.completed` (`hashTo` is null → dropped; `createdAt` in RFC3339)**

Canonical string:

```
{"amountFrom":"1.000000000000000000","amountFromInUsd":"3500.00","amountTo":"3490.250000","amountToInUsd":"3490.25","createdAt":"2026-06-15T12:00:00+00:00","cryptoFrom":{"decimals":18,"id":"aa11bb22-cc33-dd44-ee55-ff6677889900","logoUrl":"https://assets.omypayments.com/eth.png","symbol":"ETH","title":"Ethereum"},"cryptoTo":{"contractAddress":"0xdAC17F958D2ee523a2206206994597C13D831ec7","decimals":6,"id":"2c1d8f6a-7b3e-4d9c-a1f0-5e2b8c4d7a90","logoUrl":"https://assets.omypayments.com/usdt.png","symbol":"USDT","title":"Tether USD"},"hashFrom":"0x1111111111111111111111111111111111111111111111111111111111111111","id":"7a6b5c4d-3e2f-1a0b-9c8d-7e6f5a4b3c2d","networkFrom":{"id":"1f0e3a2b-4c5d-6e7f-8a9b-0c1d2e3f4a5b","symbol":"ETH","title":"Ethereum"},"networkTo":{"id":"1f0e3a2b-4c5d-6e7f-8a9b-0c1d2e3f4a5b","symbol":"ETH","title":"Ethereum"},"status":"completed"}
```

Expected signature:

```
sha256=729cc144f824a2b2de4bac765251b47298b192a437abbed92a9b14e67712fc33
```

#### Verification examples

The following examples are verified against all three test vectors above.

**Python**

```python
import hashlib
import hmac
import json


def _drop_null(obj):
    if isinstance(obj, dict):
        result = {}
        for k, v in obj.items():
            cleaned = _drop_null(v)
            if cleaned is not None:
                result[k] = cleaned
        return result if result else None
    if isinstance(obj, list):
        return [_drop_null(v) for v in obj if v is not None]
    return obj


def _canonical(payload: dict) -> str:
    cleaned = _drop_null(payload)
    return json.dumps(cleaned, sort_keys=True, separators=(',', ':'), ensure_ascii=False)


def verify_signature(payload: dict, secret: str, signature: str) -> bool:
    """Verify an OmyPayments webhook signature.

    Remove the 'signature' field from the parsed payload before calling this function.
    """
    without_sig = {k: v for k, v in payload.items() if k != 'signature'}
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        _canonical(without_sig).encode('utf-8'),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

**Usage:**

```python
import json

raw_body = request.body  # bytes from HTTP request
payload = json.loads(raw_body)
signature = payload.get('signature', '')

if not verify_signature(payload, WEBHOOK_SECRET, signature):
    return HttpResponse(status=403)

# safe to process
invoice_id = payload['id']
```

**Language notes:** `json.dumps` with `sort_keys=True` and `separators=(',',':')` handles key sorting and compact format. `ensure_ascii=False` outputs UTF-8 directly without `\uXXXX` escaping. `hmac.compare_digest` provides constant-time comparison.

***

**JavaScript / Node.js**

```javascript
const crypto = require('crypto');

function _dropNull(obj) {
    if (obj === null || obj === undefined) return undefined;
    if (Array.isArray(obj)) {
        return obj
            .filter(v => v !== null && v !== undefined)
            .map(_dropNull);
    }
    if (typeof obj !== 'object') return obj;
    const result = {};
    for (const [k, v] of Object.entries(obj)) {
        const cleaned = _dropNull(v);
        if (cleaned !== undefined) result[k] = cleaned;
    }
    return Object.keys(result).length > 0 ? result : undefined;
}

function _sortKeys(obj) {
    if (Array.isArray(obj)) return obj.map(_sortKeys);
    if (obj === null || typeof obj !== 'object') return obj;
    const sorted = {};
    for (const k of Object.keys(obj).sort()) {
        sorted[k] = _sortKeys(obj[k]);
    }
    return sorted;
}

function _canonical(payload) {
    const cleaned = _dropNull(payload);
    return JSON.stringify(_sortKeys(cleaned));
}

/**
 * Verify an OmyPayments webhook signature.
 * @param {object} payload  - Parsed JSON body (may include 'signature' key).
 * @param {string} secret   - Webhook secret from merchant dashboard.
 * @param {string} signature - Value of payload.signature.
 * @returns {boolean}
 */
function verifySignature(payload, secret, signature) {
    const { signature: _sig, ...rest } = payload;
    const msg = _canonical(rest);
    const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(msg, 'utf8').digest('hex');
    const a = Buffer.from(expected, 'utf8');
    const b = Buffer.from(signature, 'utf8');
    if (a.length !== b.length) return false;
    return crypto.timingSafeEqual(a, b);
}
```

**Usage:**

```javascript
app.post('/webhook', express.json(), (req, res) => {
    const { signature } = req.body;
    if (!verifySignature(req.body, process.env.WEBHOOK_SECRET, signature)) {
        return res.sendStatus(403);
    }
    // safe to process
    res.sendStatus(200);
});
```

**Language notes:** `JSON.stringify` does **not** sort keys - the explicit recursive `_sortKeys` step is required. `JSON.stringify` does not escape non-ASCII characters by default, which matches the spec. `crypto.timingSafeEqual` requires equal-length buffers - the length check before the call is intentional.

***

**PHP**

```php
<?php

function _dropNull(mixed $value): mixed
{
    if (!is_array($value)) {
        return $value;
    }
    $isList = array_is_list($value);
    $result = [];
    foreach ($value as $k => $v) {
        if ($v === null) {
            continue;
        }
        $result[$k] = _dropNull($v);
    }
    if ($isList) {
        return array_values($result);
    }
    if (empty($result)) {
        return null;
    }
    ksort($result, SORT_STRING);
    return $result;
}

function _canonical(array $payload): string
{
    $cleaned = _dropNull($payload);
    return json_encode($cleaned, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}

/**
 * Verify an OmyPayments webhook signature.
 *
 * @param array  $payload   Parsed JSON body (may include 'signature' key).
 * @param string $secret    Webhook secret from merchant dashboard.
 * @param string $signature Value of $payload['signature'].
 * @return bool
 */
function verifySignature(array $payload, string $secret, string $signature): bool
{
    unset($payload['signature']);
    $expected = 'sha256=' . hash_hmac('sha256', _canonical($payload), $secret);
    return hash_equals($expected, $signature);
}
```

**Usage:**

```php
$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, associative: true, flags: JSON_THROW_ON_ERROR);
$signature = $payload['signature'] ?? '';

if (!verifySignature($payload, $_ENV['WEBHOOK_SECRET'], $signature)) {
    http_response_code(403);
    exit;
}

// safe to process
$invoiceId = $payload['id'];
```

**Language notes:** `ksort` must be applied recursively - the standard `json_encode` does not sort keys. `array_is_list` distinguishes PHP lists (sequential int keys) from associative arrays: lists get `array_values` re-indexing after null removal, associative arrays get `ksort`. `JSON_UNESCAPED_UNICODE` outputs UTF-8 directly; `JSON_UNESCAPED_SLASHES` prevents `/` from being escaped as `\/`. `hash_equals` provides constant-time comparison.

***

**Go**

```go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "sort"
)

func dropNull(v interface{}) interface{} {
    switch val := v.(type) {
    case map[string]interface{}:
        result := make(map[string]interface{})
        for k, item := range val {
            if item == nil {
                continue
            }
            cleaned := dropNull(item)
            if cleaned != nil {
                result[k] = cleaned
            }
        }
        if len(result) == 0 {
            return nil
        }
        return result
    case []interface{}:
        var result []interface{}
        for _, item := range val {
            if item == nil {
                continue
            }
            result = append(result, dropNull(item))
        }
        return result
    default:
        return v
    }
}

func sortedMarshal(v interface{}) ([]byte, error) {
    switch val := v.(type) {
    case map[string]interface{}:
        keys := make([]string, 0, len(val))
        for k := range val {
            keys = append(keys, k)
        }
        sort.Strings(keys)
        var buf bytes.Buffer
        buf.WriteByte('{')
        for i, k := range keys {
            if i > 0 {
                buf.WriteByte(',')
            }
            keyBytes, _ := json.Marshal(k)
            buf.Write(keyBytes)
            buf.WriteByte(':')
            valBytes, err := sortedMarshal(val[k])
            if err != nil {
                return nil, err
            }
            buf.Write(valBytes)
        }
        buf.WriteByte('}')
        return buf.Bytes(), nil
    case []interface{}:
        var buf bytes.Buffer
        buf.WriteByte('[')
        for i, item := range val {
            if i > 0 {
                buf.WriteByte(',')
            }
            b, err := sortedMarshal(item)
            if err != nil {
                return nil, err
            }
            buf.Write(b)
        }
        buf.WriteByte(']')
        return buf.Bytes(), nil
    default:
        // scalar: use encoder with HTML escaping disabled
        var buf bytes.Buffer
        enc := json.NewEncoder(&buf)
        enc.SetEscapeHTML(false)
        if err := enc.Encode(v); err != nil {
            return nil, err
        }
        return bytes.TrimRight(buf.Bytes(), "\n"), nil
    }
}

func canonical(payload map[string]interface{}) (string, error) {
    cleaned := dropNull(payload)
    if cleaned == nil {
        return "{}", nil
    }
    b, err := sortedMarshal(cleaned)
    if err != nil {
        return "", err
    }
    return string(b), nil
}

// VerifySignature verifies an OmyPayments webhook signature.
// Pass the parsed payload (may include the "signature" key) and the raw signature string.
func VerifySignature(payload map[string]interface{}, secret, signature string) (bool, error) {
    delete(payload, "signature")
    msg, err := canonical(payload)
    if err != nil {
        return false, err
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(msg))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature)), nil
}

// ParseWebhookBody parses a raw JSON callback body into a map, preserving number types.
func ParseWebhookBody(rawBody []byte) (map[string]interface{}, error) {
    dec := json.NewDecoder(bytes.NewReader(rawBody))
    dec.UseNumber() // prevents float64 coercion of integers like "decimals":6
    var payload map[string]interface{}
    if err := dec.Decode(&payload); err != nil {
        return nil, err
    }
    return payload, nil
}

// Example HTTP handler
func webhookHandler(rawBody []byte, secret string) error {
    payload, err := ParseWebhookBody(rawBody)
    if err != nil {
        return fmt.Errorf("invalid JSON: %w", err)
    }
    sig, _ := payload["signature"].(string)
    ok, err := VerifySignature(payload, secret, sig)
    if err != nil || !ok {
        return fmt.Errorf("invalid signature")
    }
    // safe to process
    _ = payload["id"]
    return nil
}
```

**Language notes:**

* **`json.Decoder.UseNumber()`** -when decoding a JSON body into `map[string]interface{}`, Go maps all JSON numbers to `float64` by default. `UseNumber()` preserves them as `json.Number` (the exact original string), which then serializes back to the exact original representation. Without this, `"decimals":6` would round-trip correctly, but large integers or numbers with specific precision could drift.
* **`enc.SetEscapeHTML(false)`** - Go's `json.Encoder` escapes `<`, `>`, `&` to `<`, `>`, `&` by default. This would produce a different canonical string than the spec. Always disable HTML escaping.
* **Manual key sorting** - `encoding/json` does not guarantee key order when marshaling `map[string]interface{}`. Always sort keys explicitly before serializing.
* **Arrays (`[]interface{}`)** - when `dropNull` encounters a JSON array it removes null elements and recurses into the remaining items; `sortedMarshal` serializes them in their original order (arrays are never re-sorted). Both functions use a `switch` type assertion so slices are handled explicitly rather than falling through to the scalar branch.
* **`hmac.Equal`** - constant-time byte comparison. Do not use `==` or `bytes.Equal` to compare signatures.

***

#### Recommendations

**Idempotency by invoice ID.** OmyPayments does not include replay protection (timestamps, nonces). To guard against a valid callback being replayed, track which invoice IDs you have already processed and ignore duplicates:

```python
if already_processed(payload['id']):
    return HttpResponse(status=200)  # acknowledge, but don't act again
```

**Date formats.** Invoice callbacks use `"2025-01-01 00:00:00"` (UTC, no timezone marker); swap callbacks use RFC3339 (`"2025-03-11T14:30:16+00:00"`). Both are opaque strings during canonicalization — no parsing needed for signature verification.
