# Guide to OpenMeter Webhooks: Features and Best Practices

OpenMeter webhooks tell your systems when usage crosses a line that matters: a customer has used 80% of their credits, an entitlement has reset for a new period, or an invoice has been created or changed state. If you meter usage and enforce limits with OpenMeter, webhooks are how your product, billing and customer messaging react without polling OpenMeter's API.

This guide covers how OpenMeter notifications are configured with channels and rules, how deliveries are signed, the four event types and their payloads, and the best practices for running an OpenMeter receiver in production.

## What are OpenMeter webhooks?

OpenMeter calls them notifications. Two objects set them up:

* A **channel** is the destination. The only channel type is `WEBHOOK`, which sends a `POST` to a URL, with any custom headers you add and a signing secret.
* A **rule** picks an event type, its conditions (such as thresholds or a feature filter), and the channels to send to. One channel can serve several rules.

When a rule matches, OpenMeter creates an event and delivers it through Svix, which signs each request with the headers `svix-id`, `svix-timestamp` and `svix-signature`.

Every payload has the same envelope: `id`, `timestamp`, `type` and `data`. The `data` is contextual, so the receiver "doesn't have to make additional calls to the OpenMeter APIs to gather it".

## OpenMeter webhook features

| Feature | Details |
| --- | --- |
| Configuration | Notification channels and rules, through `/api/v1/notification/channels` and `/api/v1/notification/rules` |
| Channel type | `WEBHOOK` only. A `POST` to your URL |
| Custom headers | Optional `customHeaders` on the channel |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | Svix: HMAC-SHA256, base64, over `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | The channel's `signingSecret`: base64 random bytes, optionally prefixed with `whsec_` |
| Event name | Top-level `type` field |
| Event ID | Top-level `id` (a ULID) |
| Event log | `GET /api/v1/notification/events`, with delivery status per channel |
| Resend | `POST /api/v1/notification/events/{eventId}/resend` |
| Testing | `POST /api/v1/notification/rules/{ruleId}/test` sends a test event with random data |
| Alternatives | HTTP Basic auth or IP blocking, where you can't verify signatures |

OpenMeter does not document its retry schedule or response timeout. The Cloud UI's **Webhooks** page shows the status of deliveries and any errors.

## Common events

| Event type | Sent when |
| --- | --- |
| `entitlements.balance.threshold` | A metered entitlement's usage crosses a threshold in the rule |
| `entitlements.reset` | A metered entitlement is reset, manually or at the end of its period |
| `invoice.created` | A new invoice is generated |
| `invoice.updated` | An existing invoice's state changes |

### Balance threshold events

Threshold rules work only for metered entitlements, because only those have balances. A threshold can be a percentage of the total grants (such as 80% or 100%) or an exact usage number, and one rule can hold several thresholds. An optional feature filter limits the rule to certain features.

OpenMeter guarantees "only one event is triggered per Entitlement per threshold". Thresholds are re-evaluated when grants are added or voided, so a new grant can produce a notification for a lower threshold, and a reset starts the cycle again.

Here is OpenMeter's example for a numeric threshold of 2000, shortened:

```json
{
  "id": "01J5WQFP993XTYM1HAZH3SHP2G",
  "timestamp": "2024-08-22T09:43:03.209883Z",
  "type": "entitlements.balance.threshold",
  "data": {
    "entitlement": {
      "id": "01J5WM25BAXSCBKG2D9NDYJB6S",
      "featureKey": "llm_tokens",
      "subjectKey": "notification-test-2",
      "type": "metered",
      "isSoftLimit": false,
      "currentUsagePeriod": { "from": "2024-08-22T08:43:00Z", "to": "2024-08-29T08:43:00Z" }
    },
    "feature": { "id": "01J5WKAM9FJ4621ARR641JS17Q", "key": "llm_tokens" },
    "subject": { "id": "01J5WM256DNGXKG2N17AHVXKTD", "key": "notification-test-2" },
    "threshold": { "type": "NUMBER", "value": 2000 },
    "value": { "balance": 254, "hasAccess": true, "overage": 0, "usage": 2369 }
  }
}

```

### Reset and invoice events

`entitlements.reset` has the same shape as threshold events and can be filtered by feature. Invoice events wrap the invoice as the Get Invoices endpoint returns it. Notifications for gathering invoices are not supported.

## Setting up OpenMeter webhooks

1. Generate a signing secret. OpenMeter expects base64 random bytes, optionally prefixed with `whsec_`, and its example is `whsec_S6g2HLnTwd9AhHwUIMFggVS9OfoPafN8`.
2. Create a `WEBHOOK` channel with your URL and the secret in `signingSecret`, using `POST /api/v1/notification/channels`. Add `customHeaders` if your endpoint needs them.
3. Create a rule for each event type you want, with its thresholds or feature filter, and attach the channel.
4. Send a test event with `POST /api/v1/notification/rules/{ruleId}/test` and check that your endpoint verifies it.

Store the secret server-side, for example as `OPENMETER_WEBHOOK_SECRET`.

## Securing OpenMeter webhooks

OpenMeter's own example verifies with the `svix` library: `new Webhook(secret).verify(payload, headers)`. To do it by hand, build the signed content as `{svix-id}.{svix-timestamp}.{raw body}`, compute an HMAC-SHA256 keyed with the base64-decoded secret (without `whsec_`), base64-encode it, and compare it in constant time with each `v1,` entry in `svix-signature`.

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

function verifyOpenMeterSignature(rawBody, headers, secret) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader || typeof secret !== 'string') return false;

  // OpenMeter secrets are base64, with or without the "whsec_" prefix.
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  if (key.length === 0) return false;

  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // The header can hold several space-separated "v1,<base64>" entries.
  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/openmeter', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.OPENMETER_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyOpenMeterSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: if your own policy needs it, reject svix-timestamp values that are too old.
  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.type, and acknowledge unknown types with 200.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_openmeter_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header and secret):
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return True
    return False

```

