How to Build an RCS Business Messaging Campaign with Twilio in PHP

September 28, 2026
Written by

Twilio image with laptop and icons illustrating how to build an RCS messaging campaign

Rich Communication Services (RCS) is the upgrade to SMS that turns everyday text messages into branded conversations with images, carousels, verified sender logos, suggested replies, and read receipts all delivered natively through the default messaging app on the recipient’s phone.

Twilio Programmable Messaging supports RCS Business Messaging as a first-class channel, which means you can reuse the same API you already use for SMS to deliver rich and interactive messages at scale.

In this tutorial, you’ll learn how to build an RCS Business Messaging campaign in PHP. You’ll register an RCS Sender, design a rich card template in the Twilio Content Template Builder, broadcast the campaign to a list of recipients, track delivery status with a webhook, and capture replies when users tap a suggested reply button.

Prerequisites

RCS Sender registration is a manual review process. Twilio's onboarding guide walks you through submitting your brand details, logo, and use case. While the sender is under review, you can add test devices that will receive messages immediately without waiting for full approval.

How RCS campaigns work with Twilio

Before writing any code, it helps to understand how the pieces fit together:

  • RCS Sender: Your verified brand identity on the RCS network. This is what recipients see at the top of the conversation (your logo, business name, and verified checkmark).
  • Messaging Service: A Twilio resource that groups senders (RCS, SMS, WhatsApp, etc.) together. You send from the Messaging Service and Twilio picks the right sender based on the recipient’s capabilities.
  • Content Template: A reusable message layout with dynamic variables. RCS supports rich cards, carousels, quick replies, and call-to-action buttons.
  • Campaign script: Your PHP code that loops over a recipient list and sends each person a personalized message using the Content Template.
  • Status callback: A webhook that Twilio calls every time a message changes state (queued, sent, delivered, read, failed) so you can measure the campaign in real time.
  • Inbound webhook: A webhook that Twilio calls when a recipient taps a suggested reply or sends a message back, so you can capture responses.

Register an RCS Sender and add it to a Messaging Service

If you haven’t already registered an RCS Sender, do that first. Log in to your Twilio Console and navigate to Products & Services > Numbers & Senders > Overview > RCS. Click Create new Sender and follow the prompts to submit your brand information.

Twilio dashboard showing Numbers & Senders section with options for managing RCS numbers and creating new senders.

After adding in the public details, add your test device to the Tester sender section on the Try it out page so you can start sending immediately.

Screenshot of Twilio interface for configuring and testing RCS senders.

Next, add the RCS Sender to a Messaging Service:

  • Navigate to Configure and scroll down to the Add to a Messaging Service section
  • Click an existing Messaging Service and click Save

Make note of the Messaging Service SID (starts with MG) from the service overview page of your messaging service. You’ll use it in your PHP application.

Step 1: Set up a PHP application

You’ll start setting up your application by initializing a PHP project and installing the dependencies you’ll need.

Initialize a PHP project

Open up your terminal, navigate to your preferred directory for PHP projects, and enter the following commands to create a folder, change into it, and initialize a PHP project:

composer create-project settermjd/twilio-slim-base-project rcs-campaign
cd rcs-campaign

Install the required dependencies

Next, run the following command to install the dependencies you’ll need for this project:

Copy code

composer require monolog/monolog
  • monolog: You’ll use it to log information in the application.

Import environment variables

You’ll need your Twilio Account SID, Auth Token, and Messaging Service SID to send messages. Log in to your Twilio Console and locate the Account SID and Auth Token on the homepage.

Front page of the Twilio Console showing the Account Info section with the Account SID and Auth Token.
Front page of the Twilio Console showing the Account Info section with the Account SID and Auth Token.

Head back to your IDE and open .env in the root of the project. Copy the following into your .env file:

MESSAGING_SERVICE_SID=XXXXXX
STATUS_CALLBACK_URL=XXXXXX

