Agency Webhooks
curl --request POST \
--url https://app.tuco.ai/api/agency/webhooks \
--header 'Authorization: Bearer <token>'import requests
url = "https://app.tuco.ai/api/agency/webhooks"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
fetch('https://app.tuco.ai/api/agency/webhooks', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/agency/webhooks",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/agency/webhooks"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.tuco.ai/api/agency/webhooks")
.header("Authorization", "Bearer <token>")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/agency/webhooks")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_bodyAgency
Agency Webhooks
Register a webhook for any client workspace in your Tuco agency — or for every client in one call — without holding each client’s API key.
POST
/
api
/
agency
/
webhooks
Agency Webhooks
curl --request POST \
--url https://app.tuco.ai/api/agency/webhooks \
--header 'Authorization: Bearer <token>'import requests
url = "https://app.tuco.ai/api/agency/webhooks"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
fetch('https://app.tuco.ai/api/agency/webhooks', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/agency/webhooks",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/agency/webhooks"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.tuco.ai/api/agency/webhooks")
.header("Authorization", "Bearer <token>")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/agency/webhooks")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_bodyWebhooks stay workspace-scoped: each client gets its own row, its own scope and
its own secret. What changes is who can create it — your agency key can,
for any workspace you own, so registration happens in the same script that
creates the client.
Endpoint
- Method:
POSTto create,GETto read,DELETE /api/agency/webhooks/{id}to remove - Path:
/api/agency/webhooks - Auth:
Authorization: Bearer tucoagency_xxxxxxxxxxxxx(agency key)
Body
| Field | Type | Required | Notes |
|---|---|---|---|
workspaceId | string | yes | A client clerkOrgId, or the literal "all" for every client in your agency. No default — a typo must not fan out to everyone |
url | string | yes | Must parse as a URL |
events | string[] | yes | One or more of the ten events. An unknown event is rejected and named |
name | string | yes | Shown in the dashboard |
description | string | no | |
secret | string | no | Supply your own so one receiver can verify every client with a single signing secret. Omit and each client gets its own random one |
authHeader | string | no | Sent verbatim as Authorization on each delivery — for endpoints that 401 without one |
One client
curl https://app.tuco.ai/api/agency/webhooks \
-H "Authorization: Bearer tucoagency_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"workspaceId": "org_3KKJKupHJ8Y9I2NXVAga0c6ZqgT",
"url": "https://yourapp.com/hooks/tuco",
"events": ["message.reply", "conversation.handover"],
"name": "Acme Roofing → our inbox"
}'
{
"agencyId": "user_3H08IRicrVUYLDZRyOh4jKRVwrx",
"created": 1,
"webhooks": [
{
"id": "6ac52ed996359b3643c71939",
"workspaceId": "org_3KKJKupHJ8Y9I2NXVAga0c6ZqgT",
"workspaceName": "Acme Roofing",
"secret": "k3K…"
}
],
"warning": "Save these secrets now. They are not shown again."
}
Every client, one call
This is the call that removes “ten clients means ten registrations”. Supply your ownsecret and a single receiver can verify all of them.
curl https://app.tuco.ai/api/agency/webhooks \
-H "Authorization: Bearer tucoagency_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"workspaceId": "all",
"url": "https://yourapp.com/hooks/tuco",
"events": ["message.reply"],
"name": "All clients → our inbox",
"secret": "one-secret-i-verify-everything-with"
}'
workspaceId off the payload to route it.
Which clients are not wired up yet
curl https://app.tuco.ai/api/agency/webhooks \
-H "Authorization: Bearer tucoagency_xxxxxxxxxxxxx"
{
"agencyId": "user_3H08IRicrVUYLDZRyOh4jKRVwrx",
"webhooks": [
{
"id": "6ac52ed996359b3643c71939",
"workspaceId": "org_3KKJKupHJ8Y9I2NXVAga0c6ZqgT",
"workspaceName": "Acme Roofing",
"url": "https://yourapp.com/hooks/tuco",
"events": ["message.reply"],
"name": "Acme Roofing → our inbox",
"isActive": true,
"failureCount": 0
}
],
"workspacesWithoutWebhook": [
{ "clerkOrgId": "org_3KKJL4Dnbq…", "name": "Vital Cryotherapy" }
]
}
workspacesWithoutWebhook is the useful read: the clients that would silently
receive nothing. Secrets are never returned here — they are shown once, at
creation.
Add ?workspaceId=org_… to scope the read to one client.
Removing one
curl -X DELETE https://app.tuco.ai/api/agency/webhooks/6ac52ed996359b3643c71939 \
-H "Authorization: Bearer tucoagency_xxxxxxxxxxxxx"
Line-health events
Ten events are open to anyone. Two more —line.down and line.recovered —
are not, and they are the ones an agency usually wants most.
A line going quiet is operational detail. Sent to the end client it is noise
they cannot act on, so Tuco never emails or webhooks it to them. An agency is a
different reader: for its clients it is the support desk, so these two
events are available to agencies Tuco has switched on.
Switched on automatically when your agency is invoiced — Tuco bills you, not
your clients, so you are already the operator for every line in the group. On
Stripe billing, ask us and we will enable it by hand without changing how you pay.
| Event | Fires when |
|---|---|
line.down | A line stops sending and the health check confirms it |
line.recovered | That same line starts working again |
curl https://app.tuco.ai/api/agency/webhooks \
-H "Authorization: Bearer tucoagency_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"workspaceId": "all",
"url": "https://yourapp.com/hooks/tuco-line-health",
"events": ["line.down", "line.recovered"],
"name": "Line health → our on-call"
}'
Payload
Same envelope as every other event — the line-specific fields sit underdata.
{
"event": "line.down",
"timestamp": "2026-10-07T04:11:52.610Z",
"workspaceId": "org_3KKJKupHJ8Y9I2NXVAga0c6ZqgT",
"contactOwnerEmail": null,
"ghlContactId": null,
"ghlLocationId": null,
"hsPortalId": null,
"hsContactId": null,
"data": {
"lineId": "68c1f0a7d2b4e91f3c7a55e2",
"workspaceId": "org_3KKJKupHJ8Y9I2NXVAga0c6ZqgT",
"workspaceName": "Acme Roofing",
"agencyId": "user_3H08IRicrVUYLDZRyOh4jKRVwrx",
"identifier": "+14155550134",
"channelType": "phone",
"lineName": "Acme Sales 1",
"at": "2026-10-07T04:11:52.610Z"
}
}
nulls are the shared CRM fields every event carries; they are
not meaningful here. Read data.
| Field | Notes |
|---|---|
lineId | The line that went down. Pass it to GET /api/lines for its current state |
workspaceId / workspaceName | Which client. Both are present so one receiver can route across the whole group without a second lookup |
identifier | The line’s phone or email |
channelType | phone or email |
lineName | Display name, or null if the line has none |
at | When the health check confirmed it (UTC ISO) |
X-Tuco-Signature.
Two ways this is gated
Both have to hold:-
Agency key only. A workspace key (
tuco_…) cannot subscribe to these however the body is crafted — the workspace route never offers them. -
Your agency has to be enabled. If it is not, the create returns
400with a reason rather than a flat “invalid event”, so you can tell a typo from a feature that is simply not on yet:{ "error": "line.down is only available to agencies Tuco has enabled it for, and only on an agency API key. Ask us to switch it on.", "code": "INVALID_WEBHOOK", "lineHealthEnabled": false }
Relationship to the workspace route
POST /api/webhooks still does the same
job with that one client’s workspace key, and both routes share one validator —
a URL or event list rejected by one is rejected by the other, identically. Use the
workspace route when your code already holds that client’s key; use this one when
you are operating across the group.
Error responses
| Status | Code | When |
|---|---|---|
400 | WORKSPACE_REQUIRED | workspaceId missing. There is deliberately no default |
400 | INVALID_WEBHOOK | Bad URL, empty or unknown events, or missing name. The body lists validEvents |
400 | NO_WORKSPACES | "all" with no client workspaces yet |
400 | INVALID_ID | Malformed id on DELETE |
401 | UNAUTHORIZED | Missing, unknown, revoked or expired key |
403 | AGENCY_KEY_REQUIRED | A workspace key (tuco_…) was used |
403 | NOT_YOUR_WORKSPACE | workspaceId is not in your agency |
404 | NOT_FOUND | The webhook is not one of your agency’s |