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. |
|
A full ZeroHash refund record is created and linked to the original payment, after commit and before the shopper claim email. Funds have not yet been returned. |
|
ZeroHash reports settlement and the refund is saved as completed. |
|
Sent only via the dashboard's test action; not subscribable as a regular event. |
Note
HTTP 201 and refund.initiated confirm initiation, not payout. The original payment remains successful; refund completion does not emit payment.succeeded. See Refunds and Refund Notifications.
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— for payment events, a snapshot in the same shape as the API's payment object, plusexternalId. Refund events use the separate object below.data.previousAttributes— the changed fields' previous values (present when there was a previous status).
Refund Notifications
Both the merchant and its currently linked, enabled partner can receive refund.initiated and refund.completed, whether initiation came from the API or merchant dashboard. Each must subscribe its own endpoint to the desired events in its dashboard/partner portal or webhook endpoint API. Adding a merchant subscription does not subscribe the partner, and a partner does not need a merchant endpoint to receive its own notifications.
Existing subscriptions are not automatically extended. Preserve existing events when adding the new ones. The order of entries in eventTypes (stored as event_types) does not matter and does not determine delivery order. Endpoints must be active, and webhook processing must be enabled.
For example, the following merchant delivery reports completion:
{
"id": "evt_7d3f8a2b-1c4e-4f6a-9b8d-2e5c7a9f1b3d",
"type": "refund.completed",
"apiVersion": "v1",
"createdAt": "2026-10-19T13:00:01.000Z",
"data": {
"object": {
"object": "refund",
"id": "550e8400-e29b-41d4-a716-446655440001",
"paymentPromptId": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"amount": 2347,
"currency": "USD",
"completedAt": "2026-10-19T13:00:00.000Z",
"transactionHash": null,
"createdAt": "2026-10-19T12:00:00.000Z"
}
}
}
The refund.initiated payload has the same fields, with status: awaiting_shopper, completedAt: null and transactionHash: null. Neither refund event includes data.previousAttributes.
data.object.idis the refund ID returned by initiation;paymentPromptIdidentifies the original payment. Do not confuse either with the envelope's event ID.amountis integer USD cents:2347means USD 23.47.createdAtinside the object is refund creation time; the envelope timestamp is event emission time, which may be later during recovery.completedAtis the recorded settlement time.transactionHashmay still be null at completion if the provider has not supplied a chain hash; do not require it to process completion.Partner deliveries additionally include a top-level
merchantobject, for example{ "id": "<merchant-uuid>", "name": "Example Store" }. Merchant deliveries omit it. The partner relationship is checked again before delivery.Shopper contact details, claim tokens and provider identifiers are excluded.
Use the same signature verification and delivery retry handling as for payment events. Deduplicate by event ID and make refund processing idempotent by refund ID and event type: recovery can produce another event ID for the same completion. Delivery order is not guaranteed; a delayed refund.initiated must never regress a refund already recorded as completed. The original payment's successful status is not proof that the refund completed; use refund notifications and the refund record in the merchant dashboard/support for reconciliation.
Warning
Like payment.created, refund.initiated is queued after database commit and has no reconciliation replay if that queue push fails. Completion reconciliation recovers missed refund.completed events, including older completed refunds with an unset webhook sync marker. Adding a subscription does not replay all historical events. No refund-failed or refund-expired event is currently available; plan operational follow-up for refunds that do not complete.
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.