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

# Webhooks

> Outbound HTTP webhooks for V&V events. HMAC-SHA256 signing, retry-with-backoff, payload schemas for test_run.completed, requirement.gap_opened, evidence_pack.generated, standard.amendment_published.

# Webhooks

Outbound webhooks let you route Roboticks events into anything that accepts HTTP — internal dashboards, ticketing systems, data warehouses, custom Slack apps your security team has standardised on, or your own tooling.

## Set up

<Steps>
  <Step title="Add a webhook">
    **Settings → Integrations → Webhooks → Add**. Paste your endpoint URL (HTTPS only). Pick a name.
  </Step>

  <Step title="Generate a signing secret">
    Roboticks generates a 32-byte secret. **Copy it once** — it's never shown in plaintext again. Store it where your receiver can read it.
  </Step>

  <Step title="Pick events">
    Toggle which event types route here. Defaults: all `test_run.*` and `evidence_pack.*` events.
  </Step>

  <Step title="Send a test">
    Hit **Send test event**. The endpoint receives a synthetic `test_run.completed` payload.
  </Step>
</Steps>

## Payload envelope

Every webhook delivery has the same envelope:

```json theme={null}
{
  "id": "evt_7c2f...",
  "type": "test_run.completed",
  "occurred_at": "2026-05-24T12:44:15.221Z",
  "org": { "id": 42, "slug": "acme" },
  "project": { "id": 18, "slug": "warehouse" },
  "data": { /* event-type-specific payload, see below */ },
  "links": {
    "dashboard": "https://app.roboticks.io/r/warehouse/runs/8a1f3c2d"
  }
}
```

## Event types

### `test_run.completed`

```json theme={null}
"data": {
  "run_id": "8a1f3c2d-...",
  "status": "failed",
  "git": { "sha": "abcdef123456", "ref": "refs/heads/main", "pr_number": 214 },
  "pool": { "name": "prod-gpu-farm", "type": "self-hosted" },
  "tests": { "total": 412, "passed": 411, "failed": 1, "skipped": 0 },
  "duration_seconds": 139,
  "failures": [{
    "id": "tests/test_estop.py::test_estop_halts_motion",
    "message": "stop reached at 142ms (budget 100ms)",
    "requirements": ["REQ-001"]
  }],
  "requirement_coverage": {
    "confirmed": 142,
    "uncovered": 18,
    "total": 160,
    "delta_vs_previous": { "confirmed": -1, "uncovered": 1, "stale": 0 }
  }
}
```

### `requirement.gap_opened`

```json theme={null}
"data": {
  "requirement_id": "REQ-051",
  "title": "Tool-change interlock",
  "type": "safety",
  "previously_confirmed_by": "tests/test_toolchange.py::test_interlock",
  "last_confirmed_run_id": "8a1a72e0-...",
  "last_confirmed_at": "2026-05-21T17:30:00Z",
  "reason": "test_deleted",
  "responsible_commit": { "sha": "abcdef123456", "author": "amir@roboticks.io" }
}
```

`reason` is one of: `test_deleted`, `test_renamed`, `test_failing`, `requirement_added`, `staleness_window_exceeded`.

### `evidence_pack.generated`

```json theme={null}
"data": {
  "pack_id": "ev_a1b2...",
  "release_tag": "v2.4.0",
  "size_bytes": 4423012,
  "formats": ["pdf", "reqif", "zip"],
  "hash_chain_entries": 142,
  "signature": {
    "algorithm": "cosign",
    "key_id": "rbtk-prod-2026"
  },
  "download_urls": {
    "pdf":   "https://api.roboticks.io/v1/evidence-packs/ev_a1b2/pdf",
    "reqif": "https://api.roboticks.io/v1/evidence-packs/ev_a1b2/reqif",
    "zip":   "https://api.roboticks.io/v1/evidence-packs/ev_a1b2/zip"
  }
}
```

### `standard.amendment_published`

```json theme={null}
"data": {
  "standard": "iso-10218-2-2025",
  "amendment": "A1",
  "published_at": "2026-04-12",
  "summary": "Revised §5.7.5 protective-stop performance criteria",
  "impacted_pinned_requirements": ["REQ-001", "REQ-009", "REQ-049"],
  "change_impact_report_url": "https://api.roboticks.io/v1/standards/iso-10218-2-2025/A1/impact"
}
```

