> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Security

> Verify Orbit webhook signatures with HMAC-SHA256

# Webhook Security

Every webhook delivery from Orbit is signed with HMAC-SHA256 so you can verify that the request genuinely came from Orbit and hasn't been tampered with. Each delivery carries the signature in up to **three** headers:

| Header                   | When sent                                                                                                       | Signed with                                                                                                                                              |
| ------------------------ | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Orbit-Signature`      | On every current delivery (older deliveries queued before this header existed carry only `X-Devotel-Signature`) | Your **current** signing secret (the **canonical** signature — prefer this).                                                                             |
| `X-Orbit-Signature-Next` | **Only during a key-rotation grace window**                                                                     | Your **previous** signing secret (the **outgoing-key** signature — lets your verifier accept deliveries signed before your previous secret was retired). |
| `X-Devotel-Signature`    | Always                                                                                                          | Legacy combined header, kept for back-compat (carries both signatures, see below).                                                                       |

All three are valid HMAC-SHA256 signatures over the string `<t>.<raw_body>` and share the identical `t=<unix>,v1=<hex>` encoding. Always verify at least one of them before processing a delivery.

## Canonical Headers

New integrations should verify the **canonical** `X-Orbit-Signature` header, which is sent on every current delivery and carries a **single** `v1=<hex>` signed with your current signing secret (a small number of older deliveries queued before this header existed arrive with only the legacy `X-Devotel-Signature`, so keep a fallback to it — the examples below do this automatically):

```
X-Orbit-Signature: t=1715357600,v1=4f9c2e6b8a1d3f5e7c9b1a3d5f7e9c1b3a5d7f9e1c3b5a7d9f1e3c5b7a9d1f3e
```

* `t=<unix>` — Unix timestamp (seconds) when Orbit signed the payload. Use this for replay protection.
* `v1=<hex>` — HMAC-SHA256 of the string `<t>.<raw_body>` keyed by your webhook signing secret. Hex-encoded.

During a secret-rotation grace window, Orbit additionally emits `X-Orbit-Signature-Next` — the **same payload** signed with your **previous** signing secret:

```
X-Orbit-Signature-Next: t=1715357600,v1=<sig_with_prev_secret>
```

`X-Orbit-Signature-Next` is **absent** outside the grace window. Because each canonical header carries exactly one `v1`, a verifier that holds both your new and previous secrets can tell **which key matched** cleanly (no multi-candidate parsing): check `X-Orbit-Signature` against your current secret, and — if present — `X-Orbit-Signature-Next` against your previous secret.

## Legacy `X-Devotel-Signature`

For backwards compatibility, every delivery also includes the **Stripe-style** `X-Devotel-Signature` header, which is not a bare hex string:

```
X-Devotel-Signature: t=1715357600,v1=4f9c2e6b8a1d3f5e7c9b1a3d5f7e9c1b3a5d7f9e1c3b5a7d9f1e3c5b7a9d1f3e
```

It carries the same `t=<unix>` timestamp and `v1=<hex>` encoding as the canonical headers. During a secret-rotation grace window, Orbit emits **two** `v1=<hex>` values in this single header (new secret first, previous secret second) — the combined form of `X-Orbit-Signature` and `X-Orbit-Signature-Next` — so a verifier configured with either secret continues to validate:

```
X-Devotel-Signature: t=1715357600,v1=<sig_with_new_secret>,v1=<sig_with_prev_secret>
```

A verifier reading `X-Devotel-Signature` should split the header by `,`, parse each `key=value` pair, and accept the request if **any** `v1` matches your computed HMAC.

## How Signature Verification Works

The steps below work for any of the signature headers — `X-Orbit-Signature` (recommended), `X-Orbit-Signature-Next`, or the legacy `X-Devotel-Signature`. The same `t=...,v1=...` parser handles all three; the canonical headers simply carry a single `v1`.

1. Parse the signature header into a timestamp `t` and one or more `v1` candidate signatures.
2. Reject the request if `t` is older than 5 minutes (replay protection).
3. Compute `expected = HMAC_SHA256(secret, "<t>.<raw_body>")` as hex.
4. Compare `expected` against each `v1` candidate using a timing-safe comparison.

## Verification Examples

These examples read the canonical `X-Orbit-Signature` header and fall back to the legacy `X-Devotel-Signature` header so they work against any delivery. The shared parser accepts one or more `v1` candidates, so the same code also validates `X-Orbit-Signature-Next` if you choose to verify it explicitly during a rotation.

### Node.js

```javascript theme={null}
import crypto from 'node:crypto';

