> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.getmint.money/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.getmint.money/_mcp/server.

# Webhooks

> Receive signed payment updates on your server and fulfil orders safely.

Mint webhooks tell your server when a payment changes state. Use them to update
an order, unlock access, or start fulfilment without making the browser the
source of truth.

## What you will receive

Mint sends one immutable event for each relevant transition:

| Event                    | When it is sent                                                                                     | What to do                                                                   |
| ------------------------ | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `payment.created`        | A payment is created.                                                                               | Usually record it; the creating backend can ignore it.                       |
| `payment.confirming`     | Mint has seen the buyer's transaction and is confirming it.                                         | Show a pending state. Do not fulfil yet.                                     |
| `payment.deferred`       | Funds were received, but settlement is waiting under fee protection.                                | Show pending; Mint retries automatically. Do not ask the buyer to pay again. |
| `payment.refund_pending` | The protected settlement window ended; refund eligibility is being checked or is not yet available. | Keep the order unfulfilled and show the refund waiting state.                |
| `payment.refundable`     | Mint verified that a fee-protected payment has a claimable refund.                                  | Offer `refund_claim_url` to the buyer. Do not fulfil.                        |
| `payment.paid`           | The payment is complete.                                                                            | Fulfil the order.                                                            |
| `payment.failed`         | The payment cannot be completed.                                                                    | Ask the buyer to try again or follow your recovery flow.                     |

Deliveries are **at least once**. Your endpoint can receive the same event more
than once, and a retry can arrive after a later event. Treat `event_id` as an
idempotency key and make `payment.paid` fulfilment idempotent too.

An event is a snapshot at `occurred_at`, not a live payment record. Do not
overwrite a newer state with a delayed delivery. If a refund link or status
may be stale, retrieve the current payment from Mint's authenticated API.

`payment.created` is most useful when the payment was created in the Mint
Dashboard or by another service. If this same backend called Mint's payment
creation API, it already has the payment ID and can simply acknowledge and
ignore this event. It is never a fulfilment signal.

## Set up an endpoint

1. Deploy an HTTPS endpoint that accepts `POST` requests, for example
   `https://api.example.com/webhooks/mint`.
2. In the Mint dashboard, open the checkout, then go to **Settings → Webhooks**
   and add that endpoint.
3. Copy the signing secret and store it as `MINT_WEBHOOK_SECRET` in the
   receiving service's secret manager. Never put it in browser code or commit
   it to source control.
4. Create a test payment and check that your endpoint receives
   `payment.created`, then a terminal event.

Each checkout has its own endpoint and signing secret. Prefer one endpoint path
per checkout. If several checkouts share a path, your receiver must safely try
every configured secret instead of picking one from the unverified body.

### Test without writing an endpoint

