# Webhooks

Have Salfio POST new activity events to your endpoint or fire a Claude Code remote routine.

Salfio can forward new activity events to a destination of your choice the moment they are recorded — no polling required. Two transports are supported:

- **URL endpoint** — a plain `POST` to any HTTPS URL, signed with HMAC-SHA256 so you can verify it came from Salfio.
- **Claude routine** — a `POST` with a `Bearer` token suitable for triggering a Claude Code remote routine.

Both share the same payload shape, the same delivery queue, and the same retry behaviour. They differ only in the auth header.

## Events

Two events are emitted in v1:

| Event | When it fires |
|----|----|
| `activity.email.created` | A new email activity is captured from a connected Gmail integration. |
| `activity.meeting.created` | A new meeting activity is captured by the Salfio meeting bot or Fireflies. |

Manual notes (`Conversation.type = "note"`) and other resource events are intentionally out of scope for v1.

## Configure a webhook

1.  Open **Settings → Webhooks** in the dashboard.
2.  Click **Add webhook** and fill in:
    - **Name** — a human-friendly label.
    - **Event** — pick the event you want to react to.
    - **Transport** — **URL endpoint** or **Claude routine**.
    - **URL** — must use `https://`. For Claude routines, paste the routine *fire URL*.
    - **Token** *(Claude routines only)* — the long-lived OAT token to send as `Authorization: Bearer …`.
3.  Save.

If you picked the URL-endpoint transport, the dashboard shows your **signing secret exactly once**. Copy it into a secret manager before closing the dialog — Salfio stores only an AES-256-GCM encryption of the secret and can't show it to you again.

## Payload shape

For the **URL endpoint** transport every delivery body is the same JSON envelope. The `activity` field carries the same DTO that the Public API v1 returns when you fetch a single activity, so docs samples and production payloads cannot drift — with one exception: `content` is capped at 10 KiB in deliveries (a `[content truncated at 10 KiB]` marker is appended when the cut applies). When you need the full text of a long activity — a video-call transcript, say — fetch it by ID via `GET /v1/clients/{clientId}/activities/{activityId}`, which always returns the complete content.

    {
      "event": "activity.email.created",
      "occurred_at": "2026-05-12T11:42:13Z",
      "organization": { "id": "9c1a…" },
      "activity": {
        "id": "8df8…",
        "clientId": "ad44…",
        "type": "email",
        "source": "gmail",
        "subject": "Quarterly review",
        "content": "Hi team — sharing the deck…",
        "occurredAt": "2026-05-12T11:42:13Z",
        "participants": ["[email protected]", "[email protected]"],
        "immutable": true,
        "archivedAt": null,
        "createdAt": "2026-05-12T11:42:14Z",
        "updatedAt": "2026-05-12T11:42:14Z"
      }
    }

For the **Claude routine** transport the same envelope is delivered as a stringified JSON inside the `text` field, because Anthropic's `/fire` endpoint only accepts `{"text": "..."}`:

    { "text": "{\"event\":\"activity.email.created\",\"occurred_at\":\"…\",\"organization\":{\"id\":\"9c1a…\"},\"activity\":{…}}" }

If your routine wants the structured form, `JSON.parse(text)` inside the routine prompt gives you the same shape as the URL-endpoint body above.

## Headers

### URL endpoint

| Header | Value |
|----|----|
| `Content-Type` | `application/json` |
| `X-Salfio-Event` | The event type, e.g. `activity.email.created`. |
| `X-Salfio-Delivery` | A per-attempt UUID. Stable across the retry chain of one event — use it as an idempotency key on your side. |
| `X-Salfio-Signature` | `sha256=<hex>` where `<hex>` is `hmac_sha256(raw_body, signing_secret)`. |

### Claude routine

| Header | Value |
|----|----|
| `Content-Type` | `application/json` |
| `Authorization` | `Bearer <your-token>` (decrypted server-side per attempt). |
| `anthropic-version` | `2023-06-01` |
| `anthropic-beta` | `experimental-cc-routine-2026-04-01` |

