← Back to blog

Send Email with Perl using the Rovela API

Learn how to send transactional email from Perl using HTTP::Tiny, LWP::UserAgent or Mojo::UserAgent, with retry logic and idempotency keys, concurrent batch sending via Parallel::ForkManager, and webhook verification for Mojolicious and plain PSGI with constant-time HMAC comparison.

Updated November 3, 2026
Send Email with Perl using the Rovela API

Perl still runs a lot of the world's mail plumbing, and sending transactional email from it takes only a handful of modules. This guide covers HTTP::Tiny, LWP::UserAgent and Mojo::UserAgent, retry logic with idempotency keys, concurrent batch sending with Parallel::ForkManager, and webhook verification for Mojolicious and plain PSGI.

Prerequisites

Set your API key as an environment variable:

export ROVELA_API_KEY="re_..."

HTTP::Tiny, JSON::PP and Digest::SHA ship with Perl. Install the rest with cpanm:

cpanm LWP::UserAgent Mojolicious Parallel::ForkManager Plack

Send with HTTP::Tiny

HTTP::Tiny is core since Perl 5.14, so this example needs no CPAN at all:

use strict;
use warnings;
use HTTP::Tiny;
use JSON::PP;

my $http = HTTP::Tiny->new(timeout => 10);

my $res = $http->post('https://api.rovela.dev/emails', {
    headers => {
        'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
        'Content-Type'  => 'application/json',
    },
    content => JSON::PP::encode_json({
        from    => 'Acme <noreply@yourdomain.com>',
        to      => 'user@example.com',
        subject => 'Welcome aboard',
        html    => '<p>Thanks for signing up!</p>',
    }),
});

die "HTTP $res->{status}: $res->{content}\n" unless $res->{success};

my $body = JSON::PP::decode_json($res->{content});
print $body->{id}, "\n";

$res->{headers} is lowercased by HTTP::Tiny, which matters when you read retry-after further down.

Send with LWP::UserAgent

LWP::UserAgent is still the most widely deployed HTTP client in Perl:

use strict;
use warnings;
use LWP::UserAgent;
use HTTP::Request::Common qw(POST);
use JSON::PP;

my $ua = LWP::UserAgent->new(timeout => 10);

my $req = POST 'https://api.rovela.dev/emails',
    'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
    'Content-Type'  => 'application/json',
    Content         => JSON::PP::encode_json({
        from    => 'Acme <noreply@yourdomain.com>',
        to      => 'user@example.com',
        subject => 'Welcome aboard',
        html    => '<p>Thanks for signing up!</p>',
    });

my $res = $ua->request($req);
die $res->status_line unless $res->is_success;

print JSON::PP::decode_json($res->content)->{id}, "\n";

Send with Mojo::UserAgent

If you already run Mojolicious, its user agent handles the JSON encoding for you:

use strict;
use warnings;
use Mojo::UserAgent;

my $ua = Mojo::UserAgent->new;

my $tx = $ua->post('https://api.rovela.dev/emails' => {
    'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
} => json => {
    from    => 'Acme <noreply@yourdomain.com>',
    to      => 'user@example.com',
    subject => 'Welcome aboard',
    html    => '<p>Thanks for signing up!</p>',
});

die $tx->res->message unless $tx->res->is_success;

print $tx->res->json->{id}, "\n";

Retry with idempotency key

Generate the key once before the loop and reuse it on every attempt. This prevents duplicate delivery when the connection drops after the server has already accepted the request. Only 429 is retried; every other status is returned to the caller.

use strict;
use warnings;
use HTTP::Tiny;
use JSON::PP;
use Time::HiRes qw(sleep);

sub idempotency_key {
    open my $fh, '<:raw', '/dev/urandom' or die "urandom: $!";
    my $buf;
    read($fh, $buf, 16) == 16 or die "short read on /dev/urandom";
    close $fh;
    return unpack 'H*', $buf;    # 32 hex characters
}

my $MAX_RETRIES = 4;
my $http = HTTP::Tiny->new(timeout => 10);
my $payload = JSON::PP::encode_json({
    from    => 'Acme <noreply@yourdomain.com>',
    to      => 'user@example.com',
    subject => 'Welcome aboard',
    html    => '<p>Thanks for signing up!</p>',
});

my $key = idempotency_key();     # generated once, reused on every retry
my $body;

for my $attempt (0 .. $MAX_RETRIES - 1) {
    my $res = $http->post('https://api.rovela.dev/emails', {
        headers => {
            'Authorization'   => 'Bearer ' . $ENV{ROVELA_API_KEY},
            'Content-Type'    => 'application/json',
            'Idempotency-Key' => $key,
        },
        content => $payload,
    });

    if ($res->{status} == 429) {
        sleep $res->{headers}{'retry-after'} // (2 ** $attempt);
        next;
    }

    die "HTTP $res->{status}: $res->{content}\n" unless $res->{success};

    $body = JSON::PP::decode_json($res->{content});
    last;
}

