Webhooks
Subscribe to live events
When something happens in your tenant — a call ends, an SMS arrives, a deal moves stage — we POST a signed payload to your URL. n8n, Zapier, Make, custom servers — they all consume the same shape.
How it works#
- You register a URL and a list of events you care about.
- We hand back a signing secret — shown once.
- When a matching event happens, we POST a JSON envelope with HMAC-SHA256 headers.
- Your endpoint verifies the signature, processes the event, and returns 2xx.
- If you return 5xx or time out, we retry with backoff. After 5 attempts → dead-letter.
Subscribing#
POST /v1/webhooks
curl -X POST https://agency.gary.club/api/public/v1/webhooks \
-H "Authorization: Bearer gc_live_EXAMPLE_AbCdEfGhIj0123456789" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/incoming",
"events": ["call.completed", "sms.received", "deal.stage_changed"],
"description": "Daily triage flow"
}'Use ["*"] to subscribe to every event we emit.
201 Created
{
"webhook": {
"id": "00000000-0000-0000-0000-00000000aaaa",
"url": "https://your-app.example.com/incoming",
"events": ["call.completed", "sms.received", "deal.stage_changed"],
"active": true,
"secret_last4": "Xy12"
},
"secret": "whsec_EXAMPLE_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789",
"signing_header": "X-Gary-Signature"
}Stash the secret immediately
We store only a salted hash on our end. There's no way to retrieve the cleartext later — only rotate to a fresh one via
PATCH /v1/webhooks/{id} with { rotate_secret: true }.Payload shape#
Every delivery
{
"id": "evt_00000000-0000-0000-0000-aaaaaaaaaaaa",
"type": "call.completed",
"created": "1735000000",
"data": {
"call_id": "call_00000000-0000-0000-0000-bbbbbbbbbbbb",
"direction": "inbound",
"duration_seconds": 142,
"recording_url": "https://api.example.com/r/abc123",
"transcript": [ { "speaker": "agent", "content": "Hi …", "start_time_seconds": 0 } ],
"summary": "Caller asked about pricing for the Pro plan.",
"outcome": "interested",
"cost_cents": 34,
"contact_id": "con_00000000-0000-0000-0000-ccccccccaaaa"
},
"metadata": { "n8n_run_id": "run_..." }
}The metadata field is your correlation handle
When you trigger an action with
metadata (e.g. POST /v1/calls with { metadata: { run_id: "abc" } }), every event for that action carries the same metadata back. Use it to join an event to the upstream trigger.Headers we send#
X-Gary-Signaturesha256=…HMAC-SHA256 over
"{timestamp}.{raw_body}" using your subscription's secret.X-Gary-Timestampunix-secondsUsed inside the HMAC payload and for replay protection (reject > 5 minutes old).
X-Gary-EventEvent type (e.g.
call.completed) — convenient for routing without parsing the body.X-Gary-Event-IdStable UUID across retries. Dedupe on this if your handler isn't already idempotent.
X-Gary-Webhook-IdYour subscription id. Useful when one server handles webhooks from multiple tenants.
X-Gary-Delivery-IdUnique per attempt. Retry attempts share Event-Id but get fresh Delivery-Ids.
Verifying signatures#
Always verify before processing. Constant-time compare; reject on timestamp drift > 5 min.
HMAC verification
import crypto from 'node:crypto'
export function verifyGarySignature(req, rawBody) {
const ts = req.headers['x-gary-timestamp']
const sig = (req.headers['x-gary-signature'] || '').replace(/^sha256=/, '')
if (!ts || !sig) return false
// Replay protection: reject events older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const expected = crypto
.createHmac('sha256', process.env.GARY_WEBHOOK_SECRET)
.update(`${ts}.${rawBody}`, 'utf8')
.digest('hex')
// Constant-time compare prevents timing-leak attacks
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(sig, 'hex'),
)
} catch {
return false
}
}Read the raw body, not the parsed JSON
HMAC is computed over the exact bytes we sent. If your framework re-serialises the JSON before you compute the digest, you'll get a different (failing) signature. Use raw-body / pre-parse hooks (e.g.
express.raw(), FastAPI Request.body()).Event catalog#
Voice
call.receivedInbound call started — before pickup.
call.answeredPickup confirmed. Adds answered_at.
call.completedCall ended. Includes transcript turns, recording_url, summary, outcome, duration_seconds, cost_cents.
call.voicemail_leftFor outbound — voicemail dropped instead of conversation.
call.failedCouldn't connect (busy, no answer, network).
SMS (bidirectional)
sms.receivedInbound SMS landed.
sms.sentOur outbound was delivered by carrier.
sms.delivery_failedCarrier rejected, opt-out, or DLR fail.
CRM
contact.createdNew contact upserted into CRM.
contact.updatedMutable fields changed (name, lifecycle, owner, tags, custom).
company.createdNew company resolved (idempotent on domain).
deal.createdDeal opened against a pipeline + stage.
deal.stage_changedStage moved. Payload includes
prior_stage_id and new_stage_id.task.due_soonCron-fired when a task crosses its reminder window.
task.completedTask closed.
Sequences & AI
sequence.completedA contact finished an enrolled sequence.
sequence.contact_unenrolledAuto-pause on reply, manual unenroll, or list change.
sdr.outbound.completedFiltered alias of
call.completed for SDR-initiated outbound calls.ai.precall_brief.completedAsync brief job finished. Phase 2.
ai.lead_enrichment.completedAsync enrichment finished. Phase 2.
ai.kb_ingest.completedAsync KB document ingest finished. Phase 2.
ai.job.failedGeneric failure event for any async AI tool.
Lifecycle
webhook.testFired by
POST /v1/webhooks/{id}/test. Use it to verify your endpoint reaches our delivery worker.Retries & circuit breaker#
Failed deliveries (5xx, timeout, network error) retry on this schedule:
Attempt 1nowImmediate.
Attempt 2+30sFirst backoff.
Attempt 3+5mAttempt 4+30mAttempt 5+4hFinal retry.
Dead-letterafter #5Status set to
dead_letter. View via GET /v1/webhooks/{id}/deliveries.Circuit breaker
After 20 consecutive failures, the subscription is auto-disabled (
active: false). Re-enable with PATCH /v1/webhooks/{id} + { active: true } — that also resets the consecutive-failure counter.Testing#
Two ways to confirm your endpoint works without waiting for real traffic:
Fire a webhook.test event
curl -X POST \
https://agency.gary.club/api/public/v1/webhooks/00000000-0000-0000-0000-00000000aaaa/test \
-H "Authorization: Bearer gc_live_EXAMPLE_AbCdEfGhIj0123456789"
# 202 Accepted — queued. Cron deliverer dispatches within ~30 seconds.Then read back the delivery log:
GET deliveries (debug)
curl https://agency.gary.club/api/public/v1/webhooks/00000000-0000-0000-0000-00000000aaaa/deliveries?limit=10 \
-H "Authorization: Bearer gc_live_EXAMPLE_AbCdEfGhIj0123456789"Using with n8n#
- Drop a Webhook trigger node into your n8n flow, copy its URL.
- POST to
/v1/webhookswith that URL and the events you want. - Stash the returned
secretin n8n credentials. - Add a Function node before downstream logic that runs the HMAC verifier above. If it returns false, throw — the event isn't legit.
- Test with
POST /v1/webhooks/{id}/test; you should see awebhook.testarrive within 30s.

