Miracle Docs

Webhooks

Webhooks deliver real-time notifications to your server when events occur in your Miracle account. Instead of polling the API for status changes, you register an endpoint and Miracle pushes events to you as they happen.

This guide covers setup, event handling, signature verification, retry behavior, and debugging.


Why use webhooks

  • Payment results are asynchronous. A redirect back to your site does not mean the payment succeeded. The provider may still be processing the transaction. Webhooks tell you the final result.
  • Webhooks are the source of truth for payment status. Always use webhook events to update your order state. Do not rely on redirect URLs or client-side callbacks.
  • Webhooks are required for production. Your integration will not pass the Go-Live Checklist without a working webhook endpoint.

Setup

Register an endpoint

You can register a webhook endpoint in two ways:

  1. Miracle Portal — go to Settings > Webhooks and add your URL.
  2. API — create an endpoint programmatically:
curl -X POST https://api.miracle.com/v1/webhooks/endpoints \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/miracle",
    "events": ["payment.*", "refund.*"]
  }'

When you create an endpoint, Miracle generates a signing secret (whsec_...). This secret is shown once in the response — copy and store it securely.

Requirements

  • Your endpoint URL must use HTTPS. HTTP endpoints are rejected in both sandbox and production.
  • Your endpoint must respond with 200 OK within 10 seconds. If your endpoint takes longer or returns a non-2xx status, Miracle retries the delivery.
  • Subscribe to specific event types using exact names (payment.succeeded) or wildcards (payment.*, *).

Event envelope

Every webhook delivery sends a JSON payload wrapped in an events array:

{
  "events": [
    {
      "id": "evt_01J5K8MQ3R...",
      "type": "payment.succeeded",
      "createdAt": "2026-04-09T12:00:00Z",
      "livemode": false,
      "data": {
        "id": "pay_01J5K8MQ3R...",
        "status": "succeeded",
        "amount": { "currency": "USD", "valueMinor": 5000 }
      },
      "attempt": 1,
      "version": "2026-04-09"
    }
  ]
}

The outer object contains a single key events with an array of event objects. Currently each delivery contains exactly one event.

Each event object has these fields:

FieldDescription
idUnique event ID. Use this for deduplication.
typeEvent type from the event catalog, e.g. payment.succeeded.
createdAtISO 8601 UTC timestamp when the event occurred.
livemodetrue for production events, false for sandbox.
dataThe resource object that triggered the event (e.g. Payment, Refund).
attemptDelivery attempt number, starting at 1.
versionAPI version used to render the event payload.

Signature verification

Every webhook request includes three headers that you must verify before processing the event.

Headers

HeaderDescription
webhook-idUnique message ID (for deduplication).
webhook-timestampUNIX timestamp (seconds) when the event was sent.
webhook-signatureOne or more signatures, space-separated. Multiple signatures are sent during secret rotation.

How signing works

  1. Miracle constructs the signing base: {webhook-id}.{webhook-timestamp}.{raw_body}
  2. The signing base is signed with HMAC-SHA256 using your webhook secret.
  3. The resulting signature is base64-encoded and prefixed with v1,.

Verification steps

  1. Extract the webhook-id, webhook-timestamp, and webhook-signature headers.
  2. Check the timestamp. Reject events where the timestamp is more than 5 minutes old. This prevents replay attacks.
  3. Reconstruct the signing base: {webhook-id}.{webhook-timestamp}.{raw_body}. Use the raw request body bytes — do not re-serialize the JSON.
  4. Compute the HMAC-SHA256 of the signing base using your webhook secret.
  5. Compare the computed signature against each v1, value in the webhook-signature header. If any matches, the signature is valid.
  6. If no signature matches, reject the request with 401.

Never skip signature verification. Without it, an attacker can send fake events to your endpoint and trigger order fulfillment, refunds, or other actions.

Code examples

import crypto from 'crypto';

function verifyWebhook(payload, headers, secret) {
  const msgId = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];

  if (!msgId || !timestamp || !signatures) {
    throw new Error('Missing required webhook headers');
  }

  // Reject timestamps older than 5 minutes
  const tolerance = 5 * 60; // seconds
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > tolerance) {
    throw new Error('Webhook timestamp outside tolerance');
  }

  // Build signing base
  const signedContent = `${msgId}.${timestamp}.${payload}`;

  // Decode secret (remove "whsec_" prefix, base64 decode the rest)
  const secretBytes = Buffer.from(secret.split('_')[1], 'base64');

  // Compute expected signature
  const expected = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');

  // Check against all provided signatures (constant-time to prevent timing attacks)
  const expectedBuf = Buffer.from(expected, 'base64');
  const valid = signatures.split(' ').some((sig) => {
    const parts = sig.split(',', 2);
    if (parts.length !== 2) return false;
    const receivedBuf = Buffer.from(parts[1], 'base64');
    return expectedBuf.length === receivedBuf.length &&
      crypto.timingSafeEqual(expectedBuf, receivedBuf);
  });

  if (!valid) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(payload);
}

