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. |
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 |
|
Unique per delivery attempt — differs across retries of the same event. |
|
|
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
(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);
// Acknowledge fast; process asynchronously
res.sendStatus(200);
processEventAsync(event);
},
);
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
Respond 2xx immediately, process asynchronously. Long-running handlers hit the 10-second timeout and get retried, causing duplicate processing.
Deduplicate by event
id. Retries deliver the same event again (with a newX-MiraclePay-Delivery-Id); make your handler idempotent per eventid.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. Use the payment'sstatus(orpreviousAttributes) rather than assuming event order.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.