Webhooks Overview
Catalog of all inbound webhooks Eziseller accepts from third parties (Meta, Razorpay, Shiprocket) and how we verify, dispatch, and acknowledge them. Audience: new dev with Node/Express experience.
1. Overview
Eziseller receives push notifications from external providers for events that can't be polled cheaply: incoming WhatsApp/Instagram messages, Meta template/verification status changes, and Razorpay payment captures. All webhooks land on the Express backend, are signature-verified (HMAC SHA-256 with a provider-specific secret), dispatched to a handler that writes to Postgres via Prisma, and acknowledged with HTTP 200. Most inbound traffic by volume is Meta (WhatsApp + Instagram); Razorpay is low-volume but payment-critical. Shiprocket is poll-based today — we have no inbound Shiprocket webhook.
2. Architecture
Express mounts /api/* behind CORS + maintenance middleware; /webhooks/meta is mounted outside /api on purpose — Meta's webhook console does not tolerate arbitrary prefixes and must not be blocked by the maintenance middleware.
3. Webhook endpoints
| Path | Source | Method | Verification | Purpose |
|---|---|---|---|---|
GET /webhooks/meta | Meta | GET | hub.verify_token vs WHATSAPP_VERIFY_TOKEN / INSTAGRAM_VERIFY_TOKEN (plus per-user DB tokens) | Subscription handshake — echo hub.challenge |
POST /webhooks/meta | Meta (WhatsApp + Instagram) | POST | HMAC SHA-256 via X-Hub-Signature-256, secret chosen by body.object (WHATSAPP_APP_SECRET or INSTAGRAM_APP_SECRET) | Messages, statuses, template updates, business verification |
POST /api/whatsapp-business-webhook | Legacy Meta | POST | HMAC SHA-256 with WHATSAPP_APP_SECRET | Deprecated — superseded by /webhooks/meta. Kept for old tenant configs. |
GET /api/whatsapp-business-webhook | Legacy Meta | GET | Same token flow as above | Deprecated handshake |
POST /api/instagram-dm/webhook | Meta (Instagram DM) | POST | HMAC SHA-256 with INSTAGRAM_APP_SECRET | Narrow Instagram DM path (separate service) |
GET /api/instagram-dm/webhook | Meta | GET | INSTAGRAM_VERIFY_TOKEN | Handshake |
POST /api/webhooks/whatsapp | Legacy | POST | WhatsAppWebhookService.verifySignature | Legacy unified route (pre-multi-tenant) |
POST /api/webhooks/instagram | Legacy | POST | InstagramWebhookService.verifySignature | Legacy unified route |
POST /api/public/catalog/webhooks/razorpay | Razorpay | POST | Currently none (see gotchas) — only handles event === 'payment.captured' | Fallback for payments where browser closed before /checkout finished |
POST /api/test-webhook/*, POST /api/webhook-simulator/* | Dev tools | POST | None | Local simulation — not for production traffic |
Shiprocket has no inbound webhook — tracking is pulled via GET /courier/track/awb/:awb (see shipping.ts:L755).
4. Key flows
4.1 Meta webhook (canonical path)
4.2 Razorpay payment.captured fallback
The normal happy path creates the order synchronously in POST /api/public/catalog/checkout; the webhook is only for browser-abandonment recovery.
5. Signature verification pattern
All Meta endpoints follow the same shape (meta-webhooks.ts:L205-L256):
- Read
X-Hub-Signature-256header (formatsha256=<hex>). - Pick app secret based on
body.object(whatsapp_business_account→WHATSAPP_APP_SECRET;instagram/page→INSTAGRAM_APP_SECRET). - Compute
HMAC-SHA256(secret, payload). - Compare with
crypto.timingSafeEqual. - On
NODE_ENV=development, verification is skipped entirely (returns true).
Razorpay's handler does not currently verify the X-Razorpay-Signature header against RAZORPAY_WEBHOOK_SECRET — see gotchas.
6. Key files
- backend/server.ts:L93-L130 — webhook route mounting; note
/webhooks/metais the only route outside/api - backend/routes/meta-webhooks.ts:L39-L135 — canonical Meta GET + POST
- backend/routes/meta-webhooks.ts:L205-L256 — HMAC verification
- backend/routes/whatsapp-business-webhook.ts:L1-L5 — deprecated, kept for legacy tenants
- backend/routes/instagram-dm-webhook.ts:L29-L70 — narrower Instagram DM service
- backend/routes/webhooks.ts:L11-L60 — legacy
/api/webhooks/whatsappand/api/webhooks/instagram - backend/routes/public-catalog.ts:L720-L798 — Razorpay payment.captured handler
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
WHATSAPP_APP_SECRET | prod | HMAC secret for whatsapp_business_account payloads | Every WhatsApp webhook returns 401 |
INSTAGRAM_APP_SECRET | prod | HMAC secret for instagram / page payloads | Every Instagram webhook returns 401 |
WHATSAPP_VERIFY_TOKEN | prod | Subscription handshake for WhatsApp | Meta can't (re)subscribe the endpoint |
INSTAGRAM_VERIFY_TOKEN | prod | Subscription handshake for Instagram | Same, for Instagram |
RAZORPAY_WEBHOOK_SECRET | prod | Should gate /webhooks/razorpay — currently not enforced | Anyone who knows the URL can POST fake payment events |
NODE_ENV | — | development disables Meta signature verification | Do not set in prod |
FRONTEND_URL | prod | CORS origin for /api/* (not for /webhooks/meta) | — |
8. Gotchas & troubleshooting
- Signature verified against re-stringified body, not raw body →
verifyWebhookSignaturepassesJSON.stringify(req.body)throughexpress.json()output (meta-webhooks.ts:L100-L104). This works only while Node'sJSON.stringifyhappens to produce byte-identical output to Meta's. Any whitespace/key-order difference silently fails verification. Fix: capture raw body viaexpress.raw({ type: 'application/json' })on this route and HMAC the Buffer directly. Signature verify is skipped in dev so the bug is invisible locally. /webhooks/metamust stay outside/api→ The maintenance-mode middleware (app.use('/api', ...)in server.ts:L82) would return 503 to Meta, which treats 5xx as retryable and eventually disables the subscription.- Handlers swallow errors and return 200 →
meta-webhooks.tscatches its own errors and returns200 {success:false}(meta-webhooks.ts:L131-L134). Meta will not retry, so a DB outage during a webhook permanently loses that message. Check backend logs, not Meta's delivery dashboard. - No dedupe → Meta may redeliver the same
entry.id/ messageid; we only dedupe implicitly viaConversationService.storeMessageonexternalId. Other handlers (template status, business verification) useupdateManywhich is idempotent but will repeat side effects (notifications) on redelivery. - Dev signature skip →
NODE_ENV=developmentbypasses HMAC. Do not run staging withNODE_ENV=development. - Legacy routes still mounted →
/api/whatsapp-business-webhook,/api/webhooks/whatsapp,/api/webhooks/instagram. All three are superseded by/webhooks/meta. Don't add new logic to them. - Razorpay signature not checked → The handler reads
req.body.eventdirectly. In production, add HMAC check againstRAZORPAY_WEBHOOK_SECRETbefore trusting the payload. Low risk only because the webhook just flags a notification, doesn't create orders. - Raw body for Razorpay too → If you add the signature check, you need
express.raw()for that single route; adding it globally will break every other/api/*route that expects parsed JSON.
9. Extension points
- Adding a new Meta event type: extend the
change.fieldswitch inprocessWhatsAppWebhook/processInstagramWebhook(meta-webhooks.ts:L302-L310). - Adding a new provider: create
backend/routes/<provider>-webhook.ts, mount it outside/apiif the provider requires a fixed path, and implement raw-body HMAC verification following the Meta pattern — do not copy the currentJSON.stringify(body)approach. - Moving to queue-based handling: today handlers run inline before the 200 is sent. For heavier flows, push
{body, headers}onto a queue after signature verify and 200 immediately; retain verification on the sync path because once we 200 we lose provider retries. - Proper dedupe: add a
WebhookEventtable keyed by(provider, externalId)and short-circuit duplicates before dispatching handlers.
10. Related docs
- messaging/whatsapp.md — WhatsApp message flow, templates, opt-in
- messaging/instagram.md — Instagram DM + shopping flow
- messaging/messaging-overview.md — cross-channel messaging model
- shipping/shipping-overview.md — Shiprocket (poll-based, no webhook)
- ../../01-core/billing/razorpay-integration.md — subscription-side Razorpay (separate from catalog checkout webhook)
- ../../01-core/catalog/checkout.md — the synchronous checkout that the Razorpay webhook backstops