## Verify the signature (URL endpoint)

Compute `hmac_sha256(raw_body, signing_secret)` and compare against the `X-Salfio-Signature` header (after stripping the `sha256=` prefix). Always use a constant-time comparison.

### Node.js

    import crypto from "node:crypto";

    export function verifySalfioSignature(rawBody, signature, secret) {
      const expected = crypto
        .createHmac("sha256", secret)
        .update(rawBody)
        .digest("hex");
      const provided = signature.replace(/^sha256=/, "");
      const a = Buffer.from(expected, "hex");
      const b = Buffer.from(provided, "hex");
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }

### Python

    import hmac, hashlib

    def verify_salfio_signature(raw_body: bytes, signature: str, secret: str) -> bool:
        expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
        provided = signature.removeprefix("sha256=")
        return hmac.compare_digest(expected, provided)

Important: verify the signature against the **raw request body bytes**, before any JSON re-serialization on your side.

## Retry behaviour

Salfio retries non-`2xx` responses (and any transport error) with exponential back-off plus jitter, up to **5 attempts** total:

| Attempt | Delay before this attempt |
|---------|---------------------------|
| 1       | immediate                 |
| 2       | ~30 s                     |
| 3       | ~2 min                    |
| 4       | ~10 min                   |
| 5       | ~30 min                   |

After attempt 5 fails the delivery is marked terminally `failed` and the last error and status code are stored on the row (visible as "Last delivery" in the dashboard). Each attempt is bounded by a 10 s network timeout — endpoints that habitually take longer to respond will exhaust the retry budget.

Successful deliveries (any `2xx`) are terminal — Salfio does not acknowledge response bodies, so don't rely on them carrying meaning back to us.

## Send a test event

Each row in **Settings → Webhooks** has a **Send test** action that enqueues a synthetic delivery. The payload uses sample data so you can verify your endpoint without waiting for a real activity import. The test delivery goes through the same queue, signing path, retry curve, and "Last delivery" column as a real one — the only difference is the sample body.

## Disable, enable, delete

- **Disable** a webhook to pause future deliveries. Pending retries already in flight will still attempt to send.
- **Re-enable** to resume.
- **Delete** to remove the subscription. Pending and historical delivery rows are removed in the same transaction (`ON DELETE CASCADE`), so retries stop immediately.

## Related pages

- [Administrator Tools](./administrator-tools.md)
- [Agent Tools](./mcp-external-servers.md)
- [Assign a Slack channel to a client over the API](./guides-assign-slack-channel.md)
- [Authenticated health check](./api-reference-gethealth.md)
- [Authentication](./api-authentication.md)
- [Cards](./cards.md)
- [Changelog](./changelog.md)
- [Changelog](../changelog.md)
- [Connect a workspace](./getting-started-connect-workspace.md)
- [Connect an integration](./getting-started-connect-integration.md)

# Agent Instructions

This portal answers questions programmatically. To receive a synthesized,
source-cited answer instead of crawling page by page, append the `?ask=`
query parameter to any page URL on this site:

    /guides/quickstart?ask=how+do+I+authenticate

Optional parameters:

- `&goal=<what-you-are-trying-to-do>` steers the answer toward your
  objective (e.g. `&goal=write+a+python+client`).
- `&version=<label>` scopes the answer to a mounted version when the
  portal publishes more than one.

The response is `text/markdown`: the answer followed by a `# Sources` list
of the portal pages it was grounded in. Status codes are the contract:

- `200` — the answer; `402` — the portal owner’s plan or answer credits are
  exhausted (surface this to your operator; do NOT retry); `429` — you are
  rate-limited; back off for the `Retry-After` seconds; `503` — the answer
  lane is temporarily unavailable; fall back to crawling the `.md` pages.

For the full corpus map read `llms.txt` at the site root; for the tool
surface (search + page fetch as MCP tools) see `/mcp`.
