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

# Webhook security

> Verify signatures, prevent replays, handle retries safely.

Webhooks are inbound HTTP requests from Robase to your server. Without verification, an attacker who knows your endpoint URL can post fake events — "this OTP was delivered" — and walk past your auth.

This guide covers the three layers you need.

## 1. Verify the signature

Every webhook request includes:

```http theme={null}
Robase-Signature: t=1718637005,v1=3c2a9f...
Robase-Event: sms.delivered
Content-Type: application/json
```

`v1` is the hex of HMAC-SHA256 over `{t}.{raw_body}` with your webhook's secret. **Always verify before reading the body.**

<CodeGroup>
  ```typescript Node.js (Express) theme={null}
  import { verifyWebhook } from '@robase/node';
  import express from 'express';

  const app = express();

  // Reserve express.raw for the webhook route — we need the unparsed body.
  app.post('/webhooks/robase', express.raw({ type: 'application/json' }), async (req, res) => {
    const sig = req.headers['robase-signature'] as string;
    const raw = (req.body as Buffer).toString('utf8');

    const ok = await verifyWebhook(raw, sig, process.env.ROBASE_WEBHOOK_SECRET!);
    if (!ok) return res.status(401).end();

    const event = JSON.parse(raw);
    await processEvent(event);
    res.status(200).end();
  });
  ```

  ```python Python (Flask) theme={null}
  from flask import Flask, request, abort
  from robase import verify_webhook

  app = Flask(__name__)

  @app.route("/webhooks/robase", methods=["POST"])
  def hook():
      raw = request.get_data()
      sig = request.headers.get("Robase-Signature", "")
      if not verify_webhook(raw, sig, os.environ["ROBASE_WEBHOOK_SECRET"]):
          abort(401)
      event = request.get_json()
      process_event(event)
      return "", 200
  ```

  ```go Go theme={null}
  http.HandleFunc("/webhooks/robase", func(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      sig := r.Header.Get("Robase-Signature")
      if !robase.VerifyWebhook(body, sig, os.Getenv("ROBASE_WEBHOOK_SECRET"), 300) {
          http.Error(w, "", http.StatusUnauthorized)
          return
      }
      var event struct { Type string; Data map[string]any }
      _ = json.Unmarshal(body, &event)
      processEvent(event)
      w.WriteHeader(http.StatusOK)
  })
  ```
</CodeGroup>

<Warning>
  Verify against the **raw body bytes**, not a re-serialized version. JSON parsers often reorder keys or normalize whitespace — that breaks HMAC. In Express you need `express.raw`; in Flask `get_data()`; in Go `io.ReadAll(r.Body)` before any decode.
</Warning>

## 2. Reject stale timestamps

Signatures expire. The SDK helpers reject any signature whose timestamp is more than **5 minutes old** by default. This blocks replay attacks where an attacker captures a signed request and re-posts it later.

To loosen or tighten the window:

```typescript theme={null}
await verifyWebhook(raw, sig, secret, 60);  // 60 seconds tolerance
```

If your server's clock drifts (containers without NTP can drift minutes), 300 seconds is a reasonable default. Monitor clock skew — your SRE checklist should include NTP sync.

## 3. Idempotent processing

Robase retries failed webhooks with exponential backoff (1s → 5s → 30s → 2m → 15m → 1h → 6h, 7 attempts). Your handler may receive the same event more than once.

Use the `id` field (guaranteed unique, ULID-like) to deduplicate:

```typescript theme={null}
async function processEvent(event: { id: string; type: string; data: any }) {
  const seen = await redis.set(`evt:${event.id}`, '1', { NX: true, EX: 86400 });
  if (!seen) {
    // We've processed this event already — ack and skip.
    return;
  }

  // Your real logic.
  await handle(event);
}
```

Or, if you persist events to a table with `id` as the primary key, a duplicate insert + `ON CONFLICT DO NOTHING` gets you the same guarantee with one SQL round trip.

## 4. Respond fast, process later

Your webhook handler should return `200` in under a few hundred milliseconds. If downstream work (sending email, updating a CRM, running a background job) takes longer, enqueue it:

```typescript theme={null}
app.post('/webhooks/robase', async (req, res) => {
  const event = verifyAndParse(req);
  if (!event) return res.status(401).end();

  await queue.enqueue('robase-event', event);  // fire and forget
  res.status(200).end();
});
```

If you block the handler waiting on downstream work, you risk:

* **Timeouts** — Robase treats `>10s` as a failure and retries. Repeated retries pile up on your queue.
* **Chain failures** — your slow handler ties up worker slots, slowing unrelated traffic.

## 5. Don't leak the secret

<AccordionGroup>
  <Accordion title="Never log the secret, or the full signature">
    A developer debugging a signature failure might `console.log(secret)` — and suddenly it's in every SaaS log aggregator you send to. Log only the first 6 chars + `…` if you need a hint.
  </Accordion>

  <Accordion title="Never expose the webhook URL publicly">
    An attacker who knows the URL can send crafted payloads (they'll fail signature verification, but they cost you CPU). Put your webhook behind a path only you know, e.g. `/webhooks/robase/<random-16-char-token>`.
  </Accordion>

  <Accordion title="Rotate secrets after suspected exposure">
    Dashboard → Webhooks → your webhook → **Rotate secret**. The old secret is invalidated immediately; you'll need to redeploy with the new one.
  </Accordion>
</AccordionGroup>

## Common bugs

| Symptom                                         | Likely cause                                                                                                |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Verification always fails, even on fresh events | You parsed JSON before verifying, or your framework strips headers. Use raw body.                           |
| Works locally, fails in prod                    | Clock skew — container doesn't sync NTP. Run `chronyd` / `systemd-timesyncd`.                               |
| Some events verified, some not                  | Load balancer modifying body (CRLF → LF, or adding BOM). Disable body transformations for the webhook path. |
| Works for 5 minutes then breaks                 | You're caching the secret; a rotation happened. Fetch the secret on each request or pub/sub rotations.      |
