Automations
Email templates and the template language, flows with eight actions, guardrails, recipes, the API and MCP tools, and an agent walkthrough.
Every write to your store is recorded as an event. A flow turns those events into work: an email from one of your own templates, a note or a tag on an order, a cancellation, a signed webhook, a call that wakes your agent, a pause. Flows run on the same log, the same topic-and-filter vocabulary and the same retry schedule as events, so you and your agent can automate a store without running a receiver of your own.
1. The model
A flow run is one delivery — the same kind of delivery a subscription gets (events). Formahand evaluates every enabled flow against every recorded event exactly as it evaluates a subscription (the topic patterns first, then the filter), applies your store's kill switch and the loop protection of §6, and queues at most one run per (flow, event) pair. Retries and their backoff (immediately, then 1 min, 5 min, 30 min, 2 h, 8 h), the pending → delivered | failed states, POST /api/deliveries/:id/retry, retention and the run log are the subscription machinery unchanged.
A run never holds up the write that caused it. Runs are queued while the write is being answered and started straight afterwards: a write that left runs due answers with X-Formahand-Deliveries-Due: <n> and Formahand picks them up at once rather than waiting. Anything still outstanding — a retry, a wait that has come due, a write made on the storefront itself such as a newsletter sign-up — is picked up again every ten minutes.
Email a flow produced is sent for you. A send_email step hands Formahand the rendered message and Formahand sends it as your store, then writes the outcome back into the run: the step is queued until the answer comes back, then done with its message ids, or failed with mail_failed. That is also why a flow that sends email needs a sending address it may use before it can be switched on (§18).
2. Email templates
Every email your store sends comes from a template you own. A template has a key (^[a-z0-9]+(-[a-z0-9]+)*$, 1–60 characters), a name, a subject, an HTML body and a text body. Subject and bodies are templates (§3); an empty text body is derived from the rendered HTML for you. variables is documentation — the declared inputs the dashboard's variables help shows, defaulted to the paths the template actually references when you create one without them. Every write validates the three template fields and answers 400 { error, field: "subject" | "bodyHtml" | "bodyText", position } on a syntax error.
System templates are seeded in every store, ready to edit (isSystem: true, and their key is fixed):
| Key | Written for | Used by |
|---|---|---|
order-confirmation | order.paid | Formahand's own order confirmation, sent once per order |
order-shipped | order.fulfilled with a tracking number | Formahand's shipping email: fulfil with "notify customer", set tracking, buy a label, MCP fulfill_order / set_order_tracking / buy_shipping_label; the recipe shipped-tracking-email |
order-delivered | order.delivered | Recipe delivered-thank-you |
order-refunded | order.refunded (data.amountRefundedMinor is the total refunded so far) | Recipe refund-apology-email |
payment-instructions | order.created of a manual-payment order; store.paymentInstructions is the wording you saved under Payments | Recipe unpaid-order-payment-instructions |
abandoned-cart | cart.abandoned (§12): { orderId, customerEmail, customerName, items: [{ productName, quantity, unitPriceMinor }], currency, subtotalMinor, checkoutUrl, unsubscribeUrl } | The seeded, paused flow "Abandoned checkout → email after 1 hour" (recipe abandoned-checkout-email) |
monthly-statement | Formahand's monthly statement to you (billing): { period, periodLabel, currency, totalMinor, paid, card, error, lines } | Sent on the 1st; editable per store like any system template |
low-stock-merchant | product.updated with changed contains "inventory" | Default flow low-stock-notify-merchant, on in every store (sent to merchant) |
new-order-merchant | order.paid | Default flow new-order-notify-merchant, on in every store (sent to merchant) |
return-requested | return.requested (orders); data is the return with its lines, reason, the buyer's note and the order it belongs to | Default flow return-requested-notify-merchant, on in every store (sent to merchant) |
return-approved | return.approved; data.returnLabel carries the instructions, the label link, the carrier and the number | Sent when you approve with "email the buyer" ticked; recipe return-approved-email-buyer |
return-declined | return.declined (data.merchantNote is the reason the buyer reads) | Sent when you decline |
return-refunded | return.refunded (data.refundMinor is the total refunded on that return) | Sent after a return refund |
order-edited | order.edited (orders); data is the order as it now stands plus editSummary (what changed, in words), editReason, previousTotalMinor, balanceDueMinor, refundedMinor and paymentUrl | Sent when an edit is applied with "email the customer" ticked; recipe order-edited-email-buyer |
order-payment-requested | order.payment_requested: an edit raised the total and data.balanceDueMinor is owed on data.paymentUrl | Sent with the edit that opened the balance |
The order-confirmation and order-shipped defaults reproduce the copy Formahand sent before templates existed — same wrapper, table, totals rows, address block and tracking button — so nothing a buyer sees changed until a merchant edited one. Formahand's own buyer emails render through your template: the order confirmation and the shipping email read your store's template for that key and fall back to the built-in default if it is missing or no longer renders.
Seeding is idempotent per key. A template you have edited keeps your edits when Formahand ships a new or improved default, and a custom template you deleted stays deleted. A brand-new store gets its system templates on first use.
Editing and reset. PUT on a system template keeps it a system template; a template counts as modified when its subject, bodies, fromName or replyTo differ from the default. POST …/reset restores the default subject, bodies and variables and clears fromName/replyTo (system templates only, 409 otherwise); DELETE refuses system templates (409). Every write records email_template.created|updated|deleted with the template view as data.
From name and reply-to. replyTo overrides your store's verified sender as the Reply-To header. fromName (1–100 characters; line breaks and "<> are stripped) replaces the display name on the message — the address itself is never the caller's to choose.
Preview. POST …/preview { eventId? } renders against that event or, without one, the built-in sample for the template's kind (order, product, customer, cart, statement or return; §4) → { subject, bodyHtml, bodyText, errors: [{ field, error, position }], event: { id, type, occurredAt } | null, sample, template: { id, key, fromName, replyTo } }. Buyer-controlled strings are escaped in bodyHtml.
3. The template language
A small language of Formahand's own: no eval, no arbitrary code, and the same implementation everywhere, so what a preview shows is exactly what a run sends. describe_flows returns the grammar, the helper list and the context below, so an agent can write a template without reading this page. A syntax error is reported with a 0-based position, never thrown at send time.
template := (text | tag)*
tag := "{{" expression "}}" output, HTML-escaped in html mode
| "{{{" path "}}}" unescaped only when path is in the raw allow-list, else escaped
| "{{!" comment "}}" dropped
| "{{#if" expression "}}" template ("{{else}}" template)? "{{/if}}"
| "{{#unless" expression "}}" template ("{{else}}" template)? "{{/unless}}"
| "{{#each" expression "}}" template ("{{else}}" template)? "{{/each}}"
expression := helper argument* | argument
argument := path | literal | "(" expression ")"
helper := money | date | upper | lower | default | link | join | length | eq | ne | gt | lt | gte | lte | not | and | or
literal := string | number | "true" | "false" | "null"
string := "..." or '...' with escapes \" \' \\ \n \t
number := integer or decimal, optionally negative (-3, 2.5)
path := root ("." segment | "[" integer "]")*
root := identifier | this | @index | @first | @last
segment := identifier | integer (data.items.0.sku == data.items[0].sku)
| Helper | Result |
|---|---|
money amountMinor currency | (amount/100).toFixed(2) + " " + CURRENCY, e.g. 34.70 USD; "" when the amount is not a number or the currency is missing |
date iso format? | UTC, deterministic: "long" (default) → 14 September 2026, "short" → 2026-09-14, "time" → 14 September 2026, 10:12 UTC; "" for invalid dates |
upper x / lower x | case change; null → "" |
default x fallback | x unless null, undefined or "" |
link url text? | html mode: an <a> for http(s) URLs (text defaults to the URL), inserted raw with URL and text escaped; other URLs render just the text; text mode: text (url) or the URL |
join list sep? | array joined by sep (default ", "); non-arrays → "" |
length x | array length, string length, or 0 |
eq a b, ne a b | structural (JSON-style deep) equality |
gt, lt, gte, lte a b | only for two numbers or two strings, otherwise false |
not x, and a b, or a b | booleans (empty arrays count as false) |
Semantics: whitespace inside tags is optional; null/undefined render as ""; numbers and booleans as text; arrays and objects as JSON. #if/#unless use JavaScript truthiness except that empty arrays are false. #each iterates arrays only (anything else takes the {{else}} branch); inside, bare paths resolve against the current item first, then enclosing items, then the root, and {{this}}, {{@index}} (0-based), {{@first}}, {{@last}} are available. A line holding only whitespace and one block tag or comment is removed entirely (the Mustache standalone rule), so text bodies stay tidy. Helpers are pure, take positional arguments and nest with parentheses.
Security and limits: lookups use own properties only; the segments __proto__, constructor and prototype always resolve to undefined; arrays accept only integer segments; {{ }} output is escaped (& < > " ') in html mode; {{{ }}} is unescaped only for a bare path on the raw allow-list, and nothing is on that list today, so it behaves like {{ }}; link is the only helper that emits markup. 65 536 source characters, 16 nested blocks, 10 000 loop iterations per render, 262 144 output characters. Errors carry a 0-based position: Unclosed block {{#if}} opened at position N, Unexpected {{/each}}, Unknown helper \x\, Template is too long (max 65536 characters), Block nesting is too deep (max 16). Subjects are rendered in text mode and collapsed to one line; HTML bodies in html mode; text bodies in text mode, then runs of three or more newlines become two.
4. The context
What a template — or any string field of an action — is rendered against:
store: { name, url, hostname, supportEmail, paymentInstructions }
-- url = https://<your primary hostname> ("" without one); supportEmail = your verified sender or "";
-- paymentInstructions = the payment instructions you saved
event: { id, type, occurredAt, actor, resource, resourceId }
data: the object after the write (the event's data), enriched for orders (below)
previous: { field: previous value } for the changed fields
changed: ["trackingStatus", …]
links: { store, tracking, carrierTracking, order, dashboard, unsubscribe }
-- tracking = the buyer tracking page, else the carrier's page; order = https://formahand.com/dashboard?order=<id>
-- (order events only); dashboard = https://formahand.com/dashboard; unsubscribe = "" (reserved)
merchant: { email } -- the store owner's address, "" when Formahand has none
When event.resource is order, the order is enriched (never overwriting an existing field) with: label (#1042 or ""), items[i].lineTotalMinor (unitPriceMinor × quantity), addressLines (["1 Loop Road", "Portland, OR, 97201", "US"]), shipToName (shippingName else customerName), discountLabel (Discount (WELCOME10) / Discount), shippingLabel (Shipping (Standard) / Shipping), hasShipping (shippingMinor || shippingMethod), trackingLink (trackingPageUrl else the carrier URL) and carrierTrackingUrl (the typed trackingUrl else the carrier's page). Order fields otherwise are the order detail GET /api/orders?orderId=<id> returns (events), including tags. Products and customers are their GET /api/products / GET /api/crm/contacts views.
Samples: a sample order (#1042, Ada Buyer, two items, a discount, USPS, delivered), a sample product (Granola, inventory 2, previously 4), a sample customer and a sample cart, each shaped like an event view with id: "SAMPLE". A preview picks the sample by the template's declared kind; a dry run picks it by the flow's first concrete topic and uses that topic as the sample's type.
5. Flows
A flow:
{ "name": "Delivered → thank-you email", "enabled": true,
"topics": ["order.delivered"], "filter": "",
"actions": [{ "type": "send_email", "template": "order-delivered", "to": "buyer" }] }
- Limits. 20 flows per store (409), 1–20 topics, name 1–80, filter ≤ 1000 characters, 1–10 actions.
- Topics and filter are the subscription vocabulary and predicate language of events §4–5, validated the same way (400
{ error, field: "filter", position }). - Actions run in order. Every string field is a template rendered in text mode against the context of §4;
whenis an optional per-action predicate (same grammar asfilter; false → the action isskippedwithwhen: false).
| Action | Fields | Does | Skipped when |
|---|---|---|---|
send_email | template (key, must exist at save time), to, cc? | Renders the template and hands one email per recipient to Formahand to send | no recipient resolves |
notify_merchant | subject, text (plain text; the HTML is the escaped text in a <div>) | Emails the store owner | Formahand has no owner address for the store |
add_order_note | text | Appends a line to the order's note (cut at 2000) and records order.updated | not an order event; order missing |
tag_order | tags[] (1–10) | Merges the tags into the order's tags, records order.updated | not an order event |
tag_customer | tags[] | Merges the tags into the customer named by data.email (customer events) or data.customerEmail (order events), records customer.updated | no email on the event; no customer record for it |
webhook | url (a template; must pass the outbound URL rules of events after rendering), secret? | POSTs the CloudEvents envelope; with a secret, signed exactly like a subscription (webhook-signature, X-Formahand-Signature); without one, no signature headers | — (a non-2xx answer fails the action) |
wake_agent | url, bearer? | POSTs a CloudEvents batch of one with Authorization: Bearer like an agent subscription | — |
set_order_status | status: "cancelled" | Cancels the order exactly as the dashboard does (inventory released, order.cancelled recorded) | the order is not pending + unfulfilled (that 409 is a skip, not a failure) |
wait | minutes (1–10 080) | Holds the run and lets the steps after it carry on later (§20); an order's data is re-read when it resumes | when is false |
issue_code | template (a discount code kept as a template), expiresInDays? (1–365), bindToCustomer? | Makes one single-use code from the template (checkout rules "Codes made from a template"). Every action after it reads it as {{ issued.code }}, with {{ issued.link }} a link that applies it and {{ issued.expiresAt }}; the code is kept on the step's result, so it survives a wait or a retry. With bindToCustomer, only the event's customer can use it | bindToCustomer and the event names no customer email; a day's code limit reached is a failure |
A personal code after purchase is two steps: { "type": "issue_code", "template": "COMEBACK", "expiresInDays": 30, "bindToCustomer": true } then { "type": "send_email", "template": "come-back", "to": "buyer" }, with {{ issued.code }} in the template. The same on customer.created gives every new customer a welcome code of their own.
to and cc accept, comma-separated: buyer (data.customerEmail, else data.email), merchant (the store owner's address), a literal address, or a template such as {{ data.customerEmail }}; anything that does not render to an address is reported in the skip detail. Secrets (secret, bearer) are stored with the flow like a subscription's target, shown as secretSet / bearerSet in every view and event, and kept when an update omits them for an action of the same type at the same position.
How a run goes. A run that can no longer find its flow or its event is failed with missing. With the store's kill switch off the run stays pending with error = flows_disabled, is looked at again ten minutes later, and spends no attempt. Otherwise each action is prepared (predicate, rendering, recipient resolution, template render) and then performed, and the result is one entry of the run's result:
{ "actions": [{ "index": 0, "type": "send_email", "status": "queued", "to": ["ada@example.com"], "mailIds": ["…"], "detail": "1 email(s) handed over for sending" },
{ "index": 1, "type": "add_order_note", "status": "done", "detail": "note appended" }],
"depth": 1, "actor": "system:flow:<flow id>:1" }
Statuses: done, skipped (with detail), failed (with error), queued (email handed over, waiting for the answer from the mail service). A run with no failed action is delivered; the flow's runCount, lastRunAt and lastStatus = ok are updated and its failure counter reset. Any failed action puts the run back on the schedule (pending with the next backoff step, failed once exhausted) with error = "action N (type): <error>; …", and increments the flow's failure counter. A retry re-runs only the actions that failed: entries that are done, skipped or queued are carried over from the previous result. When an email is rejected, that action flips to failed (mail_failed: …), a delivered run goes back to pending on the same schedule and the flow's failure counter moves — so an outage at the mail service delays email and never duplicates a note, a tag or a webhook. Sent mail flips the action to done with its messageIds.
Delays are a step, not a separate flow. A run that reaches a wait records the step and the moment it should carry on, and stops there; it is picked up when the wait is over, so the delay survives a restart or an upgrade and costs no attempt (§20). Modelling a delay as a second flow on a later event still works and is cheaper when a real event (order.delivered, cart.abandoned) already marks the moment.
6. Guardrails
| Guardrail | Value | How it works |
|---|---|---|
| Idempotency | one run per (flow, event) | a run is queued only when that pair has none, and an already delivered run is never repeated |
| Emails per store | 200 per rolling hour | every email a flow sent in the last hour is counted before the next email action; over the limit the action fails with rate_limited: the store's 200 emails per hour are used up and the run retries on the schedule (the email is delayed, not dropped) |
| Emails per recipient | 5 per rolling hour | the same count per address: rate_limited: <address> already received 5 flow emails this hour |
| Loop protection | never on own events; chains stop after 3 hops | writes made by a flow carry the actor system:flow:<flow id>:<depth> (depth 1 when the triggering event came from anywhere else, parent depth + 1 when it came from a flow). A flow refuses an event whose actor is the flow itself, and any flow-caused event at depth 3 or more |
| Dry run | no side effects | POST /api/flows/:id/test prepares every action (predicates, rendering, recipients, email bodies) and writes nothing: no run, no email, no request |
| Kill switch | per store | one switch for the whole store; off, no run is queued and pending runs wait (flows_disabled, looked at again every ten minutes without spending attempts); on again, they run |
| Secrets | never in views, events or run logs | secret/bearer appear only as secretSet/bearerSet; run results hold recipients and message ids only; failures are logged without bodies |
| Auto-disable | 20 consecutive failed attempts | enabled = 0, disabledReason = "Disabled after 20 consecutive failed runs (last: …)", flow.updated recorded with the actor system:deliveries; PUT { enabled: true } clears the reason and the counter |
| Retries | 1 min, 5 min, 30 min, 2 h, 8 h | the same schedule subscriptions use |
Worked loop example. Flow A tags every order.updated with a; flow B appends a note on every order.updated. A merchant edits a note (event E0, actor the user): A and B run at depth 1. A's tag write records E1 (actor system:flow:A:1); B's note write records E2 (system:flow:B:1). A skips E1 (its own) and runs on E2 at depth 2, but the tag is already present, so nothing is written; B skips E2 and runs on E1 at depth 2, appending again (E3, system:flow:B:2). A runs on E3 at depth 3 (no change, no event). Had it written, its event would carry depth 3 and trigger nothing. Five runs, then silence.
7. Recipes
Recipes (GET /api/flows/recipes, also returned by describe_flows). Each is a complete flow an agent can pass to create_flow unchanged, plus placeholders: [{ path, description, example }] for the values worth changing.
| id | Trigger | Actions | Placeholders |
|---|---|---|---|
delivered-thank-you | order.delivered | send_email order-delivered → buyer | — |
shipped-tracking-email | order.fulfilled, data.trackingNumber != null | send_email order-shipped → buyer | actions[0].to (keep buyer, or an address to test with) |
new-order-notify-merchant | order.paid | send_email new-order-merchant → merchant | — |
low-stock-notify-merchant | product.updated, changed contains "inventory" and data.inventory < 3 and data.status == "active" | send_email low-stock-merchant → merchant | filter (the threshold) |
refund-apology-email | order.refunded | send_email order-refunded → buyer | — |
return-approved-email-buyer | return.approved, data.customerEmail != null | send_email return-approved → buyer | actions[0].to (keep buyer, or an address to test with) |
order-edited-email-buyer | order.edited, data.customerEmail != null | send_email order-edited → buyer | actions[0].to (keep buyer, or an address to test with) |
new-customer-welcome | customer.created | send_email welcome → {{ data.email }} | actions[0].template (create the welcome template first with upsert_email_template) |
high-value-order-wake-agent | order.paid, data.totalMinor >= 20000 | wake_agent https://agent.example.com/formahand, tag_order [vip] | filter, actions[0].url, actions[0].bearer |
unpaid-order-payment-instructions | order.created, data.status == "pending" and data.paymentProvider != "stripe" and data.paymentProvider != "paypal" and data.customerEmail != null | send_email payment-instructions → buyer | — |
Formahand already sends the shipping email when you tick "notify customer"; shipped-tracking-email sends it on every fulfilment regardless. new-customer-welcome fails validation until the welcome template exists (actions.0.template).
8. API and MCP
Routes take ?storeId= on GET/DELETE and storeId in JSON bodies for sessions; a token is bound to its store. Errors are { error, field?, position? }; field is name, topics, filter, actions, actions.<i>.<field> or a template field.
| Route | Scope | Does |
|---|---|---|
GET /api/flows | settings:read | { flows: [FlowView], flowsEnabled, max: 20, limits: { flows, actionsPerFlow, emailsPerHour, emailsPerRecipientPerHour, maxDepth, consecutiveFailuresBeforeDisable, retries } } |
POST /api/flows | settings:write | a flow → 201 { flow }; 400 with field; 409 at 20 |
PUT /api/flows | settings:write | { id, …changes } → { flow } |
DELETE /api/flows?id= | settings:write | { success: true }; runs cascade |
GET /api/flows/settings · PUT { flowsEnabled?, senderFallbackAck? } | settings:read / settings:write | the kill switch and the shared-sender acknowledgement (§18) → { flowsEnabled, senderFallbackAck }; a change records automation_settings.updated |
GET /api/flows/recipes | settings:read | { recipes } |
GET /api/flows/describe | settings:read | flow shape, topics, filter grammar, action docs, template grammar + helpers + context, your templates, recipes, limits, guardrails, howTo |
GET /api/flows/:id · PUT · DELETE | settings:read / settings:write | one flow; PUT keeps omitted fields, enabled: true clears an auto-disable |
POST /api/flows/:id/test { eventId? } | settings:write | dry run → { event, sample, sampleType?, matched, topicMatched, filterMatched, flowsEnabled, actions: [{ index, type, when, status: would_run | skipped | error, error?, detail?, rendered }] }; without eventId the newest event matching topics and filter, else a sample |
GET /api/flows/:id/runs?limit= | events:read | { runs: [DeliveryView] } newest first, each with flowId, eventType, attempt, status, error, result |
POST /api/flows/:id/readiness | settings:read | { readiness, flow } — what the flow still needs (§18) |
POST /api/flows/:id/enable { enabled? } | settings:write | switches the flow on through the gate; 409 { error, readiness, flow } when it is not ready. Off is never gated |
POST /api/flows/:id/check-url { actionIndex? } | settings:write | pings each fixed outbound address and records the answer → { checks, readiness, flow }; 409 when the flow has none |
GET /api/flows/:id/schedule-runs?limit= | events:read | a scheduled flow's ticks (§13) |
GET /api/email-templates | settings:read | { templates: [EmailTemplateView] } |
POST /api/email-templates | settings:write | { key, name, subject, bodyHtml, bodyText?, fromName?, replyTo?, variables? } upsert by key → 201 (new) / 200 (replaced) { template } |
PUT /api/email-templates | settings:write | { id, …changes } |
DELETE /api/email-templates?id= | settings:write | custom templates only; 409 for system keys |
GET /api/email-templates/:id · PUT · DELETE | settings:read / settings:write | :id is the id or the key |
POST /api/email-templates/:id/preview { eventId? } | settings:write | §2 |
POST /api/email-templates/:id/reset | settings:write | { template }; 409 for custom templates |
POST /api/deliveries/:id/retry | settings:write | works for flow runs too: the attempt count is reset to 0, the run is due now and Formahand starts it straight away rather than at the next ten-minute pass |
FlowView: { id, name, enabled, topics, filter, actions: [action with secretSet/bearerSet], schedule, nextRunAt, runCount, runs7d, lastRunAt, lastStatus, failureCount, disabledReason, readiness, createdAt, updatedAt } (readiness and runs7d on list and single reads, §18). EmailTemplateView: { id, key, name, description, subject, bodyHtml, bodyText, fromName, replyTo, variables, isSystem, isModified, createdAt, updatedAt }.
MCP tools (the token is the actor):
| Tool | Scope | Does |
|---|---|---|
describe_flows | settings:read | GET /api/flows/describe — call first |
list_flows / get_flow { id } | settings:read | flows with masked secrets |
create_flow | settings:write | a flow (strict JSON Schema) |
update_flow { id, …changes } | settings:write | omitted fields kept; omitted secrets kept |
delete_flow { id } | settings:write | |
test_flow { flowId | id, eventId? } | settings:write | dry run; your store's details and vault are supplied, so {{ secrets.* }} renders as it would live |
set_flows_enabled { flowsEnabled } | settings:write | the kill switch |
list_flow_runs { flowId | id, limit? } | events:read | runs with per-action results |
get_flow_readiness { flowId } | settings:read | the checklist (§18) |
enable_flow { flowId, enabled? } | settings:write | switches a flow on through the gate; 409 + readiness when it is not ready |
check_flow_url { flowId, actionIndex? } | settings:write | pings the flow's fixed outbound addresses (§18) |
retry_flow_run { runId } | settings:write | re-runs only the steps that failed (§21) |
acknowledge_sender_fallback { acknowledged } | settings:write | records the acknowledgement; no longer opens the email gate (§18) |
list_email_templates / get_email_template { id } | settings:read | |
upsert_email_template { key, name, subject, bodyHtml, bodyText?, fromName?, replyTo?, variables? } | settings:write | |
preview_email_template { id, eventId? } | settings:write | renders against a sample or a real event |
reset_email_template { id } | settings:write |
describe_events carries an automations field pointing at describe_flows.
9. Dashboard
Automations is a top-level module that opens as a hub of five tiles: Flows, Email templates, Inbound URLs, Webhooks and Events. Each opens one part with "← Automations", and the address carries it: #automations/flows, /templates, /hooks, /subscriptions, /events (the older words webhooks, email-templates and inbound land on the same parts).
- Flows (
#automations/flows): the list (name, one status word — Ready · Needs setup · Paused · Error — trigger, runs, Pause/Resume on the row), New flow, Start from a recipe (#automations/flows/recipes: "Use recipe" prefills the editor and lists the placeholders to fill in, connectors below), and the kill switch as a row under the list ("Run flows"; turning it off asks first). One flow is its own page,#automations/flows/<id>, with three pills: What it does (the gate of §18 with a line per requirement and a button to the page or the check that finishes it, the ordered steps, and the dry run of §19), Runs (§21, with the schedule's own ticks under it) and Settings (trigger, condition, schedule, run counts, Edit, Delete in a dialog). Enable goes through the gate, so a flow that is not ready opens on its checklist instead of failing later. The editor (#automations/flows/new, or Edit on a flow) is four steps any of which can be opened at any time: When it runs (name, topics with vocabulary quick-adds, schedule), What must be true (a filter validated as you type), What it does (an ordered actions builder with type-specific fields and per-actionwhen, move up/down, remove; a step type the editor does not know is kept as a read-only row and saved back unchanged) and Review; server errors open the step that holds the named field, and leaving with changes asks first. The Try panel (dry run against the last matching event or a given event id, per-action pills, rendered fields, a sandboxed frame of the email HTML with a text toggle) and the Runs panel (per-action results, Retry) are on the flow's page. - Email templates (
#automations/templates): the list (name, kind, key, updated) with Preview on the row; one template opens at#automations/templates/<id>(/newfor a new one): name, key read-only for system templates, subject, From name, Reply-to, HTML and text bodies in monospace, an Insert a variable picker built from your store's own context that writes{{ path }}where the cursor was, and a short variables help with "Show all variables"; saving reports the variables the sample knows nothing about; Preview against the sample or an event id witherrors[]inline; Reset (system + modified) and Delete (custom) ask in a dialog. - Inbound URLs (
#automations/hooks, one at#automations/hooks/<id>, new at/new): see §22. - Webhooks and Events: the subscriptions and the feed of events.
10. Agent walkthrough
With a token carrying settings:read, settings:write, events:read and orders:write:
describe_flows → actions, template grammar, recipes, your template keys
list_email_templates → order-shipped exists (system, unmodified)
upsert_email_template { key: "order-shipped", name: "Order shipped", subject: "{{ store.name }}: {{ data.label }} is on its way",
bodyHtml: "…{{#each data.items}}…{{/each}}…{{ link data.trackingLink \"Track your parcel\" }}…" } (optional)
create_flow { name: "Shipped → tracking email", topics: ["order.fulfilled"], filter: "data.trackingNumber != \"\"",
actions: [{ type: "send_email", template: "order-shipped", to: "buyer" }] } → { flow: { id } }
test_flow { id } → { matched: true, actions: [{ type: "send_email", status: "would_run",
rendered: { to: ["ada@example.com"], subject: "…", bodyHtml: "…", bodyText: "…" } }] }
set_order_tracking { orderId, carrier: "USPS", trackingNumber: "9400…", notifyCustomer: false }
→ the write records order.updated + order.fulfilled, the run is queued and
started at once, and the email goes out
list_flow_runs { id } → { runs: [{ status: "delivered", result: { actions: [{ status: "done", to: [...], messageIds: ["…"] }] } }] }
A queued action means the email has been handed over and its answer has not come back yet; failed with mail_failed means it was rejected and the run is back on the retry schedule.
12. Abandoned checkout
Free on every plan. A checkout that was started but not paid within 60 minutes is an abandoned cart.
- What counts. Every ten minutes Formahand looks for orders that are still
pendingand unfulfilled, were started at least 60 minutes ago and are at most 7 days old, carry a customer email, and were being paid by card or PayPal — a bank-transfer or cash-on-delivery order is waiting for you, not for the shopper, and is never abandoned. Each order is claimed exactly once, so two passes can never raise the event twice. An order paid later keeps its abandoned mark; the event has already gone out. - The event:
cart.abandoned, resourcecart, resource id the order id, actorsystem:cron(events), data{ orderId, orderNumber, customerEmail, customerName, currency, subtotalMinor, totalMinor, items[], checkoutUrl, unsubscribeUrl, createdAt, abandonedAt, paymentProvider }.checkoutUrlishttps://<your primary host>/cart/recover?order=<id>&t=<token>— it rebuilds the shopper's cart from the order's active lines and lands on/cart(a wrong token lands on/cartuntouched).unsubscribeUrlishttps://<your primary host>/email/unsubscribe?e=<email>&t=<token>; visiting it records the unsubscribe and marks a matching customerunsubscribed. Both links are signed, so neither can be forged or guessed. A shopper who unsubscribed (or a customer alreadyunsubscribed) still gets the order marked, but the event carriescustomerEmail: "", so the flow'ssend_emailis skipped ("no recipient"). - The default flow "Abandoned checkout → email after 1 hour" is seeded paused in every store with the fixed id
<storeId>:flow:abandoned-checkout. Formahand remembers that it seeded it, so a merchant who deletes it does not get it back and one who edits it keeps the edits. Enabling is one click: Automations → Flows → Resume on the flow, orupdate_flow { id, enabled: true };describe_flowslists the recipeabandoned-checkout-emailas well. The template isabandoned-cart(editable), which linkscheckoutUrlandunsubscribeUrl. - Sample: previews and dry runs of cart templates and flows use the sample cart.
13. Scheduled triggers
A flow can run on a clock as well as on events. The flow keeps everything it already has — filter, actions, run log, kill switch — and gains a schedule:
{ "every": "15m" | "1h" | "6h" | "1d", "at": "08:00", "timezone": "Europe/Berlin" }
everyis the interval. Withoutatthe runs sit on interval boundaries from the epoch (15m→ :00, :15, :30, :45). Withatthey are anchored to that wall-clock time intimezone(UTC when none is given), so{ every: "6h", at: "02:30", timezone: "Europe/Berlin" }runs at 02:30, 08:30, 14:30 and 20:30 local. The offset is recomputed at each scheduling, so a daylight-saving change moves the next run, not the stored schedule.- Saving a schedule adds the topic
schedule.tickto the flow for you. Sendingschedule: nullremoves the schedule and leaves the topic list alone.
How a tick runs. Formahand looks for schedules that have come due every ten minutes. For each one it records an event
{ "type": "schedule.tick", "resource": "schedule", "resourceId": "<flowId>", "actor": "system:schedule",
"data": { "flowId": "…", "scheduledFor": "2026-09-16T08:00:00.000Z", "timezone": "Europe/Berlin", "every": "1d" } }
and queues an ordinary flow run on it, then moves nextRunAt forward. A tick belongs to the flow it was raised for: no other flow, and no subscription filtering on schedule.tick, can pick up someone else's tick. Because that ten-minute pass is the clock, a run starts within ten minutes of its slot, never before it.
Budgets. Your plan decides how many schedules and scheduled runs you get.
| Free | Growth | Pro | |
|---|---|---|---|
| Schedules per store | 3 | 20 | 50 |
| Scheduled runs per day | 200 | 2,000 | 10,000 |
Schedules beyond the allowance (the oldest ones stay inside it) simply never tick, and the day's allowance stops further runs until midnight UTC; nothing is deleted and no data is lost. Your sandbox is never budgeted.
Pausing. Three failed runs in a row pause the schedule: nextRunAt is cleared, the schedule is marked paused with a reason, and you get an email. The flow itself stays enabled and keeps running on store events. Saving the schedule again (dashboard, or update_flow { id, schedule }) resumes it. A tick counts as failed only once its run has exhausted its retries; a run still on the retry schedule is neither a success nor a failure yet.
Never run: in a suspended or dormant store, in a store that is not ready yet, while your flows are switched off, or for a paused flow. Those ticks are recorded as skipped and the schedule moves on.
Every tick is kept: GET /api/flows/:id/schedule-runs?limit= and list_flow_schedule_runs { id } show { id, flowId, ranAt, status: queued | ok | failed | skipped, error }, keyed by the tick's event id.
test_flow accepts schedule.tick as a sample, so a scheduled flow can be dry-run before its first slot.
14. Inbound hooks
A flow can also expose a URL another system posts to — a courier's status push, a supplier's stock feed, a script on your laptop — with no helper server in between.
POST https://formahand.com/hooks/<storeId>/<flowId>
create_flow_hook { flowId, mode } (or Automations → Inbound URLs) returns the URL and, once, a secret. The flow gains the topic hook.received and runs against
{ "type": "hook.received", "resource": "hook", "resourceId": "<flowId>", "actor": "system:hook",
"data": { "flowId": "…", "body": { … }, "headers": { "content-type": "application/json" }, "receivedAt": "…" } }
so actions read the payload as {{ data.body.reference }} and so on.
Bearer — the caller sends the secret in a header:
curl -X POST https://formahand.com/hooks/STORE/FLOW \
-H "Authorization: Bearer whsec_abc123…" \
-H "Content-Type: application/json" \
-d '{"reference":"ABC-123","status":"ready"}'
HMAC — the caller signs the body, so the secret is never sent:
BODY='{"reference":"ABC-123","status":"ready"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "whsec_abc123…" -hex | sed 's/^.* //')
curl -X POST https://formahand.com/hooks/STORE/FLOW \
-H "X-Formahand-Signature: t=$T,v1=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
The signed string is "<t>.<raw body>" and t must be within 5 minutes of now, which is what stops a captured request being replayed later.
Answers: 202 { ok: true, runId } (the run is queued, not finished), 400 for a body that is not JSON, 401 for a secret or signature that does not check out or a stale timestamp, 404 for an unknown store, flow or hook — and for a hook that is switched off, so a stranger cannot tell the difference — 405 for anything but POST, 413 over 64 KB, 429 over 60 requests a minute per hook.
One hook per flow. GET /api/flow-hooks (list_flow_hooks) lists them; rotate_flow_hook issues a new secret at the same URL and the old one stops working at once; set_flow_hook_enabled switches receiving without losing the secret; delete_flow_hook removes it. Only the first characters of a secret are ever listed again (secretPrefix).
In your sandbox a hook is created switched off, so a promoted or copied integration cannot start receiving before you say so; turn it on with set_flow_hook_enabled { enabled: true } or the Inbound URLs tab. The URL is the same in both environments; the flow id decides which one runs, and your live store is tried first.
15. The secrets vault
Your store keeps credentials for other people's systems in a write-only vault.
| Name | ^[a-z][a-z0-9_]{1,40}$ — lowercase letters, digits and underscores, e.g. warehouse_api_key |
| Value | up to 4 KB |
| Count | 50 per store, per environment |
| Read back | never — not by a route, not by a tool, not in a run log |
PUT /api/secrets { name, value } / set_secret stores or replaces one; GET /api/secrets / list_secrets returns names and timestamps only (createdAt, updatedAt, lastUsedAt); DELETE /api/secrets?name= / delete_secret removes one. describe_secrets explains the rules to an agent. If the vault is not available for your store yet the route answers 503 with the reason.
Where a secret may be used. Only where it leaves the store for a system you chose:
- a
webhookaction'surl - a
webhookaction'sheaders, e.g.{ "Authorization": "Bearer {{ secrets.warehouse_api_key }}" }(up to 5 headers; the envelope's own headers cannot be overwritten) - a
wake_agentaction'sbearer
{{ secrets.* }} anywhere else — an email, a note, a tag, a filter — is refused when the flow is saved, with the field named. An action that refers to a secret your store does not have fails its run with missing_secret.
Redaction. A run log and a dry run keep the rendered request, with every stored value replaced by ***. lastUsedAt is the only trace a use leaves.
Encryption. A value is encrypted with AES-256-GCM before it is stored, under a key that belongs to your store and that one environment alone; only the ciphertext ever reaches your store's data. Two stores that keep the same value keep different ciphertexts. The plaintext exists for exactly one moment: it is handed to a run that needs it, for the length of that run.
Environments. Each environment has its own vault and its own key. promote_to_live and refresh_sandbox never copy secrets: set them again on the other side. A value that can no longer be decrypted is left out rather than failing the run, and the action that wanted it reports missing_secret.
17. Staff notification addresses
The store owner is the only person to: "merchant" resolves to. You can add up to five more addresses per email:
GET/PUT /api/notification-settings { newOrderEmails, lowStockEmails }
Addresses are trimmed, lowercased, de-duplicated and format-checked; more than five, or one that is not an address, is a 400 and nothing is written. Agent tools: get_notification_settings / set_notification_settings (settings:read / settings:write). Dashboard: Email → Sending settings → Notifications.
The fan-out happens inside the flow engine: a send_email action whose template key is new-order-merchant gets newOrderEmails added to its recipients, low-stock-merchant gets lowStockEmails, and every other key gets nobody — so the seeded recipes new-order-notify-merchant and low-stock-notify-merchant and any flow you write with those templates all reach the same people, and a buyer email is never touched. The extra addresses are additions: the owner keeps receiving the email whatever the lists say, and the per-recipient hourly limit (§6) applies to each address on its own.
On by default. "New order → email me" and "Low stock → email me" are seeded switched on in every store, once per key, so an owner who deleted one does not get it back. A flow whose email goes only to merchant (or that uses notify_merchant) has no email-sender readiness requirement.
Two senders, one rule. Formahand writing to your store's own people — you, a member of your organisation, a staff notification address on new-order-merchant / low-stock-merchant, anything from notify_merchant — always comes from Formahand's own address, whether or not your store has a sender of its own. Your store writing to anyone else (buyers, shoppers) only ever goes out from your store's own sending address, never from Formahand's. Who counts as "your store's own people" is decided from your account's memberships and nothing else, so a staff address that is not on the account is still notified — from your store's own sending address, which is your verified domain when you have one and the shared store address until then (email marketing). A paused schedule is reported to the owner the same way.
**dailySummary is gone (2026-09-22).** It was a switch that saved a flag nothing read. A schedule can already fire daily (§13), but a schedule.tick event carries only flowId, scheduledFor and timezone — no figures — so a real summary needs an event carrying yesterday's numbers first. Nothing reads or writes the setting today.
18. Readiness: the gate on switching a flow on
A flow that cannot work should never be switched on to fail quietly at three in the morning. Every flow carries a readiness checklist, returned by GET /api/flows, GET /api/flows/:id, POST /api/flows/:id/readiness, list_flows, get_flow and get_flow_readiness:
{ "ready": false,
"requirements": [
{ "id": "email-sender", "label": "An address the store's email is sent from", "done": false,
"detail": "Verify your own sending address under Email → Sending settings; until then this store sends no email at all.",
"href": "/dashboard#settings/email/domain", "blocking": true },
{ "id": "template:order-shipped", "label": "The email template \"order-shipped\"", "done": true, … },
{ "id": "shipping-mode", "label": "A shipping setup that reports parcels", "done": false, "blocking": false, … } ] }
ready is true when every blocking requirement is done; the rest is advice and never stands in the way.
| Requirement | Blocking | Done when | Fixed by |
|---|---|---|---|
email-sender (any send_email / notify_merchant) | yes | your store has a verified sending address of its own | Email → Sending settings → Email domain, #settings/email/domain (yours to do; no tool) |
merchant-address (notify_merchant, to: merchant) | yes | Formahand knows the owner's address | — |
template:<key> (per send_email) | yes | a template with that key exists | Automations → Email templates, #automations/templates; upsert_email_template |
secret:<name> (any {{ secrets.* }}) | yes | the vault holds that name | Settings → Agents & API → Vault, #settings/agents/vault; set_secret |
webhook-url:<i> (webhook, wake_agent) | yes | the address answered a check, or is built from the event | check_flow_url |
inbound-url (a hook.received flow) | yes | the flow has an inbound URL and it is on | Automations → Inbound URLs, #automations/hooks; create_flow_hook |
schedule-missing (a schedule.tick flow with no schedule) | yes | the flow carries a schedule | update_flow { schedule } |
customer-target:<i> (tag_customer) | no | the store has customer records | — |
shipping-mode (order.fulfilled / .delivered / .tracking_updated) | no | a shipping module is active or flat rates exist | Checkout → Shipping; set_shipping |
schedule-paused | no | the schedule is not paused | save the schedule again |
flows-on | no | your store's kill switch is on | set_flows_enabled |
The gate. POST /api/flows/:id/enable { enabled } (enable_flow) switches a flow on and answers 409 with the readiness when something blocking is missing; switching off is never gated. An ordinary write that asks for enabled: true is not refused — the flow is saved switched off with readiness and a notEnabled sentence in the answer, so nothing you or your agent wrote is lost and nothing half-built starts running.
Checking an address. POST /api/flows/:id/check-url { actionIndex? } (check_flow_url) sends one clearly marked request to each webhook / wake_agent step whose URL is fixed: a HEAD first, then a POST carrying { "formahand": { "check": true, … } } when HEAD is refused. Anything that is not 404 and not a 5xx counts as reached — an endpoint that demands a signature answers 401 and has still proved it is there. The outcome is kept per (flow, step); changing the URL makes the check stale and closes the gate again. The outbound rules of events apply, so a private address or one of ours is refused with the reason.
The sending address. A store that has not verified a domain of its own is not silent — its order confirmations, shipping updates and sign-in codes go out as the store over Formahand's shared store address — but **a flow's own send_email does not travel on it**. That mail is free text you wrote, to a recipient you chose, and a win-back is marketing in substance whatever header it carries; the shared address's reputation belongs to every store on it at once. So a store still on the shared address skips flow mail with reason: "own_domain_required", and email-sender keeps asking for your own verified sender (email marketing). Mail to your store's own people is unaffected, and so is Formahand's own order and shipping mail. PUT /api/flows/settings { senderFallbackAck: true } (acknowledge_sender_fallback) still records the acknowledgement and GET /api/flows still reports senderFallbackAck; it opens no gate.
19. Test runs
POST /api/flows/:id/test (test_flow { flowId | id, eventId? }) is the dry run: it picks the newest real event matching the flow's topics and filter, or the event you name, or a sample from the vocabulary when your store has neither, evaluates the filter and every when, renders every field — recipients, subjects, bodies, URLs, headers — and does nothing: no run recorded, no request, no email. Each step comes back as would_run, skipped (with the reason) or error (with the message and position). Your store's own details and its vault are supplied with the call, so a step that uses {{ secrets.name }} renders exactly as it would on a real run, with every stored value shown as ***.
In the dashboard it is on a flow's page, under What it does: the rendered email is shown in a sandboxed frame with an HTML/text toggle.
20. Delays: the wait step
{ "type": "wait", "minutes": 60 }
A wait holds the run and lets the steps after it carry on later — "wait an hour, then if the order is still unpaid, email the buyer" is a wait followed by an action with when: 'data.status == "pending"' (the recipe unpaid-after-an-hour-reminder). Up to 10 080 minutes (seven days).
- It is data, not a timer. The waiting step is recorded in the run itself with its
resumeAt, and that is when the run is next due. A restart, a deploy or an upgrade changes nothing: the run is picked up when the wait is over, so it resumes within ten minutes of the moment it asked for and never before it. - It spends no attempts. A waiting run is neither a success nor a failure: its attempt count, its earlier results and its retry schedule are untouched, and the flow's failure counter does not move.
- The data is re-read. When a run of an order event carries on after a wait,
datais read from the order again, so a laterwhensees the order as it is then — paid, cancelled, fulfilled — rather than as it was when the event happened.changedandpreviousstill describe the original write. - Steps before it run once. Everything before the wait keeps its result across the resume, exactly like a retry: a note is never appended twice.
- It starts when the steps before it have come off. A step that is still failing and retrying does not eat into the delay: the wait is recorded once everything before it has succeeded.
- A dry run shows the wait as
would_runwith how long it would hold, and holds nothing.
21. Run history
GET /api/flows/:id/runs?limit= (list_flow_runs { flowId, limit? }) is the last runs of one flow, newest first: the triggering event and its type, the attempt, the status (pending — including a run that is waiting —, delivered, failed), the error, and every step's own outcome (done, skipped with the reason, queued, waiting with resumeAt, failed with the error, and the message ids of what was sent).
POST /api/deliveries/:id/retry (retry_flow_run { runId }) runs it again, and only the steps that failed: what was done, skipped or already handed over for sending is kept, so a retry never appends a note twice or sends an email twice. A retry resets the attempt count, so the whole schedule is available again, and the run starts straight away rather than at the next ten-minute pass.
Scheduled flows keep a second log — one row per tick — at GET /api/flows/:id/schedule-runs?limit= (list_flow_schedule_runs), shown under the flow's Runs tab.
Saving a template answers with warnings: variables the sample event has nothing for ({{ data.nonsens }}) and roots that are not part of the context at all. They are advice, never a refusal — a real event may well carry a field the sample does not. describe_flows and the template preview also return paths, every variable the context offers, which is what the dashboard's "Insert a variable" picker is built from.
22. Against Shopify Flow
| Shopify Flow | Formahand | |
|---|---|---|
| Availability | Shopify plan feature, app install | On every plan, nothing to install |
| Triggers | Shopify + app triggers | Every store event (<resource>.<verb>, list_events shows them), a clock (schedule), and an inbound URL other systems post to (hook.received) |
| Conditions | Visual and/or groups | One predicate language for filters and per-step when: == != < <= > >=, in, contains, startsWith, endsWith, and / or / not with parentheses for any-of / all-of groups, over any path in data, previous, changed, type, actor, resource. Missing paths are null, never an error |
| Actions | Shopify actions + app actions | Email from your own templates, notify the owner (and staff addresses), order notes, order and customer tags, cancel an unpaid order, signed webhook, wake an agent, and wait |
| Delays | Wait steps | wait steps, held in the run itself — durable across restarts and upgrades — with the order re-read when the run carries on |
| Before it runs | Enable and hope | The readiness gate: a flow cannot be switched on until its sender, templates, stored values, addresses and inbound URL exist, and each missing line names the page and the tool that fixes it |
| Dry run | Test with a recent order | test_flow on any flow: a real recent event, an event you name, or a sample; every field rendered, nothing sent |
| Run log | Run history per flow | Run history per flow and per step, with the error, the message ids, the waiting step's resume time, and a retry that re-runs only what failed |
| Templates | Liquid in the app's editor | A safe template language with a variables picker built from the live context, missing-variable warnings at save, preview against a real event, and reset to the default |
| Agents | Admin API, no first-class tools | Every one of the above is an MCP tool and a REST route with the same shapes: describe_flows, create_flow, test_flow, get_flow_readiness, enable_flow, check_flow_url, list_flow_runs, retry_flow_run, install_recipe |
| Guardrails | App-level limits | One run per (flow, event), loop protection by actor and depth, per-store and per-recipient email limits, auto-disable after repeated failures, a store kill switch, and a write-only vault whose values never appear in a log |
| Ready-made | Templates gallery | Recipes (one flow each) and connector recipes (whole integrations, installed switched off) |
What Shopify Flow has and we deliberately do not: a drag-and-drop canvas (the dashboard's list of steps is the editor), app-provided actions from a marketplace, and branching inside one flow (a per-step when and several flows do the same work with less to go wrong).
See also
- agents.md — every flow, schedule, hook and vault tool with its scope, and the governance around the token that calls them.
- recipes.md — whole integrations installed switched off, instead of building the same flow by hand.
- events.md — the log, the topic grammar and the delivery envelope flows react to.
- custom-fields.md — the typed values a flow's templates can read and a recipe can document.
- analytics.md — the numbers a nightly scheduled flow can mail you.