One detail in OpenMeter's example is worth changing. It returns `400` for any event type it doesn't handle, which turns a new event type into a delivery failure. Acknowledge unknown types with a `2xx` instead.

To check your verifier, use the secret `whsec_aG9va2RlY2stZ3VpZGUtdGVzdC1rZXktMzJieXRlcyE=`, the headers `svix-id: msg_2kOpenMeterTest0001` and `svix-timestamp: 1761040800`, and this exact body:

```
{"id":"01J5WQFP993XTYM1HAZH3SHP2G","type":"entitlements.balance.threshold","data":{}}

```

The `svix-signature` header should be:

```
v1,mw/6fHxn4K9F+U19zpnQtq8lyGVDDbVdIFlqzfdizfc=

```

When you set up an OpenMeter source in Hookdeck, enter the secret with its `whsec_` prefix. If you chose a custom secret without the prefix, add `whsec_` in front of it in Hookdeck.

> **Make OpenMeter webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every OpenMeter delivery, deduplicates on the event `id`, and queues threshold and invoice events before your service sees them.

## OpenMeter webhook limitations and pain points

### Delivery behaviour is not documented

**The Problem:** You can't tell from OpenMeter's docs how long your endpoint has to respond, how often a failed event is retried, or when retries stop.

**Why It Happens:** OpenMeter hands delivery to Svix but does not publish its retry schedule or timeout.

**Workarounds:**

* Respond with a `2xx` quickly and process the event afterwards.
* Poll `GET /api/v1/notification/events` for events in the `FAILED` state, and resend them with `POST /api/v1/notification/events/{eventId}/resend`.
* Check the **Webhooks** page in OpenMeter Cloud after an outage.

**How Hookdeck Can Help:** Hookdeck accepts each event as soon as it arrives and stores it, so delivery to Hookdeck succeeds even while your service is down. You then set your own [retry](/docs/retries) policy and replay what failed, and [issues](/docs/issues) alert you when delivery starts failing.

### Threshold events fire once

**The Problem:** A customer's access is never blocked because your service missed the single 100% threshold event.

**Why It Happens:** OpenMeter sends one event per entitlement per threshold. If you drop it, nothing reminds you until the next threshold or a reset.

**Workarounds:**

* Treat the event as a trigger to read the entitlement's current value from the OpenMeter API, and also check access on your own schedule.
* Monitor the Events API for failed deliveries of threshold events.

**How Hookdeck Can Help:** Hookdeck keeps every event it receives, so a threshold event your service failed to process can be found and [replayed](/docs/retries).

### Custom secrets need the prefix in some tools

**The Problem:** Signature checks fail even though you copied the secret correctly.

**Why It Happens:** OpenMeter accepts a secret with or without `whsec_`. Svix-style verifiers, including Hookdeck's, treat a `whsec_` secret as base64 to decode. Hookdeck uses a secret without the prefix as plain text, so the key it computes with doesn't match the one OpenMeter signs with.

**Workarounds:**

* Use the `whsec_` form everywhere.
* If you set a custom secret without the prefix, add `whsec_` in front of it in your verifier's configuration.

**How Hookdeck Can Help:** Hookdeck's OpenMeter source verifies `svix-signature` for you once the secret is entered with its `whsec_` prefix.

## Best practices

### Deduplicate on the event `id`

Every event has a unique `id`, and a resend through the API delivers the same event again. Store each `id` you process and skip repeats.

### Acknowledge unknown event types

OpenMeter's event type list has four values today. Route on `type` and return `200` for anything you don't handle, so a new type never counts as a failed delivery.

### Use thresholds that leave room to act

A 100% threshold tells you when to block. An 80% threshold gives you time to warn the customer first. One rule can hold both.

### Verify against the raw body

Svix signs the raw bytes. Use `express.raw()` or your framework's raw body before parsing JSON.

### Acknowledge fast and process asynchronously

Return `200` after verification and queue the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Processing the same event twice should have the same result as processing it once. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

OpenMeter sends entitlement threshold, entitlement reset and invoice events through webhook channels, signed by Svix over `svix-id`, `svix-timestamp` and the raw body. Verify with the channel's signing secret, deduplicate on the event `id`, acknowledge event types you don't handle, and use the Events API to find and resend anything that failed.

[Hookdeck Event Gateway](https://hookdeck.com) checks every OpenMeter signature, stores each event before your service sees it, and retries on a schedule you control.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle OpenMeter webhooks reliably in minutes.