REST & Webhooks

How AI systems connect to the outside world: REST request/response and auth, webhooks as the async inverse (systems calling you on events), and idempotency — the discipline that keeps retries from double-charging or double-sending when networks misbehave.

~3hapiintegrationwebhooks
Learning objectives
  • Expose an AI service over a REST API with proper verbs, status codes, and auth.
  • Understand webhooks as the async inverse of REST — systems calling you when events happen.
  • Verify webhook authenticity (signatures) and never trust an unverified payload.
  • Apply idempotency so retries don't double-charge, double-send, or double-process.
Note

For JavaScript developers: REST and webhooks are the same everywhere — you've likely wired a Stripe webhook or a Zapier trigger. This topic frames them for AI integration and drills the reliability details (auth, idempotency) that separate a demo integration from a production one. Code is FastAPI (Track 1).

Why integration is the job

An AI system is only valuable when it's connected — to the CRM, the helpdesk, the data warehouse, the website. Track 9 is the "forward-deployed" flavor of the market: getting AI into the systems a business already runs. The two fundamental integration patterns are REST (you ask another system, or it asks you, synchronously) and webhooks (a system tells you something happened, asynchronously). Most AI integrations — including the hybrid code-service from Tracks 4 and 8 — are built from these two.

REST: request/response

REST is synchronous request/response over HTTP. You expose your AI capability as an endpoint others call, and you call others' endpoints. The pieces you must get right:

  • VerbsGET (read, no side effects), POST (create/act), PUT/PATCH (update), DELETE. An AI action endpoint is almost always POST.
  • Status codes200 OK, 201 created, 400 bad request, 401 unauthorized, 429 rate-limited, 500 server error. Return the right code so callers can react correctly (retry a 429, don't retry a 400).
  • Auth — never expose an unauthenticated AI endpoint (it costs you tokens per call). Common: an API key or bearer token in a header; OAuth for user-delegated access.
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()

@app.post("/classify")                                   # POST: it performs an action (and costs tokens)
def classify(payload: dict, authorization: str = Header(None)):
    if authorization != f"Bearer {EXPECTED_TOKEN}":      # AUTH first — reject before spending a token
        raise HTTPException(status_code=401, detail="unauthorized")   # 401, the correct code
    if "text" not in payload:                            # validate input
        raise HTTPException(status_code=400, detail="missing 'text'") # 400 = caller's mistake, don't retry
    return {"category": my_classifier(payload["text"])}  # 200 with the result

This is the same code-service you exposed in the hybrid pattern (Track 8) — now with the auth and status-code rigor a real caller depends on.

Tip

Exercise 1 has you build an authenticated POST AI endpoint that returns correct status codes for success, bad input, and missing auth.

Webhooks: the async inverse

A REST call is you asking. A webhook is another system telling you — it POSTs to your endpoint when an event happens (a new support ticket, a form submission, a payment). Webhooks are how AI systems react to the world in real time without polling: "a ticket arrived → classify and route it." (Every N8N/Zapier trigger is a webhook under the hood.)

@app.post("/webhooks/new-ticket")                        # a URL you register with the source system
def on_new_ticket(event: dict):
    # The source system calls THIS when a ticket is created — you react to the event.
    ticket = event["data"]
    category = my_classifier(ticket["body"])             # run your AI on the incoming event
    route_ticket(ticket["id"], category)                 # act on the result
    return {"received": True}                             # ack quickly (see: respond fast, work async)
Watch out

Respond to webhooks fast, then do the work. The sender expects a quick 2xx ack; if your handler runs a slow LLM call inline, the sender may time out and retry, causing duplicate processing. Acknowledge immediately and hand slow work to a queue/background task (next topic) — and make processing idempotent so a retry is harmless.

Verify the webhook is real

Your webhook URL is public — anyone who finds it can POST fake events (spoofed tickets, forged payments). So you must verify each webhook actually came from the expected sender, usually via a signature: the sender signs the payload with a shared secret, and you recompute and compare:

import hmac, hashlib

def verify(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()  # recompute the HMAC of the raw body
    return hmac.compare_digest(expected, signature)      # constant-time compare (avoids timing attacks)

@app.post("/webhooks/new-ticket")
async def on_new_ticket(request: Request):
    body = await request.body()                          # the RAW bytes — verify BEFORE parsing
    sig = request.headers.get("X-Signature", "")
    if not verify(body, sig, WEBHOOK_SECRET):            # reject anything not signed by the real sender
        raise HTTPException(status_code=401, detail="bad signature")
    event = json.loads(body)
    # … process the now-trusted event …

Unverified webhook input is also untrusted input to your AI — it can carry an indirect prompt injection (Track 6). Verify the sender and treat the content as untrusted.

Tip

Exercise 2 has you receive a webhook, verify its HMAC signature against a secret, and reject a forged request — the gate every public webhook needs.

Idempotency: survive retries

Networks are unreliable, so senders retry — and REST/webhook calls will be delivered more than once. If processing a duplicate causes a second charge, a second email, or a second DB row, that's a real bug. Idempotency means: processing the same request twice has the same effect as processing it once. The standard mechanism is an idempotency key — a unique id per logical event that you record and check:

processed = set()                                        # a store of handled event ids (a DB table in production)

@app.post("/webhooks/payment")
def on_payment(event: dict):
    event_id = event["id"]                               # the sender's unique id for THIS event
    if event_id in processed:                            # already handled? (a retry / duplicate delivery)
        return {"status": "duplicate, ignored"}          # no-op — safe to receive twice
    processed.add(event_id)                              # record it BEFORE the side effect
    send_confirmation_email(event["customer"])           # the side effect runs exactly once per event_id
    return {"status": "ok"}

Design any endpoint with side effects to be idempotent: dedupe on the sender's event id (or require an Idempotency-Key header on your own APIs, as Stripe does). This is the single most important reliability property for integrations — and the same discipline appears in the N8N pipelines you may already run.

Tip

Exercise 3 has you make a side-effecting handler idempotent with a dedup key and prove a replayed event is processed only once.

Sync REST vs. webhook — choosing

  • REST (synchronous) when the caller needs the answer now to continue — a user waiting on a classification, a chat response. The caller blocks on your response.
  • Webhook (asynchronous / event-driven) when something happened and you react without the source waiting — a new ticket, a nightly export, a status change. Decouples the systems.

Many real integrations use both: a webhook notifies you of an event, you ack fast, process async, and maybe call back via REST (or another webhook) with the result. Matching the pattern to whether the caller is waiting is the core design decision.

Tip

Exercise 4 has you choose REST vs. webhook for three integration scenarios and justify each by whether the caller waits.

Pitfalls

  • Unauthenticated AI endpoints. A public, keyless endpoint burns your tokens (and budget) for anyone who finds it — always auth.
  • Wrong status codes. Returning 200 on failure (or 500 on a caller's bad input) breaks callers' retry logic — return the right code.
  • Trusting unverified webhooks. Public URLs get spoofed — verify signatures, and treat content as untrusted AI input (injection).
  • Slow inline webhook handlers. Doing the LLM call before acking causes sender timeouts and retries — ack fast, work async.
  • Non-idempotent side effects. Retries then double-charge/double-send — dedupe on an idempotency key.

Recap

  • AI value comes from integration; the two primitives are REST (synchronous request/response) and webhooks (async, a system notifying you of events).
  • Get REST right: correct verbs and status codes, and never expose an unauthenticated AI endpoint.
  • Verify webhook signatures (HMAC) before trusting a payload — and treat the content as untrusted AI input.
  • Ack webhooks fast, process async, and make side-effecting handlers idempotent (dedupe on an event/ idempotency key) so retries are harmless.
  • Choose REST when the caller waits, webhooks when reacting to events; real integrations often combine both.
  • Worked examples map to the exercises: an authed REST endpoint (Ex 1), signature verification (Ex 2), idempotency (Ex 3), REST-vs-webhook choice (Ex 4).

REST & Webhooks — Exercises

Use FastAPI (Track 1). If you've wired a Stripe or Zapier webhook before, these formalize what you did.


Exercise 1 — An authenticated AI endpoint

Build a POST /classify endpoint that requires a bearer token, validates input, and returns the correct status codes for success, missing auth, and bad input.

Reference solution
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
EXPECTED = "secret-token"

@app.post("/classify")
def classify(payload: dict, authorization: str = Header(None)):
    if authorization != f"Bearer {EXPECTED}":
        raise HTTPException(401, "unauthorized")     # 401 for missing/wrong auth — reject before spending a token
    if "text" not in payload:
        raise HTTPException(400, "missing 'text'")   # 400 = caller's mistake (don't retry)
    return {"category": my_classifier(payload["text"])}  # 200 with result

Test all three paths: correct token + text → 200; no/wrong token → 401; token but no text → 400. Right status codes matter because callers react to them — a 429 should be retried, a 400 should not. And an unauthenticated AI endpoint would let anyone burn your token budget.


Exercise 2 — Verify a webhook signature

Receive a webhook and verify its HMAC-SHA256 signature against a shared secret. Reject a request with a bad signature.

Reference solution
import hmac, hashlib, json
from fastapi import Request, HTTPException
SECRET = b"webhook-secret"

def verify(body: bytes, sig: str) -> bool:
    expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)   # constant-time compare

@app.post("/webhooks/ticket")
async def on_ticket(request: Request):
    body = await request.body()                 # RAW bytes — verify before parsing
    if not verify(body, request.headers.get("X-Signature", "")):
        raise HTTPException(401, "bad signature")
    event = json.loads(body)
    return {"received": True}

Verify against the raw body (parsing first can change bytes and break the HMAC). A public webhook URL can be POSTed to by anyone; the signature proves the sender holds the shared secret. Also treat the content as untrusted AI input — it can carry indirect prompt injection (Track 6).


Exercise 3 — Make a handler idempotent

Add idempotency to a side-effecting handler (e.g. sending a confirmation email) using the sender's event id. Prove that replaying the same event sends only one email.

Reference solution
processed = set()    # a DB table in production

@app.post("/webhooks/payment")
def on_payment(event: dict):
    eid = event["id"]
    if eid in processed:
        return {"status": "duplicate, ignored"}   # retry / double-delivery -> no-op
    processed.add(eid)                             # record BEFORE the side effect
    send_confirmation_email(event["customer"])     # runs exactly once per event id
    return {"status": "ok"}

# Replay proof:
on_payment({"id": "evt_1", "customer": "a@b.com"})  # sends email
on_payment({"id": "evt_1", "customer": "a@b.com"})  # ignored — no second email

Networks retry, so the same event will arrive more than once. Deduping on the sender's event id makes processing-twice equivalent to processing-once — no double charges or double sends. This is the single most important reliability property for integrations (and the same discipline as robust N8N pipelines).


Exercise 4 — REST or webhook?

For each, choose REST (synchronous) or webhook (event-driven) and justify by whether the caller waits: (a) a user clicks 'summarize' and waits for the summary; (b) classify and route every new support ticket as it arrives; (c) a nightly notification when a data export finishes.

Reference solution
  • (a) User waits for a summaryREST. The caller needs the answer now to continue; a synchronous request/response fits — they block on your reply.
  • (b) Classify each new ticket on arrivalwebhook. An event happened; the helpdesk POSTs you the new ticket and you react without it waiting. Ack fast, classify async, route.
  • (c) Nightly export-finished notificationwebhook. A status change you react to asynchronously — no caller is waiting on a response.

The deciding question is is the caller waiting on the result? Yes → REST; no, it's reacting to an event → webhook. Real integrations often chain both: a webhook notifies you, you process async, then call back via REST.