Skip to navigation

Webhooks

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:

EventWhen it is sentWhat to do
payment.createdA payment is created.Usually record it; the creating backend can ignore it.
payment.confirmingMint has seen the buyer’s transaction and is confirming it.Show a pending state. Do not fulfil yet.
payment.deferredFunds were received, but settlement is waiting under fee protection.Show pending; Mint retries automatically. Do not ask the buyer to pay again.
payment.refund_pendingThe 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.refundableMint verified that a fee-protected payment has a claimable refund.Offer refund_claim_url to the buyer. Do not fulfil.
payment.paidThe payment is complete.Fulfil the order.
payment.failedThe 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 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.

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.

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.

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.

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

payment.confirming

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

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:

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.

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.

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.