# 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.

```javascript
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:

```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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Orb webhooks reliably in minutes.