Webhooks and signing
Add an https endpoint in the app under Phone > Webhooks, with an
event filter: *, a list such as call.completed,sms.received, or a prefix such as
contact.*. Zapier subscribes its own endpoints through POST /api/zapier/hooks.
Every delivery is a POST with this body:
{ "id": "evt guid", "type": "call.completed", "created": 1759500000, "data": { ... } }
| Event | Key fields in data |
call.completed | call_id, direction, from, to, from_name, status, started_at, answered_at, ended_at, duration_seconds, hangup_cause |
call.missed | Same as call.completed, for an inbound call nobody answered. Sent only to endpoints that name it exactly. |
voicemail.received | voicemail_id, from, duration_seconds, audio_endpoint, transcript, received_at |
sms.received | message_id, thread_id, from, to_phone_number_id, body, media_urls, received_at, is_stop, is_help |
contact.created, contact.updated, contact.deleted | contact_id, e164, first_name, last_name, company (plus email, tags, display_name, updated_at on create and update) |
Verifying the signature
Each request carries two headers:
Timberswitch-Timestamp: 1759500000
Timberswitch-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{raw body}" keyed with your endpoint's secret>
The secret (whsec_...) is shown when you create the endpoint. Compute the HMAC over the
raw request bytes, before any JSON parsing, compare in constant time, and reject
timestamps more than 5 minutes from your clock to stop replays. Answer with any 2xx within 10 seconds.
curl / shell (check a captured delivery):
# SECRET = your endpoint's whsec_... secret, TS = the Timberswitch-Timestamp header,
# body.json = the raw request body exactly as received.
{ printf '%s.' "$TS"; cat body.json; } \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.*= /sha256=/'
# The output must equal the Timberswitch-Signature header.
Node.js
const crypto = require('crypto');
// rawBody: the request body as a string or Buffer, before JSON.parse
// (in Express: express.raw({ type: 'application/json' })).
function verifyTimberswitch(rawBody, headers, secret) {
const ts = headers['timberswitch-timestamp'];
const sig = headers['timberswitch-signature'] || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(ts + '.').update(rawBody).digest('hex');
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
C#
using System.Security.Cryptography;
using System.Text;
// rawBody: the request body exactly as received (read it before deserializing).
static bool VerifyTimberswitch(string rawBody, string timestamp, string signature, string secret)
{
if (!long.TryParse(timestamp, out var ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(timestamp + "." + rawBody));
var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature ?? ""));
}
Retries: a delivery that fails or gets a non-2xx response is retried with exponential
backoff (10 s, 20 s, 40 s and so on), up to 8 attempts in total, then dropped. The id is the
same on every retry of an event, so use it to ignore duplicates.
Planned for v1: Standard Webhooks signing (webhook-id, webhook-timestamp, webhook-signature) alongside today's headers, secret rotation, and a delivery log you can replay from.
Reference
Base URL https://www.timberswitch.com. Version: v1 (preview, 2026-10-04).
Account
Who is calling, and which workspaces they can reach.
GET
/api/v1/me
Who am I
The signed-in user and, for an API key, the key's id, name and workspace binding. A good first call to check a key works.
Scope: read:user or zapier · Who: the caller (no workspace role needed)
| Response | Body | When |
| 200 | MeResponse | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs
List my workspaces
Every workspace the key's user belongs to. Use the slug in the /api/v1/orgs/{slug}/... routes. A key bound to one workspace still lists all of the user's workspaces, but only works inside its own.
Scope: read:org · Who: the caller (no workspace role needed)
| Response | Body | When |
| 200 | WorkspaceSummary[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs/{slug}
Get a workspace
One workspace and the caller's role in it.
Scope: read:org · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
| Response | Body | When |
| 200 | WorkspaceDetail | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
Numbers and extensions
The workspace's phone numbers and extensions (read only).
GET
/api/v1/orgs/{slug}/numbers
List phone numbers
The workspace's phone numbers. Buying and releasing numbers is done in the app, not over the API.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
| Response | Body | When |
| 200 | PhoneNumberDto[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs/{slug}/extensions
List extensions
The workspace's extensions.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
| Response | Body | When |
| 200 | ExtensionDto[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
Calls
The call log and per-number caller context.
GET
/api/v1/orgs/{slug}/caller-context
Caller context for a number
Everything the workspace has on one outside number in a single call: the saved contact, the last 5 calls, the last 3 voicemails and the latest text. Built for screen pops. `e164` is required.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
e164 | query | string | yes |
| Response | Body | When |
| 200 | CallerContextDto | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 400 | ApiErrorBody | The request is invalid. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs/{slug}/calls
List recent calls
Newest first. `limit` defaults to 50 and is clamped to 1-200. There is no cursor or date filter yet.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
limit | query | integer (int32) | no |
| Response | Body | When |
| 200 | CallDto[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
Voicemail
Voicemails with transcripts, read/archive state and audio.
GET
/api/v1/orgs/{slug}/voicemails
List voicemails
Newest first, with the transcript when it is ready. `limit` defaults to 50 and is clamped to 1-200. There is no cursor yet.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
includeArchived | query | boolean | no |
limit | query | integer (int32) | no |
| Response | Body | When |
| 200 | VoicemailDto[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
POST
/api/v1/orgs/{slug}/voicemails/{id}/read
Mark a voicemail read
Marks the voicemail read (audited).
Scope: write:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
| Response | Body | When |
| 204 | | No Content |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
POST
/api/v1/orgs/{slug}/voicemails/{id}/archive
Archive a voicemail
Hides the voicemail from the default list (includeArchived=true still returns it).
Scope: write:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
| Response | Body | When |
| 204 | | No Content |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs/{slug}/voicemails/{id}/audio
Get voicemail audio
Redirects (302) to a presigned audio url that expires in 15 minutes, or streams audio/mpeg. Every fetch is written to the workspace audit log. Don't store the redirect target; ask again when you need it.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
| Response | Body | When |
| 302 | | Found |
| 200 | | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
Recordings
Call-recording audio.
GET
/api/v1/orgs/{slug}/recordings/{id}/audio
Get call-recording audio
Redirects (302) to a presigned audio url that expires in 15 minutes, or streams audio/mpeg. Every fetch is written to the workspace audit log. There is no recording list endpoint yet.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
| Response | Body | When |
| 302 | | Found |
| 200 | | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
Contacts
The shared address book.
GET
/api/v1/orgs/{slug}/contacts
List or search contacts
`search` matches name, company, email or number. `limit` defaults to 100 and is clamped to 1-500. There is no cursor yet.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
search | query | string | no |
limit | query | integer (int32) | no |
| Response | Body | When |
| 200 | ContactDto[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
POST
/api/v1/orgs/{slug}/contacts
Create or update a contact by number
Upsert by E.164: posting a number that already exists updates that contact instead of failing, so retries are safe. Any member may add a new contact; overwriting an existing one needs an owner or admin. Fires `contact.created` or `contact.updated`.
Scope: write:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
Body: ContactWriteRequest (application/json)
| Response | Body | When |
| 200 | ContactDto | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 400 | ApiErrorBody | The request is invalid. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs/{slug}/contacts/{id}
Get a contact
One contact by id.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
| Response | Body | When |
| 200 | ContactDto | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
PATCH
/api/v1/orgs/{slug}/contacts/{id}
Update a contact
Partial update by id (PATCH and PUT behave the same). Fields left null keep their value; an empty string clears a field. The number cannot be changed.
Scope: write:telephony · Who: a workspace owner or admin
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
Body: ContactWriteRequest (application/json)
| Response | Body | When |
| 200 | ContactDto | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member, or is not an owner or admin (manager_only). |
| 400 | ApiErrorBody | The request is invalid. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
PUT
/api/v1/orgs/{slug}/contacts/{id}
Update a contact
Partial update by id (PATCH and PUT behave the same). Fields left null keep their value; an empty string clears a field. The number cannot be changed.
Scope: write:telephony · Who: a workspace owner or admin
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
Body: ContactWriteRequest (application/json)
| Response | Body | When |
| 200 | ContactDto | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member, or is not an owner or admin (manager_only). |
| 400 | ApiErrorBody | The request is invalid. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
DELETE
/api/v1/orgs/{slug}/contacts/{id}
Delete a contact
Removes the contact. Fires `contact.deleted`.
Scope: delete:data · Who: a workspace owner or admin
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
| Response | Body | When |
| 204 | | No Content |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member, or is not an owner or admin (manager_only). |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
POST
/api/v1/orgs/{slug}/contacts/import
Import contacts from CSV
Multipart upload of one CSV file (5 MB max). Rows upsert by number. Returns counts and a per-row error list.
Scope: write:telephony · Who: a workspace owner or admin
| Parameter | In | Type | Required |
slug | path | string | yes |
Body: object (multipart/form-data)
| Response | Body | When |
| 200 | ContactImportResult | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member, or is not an owner or admin (manager_only). |
| 400 | ApiErrorBody | The request is invalid. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
SMS
Text conversations and their messages (read only; see the Zapier send-sms action for sending).
GET
/api/v1/orgs/{slug}/sms/threads
List text conversations
Most recent first. `limit` defaults to 50 and is clamped to 1-200. There is no cursor yet.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
limit | query | integer (int32) | no |
| Response | Body | When |
| 200 | SmsThreadDto[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs/{slug}/sms/threads/{id}
Get a text conversation
One conversation by id.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
| Response | Body | When |
| 200 | SmsThreadDto | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/v1/orgs/{slug}/sms/threads/{id}/messages
List messages in a conversation
`limit` defaults to 100 and is clamped to 1-500. There is no cursor yet.
Scope: read:telephony · Who: any non-suspended member of the workspace
| Parameter | In | Type | Required |
slug | path | string | yes |
id | path | string | yes |
limit | query | integer (int32) | no |
| Response | Body | When |
| 200 | SmsMessageDto[] | OK |
| 401 | | No valid API key or session. |
| 403 | ApiErrorBody | The key lacks the scope, is bound to a different workspace, the caller is not a (non-suspended) member. |
| 404 | | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
Zapier app
The routes the TimberSwitch Zapier app uses. The workspace comes from the key, not the URL.
POST
/api/zapier/hooks
Subscribe a REST hook
Creates a webhook subscription for one trigger (call_completed, missed_call, voicemail_received, sms_received). target_url must be an https Zapier hook url. The response carries the signing secret once. At most 50 per workspace (429 after that).
Scope: zapier · Who: an X-API-Key bound to one workspace whose owner is an owner or admin there
Body: ZapierSubscribeRequest (application/json)
| Response | Body | When |
| 201 | ZapierSubscribeResponse | Created |
| 401 | ZapierErrorBody | No valid API key or session. |
| 403 | ZapierErrorBody | The key lacks the zapier scope, is not bound to one workspace, or its owner is not an owner or admin there. |
| 400 | ZapierErrorBody | The request is invalid. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
DELETE
/api/zapier/hooks/{id}
Unsubscribe a REST hook
Removes a Zapier subscription of the key's workspace.
Scope: zapier · Who: an X-API-Key bound to one workspace whose owner is an owner or admin there
| Parameter | In | Type | Required |
id | path | string | yes |
| Response | Body | When |
| 200 | ZapierUnsubscribeResponse | OK |
| 401 | ZapierErrorBody | No valid API key or session. |
| 403 | ZapierErrorBody | The key lacks the zapier scope, is not bound to one workspace, or its owner is not an owner or admin there. |
| 404 | ZapierErrorBody | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
GET
/api/zapier/triggers/{trigger}
List recent items for a trigger
Newest first, in the same shape the webhook delivers (its data object). ai_message_taken is polled here; the others are samples for Zap setup. `limit` defaults to 10.
Scope: zapier · Who: an X-API-Key bound to one workspace whose owner is an owner or admin there
| Parameter | In | Type | Required |
trigger | path | string | yes |
limit | query | integer (int32) | no |
| Response | Body | When |
| 200 | object[] | OK |
| 401 | ZapierErrorBody | No valid API key or session. |
| 403 | ZapierErrorBody | The key lacks the zapier scope, is not bound to one workspace, or its owner is not an owner or admin there. |
| 404 | ZapierErrorBody | The workspace or the item does not exist, or is not in this workspace. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
POST
/api/zapier/actions/send-sms
Send a text
Sends an SMS from one of the workspace's numbers (from_number is required when there are several). Send an idempotency_key: a retry with the same key returns the first result with duplicate=true and sends nothing. Texting must be on for the workspace; STOP replies, daily caps and US/Canada-only rules apply. 1600 characters max.
Scope: zapier · Who: an X-API-Key bound to one workspace whose owner is an owner or admin there
Body: ZapierSendSmsRequest (application/json)
| Response | Body | When |
| 200 | ZapierSendSmsResponse | OK |
| 409 | ZapierErrorBody | Conflict |
| 422 | ZapierErrorBody | Unprocessable Entity |
| 502 | ZapierErrorBody | Bad Gateway |
| 401 | ZapierErrorBody | No valid API key or session. |
| 403 | ZapierErrorBody | The key lacks the zapier scope, is not bound to one workspace, or its owner is not an owner or admin there. |
| 400 | ZapierErrorBody | The request is invalid. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
POST
/api/zapier/actions/block-number
Block a number
Adds a number to the workspace's call block list (upsert, so a retry does not add a second row).
Scope: zapier · Who: an X-API-Key bound to one workspace whose owner is an owner or admin there
Body: ZapierBlockRequest (application/json)
| Response | Body | When |
| 200 | ZapierBlockResponse | OK |
| 401 | ZapierErrorBody | No valid API key or session. |
| 403 | ZapierErrorBody | The key lacks the zapier scope, is not bound to one workspace, or its owner is not an owner or admin there. |
| 400 | ZapierErrorBody | The request is invalid. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |
POST
/api/zapier/actions/create-contact
Create or update a contact
Upsert by number; created=false means an existing contact was updated.
Scope: zapier · Who: an X-API-Key bound to one workspace whose owner is an owner or admin there
Body: ZapierContactRequest (application/json)
| Response | Body | When |
| 200 | ZapierContactResponse | OK |
| 401 | ZapierErrorBody | No valid API key or session. |
| 403 | ZapierErrorBody | The key lacks the zapier scope, is not bound to one workspace, or its owner is not an owner or admin there. |
| 400 | ZapierErrorBody | The request is invalid. |
| 429 | | Rate limited. The body is empty and no Retry-After header is sent today. |