die "rate limit exceeded after $MAX_RETRIES attempts\n" unless $body;

print $body->{id}, "\n";

On Windows, /dev/urandom does not exist — use Crypt::URandom::random_bytes(16) from CPAN instead.

Batch sending with Parallel::ForkManager

Parallel::ForkManager caps how many children run at once. Forked children cannot return values to the parent, so each child reports its own result and exits with a status code:

use strict;
use warnings;
use HTTP::Tiny;
use JSON::PP;
use Parallel::ForkManager;

sub send_email {
    my %r = @_;
    my $http = HTTP::Tiny->new(timeout => 10);
    my $res  = $http->post('https://api.rovela.dev/emails', {
        headers => {
            'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
            'Content-Type'  => 'application/json',
        },
        content => JSON::PP::encode_json({
            from    => 'Acme <noreply@yourdomain.com>',
            to      => $r{to},
            subject => $r{subject},
            html    => $r{html},
        }),
    });
    die "HTTP $res->{status}: $res->{content}\n" unless $res->{success};
    return JSON::PP::decode_json($res->{content});
}

my @recipients = (
    { to => 'ada@example.com',   subject => 'Hello Ada',   html => '<p>Hi Ada</p>' },
    { to => 'grace@example.com', subject => 'Hello Grace', html => '<p>Hi Grace</p>' },
);

my $pm = Parallel::ForkManager->new(5);    # at most 5 concurrent requests

$pm->run_on_finish(sub {
    my ($pid, $exit_code, $ident) = @_;
    printf "%s: %s\n", $ident, $exit_code == 0 ? 'sent' : 'failed';
});

for my $r (@recipients) {
    $pm->start($r->{to}) and next;         # parent: enqueue and continue

    my $ok = eval { send_email(%$r); 1 };  # child: do the work
    warn $@ unless $ok;
    $pm->finish($ok ? 0 : 1);              # child: exit
}

$pm->wait_all_children;

start returns 0 in the child, which is why and next sends only the parent back to the loop.

Verify webhooks in Mojolicious

Rovela signs every event with HMAC-SHA256. The signature arrives in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. Compute HMAC-SHA256 over <timestamp>.<raw-body> using your webhook secret and compare in constant time.

use Mojolicious::Lite;
use Digest::SHA qw(hmac_sha256_hex);
use Mojo::JSON qw(decode_json);
use Mojo::Util qw(secure_compare);

post '/webhooks/rovela' => sub {
    my $c = shift;

    my $secret = $ENV{ROVELA_WEBHOOK_SECRET};
    my $raw    = $c->req->body->slurp;                                  # bytes, not parsed JSON
    my $header = $c->req->headers->header('x-resend-signature') // '';

    my %parts = map { split /=/, $_, 2 } grep { /=/ } split /,/, $header;
    return $c->render(status => 400, text => 'missing signature fields')
        unless defined $parts{t} && defined $parts{v1};

    my $expected = hmac_sha256_hex("$parts{t}.$raw", $secret);
    return $c->render(status => 400, text => 'invalid signature')
        unless secure_compare($expected, $parts{v1});

    my $event = decode_json($raw);
    if ($event->{type} eq 'email.delivered') {
        # mark delivered
    }
    elsif ($event->{type} eq 'email.bounced') {
        # flag the address
    }

    $c->render(text => 'ok');
};

app->start;

secure_compare from Mojo::Util compares every byte and returns false on a length mismatch, so the comparison time does not leak how much of the signature matched.

Verify webhooks in plain PSGI

Without a framework, Plack::Request gives you the raw body and the header. The constant-time comparison is short enough to write yourself:

use strict;
use warnings;
use Plack::Request;
use Digest::SHA qw(hmac_sha256_hex);

sub constant_time_eq {
    my ($a, $b) = @_;
    return 0 if length($a) != length($b);
    my $diff = 0;
    $diff |= ord(substr($a, $_, 1)) ^ ord(substr($b, $_, 1))
        for 0 .. length($a) - 1;
    return $diff == 0;
}

my $app = sub {
    my $env = shift;
    my $req = Plack::Request->new($env);

    my $secret = $ENV{ROVELA_WEBHOOK_SECRET};
    my $raw    = $req->raw_body;
    my $header = $req->header('x-resend-signature') // '';

    my %parts = map { split /=/, $_, 2 } grep { /=/ } split /,/, $header;
    return [400, [], ['missing signature fields']]
        unless defined $parts{t} && defined $parts{v1};

    my $expected = hmac_sha256_hex("$parts{t}.$raw", $secret);
    return [400, [], ['invalid signature']]
        unless constant_time_eq($expected, $parts{v1});

    # parse $raw and handle the event type
    return [200, [], ['ok']];
};

Read the body before decoding it. hmac_sha256_hex must run over the exact bytes the sender signed — re-encoding a parsed structure will almost never reproduce them.