Workspace webhooks
Route lifecycle events to organization endpoints by event, agent, and session tags, then inspect every delivery attempt.
Workspace webhooks are managed independently from agents. Configure them in Settings → Webhooks or through /v1/webhooks. One endpoint can subscribe to several lifecycle events and either every agent or an explicit agent set.
Create an endpoint
const endpoint = await speko.webhooks.create({
name: 'Production call events',
url: 'https://hooks.example.com/speko',
events: ['call.status', 'call.report', 'call.analysis', 'call.recording'],
allAgents: true,
filterTags: { environment: 'production' },
headers: { 'x-source': 'speko' },
authHeaders: [{ name: 'Authorization', value: 'Bearer secret-token' }],
timeoutMs: 4000,
});Supported voice-session events are:
| Event | When it fires | Automatic retries |
|---|---|---|
call.pre_call | Before the worker starts; the response may override supported call configuration | No |
call.status | When a call lifecycle event is recorded | No; best effort |
call.report | When the report is finalized, and again when a recording becomes ready | Yes |
call.analysis | When post-call analysis completes | Yes |
call.recording | When recording reaches ready or failed | Yes |
Call control events
These describe a human call — a broker on a browser softphone talking to a phone — leg by leg, rather than an AI voice session. None are retried: they project a live event history that stays readable at GET /v1/voice/calls/{callId}/events, so a redelivery would land too late to be worth anything.
| Event | When it fires | Automatic retries |
|---|---|---|
call.initiated | A call is created, before anyone is reachable | No |
call.ringing | A leg starts ringing (an outbound INVITE is out, or a broker is being rung) | No |
call.answered | A leg answers; the first one to answer also starts the call | No |
call.bridged | A leg is pulled into another leg's conversation | No |
call.hold / call.unhold | A leg is parked or resumed | No |
call.mute / call.unmute | A leg's own audio is muted or restored, server-side | No |
call.dtmf.sent | Speko sent tones towards a PSTN leg | No |
call.transfer.initiated / call.transfer.completed / call.transfer.failed | A blind or warm transfer progresses | No |
call.leg.hangup | One leg drops | No |
call.hangup | The whole call ends | No |
Every call-control payload carries call_id, control_id (null for the call-scoped call.initiated and call.hangup), event_id and occurred_at beside the event's own fields. Human calls have no agent, so an endpoint scoped to agentIds never receives them — subscribe with allAgents.
Inbound DTMF (a key the far end pressed) is deliberately absent. It arrives as an in-room data packet addressed to room participants: a connected softphone sees it, the server does not. A subscription that can never deliver is worse than none.
Every delivery includes a stable webhook_id for that event occurrence and the session's webhook_tags. A report's initial and recording-ready emissions are separate occurrences with different IDs; retries and redeliveries reuse the original.
Agent and tag routing
allAgents defaults to true. Set it to false and provide agentIds to narrow the endpoint. New agents automatically match all-agent endpoints.
Tags are free-form, case-sensitive string pairs — up to 20 per session, keys to 64 characters and values to 256. Attach them when creating an agent-backed browser, realtime, or phone session:
await speko.voice.dial({
to: '+12015551234',
agentId: 'agent_123',
webhookTags: {
environment: 'production',
customer: 'acme',
},
});An endpoint matches when all of its filterTags exist on the session with equal values. If any tagged endpoint matches, Speko sends to every matching tagged endpoint and ignores untagged defaults; if none match, it falls back to all matching untagged endpoints. Agentless sessions reject webhookTags with a 400 WEBHOOK_TAGS_REQUIRE_AGENT — there is no agent-scoped route to resolve.
There is no per-dial webhook URL: a request cannot carry a URL for Speko to call. Every destination is an endpoint registered ahead of time with POST /v1/webhooks, and the only per-call control is webhookTags choosing between them through their filterTags. To route by tenant or environment, register one endpoint per destination and tag the session.
Pre-call webhook
call.pre_call fires before the worker starts and its response may override supported call configuration — the place to look the caller up, choose a greeting, or inject account context. Phone calls fire it automatically. A browser or realtime session asks for it per session, on POST /v1/sessions:
{
"agentId": "agent_123",
"preCallWebhook": true,
"webhookTags": { "tenant": "acme" }
}It is opt-in here for two reasons. Your own backend mints a web session and usually already holds the values a lookup would return, so it can send them as variables. And an organization already running call.pre_call for phone traffic should not find its web sessions failing the first time that handler throws. Requires agentId and a subscribed endpoint; with neither, the session is created normally. Dashboard test calls and eval runs never fire it.
The webhook writes last on pipeline configuration. A returned firstMessage replaces both one sent on the create request and anything the agent supplies per language. turnHandling merges field by field: a handler returning turnHandling: { onMachine: 'hangup' } changes that one field and leaves an inherited voicemailMessage in place. toolSecrets is the exception below.
The payload is the shape documented under phone agents. One difference: a session sends direction: "web", with to, from, dialed_number, forwarded_from_number, phone_number_id and call_control_id all null. A handler can tell a session from a call on that alone. Overrides land before prompt compilation, so variables still render. toolSecrets is the exception to the ordering above — there the create request wins.
The pre-call is fail-closed on every path. A delivery that refuses, times out or answers non-2xx fails session creation with 502 PRE_CALL_WEBHOOK_FAILED, rather than starting a session without the context its handler was meant to supply. Only one effective call.pre_call endpoint may match an agent/tag route. When more than one does, creation fails with 409 PRE_CALL_WEBHOOK_AMBIGUOUS; the attempt is still recorded in the logs below.
Signing and secrets
Requests use Standard Webhooks headers and the workspace signing secret by default. Set signingSecretSource: 'custom' with a write-only signingSecret to override it per endpoint. Auth-header values are encrypted and read back only as { name, configured: true }; on update, omit a value to keep it or supply one to rotate it.
Query values, signatures, authorization, cookies, API keys, and credential-shaped headers are redacted from delivery logs. Response bodies are capped at 64 KiB and expose responseTruncated.
Lifecycle payloads are not templated. Unlike a webhook tool, an endpoint cannot pick its HTTP method or shape its own body — every payload is a fixed object composed server-side. Its headers map is static plaintext, which is why credential-shaped names are rejected there.
Extraction fields
Endpoints subscribed to call.report or call.analysis can request extractionFields. Speko runs one extraction pass over the union of the fields all matching endpoints require, then includes only each endpoint's own fields in its custom_data. Endpoints whose agent scopes overlap must agree on any field name they share: identical definitions deduplicate, and a differing definition is rejected with 409 EXTRACTION_CONFLICT. Endpoints scoped to disjoint agents never merge, so they may reuse a name freely.
Delivery logs and redelivery
Every attempt is queryable at GET /v1/webhook-deliveries, or in the Logs tab in Settings:
const page = await speko.webhooks.deliveries.list({
endpointId: endpoint.id,
event: 'call.report',
status: 'failed',
limit: 50,
});
const detail = await speko.webhooks.deliveries.get(page.data[0]!.id);
await speko.webhooks.deliveries.redeliver(detail.id);A record holds the endpoint-specific request, sanitized URL and headers, HTTP result, duration, bounded response, error, routing tags, and chronological attempts, and is retained for 30 days. Private phone-number API deliveries (imessage.received, imessage.reaction_received, imessage.sent, imessage.delivered, imessage.delivery_failed) appear in the same place under the stable Inkbox event ID.
Check canRedeliver before retrying one. It is false for call.pre_call, for an expired record, and once the endpoint is deleted. A redelivery replays the stored payload and webhook ID against the endpoint's current URL and credentials, adding an attempt to the existing record. It neither schedules a new automatic retry nor cancels a pending one, and a delivery that already succeeded stays successful. imessage.* records are view-only: Speko retries a failed iMessage delivery automatically (up to 8 attempts over about a day, the same schedule as call.report), but you cannot redeliver one manually.
Migrating agent webhooks
Agent webhook configuration is backfilled to central endpoints, and dispatch reads only those. agent.webhooks stays supported through the current SDK major version, with legacy writes synchronizing legacy-managed endpoints. New integrations should use speko.webhooks.