Replace each XXXXXX placeholder with the corresponding value:

  • MESSAGING_SERVICE_SID: The SID of the Messaging Service that you connected with your RCS Sender.
  • STATUS_CALLBACK_URL: Leave this blank for now. You’ll fill it in once you start ngrok in a later step.

Then, update the following:

  • TWILIO_ACCOUNT_SID: Your Account SID from the Twilio Console.
  • TWILIO_AUTH_TOKEN: Your Auth Token from the Twilio Console.

Save this file.

Step 2: Create a rich RCS Content Template

RCS is at its best when the message includes a hero image, a headline, a body, and suggested reply buttons. You’ll create the template programmatically using the Content API so that your campaign setup is fully reproducible in code.

You can also create templates visually by navigating to Messaging > Content Template Builder in the Twilio Console. If you'd rather design your template through the web, follow along with How to Use Twilio's Content Template Builder for Messaging and then plug the resulting Content SID into the .env file.

Create a file called create-template.php in the root of your project and add the following code:

Copy code

<?php

declare(strict_types=1);

require_once('./vendor/autoload.php');

use Dotenv\Dotenv;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Twilio\Exceptions\TwilioException;
use Twilio\Rest\Client;
use Twilio\Rest\Content\V1\ContentModels;

$dotenv = Dotenv::createImmutable(__DIR__ . '/');
$dotenv->load();

$log = new Logger('rcs-campaign');
$log->pushHandler(
    new StreamHandler(
        'php://stdout',
        Level::Debug,
    ),
);

$apiKey    = $_ENV["TWILIO_ACCOUNT_SID"];
$apiSecret = $_ENV["TWILIO_AUTH_TOKEN"];
$twilio    = new Client($apiKey, $apiSecret);

try {
    $content = $twilio->content->v1->contents->create(
        ContentModels::createContentCreateRequest([
            "friendlyName" => "fall-sale-rcs-campaign",
            "language"     => "en",
            "variables"    => (object) [
                '1' => 'Alex',
            ],
            "types"        => ContentModels::createTypes([
                "twilio/card" => ContentModels::createTwilioCard([
                    "title"    => "Fall Sale is Here, {{1}}!",
                    "subtitle" => "Save 25% storewide this weekend",
                    "body"     => "Refresh your wardrobe with our new fall collection. Tap below to browse or grab your promo code.",
                    "media"    => [
                        "https://demo.twilio.com/owl.png",
                    ],
                    "actions"  => [
                        [
                            "type"  => "URL",
                            "title" => "Shop the sale",
                            "url"   => "https://example.com/fall-sale",
                        ],
                        ContentModels::createQuickReplyAction([
                            "type"  => "QUICK_REPLY",
                            "title" => "Send my promo code",
                            "id"    => "send-promo-code",
                        ]),
                        ContentModels::createQuickReplyAction([
                            "type"  => "QUICK_REPLY",
                            "title" => "Unsubscribe",
                            "id"    => "unsubscribe",
                        ]),
                    ],
                ]),
            ]),
        ]),
    );

    $log->info("Content Template created! SID: {$content->sid}");
    $log->info("Save this SID in your .env file as CONTENT_SID.");
} catch (TwilioException $e) {
    $log->error("Error creating template: {$e->getMessage()}");
}

This script authenticates with Twilio using your credentials, then creates a Content Template using the twilio/card type. The title, subtitle, and body fields make up the visible copy on the card, media is the hero image at the top, and each entry in actions becomes a button underneath. The {{1}} placeholder in the title is a dynamic variable you’ll fill in when you send the campaign, so the greeting is personalized for every recipient.

Save the file and run the script from your terminal:

Copy code

php create-template.php

You should see output similar to:

[2026-09-28T03:55:59.748000+00:00] rcs-campaign.INFO: Content Template created! SID: HXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa [] []
[2026-09-28T03:55:59.750996+00:00] rcs-campaign.INFO: Save this SID in your .env file as CONTENT_SID. [] []

