Gareth Wilson Gareth Wilson

Guide to Orb Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationOrb dashboard > Developers > Webhooks (per-endpoint secret)
Signature headerX-Orb-Signature, formatted v1=<hex>
Timestamp headerX-Orb-Timestamp (ISO 8601 with milliseconds)
Signature schemeHMAC-SHA256 (hex) over v1:{X-Orb-Timestamp}:{raw_body}
Replay windowConsumer-chosen (5 minutes recommended)
SDKManual verification (the orb-billing SDK has no unwrap/constructEvent helper)

Common events

Orb reports the event type in the payload's type field:

EventFires when
invoice.issuedAn invoice is issued
invoice.payment_succeeded / invoice.payment_failedAn invoice payment resolves
subscription.created / subscription.started / subscription.endedA subscription changes state
subscription.plan_changed / subscription.usage_exceededA subscription plan or usage changes
customer.created / customer.credit_balance_droppedA customer or credit balance changes
data_exports.transfer_successA 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 reconstruct v1:{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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.