How to Add Webhooks to an AI-Built App

Ask AI for a webhook endpoint and you get twelve working lines that will fail in production four ways. Here are the four, and what to ask for instead.

Steve Jefferson
Steve Jefferson
Developer Advocate
17 August 20261 min read

The fastest way to add webhooks to an AI-built app is to ask for an endpoint and ship what comes back. It will work on the first request and fail in production for four specific reasons. It will accept any POST without checking who sent it, process the same event twice when the sender retries, do slow work inside the request and time out, and have no way to recover the events it dropped while your database was down.

None of that is a knock on the generated code. It is a correct answer to the question that was asked. Here is the question to ask instead, and the four things to add.

What webhooks are, in one paragraph

A webhook is another service calling your app to tell you something happened. Your payment provider calls you when a charge succeeds. Your email service calls you when a message bounces. The important consequence: this is an inbound HTTP endpoint on the public internet that triggers real business logic, which makes it the most security-sensitive route in most small apps and the one that gets the least attention.

The four things generated code leaves out

1. Signature verification

Without it, anyone who learns your URL can post a fake "payment succeeded" event and get whatever that event unlocks. Providers sign every request with a shared secret so you can prove the payload came from them and was not modified.

Two details that generated code tends to get wrong even when it does verify. You must compute the signature over the raw request body, before any JSON parsing, because re-serialising changes the bytes. And you must use a constant-time comparison rather than string equality.

js
// Verify first, parse second. Never the other way around.
const raw = await req.text();                    // raw body, not req.json()
const sig = req.headers.get('webhook-signature');

const expected = crypto
  .createHmac('sha256', process.env.WEBHOOK_SECRET)
  .update(raw)
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
  return new Response('invalid signature', { status: 401 });
}

const event = JSON.parse(raw);                   // now it is safe to parse

Most providers also include a timestamp in the signed payload so you can reject replays of old but validly signed requests. Stripe's webhook documentation covers that pattern in detail, and it is the clearest reference even if you are integrating with someone else.

2. Idempotency

Senders retry. They retry when your server is slow, when a deploy restarts your process mid-request, and sometimes when the network dropped their acknowledgement even though you handled the event fine. Assume every event will arrive at least twice, and occasionally out of order.

The fix is a unique constraint, not a check-then-write. Store the provider's event id in a table with a unique index and let the database reject the duplicate.

sql
create table webhook_events (
  id text primary key,          -- the provider's event id
  type text not null,
  received_at timestamptz default now(),
  processed_at timestamptz
);

Insert first. If the insert conflicts, you have already seen this event, so return 200 and stop. A check-then-write has a race between the check and the write, which is exactly the window two simultaneous retries land in.

3. Acknowledge fast, work later

Most providers time out in five to thirty seconds and treat a slow response as a failure worth retrying. If your handler sends an email, generates a PDF, and calls two other APIs before responding, you will get retried while the first attempt is still running, and now the work happens twice concurrently.

Split it. The endpoint verifies, records, and returns 200. A background worker does the actual work.

js
await db.insert('webhook_events', { id: event.id, type: event.type });
await queue.publish('webhook.process', { id: event.id });
return new Response('ok', { status: 200 });      // under 100ms

That queue is the same piece of infrastructure covered in how to add background jobs to an AI-built app, and if you have already built it for something else, this is a two-line change rather than a new system.

4. A replay path

Things break. Your database has an outage, your worker crashes on a malformed event, a bug means you processed forty events wrongly. You need to be able to answer "which events did we miss, and can we run them again".

This is why you store the raw payload alongside the event id, and why `processed_at` is a separate nullable column rather than a boolean you flip. Anything with a null `processed_at` and a `received_at` older than a few minutes is stuck, which is both your alert and your replay queue.

What to ask the AI builder for

Prompt with the requirements rather than the feature. The difference in output is large:

text
Add a webhook endpoint at POST /webhooks/payments.

Requirements:
- Verify the HMAC-SHA256 signature over the raw body before parsing,
  using constant-time comparison, secret from PAYMENTS_WEBHOOK_SECRET.
- Reject requests with a timestamp older than 5 minutes.
- Insert the event id into webhook_events with a unique constraint;
  on conflict return 200 immediately without reprocessing.
- Store the full raw payload. Return 200 in under 100ms.
- Enqueue processing as a background job; do no business logic in the handler.
- Log rejected signatures with the source IP, do not log the payload.

That prompt produces a handler you can actually ship. The general principle behind it, being specific about non-functional requirements instead of only the happy path, is what most of how to build an app with AI is about.

Testing it before you trust it

Four tests, all quick, and they catch nearly everything:

  1. Send an unsigned request. Expect 401. If you get 200, stop and fix it now.

  2. Send the same valid event twice. Expect one side effect, two 200s.

  3. Send a valid signature with a body that has been modified by one character. Expect 401. This catches the parse-then-verify bug specifically.

  4. Point it at a deliberately broken worker. The endpoint should still return 200 and the event should sit unprocessed, not vanish.

Most providers give you a test-mode sender and a log of delivery attempts, which is the fastest way to see your own retry behaviour without simulating failure yourself. When something does go wrong in production, the webhook log on the provider side and your own `webhook_events` table together tell you whether you were never called or whether you were called and dropped it. That distinction is most of the debugging, and it fits the wider approach in what to do when your AI-built app breaks in production.

If your webhooks are payment-related specifically, the delivery guarantees interact with how you record money, which is covered in how to add payments to an AI-built app.

Frequently asked questions

Do I need a queue for a low-volume app?

If your handler finishes in well under a second and touches only your own database, you can process inline and add the queue later. Keep the event table and the unique constraint regardless. Those are the parts that are painful to retrofit after you have already double-charged someone.

What status code should I return on an error?

Return a 500 only when you genuinely want a retry. Return 200 for events you understand and have decided to ignore, because a 400 on an event type you do not handle will make some providers retry it for days and eventually disable your endpoint.

Should the webhook URL be secret?

Treat it as public. Obscurity is not a control, and URLs leak through logs, proxies, and screenshots. The signature is the control.

How long should I keep webhook events?

Ninety days covers nearly every replay and dispute investigation. Keep the id and timestamp longer if storage is cheap, since a tiny table of ids is what protects you from reprocessing an old event.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.