Campaigns: Draft, Schedule & Send
Create and send email campaigns to audiences
A campaign is a one-time email broadcast to an audience. Create a campaign, optionally test it, then send it to hundreds or thousands of recipients.
Create a Campaign (Draft)
Create a campaign in draft status. name, subjectA and exactly one of
templateId / html are required — omit subjectA and the request fails
with 422.
cURL
curl -X POST https://api.tratto.email/v1/campaigns \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"name":"Q3 Summer Sale",
"subjectA":"Our summer sale starts today",
"templateId":"tmpl_abc123",
"audienceId":"aud_abc123"
}'Response: the id, and nothing else.
{
"data": {
"id": "camp_xyz789"
}
}Fetch the campaign with GET /v1/campaigns/{id} to read its status, content
and stats.
Body fields:
| Field | Required | Notes |
|---|---|---|
name | yes | max 200 characters |
subjectA | yes | the subject line, max 998 characters |
subjectB | no | second subject for an A/B test |
templateId | one of | copies the template's content into the campaign. Exactly one of templateId or html: both, or neither, is a 422. The template must be published, or the request is a 422 too |
html | one of | inline HTML, no template needed |
audienceId | no | omitted = every contact in the workspace |
fromName | no | falls back to the workspace marketing sender, then to the general default sender |
fromEmail | no | falls back to the workspace marketing sender, then to the general default sender |
If neither the body nor the workspace supplies a sender, the request fails with 422 and a message telling you to set a default sender. The sender is written on the campaign when it is created: see Senders.
The campaign owns a copy of the template's content from creation onward: editing the template later does not change a campaign already created from it, and editing the campaign does not touch the template. For a Markdown campaign, see Editing markdown inside a campaign.
The template has to be published. Creating a campaign from a draft
template, or swapping one in with PATCH /v1/campaigns/:id, fails with 422
TEMPLATE_NOT_PUBLISHED and the message
Template 'tmpl_abc123' is not published and cannot be used to send. A missing
template is a 404. Publish it first: see
Templates.
Because the content is copied at creation, the check is not retroactive: campaigns created before this gate existed keep their copy and still send.
A/B Subject Test
Set subjectB to test a second subject line against subjectA. Content,
sender and audience stay the same: only the subject changes.
- How recipients are split. Subjects alternate recipient by recipient: the
first recipient gets
subjectA, the secondsubjectB, and so on. Each subject reaches half the audience, with one more for A on an odd count. - After a pause. A resumed campaign carries on the alternation where it stopped instead of restarting on A, so the split stays even.
- Test sends.
POST /v1/campaigns/:id/test-sendalways usessubjectA, prefixed with[TEST]. - Removing B. Send
"subjectB": nullwithPATCH /v1/campaigns/:id. Like every edit, this works only on adraftcampaign: any other status is a 409Campaign is '<status>'. Only draft campaigns can be edited. - No automatic winner. Tratto does not pick a subject or send the better one to the rest of the list: both go out until the campaign ends. Compare the two and decide.
Reading the result
GET /v1/campaigns/:id/stats adds a variants object with one entry per
subject, both in stats (counts) and in rates (percentages):
{
"stats": {
"variants": {
"a": { "sent": 2500, "delivered": 2475, "opened": 1300, "clicked": 390, "bounced": 20, "untracked": 70 },
"b": { "sent": 2500, "delivered": 2475, "opened": 1175, "clicked": 352, "bounced": 20, "untracked": 60 }
}
},
"rates": {
"variants": {
"a": { "deliveryRate": 99, "openRate": 53.5, "clickRate": 16.05, "bounceRate": 0.8 },
"b": { "deliveryRate": 99, "openRate": 48.16, "clickRate": 14.43, "bounceRate": 0.8 }
}
}
}Each variant's rates are computed on that variant's own sent, and its open
and click rates leave out that variant's untracked recipients, the same rule
as the campaign totals (below). variants is present
only on a campaign that started sending with a subjectB: on any other
campaign the key is absent, not filled with zeros.
Campaign Variables
A campaign fills exactly four variables, resolved per recipient when the send goes out:
| Variable | Value |
|---|---|
email | the contact's email address |
firstName | the contact's first name |
lastName | the contact's last name |
unsubscribe_url | the unsubscribe link for that contact |
Write them with double braces, in the subject line and in the body:
<p>Hi {{firstName}}, the summer sale starts today.</p>
<p><a href="{{unsubscribe_url}}">Unsubscribe</a></p>Three things decide whether they work.
Case matters. {{firstName}} is filled, {{firstname}} is not. The
spelling in the table above is the only spelling that works.
A single brace is not a variable. {firstName} is left untouched and the
recipient reads it as text. Only {{firstName}} is substituted.
A name outside the four becomes an empty string. {{companyName}} in a
campaign does not fail the send, and it does not leave the placeholder on
screen either. It disappears, and the sentence around it arrives with a hole
in it: Hi {{companyName}} customer is delivered as Hi customer. Nothing
in the inbox shows that a variable was ever there, which is why the draft is
the place to catch it.
Why the check exists on campaigns and not on templates
A campaign is sent to contacts, so Tratto knows the four names above and can
flag anything outside them while the campaign is still a draft. A template sent
with POST /v1/emails gets its variables from whoever
calls the API, so there is no closed set to check against and any check would
be a guess. On a template the only defence is passing every placeholder the
template uses on every send. See
Templates.
Test Send
Send a test email before launching to make sure everything looks correct.
cURL
curl -X POST https://api.tratto.email/v1/campaigns/camp_xyz789/test-send \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"to":"your-email@example.com"}'A test email is sent to your address immediately.
If Tratto cannot queue the test email, the API returns 503 SERVICE_UNAVAILABLE
with a Retry-After header: nothing was sent and no email was created, so you
can retry. See Error Codes.
Free, with a daily cap
Test sends are free: they do not count against the
monthly quota. Each call sends to one recipient. With
API 0.24.0 or later, a workspace can make 20 test sends per day on
Free, 100 on Starter and Growth and 200 on Scale, template and campaign test sends together, and the count resets at midnight
UTC. Past the cap the API answers 429 QUOTA_EXCEEDED and nothing is sent: do
not retry before the reset. A call refused with 404 or 422 does not use
the cap; an accepted call does, even if it then answers 503, because the cap
is taken before the email is handed to the queue.
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Test send daily limit of 20 per workspace reached.",
"suggestion": "The limit resets at midnight UTC. Test sends are free and do not count against your monthly quota. Starter raises the test-send limit to 100 a day. The account owner can upgrade at https://app.tratto.email/settings/billing.",
"docs": "https://docs.tratto.email/en/docs/error-codes"
}
}Send Immediately
Send the campaign to all contacts in the audience right now.
cURL
curl -X POST https://api.tratto.email/v1/campaigns/camp_xyz789/send \
-H "Authorization: Bearer tratto_live_..."Response:
{
"data": {
"status": "sending"
}
}The campaign status becomes sending, then completed when the send is done.
Sending is refused with 409 unless the campaign is draft or paused, and it
is checked against your remaining monthly quota before
it starts (that one is a 429, not a 409). A paused campaign is refused with a
409 too while its probe wave is still being measured: see
Resuming during the probe wait.
The send is progressive, not instantaneous. Emails leave at a capped rate
per second, so a campaign with many recipients stays sending for a while
before it completes. That is the send working, not a fault. The cap is an
operational setting and can change, so do not build a timeout around a number
you measured once.
On top of that, a campaign large enough for it starts with a probe wave and pauses in the middle on purpose.
Schedule for Later
Schedule the campaign with scheduledAt (ISO 8601). The field is not
called sendAt.
cURL
curl -X POST https://api.tratto.email/v1/campaigns/camp_xyz789/send \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"scheduledAt":"2026-10-01T09:00:00Z"}'The campaign status becomes scheduled and it sends automatically at that
time. A scheduledAt in the past is treated as "send now".
To cancel a schedule and return the campaign to draft:
curl -X POST https://api.tratto.email/v1/campaigns/camp_xyz789/unschedule \
-H "Authorization: Bearer tratto_live_..."Two different sends answer 409 here, and the message tells them apart:
- the send has already started (
statusissending): the dispatcher picked the campaign up before you cancelled it - the campaign is waiting out its probe wave: it reads
scheduledwithscheduledReason: "bounce_check", but part of the list has already received the email, so there is nothing left to return todraft
Either way, pause it instead: the recipients already reached cannot be un-sent. Pausing a campaign waiting out its wave does not release you from the wait, though: resuming it is refused until the wait is over, which is the next 409 to know about.
The Probe Wave
A campaign big enough for it does not go out to the whole list at once. The first send reaches a probe wave: 5% of the remaining recipients, never fewer than 50 and never more than 500. Tratto then waits 15 minutes, counts how many of those addresses bounced permanently, and only then releases the rest.
Why it exists. Tratto sends over a shared pool (IP Warming) and the permanent bounce rate is what decides whether sending is cut off on that pool, for every workspace on it. A list that was never cleaned does not cost only the sender who owns it. The wave caps that cost at a few dozen emails instead of the whole list.
What you see while it runs. After the wave the campaign goes back to
scheduled for those 15 minutes, with a scheduledAt 15 minutes ahead.
A campaign you never scheduled can therefore read as scheduled a moment after
you sent it. It resumes on its own, and stats.sent already counts the wave.
For the whole wait scheduledReason reads "bounce_check", which is what
tells this apart from a date you set: see
Which kind of scheduled.
Pausing during the wait does not skip the wait. The deadline is fixed when the wave goes out and it does not move, so pausing holds the campaign where it is instead of ending the check early. Until that deadline passes, sending the campaign again answers 409: see Resuming during the probe wait.
When there is no wave at all:
- the wave would cover the whole remaining list anyway. It would prove nothing, so the campaign sends and completes as before. Small campaigns are unaffected
- the campaign has already reached somebody. The wave happens once per campaign: resuming after a pause, a missed schedule or an exhausted quota picks up where it left off without a second wait
If the wave's bounce rate crosses the threshold the campaign pauses itself. See Why a campaign is paused.
Campaign Status Lifecycle
The five statuses are draft, scheduled, sending, paused and
completed. There is no sent.
draft
├─→ (test-send)
├─→ send → sending → completed
└─→ send + scheduledAt → scheduled → sending → completed
└─→ unschedule → draft
sending ─→ probe wave sent ─→ scheduled (15 min) ─→ sending → completed
└─→ paused (bounce_rate)
sending / scheduled ─→ pause → paused ─→ send (resume) → sendingscheduled therefore means one of two things: a send you scheduled, or a
campaign waiting out its probe wave. No status was added for the
wave, so a client that switches on status alone cannot tell the two apart.
scheduledReason is the field that can.
Which kind of scheduled
scheduledReason is read-only, string | null, and comes back on
GET /v1/campaigns/{id} and on GET /v1/campaigns. It has one value today:
scheduledReason | What it means | What you can do |
|---|---|---|
null | You scheduled the send yourself, with scheduledAt | unschedule returns it to draft; pause works too |
"bounce_check" | The campaign is not scheduled, it is sending. It has already gone out to its probe wave and is waiting out the 15-minute bounce check | Nothing: the wait ends on its own. unschedule answers 409, because part of the list already has the email and those cannot be recalled. pause stops the rest |
The field is additive and nullable: it appears next to fields that were
already there, so no existing client breaks by ignoring it. What does break is
assuming every scheduled campaign is one you can still unschedule.
Pause a Campaign
Stop a scheduled or in-progress campaign. Only a sending or scheduled
campaign can be paused; anything else is a 409.
cURL
curl -X POST https://api.tratto.email/v1/campaigns/camp_xyz789/pause \
-H "Authorization: Bearer tratto_live_..."Resuming is a fresh POST /:id/send on the paused campaign — there is no
separate resume endpoint. It picks up only the recipients it never reached, so
nobody is mailed twice. One case answers 409 instead of resuming, for up to
fifteen minutes: see Resuming during the probe wait.
A paused campaign can be resumed; only a draft one can be deleted.
Resuming during the probe wait
Pause a campaign in the middle of its probe wave, then send it
again, and you would be releasing the rest of the list before anyone has looked
at how the wave bounced. Two clicks, and the check the wave exists for never
happens. So that send is refused: while the 15-minute wait still has time to
run, POST /:id/send on that campaign answers 409, and the message names
how many emails the wave already reached and roughly how many minutes are left.
The scheduled form is refused the same way. POST /:id/send with a
scheduledAt during the wait does not get around it, so a new date is not a
way out either.
There are two things to do, and only two. Send the campaign again once the
wait is over, and the rest of the list goes out. Or leave it paused and stop
there. unschedule is not a third one: on a paused campaign it answers 409
as well, so it leads nowhere.
Either way, the recipients the wave already reached keep the email. They cannot be recalled, and that is the part worth knowing before you decide to stop.
The 409 costs the wait, not the campaign: it clears on its own once the deadline
passes, and the send goes through. Note that it carries the same CONFLICT code
as every other state conflict on these routes, so a client cannot tell it apart
from "the campaign is already sending" without reading message.
Why a campaign is paused
A campaign can also be paused for you, and paused alone does not say by
whom. pausedReason is the field that tells the cases apart, the
counterpart of scheduledReason on a scheduled
campaign, and it has four values plus null:
pausedReason | What happened | What to do |
|---|---|---|
null | No reason was recorded. Normally it means you paused it yourself, with POST /:id/pause | Resume when you want, unless the pause fell inside the probe wait |
"quota_exceeded" | The send did not fit the remaining monthly quota of the account, either before it started or mid-flight | Wait for the reset or raise the plan, then resume. It reaches only the recipients it missed |
"quota_unavailable" | The quota could not be counted because of an error on our side. The quota is not exhausted | Resume it |
"schedule_missed" | A campaign you scheduled was picked up more than an hour after its scheduledAt and was not sent that late | Resume by hand if the send is still wanted, or reschedule it |
"bounce_rate" | Too many permanent bounces among the emails already sent | Clean the list before resuming. See below |
schedule_missed only applies to a date you set. A campaign parked for
its probe wave also reads scheduled, but it is not paused this
way however long the wait runs over the hour: the rest of the list is not a
send nobody expects any more, it is the end of the one you asked for, so it
goes out. Told apart by
scheduledReason: "bounce_check" is never paused for a
missed schedule, null can be.
null covers two pauses that behave differently. One is the campaign you
paused yourself and can resume whenever you like. The other is a campaign you
paused while its probe wave was being measured, which cannot be
resumed until the wait is over. Nothing in the response singles that one out:
pause does not write pausedReason, and scheduledReason only reads
"bounce_check" while the status is scheduled, so on a paused campaign it is
null as well. What tells you is the resume itself, which answers 409 with the
minutes left. See Resuming during the probe wait.
The next POST /:id/send clears pausedReason, so read it before resuming.
Stopped on the bounce rate
Tratto counts the permanent bounces of the emails a campaign has actually
released. Temporary ones do not count. Over 5% of them, with a floor of 3
bounces, the campaign pauses itself with pausedReason: "bounce_rate" and an
email goes to the workspace owner. (The two numbers are one decision seen
twice: 3 is 5% of 50, the smallest probe wave.)
The 5% is measured against everything the campaign has sent, counted from
its first email and across every resume, not against the wave or the batch in
flight. That number is stats.sent, so the bounce count that stops the
campaign is 5% of it, never fewer than 3. It moves as the campaign sends: the
further along a campaign is, the more permanent bounces it takes to trip the
brake.
5% is the same line the shared sending pool is judged on, which is why we stop there rather than later.
Nothing restarts by itself. There is no automatic retry and no timer: the
campaign stays paused until you send it again. That is deliberate: a list
that bounced at that rate will bounce again unless something changes about the
list.
Before resuming, remove the addresses that bounced (Tratto suppresses them for future sends, but they stay on your list until you delete them) and check where the list came from.
What a resume gives you is a fresh measurement, not a 50-email guarantee. A
campaign restarted after a bounce_rate stop sends a new probe
wave of 50 recipients and waits the same 15 minutes before
releasing the rest. Whether those 50 are enough to catch a list you did not
clean depends on the threshold they are measured against, and that threshold is
5% of everything the campaign has already sent:
- A campaign stopped early has sent little, so its threshold is still at the floor of 3 or close to it. The wave trips it on its own, and nothing further goes out.
- A campaign already well into its list has raised its own threshold above anything 50 recipients can produce. The wave passes the check, the rest of the list starts going out, and the stop comes from a later batch instead.
The brake engages in the second case too, just later and after more email has
left. To see where you stand, read stats.sent and take 5% of it: that is how
many permanent bounces it now takes to stop this campaign. Cleaning the list is
the only thing that keeps it from stopping a second time.
Get Campaign Stats
View delivery statistics after sending.
cURL
curl https://api.tratto.email/v1/campaigns/camp_xyz789/stats \
-H "Authorization: Bearer tratto_live_..."Response: counts and rates are two separate objects.
{
"data": {
"campaignId": "camp_xyz789",
"status": "completed",
"stats": {
"total": 5000,
"sent": 5000,
"delivered": 4950,
"opened": 2475,
"clicked": 742,
"bounced": 40,
"skipped": 12,
"untracked": 130
},
"rates": {
"deliveryRate": 99,
"openRate": 50.82,
"clickRate": 15.24,
"bounceRate": 0.8,
"untracked": 130
}
}
}Rates are percentages, rounded to two decimals: 99 means 99%, not 99 in
a hundred thousand and certainly not 0.99. An alert written as
deliveryRate < 0.95 will never fire — you want < 95. Same scale as
Analytics.
Field notes:
totalis the number of recipients;sentcounts the ones actually dispatchedskipped— sends cancelled because the contact unsubscribed after dispatch. They never happened, so they feed no rateuntracked: recipients who had opted out of open and click tracking when the campaign reached them. Their opens and clicks are never recorded, so they are excluded from the openRate/clickRate denominator, never estimated:openRateisopened / (total - untracked). Counting them would push the rates down for people we deliberately do not measure. Theuntrackedkey insideratesis that same count, not a ratestats.variantsandrates.variantsappear only on a campaign that ran an A/B subject test- There is no
complainedfield, and nototalSent
List Campaigns
Get all campaigns.
cURL
curl "https://api.tratto.email/v1/campaigns?status=completed&limit=50" \
-H "Authorization: Bearer tratto_live_..."Query parameters:
status: Filter by status —draft,scheduled,sending,paused,completed.sentis not a valid value and returns 422q: Name prefix search, case-insensitive and accent-sensitive (salmatchesSaldi estate,estatedoes not). While it is set, results are ordered by name instead of newest-firstlimit: Results per page (default 50, max 100)after: Cursor for pagination
Get Campaign Details
Retrieve a specific campaign.
cURL
curl https://api.tratto.email/v1/campaigns/camp_xyz789 \
-H "Authorization: Bearer tratto_live_..."Campaign Best Practices
1. Always Test First
Send a test email to yourself before launching to any audience.
2. Use Descriptive Names
✅ Q3 Summer Sale - Newsletter Segment
❌ Campaign 1, Test
3. Review Audience Size
Count the recipients before sending —
GET /v1/contacts?status=subscribed&audienceId=… returns the same
pagination.total the send gate counts. An audience with no subscribed
contacts means no one receives it.
4. Schedule During Optimal Times
Consider your audience's timezone when scheduling. Early morning or lunch time usually see higher open rates.
5. Monitor Delivery Stats
After sending, review bounce, complaint, and open rates to understand engagement.
6. Create New Campaigns, Don't Re-send
A campaign reaches each recipient once. Re-sending a completed campaign is a
409; resuming a paused one only covers the recipients it never reached. To
mail the same audience again, create a new campaign.
Next Steps
Edit this page on GitHub
Last updated on