Receiving webhooks

Every AIGears primitive tells you what happened the same way: it POSTs a signed JSON event to a URL you own. A schedule fires, a deadline escalates, an approver decides, a watched page changes — four different primitives, one delivery contract and one receiver to build. Write the handler once, verify the signature once, and every event from every primitive flows through it.

This is the how-to companion to Webhooks & signing, which owns the contract — the envelope shape, the signature scheme, the retry and dead-letter rules. This page is the practical side: stand up an endpoint, verify a delivery, stay correct under retries, and test the whole thing before you point production traffic at it.

Build a receiver

A receiver is just an HTTPS endpoint that accepts a POST. Two rules shape a good one.

Respond 2xx fast; do the work asynchronously. We treat any 2xx as "you have it" and stop retrying; anything else (or no response within 15 seconds) is a failed delivery we will retry. So your handler's only job on the request path is to authenticate and durably record the event — verify the signature, enqueue the event (or write it to a table) for a background worker, and return 200. Do the slow part — calling other services, sending mail, reconciling state — async, off the request thread. A handler that does real work inline will eventually time out under load, and a timeout looks exactly like an outage to our retrier: you get redelivered the events you were slowest to process, which is the opposite of what you want.

Be publicly reachable — which your laptop is not. We only deliver to public HTTPS endpoints. The same SSRF guard that protects our outbound requests rejects localhost, loopback, and private-range addresses, so you cannot point a webhook at http://localhost:8000 and see it fire during development. Put a tunnel in front of your local server instead — a tool like ngrok or Cloudflare Tunnel gives you a public HTTPS URL that forwards to your machine — and register that tunnel URL as the webhook_url. In production you register the real public endpoint directly. (The scheme must be https://; the SSRF guard and the https-only rule are covered in Webhooks & signing.)

Verify the signature

A webhook_url is a public address, so anyone who learns it can POST to it. The only thing that makes a delivery trustworthy is its signature. Verify every delivery before you act on it, and reject any that fails.

Every event carries an X-AIGears-Signature header in the Stripe-style form t=<unix_ts>,v1=<hex_hmac>. The v1 value is the hex HMAC-SHA256 of the string "<unix_ts>.<raw_request_body>", keyed by your Team's webhook signing secret (found in the dashboard, separate from your API keys). Verifying is three steps:

  1. Recompute over the raw body. HMAC the exact bytes we sent — the request body as received, before any JSON parsing. Parse-then-re-serialize changes bytes (key order, whitespace) and breaks the HMAC, so read the raw body first and verify against it. Most frameworks make you opt in to the raw body; do.
  2. Compare in constant time. Use a constant-time comparison (Python's hmac.compare_digest, Node's crypto.timingSafeEqual) so an attacker can't narrow a forged signature byte-by-byte from response timing.
  3. Check the timestamp. Reject a delivery whose t is outside a tolerance window (300 seconds is a good default) so a captured-but-valid delivery can't be replayed later. Authenticate first, then trust the timestamp for the freshness check.

The core, in Python — the whole scheme is these few lines:

import hashlib
import hmac
import time

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> None:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, provided = int(parts["t"]), parts["v1"]

    signed = f"{ts}.".encode() + raw_body                       # <ts>.<raw_body>
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, provided):             # constant-time
        raise ValueError("signature mismatch")
    if abs(int(time.time()) - ts) > tolerance:                  # replay window
        raise ValueError("timestamp outside tolerance")

Don't hand-roll this in production — copy the runnable, dependency-free reference verifiers, which add header parsing, error types, and a self-check you can run to confirm the scheme:

Because the scheme is Stripe's exact shape, any off-the-shelf Stripe-style webhook library verifies it too — point it at your signing secret and the X-AIGears-Signature header.

Handle duplicates

Delivery is at-least-once. If the network drops between us delivering an event and your endpoint's 2xx reaching us, we count that as a failure and retry — and you receive the same logical event twice. This is deliberate (the alternative, at-most-once, silently loses events), and it puts one requirement on you: your handler must be idempotent.

Every retry of the same logical event carries the same event_id (the per-attempt id stays internal — you never see it). So the rule is simple: dedupe on event_id. Record each event_id you have fully processed, and on arrival, skip any you have already seen — but still return 2xx so we stop retrying it:

if already_processed(event["event_id"]):
    return HttpResponse(status=200)   # ack the duplicate; do nothing else
mark_processed(event["event_id"])     # do this atomically with your handling

Make "record that I processed it" and "do the work" atomic (one transaction, or a unique constraint on event_id) so a crash between them can't drop an event or double-apply it. Processing idempotently on event_id is the single most important property of a correct receiver.

Event types

One receiver handles every primitive, so branch on the envelope's event_type. The full set your endpoint can receive, grouped by primitive — the exact data shape for each lives in the owning guide's payload section:

Primitive event_type(s) delivered Fires when Payload shape
Schedules schedule.fired a schedule reaches its time The webhook you receive
Deadlines deadline.escalation each notification step fires (due, escalation, daily overdue) Notifications you receive
Approvals approval.approved, approval.rejected, approval.expired the request reaches a terminal decision Wait for the decision
Watches watch.changed, watch.source_error a poll finds a matched change, or a source starts failing The change webhook

event_type uses the same dotted namespace as the REST and MCP tools, so it reads the same everywhere. Switch on the string, dispatch to the right handler, and ignore any type you don't recognise (returning 2xx) — that keeps your receiver forward-compatible as new event types ship.

Test before going live

Before you point real traffic at a new endpoint, exercise its verification path with the dashboard Webhook Tester. Pick an event_type, give it your endpoint URL, and we send one realistic event through the same envelope and signing code production uses.

That is the whole value: a test delivery is indistinguishable from a production one. It is signed with your real Team secret, carries the same X-AIGears-Signature header and envelope shape, and — deliberately — nothing in the payload marks it as a test. Your handler cannot tell the difference, so if it verifies the tester's signature it will verify production's. (Server-side we do record that the delivery was a test, for your Activity feed; that flag never reaches the wire.)

Two things to know about the tester:

  • It is one-shot — a single synchronous send with the result shown inline (status, response code). There is no retry and no dead-letter, so a 2xx proves your endpoint accepted one delivery; retry and duplicate handling are still yours to get right (see Handle duplicates).
  • Test sends are not billed and don't count against any usage limit — they are an operator tool, not metered traffic.

Use it to confirm three things end to end: the signature verifies, your handler returns 2xx fast, and an unrecognised or duplicate event_id is handled cleanly. Once that holds, you're ready for live deliveries.