Copy the Content SID and add it to your .env file:

CONTENT_SID=HXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

Step 3: Build the campaign recipient list

For this tutorial, you’ll store recipients in a simple JSON file. In a production system, this list would come from your CRM, database, or customer data platform.

Create a file called recipients.json in the root of your project and add the following:

[
    {
        "name": "Dhruv",
        "phoneNumber": "+15551234567"
    },
    {
        "name": "Dylan",
        "phoneNumber": "+15557654321"
    },
    {
        "name": "Matthew",
        "phoneNumber": "+15559876543"
    },
    {
        "name": "Amanda",
        "phoneNumber": "+15559832101"
    }
]

Replace the phone numbers with the E.164-formatted phone numbers of the test devices you registered on your RCS Sender. Any number that isn’t registered as a tester will fail to receive the message.

Step 4: Send the campaign

With your template and recipient list in place, you’re ready to send the campaign. Create a file called send-campaign.php in the root of your project and add the following code:

<?php

declare(strict_types=1);

require_once('./vendor/autoload.php');

use Dotenv\Dotenv;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Twilio\Exceptions\TwilioException;
use Twilio\Rest\Client;

$dotenv = Dotenv::createImmutable(__DIR__ . '/');
$dotenv->load();

$log = new Logger('rcs-campaign');
$log->pushHandler(
    new StreamHandler(
        'php://stdout',
        Level::Debug,
    ),
);

$apiKey    = $_ENV["TWILIO_ACCOUNT_SID"];
$apiSecret = $_ENV["TWILIO_AUTH_TOKEN"];
$twilio    = new Client($apiKey, $apiSecret);

$recipients = json_decode(
    file_get_contents(__DIR__ . "/recipients.json"),
    true,
    flags: JSON_OBJECT_AS_ARRAY | JSON_ERROR_UTF8,
);

$log->info(
    sprintf(
        "Starting campaign for %d recipients...",
        count($recipients),
    ),
);

foreach ($recipients as $recipient) {
    try {
        $message = $twilio->messages->create(
            $recipient['phoneNumber'],
            [
                "body"                => "This is the ship that made the Kessel Run in fourteen parsecs?",
                "messagingServiceSid" => $_ENV['MESSAGING_SERVICE_SID'],
                "contentSid"          => $_ENV['CONTENT_SID'],
                "contentVariables"    => json_encode(
                    [
                        "1" => $recipient['name'],
                    ],
                ),
                "statusCallback"      => $_ENV['STATUS_CALLBACK_URL'],

            ],
        );
        $log->info("Queued for {$recipient['name']} ({$recipient['phoneNumber']}) — SID: {$message->sid}");
    } catch (TwilioException $e) {
        $log->info("Failed to queue message for {$recipient['phoneNumber']}: {$e->getMessage()}");
    }
}

$log->info("'Campaign submitted to Twilio.");

What this code does:

  • The script reads recipients.json into memory and iterates over each entry.
  • messagingServiceSid tells Twilio to send from the Messaging Service you configured, which contains your RCS Sender. Twilio automatically falls back to SMS if a recipient’s device does not support RCS.
  • contentSid references the Content Template you created in Step 1.
  • contentVariables is a JSON string that fills in the {{1}} placeholder with the recipient’s name for personalization.
  • statusCallback is the URL Twilio will hit whenever the message status changes. You’ll build that endpoint in the next step.

Next, you'll need to spin up the webhook server that will receive delivery updates.

Step 5: Track delivery with a status callback webhook

A campaign without measurement is a guess. Twilio can call your server every time a message moves from queued to sent to delivered to read (RCS supports read receipts) so you can build a live picture of how the campaign is performing.

Update the file src/Application.php and add the following code at the top of the class:

private array $stats = [
    "delivered"   => 0,
    "failed"      => 0,
    "queued"      => 0,
    "read"        => 0,
    "sent"        => 0,
    "undelivered" => 0,
];

