Skip to main content

Documentation Index

Fetch the complete documentation index at: https://orbit-docs.devotel.io/llms.txt

Use this file to discover all available pages before exploring further.

Webhook Security

Every webhook delivery from Orbit includes an HMAC-SHA256 signature in the X-Devotel-Signature header. Always verify this signature to ensure the request genuinely came from Orbit and hasn’t been tampered with.

Signature Format

The X-Devotel-Signature header is Stripe-style, not a bare hex string:
X-Devotel-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 emits two v1=<hex> values in the same header (new secret first, previous secret second) 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>
Your verifier 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

  1. Parse the X-Devotel-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

Node.js

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) => {
  const header = 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

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():
    header = 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

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
  • IP allowlisting — optionally restrict incoming webhooks to Orbit’s IP ranges (available in the dashboard under Settings > Security)

Signing Secret

Your webhook signing secret is generated when you create a webhook. Retrieve it from the dashboard under Webhooks > [Your Webhook] > Signing Secret, or via the API:
curl https://orbit-api.devotel.io/api/v1/webhooks/wh_abc123 \
  -H "X-API-Key: dv_live_sk_..."
The secret is prefixed with whsec_ and should be stored securely — never expose it in client-side code or logs.