Zodiac Tech CRM Docs

Webhooks (inbound)

Every inbound webhook — lead sources, WhatsApp/SMS messaging, and commercial billing — with verification, idempotency and failure behavior.

developer Advanced ⏱ 15 min Updated 2026-10-05

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).

  1. Verification (GET) — once the connection resolves, a challenge handshake (Meta, or Twitter's CRC) gets hub_challenge echoed back; malformed verification requests just get OK.
  2. Delivery (POST) — the connector's handler parses and verifies the payload:
    • Meta: X-Hub-Signature-256 HMAC — validated with the connection's stored credential, falling back to the platform-wide secret from MetaSettings::appSecret() (Super Admin → Meta Developer settings).
    • A webhook_logs row is created with status pending, then App\Jobs\ProcessInboundWebhook is 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_logs with rejected / invalid_signature status 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).
  3. Ingest — the queue job routes through ConnectorRegistry to the right connector (MetaConnector for Meta). Meta connections honor the form allowlist: only forms the tenant selected ingest leads — other forms are acknowledged and ignored, never imported.
  4. Lead creation — leads row with source, source_id, source_connection_id, form_id, raw_data, deduplication checks and unique(tenant_id, source, source_id) as the last line of defense against duplicate deliveries.
  5. Failure visibility — lead_source_connections.last_received_at / last_error track webhook health, and leadhub: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_secret configured; a configured secret with a bad signature is rejected.
  • WhatsApp connections are resolved by the payload's phone_number_id (whatsapp_connections.phone_number_id is unique), so multiple numbers per install route correctly.
  • GET on the same path serves the challenge/verification flow; valid events touch last_webhook_at health fields on the connection.

Commercial billing webhooks

Both providers follow the same contract in CommercialBillingWebhookController:

  1. Raw body read first — verification runs against the exact bytes received (Razorpay's HMAC and Cashfree's signature depend on it), before any parsing.
  2. Signature verification via the provider driver (app/Billing/Commercial/* → verifyWebhook). Invalid signature → HTTP 400, nothing stored.
  3. Idempotency — the event is inserted into billing_events with unique(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.
  4. Canonical events mapped and actioned: subscription.authorized / charged / cancelled / halted, payment.captured / failed, mandate.revoked / expired.
  5. Unknown event types are stored with processed = true and processing_error = 'unmapped' — visible, harmless, and never silently dropped.
  6. Provider state changes are reconciled again by the scheduled ReconcileProviderStatus job (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

Was this page helpful?