Webhooks

A webhook is a POST that Mintage Pay sends to your server when something happens to an invoice, such as a payment arriving. Use it to mark orders paid.

Register an endpoint

POST /v1/webhook-endpoints (scope webhooks:manage)

{ "url": "https://example.com/mintagepay/webhook", "description": "My store" }

It returns { "id", "url", "secret", "event_types" }. The secret starts with whsec_ and is shown only once: store it. You can also add an endpoint in the dashboard under Developers > Webhooks.

Call Does
GET /v1/webhook-endpoints Lists your endpoints, each with its event_types
PATCH /v1/webhook-endpoints/{id} Changes what it receives: { "events": [...] }, and or "description"
DELETE /v1/webhook-endpoints/{id} Disables it
POST /v1/webhook-endpoints/{id}/test Sends one webhook.test event now and tells you whether your endpoint answered 2xx

Choosing events

events is optional. It lists exact event names ("invoice.paid") or a whole family ("invoice.*"). An endpoint created without events receives every invoice event in the table below. Unknown names are refused with 400.

The event

Each event is a POST with this JSON body:

{
  "id": "evt_...",
  "type": "invoice.paid",
  "created_at": "2026-10-01T18:04:11.000Z",
  "data": { "invoice": { "id": "Kp3...", "status": "paid", "order_id": "1042" } }
}

data.invoice is the full invoice object.

Event Sent when
invoice.payment_detected A transfer is seen on the network, not final yet
invoice.partially_paid Some money is confirmed, but not enough
invoice.paid The invoice is fully paid. paid_late is true if that happened after it expired or was cancelled
invoice.expired Time ran out before full payment. amount_paid is above zero if part was paid
invoice.cancelled You cancelled it
invoice.refunded A refund you recorded was verified on the network. The status does not change
invoice.overpaid More money arrived on an invoice that was already paid
invoice.awaiting_bank_transfer The customer chose bank transfer. Hold the order; do not cancel it
invoice.held_for_review A payment came from a sanctioned address. See Sanctions holds

invoice.partially_paid is also sent for a late part payment on an expired or cancelled invoice. The status is then unchanged and paid_late is true.

Verify the signature

Every event carries two headers:

MintagePay-Signature: t=1759341234,v1=5f1c...
MintagePay-Event-Id: evt_...

v1 is the hex HMAC-SHA256 of t, a dot and the raw request body, keyed with your endpoint's secret. To verify:

  1. Compute HMAC_SHA256(secret, t + "." + rawBody) on the body exactly as received, before any JSON parsing.
  2. Compare it with v1 in constant time.
  3. Reject the event if t is more than 300 seconds away from now.

Node.js:

import crypto from 'node:crypto';

export function verifyMintagePay(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.trim().split('=')));
  if (!/^\d+$/.test(parts.t ?? '') || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

PHP:

function verify_mintagepay(string $rawBody, string $signatureHeader, string $secret): bool {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $p) {
        $kv = explode('=', trim($p), 2);
        if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; }
    if (abs(time() - (int) $parts['t']) > 300) { return false; }
    return hash_equals(hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret), $parts['v1']);
}

Python:

import hashlib, hmac, time

def verify_mintagepay(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(int(time.time()) - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Answering and retries

Answer 2xx within 10 seconds. Anything else is retried with growing pauses: 30 seconds, 1 minute, 2 minutes and so on up to 6 hours, for 12 attempts in all. Redirects are not followed, so register the final address.

Events can arrive more than once or out of order. Before acting:

Answer 2xx to event types your integration does not handle. An error is retried.

Test events

"Send test event" in the dashboard, or POST /v1/webhook-endpoints/{id}/test, sends one event with "type": "webhook.test". It is signed like a real event and carries data.webhook_endpoint (id, url) and a message, but no invoice. It is not retried.

Sanctions holds

Every paying address is checked against the US sanctions list (OFAC SDN). On a match:

Treat held_for_review: true as "not paid".

Last updated . This page as Markdown. Something missing or wrong? Write to hello@mintagepay.com.