WarmHawk
Docs / API reference / Leads & campaigns

Leads & campaigns.

Every lead-ingest route and the full campaign lifecycle, field by field. All routes below require Authorization: Bearer $WARMHAWK_KEY except POST /v1/leads/webhook, which is deliberately public.

Three lead-ingest paths (single create, CSV import, unauthenticated webhook) share one validation function and an open customFields object; DELETE /v1/leads/erase handles GDPR erasure. Campaigns are created with a template (spintax-capable fallback body) and an aiPromptTemplate (mustache-placeholder AI instructions). Each campaign picks the mailboxes it sends from (mailboxIds) and can carry up to three follow-ups (steps). unsubscribeUrlTemplate is optional: without it every email links to the built-in unsubscribe page. Launch is gated on picked senders, a mailing address on every sending domain, a working unsubscribe link, non-empty copy and the bounce circuit breaker.

Leads

RouteWhat it does
GET /v1/leads?campaignId=List leads, optionally scoped to a campaign. Returns { leads, total }.
POST /v1/leadsSingle-lead create. Body: campaignId, email, firstName?, lastName?, company?, customFields?. 201, or 409 on suppressed/duplicate.
POST /v1/leads/webhookUnauthenticated ingest webhook — no bearer token. Rate-limited (30/min). Skips (200) rather than errors on suppressed/duplicate.
POST /v1/leads/importBulk CSV import — multipart/form-data, fields: campaignId, file. Caps: 50,000 rows / 10MB.
POST /v1/leads/:id/suppressManually suppress a lead (adds to the suppression list, flips status).
DELETE /v1/leads/eraseGDPR erasure by email. Body: { email }. Anonymizes PII across every campaign, preserves aggregate counts.
DELETE /v1/leads/:idHard delete one lead row by id. 204, or 404 if not found.
API details (Tier 0 / self-hosters) · POST /v1/leads/import — multipart, not JSON▸
curl -X POST https://your-instance/v1/leads/import \
  -H "Authorization: Bearer $WARMHAWK_KEY" \
  -F "campaignId=camp_g7h8i9" \
  -F "file=@leads.csv"

Full walkthrough with request/response bodies for every lead route: Leads & enrichment.

Campaigns

RouteWhat it does
GET /v1/campaignsList campaigns, including mailboxIds, steps, senders (each ticked mailbox and whether its domain has an address), launch { canLaunch, problems }, progress and leadsCount/sentCount/repliesCount/domainsCount/lastActivityAt.
GET /v1/campaigns/:idFetch one campaign, with mailboxIds and steps. 404 if not found.
GET /v1/campaigns/:id/launch-checkThe launch check without launching. Returns { canLaunch, problems, warnings, passed }.
POST /v1/campaignsCreate. Body: name, aiPromptTemplate, template?, subject?, aiProvider?, unsubscribeUrlTemplate?, mailboxIds?, steps?. Returns the row plus contentQuality.
PATCH /v1/campaigns/:idUpdate content fields, bounceRateThreshold, pausedForBounceRate, mailboxIds or steps. mailboxIds and steps each replace the whole list. status is refused (422): use launch or pause. Recomputes contentQuality when template or subject changes.
POST /v1/campaigns/:id/launchRuns the launch check and sets status: ACTIVE. Otherwise 422 with every problem at once: NO_SENDERS, DOMAIN_NO_ADDRESS (one per domain), NO_UNSUBSCRIBE, BOUNCE_PAUSED, EMAIL_EMPTY, STEP_EMPTY. Warnings never block.
POST /v1/campaigns/:id/pauseSets status: PAUSED.
DELETE /v1/campaigns/:idArchives (status: ARCHIVED) — does not hard-delete.
API details (Tier 0 / self-hosters) · POST /v1/campaigns — request body▸
{
  "name": "Q3 outbound — agencies",
  "template": "Hi there — {quick question|one question} for you today?",
  "aiPromptTemplate": "Write a 2-sentence opener to {{firstName}} at {{company}}.",
  "aiProvider": "GEMINI",
  "unsubscribeUrlTemplate": "https://yourcompany.com/unsubscribe?email={{email}}",
  "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": false }
  ]
}
API details (Tier 0 / self-hosters) · POST /v1/campaigns/:id/launch — 422 when it can't launch yet▸
{
  "error": "acme.com has no mailing address; Follow-up 2 needs backup text",
  "problems": [
    { "code": "DOMAIN_NO_ADDRESS", "message": "acme.com has no mailing address",
      "domainId": "dom_a1b2c3", "domainName": "acme.com", "mailboxCount": 2 },
    { "code": "STEP_EMPTY", "message": "Follow-up 2 needs backup text", "position": 2 }
  ],
  "warnings": []
}

Full field reference, spintax syntax, and the AI personalization mechanics: Campaigns, AI & content quality.