Integrations
Webhooks
Webhooks Overview

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

PathSourceMethodVerificationPurpose
GET /webhooks/metaMetaGEThub.verify_token vs WHATSAPP_VERIFY_TOKEN / INSTAGRAM_VERIFY_TOKEN (per-Meta-App env vars; not per-seller — see Workstream A5)Subscription handshake — echo hub.challenge
POST /webhooks/metaMeta (WhatsApp + Instagram)POSTHMAC 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-webhookLegacy MetaPOSTHMAC SHA-256 with WHATSAPP_APP_SECRETDeprecated — superseded by /webhooks/meta. Kept for old tenant configs.
GET /api/whatsapp-business-webhookLegacy MetaGETSame token flow as aboveDeprecated handshake
POST /api/instagram-dm/webhookMeta (Instagram DM)POSTHMAC SHA-256 with INSTAGRAM_APP_SECRETNarrow Instagram DM path (separate service)
GET /api/instagram-dm/webhookMetaGETINSTAGRAM_VERIFY_TOKENHandshake
POST /api/webhooks/whatsappLegacyPOSTWhatsAppWebhookService.verifySignatureLegacy unified route (pre-multi-tenant)
POST /api/webhooks/instagramLegacyPOSTInstagramWebhookService.verifySignatureLegacy unified route
POST /api/public/catalog/webhooks/razorpayRazorpayPOSTCurrently 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 toolsPOSTNoneLocal 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):

  1. Read X-Hub-Signature-256 header (format sha256=<hex>).
  2. Pick app secret based on body.object (whatsapp_business_accountWHATSAPP_APP_SECRET; instagram/pageINSTAGRAM_APP_SECRET).
  3. Compute HMAC-SHA256(secret, payload).
  4. Compare with crypto.timingSafeEqual.
  5. 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

7. Env vars & config

VarRequiredPurposeWhat breaks
WHATSAPP_APP_SECRETprodHMAC secret for whatsapp_business_account payloadsEvery WhatsApp webhook returns 401
INSTAGRAM_APP_SECRETprodHMAC secret for instagram / page payloadsEvery Instagram webhook returns 401
WHATSAPP_VERIFY_TOKENprodSubscription handshake for WhatsAppMeta can't (re)subscribe the endpoint
INSTAGRAM_VERIFY_TOKENprodSubscription handshake for InstagramSame, for Instagram
RAZORPAY_WEBHOOK_SECRETprodShould gate /webhooks/razorpay — currently not enforcedAnyone who knows the URL can POST fake payment events
NODE_ENVOnly test disables signature verification (Jest sets this automatically). Real local dev with npm run backend:dev requires valid HMAC.
ENCRYPTION_KEYprod32+ char random string. Derives AES-256-GCM key for stored credentials via backend/lib/encryption.ts. Never rotate without re-encrypting existing rows — salt is hardcoded.Cannot decrypt any stored credential; all outbound API calls fail
FRONTEND_URLprodCORS origin for /api/* (not for /webhooks/meta)

8. Gotchas & troubleshooting

  • Signature verified against re-stringified body, not raw bodyverifyWebhookSignature passes JSON.stringify(req.body) through express.json() output (meta-webhooks.ts:L100-L104). This works only while Node's JSON.stringify happens to produce byte-identical output to Meta's. Any whitespace/key-order difference silently fails verification. Fix: capture raw body via express.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/meta must 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 200meta-webhooks.ts catches its own errors and returns 200 {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.
  • Webhook idempotency is enforcedclaimWebhookEvent(source, externalId) from backend/lib/webhook-idempotency.ts is called before each WhatsApp message/status and Instagram messaging event in meta-webhooks.ts. Backed by the webhook_events table with a composite unique on (source, externalId). Duplicates from Meta retries are silently skipped. Cleanup cron for old rows is a follow-up. Workstream A4, commit 176ac3a.
  • Dev signature skip → Bypass branch only fires when NODE_ENV === 'test' (Jest only). Real local dev requires a valid HMAC signature — use ngrok with the real WHATSAPP_APP_SECRET. Workstream A1, commit b930489.
  • 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.event directly. In production, add HMAC check against RAZORPAY_WEBHOOK_SECRET before 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.field switch in processWhatsAppWebhook / processInstagramWebhook (meta-webhooks.ts:L302-L310).
  • Adding a new provider: create backend/routes/<provider>-webhook.ts, mount it outside /api if the provider requires a fixed path, and implement raw-body HMAC verification following the Meta pattern — do not copy the current JSON.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.
  • Extending dedupe to other providers: WebhookEvent already exists with (source, externalId) for Meta. Adding a new source (e.g. Razorpay) means extending the WebhookSource enum in schema.prisma and calling claimWebhookEvent before dispatching the handler.

10. Related docs