How to Send RCS Messages

Send rich, branded conversations to your customers through Google's Rich Communication Services (RCS). The LigueLead API supports four template variants — plain text, single media, interactive rich cards, and horizontal carousels — created with a single request and reusable across every send.

Send rich, branded conversations to your customers through Rich Communication Services (RCS). The LigueLead API supports four template variants — plain text, single media, interactive rich cards, and horizontal carousels — created with a single request and reusable across every send.

Send RCS messages with the appropriate template type and understand the credits charged for each message.

📋 Prerequisites

  • Valid api-token and app-id from your LigueLead account, or an app-id created through the API — see Managing Apps.
  • An approved RCS agent for the app-id you will use — the sender brand your messages are delivered under. Registering it is Step 1 below, and templates and sends both depend on it being approved.
  • Devices that don't support RCS automatically receive the fallback_message (when provided) over the SMS gateway.
  • Target phone numbers in Brazilian format (national 11-digit, international with +55, or DDI form).

💳 RCS billing

RCS messages are charged per recipient according to the message type and, for text, its size in bytes.

Message typeRuleCredits chargedRate
Text — freeform or text templateUp to 160 bytes1 RCS Basic creditR$ 0.10
Text — freeform or text templateMore than 160 bytes2 RCS Basic creditsR$ 0.20
Media templateAny text size1 RCS Single creditR$ 0.15
Interactive card templateAny text size1 RCS Single creditR$ 0.15
Carousel templateAny text size1 RCS Single creditR$ 0.15

The 160-byte threshold applies to freeform messages and text templates. Bytes are not the same as characters: accented characters, emojis, and other Unicode characters can use more than one byte. As a result, messages with the same character count can fall into different billing tiers.

For example, a text message with 160 bytes or fewer consumes one RCS Basic credit. A text message above 160 bytes consumes two RCS Basic credits. Media, card, and carousel templates always consume one RCS Single credit.

🧭 Choose the right template type

TemplateEndpointWhen to use
TextPOST /v1/rcs/templates/textPlain branded notifications with {{N}} placeholders. No media, no buttons.
MediaPOST /v1/rcs/templates/mediaNotification with a single image or short video plus a body caption.
CardPOST /v1/rcs/templates/cardRich card with optional media and 1–4 interactive buttons (reply / open URL / dial call).
CarouselPOST /v1/rcs/templates/carousel2–10 rich cards rendered as a horizontal carousel. Buttons must be homogeneous across cards.

All four endpoints persist a template_id you can reuse on every subsequent send.

🔑 Get Your Credentials

  1. Access your account at areadocliente.liguelead.app.br.
  2. Navigate to API Credentials.
  3. Copy your api-token and app-id.

🤖 Step 1 — Register your RCS agent

The RCS agent is the sender brand your messages are delivered under: the name, description, logo, banner and colour the recipient sees on the device. Templates and sends both depend on an approved agent, so this comes before anything else.

Each registration is bound to the app resolved from your api-token and app-id headers. An app can hold more than one agent — typically one per use_case, such as one for verification codes and another for offers — and each template belongs to one of them (Step 2).

Register the brand

POST /v1/rcs/agents registers the brand and hands it over for review in one call. There is no draft here: the brand fields are all required, and a missing one answers 422 naming what is absent, before anything is created. test_devices is the only optional field.

use_case says what the brand is for — otp, transactional or promotional. It is not a preference: the carrier homologates the agent against it and suspends whoever leaves it, so a verification-code agent sending marketing takes the channel down in days, with no error along the way. It also decides which of the supplier's projects the brand is registered in.

It stops changing once the brand is approved. A PUT that sends a different use_case for an approved brand answers 409 with use_case_locked, naming the projects the brand is registered in — it can be more than one, since the same brand may hold a line at each supplier. Nothing else about the registration is blocked, only that field, and on a PUT use_case is the one field you may omit: leaving it out keeps what was declared.

A brand that never declared a purpose is the exception: registrations opened before this field existed carry none, and those may still declare one after approval. Filling in what was missing is not a change.

webhook_url is where this registration's review notifications go. It is optional, and it is the agent's own address — not the webhook URL configured for your app. That one carries campaign events, delivery status and reply-button postbacks; a brand review happens before any of that and usually belongs to a different system of yours. Declare it here and the review events arrive there alone, with nothing to filter out.