Then, update the setupRoutes() function to match the following version:

public function setupRoutes(): void
{
    $this->app->post("/status", [$this, "handleStatusRoute"]);
}

After that, replace the handleDefaultRoute() function with the following function:

public function handleStatusRoute(
    ServerRequestInterface $request,
    ResponseInterface $response,
): ResponseInterface {
    $data = $request->getParsedBody() ?? [];

    $messageStatus = $data['MessageStatus'] ?? '';
    if (array_key_exists($messageStatus, $this->stats)) {
        $this->stats[$messageStatus]++;
    }

    $messageSid = $data['MessageSid'];
    $to         = $data['To'];
    $this->logger->info(
        match ($messageStatus) {
            "failed", "undelivered" => "[{$messageStatus}] {$messageSid} → {$to} (error {$data['ErrorCode']})",
            default => "[{$messageStatus}] {$messageSid} → {$to}",
        },
    );

    return $response;
}

This code sets up a server that listens for POST requests at /status. Twilio sends status callbacks to this route and will capture message statuses. Of course, in a production setting you should store or send this data off to a database or a CRM for in-depth metric tracking for customers. Message statuses you can expect for RCS include:

  • queued — Twilio has accepted the message.
  • sent — Twilio has handed the message off to the carrier network.
  • delivered — The message reached the recipient’s device.
  • read — The recipient opened the message (RCS-only).
  • failed / undelivered — Something went wrong. The ErrorCode field tells you why.

Start the server in a terminal window:

composer serve

You should see:

Webhook server running at http://127.0.0.1:8080/

Leave this terminal running.

Step 6: Handle replies from your recipients

Your campaign template includes two suggested reply buttons: Send my promo code and Unsubscribe. When a recipient taps either one, Twilio delivers the button’s payload as an incoming message, which you can respond to with TwiML.

Open src/Application.php and update the following setupRoutes() function to match the following:

public function setupRoutes(): void
{
    $this->app->post("/status", [$this, "handleStatusRoute"]);
    $this->app->post("/incoming", [$this, "handleIncomingRoute"]);
}

Then, add the following function at the end of the class.

public function handleIncomingRoute(
    ServerRequestInterface $request,
    ResponseInterface $response,
): ResponseInterface {
    $data = $request->getParsedBody();

    $from = $data['From'];
    $body = trim($data['Body']);

    $this->logger->info("Inbound from {$from}: \"{$body}\"");

    $messageResponse = new MessagingResponse();
    $message         = $messageResponse->message('');

    if (mb_stripos($body, "send my promo code") !== false) {
        $message->body('Your promo code is FALL25. It expires Sunday at midnight.');
    } elseif (mb_stripos($body, "unsubscribe") !== false) {
        $message->body('You have been unsubscribed. Reply START to opt back in.');
        // Process the unsubscription, e.g.
    } else {
        $message->body('Thanks for the message! A team member will get back to you shortly');
        // Process the incoming message
    }

    $response = $response->withHeader('Content-Type', 'application/xml');
    $response->getBody()->write($messageResponse->asXML());

    return $response;
}

Finally, add the following use statements to the top of the file.

use Twilio\TwiML\MessagingResponse;

use function array_key_exists;
use function mb_stripos;
use function trim;

When a user taps a quick reply button, Twilio sends the button’s title as the Body of an inbound message webhook. This handler inspects the body and responds with the appropriate follow-up message using TwiML.

Step 7: Expose your server with ngrok

Twilio needs a public URL to call. Open a new terminal window and run:

ngrok http 8080

ngrok will print a forwarding URL that looks like https://abcd-1234.ngrok-free.app.

 Terminal output from ngrok showing the public forwarding URL pointing to localhost port 3000.
 Terminal output from ngrok showing the public forwarding URL pointing to localhost port 3000.

Copy the https:// forwarding URL and update the STATUS_CALLBACK_URL line in your .env file so it ends with /status:

