Webhooks
Webhooks notify your backend about payment events as they happen, so you don't need to poll. MiraclePay sends a signed POST request to your HTTPS endpoint whenever a subscribed event occurs.
Setup
Log in to the merchant panel and navigate to Settings → Developer → Webhooks
Click Add Endpoint, enter your HTTPS URL, and choose the event types to subscribe to
Copy the endpoint's signing secret (starts with
whsec_) — you need it to verify deliveriesUse the Send test event action to deliver a
webhook.testevent and confirm your handler works
You can register up to 10 endpoints per merchant account.
Event Types
Event |
Fired when |
|---|---|
|
A payment prompt is created. |
|
The payment is confirmed (status became |
|
The payment failed (status became |
|
The prompt expired without a completed payment. |
|
The payment was cancelled. |
|
Sent only via the dashboard's test action; not subscribable as a regular event. |
Note
There is no refund event. Refunds are not available via the API — see Refunds.
Event Envelope
Every delivery is a JSON body with this shape:
{
"id": "evt_7d3f8a2b-1c4e-4f6a-9b8d-2e5c7a9f1b3d",
"type": "payment.succeeded",
"apiVersion": "v1",
"createdAt": "2026-07-21T14:35:02.000Z",
"data": {
"object": {
"object": "payment_prompt",
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "successful",
"sessionStatus": "completed",
"amount": 1099,
"commissionAmount": 22,
"commissionPayer": "merchant",
"settlementModel": "split",
"blockchainId": "eth-usdc",
"tokenAmount": "10.99",
"transactionHash": "0xabc...",
"paidAt": "2026-07-21T14:35:00.000Z",
"expiresAt": "2026-07-21T15:00:00.000Z",
"createdAt": "2026-07-21T14:30:00.000Z",
"redirectUrl": "https://shop.example.com/order/12345/complete",
"externalId": null,
"customerReferenceId": "order_12345",
"cancellationReason": null,
"lastError": null
},
"previousAttributes": {
"status": "pending"
}
}
}
id— unique event ID (evt_prefix). Use it for deduplication.data.object— a snapshot of the payment in the same shape as the API's payment object, plusexternalId.data.previousAttributes— the changed fields' previous values (present when there was a previous status).
Delivery Headers
Header |
Description |
|---|---|
|
|
|
The event's |
|
The event's |
|
Identifies the event/endpoint delivery. The same ID is retained across automatic and manual retries; it is not an attempt ID. |
|
|
Verifying Signatures
Every delivery is signed so you can confirm it genuinely came from MiraclePay. The scheme:
Parse
X-MiraclePay-Signatureinto thettimestamp and one or morev1candidate signatures.Reject if
tis more than 5 minutes (300 seconds) from the current time.Compute
HMAC-SHA512(secret, "<t>." + rawBody)as lowercase hex.Accept if the expected value matches any
v1candidate, using a constant-time comparison.
Warning
Verify against the raw request body bytes — parse the JSON only after verification. Re-serializing a parsed body changes key order and whitespace and breaks the signature. In Express, capture the raw body (e.g. express.raw({ type: "application/json" })); in Next.js route handlers, use await request.text().
Reference verifier (Node.js / TypeScript):
import { createHmac, timingSafeEqual } from "node:crypto";
function safeCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
export function verifyWebhookSignature(
secret: string,
signatureHeader: string,
rawBody: string,
toleranceSeconds = 300,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
const parts = signatureHeader.split(",").map((part) => part.trim());
let timestamp: string | null = null;
const candidates: string[] = [];
for (const part of parts) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const key = part.slice(0, eq);
const value = part.slice(eq + 1);
if (key === "t") timestamp = value;
if (key === "v1") candidates.push(value);
}
if (!timestamp || candidates.length === 0) return false;
const timestampSeconds = Number(timestamp);
if (!Number.isFinite(timestampSeconds)) return false;
if (Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds) return false;
const expected = createHmac("sha512", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return candidates.some((candidate) =>
safeCompare(expected, candidate.toLowerCase()),
);
}
Express handler example:
import express from "express";
import { verifyWebhookSignature } from "./verify";
const app = express();
app.post(
"/webhooks/miraclepay",
express.raw({ type: "application/json" }), // keep the raw bytes
async (req, res) => {
const signature = req.header("X-MiraclePay-Signature") ?? "";
const rawBody = req.body.toString("utf8");
if (!verifyWebhookSignature(process.env.MPAY_WEBHOOK_SECRET!, signature, rawBody)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
try {
// Application-provided durable enqueue, deduplicated by event.id.
// A worker processes accepted events and fulfills each payment once.
await persistEventForProcessing(event);
return res.sendStatus(200);
} catch {
// Do not acknowledge an event that was not durably accepted.
return res.sendStatus(503);
}
},
);
Delivery and Retries
A delivery counts as successful when your endpoint responds with a 2xx status within 10 seconds.
Failed deliveries are retried with increasing delays — 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 8 h — up to 10 attempts spanning roughly 24 hours, after which the delivery is marked exhausted. You can retry an exhausted delivery manually from the dashboard.
After 20 consecutive failures spanning at least 24 hours, the endpoint is automatically disabled. Re-enable it from the dashboard once fixed.
Delivery history (attempts, response codes) is visible per endpoint in the dashboard.
Best Practices
Persist/enqueue durably, then respond 2xx promptly. Process asynchronously after durable acceptance; acknowledging before saving can lose an event. Long-running handlers hit the 10-second timeout and get retried.
Deduplicate by event
id. Retries retain the event and delivery IDs. Also make fulfillment idempotent per payment ID: reconciliation can emit a separate event for the same final state.Treat webhooks as notifications, not proof. Before fulfilling an order, confirm the state with
GET /external/v1/payments/:id— see Checking Payment Status.Handle out-of-order delivery. Retries mean a
payment.createdcan arrive afterpayment.succeeded. An event snapshot andpreviousAttributesdescribe that event, not necessarily the latest state. Retrieve the payment to resolve conflicting or stale notifications.Rotate secrets safely. After rotating an endpoint's secret in the dashboard, deliveries carry two
v1signatures for one hour — your verifier accepts either, so you can switch secrets without dropping events.