You can set it on creation or add it later with PUT. An agent without it is simply never notified, and nothing falls back to the app's URL.

curl -X POST "https://api.liguelead.com.br/v1/rcs/agents" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "use_case": "promotional",
    "webhook_url": "https://api.suaempresa.com.br/webhooks/rcs-agent",
    "sender_name": "Loja Exemplo",
    "sender_description": "Atendimento e ofertas da Loja Exemplo",
    "brand_color": "#1B4DFF",
    "logo_url": "https://cdn.exemplo.com.br/rcs/logo.png",
    "banner_url": "https://cdn.exemplo.com.br/rcs/banner.png",
    "contact_name": "Maria Souza",
    "contact_email": "[email protected]",
    "company_name": "Loja Exemplo",
    "legal_name": "Loja Exemplo Comércio Ltda.",
    "company_website": "https://exemplo.com.br",
    "phone": "+551130000000",
    "public_email": "[email protected]",
    "public_website": "https://exemplo.com.br/atendimento",
    "privacy_policy_url": "https://exemplo.com.br/privacidade",
    "terms_url": "https://exemplo.com.br/termos",
    "test_devices": ["+5511999999999"]
  }'

Response

{
  "id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
  "app_id": "e3f1c2a4-0000-4000-8000-000000000001",
  "status": "submitted",
  "rejection_reason": null,
  "use_case": "promotional",
  "webhook_url": "https://api.suaempresa.com.br/webhooks/rcs-agent",
  "sender_name": "Loja Exemplo",
  "sender_description": "Atendimento e ofertas da Loja Exemplo",
  "brand_color": "#1B4DFF",
  "logo_url": "https://cdn.exemplo.com.br/rcs/logo.png",
  "banner_url": "https://cdn.exemplo.com.br/rcs/banner.png",
  "contact_name": "Maria Souza",
  "contact_email": "[email protected]",
  "company_name": "Loja Exemplo",
  "legal_name": "Loja Exemplo Comércio Ltda.",
  "company_website": "https://exemplo.com.br",
  "phone": "+551130000000",
  "public_email": "[email protected]",
  "public_website": "https://exemplo.com.br/atendimento",
  "privacy_policy_url": "https://exemplo.com.br/privacidade",
  "terms_url": "https://exemplo.com.br/termos",
  "test_devices": ["+5511999999999"]
}

Store the id — it is a UUID, and every other call in this section takes it. POST, GET and PUT all answer with this same shape; status and rejection_reason are what change over the review.

What each field is for

GroupFields
What the carrier verifies about the companylegal_name, company_name, company_website, contact_name, contact_email
What the recipient seessender_name (100 chars), sender_description (500 chars), brand_color, logo_url, banner_url, phone, public_email, public_website
What the carrier requires to launchprivacy_policy_url, terms_url

contact_name and contact_email are never shown to recipients — LigueLead uses them to reach you during the review. test_devices takes up to 20 phone numbers allowed to receive the brand before approval, and is not required to submit.

⚠️ Asset rules checked during review

The API checks the syntax of brand_color — anything that is not #RRGGBB is refused on creation. Everything below is checked only by the carrier, during the review: image dimensions, file sizes and colour contrast all pass the API call and can still be refused later.

AssetCarrier requirement
Logo224×224 pixels, up to 50 KB, JPEG or PNG.
Banner1440×448 pixels, up to 200 KB.
brand_color#RRGGBB with a contrast ratio of at least 4.5:1 against white.

The device paints brand_color behind white text. A pale colour passes the API call and is refused by the carrier.

Follow the review

The registration goes through our triage, which takes hours, and then the carrier's homologation, which takes days. You do not have to watch it.

The webhook tells you. If you declared a webhook_url on the agent, a rcs.agent.status event arrives there three times: when the registration is picked up for review, when it is approved, and when it is rejected. The approval is the one worth acting on — it is the moment RCS sending is enabled for the app.

{
  "event": "rcs.agent.status",
  "app_id": "e3f1c2a4-0000-4000-8000-000000000001",
  "occurred_at": "2026-05-23T18:42:11.234Z",
  "agent": {
    "id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
    "status": "approved",
    "rejection_reason": null
  }
}

