Webhooks sit at a tricky intersection: the boilerplateWhat is boilerplate?Repetitive, standardized code that follows a known pattern and appears in nearly every project - like setting up a server or wiring up database connections. is tedious and repetitive, but the edge cases are subtle and dangerous. That makes them a perfect candidate for AI-assisted development, as long as you know which parts to trust and which to verify by hand.
What AI does well vs. poorly
| AI does well | AI does poorly |
|---|---|
| Generating route handler boilerplate | Idempotency logic (race conditions, dedup windows) |
| Signature verification code for known providers | Error recovery flows (what happens when step 3 of 5 fails?) |
| Event type switch/case structures | Distributed lock patterns (Redis SETNX, TTL choices) |
| Parsing and validating webhook payloads | Retry topology (exponential backoff tuning, dead letter queues) |
| Generating TypeScript types from webhook docs | Ordering guarantees (handling out-of-order events) |
The pattern is clear: AI handles the structural parts well but struggles with the behavioral parts, the things that only matter when something goes wrong.
Prompt templates
1. Generate a Stripe webhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. handler with signature verification
Prompt:
Generate an Express.js webhook handler for Stripe with:
- Signature verification using stripe.webhooks.constructEvent
- A switch statement handling: payment_intent.succeeded,
payment_intent.payment_failed, customer.subscription.deleted
- Raw body parsing (required for Stripe signature verification)
- TypeScript types for the request handlerWhat AI typically generates (and it is usually correct):
import express, { Request, Response } from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// Important: Stripe needs the raw body for signature verification
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req: Request, res: Response) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Signature verification failed:', err);
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentSuccess(paymentIntent);
break;
case 'payment_intent.payment_failed':
const failedPayment = event.data.object as Stripe.PaymentIntent;
await handlePaymentFailure(failedPayment);
break;
case 'customer.subscription.deleted':
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCancelled(subscription);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true });
}
);This is solid scaffoldingWhat is scaffolding?Auto-generating the basic file structure and starter code for a project or feature so you don't have to write it from scratch.. The signature verification is correct, the raw body parsing is there (a common gotcha), and the event routing is clean. But notice what is missing: idempotency checks, async processing, and error handling inside each case branch. That is your job.
2. Review webhook code for idempotency issues
Prompt:
Review this webhook handler for idempotency issues. List every
scenario where duplicate processing could cause data corruption
or inconsistency:
[paste your webhook code here]
For each issue found, explain the failure scenario step by step
and suggest a fix.AI is surprisingly good at this review task. It will typically catch:
- Missing deduplication checks on event IDs
- Non-idempotentWhat is idempotent?An operation that produces the same result whether you perform it once or multiple times, making retries safe. database operations (INSERT without ON CONFLICT)
- Side effects that cannot be safely repeated (sending emails, charging cards)
- Race conditions between duplicate requests arriving simultaneously
Where it falls short: it often suggests naive fixes like "use a Set to track processed events" (which does not survive a server restart) or forgets to account for the window between checking and marking an event as processed.
3. Design a retry strategy for failed webhook processing
Prompt:
Design a retry strategy for a webhook processing system with
these requirements:
- Webhook events are queued in a database table
- Failed events should retry with exponential backoff
- Max 5 retries over 24 hours
- After max retries, move to a dead letter queue for manual review
- Include the database schema and the worker process code
Use TypeScript and PostgreSQL.Typical AI output (simplified):
// Database schema AI generates
interface WebhookEvent {
id: string;
event_type: string;
payload: Record<string, unknown>;
status: 'pending' | 'processing' | 'completed' | 'failed' | 'dead_letter';
attempts: number;
next_retry_at: Date | null;
last_error: string | null;
created_at: Date;
}
// Retry delay calculation
function getNextRetryDelay(attempt: number): number {
// Exponential backoff: 1min, 5min, 25min, 2hr, 10hr
const baseDelay = 60_000;
return baseDelay * Math.pow(5, attempt);
}
// Worker that processes the queue
async function processWebhookQueue() {
const event = await db.query(`
UPDATE webhook_events
SET status = 'processing'
WHERE id = (
SELECT id FROM webhook_events
WHERE status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *
`);
if (!event) return;
try {
await handleWebhookEvent(event);
await db.query(
`UPDATE webhook_events SET status = 'completed' WHERE id = CODE_BLOCK`,
[event.id]
);
} catch (error) {
const nextAttempt = event.attempts + 1;
if (nextAttempt >= 5) {
await db.query(
`UPDATE webhook_events
SET status = 'dead_letter', last_error = $2, attempts = $3
WHERE id = CODE_BLOCK`,
[event.id, (error as Error).message, nextAttempt]
);
} else {
const retryAt = new Date(Date.now() + getNextRetryDelay(nextAttempt));
await db.query(
`UPDATE webhook_events
SET status = 'pending', attempts = $2,
next_retry_at = $3, last_error = $4
WHERE id = CODE_BLOCK`,
[event.id, nextAttempt, retryAt, (error as Error).message]
);
}
}
}This is a good starting point. The FOR UPDATE SKIP LOCKED pattern is correct for preventing duplicate processing. But you should verify: what happens if the worker crashes mid-processing? The event stays in processing status forever. You need a separate cleanup job that resets stale processing events, AI rarely adds that unprompted.
What to verify in AI-generated webhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. code
| Checklist item | What AI typically misses | Risk if not fixed |
|---|---|---|
| Idempotency check | Often absent or uses in-memory Set | Duplicate charges, double emails |
| Async processing | Processes before responding 200 | Timeout retries create more duplicates |
| Signature verification | Sometimes present but wrong raw body handling | Anyone can send fake events |
| Granular error handling | Single try/catch around everything | Partial failures leave inconsistent state |
| Stale processing cleanup | Never included | Worker crash leaves events stuck forever |
Every time AI generates webhook code for you, run through this checklist:
1. Missing idempotency
Does the handler check if this event was already processed? If not, duplicate webhooks will cause duplicate side effects (double charges, double emails, double inventory decrements).
2. Blocking processing
Does the handler do all its work before responding? If the processing takes more than a few seconds, the webhook sender will time out and retry, creating the very duplicates you are trying to avoid.
3. No signature verification
Is the handler verifying that the request actually came from the expected sender? Without this, anyone who discovers your webhook URL can send fake events.
4. Incomplete error handling
What happens when one step in a multi-step process fails? If you fulfill an order and then the email send fails, does the whole thing roll back? AI often generates a single try/catch around everything, which is rarely the right granularity.
The hybrid workflow
Here is the workflow that gets the best results when building webhooks with AI:
Step 1, AI generates the scaffold
Prompt AI for the route handlerWhat is route handler?A Next.js file named route.js inside the app/ directory that handles HTTP requests directly - the App Router equivalent of API routes., signature verification, and event type routing. This saves 20-30 minutes of boilerplateWhat is boilerplate?Repetitive, standardized code that follows a known pattern and appears in nearly every project - like setting up a server or wiring up database connections. typing and gets the providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually.-specific details right (raw body parsing, header names, verification methods).
Step 2, You add idempotency and error handling
This is where your engineering judgment matters. Add the deduplication check, decide on your idempotency storage (Redis vs. database), set the TTLWhat is ttl?Time-to-Live - a countdown attached to cached data that automatically expires it after a set number of seconds. window, and design the error handling granularity. These decisions depend on your specific system's failure modes.
Step 3, AI generates tests
This is where AI shines again. Prompt it with your final handler code and ask for test cases covering: duplicate events, invalid signatures, out-of-order events, partial processing failures, and concurrent delivery. AI is great at generating edge case tests because it can enumerate failure scenarios systematically.
// Example test AI might generate for your handler
describe('Stripe webhook handler', () => {
it('should reject requests with invalid signatures', async () => {
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', 'invalid_sig')
.send({ id: 'evt_123', type: 'payment_intent.succeeded' });
expect(res.status).toBe(400);
});
it('should process duplicate events idempotently', async () => {
const event = createTestEvent('payment_intent.succeeded');
// First call: processes normally
await request(app).post('/webhooks/stripe').send(event);
// Second call: same event, should not duplicate
await request(app).post('/webhooks/stripe').send(event);
const orders = await db.orders.findAll({
where: { paymentId: event.data.object.id }
});
expect(orders).toHaveLength(1); // Not 2!
});
it('should handle processing failures gracefully', async () => {
mockPaymentService.rejectNext();
const res = await request(app)
.post('/webhooks/stripe')
.send(createTestEvent('payment_intent.succeeded'));
expect(res.status).toBe(200); // Still acknowledge receipt
// Event should be queued for retry
const pending = await db.webhookEvents.findPending();
expect(pending).toHaveLength(1);
});
});The takeaway: let AI handle the repetitive structural code, keep the resilience logic in your own hands, then let AI help you test the whole thing. That combination gives you speed without sacrificing reliability.