NAME
Twilio::Manual - How to send messages, make calls and answer them with the Twilio library for Perl
DESCRIPTION
This guide follows Twilio's own quickstarts for messaging and voice, in Perl. It shows how to send an SMS, receive and reply to one, make a call, and answer one, and then the rest of what you need day to day: lists, errors, other Twilio products, and how to turn any example from Twilio's documentation into Perl.
Every example here is run by the distribution's tests, against a fake HTTP client, so what you read is what the library does.
INSTALLATION
cpanm Twilio
It needs Perl 5.16 or later, and modules that most installations already have, such as HTTP::Tiny and IO::Socket::SSL. Two modules are optional: Cpanel::JSON::XS makes JSON faster, and Crypt::OpenSSL::RSA is needed only for "Public Key Client Validation".
CREDENTIALS
Every request needs your Account SID and a secret. Find the Account SID and the Auth Token in the Twilio Console.
Keep them out of your code. With no arguments, the client reads them from the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN environment variables:
use Twilio;
my $client = Twilio->new;
You can also pass them, from wherever you keep them:
my $client = Twilio->new($account_sid, $auth_token);
For applications, Twilio recommends an API key instead of the Auth Token: you can limit what a key may do, and revoke it without changing the token. Create one in the Console, and give the account with account_sid:
my $client = Twilio->new($api_key_sid, $api_key_secret, { account_sid => $account_sid });
Twilio->new returns a Twilio::Rest::Client, and takes the same arguments as Twilio::Rest::Client->new.
SEND AN SMS
my $message = $client->messages->create(
'+15558675310',
{
from => '+15017122661',
body => q{Join Earth's mightiest heroes. Like Kevin Bacon.},
}
);
print $message->sid, "\n";
The first value is the recipient, in E.164 form. from is one of your Twilio numbers. A trial account can only send to numbers you have verified.
The message is queued, not yet delivered. Its status changes as it goes, and you can fetch it again, or have Twilio call a URL of yours at each change:
my $message = $client->messages->create(
'+15558675310',
{
from => '+15017122661',
body => 'Hello',
status_callback => 'https://example.com/status',
}
);
my $latest = $client->messages($message->sid)->fetch;
print $latest->status, "\n"; # such as queued, sent, delivered or failed
To send a picture, add media_url with one or more public URLs. For WhatsApp, put whatsapp: before both numbers:
$client->messages->create(
'+15558675310',
{
from => '+15017122661',
body => 'An owl',
media_url => ['https://demo.twilio.com/owl.png'],
}
);
$client->messages->create('whatsapp:+15558675310',
{ from => 'whatsapp:+14155238886', body => 'Hello from WhatsApp' });
RECEIVE AND REPLY TO AN SMS
When someone texts your Twilio number, Twilio makes an HTTP request to a URL of yours, a webhook, with the message in form parameters such as From and Body. Your answer is TwiML, an XML document that tells Twilio what to do, here to reply. So receiving needs a small web application.
With Mojolicious::Lite, the closest thing in Perl to the Flask and Express apps of Twilio's quickstarts, a whole application is one file:
#!/usr/bin/env perl
use Mojolicious::Lite;
use Twilio::TwiML::MessagingResponse;
post '/reply_sms' => sub {
my ($c) = @_;
my $response = Twilio::TwiML::MessagingResponse->new;
$response->message('The Robots are coming! Head for the hills!');
$c->res->headers->content_type('text/xml');
$c->render(data => "$response");
};
app->start;
Run it with perl app.pl daemon, and it listens on port 3000. Twilio needs a public URL, so in development use a tunnel such as ngrok: ngrok http 3000 gives you a public HTTPS URL that reaches your port 3000. In the Console, open your phone number, and under A message comes in set the webhook to that URL followed by /reply_sms, with HTTP POST. Then text your number.
Under a web server that runs CGI scripts, such as Apache, the same is a script like this one, with the CGI module:
#!/usr/bin/perl
use strict;
use warnings;
use CGI;
use Twilio::TwiML::MessagingResponse;
my $query = CGI->new;
my $from = $query->param('From');
my $body = $query->param('Body');
my $response = Twilio::TwiML::MessagingResponse->new;
$response->message("Thanks for your message, $from. You said: $body");
print $query->header(-type => 'text/xml', -charset => 'UTF-8'), $response;
A TwiML object writes itself as UTF-8 bytes, ready to send, so print it as it is, without an encoding layer on the handle.
To answer with no reply at all, return an empty MessagingResponse. See Twilio::TwiML::MessagingResponse and Twilio::TwiML::Messaging::Message for everything a reply can hold.
MAKE A CALL
A call needs a number to call, a Twilio number to call from, and instructions for when it is answered: a URL that returns TwiML, or the TwiML itself.
my $call = $client->calls->create('+15558675310', '+15017122661',
{ url => 'http://demo.twilio.com/docs/voice.xml' });
print $call->sid, "\n";
use Twilio::TwiML::VoiceResponse;
my $twiml = Twilio::TwiML::VoiceResponse->new;
$twiml->say('Ahoy, World!');
my $call = $client->calls->create('+15558675310', '+15017122661', { twiml => $twiml });
A call in progress can be changed, for example to hang it up:
$client->calls($call_sid)->update({ status => 'completed' });
ANSWER A CALL
Answering is the same as replying to a message: Twilio calls the voice webhook of your number, set in the Console under A call comes in, and your application returns TwiML. This one greets the caller and asks for a choice, and <Gather> sends the key pressed to a second URL:
#!/usr/bin/env perl
use Mojolicious::Lite;
use Twilio::TwiML::VoiceResponse;
post '/voice' => sub {
my ($c) = @_;
my $response = Twilio::TwiML::VoiceResponse->new;
my $gather = $response->gather({ num_digits => 1, action => '/gather' });
$gather->say('For sales, press 1. For support, press 2.');
$response->redirect('/voice'); # nothing was pressed: ask again
reply_with($c, $response);
};
post '/gather' => sub {
my ($c) = @_;
my %department = (1 => 'sales', 2 => 'support');
my $choice = $department{ $c->param('Digits') // q{} };
my $response = Twilio::TwiML::VoiceResponse->new;
if ($choice) {
$response->say("You chose $choice. Goodbye.");
}
else {
$response->say('Sorry, I did not understand that choice.');
$response->redirect('/voice');
}
reply_with($c, $response);
};
sub reply_with {
my ($c, $twiml) = @_;
$c->res->headers->content_type('text/xml');
return $c->render(data => "$twiml");
}
app->start;
Twilio::TwiML::VoiceResponse lists every verb, such as dial, play, record and enqueue, and each verb's module lists its attributes.
CHECK THAT A REQUEST COMES FROM TWILIO
Anyone who learns the URL of a webhook can call it. Twilio signs each request with your Auth Token, in the X-Twilio-Signature header, and Twilio::Security::RequestValidator checks it. The URL must be the one Twilio called, including any query string. Behind a proxy or a load balancer, build it from what Twilio sees, not from what your server sees.
In Mojolicious, check every request before its route runs:
#!/usr/bin/env perl
use Mojolicious::Lite;
use Twilio::Security::RequestValidator;
use Twilio::TwiML::MessagingResponse;
my $validator = Twilio::Security::RequestValidator->new($ENV{TWILIO_AUTH_TOKEN});
under sub {
my ($c) = @_;
my $signature = $c->req->headers->header('X-Twilio-Signature') // q{};
my $url = $c->req->url->to_abs->to_string;
return 1 if $validator->validate($signature, $url, $c->req->body_params->to_hash);
$c->render(text => 'Forbidden', status => 403);
return undef;
};
post '/reply_sms' => sub {
my ($c) = @_;
my $response = Twilio::TwiML::MessagingResponse->new;
$response->message('Checked, and from Twilio.');
$c->res->headers->content_type('text/xml');
$c->render(data => "$response");
};
app->start;
In a CGI script:
use CGI;
use Twilio::Security::RequestValidator;
my $query = CGI->new;
my $url = "https://$ENV{HTTP_HOST}$ENV{REQUEST_URI}";
my %params = map { $_ => [ $query->multi_param($_) ] } $query->param;
my $validator = Twilio::Security::RequestValidator->new($ENV{TWILIO_AUTH_TOKEN});
if (!$validator->validate($ENV{HTTP_X_TWILIO_SIGNATURE} // q{}, $url, \%params)) {
print $query->header(-status => '403 Forbidden');
exit;
}
multi_param keeps every value of a parameter sent more than once, and needs CGI 4.08 or later. A webhook that receives JSON is validated with its raw body in place of the parameters; see Twilio::Security::RequestValidator.
READ LISTS
read returns records as a list, fetching as many pages as it needs. Filters go in the first hash, then a limit, and optionally a page size:
for my $message ($client->messages->read({ to => '+15558675310' }, 20)) {
printf "%s %s %s\n", $message->date_sent // q{-}, $message->from, $message->body;
}
Without a limit, read fetches every record. For long lists, stream fetches one page at a time, as you go:
my $stream = $client->messages->stream({}, 500);
while (my $message = $stream->next) {
print $message->sid, "\n";
}
Dates come back as Time::Piece objects in UTC, and dates you send can be Time::Piece or DateTime objects, or ISO 8601 text. page fetches one page at a time, if you want to handle paging yourself; see Twilio::Page.
ERRORS
When Twilio answers with an error, the method dies with a Twilio::Exceptions::RestException, which has Twilio's error code, the HTTP status, and a link to the error's documentation:
use Scalar::Util qw(blessed);
my $message = eval { $client->messages->create('+15005550001', { from => $from, body => 'Hi' }) };
if (!$message) {
my $error = $@;
die $error if !(blessed($error) && $error->isa('Twilio::Exceptions::RestException'));
warn sprintf "Twilio error %d (HTTP %d): %s\nSee %s\n",
$error->get_code, $error->get_status_code, $error->get_message, $error->get_more_info;
}
Newer APIs describe errors in more detail, with Twilio::Exceptions::RestExceptionV1 or Twilio::Exceptions::TwilioServiceException, which also has the fields at fault. Both are a RestException, so the code above catches them too. Problems that are not Twilio's answer, such as missing credentials or a network failure, are other Twilio::Exceptions::TwilioException subclasses.
An exception turns into its message as text, so die and warn show something useful without any of this.
OTHER TWILIO PRODUCTS
Messages and calls belong to Twilio's main API, and the client has a shortcut to each resource of it. Every other product is reached through its name and version, the same path as in Twilio's documentation. Verify, for example:
my $verification = $client->verify->v2->services($verify_service_sid)
->verifications->create('+15558675310', 'sms');
# later, with the code the user typed
my $check = $client->verify->v2->services($verify_service_sid)
->verification_checks->create({ to => '+15558675310', code => $code });
print $check->status, "\n"; # approved
Each resource is documented in its own module, with every value it takes, in Twilio's words: $client->messages is Twilio::Rest::Api::V2010::Account::Message, and $client->verify->v2->services is Twilio::Rest::Verify::V2::Service. Twilio::Rest::Client lists the products.
TRANSLATING TWILIO'S EXAMPLES
Twilio's documentation has examples in several languages, and the library follows the official libraries closely, twilio-php most of all, so any of them can become Perl with a few rules.
- The client
-
new Client($sid, $token)in PHP andClient(account_sid, auth_token)in Python areTwilio->new($sid, $token). - The path to a resource
-
It is the same chain, with
->, and names in snake_case: PHP's$twilio->incomingPhoneNumbersand Python'sclient.incoming_phone_numbersare$client->incoming_phone_numbers. An id goes in the call that names the resource, as in$client->messages($sid). - Required values
-
They come first, as plain arguments, in the order twilio-php uses. The module of each resource lists them in that order under its method. Python passes them by name, so check the order there.
- Optional values
-
They go in one hash reference, with snake_case names: PHP's
["statusCallback" => $url]is{ status_callback => $url }, and Python'sfrom_is justfrom. - Values of a record
-
They are methods with snake_case names: PHP's
$message->dateCreatedand Python'smessage.date_createdare$message->date_created. - Kinds of values
-
A list is an array reference, a JSON object a hash reference, a boolean any Perl true or false value, and a date a Time::Piece or
DateTimeobject, or ISO 8601 text. - Lists
-
PHP's
read([], 20)is the same. Python'slist(limit=20)isread({}, 20), with any filters in that first hash. - Errors
-
tryandcatchbecomeeval, and the class to look for is Twilio::Exceptions::RestException. - TwiML
-
new VoiceResponse()isTwilio::TwiML::VoiceResponse->new, and attributes are a hash reference with snake_case names: PHP's$response->gather(['numDigits' => 1])is$response->gather({ num_digits => 1 }).
Here is the call from Twilio's voice quickstart, in PHP:
$call = $twilio->calls->create(
"+15558675310",
"+15017122661",
["url" => "http://demo.twilio.com/docs/voice.xml"]
);
print($call->sid);
and in Perl:
my $call = $twilio->calls->create(
'+15558675310',
'+15017122661',
{ url => 'http://demo.twilio.com/docs/voice.xml' }
);
print $call->sid, "\n";
And a message from the messaging quickstart, in Python:
message = client.messages.create(
body="Join Earth's mightiest heroes. Like Kevin Bacon.",
from_="+15017122661",
to="+15558675310",
)
print(message.body)
which in Perl takes to first, as twilio-php does:
my $message = $client->messages->create(
'+15558675310',
{
body => q{Join Earth's mightiest heroes. Like Kevin Bacon.},
from => '+15017122661',
}
);
print $message->body, "\n";
MORE
The status code and headers of an answer
with_metadata runs an operation and returns its result together with the status code and headers of Twilio's answer; see Twilio::Metadata.
my $answer = $client->messages->with_metadata(create => '+15558675310',
{ from => '+15017122661', body => 'Hello' });
print $answer->get_status_code, "\n"; # 201
print $answer->get_header('Twilio-Request-Id'), "\n";
my $message = $answer->get_data;
Regions and edges
To keep traffic in one of Twilio's regions, set both the region and the edge, with these options or the TWILIO_REGION and TWILIO_EDGE environment variables:
my $client = Twilio->new($account_sid, $auth_token, { region => 'au1', edge => 'sydney' });
Retries
Twilio answers HTTP 429 when there are too many requests. With max_retries, those requests are sent again, after the wait Twilio asks for. Other failures are never repeated, so a message is never sent twice. See "Retries" in Twilio::Base::BaseClient.
Seeing the requests
Setting the TWILIO_LOG_LEVEL environment variable to debug, or calling $client->set_log_level('debug'), prints each request and answer on STDERR, without the credentials.
OAuth
Twilio's OAuth client credentials work through Twilio::CredentialProvider::ClientCredentialProvider, given as the client's credential_provider, with the account in account_sid.
Public Key Client Validation
Accounts that enforce Public Key Client Validation, a setting in the Console, accept only requests signed with the account's private key. Twilio::Http::ValidationClient signs them. Use it only for such accounts: Twilio refuses signed requests to others.
Access tokens
Twilio's client SDKs, such as Voice, Video and Conversations in a browser or an app, connect with an access token that your server makes; see Twilio::Jwt::AccessToken.
Testing your code
Twilio::Http::FakeClient answers requests from a queue of responses and records what would have been sent, so your tests need no account and no network.
SEE ALSO
Twilio, Twilio::Rest::Client, Twilio::Base::BaseClient, and Twilio's documentation at https://www.twilio.com/docs.
LICENSE
Copyright (c) 2026 Francisco Zarabozo. Released under the MIT License.