Preflight a campaign
/api/v1/campaigns/{campaign}/preflightChecks a campaign for the problems that would stop it — unverified domain, missing unsubscribe, empty audience, insufficient allowance — without sending anything.
This endpoint takes no request body.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
campaignrequired | path | string | The campaign identifier. |
Request
/api/v1/campaigns/{campaign}/preflightcurl -X POST 'https://app.mailyte.com/api/v1/campaigns/01JBT8XQ2M9WYC3K4F6R7S8T9V/preflight' \
-H 'Authorization: Bearer mk_live_YOUR_API_KEY'const response = await fetch('https://app.mailyte.com/api/v1/campaigns/01JBT8XQ2M9WYC3K4F6R7S8T9V/preflight', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_YOUR_API_KEY',
},
});
const { data } = await response.json();import requests
response = requests.post(
"https://app.mailyte.com/api/v1/campaigns/01JBT8XQ2M9WYC3K4F6R7S8T9V/preflight",
headers={"Authorization": "Bearer mk_live_YOUR_API_KEY"},
)
data = response.json()["data"]<?php
$response = Http::withToken('mk_live_YOUR_API_KEY')
->post('https://app.mailyte.com/api/v1/campaigns/01JBT8XQ2M9WYC3K4F6R7S8T9V/preflight');
$data = $response->json('data');require "net/http"
require "json"
uri = URI("https://app.mailyte.com/api/v1/campaigns/01JBT8XQ2M9WYC3K4F6R7S8T9V/preflight")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer mk_live_YOUR_API_KEY"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }Response
Success.
dataobjectTHE ONLY READINESS SURFACE in this section. A campaign does not carry its own blockers, because readiness depends on the organization's marketing-review state, the sender, the audience size and today's warm-up ceiling — none of them campaign columns, all of them moving without the campaign being touched. Preflighting a campaign that is not a draft is noise: it reports `not_draft` plus a full credit projection for a send that already happened.
objectstringcampaign_preflightcampaign_idstringstatestringdraft | scheduled | sending | paused | sent | canceled | unknownThe campaign's lifecycle state, from the same map `campaign.state` uses.
sendablebooleanThe verdict. Exactly `blockers === []`, computed in the same expression, and kept because "is there anything in this array" is a worse thing to have to write.
blockersarray<object>Everything that stops this campaign going out. Empty means sendable. Beware: `POST /send` and `POST /schedule` refuse with a validation error that carries the message but NOT the code, so preflight is the only place you get a stable code to branch on.
codestringnot_draft | marketing_review_pending | sender_unverified | no_content | no_unsubscribe | no_audience | warmup_exceeded | marketing_allowance | scheduled_sendsClosed set. `not_draft`: only a draft can be scheduled. `marketing_review_pending`: the ORGANIZATION's marketing application is not approved — this is the gate that stops every new customer's first campaign, and it is not a campaign state because the campaign becomes sendable without being touched. `sender_unverified`: the campaign's sender is not verified. `no_content`: no subject plus HTML body, and no template. `no_unsubscribe`: the HTML carries no way to opt out. `no_audience`: no list and no segment targeted. `warmup_exceeded`: more recipients than today's warm-up ceiling leaves — advisory in one sense, since the real ceiling is enforced on the mail server and this stays silent rather than blocking every campaign when the inventory cannot be read. `marketing_allowance`: the audience is more than this month's marketing emails have left; scheduling answers with a 402 that names the plan covering it. `scheduled_sends`: a later send time on a plan without scheduling.
fieldstringWhich part of the campaign the code is about.
messagestringHuman-readable prose. Not stable — branch on `code`.
warningsarray<object>Worth knowing, does not stop the send.
codestringinsufficient_creditsClosed set of one. `insufficient_credits`: the balance is below the recipient count. NOT a blocker, because scheduling does not refuse on an empty balance — calling it one would make preflight refuse something the API accepts.
fieldstringWhich part of the campaign the code is about.
messagestringHuman-readable prose. Not stable — branch on `code`.
recipient_countintegerThe projected distinct sendable set, by the same rule the send path materialises, so the answer is stable whether or not the recipient rows exist yet. Always an integer — unlike the stats counts, this is a projection that can always be made.
credit_balanceintegerWhole sends, not money: one credit is one recipient. The organization's balance in currency belongs to billing.
personalizationobjectvariablesarray<object>Which template variables the content uses. Called `variables`, not `tags`, because this API already uses `tags` for the send-time correlation labels stamped on every message.
namestringThe variable, without braces. In practice NEVER null — a row exists here only because the scanner matched a named `{{ tag }}` in the content, so there is always a name to publish. The marker is defensive against a malformed scanner row, not a case you have to handle.
systembooleanTrue for variables always available at send time: `email`, `sender_name`, `sender_email`, `organization_name`, `unsubscribe_url`. A system variable's `missing` is always 0. `name` is NOT one of them — it comes off the contact row and a contact may have none, so it is counted like any other contact-backed variable.
missingintegerRecipients in the audience with no value for it. A count, not a rate — `recipient_count` is the denominator if you want one.
defaultstringThe organization's contact-field default, which fills a blank at send time. A variable with a default is never actually empty, so a non-zero `missing` beside a non-null `default` is informational rather than a problem.
has_unsubscribebooleanWhether the content satisfies the compliance gate that `no_unsubscribe` enforces. The blocker is the refusal; this is the observation, and both read the same check.
warmupobjectToday's warm-up ceiling for the sending address. `null` has TWO meanings here and they are not the same thing. Usually it is NONE — the question does not apply, because the organization is not warming a dedicated IP, which is almost all of them, or its address has no ceiling set yet; a zeroed object would read as "no headroom". But it is also null when the IP inventory COULD NOT BE READ — the mail server was unreachable — and this response cannot tell you which happened. That is deliberate on the blocking side: `warmup_exceeded` stays silent rather than refusing every campaign because an inventory read failed, and the real ceiling is enforced on the mail server regardless. It is a genuine gap on the reporting side, recorded in plans/13-public-api-contract/converted/campaigns.md. When the object IS present, every member of it is a real number.
dayintegerWhich day of the warm-up curve this is. Never null when the enclosing `warmup` object is present — the snapshot that builds it either has every figure or is itself null.
ceilingintegerToday's allowance, in recipients. Never null when the enclosing `warmup` object is present: an address whose ceiling has not been set is one the warm-up engine has not started pacing, and that case makes `warmup` itself null rather than reporting a ceiling of zero.
sent_todayintegerRecipients already sent on this address today, counted from our own delivery rollup. Never null when the enclosing `warmup` object is present, and **`0` means NOTHING SENT YET, not "we could not read the meter"** — the unreadable case makes the whole `warmup` object null, it never surfaces as a zero here. That distinction is the difference between a client believing it has a full day's headroom and knowing it has no figure at all.
remaininginteger`ceiling - sent_today`, floored at 0. Kept although it is derived: it is what `warmup_exceeded` compares against, and publishing the comparison the gate actually makes is worth one derived integer. Never null when the enclosing `warmup` object is present.
recipientsintegerWhat THIS campaign would add — the same projection as the top-level `recipient_count`. Never null when the enclosing `warmup` object is present.
completes_onstringThe date the curve finishes. This is the ONE member of `warmup` that really can be null while the object is present, and it means UNKNOWN: the inventory row carries no readable warm-up start date, so there is nothing to add the configured warm-up length to. It does NOT mean the curve never finishes.
Returned inside the standard envelope.
Errors
| Status | When |
|---|---|
401 | The API key is missing, unknown, revoked or expired. All four answer identically, on purpose: distinguishing them would confirm which keys exist. |
403 | The key is valid but may not do this: it lacks the required scope, its IP allowlist does not include you, or this endpoint does not accept API keys. |
404 | No such resource in this organization. |
422 | The request was understood but the values were not acceptable. |
429 | Too many requests, or the organization has spent its sending allowance. `Retry-After` says how long to wait. |
Every status, with what causes it and what to do, is on the error reference.