How to Receive Webhooks
Configure your application to receive real-time webhook notifications for SMS, SMS Flash, Voice, and RCS: delivery status updates, replies to your SMS, RCS reply-button postbacks, and the review decisions on your RCS sender brand.
Overview
LigueLead automatically sends webhooks to notify your application when something happens on your account. Most of it is campaign events, across all channels: SMS, SMS Flash, Voice, and RCS. One event is not about a campaign at all — rcs.agent.status reports the review of your RCS sender brand, which exists before any send. Webhooks enable you to:
- Monitor in real-time the status of your deliveries and calls
- Implement retry logic for delivery or call failures
- Keep synchronization between your system and LigueLead
- Collect metrics on delivery, call performance, and credits consumed
- React to user interactions with RCS reply buttons (postback events) and replies to your SMS
- Know when your RCS brand is approved without polling for it
Webhook URL Configuration
Client Area Access
- Access https://areadocliente.liguelead.app.br/
- Log in with your credentials
- Navigate to Integrations > API Token
- Locate the "Webhook URL" section
- Enter the complete URL of your webhook endpoint
- Click "Save"
The app's webhook URL receives notifications for all channels (SMS, SMS Flash, Voice, and RCS) and all campaign event types (delivery status, SMS replies and RCS postbacks). Route incoming requests by inspecting the
eventfield and, for delivery events, thecampaign.typefield. To send the status of a specific send somewhere else, passwebhook_urlon that send — see below.
Per-send webhook URL
Every send endpoint (POST /v1/sms, POST /v1/rcs, POST /v1/voice and POST /v1/voice-agent/call) accepts an optional webhook_url. When it is present, the status events of that send (campaign.status) go to it instead of the app's webhook URL — the same model as the Twilio StatusCallback.
The URL is called exactly as you wrote it, query string included, so use it to carry your own identifiers and match each status to your records:
{
"message": "Your order has shipped",
"phones": ["11999999999"],
"webhook_url": "https://api.mycompany.com/webhooks/liguelead?order=123"
}- Only
campaign.statusfollows it. SMS replies (sms.inbound), RCS button postbacks andrcs.agent.statuskeep their usual destination. - A send without
webhook_urlbehaves as before. - It must use
httporhttps, have a public hostname without underscores and be at most 512 characters. Private and reserved addresses (localhost,10.x,192.168.x,169.254.xand the like) are refused with 422, like every other validation error of the API. - No authentication header is sent to it; if you need to verify the caller, put a token of your own in the query string.
- Redirects are not followed: the URL itself has to answer with a 2xx status. A
3xxanswer is not retried, since the endpoint may already have processed the request before redirecting. - The URL is kept for 7 days after the send, which covers every status that send can produce.
URL Requirements
Your webhook URL must meet these requirements:
| Requirement | Details |
|---|---|
| Protocol | HTTPS (required for production) |
| Response time | Answer within a few seconds: acknowledge first, then process. The request is abandoned after 60 seconds |
| Status code | Return a 2xx status (e.g. 200) to confirm receipt |
| Availability | Must be always available to receive notifications |
A 5xx, 408, 425 or 429 answer, a timeout or an unreachable endpoint is retried for about 45 minutes; any other answer outside 2xx drops that notification (see Retries).
Valid URL example:
https://api.mycompany.com/webhooks/ligueleadWebhook Payload Structure
All LigueLead webhooks share the same three top-level fields. Campaign webhooks — delivery updates and RCS reply-button clicks — carry a campaign object on top of them, with the common fields below.
Two events differ from that shape, and switching on event is what tells them apart:
rcs.agent.status, about the sender brand rather than a message, has nocampaignat all and goes to a different address. It is described right after this table.sms.inbound, a reply to an SMS you sent, carries a leancampaignobject with onlyid,phoneandmessage. It is described in the SMS / SMS Flash tab below.
Common Fields
event, app_id and occurred_at are present in every webhook payload. The campaign.* fields are present in every campaign.status and rcs.inbound.postback payload — not in rcs.agent.status, and only id and phone of them in sms.inbound:
| Field | Type | Description |
|---|---|---|
event | string | Event type. "campaign.status" for delivery updates (all channels), "sms.inbound" for a reply to an SMS, "rcs.inbound.postback" for an RCS reply-button click, or "rcs.agent.status" when an RCS brand moves through review. |
app_id | string | Your application ID in LigueLead |
occurred_at | string | When LigueLead processed the event and issued this notification, in UTC, ISO 8601 with milliseconds (e.g. "2026-02-02T12:00:15.400Z"). Each notification gets its own value, and a retry of the same notification carries the same one |
campaign.id | string | Unique campaign ID (UUID v4) — the campaign_id returned by the send |
campaign.type | string | Campaign type: "sms", "sms_flash", "voice", "voice_ai", "rcs_single", or "rcs_basic". "voice" is a regular voice call and "voice_ai" is an AI agent call |
campaign.source | string | Source of the campaign (e.g., "api", "n8n", "make"). An SMS sent by an AI agent during a call carries "voice_ai" |
campaign.phone | string | Recipient phone number in international format |
campaign.credits_required | number | Number of credits consumed by this message or call |
campaign.sent_at | string | When the send was dispatched, in ISO 8601. SMS, SMS Flash and RCS carry the São Paulo offset (e.g. "2026-02-02T09:00:00-03:00"), and may carry only the date ("2026-02-02") for older sends. Voice and Voice AI carry UTC (e.g. "2026-02-02T12:00:00Z"). Parse it with an ISO 8601 parser instead of comparing strings |
campaign.status | string | Current campaign status. Present on campaign.status events only — see channel-specific statuses below. |
rcs.agent.statusis the one event without acampaign. It is not about a
message — it is about the sender brand, which exists before any send and
outlives every one of them. That event carries anagentobject instead, and
onlyevent,app_idandoccurred_atare shared with the rest. Switch on
eventbefore readingcampaign, or a brand approval will look like a
malformed delivery.It also does not arrive at the same address: it goes to the
webhook_url
declared on the agent, while everything else on this page goes to the app's
webhook URL.
Channel-Specific Payload and Statuses
Each channel includes additional fields and its own set of statuses. Select the tab below for your channel.
SMS Payload Example
{
"event": "campaign.status",
"app_id": "your-app-id-here",
"occurred_at": "2026-02-02T12:00:15.400Z",
"campaign": {
"id": "campaign-uuid-v4",
"type": "sms",
"source": "api",
"phone": "+5513991884678",
"message": "Check out our latest offers! Visit our website now.",
"credits_required": 1,
"sent_at": "2026-02-02T09:00:00-03:00",
"status": "delivered"
}
}SMS Flash Payload Example
SMS Flash uses the same envelope — only the campaign.type discriminator changes:
{
"event": "campaign.status",
"app_id": "your-app-id-here",
"occurred_at": "2026-02-02T12:00:15.400Z",
"campaign": {
"id": "campaign-uuid-v4",
"type": "sms_flash",
"source": "api",
"phone": "+5513991884678",
"message": "Your verification code is 123456",
"credits_required": 1,
"sent_at": "2026-02-02T09:00:00-03:00",
"status": "delivered"
}
}SMS-Specific Fields
These fields appear only in SMS and SMS Flash webhooks, in addition to the common fields:
| Field | Type | Description |
|---|---|---|
campaign.message | string | The SMS message content sent to the recipient |
Standard SMS and SMS Flash share an identical payload structure. Differentiate them via the
campaign.typefield:"sms"for standard SMS and"sms_flash"for Flash SMS.
SMS Campaign Statuses
Currently, SMS and SMS Flash campaign.status events report a single status:
| Status | Description |
|---|---|
delivered | Message was delivered to the recipient |
A message that is not delivered currently produces no campaign.status event, and SMS does not report sent or any intermediate status by webhook.
To learn the outcome of every recipient — including the ones that were not delivered — use
GET /v1/campaigns/{campaign_id}/recipients. That endpoint reports each recipient individually, with a finer set of statuses than the webhook, and keeps them for 72 hours after the send. See How to Check Campaign Status.
Write your handler to accept any
statusvalue instead of rejecting the ones it does not know.failedcan appear in exceptional cases, and the statuses reported by webhook for SMS may be extended in the future.
graph TD
A[SMS accepted] --> B{Delivered?}
B -->|Yes| C["campaign.status: delivered"]
B -->|No| D["No webhook — check<br/>GET /v1/campaigns/{campaign_id}/recipients"]SMS Reply Payload Example (sms.inbound)
sms.inbound)Fired when a recipient replies to an SMS you sent. It goes to the app's webhook URL — unless LigueLead has set up a dedicated forwarding address for replies on your account, in which case replies go there instead. It never goes to the webhook_url of the send.
{
"event": "sms.inbound",
"app_id": "your-app-id-here",
"occurred_at": "2026-02-02T12:07:41.902Z",
"campaign": {
"id": "campaign-uuid-v4",
"phone": "+5513991884678",
"message": "Yes, I want to know more"
}
}| Field | Type | Description |
|---|---|---|
campaign.id | string | The campaign_id of the send this reply answers, or an empty string when the reply could not be matched to a send. |
campaign.phone | string | Phone number of the person who replied, in international format (+55…). |
campaign.message | string | Text of the reply. |
Matching a reply to the send it answers is best effort, so treat
campaign.idas optional. A reply that cannot be attributed to a recent send of your app may not be delivered at all — except at a dedicated forwarding address, which can also receive unmatched replies, withcampaign.idandapp_idempty. Not every send can receive replies: it depends on the route the message went out on.
sms.inboundhas nocampaign.type,campaign.status,campaign.source,campaign.credits_requiredorcampaign.sent_at— switch oneventbefore reading them.
RCS delivers three distinct event types, and they are listed here in the order they happen: rcs.agent.status when the sender brand moves through review, then campaign.status for the delivery lifecycle of each send, then rcs.inbound.postback when a user taps a reply button on a card.
The brand comes first because nothing can be sent until it is approved.
They do not all go to the same address. The two campaign events go to the app's webhook URL, configured in the client area. The brand review goes to the URL declared on the agent itself, in webhook_url. Switch on the top-level event field either way.
RCS Agent Status Payload Example
This one is not about a message — it is about the sender brand. Fired when the RCS agent you registered with POST /v1/rcs/agents moves through review, it replaces polling GET /v1/rcs/agents/{id}: registration goes through our triage and then the carrier's homologation, and the carrier step takes days.
Unlike the two campaign events, it carries no campaign object at all.
{
"event": "rcs.agent.status",
"app_id": "your-app-id-here",
"occurred_at": "2026-05-23T18:42:11.234Z",
"agent": {
"id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
"status": "approved",
"rejection_reason": null
}
}| Field | Type | Description |
|---|---|---|
agent.id | string | The registration's UUID — the same id returned by POST /v1/rcs/agents. |
agent.status | string | under_review, approved or rejected. |
agent.rejection_reason | string | null | What to correct. Filled on rejected, null otherwise. |
Three transitions fire, and only three:
| Status | What happened | What to do |
|---|---|---|
under_review | Someone picked the registration up and is checking it. | Nothing. The carrier step that follows takes days. |
approved | The brand is live. RCS sending is enabled for the app. | Start sending — this is the event worth acting on. |
rejected | The registration was refused. | Read rejection_reason, fix it, and PUT to resubmit. |
Your own actions do not produce an event. Registering, editing, and resubmitting are things you already know you did — the webhook reports what we decided, not what you sent.
These three are the whole contract, and the boundary that produces the event refuses anything else — so a client generated from this page will not meet a
statusit does not know.
RCS Delivery Status Payload Example
{
"event": "campaign.status",
"app_id": "your-app-id-here",
"occurred_at": "2026-05-23T18:42:11.234Z",
"campaign": {
"id": "campaign-uuid-v4",
"type": "rcs_single",
"source": "api",
"phone": "+5513991884678",
"credits_required": 1,
"sent_at": "2026-05-23T06:15:00-03:00",
"status": "delivered",
"template_id": "tmpl_2X8…",
"template_title": "Promo Card"
}
}RCS Inbound Postback Payload Example
Fired when the user taps a reply-type suggestion on an RCS card (single card or any card inside a carousel). The campaign envelope mirrors the delivery payload (so your existing parser keeps working) plus an inbound sub-object carrying the postback data you declared when creating the template.
{
"event": "rcs.inbound.postback",
"app_id": "your-app-id-here",
"occurred_at": "2026-05-23T18:50:00.000Z",
"campaign": {
"id": "campaign-uuid-v4",
"type": "rcs_single",
"source": "api",
"phone": "+5513991884678",
"credits_required": 1,
"sent_at": "2026-05-23T06:15:00-03:00",
"template_id": "tmpl_2X8…",
"template_title": "Promo Card",
"inbound": {
"type": "postback",
"postback_data": "KNOW_MORE"
}
}
}Correlate a postback back to the original send via
campaign.id— it's the same identifier echoed on every delivery event triggered by that send.
RCS-Specific Fields
These fields appear only in the two campaign events above, in addition to the common fields. The rcs.agent.status event carries none of them.
| Field | Type | Description |
|---|---|---|
campaign.message | string | Freeform message body. Present only for sends made without template_id. |
campaign.template_id | string | Identifier of the RCS template used for the send. Present only for template sends. |
campaign.template_title | string | Friendly name of the template. Present only for template sends. |
campaign.inbound.type | string | Always "postback" today. Reserved discriminator for future inbound variants (text, media, ...). |
campaign.inbound.postback_data | string | The opaque payload you declared on the reply suggestion when creating the template. |
RCS Campaign Statuses
Delivered on event: "campaign.status". These are message statuses — the brand's own review states live in rcs.agent.status above and never appear here.
| Status | Description |
|---|---|
sent | Message accepted by the RCS gateway and en route to the recipient. |
delivered | Message was delivered to the recipient's device. |
read | Recipient opened the message (read receipt). RCS-only. |
undelivered | The carrier could not deliver the message (e.g. device offline beyond TTL). |
failed | Sending failed (provider/gateway error, invalid number, content rejected, etc.). |
insufficient_credits | The account's RCS balance did not cover this message when it was due to go out — a long Basic message can cost more than one unit, so this can happen with credits still left — and it was not sent; no credit was consumed. It is the only status that phone gets: no sent comes before it and nothing follows it. Top up the balance and send again to reach that phone. |
Devices that do not support RCS receive the SMS fallback automatically — those fallback messages arrive on your webhook as a regular campaign.type: "sms" event, not as RCS, with the same campaign.id as the RCS send. Like any SMS, the fallback currently reports only delivered (see the SMS / SMS Flash tab).
Voice Payload Example
{
"event": "campaign.status",
"app_id": "your-app-id-here",
"occurred_at": "2026-02-02T12:00:15.400Z",
"campaign": {
"id": "campaign-uuid-v4",
"type": "voice",
"source": "api",
"phone": "+5513991884678",
"audio_title": "Promo Fevereiro",
"audio_time": "00:00:30.00",
"audio_id": 1234,
"credits_required": 1,
"sent_at": "2026-02-02T12:00:00Z",
"duration_sec": 23,
"dtmf": "1#21212",
"status": "answer"
}
}Voice-Specific Fields
These fields appear in regular voice webhooks (campaign.type: "voice"), in addition to the common fields. AI agent calls arrive as campaign.type: "voice_ai" and carry a different set — see the Voice AI tab.
| Field | Type | Description |
|---|---|---|
campaign.audio_title | string | Title of the audio file used in the call. |
campaign.audio_time | string | Duration of the audio message, with hundredths (e.g., "00:00:30.00"). |
campaign.audio_id | number | Unique identifier of the audio file. |
campaign.duration_sec | number | Duration of the answered call, in seconds. Present only on answered calls |
campaign.dtmf | string | Raw stream of every key the recipient pressed during the call, concatenated in the order they were pressed. It may contain the digits 0-9 and the control keys * and #. There is no de-duplication: a recipient who mistypes and tries again produces extra keypresses in the same string, which is why a single menu choice can arrive as a long sequence (e.g. "1", "12", "1#21212"). Do not assume a single digit, and do not treat the value as the option the recipient selected. The key is absent when there was no keypress. |
Voice Campaign Statuses
| Status | Description |
|---|---|
sent | Call was queued and sent to the carrier |
answer | Call was answered by the recipient |
no_answer | Call was not answered (includes busy and timeout) |
invalid_number | The provided phone number is invalid |
failed | Call failed (hangup, error, congestion, no route, or unknown) |
graph TD
A[Call Initiated] --> B[sent]
B --> C{Call Result}
C --> D[answer]
C --> E[no_answer]
C --> F[invalid_number]
C --> G[failed]Calls conducted by an AI agent arrive with campaign.type: "voice_ai". Everything else in this guide applies unchanged — same URL, same envelope, same statuses.
Voice AI Payload Example
{
"event": "campaign.status",
"app_id": "your-app-id-here",
"occurred_at": "2026-02-02T12:00:15.400Z",
"campaign": {
"id": "campaign-uuid-v4",
"type": "voice_ai",
"source": "api",
"phone": "+5513991884678",
"credits_required": 1,
"sent_at": "2026-02-02T12:00:00Z",
"duration_sec": 23,
"recording_url": "https://.../recording.wav",
"action_executed": "schedule_event",
"transcript": [
{ "role": "assistant", "content": "Hi! This is the assistant of XYZ. May I schedule a demo for you?" },
{ "role": "user", "content": "Sure, Thursday at 2pm works." },
{ "role": "assistant", "content": "Perfect! Booked for Thursday at 2pm. Talk soon!" }
],
"status": "answer"
}
}Voice AI-Specific Fields
| Field | Type | Description |
|---|---|---|
campaign.duration_sec | number | Duration of the answered call, in seconds. Present only on answered calls |
campaign.recording_url | string | Public URL of the call recording. Present only when a recording was produced |
campaign.action_executed | string | Action the agent executed during the call — see the table below. Always present on AI calls; falls back to none |
campaign.transcript | array | Full conversation history. Absent when the call produced no conversation (e.g. never answered) |
campaign.transcript[].role | string | Who spoke: "assistant" or "user" |
campaign.transcript[].content | string | What was said |
The audio fields (
audio_id,audio_title,audio_time) anddtmfnever appear here. An AI call plays no audio file and does not collect keypresses — those belong to regular voice, on the Voice tab.
action_executed Values
action_executed Values| Value | Description |
|---|---|
none | No action was executed |
end_call | The agent actively ended the call |
transfer_call | Call transferred to a human |
schedule_event | The agent scheduled a calendar event |
send_email | The agent sent an e-mail |
send_sms | The agent sent the SMS of its SEND_SMS action to the recipient. The SMS reports its own delivery in a separate SMS webhook, with campaign.source: "voice_ai" |
http_webhook | The agent called the configured HTTP webhook action |
voice_mail | The call landed on voicemail |
silence_timeout | The call was ended after the recipient stayed silent |
virtual_assistant | The call was answered by a virtual assistant / IVR, not a person |
check_availability | The agent looked up availability on the calendar |
Unknown values returned by the provider are normalized to none.
Voice AI Campaign Statuses
Same set as regular voice: sent, answer, no_answer, invalid_number and failed.
Endpoint Implementation
The webhook endpoint structure is the same for all channels. The difference is in the fields you validate and the statuses you handle. Select the channel tab, then the language tab for an example.
These examples are illustrations, not production code. Once your endpoint answers 2xx, LigueLead never sends that notification again — retries only happen while it does not —, so a production receiver must store the payload durably — in a message queue or a database — before answering 2xx, and process it from there. Work kept only in memory (
setImmediate, in-process background tasks, or processing inline before answering) is lost if the process restarts, and processing inline also risks the 60-second limit. The SMS / SMS Flash tab shows that shape; in the other tabs, the routing and status handling is what to take from them.
const express = require('express');
const app = express();
app.use(express.json());
// `durableQueue` stands for your own durable storage (a message queue or a
// database table). It is not part of any LigueLead SDK.
app.post('/webhooks/liguelead', async (req, res) => {
try {
// Persist BEFORE acknowledging: a notification answered with 2xx is not
// retried, so whatever is only in memory when you answer is lost if this
// process restarts. Keep the delivery id with it, to drop a retry you
// already have.
await durableQueue.enqueue({
deliveryId: req.get('X-LigueLead-Delivery-Id'),
payload: req.body,
});
res.status(200).json({ received: true });
} catch (error) {
// Not stored. A 500 makes LigueLead send this notification again later,
// for about 45 minutes; log enough to reconcile if it never gets through.
console.error('Could not store webhook:', error, req.body);
res.status(500).json({ error: 'Internal server error' });
}
});
// A separate worker consumes `durableQueue`, skips a deliveryId it already
// processed, and calls routeEvent() with the stored payload.
function routeEvent(payload) {
switch (payload.event) {
case 'campaign.status':
// The same URL receives every channel; keep only SMS and SMS Flash here.
if (['sms', 'sms_flash'].includes(payload.campaign?.type)) {
processStatus(payload);
}
break;
case 'sms.inbound':
processReply(payload);
break;
default:
// Other events and future ones: acknowledged above, ignored here.
break;
}
}
function processStatus(payload) {
const { campaign, occurred_at } = payload;
const { id: campaign_id, type: campaign_type, phone, status } = campaign;
console.log(`Campaign ${campaign_id} (${campaign_type}) ${phone}: ${status} at ${occurred_at}`);
switch (status) {
case 'delivered':
handleDelivered(payload);
break;
case 'failed':
handleFailed(payload);
break;
default:
// Accept statuses you do not handle yet instead of rejecting them.
console.info(`Unhandled SMS status: ${status}`);
}
}
function processReply(payload) {
const { id: campaign_id, phone, message } = payload.campaign;
// campaign_id is the send this reply answers, or '' when it could not be matched.
console.log(`Reply from ${phone} to campaign ${campaign_id || '(unknown)'}: ${message}`);
}
function handleDelivered(payload) {
// Update status in database
// Send notification to user
// Trigger next workflow action
}
function handleFailed(payload) {
// Exceptional — look the recipient up with
// GET /v1/campaigns/{campaign_id}/recipients?phone=...
}
app.listen(3000, () => console.log('Webhook server running on port 3000'));from fastapi import FastAPI, Request
from pydantic import BaseModel
import logging
app = FastAPI()
class SmsCampaign(BaseModel):
id: str
type: str # "sms" or "sms_flash"
source: str
phone: str
message: str
credits_required: int
sent_at: str # ISO 8601 with offset; may be date-only for older sends
status: str
class SmsReply(BaseModel):
id: str # campaign_id of the send this reply answers; may be ""
phone: str
message: str
# `durable_queue` stands for your own durable storage (a message queue or a
# database table). It is not part of any LigueLead SDK.
@app.post("/webhooks/liguelead")
async def receive_webhook(request: Request):
payload = await request.json()
# Persist BEFORE acknowledging: a notification answered with 2xx is not
# retried, so whatever is only in memory when you answer is lost if this
# process restarts. Keep the delivery id with it, to drop a retry you already
# have. If enqueue raises, FastAPI answers 500 and LigueLead sends the
# notification again later, for about 45 minutes.
await durable_queue.enqueue({
"delivery_id": request.headers.get("X-LigueLead-Delivery-Id"),
"payload": payload,
})
return {"received": True}
# A separate worker consumes `durable_queue`, skips a delivery_id it already
# processed, and calls route_event() with the stored payload.
async def route_event(payload: dict):
event = payload.get("event")
campaign = payload.get("campaign") or {}
if event == "campaign.status" and campaign.get("type") in ("sms", "sms_flash"):
await process_status(SmsCampaign(**campaign))
elif event == "sms.inbound":
await process_reply(SmsReply(**campaign))
# Other channels and events: ignored here.
async def process_status(campaign: SmsCampaign):
logging.info(f"Campaign {campaign.id} ({campaign.type}) {campaign.phone}: {campaign.status}")
if campaign.status == "delivered":
await handle_delivered(campaign)
elif campaign.status == "failed":
await handle_failed(campaign)
else:
# Accept statuses you do not handle yet instead of rejecting them.
logging.info(f"Unhandled SMS status: {campaign.status}")
async def process_reply(reply: SmsReply):
logging.info(f"Reply from {reply.phone} to campaign {reply.id or '(unknown)'}: {reply.message}")
async def handle_delivered(campaign: SmsCampaign):
# Implement successful delivery logic
pass
async def handle_failed(campaign: SmsCampaign):
# Exceptional — look the recipient up with
# GET /v1/campaigns/{campaign_id}/recipients?phone=...
passconst express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/liguelead', (req, res) => {
try {
const payload = req.body;
// RCS endpoints handle two event families:
// - 'campaign.status' → delivery lifecycle (sent / delivered / read / undelivered / failed / insufficient_credits)
// - 'rcs.inbound.postback' → user tapped a reply button on a card
switch (payload.event) {
case 'campaign.status':
processStatus(payload);
break;
case 'rcs.inbound.postback':
processPostback(payload);
break;
default:
console.warn(`Unknown event: ${payload.event}`);
}
// Illustration only: in production, persist the payload durably before
// answering and process it from there (see the warning above)
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
function processStatus(payload) {
const { campaign } = payload;
// Filter out non-RCS deliveries
if (!['rcs_single', 'rcs_basic'].includes(campaign.type)) return;
console.log(`RCS ${campaign.id}: ${campaign.status}`);
switch (campaign.status) {
case 'sent': return handleSent(payload);
case 'delivered': return handleDelivered(payload);
case 'read': return handleRead(payload);
case 'undelivered': return handleUndelivered(payload);
case 'failed': return handleFailed(payload);
case 'insufficient_credits': return handleInsufficientCredits(payload);
default:
console.warn(`Unknown RCS status: ${campaign.status}`);
}
}
function processPostback(payload) {
const { campaign } = payload;
const postbackData = campaign.inbound.postback_data;
// postback_data is the opaque value you set on the `reply` suggestion
// when creating the template — use it to route the user's intent.
console.log(`RCS postback ${campaign.id}: ${postbackData}`);
// Example: route to handlers based on the postback discriminator
if (postbackData === 'KNOW_MORE') {
handleKnowMore(payload);
} else if (postbackData.startsWith('CONFIRM_')) {
handleConfirmation(payload, postbackData);
}
}
function handleSent(payload) { /* RCS accepted by the gateway */ }
function handleDelivered(payload) { /* Device received the message */ }
function handleRead(payload) { /* User opened the message — RCS-only */ }
function handleUndelivered(payload) { /* Carrier could not deliver (TTL, offline, ...) */ }
function handleFailed(payload) { /* Provider/gateway error */ }
function handleInsufficientCredits(payload) { /* Not sent: the RCS balance did not cover this message */ }
function handleKnowMore(payload) { /* User tapped "Quero saber mais" */ }
function handleConfirmation(payload, postbackData) { /* User confirmed something */ }
app.listen(3000, () => console.log('Webhook server running on port 3000'));from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
import logging
app = FastAPI()
class InboundPostback(BaseModel):
type: str
postback_data: str
class RcsCampaign(BaseModel):
id: str
type: str
source: str
phone: str
credits_required: int
sent_at: str
status: Optional[str] = None
message: Optional[str] = None
template_id: Optional[str] = None
template_title: Optional[str] = None
inbound: Optional[InboundPostback] = None
class WebhookPayload(BaseModel):
event: str
app_id: str
occurred_at: str
campaign: RcsCampaign
@app.post("/webhooks/liguelead")
async def receive_webhook(payload: WebhookPayload):
try:
if payload.event == "campaign.status":
if payload.campaign.type in ("rcs_single", "rcs_basic"):
await process_status(payload)
elif payload.event == "rcs.inbound.postback":
await process_postback(payload)
else:
logging.warning(f"Unknown event: {payload.event}")
return {"received": True}
except Exception as e:
logging.error(f"Webhook processing error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
async def process_status(payload: WebhookPayload):
status = payload.campaign.status
logging.info(f"RCS {payload.campaign.id}: {status}")
handlers = {
"sent": handle_sent,
"delivered": handle_delivered,
"read": handle_read,
"undelivered": handle_undelivered,
"failed": handle_failed,
"insufficient_credits": handle_insufficient_credits,
}
handler = handlers.get(status)
if handler:
await handler(payload)
async def process_postback(payload: WebhookPayload):
postback_data = payload.campaign.inbound.postback_data
logging.info(f"RCS postback {payload.campaign.id}: {postback_data}")
# Route based on the value you declared on the `reply` suggestion.
async def handle_sent(payload): pass # RCS accepted by the gateway
async def handle_delivered(payload): pass # Device received the message
async def handle_read(payload): pass # User opened the message — RCS-only
async def handle_undelivered(payload): pass # Carrier could not deliver
async def handle_failed(payload): pass # Provider/gateway error
async def handle_insufficient_credits(payload): pass # Not sent: the RCS balance did not cover this messageconst express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/liguelead', (req, res) => {
try {
const payload = req.body;
// Validate payload structure
if (!isValidPayload(payload)) {
return res.status(400).json({ error: 'Invalid payload' });
}
// Process webhook based on status
processWebhook(payload);
// Illustration only: in production, persist the payload durably before
// answering and process it from there (see the warning above)
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
function isValidPayload(payload) {
const requiredFields = ['event', 'app_id', 'occurred_at'];
// duration_sec is optional — it is only present on answered calls.
const campaignFields = [
'id', 'type', 'status', 'source', 'phone',
'audio_title', 'audio_time', 'audio_id',
'credits_required', 'sent_at'
];
return requiredFields.every(field => payload.hasOwnProperty(field))
&& payload.campaign
&& campaignFields.every(field => payload.campaign.hasOwnProperty(field));
}
function processWebhook(payload) {
const { campaign, occurred_at } = payload;
const { id: campaign_id, type: campaign_type, status } = campaign;
console.log(`Campaign ${campaign_id} (${campaign_type}): ${status} at ${occurred_at}`);
switch (status) {
case 'answer':
handleAnswered(payload);
break;
case 'no_answer':
handleNoAnswer(payload);
break;
case 'invalid_number':
handleInvalidNumber(payload);
break;
case 'failed':
handleFailed(payload);
break;
case 'sent':
handleSent(payload);
break;
default:
console.warn(`Unknown status: ${status}`);
}
}
function handleAnswered(payload) {
// Call was answered — update status, trigger follow-up actions
console.log(`Call answered: ${payload.campaign.id}`);
}
function handleNoAnswer(payload) {
// Call not answered — schedule retry or notify
console.log(`Call not answered: ${payload.campaign.id}`);
}
function handleInvalidNumber(payload) {
// Invalid number — remove or flag in your contact list
console.log(`Invalid number: ${payload.campaign.phone}`);
}
function handleFailed(payload) {
// Call failed — log error, update metrics, notify
console.log(`Call failed: ${payload.campaign.id}`);
}
function handleSent(payload) {
// Call queued — update campaign status in your system
console.log(`Call queued: ${payload.campaign.id}`);
}
app.listen(3000, () => console.log('Webhook server running on port 3000'));from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import logging
app = FastAPI()
class Campaign(BaseModel):
id: str
type: str
source: str
phone: str
audio_title: str
audio_time: str
audio_id: int
credits_required: int
sent_at: str
status: str
duration_sec: int | None = None # only on answered calls
class WebhookPayload(BaseModel):
event: str
app_id: str
campaign: Campaign
occurred_at: str
@app.post("/webhooks/liguelead")
async def receive_webhook(payload: WebhookPayload):
try:
# Validate event type
if payload.event != "campaign.status":
raise HTTPException(status_code=400, detail="Invalid event type")
# Process webhook asynchronously
await process_webhook(payload)
return {"received": True}
except Exception as e:
logging.error(f"Webhook processing error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
async def process_webhook(payload: WebhookPayload):
logging.info(f"Campaign {payload.campaign.id} ({payload.campaign.type}): {payload.campaign.status}")
if payload.campaign.status == "answer":
await handle_answered(payload)
elif payload.campaign.status == "no_answer":
await handle_no_answer(payload)
elif payload.campaign.status == "invalid_number":
await handle_invalid_number(payload)
elif payload.campaign.status == "failed":
await handle_failed(payload)
elif payload.campaign.status == "sent":
await handle_sent(payload)
async def handle_answered(payload: WebhookPayload):
# Call was answered — update status, trigger follow-up actions
pass
async def handle_no_answer(payload: WebhookPayload):
# Call not answered — schedule retry or notify
pass
async def handle_invalid_number(payload: WebhookPayload):
# Invalid number — remove or flag in your contact list
pass
async def handle_failed(payload: WebhookPayload):
# Call failed — log error, update metrics
pass
async def handle_sent(payload: WebhookPayload):
# Call queued — update campaign status in your system
passAn AI call is routed by campaign.type: "voice_ai" and reacted to by action_executed, not by audio or keypresses. The status values are the same as regular voice.
function processVoiceAiWebhook(payload) {
const { campaign } = payload;
const { id, status, action_executed, transcript, recording_url } = campaign;
console.log(`AI call ${id}: ${status}, action=${action_executed}`);
// `transcript` and `recording_url` only exist when the call produced them.
if (transcript?.length) {
console.log(`Conversation with ${transcript.length} turns`);
}
if (recording_url) {
storeRecording(id, recording_url);
}
switch (action_executed) {
case 'schedule_event':
onMeetingBooked(payload);
break;
case 'transfer_call':
onTransferredToHuman(payload);
break;
case 'send_email':
onEmailSent(payload);
break;
case 'send_sms':
onSmsSent(payload);
break;
case 'http_webhook':
onAgentWebhookCalled(payload);
break;
case 'voice_mail':
case 'virtual_assistant':
// Nobody human picked up — worth a retry on another window.
onMachineAnswered(payload);
break;
case 'silence_timeout':
onRecipientWentSilent(payload);
break;
case 'check_availability':
onAvailabilityChecked(payload);
break;
case 'end_call':
case 'none':
onNoAction(payload);
break;
default:
// New actions may appear; treat the unknown as no action.
console.warn(`Unknown action: ${action_executed}`);
onNoAction(payload);
}
}MACHINE_ANSWERED = {"voice_mail", "virtual_assistant"}
NO_ACTION = {"end_call", "none"}
def process_voice_ai_webhook(payload: dict) -> None:
campaign = payload["campaign"]
campaign_id = campaign["id"]
action = campaign.get("action_executed", "none")
print(f"AI call {campaign_id}: {campaign['status']}, action={action}")
# `transcript` and `recording_url` only exist when the call produced them.
transcript = campaign.get("transcript") or []
if transcript:
print(f"Conversation with {len(transcript)} turns")
if campaign.get("recording_url"):
store_recording(campaign_id, campaign["recording_url"])
if action == "schedule_event":
on_meeting_booked(payload)
elif action == "transfer_call":
on_transferred_to_human(payload)
elif action == "send_email":
on_email_sent(payload)
elif action == "send_sms":
on_sms_sent(payload)
elif action == "http_webhook":
on_agent_webhook_called(payload)
elif action in MACHINE_ANSWERED:
on_machine_answered(payload)
elif action == "silence_timeout":
on_recipient_went_silent(payload)
elif action == "check_availability":
on_availability_checked(payload)
elif action in NO_ACTION:
on_no_action(payload)
else:
# New actions may appear; treat the unknown as no action.
print(f"Unknown action: {action}")
on_no_action(payload)Error Handling
Retries
LigueLead sends a notification again when your endpoint could not take it, and only then. This applies to every event type, on the app's webhook URL and on a per-send webhook_url alike.
| Your endpoint… | What happens |
|---|---|
answers 2xx | Delivered. Not retried — though a notification can still reach you twice, so deduplicate (see below) |
answers 5xx, 408, 425 or 429 | Retried |
| has not answered after 60 seconds, refuses the connection, or its hostname does not resolve | Retried |
answers any other 4xx | Not retried — the notification is dropped |
answers a 3xx | On a per-send webhook_url: the redirect is not followed and the notification is not retried. On the app's webhook URL the redirect is followed, as it always was, and the final answer decides |
Schedule. Up to 8 attempts over about 45 minutes. The wait after a failed attempt starts at 30 seconds and doubles each time, up to 15 minutes — 30 s, 1 min, 2 min, 4 min, 8 min, 15 min, then 15 min — and each wait varies by up to 20% (never above 15 minutes), so that a backlog does not come back all at once. When a 429 or 503 carries a Retry-After header (in seconds or as an HTTP date), the wait is at least that long, up to 15 minutes. After the eighth failed attempt the notification is no longer sent.
Headers. Every attempt carries two headers that tell a retry apart from a new notification:
| Header | Value |
|---|---|
X-LigueLead-Delivery-Id | Identifies the notification. The same on every attempt of it |
X-LigueLead-Delivery-Attempt | 1 on the first attempt, counting up on each retry. A number can repeat when LigueLead had to send the same attempt again |
What this means for your endpoint:
- Answer 2xx fast — store the payload and answer before processing it. A request still open after 60 seconds is a failed attempt, and the notification is sent again even if you went on to process it
- Deduplicate by
X-LigueLead-Delivery-Id— a retry is the same notification sent again, and you can receive one you already have: when your answer did not reach us in time, for example. If you already stored that id, answer 2xx and drop the request - Expect events out of order — a retried notification can arrive after a later one for the same recipient, such as an RCS
deliveredretried after thereadthat followed it got through. Order byoccurred_at, which is set when LigueLead issues the notification and does not change between attempts, and do not let an older status overwrite a newer one - Answer 4xx only for what you never want to receive — a
400or404drops the notification for good, while a503gets it sent again
A notification your endpoint keeps failing for about 45 minutes — or refuses with a
4xxother than408,425and429— is no longer sent. To keep it from being lost:
- Persist, then respond — store the payload durably (message queue or database) and return a 2xx status before doing any other processing
- Use asynchronous processing — process from that durable queue, not from memory: an in-process background task is lost if the process restarts after you answered
- Keep detailed logs — log every incoming webhook, with its
X-LigueLead-Delivery-Id, for debugging and reconciliation- Monitor endpoint availability — an outage longer than about 45 minutes loses the notifications issued during it
- Reconcile SMS on demand —
GET /v1/campaigns/{campaign_id}/recipientslists the status of every recipient of an SMS campaign for 72 hours, so a lost notification can be recovered there
Security and Best Practices
1. Origin Validation
LigueLead does not currently implement HMAC signatures. Validate the request origin and payload structure:
// Validate origin IP (if LigueLead provides a static IP range)
const allowedIPs = ['LIGUELEAD_IP'];
if (!allowedIPs.includes(req.ip)) {
return res.status(403).json({ error: 'Forbidden' });
}
// Check the event type, but acknowledge one you do not know instead of rejecting it:
// a 400 is not retried, so it only loses the notification.
const knownEvents = ['campaign.status', 'sms.inbound', 'rcs.inbound.postback', 'rcs.agent.status'];
if (!knownEvents.includes(payload.event)) {
console.warn(`Ignoring unknown event: ${payload.event}`);
return res.status(200).json({ received: true });
}2. Idempotency
Two kinds of duplicate can reach your endpoint, and each has its own key:
- A retry of a notification you already received carries the same
X-LigueLead-Delivery-Idheader (see Retries). Drop a request whose delivery id you already stored. - A second notification of the same status — the provider reported it twice, for example — is a new notification, with its own delivery id and its own
occurred_at. Recognise it by its content instead, leavingoccurred_atout of the key:
const processedWebhooks = new Set();
function processWebhook(payload) {
// Unique key for a status event: campaign ID + recipient + status
const webhookId = `${payload.campaign.id}_${payload.campaign.phone}_${payload.campaign.status}`;
if (processedWebhooks.has(webhookId)) {
console.log('Webhook already processed:', webhookId);
return;
}
processedWebhooks.add(webhookId);
// Process webhook...
}In production, replace the in-memory
Setwith a persistent store (e.g., Redis or a database table) to survive server restarts.
3. Rate Limiting
Size your endpoint for the notification volume instead of throttling it. A campaign produces status notifications for each of its recipients, and they can arrive in bursts. A rate limiter that answers 429 only delays the notification it refuses — it is retried, after at least the Retry-After you send, up to 15 minutes — but only for about 45 minutes, and every refused notification comes back on top of the new ones.
If you must put a limiter in front of the endpoint, set it above your peak campaign volume, answer 429 (never another 4xx, which drops the notification), and keep the processing behind a queue so the endpoint itself only acknowledges.
Monitoring and Debug
Webhook Headers
LigueLead sends the following headers with every webhook request:
Content-Type: application/json
User-Agent: LigueLead-WebhookDispatcher/1.0
X-LigueLead-Delivery-Id: 6f1d2c3b-8a4e-4f5d-9b7c-0e1f2a3b4c5d
X-LigueLead-Delivery-Attempt: 1X-LigueLead-Delivery-Id is the same on every attempt of a notification, and X-LigueLead-Delivery-Attempt counts them — see Retries.
Recommended Logging
Log incoming webhooks with channel-relevant fields:
console.log('Webhook received:', {
campaign_id: payload.campaign.id,
campaign_type: payload.campaign.type, // 'sms' or 'sms_flash'
status: payload.campaign.status,
message: payload.campaign.message,
timestamp: new Date().toISOString(),
processing_time_ms: processingTime
});console.log('Webhook received:', {
event: payload.event, // 'campaign.status' or 'rcs.inbound.postback'
campaign_id: payload.campaign.id,
campaign_type: payload.campaign.type, // 'rcs_single' or 'rcs_basic'
status: payload.campaign.status, // delivery only
template_id: payload.campaign.template_id, // template sends only
postback_data: payload.campaign.inbound?.postback_data, // postback events only
timestamp: new Date().toISOString(),
processing_time_ms: processingTime
});console.log('Webhook received:', {
campaign_id: payload.campaign.id,
campaign_type: payload.campaign.type,
status: payload.campaign.status,
audio_title: payload.campaign.audio_title,
duration_sec: payload.campaign.duration_sec,
timestamp: new Date().toISOString(),
processing_time_ms: processingTime
});console.log('Webhook received:', {
campaign_id: payload.campaign.id,
campaign_type: payload.campaign.type,
status: payload.campaign.status,
action_executed: payload.campaign.action_executed,
duration_sec: payload.campaign.duration_sec,
transcript_turns: payload.campaign.transcript?.length ?? 0,
timestamp: new Date().toISOString(),
processing_time_ms: processingTime
});Do not log audio_title here — an AI call carries no audio file, so the field is always absent.
Important Metrics
Monitor these metrics across all channels:
- Success rate of received webhooks (2xx responses)
- Response time of your endpoint (target < 1 second)
- Status distribution across campaigns (delivered vs. failed, read for RCS, answer vs. no_answer for voice)
- Webhook frequency per campaign and channel
Testing Webhooks
1. Development Environment
Use ngrok to expose your local server to the internet:
npm install -g ngrok
ngrok http 3000Copy the generated HTTPS URL (e.g., https://abc123.ngrok.io) and paste it into the Webhook URL field in the LigueLead client area.
2. Payload Simulation
Send a test webhook to your local endpoint to verify your implementation:
// Standard SMS
const smsPayload = {
"event": "campaign.status",
"app_id": "test-app-id",
"occurred_at": new Date().toISOString(),
"campaign": {
"id": "test-campaign-123",
"type": "sms",
"source": "api",
"phone": "+5511999999999",
"message": "Check out our latest offers!",
"credits_required": 1,
"sent_at": "2026-02-02T09:00:00-03:00",
"status": "delivered"
}
};
// SMS Flash — same envelope, only campaign.type changes
const smsFlashPayload = {
...smsPayload,
campaign: {
...smsPayload.campaign,
type: "sms_flash",
message: "Your verification code is 123456"
}
};
// Reply to an SMS — lean campaign object, sent to the app's webhook URL (never to a send's webhook_url)
const smsReplyPayload = {
"event": "sms.inbound",
"app_id": "test-app-id",
"occurred_at": new Date().toISOString(),
"campaign": {
"id": "test-campaign-123",
"phone": "+5511999999999",
"message": "Yes, I want to know more"
}
};
for (const payload of [smsPayload, smsFlashPayload, smsReplyPayload]) {
fetch('http://localhost:3000/webhooks/liguelead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}// 1) Delivery status event
const statusPayload = {
"event": "campaign.status",
"app_id": "test-app-id",
"occurred_at": new Date().toISOString(),
"campaign": {
"id": "test-campaign-123",
"type": "rcs_single",
"source": "api",
"phone": "+5511999999999",
"credits_required": 1,
"sent_at": "2026-05-23T06:15:00-03:00",
"status": "delivered",
"template_id": "tmpl_test_123",
"template_title": "Promo Card"
}
};
// 2) Inbound postback event (button click)
const postbackPayload = {
"event": "rcs.inbound.postback",
"app_id": "test-app-id",
"occurred_at": new Date().toISOString(),
"campaign": {
"id": "test-campaign-123",
"type": "rcs_single",
"source": "api",
"phone": "+5511999999999",
"credits_required": 1,
"sent_at": "2026-05-23T06:15:00-03:00",
"template_id": "tmpl_test_123",
"template_title": "Promo Card",
"inbound": {
"type": "postback",
"postback_data": "KNOW_MORE"
}
}
};
for (const payload of [statusPayload, postbackPayload]) {
fetch('http://localhost:3000/webhooks/liguelead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}const testPayload = {
"event": "campaign.status",
"app_id": "test-app-id",
"occurred_at": new Date().toISOString(),
"campaign": {
"id": "test-campaign-123",
"type": "voice",
"source": "api",
"phone": "+5511999999999",
"audio_title": "Test Audio",
"audio_time": "00:00:30.00",
"audio_id": 1234,
"credits_required": 1,
"sent_at": new Date().toISOString(),
"duration_sec": 23,
"status": "answer"
}
};
fetch('http://localhost:3000/webhooks/liguelead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testPayload)
});const testPayload = {
"event": "campaign.status",
"app_id": "test-app-id",
"occurred_at": new Date().toISOString(),
"campaign": {
"id": "test-campaign-123",
"type": "voice_ai",
"source": "api",
"phone": "+5511999999999",
"credits_required": 1,
"sent_at": new Date().toISOString(),
"duration_sec": 23,
"recording_url": "https://example.com/recording.wav",
"action_executed": "schedule_event",
"transcript": [
{ "role": "assistant", "content": "Hi! May I schedule a demo?" },
{ "role": "user", "content": "Sure, Thursday at 2pm." }
],
"status": "answer"
}
};
fetch('http://localhost:3000/webhooks/liguelead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testPayload)
});Note the absent audio fields and dtmf — that is what a real AI payload looks like, and a handler that assumes them will break here.
Frequently Asked Questions
How often are webhooks sent?
Webhooks are sent as soon as LigueLead processes the event, for all channels (SMS, SMS Flash, RCS, and Voice). For SMS and SMS Flash, the status events currently report only deliveries (delivered). An sms.inbound event is sent when a recipient replies to an SMS, and for RCS an additional rcs.inbound.postback event is sent every time the recipient taps an interactive reply button.
What if my endpoint is down?
LigueLead retries the notification for about 45 minutes — up to 8 attempts, with waits from 30 seconds to 15 minutes — while your endpoint is unreachable, does not answer within 60 seconds, or answers 5xx, 408, 425 or 429; see Retries. After that, or at once for any other 4xx or a 3xx, the notification is no longer sent. An outage longer than about 45 minutes therefore loses the notifications issued during it, so high availability is still strongly recommended. For SMS, GET /v1/campaigns/{campaign_id}/recipients keeps the status of every recipient for 72 hours, so you can reconcile what you missed.
Can I configure different URLs for different campaign types?
Not per campaign type: the app's webhook URL receives notifications for all channels and events. Use the event field to differentiate campaign.status from sms.inbound and rcs.inbound.postback, and campaign.type to route status events by channel. What you can do is pass webhook_url on a send, and the status events of that send go there instead — see Per-send webhook URL.
How do I identify duplicate webhooks?
A retry of the same notification carries the same X-LigueLead-Delivery-Id header: drop a request whose delivery id you already stored. A second notification of the same status is a new notification with its own delivery id, so for status events also use the combination of campaign.id + campaign.phone + campaign.status: a campaign has one campaign.id for all of its recipients, so the phone is what tells them apart. Do not include occurred_at — it is set each time LigueLead issues a notification, so a second notification of the same status carries a different value (a retry keeps the same one). For rcs.inbound.postback events, campaign.id + campaign.phone + campaign.inbound.postback_data identifies the same answer from the same recipient.
Is there a payload or frequency limit?
There is no specific payload size limit. Webhook frequency depends on your campaign volume and, for RCS, on user engagement with interactive buttons.
How do I distinguish SMS from SMS Flash in the payload?
Check the campaign.type field: "sms" for standard SMS and "sms_flash" for Flash SMS. The rest of the payload is identical between the two.
How do I distinguish webhooks across channels?
First check the event field: "rcs.inbound.postback" always refers to an RCS button tap; "sms.inbound" is a reply to an SMS and carries no campaign.type; "campaign.status" is a delivery status update. For status events, use campaign.type to route by channel: "sms" (SMS) and "sms_flash" (SMS Flash), "rcs_single" or "rcs_basic" (RCS), and "voice" (regular voice) or "voice_ai" (AI agent call). Match every value you expect — routing Voice on "voice" alone silently drops AI agent calls.
What is an `rcs.inbound.postback` event?
When a recipient taps a reply button on an RCS card or carousel, the messaging app sends back the button's postback_data value. LigueLead forwards this to your webhook as an rcs.inbound.postback event so you can react in real time (e.g., capture interest, trigger a follow-up flow). Buttons of type open_url and dial_call do not generate postback events — they are handled directly by the user's device.
How do I receive replies to my SMS?
Replies arrive at the app's webhook URL as sms.inbound events — or at the dedicated forwarding address for replies, when LigueLead has set one up for your account — with the reply text in campaign.message and the sender in campaign.phone. campaign.id is the campaign_id of the send being answered when the reply could be matched to it, and an empty string otherwise — matching is best effort. The payload is described in the SMS / SMS Flash tab of Channel-Specific Payload and Statuses.
How do I correlate a postback with the original RCS campaign?
The campaign.id returned in the rcs.inbound.postback event is the same identifier returned when the RCS message was sent. Store the mapping campaign.id → user/context at send time and look it up when the postback arrives. The campaign.template_id is also included for template-level correlation.
What happens if RCS delivery fails — do I get an SMS fallback notification?
When an RCS message cannot be delivered (recipient not RCS-capable, device offline beyond TTL, etc.), LigueLead automatically falls back to SMS — with the template's fallback_message for template sends, or with the message itself for freeform sends. In that case you receive an RCS campaign.status with status: "failed" or "undelivered" for the RCS attempt, and, once the fallback SMS is delivered, a separate campaign.status with campaign.type: "sms" and status: "delivered". Both carry the same campaign.id. Like any SMS, the fallback currently reports only delivered: a fallback SMS that is not delivered produces no SMS event.
Support
For questions or issues with webhooks:
- 🌐 Portal: https://areadocliente.liguelead.app.br/
This documentation was updated in October 2026. For the latest version, always consult the API portal.
Updated about 5 hours ago
