# 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_remaining_credit_balance_reached`, `alerts.low_remaining_commit_balance_reached`, `alerts.spend_threshold_reached`, `alerts.usage_threshold_reached`, `alerts.invoice_total_reached` | 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_provider_error` | 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_metering_disabled`, `.azure_metering_disabled`, `.gcp_metering_disabled` | A marketplace customer is disabled |
| Payment gating | `payment_gate.threshold_reached`, `payment_gate.payment_status`, `payment_gate.payment_pending_action_required`, `payment_gate.external_initiate` | 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`:

```json
{
  "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:

```json
{
  "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

1. In the Metronome app, go to **Developer > Notifications > Webhooks > Add** and enter your HTTPS endpoint URL.
2. Store the webhook's secret key server-side, for example as `METRONOME_WEBHOOK_SECRET`. Each webhook has its own key.
3. 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.
4. 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.

```javascript
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:

```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:

```json
{
  "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](/event-gateway) verifies the `Metronome-Webhook-Signature` header over `X-Metronome-Date` and 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](/docs/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 `200` before 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](/docs/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_id}/invoices/{invoice_id}`, before acting.
* Validate only the fields documented when you integrated, since Metronome may add fields without notice.

**How Hookdeck Can Help:** Hookdeck [transformations](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Metronome webhooks reliably in minutes.