Guide to Metronome Webhooks: Features and Best Practices
Metronome webhooks tell your systems when something changes in your usage-based billing: a customer's credit balance runs low, spend crosses a threshold, a contract starts or ends, an invoice is finalised, or a payment needs attention. If you bill on usage with Metronome, webhooks are how those changes reach your product, your customer emails and your internal alerting without polling the API.
This guide covers how Metronome webhooks are configured and signed, the notification types and their two payload shapes, Metronome's retry and duplicate behaviour, and the practices that keep a Metronome receiver reliable.
What are Metronome webhooks?
In Metronome, a webhook is a destination URL, and a notification is a rule that decides when to send something. You add webhook URLs once, then configure notifications separately, and every notification goes to every webhook URL. Each delivery is a JSON POST with the notification's id and type.
Metronome signs each request. The Metronome-Webhook-Signature header carries a hex HMAC-SHA256 of the X-Metronome-Date header, a newline and the exact request body, keyed with that webhook's secret key. Metronome also sends a standard Date header with the same value, but recommends X-Metronome-Date, because proxies and frameworks can rewrite Date.
Metronome says its notifications "contain only minimal information about the event", and suggests treating a notification as a hint to fetch the full object from the API.
Metronome webhook features
| Feature | Details |
|---|---|
| Configuration | Developer > Notifications > Webhooks > Add for destinations, Create Notification for rules |
| Delivery model | Every notification goes to every webhook URL |
| Signature header | Metronome-Webhook-Signature, bare hex |
| Date header | X-Metronome-Date, an HTTP date such as Mon, 02 Jan 2006 22:04:05 GMT |
| Scheme | HMAC-SHA256, hex |
| Signed content | <X-Metronome-Date>\n<raw body>, with a newline |
| Secret | One per webhook |
| Success | Any status of 299 or below |
| Retries | Exponential backoff to a 15-minute cadence, until accepted or two days pass (about 200 retries) |
| Duplicates | Possible. Deduplicate on id |
| Source IPs | 12 published addresses, changed with at least 30 days' notice |
| Schema changes | Fields may be added without notice |
Common events
Metronome groups its notifications into families, and the body's type names the notification:
| Family | Types | Fires when |
|---|---|---|
| Threshold | alerts.low_, alerts.low_, alerts.spend_, alerts.usage_, alerts.invoice_ | A real-time metric crosses a threshold you defined |
| System | contract.create, .start, .edit, .end, .archive; commit.create, .edit, .archive, .segment.start, .segment.end; the same for credit.*; subscription.create, .start, .end; invoice.void | An action happens, such as a contract being created, or an object's configured date arrives, such as a contract start |
| Offset | System types, scheduled relative to a date | A set time before or after a known date, such as a commit's end |
| Invoices | invoice.finalized | An invoice is finalised, after the grace period. Set up by Metronome support |
| Billing provider | invoice.billing_ | Sending an invoice to Stripe fails. Sent automatically when the Stripe integration is on |
| Integrations | integration.issue | A third-party integration has an error, such as invalid credentials |
| Marketplaces | marketplaces.aws_, .azure_metering_disabled, .gcp_metering_disabled | A marketplace customer is disabled |
| Payment gating | payment_, payment_, payment_, payment_ | A payment-gated workflow needs a payment, reports a payment's result, needs an extra step such as 3-D Secure, or hands payment to your own gateway |
There are two body shapes. Threshold, invoice, integration, marketplace and payment gating notifications nest their fields under properties:
{
"id": "445b849e-1366-4580-a6cc-f488e27c059f",
"properties": {
"customer_id": "ac39ecc3-87ee-4d58-8ec0-24041464dddd",
"alert_id": "445b849e-1366-4580-a6cc-f488e27c059f",
"timestamp": "2024-10-07T15:08:44.865Z",
"threshold": 20000,
"alert_name": "Credit balance low",
"credit_type_id": "2714e483-4ff1-48e4-9e25-ac732e8f24f2",
"remaining_balance": 5000,
"triggered_by": "usage"
},
"type": "alerts.low_remaining_credit_balance_reached"
}
System and offset notifications are flat, with an environment_type and the custom fields of the objects involved:
{
"id": "fca7b4ef-6187-51d5-a9d9-b57f40117728",
"type": "contract.create",
"timestamp": "2025-09-12T20:20:04Z",
"environment_type": "PRODUCTION",
"contract_id": "33d206f6-7455-49f6-857d-2172c37db68d",
"contract_custom_fields": { "org_key": "g_major" },
"customer_id": "1ed86915-97f1-4d92-8fa6-763c0235093a",
"customer_custom_fields": { "bill_customer_id": "0cu21JDIRIUDQKDS6wmx" }
}
Read customer_id from properties for the first shape and from the top level for the second. The payment gating examples in Metronome's docs contain trailing commas, so they aren't valid JSON as published. Don't paste them into tests as they are.
Setting up Metronome webhooks
- In the Metronome app, go to Developer > Notifications > Webhooks > Add and enter your HTTPS endpoint URL.
- Store the webhook's secret key server-side, for example as
METRONOME_WEBHOOK_SECRET. Each webhook has its own key. - Go to Developer > Notifications > Create Notification and create the notifications you need, such as Low credit balance reached. For
invoice.finalized, contact Metronome through its support portal. - Trigger a notification and check that it reaches your endpoint and verifies.
Add each URL only once. Metronome sends every notification to every destination, so the same URL added twice receives everything twice.
If you receive Metronome webhooks through Hookdeck, create a source with the Metronome type, add its URL as the webhook destination in Metronome, and paste that webhook's secret key into the source. The source accepts POST only, which is the method Metronome uses.
Securing Metronome webhooks
Read X-Metronome-Date and Metronome-Webhook-Signature. Build the signed bytes from the date header exactly as received, a newline (\n), and the raw request body. Compute an HMAC-SHA256 of them with the secret key as a UTF-8 key, hex-encode it, and compare it with the header in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyMetronomeSignature(rawBody, signatureHeader, dateHeader, secret, toleranceSeconds = 0) {
if (!signatureHeader || !dateHeader || !secret) return false;
const signature = signatureHeader.trim();
if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // check before Buffer.from(x, 'hex')
// Optional replay window, off by default. Retries and replays resend the original date.
if (toleranceSeconds > 0) {
const sentAt = Date.parse(dateHeader); // an HTTP date, e.g. "Mon, 02 Jan 2006 22:04:05 GMT"
if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) / 1000 > toleranceSeconds) return false;
}
// X-Metronome-Date, a newline, then the raw body bytes.
const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${dateHeader}\n`, 'utf8'), rawBody]))
.digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/metronome', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.METRONOME_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
// Use X-Metronome-Date, not Date: intermediaries may rewrite Date.
const valid = verifyMetronomeSignature(
req.body,
req.get('metronome-webhook-signature'),
req.get('x-metronome-date'),
secret
);
if (!valid) return res.sendStatus(401);
const notification = JSON.parse(req.body.toString('utf8'));
// Deduplicate on notification.id, route on notification.type, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import re
import time
from email.utils import parsedate_to_datetime
def verify_metronome_signature(raw_body: bytes, signature_header, date_header, secret: str,
tolerance_seconds: int = 0) -> bool:
if not signature_header or not date_header or not secret:
return False
signature = signature_header.strip()
if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
return False
# Optional replay window, off by default.
if tolerance_seconds > 0:
try:
sent_at = parsedate_to_datetime(date_header).timestamp()
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > tolerance_seconds:
return False
signed = date_header.encode("utf-8") + b"\n" + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_metronome_signature(raw_body, request.headers.get("metronome-webhook-signature"),
# request.headers.get("x-metronome-date"),
# os.environ["METRONOME_WEBHOOK_SECRET"])
To check your verifier, use Metronome's own example. The secret key is correct-horse-battery-staple, X-Metronome-Date is Mon, 02 Jan 2006 22:04:05 GMT, and the body is exactly this, with two-space indentation and no trailing newline:
{
"id": "b2c9e307-624e-4e7d-a5a4-1b74107d78c4",
"type": "widget_created",
"properties": {
"customer_id": "5f794d50-085a-4db6-8d15-286e518b7225",
"widget_id": "0891458d-b6f0-4fdd-a41e-380aae1a1e38"
}
}
The signature should be:
b82652fa2246cf1d8a27e591f155c865f68b46c19b9213fd9c052f2419b4742b
Metronome warns that "THE BODY MUST BE TREATED AS BYTES": re-serialising parsed JSON "is not guaranteed to produce the same JSON".
Metronome says the date header "is included to aid in deduplication", and that you should ignore requests older than five minutes so you only need to store recent notification IDs. The samples above leave that check off unless you pass a tolerance. Hookdeck applies no timestamp tolerance, and its retries and replays resend Metronome's original X-Metronome-Date, so a five-minute window in your own code would drop a legitimate retry that arrives later. If you rely on a window to bound your deduplication store, keep the store at least as long as the oldest delivery you accept.
Make Metronome webhooks production-ready. Hookdeck Event Gateway verifies the
Metronome-Webhook-Signatureheader overX-Metronome-Dateand the raw body, stores every billing notification, and delivers it to your service with retries you control.
Metronome webhook limitations and pain points
A proxy rewrites the date and breaks verification
The Problem: Verification works locally and fails in production.
Why It Happens: The code reads the standard Date header, which Metronome still supports for backward compatibility but which load balancers, proxies and frameworks can overwrite. The signature covers the original value.
Workarounds:
- Use
X-Metronome-Date, which Metronome says "is preserved end-to-end". - Use the newline as the separator, not a dot or a space.
How Hookdeck Can Help: Hookdeck's Metronome source verifies with X-Metronome-Date only. Hookdeck answers Metronome with a 200 either way, then records a request that fails the check as failed verification, so it never reaches your destination, and you can see the exact headers that arrived.
Every notification goes to every URL
The Problem: Each service receives the full stream, and adding a second URL doubles the traffic.
Why It Happens: Notifications aren't routed per destination. Metronome sends each one to all configured webhooks, including the same URL twice if it is added twice.
Workarounds:
- Use a single URL and dispatch internally on
type. - Deduplicate on
id, as Metronome recommends.
How Hookdeck Can Help: Point Metronome at one Hookdeck source and use filters on type to send threshold alerts, contract changes and invoices to different services, so each one sees only what it needs.
Two days of retries, then nothing
The Problem: After a long outage, notifications from the start of it are gone.
Why It Happens: Metronome retries with exponential backoff, settles on a 15-minute cadence, and stops two days after the first attempt.
Workarounds:
- Acknowledge with a
200before you process, as Metronome recommends, so slow processing never causes a retry. - After an outage, reconcile against the API for contracts, balances and invoices.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with retries you configure and lets you replay any event, so your own downtime doesn't run down Metronome's two-day clock.
Notifications carry minimal data
The Problem: The webhook doesn't contain enough to act on, such as invoice line items.
Why It Happens: Metronome keeps notifications small and points you to the API for the details.
Workarounds:
- Fetch the object, for example
/customers/{customer_, before acting.id}/invoices/{invoice_ id} - Validate only the fields documented when you integrated, since Metronome may add fields without notice.
How Hookdeck Can Help: Hookdeck transformations can normalise the two body shapes, for example lifting properties.customer_id to the top level, before the event reaches your code.
Best practices
Verify against the raw body
The signature covers the exact bytes Metronome sent. In Express, use express.raw({ type: '*/*' }). In Next.js, use Buffer.from(await request.arrayBuffer()). In FastAPI, use await request.body().
Deduplicate on id
Retries and multiple destinations both produce duplicates. Store each id before processing and skip repeats. See our guide to webhook idempotency.
Handle both body shapes
Branch on whether properties is present, and log any type you don't recognise rather than failing, so a new notification type doesn't trigger two days of retries.
Acknowledge fast and process asynchronously
Metronome recommends storing the payload in a queue, returning 200, and processing afterwards. See why to process webhooks asynchronously.
Allowlist Metronome's IPs if you need to
Metronome publishes 12 source IP addresses and gives at least 30 days' notice of changes. An allowlist adds a second check alongside the signature, but update it when Metronome announces a change.
Conclusion
Metronome signs every notification with a hex HMAC-SHA256 of X-Metronome-Date, a newline and the raw body. Verify with X-Metronome-Date rather than Date, test against Metronome's published example, deduplicate on id, handle both body shapes, and acknowledge quickly so the two-day retry window is never in play.
Hookdeck Event Gateway sits in front of your receiver, verifies every Metronome signature, and holds each billing notification until your service has processed it.
Get started with Hookdeck for free and handle Metronome webhooks reliably in minutes.