Skip to content
All articles
6 min read

Building Automated Reminder Calls with Ruby on Rails, Twilio, and Amazon Polly

  • software-engineering
  • automation
  • amazon-web-services
  • ruby-on-rails
  • twilio

How to turn a scheduled database record into a natural-sounding phone call — in about 100 lines of Rails code

Missed appointments cost businesses real money. A dentist’s office loses a chair-hour. A salon loses a slot it could have rebooked. A loan servicer misses a payment reminder that could have prevented a late fee. The fix isn’t complicated: call people before the thing happens, using a voice that doesn’t sound like a robot from 2004.

In this article, I’ll walk through building that system in Rails, using Amazon Polly for natural neural text-to-speech and Twilio to actually place the call. By the end you’ll have a Reminder model, a background job, and a TwiML endpoint that together can call anyone on a schedule and speak a custom message to them.

Why Polly instead of Twilio’s built-in <Say>?

Twilio can already do text-to-speech with the <Say> verb, and it even lets you pick a Polly voice directly (voice: "Polly.Joanna") without touching AWS at all. That's the fastest path if you just want a voice.

The reason to bring Polly into your own stack directly is control:

  • SSML — fine-grained pauses, emphasis, and pronunciation (handy for names, dosages, dollar amounts)
  • Caching — synthesize once, store the MP3, reuse it for every recipient of an identical templated message instead of paying to regenerate it
  • Consistency — if the rest of your infrastructure is already on AWS, keeping audio generation there too simplifies your ops story

If you don’t need any of that, skip straight to <Say voice="Polly.Matthew"> in your TwiML and save yourself a step. This article assumes you want the extra control.

Architecture

Reminder record (Rails/Postgres)
│
▼
Sidekiq job runs on schedule
│
▼
Amazon Polly synthesizes speech ──► uploads MP3 to S3
│
▼
Twilio Voice API places the call, pointed at your TwiML endpoint
│
▼
Recipient's phone rings, plays the message
│
▼
Twilio posts call status back to your app (answered / no-answer / failed)

1. Setup

Add the gems:

# Gemfile
gem "twilio-ruby"
gem "aws-sdk-polly"
gem "aws-sdk-s3"
gem "sidekiq"
bundle install

Add credentials to config/credentials.yml.enc (via rails credentials:edit):

twilio:
account_sid: ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
auth_token: your_auth_token
from_number: "+1XXXXXXXXXX"
aws:
access_key_id: your_access_key
secret_access_key: your_secret_key
region: us-east-1
bucket: your-reminder-audio-bucket

2. The Reminder model

# db/migrate/XXXX_create_reminders.rb
class CreateReminders < ActiveRecord::Migration[7.1]
def change
create_table :reminders do |t|
t.string :phone_number, null: false
t.text :message, null: false
t.datetime :send_at, null: false
t.string :status, default: "pending" # pending, sent, answered, no_answer, failed
t.string :audio_url
t.string :twilio_call_sid
t.timestamps
end
add_index :reminders, [:status, :send_at]
end
end
# app/models/reminder.rb
class Reminder < ApplicationRecord
validates :phone_number, :message, :send_at, presence: true
scope :due, -> { where(status: "pending").where("send_at <= ?", Time.current) }
end

3. Generating speech with Amazon Polly

# app/services/polly_speech_generator.rb
class PollySpeechGenerator
def initialize
creds = Rails.application.credentials.aws
@polly = Aws::Polly::Client.new(
region: creds[:region],
access_key_id: creds[:access_key_id],
secret_access_key: creds[:secret_access_key]
)
@s3 = Aws::S3::Client.new(
region: creds[:region],
access_key_id: creds[:access_key_id],
secret_access_key: creds[:secret_access_key]
)
@bucket = creds[:bucket]
end

# Returns a presigned S3 URL Twilio can fetch to play the audio
def synthesize_and_upload(text:, key:, voice_id: "Joanna", ssml: false)
resp = @polly.synthesize_speech(
text: text,
text_type: ssml ? "ssml" : "text",
output_format: "mp3",
voice_id: voice_id,
engine: "neural"
)
@s3.put_object(
bucket: @bucket,
key: key,
body: resp.audio_stream.read,
content_type: "audio/mpeg"
)
presigner = Aws::S3::Presigner.new(client: @s3)
presigner.presigned_url(:get_object, bucket: @bucket, key: key, expires_in: 600)
end
end

A presigned URL (rather than a public bucket) means the audio file is only reachable for a short window — plenty of time for Twilio to fetch it, but not indefinitely exposed. This matters if your reminders contain anything even mildly sensitive (appointment details, balances, etc.).

Using SSML for pacing

ssml = <<~SSML
<speak>
Hi, this is a reminder from Riverside Dental.
<break time="500ms"/>
Your appointment is tomorrow at <emphasis level="strong">3 PM</emphasis>.
Please arrive 10 minutes early.
</speak>
SSML
generator = PollySpeechGenerator.new
url = generator.synthesize_and_upload(text: ssml, key: "reminders/#{SecureRandom.uuid}.mp3", ssml: true)

4. Placing the call with Twilio

# app/services/reminder_caller.rb
class ReminderCaller
def initialize
creds = Rails.application.credentials.twilio
@client = Twilio::REST::Client.new(creds[:account_sid], creds[:auth_token])
@from_number = creds[:from_number]
end

