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:
- Compute
HMAC_SHA256(secret, t + "." + rawBody)on the body exactly as received, before any JSON parsing. - Compare it with
v1in constant time. - Reject the event if
tis 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:
- re-read
data.invoice.status, or callGET /v1/invoices/{id}; - make your handler safe to run twice for the same order.
MintagePay-Event-Idis the event'sid, so a repeat of the same event carries the same value.
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:
- the invoice gets
held_for_review: trueand you receiveinvoice.held_for_review; invoice.paidis not sent while the hold lasts, and a fully paid held invoice reportsstatus: "held_for_review", neverpaid;- do not ship and do not refund.
invoice.paidis sent if the hold is released.
Treat held_for_review: true as "not paid".
Last updated . This page as Markdown. Something missing or wrong? Write to hello@mintagepay.com.