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.
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_exceededClosed 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.
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.