Automations
An automation is a sequence of emails triggered by something happening in your application. You post one event; Quolle handles the timing, the sends, and every reason a person should stop receiving them.
The shape is deliberately small. A sequence is a list of steps and the connections between them. Most connections just say what comes next. A connection out of a condition or a wait also carries a type, saying which answer it belongs to.
Coming from Resend? The automation format is the same one: the same step types, the same connections with condition_met / condition_not_met / event_received / timeout, the same and and or rule groups, and the same POST /events/send. Durations accept both "3 days" and "3d", and a send step takes either a template slug or { "id": "..." }. In most cases the JSON moves across unchanged.
Two differences worth knowing. A condition here can also test engagement — whether an earlier email in the same run was opened or clicked — which is the branch most drip campaigns are actually about. And automations are included in your plan rather than billed per run.
A welcome series in one request
Automations are created disabled. Nothing you build is ever one API call away from mailing people. You enable it once you have looked at it.
curl -X POST https://api.quolle.com/v1/automations \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome series",
"topicId": "b2f1c8e0-...",
"steps": [
{ "key": "start", "type": "trigger", "config": { "eventName": "user.created" } },
{ "key": "welcome", "type": "send_email", "config": { "template": "welcome-email", "from": "hello@yourdomain.com" } },
{ "key": "wait", "type": "delay", "config": { "duration": "2d" } },
{ "key": "tips", "type": "send_email", "config": { "template": "getting-started", "from": "hello@yourdomain.com" } }
],
"connections": [
{ "from": "start", "to": "welcome" },
{ "from": "welcome", "to": "wait" },
{ "from": "wait", "to": "tips" }
]
}'Enable it
curl -X POST https://api.quolle.com/v1/automations/<id>/enable \
-H "Authorization: Bearer qle_your_api_key"Enabling checks more than the shape of the sequence. Every send_email step must name a template that exists and a from address on a verified domain, or you get a 422 listing everything that is wrong at once. An automation that would fail for every contact should not be enableable, and finding out one contact at a time is the worst way to learn.
Triggering it
Post the event when it happens. If you only have an email address, Quolle finds the contact or creates one; if you already track contact ids, pass contactId.
curl -X POST https://api.quolle.com/v1/events/send \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"event": "user.created",
"email": "ada@example.com",
"payload": { "plan": "pro", "referrer": "twitter" }
}'await fetch("https://api.quolle.com/v1/events/send", {
method: "POST",
headers: {
"Authorization": "Bearer qle_your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
event: "user.created",
email: "ada@example.com",
payload: { plan: "pro", referrer: "twitter" },
}),
});import requests
requests.post(
"https://api.quolle.com/v1/events/send",
headers={"Authorization": "Bearer qle_your_api_key"},
json={
"event": "user.created",
"email": "ada@example.com",
"payload": {"plan": "pro", "referrer": "twitter"},
},
timeout=15,
)An event nobody is listening for is not an error. You get 202 with "started": 0, so you can emit events freely without wrapping every call in a try/catch or waiting until an automation exists.
Using the payload in a template
Whatever you send as payload is available to every template in the sequence under event, alongside the contact's own fields. It is frozen at the moment of the trigger, because a sequence spans days and your application will have moved on by step three.
<p>Hi {{firstName}}, thanks for choosing the {{event.plan}} plan.</p>Step types
| Type | Config | What it does |
|---|---|---|
trigger | eventName | The event that starts the sequence. Exactly one per automation, and nothing connects into it. |
delay | duration | Waits. 30m or 30 minutes, 4h or 4 hours, 2d or 2 days. Maximum 90 days. |
send_email | template, from, optional replyTo | Sends a stored template from a verified domain. template is a slug, or { "id": "slug" }. |
condition | field, operator, sometimes value | Splits the sequence on a contact field, the event payload, or engagement. Branches condition_met / condition_not_met. |
wait_for_event | eventName, timeout, optional filterRule | Parks the run until that event arrives for this contact. Branches event_received / timeout. |
contact_update | fields and/or metadata | Writes to the contact. firstName, lastName and phone, plus anything under metadata. |
contact_delete | — | Removes the contact and ends the run. The run history survives. Nothing may be connected after it — see below. |
add_to_topic | topicId | Subscribes them to a topic. Never overrides an explicit opt-out. |
add_to_list | listId | Moves them to another contact list. |
Acting on the contact
A sequence can record what it learns. contact_update merges rather than replaces, and {{event.*}} in a metadata value pulls from the trigger payload — so the plan somebody signed up on can be saved without a second API call from your application.
{
"key": "remember_plan",
"type": "contact_update",
"config": {
"fields": { "firstName": "{{event.name}}" },
"metadata": { "plan": "{{event.plan}}", "onboarded": "yes" }
}
}Two fields a sequence cannot write. email is the identity the run is built around and what suppression is keyed on; changing it mid-flight would detach somebody from their own unsubscribe. And unsubscribed is the recipient’s decision — an automation that could set it back to false would be a way to re-subscribe people who asked to leave. Both are rejected at save time.
For the same reason, add_to_topic subscribes somebody who has never expressed a preference, but leaves an explicit opt-out alone.
No add_to_segment. Quolle has segments, but membership is a rule evaluated every time you ask — so there is no list to add anybody to. The step that achieves what people mean by it is contact_update: set the field the segment tests, and they are in it. Sending add_to_segment returns a 422 saying exactly that, rather than succeeding and doing nothing.
Nothing follows contact_delete. The run ends there, because there is nobody left to email. A step connected after it would validate cleanly and never execute, so it is refused with a 422 at save time instead. Ending one arm of a condition this way is fine — the other arm carries on.
Branching
A condition asks a question and sends the contact down the condition_met or condition_not_met arm. A wait_for_event pauses until something happens, then takes event_received, or timeout if it never does. A connection with no type, or "type": "default", is the single path out of a step that doesn't branch.
An arm with nothing wired to it ends the sequence, which is usually what you want: if they opened it we're done, if not send the nudge needs one arm, not two. Both arms may also lead back into the same step, so a split can rejoin a shared ending rather than you duplicating it.
{
"steps": [
{ "key": "start", "type": "trigger", "config": { "eventName": "user.created" } },
{ "key": "welcome", "type": "send_email", "config": { "template": "welcome", "from": "hello@yourdomain.com" } },
{ "key": "wait", "type": "delay", "config": { "duration": "2d" } },
{ "key": "opened", "type": "condition", "config": { "field": "engagement.opened", "operator": "is_true" } },
{ "key": "upsell", "type": "send_email", "config": { "template": "upsell", "from": "hello@yourdomain.com" } },
{ "key": "nudge", "type": "send_email", "config": { "template": "nudge", "from": "hello@yourdomain.com" } }
],
"connections": [
{ "from": "start", "to": "welcome" },
{ "from": "welcome", "to": "wait" },
{ "from": "wait", "to": "opened" },
{ "from": "opened", "to": "upsell", "type": "condition_met" },
{ "from": "opened", "to": "nudge", "type": "condition_not_met" }
]
}What a condition can ask about
| Field | Reads |
|---|---|
contact.firstName, contact.lastName, contact.email, contact.phone | The contact as they are now, not as they were at the trigger. That is the point of asking three days in — including anything a contact_update step wrote earlier in the same sequence. |
contact.metadata.* | Anything you store on the contact, nested as deep as you like. |
event.* | The trigger payload, frozen when the run started. |
engagement.opened, .clicked, .delivered, .bounced | Whether the most recent email in this run was opened, clicked, delivered or bounced. Add "step": "welcome" to ask about a specific earlier send instead. |
Operators
eq, neq, gt, gte, lt, lte, contains, starts_with, ends_with, exists, is_empty, plus is_true and is_false for the engagement fields. Everything that compares against something needs a value; exists, is_empty, is_true and is_false do not.
Combining rules
A condition is one rule, or a group of them joined by and or or. Groups nest, up to five deep.
{
"key": "worth_upselling",
"type": "condition",
"config": {
"type": "and",
"rules": [
{ "type": "rule", "field": "event.plan", "operator": "eq", "value": "pro" },
{ "type": "rule", "field": "engagement.opened", "operator": "is_true" }
]
}
}A single rule can leave type out entirely — { "field": ..., "operator": ..., "value": ... } means the same as { "type": "rule", ... }.
A question we cannot answer takes the false arm. A missing field, or a number compared against a word, is not an error — it is an unknown, and unknown is not the same as no. So is_false on engagement.opened is false for a contact who has not been sent anything yet: they have not ignored you, there was nothing to ignore. This is what stops a nudge going out before the email it is nudging about.
Waiting for something to happen
A wait_for_event step parks the run until you post a matching event for that contact, or until its timeout elapses. Both a config.eventName and a config.timeout are required: a wait with no deadline is a run that sits forever, which looks exactly like a sequence that got lost.
{
"key": "hold",
"type": "wait_for_event",
"config": { "eventName": "purchase.completed", "timeout": "3d" }
}Posting purchase.completed to /v1/events/send wakes the run even if no automation is triggered by that event — the response tells you how many runs it resumed. If the event and the deadline land together, exactly one arm is taken: the contact never gets both the thank-you and the reminder.
Waiting for a particular kind of event
Add a filterRule when the same event name can mean different things. It uses exactly the same shape as a condition, read against the arriving payload.
{
"key": "hold",
"type": "wait_for_event",
"config": {
"eventName": "payment.completed",
"timeout": "3 days",
"filterRule": {
"type": "rule",
"field": "event.status",
"operator": "eq",
"value": "succeeded"
}
}
}An event that doesn't match leaves the run exactly where it was. Without the filter, a failed payment would wake this run and send a receipt for money that never arrived — and you would hear about it from the person who got it.
When somebody stops receiving the sequence
This is checked before every send, not once at the trigger. That is the whole point of a delay step: days pass, and in those days people unsubscribe, bounce, and are deleted. The reason is recorded on the run, so "why did this person stop getting emails" always has an answer.
| Reason | When |
|---|---|
unsubscribed | The contact unsubscribed from marketing email. |
topic_opt_out | They left this automation's topic. Sequences on your other topics keep running. |
bounced | The address hard-bounced, here or anywhere else on the platform. |
complained | They marked a message as spam. |
contact_deleted | The contact was removed. The run's history survives. |
automation_archived | You archived the automation, which stops live runs. |
step_failed | A step failed permanently. The rest of the sequence does not run. |
completed_condition_met, completed_condition_not_met | A condition took an arm that had nothing wired to it. A finished sequence, named so the run still explains itself. |
completed_event_received, completed_timeout | The same, for a wait step. |
Topics
Attaching an automation to a topic means an unsubscribe is scoped rather than total. Someone who leaves "Onboarding" stops receiving your onboarding sequence and keeps receiving "Product updates". Without a topic, only a blanket unsubscribe ends the run.
Editing a running automation
You cannot. A PATCH on an enabled automation returns 422 asking you to disable it first. Disabling stops new entries and leaves people already mid-sequence alone; they finish on the version they started with.
The alternative is editing step 3 while a thousand people wait at step 2, and nobody afterwards being able to say what any of them received.
Changing one without stopping it
Duplicate it, edit the copy, enable the copy and archive the original. The copy starts disabled at version 1 with no runs of its own, so it is a new sequence that happens to look like an old one rather than a continuation of it — and nobody mid-flight is disturbed while you work.
curl -X POST https://api.quolle.com/v1/automations/aut_123/duplicate \
-H "Authorization: Bearer qle_your_api_key"{
"id": "c0dcb200-1673-41e9-86fa-a251aa84773e",
"name": "Welcome series (copy)",
"status": "disabled",
"version": 1,
"steps": [ ... ],
"connections": [ ... ]
}Disable, or archive?
Disable is a pause on the front door: nobody new enters, everyone inside finishes. Archive is a stop: live runs are cancelled. Use disable while you edit; archive when the sequence is done for good.
The same person triggering twice
While a run is live, a second event for that contact is ignored and the response says so, so a retry after a network timeout never produces two welcome series. Once their run has finished they can enter again, because somebody genuinely signing up twice is something you should be able to see rather than something Quolle quietly swallows.
Retries and failures
A dropped connection is retried with backoff, five times. A mistake that will not fix itself, such as a template that doesn't exist or an unverified from domain, fails the run immediately, because burning five attempts on it only delays you finding out.
Running out of marketing quota is neither. The run holds: the contact keeps their place and the sequence resumes when your account is back in good standing.
Building one without the API
Everything on this page can also be done from the dashboard: steps as a list you add to, branches shown as indented arms, and templates, topics and sending domains chosen from what your account already has. The runs appear underneath, including which way each contact went at a branch.
Being told, rather than asking
Three webhook events cover a run's life: automation.run.started, automation.run.completed and automation.run.failed. The completed event carries endedReason, so you can tell a sequence that finished from one that stopped because the contact unsubscribed — both are completed, and only one is worth acting on.
Cancelling a run or archiving its automation fires nothing: you did that deliberately.
How long runs are kept
Finished runs are deleted once they pass your plan's retention window, the same one that governs your email logs. Runs still in progress are never removed, however old — a sequence with a 90-day delay in it is waiting, not stale.
Watching a sequence run
curl https://api.quolle.com/v1/automations/<id>/runs?status=running \
-H "Authorization: Bearer qle_your_api_key"Each run shows the contact, which step they are on, every step so far with the email it produced, and why it ended if it has. Delivery and opens live on the linked email, so nothing is reported twice from two places that can disagree.
Knowing which events you send
Quolle keeps a list of the event names your application has actually posted. It fills itself — the first time user.created arrives it appears in the list, with a count and the time it was last seen — so the list describes what really happens rather than what somebody remembered to write down.
curl https://api.quolle.com/v1/event-types \
-H "Authorization: Bearer qle_your_api_key"{
"events": [
{
"id": "b77ada3a-4e1c-49af-af39-966b0bead26d",
"name": "user.created",
"description": "Someone finished signing up",
"lastSeenAt": "2026-08-23T11:34:40.081Z",
"seenCount": 1284,
"createdAt": "2026-08-01T09:12:00.000Z"
}
]
}The main use is the trigger picker in the dashboard: choosing a name from a list rather than typing it is the difference between a sequence that fires and a silent typo. You can also register a name ahead of time, or add a description to one that registered itself — posting the same name again updates it rather than conflicting.
curl -X POST https://api.quolle.com/v1/event-types \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "name": "trial.expiring", "description": "Three days left" }'Registering is optional and always will be. Refusing an unregistered event would mean an outage every time you ship a new one before remembering to declare it. An email platform that drops events over paperwork is worse than one with an untidy list.
Deleting a name only removes it from the list. Automations triggering on it keep working — the registry describes what happens, it does not govern it.
Endpoints
| Method & path | Description |
|---|---|
POST /v1/events/send | Trigger. event plus contactId or email, optional payload. |
POST /v1/automations | Create. Always starts disabled. |
GET /v1/automations | List, with run counts and how many contacts are enrolled. |
GET /v1/automations/<id> | Fetch one, including its steps. |
PATCH /v1/automations/<id> | Edit. Refused while enabled. |
POST /v1/automations/<id>/duplicate | Copy it. The copy starts disabled at version 1, named “… (copy)”. |
POST /v1/automations/<id>/enable | Freeze the current version and start accepting events. |
POST /v1/automations/<id>/disable | Stop new entries. Live runs continue. |
POST /v1/automations/<id>/archive | Stop for good. Live runs are cancelled. |
GET /v1/automations/<id>/runs | Runs, filterable by status. |
POST /v1/automations/runs/<id>/cancel | Stop one person's run. |
GET /v1/event-types | Event names you have sent, most recently used first. |
POST /v1/event-types | Register a name, or describe one that registered itself. |
DELETE /v1/event-types/<id> | Remove a name from the list. Live automations are unaffected. |
What this costs
Automations are included in your marketing plan. There is no per-email or per-run charge. What is limited is how many contacts can be in a sequence at once, and contacts leave that count as their sequences finish.
At the limit, new triggers are refused with a 402 naming the cap and your current count. Runs already in flight are never cut short: refusing new entries is a limit, but stopping someone three emails into a five-email series is a broken product. See pricing for the numbers on each plan.