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:

FieldRequiredNotes
nameyesmax 200 characters
subjectAyesthe subject line, max 998 characters
subjectBnosecond subject for an A/B test
templateIdone ofcopies 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
htmlone ofinline HTML, no template needed
audienceIdnoomitted = every contact in the workspace
fromNamenofalls back to the workspace marketing sender, then to the general default sender
fromEmailnofalls 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 second subjectB, 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-send always uses subjectA, prefixed with [TEST].
  • Removing B. Send "subjectB": null with PATCH /v1/campaigns/:id. Like every edit, this works only on a draft campaign: any other status is a 409 Campaign 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:

VariableValue
emailthe contact's email address
firstNamethe contact's first name
lastNamethe contact's last name
unsubscribe_urlthe 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 (status is sending): the dispatcher picked the campaign up before you cancelled it
  • the campaign is waiting out its probe wave: it reads scheduled with scheduledReason: "bounce_check", but part of the list has already received the email, so there is nothing left to return to draft

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) → sending

scheduled 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:

scheduledReasonWhat it meansWhat you can do
nullYou scheduled the send yourself, with scheduledAtunschedule 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 checkNothing: 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:

pausedReasonWhat happenedWhat to do
nullNo reason was recorded. Normally it means you paused it yourself, with POST /:id/pauseResume 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-flightWait 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 exhaustedResume it
"schedule_missed"A campaign you scheduled was picked up more than an hour after its scheduledAt and was not sent that lateResume by hand if the send is still wanted, or reschedule it
"bounce_rate"Too many permanent bounces among the emails already sentClean 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:

  • total is the number of recipients; sent counts the ones actually dispatched
  • skipped — sends cancelled because the contact unsubscribed after dispatch. They never happened, so they feed no rate
  • untracked: 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: openRate is opened / (total - untracked). Counting them would push the rates down for people we deliberately do not measure. The untracked key inside rates is that same count, not a rate
  • stats.variants and rates.variants appear only on a campaign that ran an A/B subject test
  • There is no complained field, and no totalSent

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. sent is not a valid value and returns 422
  • q: Name prefix search, case-insensitive and accent-sensitive (sal matches Saldi estate, estate does not). While it is set, results are ordered by name instead of newest-first
  • limit: 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