Pharos Automations
Multi-step email sequences fired by per-project triggers and timed by QStash. No cron, no daemon — when a contact enters an automation, each step's delay becomes a delayed QStash message that POSTs back to Pharos when its time comes.
Concepts
Automation — a per-project sequence of email steps with a single trigger that decides when a contact gets enrolled.
Step — one email send, with an optional skipIfTag condition.
Steps run in order; each step's delayMinutes is the wait before it
fires (relative to the previous step completing).
Run — one row per (contact, automation) enrollment. It tracks the
current step pointer, when the next step fires, and a JSON history of
every step event. Re-enrollment is only allowed once a previous run
finishes (status sent or failed).
Triggers (v1)
| Trigger | Fires when |
|---|---|
new_subscriber |
A contact lands on any list (/api/subscribe). |
tag_added |
A tag is added to a contact (via PATCH or /api/contacts/event). Optional triggerConfig.tag filters to one tag; empty matches all. |
schedule and no_open exist in the schema but aren't wired in v1.
Step shape
| Field | Notes |
|---|---|
delayMinutes |
Wait before this step fires. 0 = immediate. Common: 0, 5, 60, 1440 (1d), 4320 (3d), 10080 (1w), 43200 (30d). |
templateId |
One of welcome, newsletter, announcement. Picks the React Email shell. |
subject / body |
English content. Body is plain text injected into the template. |
subjectPt / bodyPt |
Optional Brazilian Portuguese variants. Contacts with language = pt-BR get this; others fall back to EN. |
skipIfTag |
If the contact has this tag at processor time, skip the send (no email goes out) and move to the next step. |
Lighting beacons: skipIfTag and /api/contacts/event
Pharos doesn't know what "paid" or "onboarded" means in your domain. It checks tags. External systems (Stripe webhooks, your app's backend, Linear, anything) light a beacon by tagging contacts via:
POST /api/contacts/event?secret=<WEBHOOK_SECRET>
{
"projectId": "my-app",
"email": "user@example.com",
"tag": "paid",
"action": "add" // or "remove"; defaults to "add"
}
On add, Pharos also fires tag_added automations for that tag — so a
single webhook call can both suppress an in-flight chain step (via
skipIfTag) and enroll the contact in a different chain.
WEBHOOK_SECRET is shared with the SES webhook. If you didn't set it,
the endpoint is unguarded — only safe behind a private network.
Editing while runs are in flight
- Pharos stores
currentStepId, notcurrentOrder. So if you reorder steps, in-flight contacts stay on whichever step they were waiting for. - If you delete the step a contact was waiting on, that run ends gracefully (no email, no error).
- If you edit copy on a step that hasn't fired yet for some contacts, they get the new copy when their wait elapses. No snapshot.
- Toggling Paused stops in-flight runs at the next processor tick;
they get marked
skipped.
Example: Welcome drip
Three-step linear drip after subscribe.
Trigger: new_subscriber
Step 1 — Immediately
Template: welcome
Subject: "Welcome to My App 👋"
Body: "Quick start: log your first meal..."
Step 2 — 1 day later
Template: newsletter
Subject: "Day 1 — what to track"
Body: "Three things that help on day one..."
Step 3 — 3 days later
Template: announcement
Subject: "Already love it? Tell a friend"
Bilingual: each step has subjectPt / bodyPt filled. Contacts with
language=pt-BR get the Portuguese copy automatically.
Example: Payment nudge with skipIfTag
Subscribe → free trial. After 3 days, nudge if not paid. After 7 more, last call. As soon as they pay, both nudges stop firing.
Trigger: new_subscriber
Step 1 — Immediately
welcome / "Welcome to your trial"
Step 2 — 3 days later
newsletter / "How's it going so far?"
skipIfTag: paid
Step 3 — 4 days later
announcement / "Last chance to keep your data"
skipIfTag: paid
Then on checkout success:
curl -X POST 'https://pharos.d2vsolutions.com/api/contacts/event?secret=...' \
-H 'content-type: application/json' \
-d '{"projectId":"my-app","email":"user@x.com","tag":"paid"}'
Step 2's processor (or step 3's, whenever next fires) sees the paid
tag, marks itself skipped in the run history, schedules nothing. Done.
Example: Post-purchase onboarding
When someone is added with the paid tag (e.g. they bought right
away), enroll them in a different sequence than the trial users.
Trigger: tag_added
Trigger config: { "tag": "paid" }
Step 1 — Immediately
welcome / "Thanks for going pro!"
Step 2 — 1 day later
newsletter / "Three pro features worth trying first"
Step 3 — 7 days later
newsletter / "Save 20% on your annual upgrade"
skipIfTag: pro_annual
Same /api/contacts/event call adds paid; Pharos sees the new tag
and fires the matching automation. If they upgrade to annual, tag them
pro_annual and step 3 quietly skips.
Example: Tag-based segment broadcast
Tag a contact vip from anywhere; Pharos sends them a one-step
sequence that's effectively a personalized broadcast.
Trigger: tag_added
Trigger config: { "tag": "vip" }
Step 1 — Immediately
announcement / "You're on the VIP list — early access opens Friday"
Run history
Every automation's edit page shows recent runs at the bottom: contact email, status, current step (for pending), expandable step trace with timestamps. Filter by status. Failed runs surface the error inline.
Operational notes
- Failures: a step throws → the run is marked
failed, the chain stops, no auto-retry. Check the run history to see which step blew up and why. - Disabled automation: when a step's processor runs and finds the
automation disabled, it marks the run
skippedand stops. Re-enabling doesn't resume in-flight runs (they stay skipped); new triggers create fresh enrollments. - Quotas: QStash free tier covers ~500 messages/day. Each step = one message. With 50 users entering a 3-step drip on the same day that's 150 messages. Plenty of headroom.
- Contact deletion: deleting a contact does NOT cancel their in-flight runs. The processor will harmlessly fail to look them up next tick. Worth cleaning up later if it becomes noise.