Miracle Docs

Handle Webhooks

Step 3 of 4. Webhooks notify your server when events happen — payments succeed, fail, or need action.


Why webhooks

  • Payment results are asynchronous. A redirect back to your site does not mean the payment succeeded. Do not rely on the redirect alone to fulfill orders.
  • Webhooks are the single source of truth for payment status. Always use the webhook event to update your order state, not the redirect URL or client-side callbacks.
  • Webhooks are required for production. Your integration will not pass the Go-Live Checklist without a working webhook endpoint.

Set up your endpoint

  1. Create an HTTPS endpoint on your server that accepts POST requests with a JSON body.
  2. Register the URL in the Miracle Portal under Settings > Webhooks. You will receive a signing secret (whsec_...) — copy it immediately.
  3. Return 200 OK within 10 seconds. If your endpoint takes longer or returns a non-2xx status, Miracle will retry the delivery on the following schedule: 1 min, 5 min, 15 min, 1 hour, 6 hours (repeating at 6-hour intervals) for 9 retries after the initial attempt (10 total).

Your endpoint URL must use HTTPS. HTTP endpoints are rejected in both sandbox and production environments.


Verify the signature

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

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

The signing base is {webhook-id}.{webhook-timestamp}.{raw_body}, signed with HMAC-SHA256 using your webhook secret.

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

import crypto from 'crypto';

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

  // Check timestamp (reject if older than 5 minutes)
  const tolerance = 5 * 60;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > tolerance) {
    throw new Error('Webhook timestamp too old');
  }

  // Compute expected signature
  const signedContent = `${msgId}.${timestamp}.${payload}`;
  const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
  const expected = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');

  // Compare signatures (constant-time to prevent timing attacks)
  const received = signature.split(' ')
    .map(s => s.split(',')[1])
    .find(s => s);

  if (!received || !crypto.timingSafeEqual(
    Buffer.from(expected, 'base64'),
    Buffer.from(received, 'base64')
  )) {
    throw new Error('Invalid webhook signature');
  }
}
import hashlib
import hmac
import base64
import time

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

    # Check timestamp (reject if older than 5 minutes)
    tolerance = 5 * 60
    now = int(time.time())
    if abs(now - int(timestamp)) > tolerance:
        raise ValueError("Webhook timestamp too old")

    # Compute expected signature
    signed_content = f"{msg_id}.{timestamp}.{payload}"
    secret_bytes = base64.b64decode(secret.split("_")[1])
    expected = base64.b64encode(
        hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
    ).decode()

    # Compare signatures
    received = None
    for sig in signature.split(" "):
        parts = sig.split(",", 1)
        if len(parts) == 2:
            received = parts[1]
            break

    if not hmac.compare_digest(expected, received or ""):
        raise ValueError("Invalid webhook signature")
function verifyWebhook(string $payload, array $headers, string $secret): void
{
    $msgId     = $headers['webhook-id'];
    $timestamp = $headers['webhook-timestamp'];
    $signature = $headers['webhook-signature'];

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

    // Compute expected signature
    $signedContent = "{$msgId}.{$timestamp}.{$payload}";
    $secretBytes   = base64_decode(explode('_', $secret, 2)[1]);
    $expected      = base64_encode(
        hash_hmac('sha256', $signedContent, $secretBytes, true)
    );

    // Compare signatures
    $received = null;
    foreach (explode(' ', $signature) as $sig) {
        $parts = explode(',', $sig, 2);
        if (count($parts) === 2) {
            $received = $parts[1];
            break;
        }
    }

    if (!hash_equals($expected, $received ?? '')) {
        throw new \Exception('Invalid webhook signature');
    }
}
require "openssl"
require "base64"

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

  # Check timestamp (reject if older than 5 minutes)
  tolerance = 5 * 60
  if (Time.now.to_i - timestamp.to_i).abs > tolerance
    raise "Webhook timestamp too old"
  end

  # Compute expected signature
  signed_content = "#{msg_id}.#{timestamp}.#{payload}"
  secret_bytes   = Base64.decode64(secret.split("_", 2)[1])
  expected       = Base64.strict_encode64(
    OpenSSL::HMAC.digest("sha256", secret_bytes, signed_content)
  )

  # Compare signatures
  received = signature.split(" ")
    .map { |s| s.split(",", 2)[1] }
    .compact
    .first

  unless received && OpenSSL.fixed_length_secure_compare(expected, received)
    raise "Invalid webhook signature"
  end
end

Handle events

Parse the verified payload and route by event type. The webhook body is an envelope containing an events array — even for single-event deliveries. Here is a minimal Express handler that processes payment results:

app.post('/webhooks/miracle', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString();
  const headers = req.headers;

  try {
    verifyWebhook(payload, headers, process.env.WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send('Invalid signature');
  }

  const { events } = JSON.parse(payload);

  for (const event of events) {
    switch (event.type) {
      case 'payment.succeeded':
        // Fulfill the order
        break;
      case 'payment.failed':
        // Notify the customer
        break;
    }
  }

  res.status(200).send('OK');
});

Security tips

  • Always verify signatures before processing any event.
  • Return 200 OK even for events you do not handle. A non-2xx response triggers retries.
  • Return 200 OK for unknown event IDs to prevent information leakage.
  • Process events idempotently. Miracle guarantees at-least-once delivery, so you may receive the same event more than once. Use event.id to deduplicate.

Next step

Your webhook endpoint is ready. Before going live, run through the full checklist:

Go-Live Checklist -->

For the complete webhooks reference — event types, retry policy, secret rotation, and more — see the Webhooks Guide.

On this page