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 Settings → Developer → Webhooks

  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.

refund.initiated

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.

refund.completed

ZeroHash reports settlement and the refund is saved as completed.

webhook.test

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, plus externalId. 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.id is the refund ID returned by initiation; paymentPromptId identifies the original payment. Do not confuse either with the envelope's event ID.

  • amount is integer USD cents: 2347 means USD 23.47. createdAt inside the object is refund creation time; the envelope timestamp is event emission time, which may be later during recovery.

  • completedAt is the recorded settlement time. transactionHash may 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 merchant object, 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

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

Identifies the event/endpoint delivery. The same ID is retained across automatic and manual retries; it is not an attempt ID.

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
  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

  1. 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.

  2. 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.

  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. An event snapshot and previousAttributes describe that event, not necessarily the latest state. Retrieve the payment to resolve conflicting or stale notifications.

  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.