Webhooks
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:
- Subscribe to
session.status_changedwithstatus=[completed]. - Verify the raw body using the
kidfrom JWKS. - Deduplicate
Torqee-Event-Id. - Fetch the session transcript.
Webhook delivery is at least once, so your handler must be idempotent. Unlike webhooks, the SSE event stream 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:
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:
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:
GET https://connect.torqee.app/.well-known/webhook-signing-keys.jsonSelect the Ed25519 JWK whose kid matches the signature header. Verify v1
against the UTF-8 bytes of:
<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:
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:
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:
POST /api/v1/webhooks/:id/test
GET /api/v1/webhook-deliveries
GET /api/v1/webhook-deliveries/:id
POST /api/v1/webhook-deliveries/:id/replayEndpoint management is available at:
GET /api/v1/webhooks
POST /api/v1/webhooks
GET /api/v1/webhooks/:id
PATCH /api/v1/webhooks/:id
DELETE /api/v1/webhooks/:idEndpoint 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.