> ## Documentation Index
> Fetch the complete documentation index at: https://documents.xobito.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Security

> Verify Xobito webhook signatures with HMAC-SHA256.

Every outbound webhook Xobito sends is signed with HMAC-SHA256. Verifying the signature protects you from spoofed requests and from accidentally processing unsigned traffic.

## Signature header

| Header                | Algorithm                  | Signed over                                  |
| --------------------- | -------------------------- | -------------------------------------------- |
| `X-Webhook-Signature` | HMAC-SHA256, lowercase hex | The **raw** JSON request body, byte-for-byte |

The header value is a hex-encoded HMAC-SHA256 digest — no `sha256=` prefix, no timestamps.

## Secret

The signing secret is a shared value between Xobito and your endpoint.

<Note>
  Xobito issues a unique signing secret per workspace. If you do not yet have your secret, contact Xobito support to have one issued and rotated.
</Note>

<Warning>
  Keep the secret out of source control and client-side code. Store it in an environment variable or secrets manager on your webhook receiver.
</Warning>

## Verifying a request

You **must** verify using the raw body bytes, not a re-serialised JSON string. Many frameworks parse JSON before your handler runs — use the framework's raw-body hook.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.XOBITO_WEBHOOK_SECRET;

  // IMPORTANT: capture the raw body for signature verification
  app.use(express.raw({ type: "application/json" }));

  app.post("/webhook", (req, res) => {
    const sig = req.header("X-Webhook-Signature");
    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(req.body) // Buffer (raw bytes)
      .digest("hex");

    const ok =
      sig &&
      sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));

    if (!ok) return res.status(401).send("Invalid signature");

    const payload = JSON.parse(req.body.toString("utf8"));
    // handle payload.event + payload.model ...
    res.sendStatus(200);
  });
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, os
  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = os.environ["XOBITO_WEBHOOK_SECRET"].encode()

  @app.post("/webhook")
  def webhook():
      signature = request.headers.get("X-Webhook-Signature", "")
      expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
      if not hmac.compare_digest(signature, expected):
          abort(401)

      payload = request.get_json()
      # handle payload["event"] + payload["model"] ...
      return "", 200
  ```

  ```php PHP theme={null}
  <?php
  $secret = getenv('XOBITO_WEBHOOK_SECRET');
  $raw    = file_get_contents('php://input');
  $sig    = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

  $expected = hash_hmac('sha256', $raw, $secret);

  if (!hash_equals($expected, $sig)) {
      http_response_code(401);
      exit('Invalid signature');
  }

  $payload = json_decode($raw, true);
  // handle $payload['event'] + $payload['model'] ...
  http_response_code(200);
  ```

  ```ruby Ruby (Sinatra) theme={null}
  require 'sinatra'
  require 'openssl'

  SECRET = ENV.fetch('XOBITO_WEBHOOK_SECRET')

  post '/webhook' do
    raw = request.body.read
    sig = request.env['HTTP_X_WEBHOOK_SIGNATURE'].to_s
    expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, raw)
    halt 401 unless Rack::Utils.secure_compare(sig, expected)

    payload = JSON.parse(raw)
    # handle payload['event'] + payload['model'] ...
    status 200
  end
  ```
</CodeGroup>

<Tip>Always use a constant-time comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`). A naive `==` is vulnerable to timing attacks.</Tip>

## Retry-aware handlers

Xobito retries up to **3 total attempts** — the first attempt fires immediately, then there's a `1s` wait before retry 2 and a `2s` wait before retry 3 (exponential backoff). If your endpoint is slow or flaps, you may receive the same event more than once.

<Check>Deduplicate by `(model, data.id, event, timestamp)` — that tuple is stable across retries.</Check>
<Check>Return `2xx` as soon as you have persisted (or enqueued) the event. Heavy work belongs in a background job.</Check>
<Check>Return the same `2xx` on a duplicate so Xobito stops retrying.</Check>

## Optional hardening

* **HTTPS only.** Reject `http://` at the load balancer — signatures are not a substitute for transport encryption.
* **Narrow scope.** Only listen for the events you actually handle (`contacts_actions`, `status_actions`, `source_actions` in **Settings → Webhook Settings**).
* **Logs.** Keep at least 30 days of request logs on your side — Xobito's `webhook_logs` are purged after 30 days.

## Troubleshooting

<AccordionGroup>
  <Accordion title="My signatures never match">
    Double-check you are hashing the **raw request body**, not a pretty-printed or re-serialised JSON string. Even whitespace differences will break HMAC.
  </Accordion>

  <Accordion title="I receive events but no `X-Webhook-Signature` header">
    Some proxies strip non-standard headers. Configure your ingress (Nginx, Cloudflare, etc.) to pass the header through unchanged.
  </Accordion>

  <Accordion title="Same event arrives twice">
    That is expected on retries after a slow or failed first attempt. Deduplicate on `(model, data.id, event, timestamp)`.
  </Accordion>

  <Accordion title="How do I rotate the secret?">
    Contact Xobito support. There is no self-service rotation in the current version.
  </Accordion>
</AccordionGroup>