status is under_review, approved or rejected, and rejection_reason carries what to fix on a rejection. The full contract is in How to Receive Webhooks.

Note that it does not arrive at the webhook URL configured for your app — that one receives delivery status and postbacks. This event goes to the webhook_url of the agent itself.

If you did not declare one, or if you want to check on demand, GET /v1/rcs/agents/{id} answers the current state at any time.

curl "https://api.liguelead.com.br/v1/rcs/agents/7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID"
StatusMeaningNext step
submittedHanded over for review. This is where a registration created here starts.Wait for the webhook.
under_reviewWe or the carrier are checking it. The carrier step takes days.Wait for the webhook.
approvedReady; RCS sending is enabled for the app.Move on to Step 2.
rejectedRejected.Read rejection_reason and correct it with PUT — that sends it back for review.
draft, editedOnly reachable from the LigueLead panel, where a registration is filled in over time.You may see them if someone at LigueLead touched yours.

Wait for approved before relying on RCS sending. The carrier step takes days and nobody at LigueLead can speed it up.

If the registration is rejected, correct it with PUT /v1/rcs/agents/{id}, which sends it back for review in the same call — a new registration would start the review over from scratch.

Spotted a typo right after registering? Editing is refused while the review is in progress — submitted and under_review both answer 409. Delete the registration with DELETE /v1/rcs/agents/{id} and register again.

Need the brand in another project? The purpose is not the way to move it: changing it would only point the send at a project where the brand does not exist, which is why it is refused. Ask support — registering a brand in a supplier project is a step someone takes on the supplier's side, not something an API call can do on its own.

✏️ Step 2 — Create a template

Every template is created for one agent: pass its agent_id — the id returned by POST /v1/rcs/agents in Step 1. The template belongs to that agent and is registered in the supplier project of that agent's line, the one its use_case chose. That is why the agent is declared here and not at send time: a template registered for one agent does not exist in another agent's project, so every send of this template goes out as this agent.

The agent must be an approved agent of this app. One that does not exist, belongs to another app or is still under review returns 422, before anything is created. To use the same content under two agents, create one template for each.

Templates created before this rule belong to the app's promotional agent, the project they were already registered in. GET /v1/rcs/templates returns the agent_id of each template, so you can check which agent a template sends as.

Text template

POST /v1/rcs/templates/text accepts a JSON body. default_variables is an array of {key, value} pairs — each key is the numeric placeholder (e.g. "1") and value is the fallback rendered when the caller doesn't override the variable at send time.

curl -X POST "https://api.liguelead.com.br/v1/rcs/templates/text" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
    "title": "OTP Verification",
    "body": "Olá {{1}}, seu código é {{2}}",
    "default_variables": [
      { "key": "1", "value": "Cliente" },
      { "key": "2", "value": "000000" }
    ],
    "fallback_message": "Seu código LigueLead: 000000"
  }'

Response

{
  "message": "Template created successfully.",
  "data": {
    "id": "tmpl_2X8…",
    "title": "OTP Verification",
    "agent_id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47"
  }
}

Media template

POST /v1/rcs/templates/media accepts application/json. Provide media via either media_url (public URL) or media_file (a base64 data URI) — never both. default_variables is an array of {key, value} pairs.

curl -X POST "https://api.liguelead.com.br/v1/rcs/templates/media" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
    "title": "Promo banner",
    "header": "Oferta de {{1}}",
    "body": "Aproveite enquanto durar.",
    "media_url": "https://cdn.example.com/banner.jpg",
    "default_variables": [{ "key": "1", "value": "Black Friday" }],
    "fallback_message": "Promo no nosso site: https://example.com"
  }'

To upload a local image instead of a URL, send it as a base64 data URI in the media_file field (drop media_url):

  -d '{
    "agent_id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
    "title": "Promo banner",
    "header": "Oferta",
    "body": "Aproveite enquanto durar.",
    "media_file": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
  }'

Card template (interactive buttons)

POST /v1/rcs/templates/card adds a buttons array (1–4 entries) on top of the media contract. Each button is an object with a type discriminator:

  • type: "reply" + title + optional postback_data — quick-reply button.
  • type: "open_url" + title + url — opens a URL.
  • type: "dial_call" + title + phone_number (E.164) — dials a number.
