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
- Create an HTTPS endpoint on your server that accepts
POSTrequests with a JSON body. - Register the URL in the Miracle Portal under Settings > Webhooks. You will receive a signing secret (
whsec_...) — copy it immediately. - Return
200 OKwithin 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:
| 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 |
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
endHandle 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 OKeven for events you do not handle. A non-2xx response triggers retries. - Return
200 OKfor 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.idto deduplicate.
Next step
Your webhook endpoint is ready. Before going live, run through the full checklist:
For the complete webhooks reference — event types, retry policy, secret rotation, and more — see the Webhooks Guide.