Guide to Orb Webhooks: Features and Best Practices
Orb webhooks notify your application about usage-based billing activity: an invoice is issued, a subscription is created, a customer's credit balance drops. If you're building on Orb, webhooks are how you react to billing events without polling.
This guide covers how Orb webhooks work, the events you'll handle, how to verify the X-Orb-Signature, and the best practices for production.
What are Orb webhooks?
Orb webhooks are JSON POSTs delivered to a URL you configure per endpoint. Each is signed with an X-Orb-Signature header formatted v1=<hex>, alongside an X-Orb-Timestamp header (an ISO 8601 timestamp with milliseconds, not a Unix epoch). The signature is an HMAC-SHA256 (hex) over the literal string v1:{X-Orb-Timestamp}:{raw_body}, keyed with the endpoint's signing secret. Orb doesn't publish a fixed replay tolerance, so pick your own (5 minutes is a sensible default).
Orb webhook features
| Feature | Details |
|---|---|
| Configuration | Orb dashboard > Developers > Webhooks (per-endpoint secret) |
| Signature header | X-Orb-Signature, formatted v1=<hex> |
| Timestamp header | X-Orb-Timestamp (ISO 8601 with milliseconds) |
| Signature scheme | HMAC-SHA256 (hex) over v1:{X-Orb-Timestamp}:{raw_body} |
| Replay window | Consumer-chosen (5 minutes recommended) |
| SDK | Manual verification (the orb-billing SDK has no unwrap/constructEvent helper) |
Common events
Orb reports the event type in the payload's type field:
| Event | Fires when |
|---|---|
invoice.issued | An invoice is issued |
invoice.payment_succeeded / invoice.payment_failed | An invoice payment resolves |
subscription.created / subscription.started / subscription.ended | A subscription changes state |
subscription.plan_changed / subscription.usage_exceeded | A subscription plan or usage changes |
customer.created / customer.credit_balance_dropped | A customer or credit balance changes |
data_exports.transfer_success | A data export transfer completes |
The envelope is { id, created_at, type, properties }, where properties carries resource IDs (like invoice_id) you use to fetch full detail from the Orb API.
Setting up Orb webhooks
In the Orb dashboard, go to Developers > Webhooks > Add endpoint, enter your endpoint URL, optionally filter events, and save. Each endpoint has its own signing secret (distinct from the account API key), reveal it and store it as ORB_WEBHOOK_SECRET. Sandbox and production use separate endpoints and secrets.
Securing Orb webhooks
Strip the v1= prefix from X-Orb-Signature, build the literal string v1:{X-Orb-Timestamp}:{raw_body} using the timestamp exactly as sent, compute an HMAC-SHA256 with your endpoint secret, hex-encode it, and compare in constant time. Then enforce your own freshness window on X-Orb-Timestamp. Verify against the raw body before parsing.
const crypto = require("crypto");
const SECRET = process.env.ORB_WEBHOOK_SECRET;
function verify(rawBody, signatureHeader, timestamp) {
if (!signatureHeader || !timestamp) return false;
const provided = signatureHeader.startsWith("v1=") ? signatureHeader.slice(3) : signatureHeader;
const signed = `v1:${timestamp}:${rawBody.toString("utf8")}`;
const expected = crypto.createHmac("sha256", SECRET).update(signed).digest("hex");
try {
return crypto.timingSafeEqual(Buffer.from(provided, "hex"), Buffer.from(expected, "hex"));
} catch {
return false;
}
}
app.post("/webhooks/orb", express.raw({ type: "application/json" }), (req, res) => {
const ok = verify(req.body, req.headers["x-orb-signature"], req.headers["x-orb-timestamp"]);
if (!ok) return res.sendStatus(401);
res.sendStatus(200); // acknowledge fast
processQueue.add(JSON.parse(req.body.toString())); // branch on type, dedupe on id, async
});
The same check in Python:
import hashlib
import hmac
import os
SECRET = os.environ["ORB_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, signature_header: str, timestamp: str) -> bool:
if not signature_header or not timestamp:
return False
provided = signature_header[3:] if signature_header.startswith("v1=") else signature_header
signed = f"v1:{timestamp}:".encode() + raw_body # concatenate raw bytes, no re-encode
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected)
Orb webhook limitations and pain points
The signed string is v1:{timestamp}:{body}
The Problem: The HMAC is over the literal v1:{X-Orb-Timestamp}:{raw_body}, and the signature header is prefixed v1=. Signing the body alone, or forgetting the prefix or timestamp, never matches.
Why It Happens: Orb versions and timestamps the signed message.
Workarounds:
- Strip the
v1=prefix, and reconstructv1:{timestamp}:{body}from the exact header and raw body.
How Hookdeck Can Help: Hookdeck reconstructs and verifies the signed string at the edge.
The timestamp is ISO 8601, not epoch
The Problem: X-Orb-Timestamp is an ISO 8601 string with milliseconds. Treating it as a Unix epoch, or reformatting it before signing, breaks the match.
Why It Happens: Orb uses an ISO 8601 timestamp and signs it byte-for-byte.
Workarounds:
- Use the timestamp string exactly as delivered in the signed content.
How Hookdeck Can Help: Hookdeck handles the timestamp format at the edge.
Per-endpoint secrets, not the API key
The Problem: Each endpoint has its own signing secret. Using the account API key makes verification fail silently.
Why It Happens: Orb scopes signing secrets per endpoint.
Workarounds:
- Use the endpoint's own secret (
ORB_WEBHOOK_SECRET), separate per environment.
How Hookdeck Can Help: Hookdeck verifies with the correct per-endpoint secret at the edge.
No published replay window, and duplicates
The Problem: Orb doesn't publish a replay tolerance, and delivery is at-least-once, so you choose the window and handle repeats.
Why It Happens: Orb leaves the tolerance to the consumer and delivers at-least-once.
Workarounds:
- Enforce your own freshness window (5 minutes), and dedupe on the event
id.
How Hookdeck Can Help: Hookdeck enforces freshness and deduplicates at the edge. See our guide to webhook idempotency.
Best practices
Verify HMAC-SHA256 over v1:{timestamp}:{body}
Strip the v1= prefix, reconstruct the signed string, and compare in constant time.
Enforce your own freshness window
Reject deliveries whose X-Orb-Timestamp is more than about 5 minutes off.
Use the per-endpoint secret and dedupe on id
Key the HMAC with the endpoint's secret, and make handlers idempotent on the event id.
Acknowledge fast, process asynchronously
Return 200 quickly and defer work to a queue. See why to process webhooks asynchronously.
Make Orb webhooks production-ready
Hookdeck verifies X-Orb-Signature, deduplicates, and durably queues every billing event
Conclusion
Orb webhooks are verified with an X-Orb-Signature HMAC-SHA256 over the literal v1:{X-Orb-Timestamp}:{raw_body}, hex-encoded and prefixed v1=, using an ISO 8601 timestamp and a per-endpoint secret. Strip the prefix, reconstruct the signed string from the exact timestamp and raw body, enforce your own freshness window, and dedupe on the event id.
Hookdeck verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events.
Get started with Hookdeck for free and handle Orb webhooks reliably in minutes.