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 it is not set, the
endpoint refuses every request with 503.
The secret belongs to whoever operates the deployment and only reaches that
operator's own projects (the organizations in PHAROS_PLATFORM_SES_ORGS);
any other workspace gets 403. From outside, light the same beacon with
PATCH /api/contacts/:id and a project key — it fires tag_added the same
way. See Automations API.
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.