def call(reminder)
call = @client.calls.create(
to: reminder.phone_number,
from: @from_number,
url: Rails.application.routes.url_helpers.twiml_reminder_url(
reminder, host: default_host
),
status_callback: Rails.application.routes.url_helpers.reminder_status_callback_url(
reminder, host: default_host
),
status_callback_event: ["completed", "no-answer", "busy", "failed"]
)
reminder.update!(status: "sent", twilio_call_sid: call.sid)
end

private

def default_host
Rails.application.config.action_mailer.default_url_options[:host]
end
end

Rather than passing raw TwiML inline, we point Twilio at a route in our own app (twiml_reminder_url). That way you can add logic — like "press 1 to confirm" — without redeploying every time you send a reminder.

5. The TwiML controller

# config/routes.rb
Rails.application.routes.draw do
resources :reminders, only: [] do
member do
get :twiml, to: "reminders#twiml", as: :twiml
post :confirm, to: "reminders#confirm", as: :confirm
end
end
post "reminders/:id/status_callback", to: "reminders#status_callback", as: :reminder_status_callback
end
# app/controllers/reminders_controller.rb
class RemindersController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :set_reminder

def twiml
render xml: twiml_response, content_type: "text/xml"
end

def confirm
digit = params["Digits"]
case digit
when "1"
@reminder.update!(status: "confirmed")
render xml: say("Thanks, your appointment is confirmed. Goodbye.")
when "2"
@reminder.update!(status: "reschedule_requested")
render xml: say("Okay, someone will call you to reschedule. Goodbye.")
else
render xml: say("We didn't get a valid response. Goodbye.")
end
end

def status_callback
@reminder.update!(status: params["CallStatus"]) # completed, no-answer, busy, failed
head :no_content
end

private

def set_reminder
@reminder = Reminder.find(params[:id])
end

def twiml_response
Twilio::TwiML::VoiceResponse.new do |r|
r.gather(num_digits: 1, action: confirm_reminder_url(@reminder), method: "POST", timeout: 8) do |g|
g.play(url: @reminder.audio_url)
g.say(voice: "Polly.Joanna", message: "Press 1 to confirm, or press 2 to reschedule.")
end
r.say(voice: "Polly.Joanna", message: "We didn't receive a response. Goodbye.")
end.to_s
end

def say(message)
Twilio::TwiML::VoiceResponse.new { |r| r.say(voice: "Polly.Joanna", message: message) }.to_s
end
end

Note the fallback <Say voice="Polly.Joanna"> for the short prompts — no need to round-trip through your own Polly service for a one-off line like "press 1 to confirm."

6. Tying it together with a background job

# app/jobs/send_reminder_job.rb
class SendReminderJob
include Sidekiq::Job

def perform(reminder_id)
reminder = Reminder.find(reminder_id)
return unless reminder.status == "pending"
generator = PollySpeechGenerator.new
key = "reminders/#{reminder.id}-#{SecureRandom.uuid}.mp3"
audio_url = generator.synthesize_and_upload(text: reminder.message, key: key)
reminder.update!(audio_url: audio_url)
ReminderCaller.new.call(reminder)
end

end

7. Scheduling reminders

Use sidekiq-cron (or sidekiq-scheduler) to poll for due reminders every minute:

# config/schedule.yml
dispatch_due_reminders:
cron: "* * * * *"
class: "DispatchDueRemindersJob"
# app/jobs/dispatch_due_reminders_job.rb
class DispatchDueRemindersJob
include Sidekiq::Job

def perform
Reminder.due.find_each do |reminder|
SendReminderJob.perform_async(reminder.id)
end
end

end

This keeps the “what’s due right now” logic in your database, and the actual calling logic in a separate job — so a burst of 500 due reminders doesn’t block anything else in your queue.

8. A few things worth knowing before you ship this

  • Consent and compliance. Automated calls are regulated (TCPA in the U.S., similar rules elsewhere). Only call people who’ve opted in, and give them an easy way to opt out — e.g., “press 9 to stop future reminders” wired into the confirm action.
  • Time zones. Store send_at in UTC and convert from the recipient's local time zone when a reminder is created — don't leave this to Polly or Twilio, neither knows the recipient's locale.
  • Cost control. Polly bills per character for neural voices; cache and reuse audio for templated messages (e.g., “Your appointment is at {time}” as a single SSML template with the time swapped in) rather than regenerating full audio for every recipient.
  • Retries. If status_callback reports no-answer or busy, requeue the job with a delay (SendReminderJob.perform_in(30.minutes, reminder.id)) rather than silently giving up.

That’s the whole loop: a Rails model holding what to say and when, Polly turning it into speech, S3 hosting it briefly, and Twilio dialing the phone. From here, natural extensions are SMS fallback for calls that go unanswered, a small admin dashboard showing reminder status in real time, and multi-locale support by picking a Polly voice based on the recipient’s language.

If you build this, I’d love to hear what you use it for — appointment reminders, bill due dates, medication schedules, or something else entirely.

Also published on Medium , where you can comment and clap.

Need help with your app?

I build and upgrade Rails and React apps for teams in Canada, the US and Europe.

More articles