Why test webhooks with real payloads
Developing webhook integrations without reliable test payloads leads to production bugs. Most common mistake: assuming all events have the same shape. GitHub can send push events with no commits (forced), Stripe includes variable metadata depending on customer plan, and Slack changes structure by message type.
Using generated payloads prevents three critical issues: breaking deploys when the provider updates their API, not handling edge cases (partial refunds, unanswered calls), and testing only happy path. Payloads here include 550 errors, disputes, and cancelled events many devs forget.
Real example: a startup lost $12k because their Stripe webhook didn't handle payment_intent.payment_failed with card_declined error. They assumed all failed payments happened silently. Test payloads reveal these scenarios before deploying.
Typical payload structure by provider
GitHub structures events with action (opened, closed), repository, and the affected object (issue, PR, release). Always includes sender. Stripe uses type (dot notation) and nests data in object. Pattern: resource.event (customer.created, invoice.paid).
Slack and Discord use numeric or string type. Slack includes nested event for app mentions, Discord differentiates interactions (type 2 = slash command, type 3 = button). Shopify uses topic with slash notation: orders/create, products/update.
Twilio is REST-style: all fields top-level (MessageSid, CallStatus, From). Mailgun uses simple event (delivered, bounced) with metadata in message. Knowing these patterns speeds debugging: if you see type string + data.object, it's Stripe; if you see MessageSid, it's Twilio.
Edge cases that break integrations
Test payloads must include: empty arrays (GitHub push with no commits), null values (Stripe customer without email), missing fields (Slack messages without text when it's file_share). Also: duplicate events (Shopify resends if no 200 in 5s), and giant payloads (Zoom 3-hour recordings).
Common errors: not validating refunded: true in Stripe charges (amount can be partial), assuming Discord interactions always have member (DMs don't include it), and not checking attempt_count in invoices (Stripe retries up to 4 times).
A Twilio payload with NumMedia > 0 requires parsing MediaUrl0, MediaUrl1... dynamically. GitHub pull_request can have mergeable: null (calculating), not just true/false. Mailgun permanent vs temporary bounces change retry logic.
Effective local testing strategy
Use tools like ngrok or webhook.site to receive requests locally. Set up a /webhooks/:provider route that logs the complete payload. Important: verify signatures (GitHub uses HMAC-SHA256, Stripe has its own lib).
Recommended structure: one handler per provider with schema validation (Zod, Joi). Log unknown payloads to Sentry to detect API changes. Implement idempotency: save event.id or MessageSid in Redis/DB to ignore duplicates.
For CI/CD: commit sample payloads to tests/fixtures/webhooks/ and write tests that run them through your handler. Mock signatures with test secret. This prevents regressions when refactoring webhook code. Most webhook bugs are validations that fail silently.