Preflight a campaign

POST/api/v1/campaigns/{campaign}/preflight
Requires anorganization API keywith the scopecampaigns:read

Checks a campaign for the problems that would stop it — unverified domain, missing unsubscribe, empty audience, insufficient allowance — without sending anything.

Parameters

NameInTypeDescription
campaignrequiredpathstringThe campaign identifier.

Request

POST/api/v1/campaigns/{campaign}/preflight
curl -X POST 'https://app.mailyte.com/api/v1/campaigns/01JBT8XQ2M9WYC3K4F6R7S8T9V/preflight' \
  -H 'Authorization: Bearer mk_live_YOUR_API_KEY'
The key names its own organization, so no X-Organization-ID header is needed.

Response

Success.

  • dataobject

    THE 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_preflight
    • campaign_idstring
    • statestringdraft | scheduled | sending | paused | sent | canceled | unknown

      The campaign's lifecycle state, from the same map `campaign.state` uses.

    • sendableboolean

      The 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

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

      • fieldstring

        Which part of the campaign the code is about.

      • messagestring

        Human-readable prose. Not stable — branch on `code`.

    • warningsarray<object>

      Worth knowing, does not stop the send.

      • codestringinsufficient_credits

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

      • fieldstring

        Which part of the campaign the code is about.

      • messagestring

        Human-readable prose. Not stable — branch on `code`.

    • recipient_countinteger

      The 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_balanceinteger

      Whole sends, not money: one credit is one recipient. The organization's balance in currency belongs to billing.

    • personalizationobject
      • variablesarray<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.

        • namestring

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

        • systemboolean

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

        • missinginteger

          Recipients in the audience with no value for it. A count, not a rate — `recipient_count` is the denominator if you want one.

        • defaultstring

          The 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_unsubscribeboolean

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

    • warmupobject

      Today'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.

      • dayinteger

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

      • ceilinginteger

        Today'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_todayinteger

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

      • recipientsinteger

        What THIS campaign would add — the same projection as the top-level `recipient_count`. Never null when the enclosing `warmup` object is present.

      • completes_onstring

        The 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

StatusWhen
401The API key is missing, unknown, revoked or expired. All four answer identically, on purpose: distinguishing them would confirm which keys exist.
403The 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.
404No such resource in this organization.
422The request was understood but the values were not acceptable.
429Too 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.