function parseSignatureHeader(header) {
  const parts = header.split(',').map((p) => p.trim());
  let t = null;
  const v1s = [];
  for (const part of parts) {
    const eq = part.indexOf('=');
    if (eq < 0) continue;
    const key = part.slice(0, eq);
    const value = part.slice(eq + 1);
    if (key === 't') t = value;
    else if (key === 'v1') v1s.push(value);
  }
  return { t, v1s };
}

function verifyWebhookSignature(rawBody, header, secret) {
  const { t, v1s } = parseSignatureHeader(header);
  if (!t || v1s.length === 0) return false;

  // Replay protection: reject signatures older than 5 minutes.
  const ageSec = Math.floor(Date.now() / 1000) - Number(t);
  if (!Number.isFinite(ageSec) || ageSec < 0 || ageSec > 5 * 60) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`, 'utf-8')
    .digest('hex');
  const expectedBuf = Buffer.from(expected, 'hex');

  // Accept the request if ANY v1 matches (covers rotation grace).
  return v1s.some((candidate) => {
    const candidateBuf = Buffer.from(candidate, 'hex');
    return (
      candidateBuf.length === expectedBuf.length &&
      crypto.timingSafeEqual(candidateBuf, expectedBuf)
    );
  });
}

// In your webhook handler:
app.post('/webhooks/orbit', (req, res) => {
  // Prefer the canonical header; fall back to the legacy one for older deliveries.
  const header = req.headers['x-orbit-signature'] ?? req.headers['x-devotel-signature'];
  const isValid = verifyWebhookSignature(req.rawBody, header, 'whsec_your_secret');

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.rawBody);
  // Process the event...

  res.status(200).json({ received: true });
});
```

### Python

```python theme={null}
import hmac
import hashlib
import time


def parse_signature_header(header: str):
    t = None
    v1s = []
    for part in header.split(","):
        part = part.strip()
        if "=" not in part:
            continue
        key, _, value = part.partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            v1s.append(value)
    return t, v1s


def verify_webhook_signature(raw_body: bytes, header: str, secret: str) -> bool:
    t, v1s = parse_signature_header(header)
    if not t or not v1s:
        return False

    # Replay protection: reject signatures older than 5 minutes.
    age = int(time.time()) - int(t)
    if age < 0 or age > 5 * 60:
        return False

    signed = f"{t}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    # Accept the request if ANY v1 matches (covers rotation grace).
    return any(hmac.compare_digest(candidate, expected) for candidate in v1s)


# In your webhook handler (Flask example):
@app.route("/webhooks/orbit", methods=["POST"])
def handle_webhook():
    # Prefer the canonical header; fall back to the legacy one for older deliveries.
    header = request.headers.get("X-Orbit-Signature") or request.headers.get(
        "X-Devotel-Signature", ""
    )
    if not verify_webhook_signature(request.data, header, "whsec_your_secret"):
        return jsonify({"error": "Invalid signature"}), 401

    event = request.get_json()
    # Process the event...

    return jsonify({"received": True}), 200
```

### Go

```go theme={null}
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

func parseSignatureHeader(header string) (string, []string) {
	var t string
	var v1s []string
	for _, part := range strings.Split(header, ",") {
		part = strings.TrimSpace(part)
		eq := strings.IndexByte(part, '=')
		if eq < 0 {
			continue
		}
		key, value := part[:eq], part[eq+1:]
		switch key {
		case "t":
			t = value
		case "v1":
			v1s = append(v1s, value)
		}
	}
	return t, v1s
}

func verifyWebhookSignature(rawBody []byte, header, secret string) bool {
	t, v1s := parseSignatureHeader(header)
	if t == "" || len(v1s) == 0 {
		return false
	}

	// Replay protection: reject signatures older than 5 minutes.
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return false
	}
	age := time.Now().Unix() - ts
	if age < 0 || age > 5*60 {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))

	// Accept the request if ANY v1 matches (covers rotation grace).
	for _, candidate := range v1s {
		if hmac.Equal([]byte(candidate), []byte(expected)) {
			return true
		}
	}
	return false
}
```

## Security Best Practices

* **Always verify signatures** — never process webhooks without checking the signature
* **Use timing-safe comparison** — prevents timing attacks (`crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python)
* **Use HTTPS** — your webhook endpoint must use HTTPS to protect payloads in transit
* **Rotate secrets** — rotate your webhook signing secret periodically in **Settings > Webhooks**
* **Idempotency** — use the event `id` field to deduplicate, since Orbit guarantees at-least-once delivery
* **Respond quickly** — return a `2xx` within 30 seconds; process heavy work asynchronously
* **Verify signatures first, allowlist IPs second** — always authenticate every delivery with the HMAC signature above; a source IP can be shared or spoofed upstream, so it is never sufficient on its own. If your webhook endpoint sits behind a firewall that only accepts inbound HTTPS from known addresses, `GET /api/v1/webhooks/egress-ips` returns the static source IP(s) Orbit sends deliveries from so you can allowlist them. Allowlist **every** entry the endpoint returns and don't assume a fixed count. (The separate IP allowlist under **Settings > Security** restricts who can reach the Orbit dashboard and API by IP; it does not affect webhook delivery.)

```bash theme={null}
# The source IP(s) every webhook delivery is sent from — allowlist these on
# a firewall that restricts inbound HTTPS to your endpoint.
curl https://api.orbit.devotel.io/api/v1/webhooks/egress-ips \
  -H "X-API-Key: dv_live_sk_..."
```

## Signing Secret

Your webhook signing secret (prefixed with `whsec_`) can be supplied on creation — pass it as the optional `secret` body field (minimum 16 characters) — or auto-generated by Orbit when you omit it. **The cleartext secret is returned only once** — in the response to `POST /api/v1/webhooks` (endpoint creation) or `POST /api/v1/webhooks/{id}/rotate-secret` (rotation). Store it securely at that moment.

```bash theme={null}
# Omit `secret` to have Orbit auto-generate one. The response includes the
# full `whsec_...` secret either way — copy it now.
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks", "events": ["message.delivered"]}'
```

```bash theme={null}
# Or supply your own secret (at least 16 characters) in the `secret` field.
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks", "events": ["message.delivered"], "secret": "whsec_your_own_signing_secret"}'
```

`GET /api/v1/webhooks/{id}` does **not** return a usable secret — it returns a masked preview (`whsec_********...<last4>`) for identification only. Do **not** copy that masked value into your verification logic; the HMAC will never match and signature checks will silently fail.

If you lose the secret, rotate to obtain a new one:

```bash theme={null}
# Returns a new cleartext `whsec_...` secret. The previous secret is honored
# during a 7-day grace window (604800 s) so in-flight deliveries keep verifying.
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks/wh_abc123/rotate-secret \
  -H "X-API-Key: dv_live_sk_..."
```

You can also view (and rotate) the secret in the dashboard under **Webhooks > \[Your Webhook] > Signing Secret**. Store it securely — never expose it in client-side code or logs.
