---
title: Webhooks
description: Receive durable workspace events without maintaining an SSE connection.
---

Webhooks deliver selected workspace events to an HTTPS endpoint. They are a
good fit for server-to-server workflows that should start shortly after a
session completes:

1. Subscribe to `session.status_changed` with `status=[completed]`.
2. Verify the raw body using the `kid` from JWKS.
3. Deduplicate `Torqee-Event-Id`.
4. Fetch the session transcript.

Webhook delivery is at least once, so your handler must be idempotent. Unlike
webhooks, the [SSE event stream](/docs/guides/realtime-events) is best-effort
and does not replay events emitted while a client is disconnected.

## Authentication and scopes

Use an OAuth access token or API key with these scopes:

| Scope                     | Why it is needed                                   |
| ------------------------- | -------------------------------------------------- |
| `torqee:webhooks:write`   | Create, update, delete, test, and replay webhooks. |
| `torqee:webhooks:read`    | Read endpoints and delivery history.               |
| `torqee:sessions:read`    | Subscribe to `session.*` events.                   |
| `torqee:transcripts:read` | Fetch the transcript after receiving an event.     |

Existing credentials do not gain the webhook scopes automatically. Issue a
new API key or OAuth grant with the scopes your integration needs.

## Register an endpoint

Create an active endpoint with a completed-session filter:

```bash
curl -X POST https://connect.torqee.app/api/v1/webhooks \
  -H "Authorization: Bearer torqee_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "Completed session processor",
    "url": "https://partner.example/webhooks/torqee",
    "filters": [
      {
        "type": "session.status_changed",
        "status": ["completed"]
      }
    ]
  }'
```

Filter entries are OR-matched. Fields inside one entry are AND-matched. A
filter can optionally include `sessionId`; a `session.status_changed` filter
can also include one or more unique session statuses. Filters contain 1–20
entries.

Webhook subscriptions currently support only `session.created` and
`session.status_changed`. `transcript.segment.created` and `board.updated` are
not supported as webhook subscriptions.

Production endpoints must use HTTPS. Redirects are not followed, and private,
loopback, link-local, reserved, and Cloudflare-internal destinations are
rejected.

## Receive and verify a delivery

Torqee sends an HTTP `POST` with `Content-Type: application/json` and these
headers:

```http
Torqee-Webhook-Id: wh_...
Torqee-Event-Id: evt_...
Torqee-Delivery-Id: dlv_...
Torqee-Signature: t=1752986400,kid=key_2026_01,v1=<base64url-signature>
```

Fetch the public signing keys without authentication:

```text
GET https://connect.torqee.app/.well-known/webhook-signing-keys.json
```

Select the Ed25519 JWK whose `kid` matches the signature header. Verify `v1`
against the UTF-8 bytes of:

```text
<t>.<exact raw request body>
```

Read and preserve the exact request bytes before parsing JSON. Re-serializing
parsed JSON can change the bytes and will fail verification. Also reject a
timestamp outside a narrow tolerance chosen for your service (five minutes is
a typical starting point) to limit replay attacks. Cache the JWKS briefly and
refresh it when an unknown `kid` is received; retiring public keys remain in
the JWKS for at least 30 days.

The following TypeScript sketch uses Web Crypto and `jose`:

```ts
import { importJWK } from "jose";

const parseSignature = (value: string) =>
  Object.fromEntries(value.split(",").map((part) => part.split("=", 2)));

const verifyWebhook = async (request: Request) => {
  const rawBody = new Uint8Array(await request.arrayBuffer());
  const signature = parseSignature(
    request.headers.get("Torqee-Signature") ?? "",
  );
  const timestamp = Number(signature.t);

  if (
    !Number.isSafeInteger(timestamp) ||
    Math.abs(Date.now() / 1000 - timestamp) > 300
  ) {
    throw new Error("stale webhook timestamp");
  }

  const jwks = await fetch(
    "https://connect.torqee.app/.well-known/webhook-signing-keys.json",
  ).then((response) => response.json());
  const jwk = jwks.keys.find(
    (key: { kid?: string }) => key.kid === signature.kid,
  );
  if (!jwk) throw new Error("unknown webhook signing key");

  const key = await importJWK(jwk, "EdDSA");
  if (!(key instanceof CryptoKey)) throw new Error("invalid signing key");
  const prefix = new TextEncoder().encode(`${timestamp}.`);
  const signed = new Uint8Array(prefix.length + rawBody.length);
  signed.set(prefix);
  signed.set(rawBody, prefix.length);

  const encoded = signature.v1.replaceAll("-", "+").replaceAll("_", "/");
  const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
  const bytes = Uint8Array.from(atob(padded), (character) =>
    character.charCodeAt(0),
  );
  const valid = await crypto.subtle.verify("Ed25519", key, bytes, signed);
  if (!valid) throw new Error("invalid webhook signature");

  return JSON.parse(new TextDecoder().decode(rawBody));
};
```

After successful verification, atomically record `Torqee-Event-Id` before
starting side effects. The event ID stays the same across retries and manual
replays. A normal retry keeps its delivery ID; a manual replay creates a new
delivery ID while preserving the event ID.

Return any 2xx response within 10 seconds to acknowledge the delivery. Torqee
does not store your response body.

## Fetch the completed transcript

A status-change payload includes `data.sessionId`, `data.status`,
`data.previousStatus`, and a session snapshot. Once `data.status` is
`completed`, fetch the transcript:

```bash
curl "https://connect.torqee.app/api/v1/sessions/session_123/transcripts?revisionKind=final" \
  -H "Authorization: Bearer torqee_YOUR_API_KEY"
```

The response contains a `transcripts` array. For a completed session, consume
the `segments` of the returned final revision.

The webhook payload is fixed when the event is created. Use the REST API when
your processing needs the persisted transcript or current session state.

## Delivery attempts, testing, and replay

Torqee retries network errors, timeouts, redirects, and non-2xx responses. The
schedule is the initial attempt, then after 1 minute, 5 minutes, 30 minutes,
2 hours, 8 hours, and 24 hours: seven attempts in total. A delivery becomes
`dead_letter` after the final failure; the endpoint is not disabled
automatically.

A test delivery is signed and appears in delivery history with kind `test`,
but it is attempted only once. Replay queues a new delivery with the normal
retry policy:

```text
POST /api/v1/webhooks/:id/test
GET  /api/v1/webhook-deliveries
GET  /api/v1/webhook-deliveries/:id
POST /api/v1/webhook-deliveries/:id/replay
```

Endpoint management is available at:

```text
GET    /api/v1/webhooks
POST   /api/v1/webhooks
GET    /api/v1/webhooks/:id
PATCH  /api/v1/webhooks/:id
DELETE /api/v1/webhooks/:id
```

Endpoint and delivery lists use cursor pagination. Delivery history and
attempts are retained for 30 days after a delivery reaches `succeeded`,
`dead_letter`, or `cancelled`. Lookup and replay are not guaranteed after that
boundary. Pending and retrying deliveries are not expired by this retention
window.
