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:
- Miracle Portal — go to Settings > Webhooks and add your URL.
- 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 OKwithin 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:
| Field | Description |
|---|---|
id | Unique event ID. Use this for deduplication. |
type | Event type from the event catalog, e.g. payment.succeeded. |
createdAt | ISO 8601 UTC timestamp when the event occurred. |
livemode | true for production events, false for sandbox. |
data | The resource object that triggered the event (e.g. Payment, Refund). |
attempt | Delivery attempt number, starting at 1. |
version | API version used to render the event payload. |
Signature verification
Every webhook request includes three headers that you must verify before processing the event.
Headers
| Header | Description |
|---|---|
webhook-id | Unique message ID (for deduplication). |
webhook-timestamp | UNIX timestamp (seconds) when the event was sent. |
webhook-signature | One or more signatures, space-separated. Multiple signatures are sent during secret rotation. |
How signing works
- Miracle constructs the signing base:
{webhook-id}.{webhook-timestamp}.{raw_body} - The signing base is signed with HMAC-SHA256 using your webhook secret.
- The resulting signature is base64-encoded and prefixed with
v1,.
Verification steps
- Extract the
webhook-id,webhook-timestamp, andwebhook-signatureheaders. - Check the timestamp. Reject events where the timestamp is more than 5 minutes old. This prevents replay attacks.
- Reconstruct the signing base:
{webhook-id}.{webhook-timestamp}.{raw_body}. Use the raw request body bytes — do not re-serialize the JSON. - Compute the HMAC-SHA256 of the signing base using your webhook secret.
- Compare the computed signature against each
v1,value in thewebhook-signatureheader. If any matches, the signature is valid. - 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"
endRetry policy
If your endpoint does not return a 2xx response, Miracle retries the delivery with exponential backoff.
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 min |
| 3 | 5 minutes | 6 min |
| 4 | 15 minutes | 21 min |
| 5 | 1 hour | 1h 21m |
| 6 | 6 hours | 7h 21m |
| 7 | 6 hours | 13h 21m |
| 8 | 6 hours | 19h 21m |
| 9 | 6 hours | 25h 21m |
| 10 | 6 hours | 31h 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
attemptfield 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.succeededarrives beforerefund.created. - Event IDs are immutable. The same event always has the same
id, regardless of how many times it is delivered.
Best practices
- Always verify signatures before processing. See the signature verification section above.
- Return
200 OKquickly, then process asynchronously. Enqueue the event in a job queue (Redis, SQS, RabbitMQ) and return200immediately. This prevents timeouts. - Return
200 OKeven 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. - Handle events idempotently. Use
event.idto check whether you have already processed an event before taking action. - Do not rely on event ordering. Check the current resource state via the API if ordering matters for your logic.
- 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
| Symptom | Likely cause | Fix |
|---|---|---|
401 or 403 response | Signature verification is failing | Check that you are using the raw request body, not re-serialized JSON. Verify the secret matches. |
| Timeout (no response) | Endpoint processing takes too long | Offload processing to a background queue. Return 200 immediately. |
| Missing events | Wrong endpoint URL or event subscription | Verify the URL in Portal. Check that your endpoint subscribes to the event types you need. |
| Duplicate events | Normal behavior (at-least-once) | Deduplicate using event.id before processing. |
| Events arriving out of order | Normal behavior | Do 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
- Event Types — full catalog of available webhook events.
- Getting Started: Handle Webhooks — quick setup in the context of your first integration.