### `runner_pool.offline`

```json theme={null}
"data": {
  "pool_name": "onprem-airgapped",
  "pool_type": "self-hosted",
  "airgapped": true,
  "last_online_at": "2026-05-24T11:27:01Z",
  "queued_jobs": 14
}
```

## Signatures

Every delivery includes an HMAC-SHA256 signature in the `X-Roboticks-Signature` header:

```
X-Roboticks-Signature: t=1716561855,v1=abc123...
```

`t` is the Unix timestamp the signature was minted (use to defeat replay); `v1` is the HMAC.

### Verify

Compute HMAC-SHA256 over `"{t}.{raw_body}"` with your signing secret. Compare to `v1` in constant time.

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import hmac, hashlib, time

    def verify(raw_body: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool:
        parts = dict(p.split("=", 1) for p in sig_header.split(","))
        t = int(parts["t"])
        if abs(time.time() - t) > tolerance:
            return False  # outside replay window
        signed = f"{t}.".encode() + raw_body
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, parts["v1"])
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={null}
    const crypto = require('crypto');

    function verify(rawBody, sigHeader, secret, toleranceSec = 300) {
      const parts = Object.fromEntries(
        sigHeader.split(',').map(p => p.split('=', 2))
      );
      const t = parseInt(parts.t, 10);
      if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
      const signed = Buffer.concat([Buffer.from(`${t}.`), rawBody]);
      const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
      return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(parts.v1)
      );
    }
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "strings"
      "strconv"
      "time"
    )

    func Verify(rawBody []byte, sigHeader, secret string, toleranceSec int64) bool {
      parts := map[string]string{}
      for _, p := range strings.Split(sigHeader, ",") {
        kv := strings.SplitN(p, "=", 2)
        parts[kv[0]] = kv[1]
      }
      t, _ := strconv.ParseInt(parts["t"], 10, 64)
      if abs(time.Now().Unix()-t) > toleranceSec { return false }
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
      mac.Write(rawBody)
      expected := hex.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(expected), []byte(parts["v1"]))
    }
    ```
  </Tab>
</Tabs>

<Warning>
  Always reject deliveries outside the 5-minute timestamp tolerance to defeat replay attacks.
</Warning>

## Retry semantics

Failed deliveries (non-2xx response, timeout > 10 s, connection error) retry with exponential backoff:

| Attempt | Delay     |
| ------- | --------- |
| 1       | immediate |
| 2       | 30 s      |
| 3       | 5 min     |
| 4       | 30 min    |
| 5       | 4 h       |
| 6       | 24 h      |

After 6 failed attempts, the delivery is marked **failed** and a `webhook.delivery_failed` event fires (you can wire that to Slack to know you're losing data).

Replays from the dashboard: **Settings → Integrations → Webhooks → Delivery log → ⋯ → Resend**.

## Idempotency

Use the envelope `id` for idempotency. The same event will never appear under two different `id` values; the same `id` may appear more than once if your endpoint timed out and we retried.

```sql theme={null}
INSERT INTO webhook_events (id, type, occurred_at, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO NOTHING;
```

## Best practice

* Acknowledge fast: return 2xx within 5 s, then process async. Don't do heavy work in the request handler.
* Verify the signature **before** parsing JSON.
* Log the envelope `id` for every delivery; correlate with the delivery log on triage.
* Reject deliveries whose `org.id` doesn't match your expectation (defence-in-depth).

## Troubleshooting

<AccordionGroup>
  <Accordion title="Deliveries always retried then failed">
    Most often the endpoint takes > 10 s to respond. Move processing to a queue and ack immediately.
  </Accordion>

  <Accordion title="Signature verification fails for some events">
    You're parsing JSON before computing the HMAC. Compute the HMAC over the **raw bytes** of the request body — even whitespace differences invalidate the signature.
  </Accordion>

  <Accordion title="Want to inspect raw deliveries">
    Use [webhook.site](https://webhook.site) or `ngrok http 4000` for local development. Both surface the exact headers and body Roboticks sends.
  </Accordion>
</AccordionGroup>
