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 aPOSTto 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:
{
"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
- Generate a signing secret. OpenMeter expects base64 random bytes, optionally prefixed with
whsec_, and its example iswhsec_.S6g2HLnTwd9AhHwUIMFggVS9OfoPafN8 - Create a
WEBHOOKchannel with your URL and the secret insigningSecret, usingPOST /api/v1/notification/channels. AddcustomHeadersif your endpoint needs them. - Create a rule for each event type you want, with its thresholds or feature filter, and attach the channel.
- Send a test event with
POST /api/v1/notification/rules/{ruleId}/testand 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.
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:
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_, the headers svix-id: msg_ 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 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
2xxquickly and process the event afterwards. - Poll
GET /api/v1/notification/eventsfor events in theFAILEDstate, and resend them withPOST /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 policy and replay what failed, and 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.
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.
Make handlers idempotent
Processing the same event twice should have the same result as processing it once. See our guide to 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 checks every OpenMeter signature, stores each event before your service sees it, and retries on a schedule you control.
Get started with Hookdeck for free and handle OpenMeter webhooks reliably in minutes.