# Metronome Webhooks

## When to Use This Skill

* How do I receive Metronome webhooks?
* How do I verify the `Metronome-Webhook-Signature` header?
* Why is my Metronome webhook signature verification failing?
* How do I handle `alerts.spend_threshold_reached`, `contract.create` or `invoice.finalized` events?
* How do I deduplicate Metronome webhook retries?

## Verification (core)

Metronome sends the signature as bare lowercase hex in `Metronome-Webhook-Signature`. It is HMAC-SHA256, keyed with the webhook's secret key (UTF-8 bytes, used as-is), over `X-Metronome-Date` + a newline (LF, `0x0A`) + the **raw request body bytes**. Use `X-Metronome-Date`, not `Date` (intermediaries may rewrite `Date`). No official SDK helper exists, so verify manually.

```javascript
const crypto = require('crypto');

function verifyMetronomeSignature(rawBody /* Buffer */, signature, dateHeader, secret) {
  if (!signature || !dateHeader || !secret) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // validate before decoding
  const signed = Buffer.concat([Buffer.from(dateHeader, 'latin1'), Buffer.from('\n'), rawBody]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(signed).digest();
  const provided = Buffer.from(signature.toLowerCase(), 'hex');
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
}

```

Python:

```python
import hashlib, hmac, re

def verify(raw_body: bytes, signature: str, date_header: str, secret: str) -> bool:
    if not signature or not date_header or not secret or not re.fullmatch(rb"[0-9a-fA-F]{64}", signature.encode("latin-1")):
        return False
    signed = b"".join([date_header.encode("latin-1"), b"\n", raw_body])
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
    return hmac.compare_digest(signature.encode("latin-1").lower(), expected)

```

Return `401` for a missing or malformed signature or header, or a mismatch. Read the body as bytes (Express: `express.raw({ type: '*/*' })`; Next.js: `await request.arrayBuffer()`; FastAPI: `await request.body()`) and parse JSON only after the check.

**Timestamp window is off by default.** Hookdeck retries and replays resend the original `X-Metronome-Date`, and Hookdeck itself applies no tolerance, so a fixed window would reject legitimate retries. The examples offer an opt-in `METRONOME_WEBHOOK_TOLERANCE_SECONDS` (unset or `0` = off); `X-Metronome-Date` is an HTTP date such as `Mon, 02 Jan 2006 22:04:05 GMT`.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/metronome-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/metronome-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/metronome-webhooks/examples/fastapi/).

## Common Event Types

Dispatch on the body's `type`. Deduplicate on the body's `id` (duplicates are possible, and a status above 299 is retried for up to two days).

| Event | Description |
| --- | --- |
| `alerts.spend_threshold_reached` | A spend threshold alert fired |
| `alerts.low_remaining_credit_balance_reached` | Remaining credit balance fell below the threshold |
| `alerts.low_remaining_commit_balance_reached` | Remaining commit balance fell below the threshold |
| `alerts.usage_threshold_reached` | A usage threshold alert fired |
| `alerts.invoice_total_reached` | An invoice total threshold was reached |
| `contract.create` / `contract.start` / `contract.edit` / `contract.end` / `contract.archive` | Contract lifecycle |
| `commit.create` | A commit was created |
| `credit.create` | A credit was created |
| `invoice.finalized` / `invoice.void` | Invoice finalized or voided |
| `invoice.billing_provider_error` | Pushing an invoice to the billing provider failed |
| `subscription.start` / `subscription.end` | Subscription lifecycle |

Metronome documents 34 types (more commit, credit, marketplace and integration events); the examples log any type they don't handle.

> **For the full list**, see [Metronome webhook documentation](https://docs.metronome.com/guides/platform-configuration/setup-webhooks).

## Environment Variables

```bash
METRONOME_WEBHOOK_SECRET=your_webhook_secret_key   # Per-webhook secret key
METRONOME_WEBHOOK_TOLERANCE_SECONDS=300            # Optional; unset or 0 = no timestamp window

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 metronome --path /webhooks/metronome

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/metronome-webhooks/references/overview.md) - Metronome webhook concepts and events
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/metronome-webhooks/references/setup.md) - Configure a webhook and get the secret key
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/metronome-webhooks/references/verification.md) - Signature verification details and gotchas