[webhook.site](https://webhook.site/) gives you a temporary URL and shows the
requests it receives. Copy its unique URL into a **test** checkout's Webhooks
settings, then create a test payment to inspect Mint's headers and payloads.

Use it only for testing. It is a third-party request inspector, so do not use
it as a production endpoint or send production payment data to it.

## Pause and resume delivery

You can pause a checkout's webhooks without changing its endpoint or signing
secret:

1. Open the checkout's **Settings → Webhooks** page and select **Disable**.
2. When ready, select **Enable** on the same page.

The endpoint and secret are kept, so re-enabling does not require a redeploy.
While delivery is disabled, do not rely on events being delivered later;
reconcile any missed payment status with Mint's authenticated payment API.

## Verify the signature

Mint signs the **exact request body** and sends the result in
`X-Mint-Signature`.

```text
v1=base64(HMAC-SHA256(raw request body, signing secret))
```

Read the raw body before parsing JSON. Parsing and serializing it again can
change its bytes and make a valid signature fail verification.

Here is the Express handler shape. It accepts a current secret and, during a
rotation, an optional previous secret. Implement `processMintEventOnce` as a
single database transaction in your application.

```ts
import crypto from 'node:crypto';
import express from 'express';

const app = express();

function hasValidMintSignature(rawBody: Buffer, header: string | undefined, secrets: Array<string | undefined>) {
  if (!header) return false;

  const received = header.split(',').map((value) => Buffer.from(value.trim(), 'utf8'));
  return secrets.filter(Boolean).some((secret) => {
    const expected = Buffer.from(
      `v1=${crypto.createHmac('sha256', secret!).update(rawBody).digest('base64')}`,
      'utf8',
    );
    return received.some((value) => value.length === expected.length && crypto.timingSafeEqual(value, expected));
  });
}

app.post('/webhooks/mint', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body as Buffer;
  const signature = req.header('X-Mint-Signature');

  if (!hasValidMintSignature(rawBody, signature, [process.env.MINT_WEBHOOK_SECRET, process.env.MINT_WEBHOOK_PREVIOUS_SECRET])) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(rawBody.toString('utf8'));

  // In one database transaction:
  // 1. Insert event.event_id into a table with a UNIQUE constraint.
  // 2. If it was already present, do nothing.
  // 3. If event.event_type is payment.paid, fulfil the related order.
  await processMintEventOnce(event);

  return res.sendStatus(204);
});
```

Return a `2xx` response only after you have safely recorded the event. For an
invalid signature, return `401` and do not parse or act on the body.

## Event schemas

Every event has this envelope. `merchant_order_id` is always present; it is
`null` when you did not provide one while creating the payment.

```ts
type PaymentEventEnvelope = {
  event_id: string;
  schema_version: 1;
  occurred_at: string; // ISO 8601 timestamp
  payment_id: string;
  checkout_id: string;
  merchant_order_id: string | null;
  amount: { value: string; currency: string };
  status: 'pending' | 'confirming_payment' | 'deferred' | 'refund_pending' | 'refundable' | 'paid' | 'failed';
  status_message: string | null;
  refundable_at: string | null; // ISO 8601 timestamp
  refund_claim_url: string | null;
};
```

The recovery fields are present on every event and are `null` when not
applicable. `status_message` is display copy, not a value to branch on; use
`event_type` or `status`. `refundable_at` is the refund eligibility time, not
confirmation that funds have been returned. `refund_claim_url` is only exposed
for an available fee-protection refund.

These additions keep `schema_version: 1`. Receivers should tolerate additional
fields and safely acknowledge event types they do not handle after verifying
the signature.

### `payment.created`

No payment attempt exists yet.

```ts
type PaymentCreatedEvent = PaymentEventEnvelope & {
  event_type: 'payment.created';
};
```

### `payment.confirming`

Mint has started confirming the buyer's transaction. `transaction` is included
when it is known.

```ts
type PaymentConfirmingEvent = PaymentEventEnvelope & {
  event_type: 'payment.confirming';
  attempt_id: string;
  attempt_number: number;
  transaction?: { hash: string; chain: string; token: string };
};
```

### Fee-protection events

These events describe settlement recovery for fee-protected payments, not
every underpayment or overpayment refund. All three include attempt details
and the buyer's transaction when known:

```ts
type PaymentRecoveryEvent = PaymentEventEnvelope & {
  event_type: 'payment.deferred' | 'payment.refund_pending' | 'payment.refundable';
  attempt_id: string;
  attempt_number: number;
  transaction?: { hash: string; chain: string; token: string };
};
```

* **`payment.deferred`**: Mint received funds but has not completed settlement.
  It retries within the settlement window. Successful settlement emits
  `payment.paid`; no second payment is needed. Even a one-check window enters
  this state after an unsuccessful check before cutoff, then moves to
  `payment.refund_pending` at the next minute tick (or cutoff, if sooner).
* **`payment.refund_pending`**: Mint stops new settlement submissions after
  the cutoff and checks the outcome of any earlier submission. Use
  `refundable_at` for the waiting-time display, but do not assume that reaching
  that timestamp guarantees a refund. A confirmed earlier settlement can
  still produce `payment.paid`.
* **`payment.refundable`**: Refund availability has been verified. Send the
  buyer to `refund_claim_url` when non-null. This means the refund can be
  claimed, not that it has already been returned.

When Mint records a claimed refund on the current unpaid attempt, it emits
`payment.failed` with `failure.code: 'refund_claimed'`. If no recoverable funds
remain at expiry, the failure code is `attempt_expired` instead. Neither is a
fulfilment signal. Reconcile the current payment before offering recovery:
an older `payment.refundable` event remains unchanged even after a claim.

### `payment.paid`

This is the fulfilment event. It includes the buyer's transaction when known
and Mint's settlement transaction.

```ts
type PaymentPaidEvent = PaymentEventEnvelope & {
  event_type: 'payment.paid';
  attempt_id: string;
  attempt_number: number;
  transaction?: { hash: string; chain: string; token: string };
  settlement: {
    chain: string;
    token: string;
    transaction_hash: string;
    gross_amount_base_units: string;
    mint_fee_base_units: string;
    merchant_amount_base_units: string;
  };
};
```

`transaction.hash` belongs to the buyer. `settlement.transaction_hash` belongs
to Mint's payout. Base-unit fields are strings so they can be handled without
losing precision.

### `payment.failed`

The transaction fields are included when they are known. `failure.code` is one
of `attempt_expired`, `refund_claimed`, `provider_failed`, or `test_failure`.

```ts
type PaymentFailedEvent = PaymentEventEnvelope & {
  event_type: 'payment.failed';
  attempt_id: string;
  attempt_number: number;
  transaction?: { hash: string; chain: string; token: string };
  failure: {
    code: 'attempt_expired' | 'refund_claimed' | 'provider_failed' | 'test_failure';
  };
};
```

## Fulfil safely

Use this order of operations:

1. Verify `X-Mint-Signature` against the raw body.
2. Insert `event_id` into a unique received-events table in the same database
   transaction as your business update.
3. Ignore the event if that insert conflicts: it is a retry.
4. Fulfil only for `payment.paid`; make the fulfilment operation idempotent by
   order ID or payment ID as a second line of defence.
5. Return `2xx` after the transaction commits.

Use Mint's authenticated payment API to reconcile a payment if your system is
missing data or needs the full payment record. A webhook is a state-change
notification, not a copy of the complete payment object.

## Rotate a signing secret

1. In **Settings → Webhooks**, select **Rotate secret**.
2. Deploy the new secret while continuing to accept the previous secret.
3. Wait until the previous delivery retry window has passed.
4. Select **Retire previous secret**.

Do not retire the old secret before every receiver has the new one. Mint keeps
it valid during rotation so delayed deliveries signed before the change can
still be verified.