Two content fields, one job each.
A campaign ties a message to a send policy. Content quality (spam score, spintax validity) is evaluated on every save, not just before send — so you see the signal while you’re still writing, not after launch.
A WarmHawk campaign has two content fields: template (the literal fallback body, supports native spintax variation) and aiPromptTemplate (instructions for your BYOK Gemini/Claude key, supports flat {{fieldName}} placeholders). Every create/update computes a contentQuality block — spam-word score plus spintax group count. A campaign sends only from the mailboxes it picks, can add up to three follow-ups in the same thread, and launching enforces CAN-SPAM (an address on every sending domain, a working unsubscribe link) and the bounce circuit breaker before it will go live.
Create and update
API details (Tier 0 / self-hosters) · POST /v1/campaigns▸
curl -X POST https://app.yourcompany.com/v1/campaigns \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Q3 outbound — agencies",
"template": "Hi there — {quick question|one question} for you today?",
"aiPromptTemplate": "Write a 2-sentence opener to {{firstName}} at {{company}}, referencing {{recentNews}}.",
"aiProvider": "GEMINI",
"unsubscribeUrlTemplate": "https://yourcompany.com/unsubscribe?email={{email}}"
}'API details (Tier 0 / self-hosters) · Response (201) — includes contentQuality▸
{
"id": "camp_g7h8i9",
"name": "Q3 outbound — agencies",
"status": "DRAFT",
"contentQuality": { "spamScore": { "score": 2, "flaggedTerms": [] }, "spintaxGroupCount": 1 }
}PATCH /v1/campaigns/:id takes the same content fields (plus bounceRateThreshold and pausedForBounceRate for a dashboard’s “resume” action) and recomputes contentQuality whenever template or subject changes. It refuses status and any unknown field with 422, so a campaign only goes live through the launch check below. Malformed spintax — an unbalanced {/} — returns 422 on either call, since it would break rendering at send time, not just read oddly.
Native spintax
Syntax: {option one|option two|option three}, resolved to one uniformly-random option per send. Groups can nest. It works independently of AI configuration, so a campaign with no AI provider at all still gets per-send variation instead of one identical template hitting every lead:
API details (Tier 0 / self-hosters) · Spintax in template▸
Hey {there|friend} — {quick|fast} question about your {outbound|cold email} process?BYOK AI personalization
Save a Gemini or Claude key once (validated with one lightweight call before it’s encrypted and stored):
API details (Tier 0 / self-hosters) · POST /v1/ai-providers▸
curl -X POST https://app.yourcompany.com/v1/ai-providers \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider": "GEMINI", "apiKey": "YOUR_GEMINI_KEY", "model": "gemini-2.5-flash" }'Set aiProvider on the campaign and every send fills aiPromptTemplate’s {{fieldName}} placeholders from the lead’s firstName/lastName/company and its flat customFields, then hands the whole lead context to your key to write the finished body — never the other way around (WarmHawk never routes your key through a shared gateway). Delete a key with DELETE /v1/ai-providers/:provider; campaigns referencing it fall back to sending template unpersonalized rather than breaking.
Every AI-generated send that resolves to an EU recipient gets the EU AI Act Article 50 disclosure marker appended automatically — a fallback (unpersonalized) send has nothing to disclose, so the marker only appears on genuinely AI-written content. See Guardrails & compliance.
Senders and follow-ups
mailboxIds is the list of mailboxes a campaign sends its first emails from. The queue rotates across only those, least recently used first, so one client’s campaign never goes out from another client’s mailbox. New mailboxes are never added to a campaign on their own. A warming mailbox can be picked and joins the rotation once its warm-up finishes.
API details (Tier 0 / self-hosters) · PATCH /v1/campaigns/:id — senders and follow-ups▸
curl -X PATCH https://app.yourcompany.com/v1/campaigns/camp_g7h8i9 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mailboxIds": ["mbx_a1b2c3", "mbx_d4e5f6"],
"steps": [
{ "waitDays": 3, "body": "Just floating this back up — worth a look?", "aiRewrite": false },
{ "waitDays": 4, "body": "Last note from me on this one.", "aiRewrite": true }
]
}'steps holds up to three follow-ups, each waiting waitDays (1–30) after the email before it. Both lists replace the whole set on every call. How a sequence runs:
- Same mailbox. A lead is pinned to the mailbox that sent its first email, and every follow-up goes from it. If that mailbox can’t send, the follow-up waits rather than switching sender.
- Same thread. The subject is “Re:” plus the first subject, with In-Reply-To and References set, so the inbox shows one conversation.
- Follow-ups first. Due follow-ups take a mailbox’s daily capacity before new first emails, so a started sequence keeps its timing.
- Stops on its own. A reply to any email in the thread, a bounce, an unsubscribe or a suppression ends the sequence for that lead.
- Unticking a mailbox stops its new first emails only. It still sends the follow-ups for leads it already started.
Each follow-up’s body is what sends when AI is off or fails; with aiRewrite: true your AI key rewrites it using the campaign’s AI settings. The footer and unsubscribe link are the same as the first email’s, from the pinned mailbox’s domain.
Launch and pause
API details (Tier 0 / self-hosters) · POST /v1/campaigns/:id/launch▸
curl -X POST https://app.yourcompany.com/v1/campaigns/camp_g7h8i9/launch \
-H "Authorization: Bearer YOUR_API_KEY"Runs the launch check and returns every problem at once in a 422 — no mailbox picked, a sending domain with no mailing address, an unsubscribe link missing on an install with no domain to serve the built-in page, an empty first email or follow-up, or the campaign currently pausedForBounceRate (see Sending safely & domain health) — otherwise sets status: "ACTIVE" and returns any warnings (a mailbox with no sender name, every mailbox still warming, no leads yet, a merge field that is blank or unknown). GET /v1/campaigns/:id/launch-check runs the same check without launching. POST /v1/campaigns/:id/pause is the reverse. DELETE /v1/campaigns/:id archives (sets status: "ARCHIVED") rather than hard-deleting — campaign history isn’t personal data, so GDPR erasure is per-lead, not per-campaign.
API details (Tier 0 / self-hosters) · GET /v1/campaigns — list view with rollups▸
curl https://app.yourcompany.com/v1/campaigns -H "Authorization: Bearer YOUR_API_KEY"
# each row includes mailboxIds, steps, senders, launch, progress,
# leadsCount, sentCount, repliesCount, domainsCount, lastActivityAtQuestions
Campaigns, AI & content quality: questions worth answering up front
What is the difference between template and aiPromptTemplate?+
template is the literal body sent as-is — or used as a fallback if no AI provider is configured or personalization fails — and it supports spintax {option one|option two} variation. aiPromptTemplate is the instruction handed to your AI key, which then writes the actual send; it supports flat {{fieldName}} placeholders instead of spintax.
Does a low content-quality score block a campaign from launching?+
No. The spam-word score is advisory — computed on every save and returned in the response so the dashboard's Content Quality tab can surface it, but it never hard-blocks a launch. Unbalanced spintax syntax does block a save (422), since it would break rendering at send time.
What happens if my BYOK AI key is missing or the call fails?+
WarmHawk retries once after a short delay, then falls back to sending the campaign's plain template as-is rather than stalling the send — aiPersonalizationFailed: true is set so it's visible, but the lead still gets emailed on schedule.
Why does launching a campaign sometimes return a 422?+
The launch check found something to fix, and the 422 lists every problem at once, not just the first: no mailbox picked to send from (NO_SENDERS), a sending domain with no mailing address (DOMAIN_NO_ADDRESS, one per domain), no working unsubscribe link (NO_UNSUBSCRIBE), the bounce circuit breaker tripped (BOUNCE_PAUSED), or an empty first email or follow-up (EMAIL_EMPTY, STEP_EMPTY). A campaign with no unsubscribeUrlTemplate of its own uses the unsubscribe page WarmHawk serves on your install’s domain, so NO_UNSUBSCRIBE only refuses an install that has no domain set. GET /v1/campaigns/:id/launch-check runs the same check without launching.
Do follow-ups come from a different mailbox if the first one is busy?+
No. A lead is pinned to the mailbox that sent its first email, and every follow-up goes from that one mailbox as a reply in the same thread. If that mailbox can’t send (paused, needs reconnecting, at its daily cap, or its domain lost its address), the follow-up waits rather than switching sender.