curl -X POST "https://api.liguelead.com.br/v1/rcs/templates/card" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
    "title": "Promo Card",
    "header": "Olá {{1}}",
    "body": "Confira nossa oferta",
    "media_url": "https://cdn.example.com/promo.jpg",
    "default_variables": [{ "key": "1", "value": "Cliente" }],
    "buttons": [
      { "type": "open_url", "title": "Ver oferta", "url": "https://example.com/promo" },
      { "type": "reply", "title": "Quero saber mais", "postback_data": "KNOW_MORE" }
    ]
  }'

Carousel template (2–10 cards)

POST /v1/rcs/templates/carousel extends the card contract to multiple cards under a cards array. Each card carries its own header, body, optional media (media_url or base64 data URI media_file), and up to 2 buttons — fewer than the 4 a single card allows.

Homogeneity rule: every card must declare the same number of buttons, in the same order of types. The labels and values vary per card, but the structural shape is fixed across the carousel.

curl -X POST "https://api.liguelead.com.br/v1/rcs/templates/carousel" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
    "title": "Catálogo Promo",
    "default_variables": [{ "key": "1", "value": "Cliente" }],
    "cards": [
      {
        "header": "Camiseta {{1}}",
        "body": "Tecido premium",
        "media_url": "https://cdn.example.com/camiseta.jpg",
        "buttons": [
          { "type": "open_url", "title": "Comprar", "url": "https://loja.example.com/camiseta" },
          { "type": "reply", "title": "Mais info", "postback_data": "INFO_CAMISETA" }
        ]
      },
      {
        "header": "Calça {{1}}",
        "body": "Edição limitada",
        "media_file": "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
        "buttons": [
          { "type": "open_url", "title": "Comprar", "url": "https://loja.example.com/calca" },
          { "type": "reply", "title": "Mais info", "postback_data": "INFO_CALCA" }
        ]
      }
    ]
  }'

Card 0 uses a public media_url while card 1 ships its image inline as a base64 data URI in media_file. The two media options are interchangeable per card.

📤 Step 3 — Send a message

POST /v1/rcs accepts a JSON body and returns 202 Accepted immediately — actual delivery is processed asynchronously and reported back through the configured webhook. Two mutually-exclusive modes are supported:

  • Template send: pass template_id (from Step 2) and no agent_id. Use template_variables to override the template's default_variables at send time.
  • Freeform send: pass message (capped at 306 characters) and agent_id.

The agent is the registered brand the message goes out as — what the recipient sees — and where it comes from depends on the mode:

  • On a template send it comes from the template: the agent declared when the template was created in Step 2, the one whose supplier project the template is registered in. Sending agent_id together with template_id returns 422 — the template already says who sends it.
  • On a freeform send agent_id is required. It is the id returned by POST /v1/rcs/agents in Step 1, and it is required even when the app has a single approved agent — it is never inferred from the app. An app may hold more than one agent — one for verification codes, another for offers — and guessing which to use is what would deliver under the wrong brand: a failure that does not show up in a log, only on the recipient's phone.

The agent is checked before the send is accepted, in both modes: an agent that does not exist, belongs to another app, or is still under review returns 422 — not a 202 followed by silence. On a template send the error names the template the agent came from.

Exactly one of template_id or message must be provided. Devices that don't support RCS receive the SMS fallback automatically — taken from the template's fallback_message for template sends, or from the message field itself for freeform sends.

The 306-character freeform limit is a technical validation rule. Text-message billing is calculated separately by byte size: up to 160 bytes consumes one RCS Basic credit, while text above 160 bytes consumes two RCS Basic credits.

Template send

curl -X POST "https://api.liguelead.com.br/v1/rcs" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "phones": ["11999999999", "+5511888888888"],
    "template_id": "tmpl_2X8…",
    "template_variables": [
      { "key": "1", "value": "Cliente" },
      { "key": "2", "value": "12345" }
    ]
  }'

There is no agent_id here: the message goes out as the template's agent.

template_variables follows the same {key, value} array shape used by default_variables on the template-creation endpoints. Keys are numeric strings that match the {{N}} placeholders inside the template body.

Freeform send

curl -X POST "https://api.liguelead.com.br/v1/rcs" \
  -H "api-token: YOUR_API_TOKEN" \
  -H "app-id: YOUR_APP_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "phones": ["11999999999"],
    "agent_id": "7b3c1e90-4d2a-4f11-9c8e-2a5b6d0f3e47",
    "message": "Olá! Confira nossa promoção: https://example.com"
  }'

