Gary Club
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#

  1. You register a URL and a list of events you care about.
  2. We hand back a signing secret — shown once.
  3. When a matching event happens, we POST a JSON envelope with HMAC-SHA256 headers.
  4. Your endpoint verifies the signature, processes the event, and returns 2xx.
  5. 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-seconds
Used inside the HMAC payload and for replay protection (reject > 5 minutes old).
X-Gary-Event
Event type (e.g. call.completed) — convenient for routing without parsing the body.
X-Gary-Event-Id
Stable UUID across retries. Dedupe on this if your handler isn't already idempotent.
X-Gary-Webhook-Id
Your subscription id. Useful when one server handles webhooks from multiple tenants.
X-Gary-Delivery-Id
Unique 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.received
Inbound call started — before pickup.
call.answered
Pickup confirmed. Adds answered_at.
call.completed
Call ended. Includes transcript turns, recording_url, summary, outcome, duration_seconds, cost_cents.
call.voicemail_left
For outbound — voicemail dropped instead of conversation.
call.failed
Couldn't connect (busy, no answer, network).

SMS (bidirectional)

sms.received
Inbound SMS landed.
sms.sent
Our outbound was delivered by carrier.
sms.delivery_failed
Carrier rejected, opt-out, or DLR fail.

CRM

contact.created
New contact upserted into CRM.
contact.updated
Mutable fields changed (name, lifecycle, owner, tags, custom).
company.created
New company resolved (idempotent on domain).
deal.created
Deal opened against a pipeline + stage.
deal.stage_changed
Stage moved. Payload includes prior_stage_id and new_stage_id.
task.due_soon
Cron-fired when a task crosses its reminder window.
task.completed
Task closed.

Sequences & AI

sequence.completed
A contact finished an enrolled sequence.
sequence.contact_unenrolled
Auto-pause on reply, manual unenroll, or list change.
sdr.outbound.completed
Filtered alias of call.completed for SDR-initiated outbound calls.
ai.precall_brief.completed
Async brief job finished. Phase 2.
ai.lead_enrichment.completed
Async enrichment finished. Phase 2.
ai.kb_ingest.completed
Async KB document ingest finished. Phase 2.
ai.job.failed
Generic failure event for any async AI tool.

Lifecycle

webhook.test
Fired 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 1now
Immediate.
Attempt 2+30s
First backoff.
Attempt 3+5m
Attempt 4+30m
Attempt 5+4h
Final retry.
Dead-letterafter #5
Status 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#

  1. Drop a Webhook trigger node into your n8n flow, copy its URL.
  2. POST to /v1/webhooks with that URL and the events you want.
  3. Stash the returned secret in n8n credentials.
  4. Add a Function node before downstream logic that runs the HMAC verifier above. If it returns false, throw — the event isn't legit.
  5. Test with POST /v1/webhooks/{id}/test; you should see a webhook.test arrive within 30s.