// Express example
app.post(
  '/webhooks/miracle',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    try {
      const body = verifyWebhook(
        req.body.toString(),
        req.headers,
        process.env.WEBHOOK_SECRET
      );

      // Events are wrapped in { events: [event] }
      for (const event of body.events) {
        switch (event.type) {
          case 'payment.succeeded':
            // Fulfill the order
            break;
          case 'payment.failed':
            // Notify the customer
            break;
        }
      }

      res.status(200).send('OK');
    } catch (err) {
      console.error('Webhook verification failed:', err.message);
      res.status(400).send('Invalid signature');
    }
  }
);
import hashlib
import hmac
import base64
import json
import time


def verify_webhook(payload: str, headers: dict, secret: str) -> dict:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")

    if not msg_id or not timestamp or not signatures:
        raise ValueError("Missing required webhook headers")

    # Reject timestamps older than 5 minutes
    tolerance = 5 * 60  # seconds
    now = int(time.time())
    if abs(now - int(timestamp)) > tolerance:
        raise ValueError("Webhook timestamp outside tolerance")

    # Build signing base
    signed_content = f"{msg_id}.{timestamp}.{payload}"

    # Decode secret (remove "whsec_" prefix, base64 decode the rest)
    secret_bytes = base64.b64decode(secret.split("_")[1])

    # Compute expected signature
    expected = base64.b64encode(
        hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
    ).decode()

    # Check against all provided signatures
    valid = False
    for sig in signatures.split(" "):
        parts = sig.split(",", 1)
        if len(parts) == 2 and hmac.compare_digest(expected, parts[1]):
            valid = True
            break

    if not valid:
        raise ValueError("Invalid webhook signature")

    return json.loads(payload)


# Flask example
from flask import Flask, request

app = Flask(__name__)

@app.route("/webhooks/miracle", methods=["POST"])
def handle_webhook():
    try:
        body = verify_webhook(
            request.get_data(as_text=True),
            dict(request.headers),
            WEBHOOK_SECRET,
        )
    except ValueError as e:
        return str(e), 400

    # Events are wrapped in { "events": [event] }
    for event in body["events"]:
        if event["type"] == "payment.succeeded":
            # Fulfill the order
            pass
        elif event["type"] == "payment.failed":
            # Notify the customer
            pass

    return "OK", 200
<?php

function verifyWebhook(string $payload, array $headers, string $secret): array
{
    $msgId      = $headers['webhook-id'] ?? null;
    $timestamp  = $headers['webhook-timestamp'] ?? null;
    $signatures = $headers['webhook-signature'] ?? null;

    if (!$msgId || !$timestamp || !$signatures) {
        throw new \Exception('Missing required webhook headers');
    }

    // Reject timestamps older than 5 minutes
    $tolerance = 5 * 60;
    if (abs(time() - intval($timestamp)) > $tolerance) {
        throw new \Exception('Webhook timestamp outside tolerance');
    }

    // Build signing base
    $signedContent = "{$msgId}.{$timestamp}.{$payload}";

    // Decode secret (remove "whsec_" prefix, base64 decode the rest)
    $secretBytes = base64_decode(explode('_', $secret, 2)[1]);

    // Compute expected signature
    $expected = base64_encode(
        hash_hmac('sha256', $signedContent, $secretBytes, true)
    );

    // Check against all provided signatures
    $valid = false;
    foreach (explode(' ', $signatures) as $sig) {
        $parts = explode(',', $sig, 2);
        if (count($parts) === 2 && hash_equals($expected, $parts[1])) {
            $valid = true;
            break;
        }
    }

    if (!$valid) {
        throw new \Exception('Invalid webhook signature');
    }

    return json_decode($payload, true);
}

// Usage
$payload = file_get_contents('php://input');
$headers = [
    'webhook-id'        => $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
    'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
    'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
];

try {
    $body = verifyWebhook($payload, $headers, $webhookSecret);

    // Events are wrapped in { "events": [event] }
    foreach ($body['events'] as $event) {
        switch ($event['type']) {
            case 'payment.succeeded':
                // Fulfill the order
                break;
            case 'payment.failed':
                // Notify the customer
                break;
        }
    }

    http_response_code(200);
    echo 'OK';
} catch (\Exception $e) {
    http_response_code(400);
    echo $e->getMessage();
}
require "openssl"
require "base64"
require "json"

