Webhook endpoints
The inbound webhooks the CRM receives — Meta lead ads, WhatsApp Cloud API, Razorpay and Cashfree — with verification, signatures, idempotency and local testing.
These are the endpoints external platforms call into. Every one is token-scoped or signature-verified — except inbound lead intake, which authenticates with the workspace's tenant API key.
| Source | Method & path | Auth |
|---|---|---|
| Meta lead ads | POST /webhook/{tenant}/{source}/{token} and GET for verification |
URL token + X-Hub-Signature-256 (when an app secret is configured) |
| WhatsApp Cloud API | POST / GET /api/messaging/whatsapp/webhook/{tenant}/{token} |
URL token, verify token, X-Hub-Signature-256 when an app secret exists |
| Other messaging (SMS/Telegram/Viber) | POST / GET /api/messaging/{channel}/webhook/{tenant}/{token} |
URL token |
| Razorpay | POST /api/webhooks/razorpay |
Provider signature over the raw body |
| Cashfree | POST /api/webhooks/cashfree |
Provider signature over the raw body |
| Twilio voice | POST /api/voice/{tenant}/{twiml|status|recording}/{token} |
URL token |
Rate limits: generic/webhook/messaging routes 120/min/IP; payment webhooks 240/min/IP.
Where the URLs come from The tenant id and token are generated by the CRM — you never invent them. Meta connection URLs are shown on the lead-source connection; the WhatsApp callback URL is displayed on the WhatsApp connection settings page. Full install-time wiring: Meta / WhatsApp / payment callbacks.
Who this is for
- Developer — implementing or debugging a webhook consumer/provider pairing.
- Admin — copying the correct URL into Meta, WhatsApp or the payment provider's dashboard.
- Support — reading webhook logs to explain "leads not arriving" or "payment succeeded but the invoice didn't".
Meta lead ads
Verification handshake (GET)
When you save the Callback URL in the Meta app dashboard, Meta sends:
GET /webhook/{tenant}/{source}/{token}?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...
The CRM echoes hub.challenge (also accepting the hub.challenge dot spelling; Twitter's crc_token special case is handled too). Check the events log in the CRM if Meta reports the URL could not be validated.
Lead delivery (POST)
Meta sends entry[].changes[] entries with field: "leadgen". The CRM:
- Resolves the tenant/source from the URL token.
- Verifies
X-Hub-Signature-256=sha256=+ HMAC-SHA256 of the raw body using the connection's app secret (or the platform Meta app secret from Super Admin → Meta Developer settings). If no secret is configured, verification is skipped. - Records a
WebhookLogrow (pending, orinvalid_signatureon failure) and queues processing.
For each leadgen change it then fetches the lead from the Graph API (field_data,created_time,ad_id,form_id) and maps full_name / first_name / last_name / email / phone_number (plus any tenant field mapping) into a lead.
Response behaviour: the endpoint always returns 200 OK with body OK. Unknown tokens get a silent 200 with no log row; inactive/unknown connections get a 200 plus a rejected log entry. This is deliberate — Meta deduplicates and retries aggressively, so failures are surfaced in the CRM's webhook log, not via HTTP status.
Expected payload shape (abridged)
{
"object": "page",
"entry": [
{
"id": "PAGE_ID",
"changes": [
{
"field": "leadgen",
"value": { "leadgen_id": "1234567890", "page_id": "PAGE_ID", "form_id": "FORM_ID", "ad_id": "AD_ID" }
}
]
}
]
}
WhatsApp Cloud API
Verification handshake (GET)
GET /api/messaging/whatsapp/webhook/{tenant}/{token}?hub_mode=subscribe&hub_verify_token=YOUR_VERIFY_TOKEN&hub_challenge=...
All three must match — the hub.mode must be subscribe, the hub.verify_token must equal the connection's verify token, and the {token} path segment must match the workspace's messaging webhook token (or the WhatsApp connection's verify token). On success the challenge is echoed as text/plain 200; otherwise 403 Forbidden.
Message delivery (POST)
POST /api/messaging/whatsapp/webhook/{tenant}/{token}
- Token mismatch →
200 {"ok":true}(the endpoint never reveals which part was wrong). - If the connection has an app secret,
X-Hub-Signature-256is required; a bad signature →403 {"ok":false,"error":"invalid signature"}. - If the
phone_number_idin the payload is not owned by this workspace's connection →403 {"ok":false,"error":"phone number not owned"}.
Parsed content: entry[].changes[].value.messages[] (text body, from, media links, wamid) and value.statuses[] with states sent, delivered, read, failed (applied to the outbound message ledger). Messages are de-duplicated by wamid, so Meta retries never create duplicate inbox rows. STOP-style keywords trigger the opt-out guard on the matched lead.
Expected payload shape (abridged)
{
"object": "whatsapp_business_account",
"entry": [
{
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": { "phone_number_id": "..." },
"messages": [ { "from": "9198...", "id": "wamid....", "type": "text", "text": { "body": "Hi" } } ],
"statuses": [ { "id": "wamid....", "status": "delivered" } ]
}
}
]
}
]
}
Razorpay & Cashfree (commercial billing)
POST /api/webhooks/razorpay
POST /api/webhooks/cashfree
These carry no URL token — authenticity comes from the provider signature computed over the raw request body with the secret configured in Super Admin → Billing → Providers & Settings.
| Situation | Response |
|---|---|
| Signature invalid | 400 {"ok":false,"error":"invalid signature"} |
| Provider not configured yet | 200 {"ok":false,"error":"provider not configured"} (so the provider stops retrying a config problem) |
| First delivery | 200 {"ok":true} after the event is stored and processed |
| Duplicate delivery | 200 {"ok":true,"duplicate":true} |
Idempotency: every event is written to the BillingEvent ledger keyed by (provider, event_id) with a SHA-256 payload hash. A repeat — including a concurrent race — is detected and acknowledged without side effects. Canonical events processed: subscription.authorized, subscription.charged, subscription.cancelled, subscription.halted, payment.captured, payment.failed, mandate.revoked, mandate.expired. Unmapped event types are stored as processed with {"ok":true,"unmapped":true}.
Retries: providers retry on 5xx/timeouts; because the endpoint is idempotent, retries are always safe. On our side, outbound webhook delivery to your endpoints retries 5 times with 2/4/8/16/32-minute backoff — see Outbound webhooks & API keys.
Other inbound endpoints
| Path | Notes |
|---|---|
POST /api/inbound/leads and POST /api/v1/inbound |
Lead intake for automation tools (120/min/IP) — Authorization: Bearer {tenant api_key} + ?tenant={slug} |
POST /api/webhook/{tenant}/{source}/{token} |
Generic token-scoped receiver — same machinery as Meta above |
POST /api/forms/{tenant}/{slug}/submit |
Public form submission (60/min/IP), creates a lead |
POST /api/voice/{tenant}/twiml|status|recording/{token} |
Twilio voice callbacks |
Inbound lead intake details (POST /api/inbound/leads or POST /api/v1/inbound): send the workspace's tenant API key as Authorization: Bearer {tenant api_key} and name the same workspace in ?tenant={slug} — a missing key returns 401, an unknown or inactive tenant slug 404, and a key that does not match that tenant 401. email is required; accepted fields: first_name, last_name, phone, company, source, notes, tags[], custom_fields. A new lead returns 201 {"status":"created","lead_id":...}; an email that already exists returns 200 {"status":"duplicate","lead_id":...}.
Local testing
You cannot point a cloud provider at localhost. Two practical options:
-
Tunnel — expose the local install with any tunnelling tool (ngrok or equivalent), then paste the public URL + path into the provider dashboard. Example with the XAMPP dev install:
# 1) start the tunnel against your web root, e.g. port 80 ngrok http 80 # 2) the Meta callback URL becomes: # https://<tunnel-id>.ngrok.io/Lead%20Hub/webhook/{tenant}/{source}/{token} -
Simulate the POST with curl — enough to confirm token resolution, logging and the job pipeline:
# Meta-style ping (no signature configured → verification skipped) curl -i -X POST "http://localhost/Lead%20Hub/webhook/12/meta/yourWebhookToken123" \ -H "Content-Type: application/json" \ -d '{"object":"page","entry":[]}' # → 200 OK, body: OK # WhatsApp verification handshake curl -i "http://localhost/Lead%20Hub/api/messaging/whatsapp/webhook/12/yourToken?hub_mode=subscribe&hub_verify_token=yourVerifyToken&hub_challenge=12345" # → 200 with body "12345" when both tokens match # Razorpay (expect 400 without a valid signature) curl -i -X POST "http://localhost/Lead%20Hub/api/webhooks/razorpay" \ -H "Content-Type: application/json" \ -H "X-Razorpay-Signature: test" \ -d '{"event":"payment.captured","payload":{}}'
Signature testing To test a valid signature you must compute the HMAC over the exact raw bytes you send, using the same secret stored in the CRM. Most API tools (Postman, Insomnia) can script this in a pre-request hook.
Inspect results in the CRM: webhook entries land in the log with statuses like pending, rejected and invalid_signature, and processing runs on the queue — see Queues, jobs & scheduler.
Common mistakes
- Pointing the provider at an unversioned or wrong path (e.g. forgetting
/api/messaging/...for WhatsApp). - Rotating the app secret in the provider dashboard without updating Super Admin → Meta Developer settings — every signature then fails (
invalid_signature). - Returning non-2xx from your outbound receiver — triggers five retries with backoff.
- Expecting the Meta endpoint to return 4xx on bad signatures — it intentionally answers 200 and logs instead.
Troubleshooting
| Symptom | Check |
|---|---|
| Meta says "callback URL couldn't be validated" | The GET verification route — confirm the tunnel is live and the token exact |
| WhatsApp verification 403 | hub_verify_token vs the connection's verify token, and the path token |
Webhook log full of invalid_signature |
App secret mismatch between the provider and the CRM |
| Payments succeed but invoices don't update | Billing event processing — queue worker running? ReconcileProviderStatus runs hourly as a backstop |
Technical details
- Generic receiver:
App\Http\Controllers\InboundWebhookController(routes/web.php, GET+POST/webhook/{tenant}/{source}/{token}) →ProcessInboundWebhookjob. - Meta parsing/signature:
App\Services\LeadSources\MetaConnector(X-Hub-Signature-256,leadgenchanges). - WhatsApp:
App\Http\Controllers\Api\MessagingWebhookController(verify + handle),App\Services\WhatsApp\*. - Payments:
App\Http\Controllers\Api\CommercialBillingWebhookController,BillingEventledger,ProcessCommercialWebhookjob. - Log model:
WebhookLog; connection URLs:App\Models\LeadSourceConnection::webhook_url, WhatsApp pageapp/Filament/Pages/Settings/WhatsAppConnectionPage.php.
Related guides
Was this page helpful?
Thanks — noted. ✦