# Webhooks

> MutoPay sends an HMAC-SHA256 signed webhook when a payment completes, fails, expires, or needs attention. This page documents every event, the payload shape, and the delivery/retry guarantees.

Source: https://mutopay.com/docs/webhooks/
Language: en

---

MutoPay sends a webhook to your configured URL whenever a payment changes to a terminal state or requires your attention. Payloads are signed with HMAC-SHA256. Always verify the signature before trusting the data. See [Verify a webhook signature](/docs/verify-webhook-signature/).

## Configure

Set the webhook URL and retrieve the webhook secret per-channel at [Settings → Channels](https://mutopay.com/dashboard/settings). Each channel has its own URL and secret.

## Events

| Event | When it fires |
|---|---|
| `payment.completed` | Payment succeeded, funds settled in your wallet |
| `payment.failed` | Payment attempt failed (on-chain revert, provider rejection, etc.) |
| `payment.expired` | Payment was not completed before `expires_at` |
| `payment.underpaid` | Customer sent less than the required amount |
| `payment.kyc_required` | The swap provider requested customer KYC. Payment is paused, not terminal |
| `payment.needs_manual_check` | Unknown provider status. MutoPay support will investigate |

`kyc_required` and `needs_manual_check` are non-terminal. When they resolve (e.g. KYC cleared → `processing` → `completed`), **no additional webhook fires for the recovery**: the eventual `payment.completed` or `payment.failed` is your signal.

## Payload

```json
{
  "event": "payment.completed",
  "payment_id": "pay_abc123",
  "status": "completed",
  "amount_usd": 54.23,
  "amount_original": 50,
  "fx_rate": 1.0846,
  "currency": "EUR",
  "src_token": "USDC",
  "src_chain_id": "56",
  "src_amount": null,
  "dest_token": "USDC",
  "dest_chain_id": "137",
  "dest_amount": "54230000",
  "dest_decimals": 6,
  "external_id": "order_1042",
  "tx_hash": "fa67b42db29d0b54ffd778fdffcc1c65ae5c137d2fef0bd66f34b3a9f46d77c5",
  "payer_deposits": [
    {
      "tx_hash": "fa67b42db29d0b54ffd778fdffcc1c65ae5c137d2fef0bd66f34b3a9f46d77c5",
      "amount": "1035691177",
      "amount_human": "1.04 TON",
      "received_at": 1779091349,
      "source_address": "UQABCxyz...EnD"
    }
  ],
  "failure_reason": null,
  "completed_at": "2026-04-21T12:00:00Z",
  "timestamp": "2026-04-21T12:00:01Z"
}
```

### Field notes

- **`dest_amount`** is raw token base units. Divide by `10^dest_decimals` for human-readable: `54230000 / 10^6 = 54.23 USDC`.
- **`external_id`** is the value you sent when creating the payment. Use it for reconciliation.
- **`payer_deposits`** is the list of customer inbound transactions, newest first. Each item is one TonAPI trace with the payer wallet (`source_address`), raw `amount`, formatted `amount_human`, and `received_at` (unix seconds). Use this for reconciliation against the customer's wallet history. On underpaid payments that get topped up, you see every partial deposit here.
- **`tx_hash`** is the most recent payer transaction hash, kept for backward compatibility. **Deprecated**: use `payer_deposits[0].tx_hash` instead.
- **`failure_reason`** is populated for `payment.failed` and sometimes `payment.expired`.
- **`timestamp`** is when MutoPay fired the webhook. **`completed_at`** is when settlement actually happened.

## Delivery guarantees

- Delivery is **at-least-once**. You may receive the same webhook more than once; idempotency is your responsibility (use `payment_id` + `event` as the dedupe key).
- Your endpoint must respond with a **2xx status** within 30 seconds to be considered delivered.
- Non-2xx responses, timeouts, and network errors are retried up to **5 times** with exponential backoff:

  | Attempt | Delay after previous failure |
  |---|---|
  | 2 | 1 minute |
  | 3 | 5 minutes |
  | 4 | 30 minutes |
  | 5 | 2 hours |
  | (final) | 12 hours |

  After the final attempt fails, delivery is abandoned. Use the merchant dashboard or `GET /api/merchant/payments/:id` to reconcile.

## Request headers

```http
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-MutoPay-Signature: sha256=<hex-digest>
User-Agent: MutoPay-Webhook/1.0
```

## Security

- **Always verify the signature** before processing the payload. See [Verify a webhook signature](/docs/verify-webhook-signature/).
- **Never log the webhook secret.** Rotate it via the channel settings if you suspect compromise.
- Your endpoint should be **HTTPS**.

## Test your endpoint

Two ways:

1. **One-off ping**. From [Settings → Channels](https://mutopay.com/dashboard/settings) click **Send test webhook**. MutoPay POSTs a signed sample payload to your configured URL with the same shape, same headers, and same signing algorithm as production. Good for a quick sanity check.
2. **Full sandbox**. Create a **Test channel** (prefix `ep_test_`). Open any test payment in the browser and use the **Simulate complete / fail / expire / underpaid** buttons to fire the matching event. Real signed payloads, real retry pipeline, real `redirect_url`, with an extra `"test": true` field added so your handler can branch. This is the right way to verify status-branching logic and idempotency under retry. See [Getting Started → Test before you go live](/docs/getting-started/#test-before-you-go-live).

## See also

- [Verify a webhook signature](/docs/verify-webhook-signature/): Node, Python, PHP examples.
- [Payment statuses](/docs/payment-statuses/): status transition reference.