def verify_webhook(payload, headers, secret)
  msg_id     = headers["webhook-id"]
  timestamp  = headers["webhook-timestamp"]
  signatures = headers["webhook-signature"]

  unless msg_id && timestamp && signatures
    raise "Missing required webhook headers"
  end

  # Reject timestamps older than 5 minutes
  tolerance = 5 * 60 # seconds
  if (Time.now.to_i - timestamp.to_i).abs > tolerance
    raise "Webhook timestamp outside tolerance"
  end

  # Build signing base
  signed_content = "#{msg_id}.#{timestamp}.#{payload}"

  # Decode secret (remove "whsec_" prefix, base64 decode the rest)
  secret_bytes = Base64.decode64(secret.split("_", 2)[1])

  # Compute expected signature
  expected = Base64.strict_encode64(
    OpenSSL::HMAC.digest("sha256", secret_bytes, signed_content)
  )

  # Check against all provided signatures
  valid = signatures.split(" ").any? do |sig|
    parts = sig.split(",", 2)
    parts.length == 2 && OpenSSL.fixed_length_secure_compare(expected, parts[1])
  end

  raise "Invalid webhook signature" unless valid

  JSON.parse(payload)
end

# Sinatra example
require "sinatra"

post "/webhooks/miracle" do
  payload = request.body.read

  begin
    body = verify_webhook(
      payload,
      {
        "webhook-id"        => request.env["HTTP_WEBHOOK_ID"],
        "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
        "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"]
      },
      ENV["WEBHOOK_SECRET"]
    )
  rescue => e
    halt 400, e.message
  end

  # Events are wrapped in { "events" => [event] }
  body["events"].each do |event|
    case event["type"]
    when "payment.succeeded"
      # Fulfill the order
    when "payment.failed"
      # Notify the customer
    end
  end

  status 200
  "OK"
end

Retry policy

If your endpoint does not return a 2xx response, Miracle retries the delivery with exponential backoff.

AttemptDelayCumulative
1Immediate0
21 minute1 min
35 minutes6 min
415 minutes21 min
51 hour1h 21m
66 hours7h 21m
76 hours13h 21m
86 hours19h 21m
96 hours25h 21m
106 hours31h 21m
  • Maximum 10 attempts. After all retries are exhausted, the endpoint is marked as failing and an alert is sent to your account.
  • Successful delivery cancels remaining retries. If attempt 3 succeeds, attempts 4-10 are not sent.
  • The attempt field in the event payload tells you which delivery attempt this is.

Delivery guarantees

  • At-least-once delivery. You may receive the same event more than once. Always deduplicate using event.id.
  • Ordering is not guaranteed. Events for different resources may arrive out of order. Do not assume payment.succeeded arrives before refund.created.
  • Event IDs are immutable. The same event always has the same id, regardless of how many times it is delivered.

Best practices

  1. Always verify signatures before processing. See the signature verification section above.
  2. Return 200 OK quickly, then process asynchronously. Enqueue the event in a job queue (Redis, SQS, RabbitMQ) and return 200 immediately. This prevents timeouts.
  3. Return 200 OK even for event types you do not handle. A non-2xx response triggers retries. If you receive an unknown event type, acknowledge it and move on.
  4. Handle events idempotently. Use event.id to check whether you have already processed an event before taking action.
  5. Do not rely on event ordering. Check the current resource state via the API if ordering matters for your logic.
  6. Log event IDs for debugging. When something goes wrong, event IDs let you trace the exact delivery in the Miracle dashboard.

Testing webhooks

  • Local development. Use a tunnel service like ngrok to expose your local server to the internet. Register the tunnel URL as your webhook endpoint in sandbox mode.
  • Dashboard test sender. The Miracle Portal webhook settings page includes a Send test event button that delivers a sample event to your endpoint.
  • Automated testing. See the Webhook Testing Guide for strategies on testing webhook handlers in CI.

Debugging

Common issues

SymptomLikely causeFix
401 or 403 responseSignature verification is failingCheck that you are using the raw request body, not re-serialized JSON. Verify the secret matches.
Timeout (no response)Endpoint processing takes too longOffload processing to a background queue. Return 200 immediately.
Missing eventsWrong endpoint URL or event subscriptionVerify the URL in Portal. Check that your endpoint subscribes to the event types you need.
Duplicate eventsNormal behavior (at-least-once)Deduplicate using event.id before processing.
Events arriving out of orderNormal behaviorDo not assume ordering. Fetch current state from the API if needed.

Checking delivery logs

The Miracle Portal shows delivery history for each webhook endpoint, including:

  • Event type and ID
  • HTTP status code returned by your endpoint
  • Delivery timestamp and attempt number
  • Request and response headers

Use this to diagnose failed deliveries without adding extra logging on your side.


Next steps

On this page