A survey platform holds the roster, a survey link per participant, date fields, and a completion flag on every form. Messaging APIs handle SMS and email. TEMPO connects the two: it reads the survey platform, works out which participants are due a message and when, sends it, and logs the result.
Once the schedule is declared, it runs unattended. This covers reminders due on a given day and EMA prompts that have to fire at an exact minute, 25 times across a sampling period.
Each message is declared once. The pipeline expands the declarations against the current export into dated rows. Senders drain that queue on a clock.
The build here uses REDCap, OpenPhone and Gmail SMTP. Swapping any of them means reimplementing a few calls — Qualtrics, Twilio and SES expose the same primitives.
| Integration point | What TEMPO needs from it |
|---|---|
| Survey platform | A record export, date fields, per-form completion flags, and a per-participant survey link |
| SMS provider | A send endpoint. Optionally a message-history endpoint, used to arbitrate against duplicates |
| SMTP credentials, or any HTTP send API | |
| Scheduler | Anything that can invoke a URL on a schedule |
Field names below are placeholders for your own event and instrument names.
One entry: when it is owed, when it fires, where it goes, what it says.
{
instrument: "Follow-up reminder",
// Owed only while the form is still incomplete.
condition: "[followup_arm_1][followup_1_complete]<>2",
// 14 days after the baseline date, then every 7 days, at most 3 times.
sendDateSpec: "14 days after [baseline_arm_1][baseline_date] " +
"repeat every 7 days up to 3 times",
// A phone field implies SMS, an email field implies email.
destinationSpec: "[baseline_arm_1][participant_phone]\t[baseline_arm_1][participant_email]",
// [event][field] fills per participant; [survey-link:form] becomes
// that participant's own URL, resolved at send time.
message: "Hi [baseline_arm_1][first_name], your follow-up is open: " +
"[followup_arm_1][survey-link:followup_1]",
}
Resolved per participant against their own dates. Repeats stop when the completion flag flips, so responders are not chased.
const anchor = participant.fields["baseline_date"]; // "2026-03-04"
const { offsetDays, everyDays, maxTimes } = parseSpec(alert.sendDateSpec);
const sends = [];
for (let n = 0; n < maxTimes; n++) {
if (isComplete(participant, alert.condition)) break; // responded, stop
sends.push(atStudyTime(addDays(anchor, offsetDays + n * everyDays), "17:00"));
}
// → 2026-03-18T17:00, 2026-03-25T17:00, 2026-04-01T17:00
await fetch("https://api.openphone.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: API_KEY },
body: JSON.stringify({ content: body, from: FROM_NUMBER, to: [e164] }),
signal: AbortSignal.timeout(30_000),
});
const mailer = nodemailer.createTransport({
host: "smtp.gmail.com", port: 587, secure: false,
pool: true, auth: { user: SMTP_USER, pass: SMTP_PASS },
});
await mailer.sendMail({ from, to: recipient, subject, text: body });
Sending cannot be undone, so at-most-once is enforced three separate ways.
| Mechanism | What it catches |
|---|---|
Append-only ledgerpid|alert|time|channel|recipient | The ordinary case: a rerun, a retry, an overlapping schedule |
| Claim before transmit | Two runs hitting the same slot; only one wins the claim |
| Provider history check | A send that happened but whose ledger write was lost |
Other guards: quiet hours, an opt-out list checked at queue time and again at send time, a per-run cap, no send if the survey link did not resolve, dry-run by default, and a kill switch that takes effect without a redeploy.
EMA prompts ask what someone is doing right now, so they have to arrive at the minute they were scheduled for. Late is not useful.
A sampling grid is a set of fixed weekday and clock-time slots. It gets anchored to each participant's start date and expanded into dated rows, each carrying their phone number and survey link.
// A sampling grid: slots repeat across the period, anchored per participant.
const GRID = [
{ day: "Mon", at: "08:10" }, { day: "Mon", at: "13:45" }, { day: "Mon", at: "19:20" },
// … 25 slots across the sampling period
];
for (const slot of GRID) {
schedule.push({
pid: participant.id,
sendAt: atStudyTime(nextWeekday(startDay, slot.day), slot.at),
phone: participant.fields["prompt_phone"],
surveyLink: await resolveLink(participant, slot.form),
});
}
Schedulers are not punctual, so the sender does not rely on being invoked at the right moment. It runs on a coarse clock, picks up any slot inside its look-ahead window, and waits in-process until the exact second. It claims the slot before transmitting, so two overlapping runs cannot both send. Past the grace window a prompt is recorded as skipped rather than sent late.
// Wait out the remainder in-process, then claim and fire.
const waitMs = slot.sendAt - Date.now();
if (waitMs > 0) await sleep(waitMs);
if (await claim(slot.key)) { // lost the race? another run has it
await sendSMS(slot.phone, render(slot));
await ledger.append({ ...slot, status: "sent", latencySec: 2 });
}
Measured latency in production is 2–4 seconds past the scheduled second. It is written to the ledger per message.
The dashboard reads the same files the senders write. No second database. Screens below run on synthetic data.
Access is a shared password behind a signed cookie, checked in middleware on every route. A daily job reconciles what was scheduled against what the ledger recorded and flags anything missing.