DEV Community

Webappers
Webappers

Posted on

Your AI wrote that webhook handler from the docs. The docs are abridged.

There's a specific bug I've hit enough times to recognise on sight now, and I haven't seen it written up. It comes from a habit that is otherwise a good one: asking an AI assistant to write your webhook handler.

The handler it produces looks right. It passes review. It matches the provider's documentation exactly. And then the first real event hits it in production and it throws on a field that isn't there.

Why this happens specifically with webhooks
An AI assistant writes your handler from what it learned: the provider's documentation, blog posts, and Stack Overflow answers. For most code that's fine — the docs for a REST client describe the REST client accurately.

Webhook documentation is different, because documentation payloads are abridged on purpose. A real Stripe payment_intent.succeeded event is several hundred lines. Nobody would put that in a getting-started guide, so the docs show a trimmed version with the interesting fields and an ellipsis. That trimmed version is entirely honest as documentation and entirely misleading as a schema.

So your assistant writes a handler for the illustration, not for the payload. And it has never seen your actual account's events, because nobody publishes those — they're full of real customer data.

The result is a handler that is confidently wrong in a small number of predictable ways.

The four shapes this takes

  1. Nesting that the example flattened Clerk's user.created is the one that gets everybody. There is no email on the user object. There's an array of email addresses, plus a pointer to which one is primary:
// What an assistant tends to write
const email = evt.data.email;                    // undefined

// What Clerk actually sends
const { email_addresses, primary_email_address_id } = evt.data;
const email = email_addresses.find(
  e => e.id === primary_email_address_id
)?.email_address;

Enter fullscreen mode Exit fullscreen mode

The first version doesn't throw. It writes undefined into your database and you find out days later.

  1. Absent versus null Docs examples usually show every field populated, because a field with a value is more instructive than one without. So the assistant writes:
if (event.data.object.metadata.orderId) { ... }
Enter fullscreen mode Exit fullscreen mode

metadata is {} on most real events, which is fine. But on some providers optional objects are omitted entirely rather than sent empty — and then metadata is undefined and this line throws Cannot read properties of undefined. Optional chaining costs nothing and is the whole fix:

if (event.data.object.metadata?.orderId) { ... }
Enter fullscreen mode Exit fullscreen mode
  1. Expandable fields that arrive as strings This one is Stripe-specific and genuinely nasty. Several fields are either an ID string or a full object depending on how the event was created and what was expanded. The docs example shows the expanded object. Reality often sends the string:

// Works in the docs example, throws on half your real events
const customerEmail = event.data.object.customer.email;

// Actually safe

const customer = event.data.object.customer;
const customerId = typeof customer === 'string' ? customer : customer?.id;
Enter fullscreen mode Exit fullscreen mode
  1. The envelope, not the row Supabase database webhooks don't send you the row. They send an envelope describing what happened to it:
{
  "type": "INSERT",
  "table": "orders",
  "schema": "public",
  "record": { "id": 1, "status": "paid" },
  "old_record": null
}
Enter fullscreen mode Exit fullscreen mode

Assistants routinely write const { id, status } = payload and get nothing, because the row is under record. On UPDATE you also need old_record to know what changed, and on DELETE the row is in old_record and record is null.

Why testing doesn't catch it
Because the test fixture has the same ancestry as the bug. You ask for tests, and the assistant writes a fixture — from the same documentation example that produced the handler. The handler passes against a payload shaped exactly like the one it was written for. Both are wrong in the same direction, so they agree.

Provider test tools help less than you'd hope, too. stripe trigger payment_intent.succeeded sends a synthetic event, and synthetic events are also idealised. They're generated, not drawn from your account. The mismatch you care about is between the docs and your real traffic.

The fix is boring and it works
Stop writing handlers against documentation. Write them against one real payload.

Capture one real event. Add a second webhook endpoint in your provider's dashboard pointing at any URL that records raw requests. Providers fan out to every configured endpoint, so this runs alongside your real handler and changes nothing.
Trigger it for real. Not the test button — make an actual test-mode purchase, sign up an actual user. You want your account's real output.
Diff it against what your handler assumes. This takes about two minutes and is where you find all four bugs above at once.
Save that payload as your test fixture. Now your tests are anchored to reality rather than to the same illustration that produced the bug.
Replay it while you fix. Being able to fire the identical request at your handler repeatedly is the difference between a ten-minute fix and an afternoon of making test purchases.
Step 5 is the one people skip, and it's the one that hurts. Without replay, each iteration means generating a fresh real event, which for something like a subscription renewal can be genuinely difficult to produce on demand.

Tools for the capture step
webhook.site is free, needs no signup, and is perfect for steps 1–3. Open it, copy the URL, done. For a one-off diff it's all you need and I'd start there.

For step 5 you want something that stores events and re-fires them at your own endpoint. Hookdeck does this and has a free tier. I built Eventfy for the same loop, aimed specifically at people shipping AI-written code — it's a paid product with a trial, so weigh it accordingly.

The tool matters much less than the habit, though. One real payload, captured before you write the handler, prevents every bug in this post.

If you've hit a fifth shape of this I haven't listed, I'd genuinely like to hear it — I suspect there are more.

Top comments (0)