.. _webhooks: 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 ----------- .. list-table:: :widths: 28 72 :header-rows: 1 * - 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: .. code-block:: json { "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 :ref:`payment object `, plus ``externalId``. - ``data.previousAttributes`` — the changed fields' previous values (present when there was a previous status). Delivery Headers ---------------- .. list-table:: :widths: 34 66 :header-rows: 1 * - Header - Description * - ``X-MiraclePay-Signature`` - ``t=,v1=[,v1=]`` — 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, "." + 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):** .. code-block:: 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:** .. code-block:: typescript 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 :ref:`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.