Webhooks (inbound)
Every inbound webhook — lead sources, WhatsApp/SMS messaging, and commercial billing — with verification, idempotency and failure behavior.
Webhooks (inbound)
Three families of external systems push data into the platform: lead sources (Meta lead ads and friends), messaging channels (WhatsApp/SMS/…), and payment providers (Razorpay/Cashfree). All are tokenized or signature-verified, CSRF-exempt, and designed to fail safely — a webhook must never create duplicates, and an invalid caller must learn nothing.
Who this is for
Developers debugging "leads stopped arriving" / "delivery statuses missing", or integrating a new source.
Difficulty: Advanced · Time: about 15 minutes.
Route inventory
| Webhook | Method(s) | Route | Handler |
|---|---|---|---|
| Generic lead source | GET (verification) + POST |
/webhook/{tenant}/{source}/{token} |
App\Http\Controllers\InboundWebhookController |
| Messaging (WhatsApp/SMS/Telegram/Viber) | GET (verification) + POST |
/api/messaging/{channel}/webhook/{tenant}/{token} |
App\Http\Controllers\Api\MessagingWebhookController |
| Commercial billing — Razorpay | POST |
/api/webhooks/razorpay |
App\Http\Controllers\Api\CommercialBillingWebhookController |
| Commercial billing — Cashfree | POST |
/api/webhooks/cashfree |
idem |
| Legacy gateway billing | POST |
/billing/webhook/{gateway} |
App\Http\Controllers\BillingController |
CSRF exemptions are registered in bootstrap/app.php for webhook/*, billing/webhook/*, billing/commercial/* and the embeddable form widget (widget/*/submit) — these callers cannot carry a session token.
Lead-source webhooks
{tenant} is the workspace slug; {token} is the per-connection secret on lead_source_connections.webhook_token (unique column; generated as a random secret when the connection is created).
- Verification (GET) — once the connection resolves, a challenge handshake (Meta, or Twitter's CRC) gets
hub_challengeechoed back; malformed verification requests just getOK. - Delivery (POST) — the connector's handler parses and verifies the payload:
- Meta:
X-Hub-Signature-256HMAC — validated with the connection's stored credential, falling back to the platform-wide secret fromMetaSettings::appSecret()(Super Admin → Meta Developer settings). - A
webhook_logsrow is created with statuspending, thenApp\Jobs\ProcessInboundWebhookis queued and updates it (including a human-readable error when a Graph fetch fails, so the log is retryable from the UI). - Rejected deliveries (inactive connection, unregistered source, signature mismatch) are still recorded in
webhook_logswithrejected/invalid_signaturestatus and acknowledged with a 200 — operators can see them, while attackers probing tokens learn nothing (unknown tenant/source/token gets a silent 200 and only a redacted log line).
- Meta:
- Ingest — the queue job routes through
ConnectorRegistryto the right connector (MetaConnectorfor Meta). Meta connections honor the form allowlist: only forms the tenant selected ingest leads — other forms are acknowledged and ignored, never imported. - Lead creation —
leadsrow withsource,source_id,source_connection_id,form_id,raw_data, deduplication checks andunique(tenant_id, source, source_id)as the last line of defense against duplicate deliveries. - Failure visibility —
lead_source_connections.last_received_at/last_errortrack webhook health, andleadhub:meta-health(every 6 h) actively pings Meta tokens so "connected but expired" surfaces before leads silently stop.
Re-sent webhooks are normal Meta retries on non-2xx responses and can deliver the same event twice. Ingest is expected to be idempotent (unique key + dedupe checks); do not build connectors that assume exactly-once delivery.
Messaging webhooks (WhatsApp)
sequenceDiagram
participant M as Meta (WhatsApp Cloud API)
participant C as MessagingWebhookController
participant S as WhatsApp service layer
participant DB as conversations / message tables
M->>C: POST /api/messaging/whatsapp/webhook/{tenant}/{token}
C->>C: resolve connection by phone_number_id
C->>C: verify X-Hub-Signature-256 (when app_secret configured)
C->>S: handle message / status payload
S->>DB: store inbound message OR update delivery/read status
DB->>S: conversation + automation hooks (STOP keyword, template tracking)
C-->>M: 200 (always — safe acknowledgment)
Behavioral rules enforced by the controller:
- Token mismatch or unknown connection → HTTP 200 with
{"ok":true}and nothing else. The endpoint never reveals whether a tenant/token exists (anti-enumeration). - Signature verified whenever the connection has an
app_secretconfigured; a configured secret with a bad signature is rejected. - WhatsApp connections are resolved by the payload's
phone_number_id(whatsapp_connections.phone_number_idis unique), so multiple numbers per install route correctly. GETon the same path serves the challenge/verification flow; valid events touchlast_webhook_athealth fields on the connection.
Commercial billing webhooks
Both providers follow the same contract in CommercialBillingWebhookController:
- Raw body read first — verification runs against the exact bytes received (Razorpay's HMAC and Cashfree's signature depend on it), before any parsing.
- Signature verification via the provider driver (
app/Billing/Commercial/*→verifyWebhook). Invalid signature → HTTP 400, nothing stored. - Idempotency — the event is inserted into
billing_eventswithunique(provider, event_id)plus a SHA-256 hash of the payload. A retry of the same event is a no-op, not a second charge/state change. - Canonical events mapped and actioned:
subscription.authorized / charged / cancelled / halted,payment.captured / failed,mandate.revoked / expired. - Unknown event types are stored with
processed = trueandprocessing_error = 'unmapped'— visible, harmless, and never silently dropped. - Provider state changes are reconciled again by the scheduled
ReconcileProviderStatusjob (every 30 min), so a lost webhook self-heals.
Failure handling summary
| Failure | Behavior |
|---|---|
| Invalid billing signature | 400, event not stored |
| Duplicate billing event | Stored once; retry is a no-op |
| Messaging token mismatch | 200 {"ok":true} (indistinguishable from success) |
| Meta signature mismatch | Rejected; entry in webhook_logs context |
| Meta token expired | Surfaced by leadhub:meta-health + last_error |
| Handler exception | Queued job retries; failure logged; connection health fields updated |
Related guides
Was this page helpful?
Thanks — noted. ✦