Webhooks
A webhook is an https URL you register once; the API POSTs a signed event
to it when something you subscribed to happens. Nothing about the rest of the
API changes: GET /v1/operations/{id} remains the source of truth, and a
webhook only saves you the polling.
Register one
Section titled “Register one”truo webhooks create --url https://example.com/hooks/truo \ --events operation.completed --events service.active{ "object": "webhook", "id": "whk_12", "url": "https://example.com/hooks/truo", "events": ["operation.completed", "service.active"], "enabled": true, "secret": "whsec_…" } // ← shown only here. Store it.- The URL must be
httpsand reachable from the public internet. Private, loopback and link-local addresses are rejected at registration and again before every delivery. - Up to 10 webhooks per account.
secretis returned once, here and onrotate-secret.GETnever shows it.- Scope:
account:writeto manage,account:readto list and inspect.
Events
Section titled “Events”type |
When | data |
|---|---|---|
operation.completed |
any operation ends, succeeded or failed — including the ones that finish inside the request |
the operation |
service.active |
a service becomes active (first provisioning, or unsuspended) | a service summary |
service.suspended |
billing suspended it | a service summary |
service.terminated |
billing terminated it | a service summary |
webhook.ping |
you called POST /v1/webhooks/{id}/ping |
{ "object": "ping", … } |
Subscribe to "*" to receive every type, including ones added later.
Every delivery has the same body:
{ "id": "evt_01JQ8X…", "object": "event", "type": "operation.completed", "created_at": "2026-09-16T00:00:00.000Z", "data": { "object": "operation", "id": "op_01JQ8X…", "type": "orders.create", "status": "succeeded", "resource": { "object": "order", "id": "ord_245" }, "result": { "service": { "id": 1241, "status": "active" } }, … } }Verify the signature
Section titled “Verify the signature”Every request carries:
Truo-Signature: t=1760000000,v1=5f1a…c9 # HMAC-SHA256(secret, "<t>.<raw body>"), hexTruo-Event: operation.completedTruo-Event-Id: evt_01JQ8X…Truo-Delivery: dlv_01JQ8X…Verify against the raw body, before parsing. Re-serializing the JSON changes the bytes and the signature will never match.
import { verifyWebhookSignature } from "@truocloud/sdk";
export async function POST(req: Request) { const body = await req.text(); // raw, unparsed const ok = await verifyWebhookSignature({ secret: process.env.TRUO_WEBHOOK_SECRET!, body, header: req.headers.get("truo-signature") ?? "", }); if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body); // … return new Response(null, { status: 204 });}Without the SDK: split the header on ,, compute
HMAC-SHA256(secret, t + "." + body) in hex, compare it to v1 in constant
time, and reject if |now − t| > 300 s.
Respond with any 2xx within 10 seconds. Do the work afterwards.
Retries
Section titled “Retries”A non-2xx, a timeout, or a connection error schedules a retry:
| attempt | delay after failure |
|---|---|
| 1 → 2 | 30 s |
| 2 → 3 | 2 min |
| 3 → 4 | 10 min |
| 4 → 5 | 1 h |
| 5 → 6 | 6 h |
| after 6 | failed |
Retries carry the same body and a fresh signature. Make your handler
idempotent on Truo-Event-Id (or id in the body): the same event can be
delivered more than once.
Debugging
Section titled “Debugging”truo webhooks deliveries whk_12 # newest first, with status and last errortruo webhooks delivery whk_12 dlv_01JQ8X… # the exact body that was signedtruo webhooks redeliver whk_12 dlv_01JQ8X… # send it againtruo webhooks ping whk_12 # a test event to this webhook onlyEach delivery keeps the exact body that was signed, the HTTP status of the
last attempt, and when the next one is due. consecutive_failures on the
webhook itself tells you at a glance whether the endpoint is healthy;
re-enabling a webhook resets it.