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

  1. Log in to the merchant panel and navigate to SettingsDeveloperWebhooks

  2. Click Add Endpoint, enter your HTTPS URL, and choose the event types to subscribe to

  3. Copy the endpoint's signing secret (starts with whsec_) — you need it to verify deliveries

  4. Use the Send test event action to deliver a webhook.test event and confirm your handler works

You can register up to 10 endpoints per merchant account.

Event Types

Event

Fired when

payment.created

A payment prompt is created.

payment.succeeded

The payment is confirmed (status became successful). Fulfill the order.

payment.failed

The payment failed (status became unsuccessful). Inspect lastError on the payment object.

payment.expired

The prompt expired without a completed payment.

payment.cancelled

The payment was cancelled.

webhook.test

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, plus externalId.

  • data.previousAttributes — the changed fields' previous values (present when there was a previous status).

Delivery Headers

Header

Description

X-MiraclePay-Signature

t=<unix seconds>,v1=<hex>[,v1=<hex>] — see Verifying Signatures. Multiple v1 entries appear only during the one-hour grace window after a secret rotation (one per active secret).

X-MiraclePay-Event-Id

The event's id.

X-MiraclePay-Event-Type

The event's type.

X-MiraclePay-Delivery-Id

Unique per delivery attempt — differs across retries of the same event.

Content-Type

application/json.

Verifying Signatures

Every delivery is signed so you can confirm it genuinely came from MiraclePay. The scheme:

  1. Parse X-MiraclePay-Signature into the t timestamp and one or more v1 candidate signatures.

  2. Reject if t is more than 5 minutes (300 seconds) from the current time.

  3. Compute HMAC-SHA512(secret, "<t>." + rawBody) as lowercase hex.

  4. Accept if the expected value matches any v1 candidate, 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

  1. Respond 2xx immediately, process asynchronously. Long-running handlers hit the 10-second timeout and get retried, causing duplicate processing.

  2. Deduplicate by event id. Retries deliver the same event again (with a new X-MiraclePay-Delivery-Id); make your handler idempotent per event id.

  3. Treat webhooks as notifications, not proof. Before fulfilling an order, confirm the state with GET /external/v1/payments/:id — see Checking Payment Status.

  4. Handle out-of-order delivery. Retries mean a payment.created can arrive after payment.succeeded. Use the payment's status (or previousAttributes) rather than assuming event order.

  5. Rotate secrets safely. After rotating an endpoint's secret in the dashboard, deliveries carry two v1 signatures for one hour — your verifier accepts either, so you can switch secrets without dropping events.