Copy code

STATUS_CALLBACK_URL=https://abcd-1234.ngrok-free.app/status

Save the file. Your campaign script will now include this URL as the statusCallback parameter on every message.

Step 8: Wire up the webhooks in the Twilio Console

Now, you need to tell Twilio where to send delivery statuses and inbound messages:

  • In the Twilio Console, navigate to your RCS sender you created: Products & Services > Numbers & Senders > Overview > RCS and open your RCS Sender.
  • Click Configuration from the top.
  • In the Status callback URL field, paste your ngrok URL followed by /status (for example, https://abcd-1234.ngrok-free.app/status).
  • Click Save.
Screenshot of the Twilio Messaging Service Integration page with the Send a webhook option selected and the incoming webhook URL filled in.
Screenshot of the Twilio Messaging Service Integration page with the Send a webhook option selected and the incoming webhook URL filled in.

Click Save configuration.

Next, for incoming messages navigate to messaging services from your Twilio Console: Products & Services > Messaging > Services.

Then, click on your messaging service that is hooked up to your RCS sender. Navigate to the Settings tab.

Twilio account settings page for configuring how the messaging service handles inbound messages.

Under Inbound messages, click the Send a webhook. Scroll further and under Request URL paste your ngrok URL followed by /incoming (for example, https://abcd-1234.ngrok-free.app/incoming). Select HTTP POST for Method.

Screenshot of Twilio settings page showing messaging service URLs and method details.

Scroll down and click Save.

Test the Campaign

You now have the server running, ngrok forwarding traffic, and the webhook wired up. Open a third terminal window (leave the PHP application and ngrok running in their own windows) and send the campaign:

php send-campaign.php

Switch to the terminal tab running the PHP application. Within a few seconds, you’ll see status callbacks streaming in as each message moves through the delivery pipeline.

Check your test device. You should see a rich RCS card with your hero image, the personalized greeting, the subtitle, the body, and three buttons.

Smartphone displaying Twilio Dev text about a sale with an owl image and options to shop, get code, or unsubscribe.

Tap Send my promo code. In the server terminal, you’ll see:

Inbound from +15551234567: "Send my promo code"

And on the phone, you’ll receive the promo code follow-up message. Tap Unsubscribe to see the opt-out response, or send any freeform text to trigger the fallback reply.

Troubleshooting

If something doesn’t behave as expected, work through these common causes:

  • Messages are delivered as SMS instead of RCS. The recipient’s device may not support RCS, or the sender may still be pending approval. Confirm the device is a registered tester on your RCS Sender.
  • failed status with error code 30001 or 63001. Your Messaging Service does not have a sender that can reach the recipient. Verify the RCS Sender is attached to the Messaging Service and the phone number is in E.164 format.
  • Status callbacks never arrive. Confirm that STATUS_CALLBACK_URL in your .env file matches the current ngrok URL (ngrok assigns a new URL every time you restart it) and that the server is running.
  • Inbound webhook not firing. Re-check the webhook URL saved in your Messaging Service’s Integration tab, and make sure the ngrok tunnel is still active.

What’s next?

You’ve built an RCS Business Messaging campaign in PHP: a rich Content Template, a personalized broadcast, live delivery tracking, and interactive reply handling. Here are some ways to take it further:

  • Swap the recipient JSON file for a real audience source like Twilio Segment or your own database.
  • Add a carousel template so a single message can showcase multiple products.
  • Persist status events to a database and build a small dashboard on top of the running totals for real reporting.
  • Secure webhooks with Twilio’s webhook request validation so your /status and /incoming endpoints only accept traffic that originated at Twilio.

For deeper reference material, check out the RCS Business Messaging documentation and the Content API resources.

Matthew Setter is a PHP, Go, and Rust Editor in the Twilio Voices team. He’s also the author of Mezzio Essentials and Deploy with Docker Compose. You can find him at msetter@twilio.com. He's also on LinkedIn and GitHub.