Response

{
  "message": "RCS accepted successfully",
  "data": {
    "campaign_id": "3b4f7c5e-7e2d-4157-b542-5eb2155455c5",
    "accepted_at": "2026-05-23T18:42:11.234Z"
  }
}

Persist the campaign_id — every webhook event triggered by this send echoes the same identifier so you can correlate deliveries, reads and postbacks back to the original request.

To have the status of this send delivered somewhere other than the app's webhook URL, add webhook_url to the body. It is called exactly as you wrote it, query string included, so it can carry your own identifiers (?order=123); reply-button postbacks keep going to the app's URL. The rules are in How to Receive Webhooks.

📡 Step 4 — Receive webhooks

There are three event families relevant to RCS, listed in the order they happen — and they do not all arrive at the same address:

  • Brand review — event: 'rcs.agent.status', fired when the registration from Step 1 is picked up for review, approved or rejected. Goes to the webhook_url declared on the agent. It is the one event without a campaign: it is about the brand, not a message, and carries an agent object instead.
  • Delivery status — event: 'campaign.status', with campaign.status one of sent, delivered, read, undelivered, failed, insufficient_credits. The last one means the account's RCS balance did not cover this message at send time (a long Basic message can cost more than one unit): that phone was not sent to, and it is the only status the phone gets — no sent before it. Goes to the webhook_url passed on the send when there is one, and otherwise to the webhook URL configured for your app. Same payload shape as SMS and voice.
  • Inbound postback — event: 'rcs.inbound.postback', fired when a user taps a reply button. Also goes to the app's URL. The payload carries the postback_data you declared on the template plus the originating campaign envelope, so you can correlate the click back to the send.

Switch on the top-level event field before reading campaign, or a brand approval will look like a malformed delivery.

For the full webhook contract and signature verification, read How to Receive Webhooks.

⚠️ Common pitfalls

  • agent_id must be an approved agent of this app — on template creation and on a freeform send, an id that does not exist, belongs to another app, or is still under review returns 422. The check happens before the send is queued, so a wrong id fails at the request, not silently afterwards.
  • A template send takes no agent_id — the agent comes from the template, and sending both returns 422. To send the same content as another agent, create a template for that agent.
  • Carousel homogeneity — different button counts or button types across cards return 422. Pick the union of buttons you want and apply it to every card (mismatching only the labels/URLs).
  • media_url × media_file per card — providing both on the same card (or both for /media / /card) returns 422. media_file is a base64 data URI (data:<mime>;base64,...), not a binary upload.
  • media_url must use HTTPS — a plain-HTTP or non-HTTP URL (javascript:, data:, ftp:, ...) returns 422.
  • Carousel media has a combined ceiling, not just a per-card one — each card's media_file is capped at 1 MB decoded (2 MB on /media and /card), and the sum across every card in the carousel also cannot exceed 5 MB. An image over its own cap returns 413; a carousel over the combined ceiling is refused as well.
  • media_file content is verified, not just trusted — the decoded bytes are sniffed to confirm they are a real JPEG, PNG, GIF or WEBP. A file whose content doesn't match a supported image format is rejected with 415, regardless of the declared extension or MIME type.
  • phone_number format — dial_call buttons require E.164 (+55…). Numbers without the leading + are rejected.
  • default_variables / template_variables shape — always an array of {key, value} pairs (on both template creation and send). Sending an object ({"1":"Cliente"}) or a JSON string is rejected.
  • Numeric keys only — variable keys must match ^\d+$ so they line up with {{N}} placeholders.
  • /rcs send mode is mutually exclusive — supply either template_id or message, never both and never neither (422). agent_id goes only with message.
  • Freeform message is capped at 306 chars — because the same content is reused as the SMS fallback. Template sends are not bound by this cap (their fallback comes from the template you created). This character limit is separate from RCS Basic billing, which uses the 160-byte threshold for text messages.
  • /rcs payload only accepts the documented fields — unknown fields return 422. The SMS fallback is always resolved from the template's fallback_message (for template sends) or from message (for freeform sends); never sent on the request.

🔗 Endpoint reference


Did this page help you?