The full operating reference for the Instantly v2 cold-email API behind Campaign 05. Every endpoint, plus the dozen gotchas we paid for in 403s, bounces, and one campaign that silently went live before it should have.
Prepared by Dove Web Consulting · for the IEXDG operations bench · v1.0 · 2026-06-10
Instantly v2 is a REST API. V1 is deprecated and shares no compatibility, so always build on v2.
| Item | Value |
|---|---|
| Base URL | https://api.instantly.ai · all v2 paths under /api/v2 |
| Auth header | Authorization: Bearer <api_key> (scoped keys, create/revoke in the Instantly UI) |
| Key storage (IEXDG) | source/secrets/instantly_api_key.txt · 68-char base64, prefix NjY4Ym |
| Accept | application/json |
| Content-Type | application/json on every body EXCEPT DELETE /leads (see Law 8) |
The API sits behind Cloudflare. Without a browser User-Agent header you get 403 / error code 1010 (browser-signature ban), and the failure is often swallowed as "returned 0 results." Send a real Chrome UA on every call, exactly like the GHL pattern.
# every request, no exceptions
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36
#-comment lines. The first raw line may be a comment, and passing it as the token returns 401 Invalid authorization header.
Each of these cost a real failure. They are not in the official docs. This table is the most valuable part of this reference.
| # | Law | Why / symptom |
|---|---|---|
| 1 | Send a Chrome User-Agent on every call | Cloudflare 403 / code 1010 otherwise, often swallowed as "0 results" |
| 2 | Skip # comment lines when reading the key file | 401 Invalid authorization header |
| 3 | Timezone enum: use America/Detroit for Eastern, NOT America/New_York | New_York is rejected by the schedule enum; Detroit is the accepted ET value |
| 4 | Email body must be <div>-wrapped lines with NO literal newlines | The body sanitizer strips bare text and <br>\n; blank line = <div><br></div> |
| 5 | CTA links: wrap in an <a href> with a phrase as the anchor text, period OUTSIDE the tag | Copy ending "[link]." became a bare iexdg.com/clarity. with the period inside the auto-link, 302 to /404, served the generic "Contact Us" page. 0 clicks on 51 sends. |
| 6 | POST /campaigns/{id}/activate needs an empty OBJECT body {} | An empty/no body with content-type json is rejected |
| 7 | ⚠ Adding leads can AUTO-FLIP a campaign from paused to active (status 1) and it starts sending | A campaign that was status 3 (no audience) flipped to status 1 the moment a populated audience + a live schedule existed. Re-check status after every lead load; pause explicitly if not ready. |
| 8 | DELETE /leads/{id} must send NO Content-Type header and NO body | Otherwise 400 FST_ERR_CTP_EMPTY_JSON_BODY ("body must be null") |
| 9 | POST /leads/list (it is POST, not GET); keep limit ≤ ~100 | limit 200 returns a 400; body {campaign, limit}; results under items[] |
| 10 | For the true lead count, read leads_count from analytics, not /leads/list | /leads/list can 400 or lag and read 0 while leads exist; analytics is authoritative |
| 11 | Email verification is ASYNC: poll until status leaves pending | First POST often returns pending; re-POST to poll. 0.25 credits each. See Verification. |
| 12 | Status integers: 1=active, 2=paused, 3=completed/empty, -1=error | The print label "(0=paused)" in old scripts is wrong; paused reads back as 2 |
The endpoints IEXDG uses are marked LIVE. Full v2 catalog from developer.instantly.ai included for completeness.
| Method | Path | Use |
|---|---|---|
| GET | /api/v2/campaigns | list campaigns |
| GET | /api/v2/campaigns/{id} | get one (status, email_list, sequences) live |
| POST | /api/v2/campaigns | create (we create PAUSED) live |
| PATCH | /api/v2/campaigns/{id} | update sequences/schedule live |
| POST | /api/v2/campaigns/{id}/activate | start/resume · body must be {} live |
| POST | /api/v2/campaigns/{id}/pause | pause live |
| GET | /api/v2/campaigns/analytics?id={id} | leads_count, emails_sent_count, open_count, reply_count, bounced_count live |
| GET | /api/v2/campaigns/{id}/steps/analytics | per-step analytics |
| POST | /api/v2/campaigns/{id}/duplicate | copy a campaign |
| DEL | /api/v2/campaigns/{id} | delete |
| Method | Path | Use |
|---|---|---|
| POST | /api/v2/leads | add one · body {campaign,email,first_name,last_name,company_name} live |
| POST | /api/v2/leads/list | list · body {campaign,limit≤100} → items[] live |
| DEL | /api/v2/leads/{id} | remove · NO content-type, NO body live |
| POST | /api/v2/leads/bulk-add | add up to 1000 to campaign/list |
| POST | /api/v2/leads/move | move leads between campaigns/lists |
| PATCH | /api/v2/leads/{id} | update a lead |
| Method | Path | Use |
|---|---|---|
| POST | /api/v2/email-verification | verify · body {email} · async, poll · 0.25 credits live |
| GET | /api/v2/email-verification/{email} | check status |
| Method | Path | Use |
|---|---|---|
| GET | /api/v2/accounts | list inboxes · warmup_score, status live |
| POST | /api/v2/accounts/enable-warmup | start warmup |
| GET | /api/v2/accounts/{id}/warmup-analytics | warmup performance |
| GET | /api/v2/accounts/{id}/vitals | account health test |
Emails (/api/v2/emails, 20 req/min cap), Lead Lists (/api/v2/lead-lists), Subsequences (/api/v2/campaign-subsequences), Webhooks (/api/v2/webhooks + event-types), Block List, Custom Tags, Lead Labels, Inbox Placement Tests, SuperSearch Enrichment, Workspace, API Keys, Background Jobs.
This is the order that does not bounce inboxes or send before you mean to.
1. CREATE paused POST /campaigns status 0/draft, schedule + sequence set
2. VERIFY each email POST /email-verification {email} poll until verified | invalid
3. LOAD only valid POST /leads {campaign,email,...} // invalid never gets loaded
4. RE-CHECK STATUS GET /campaigns/{id} // Law 7: adding leads can auto-activate
5. PAUSE if not ready POST /campaigns/{id}/pause {} // hold until sign-off
6. ACTIVATE on go POST /campaigns/{id}/activate {} // empty OBJECT body
7. MONITOR GET /campaigns/analytics?id= sent / open / reply / bounce
/email-verification and load only verified / accept_all. This caught NAM, Feeding America, and Catholic Charities as invalid before they touched a warmed inbox.
Async. The first POST usually returns pending. Re-POST the same email to poll the cached result.
| verification_status | Load? | Meaning |
|---|---|---|
verified | YES | mailbox confirmed deliverable |
accept_all / catch_all true | CAUTION | domain accepts everything; will not hard-bounce but mailbox unconfirmed |
invalid | NO | would bounce; never load |
pending | WAIT | still checking; poll again in ~7s |
catch_all is a separate boolean from the status. Credits: 0.25 per check; the workspace pool was ~949 at last read.
Three voices, three warmed inboxes on iexdg-team.com, ET business-hours schedule, daily cap 20, stop_on_reply on.
| Campaign | ID | Sender | Voice |
|---|---|---|---|
| 05A Executive-Peer | 537becfb-a607-4f45-bfbe-89ffbdf42d80 | drdnicole@iexdg-team.com | leader-to-leader (CHRO/C-suite) |
| 05B Partnerships | b6ce458e-1fc9-451f-beef-08de839db8b0 | partnerships@iexdg-team.com | "we" partnerships (associations, member orgs) |
| 05C Practitioner | e38c3302-a72e-495d-8bf7-728966d8f19a | info@iexdg-team.com | practitioner-to-practitioner (HR/L&D) |
Build + repair: source/scripts/outreach/build_campaign05.py (sequences, schedule, the --fix --commit re-push). Lead load: source/scripts/outreach/load_campaign05_leads.py. Verified-lead records: source/data/campaign05_*_verified.csv. CTA on every sequence: <a href="https://iexdg.com/clarity">pick a time</a>.
def call(method, path, body=None, ctype=True): """Instantly v2 call. Chrome UA mandatory. ctype=False for DELETE /leads.""" h = {"Authorization": f"Bearer {KEY}", "User-Agent": "Mozilla/5.0 ... Chrome/124.0 Safari/537.36", "Accept": "application/json"} if body is not None and ctype: h["Content-Type"] = "application/json" # urllib request to https://api.instantly.ai/api/v2 + path ... # verify (poll) -> load only valid status = call("POST", "/email-verification", {"email": em}) # poll while pending if status in ("verified", "accept_all"): call("POST", "/leads", {"campaign": CID, "email": em, "first_name": fn, "last_name": ln, "company_name": co}) # delete a lead: NO content-type, NO body call("DELETE", f"/leads/{lead_id}", body=None, ctype=False) # activate: empty OBJECT body call("POST", f"/campaigns/{CID}/activate", {}) # then ALWAYS verify it read back status 1 status = call("GET", f"/campaigns/{CID}")["status"]
This is no longer a plan. As of 2026-06-16 the connector is live and a full automation is active: every Campaign 05 reply auto-creates or updates a GoHighLevel contact, tagged instantly-05a. Everything below was driven and confirmed end-to-end, not read off a help page.
The Instantly help article implies "paste an API key and you are done." That is not what actually happens. Connecting a HighLevel account fires a full OAuth handshake in a popup against marketplace.gohighlevel.com, the OAuth broker is Nango (api.nango.dev/oauth/callback), and the Instantly API key is only requested at the very last install screen. You need BOTH: the GHL OAuth login (with 2FA) and the Instantly key with leads:create + accounts:read scope.
Our key source/secrets/instantly_api_key.txt carries scopes:["all:all"] (confirmed via GET /api/v2/api-keys), which satisfies that requirement.
| # | Screen | What you do |
|---|---|---|
| 1 | Instantly → Automations → action/trigger → Link HighLevel Account | Click Connect; a popup opens |
| 2 | marketplace.gohighlevel.com/oauth/chooselocation | “Please login to HighLevel” → click Login (opens a new tab) |
| 3 | app.gohighlevel.com login | Email + password → Verify Security Code (emailed) → tab auto-closes on success |
| 4 | Install confirmation | Next |
| 5 | Select an account | Choose INTEGRAL EXPLORATION DEVELOPMENT GROUP, LLC → Verify and install |
| 6 | “Please add the details” — API Key | Paste the Instantly key → Verify and install |
| 7 | Redirect to api.nango.dev/oauth/callback?code=… | Done. The Automations Account field now shows My Connection |
| Piece | Setting |
|---|---|
| Name | Lead Replied to GHL Create Contact · automation id 019ed046-8ca7-7179-9327-7a18e49401d4 |
| Trigger | Instantly (built-in) → Lead Replied, campaign = All live |
| Action | HighLevel → Create Contact on account My Connection (loc N5N9WnYQGjQgzGQlWeSc) live |
| Field map | Email ← Lead Email · First Name · Last Name · Phone · Company ← Company Name |
| Tag | instantly-05a (literal; GHL lowercases it) |
| Status | active · verified persistent across reload |
| Direction | Exact options seen in the builder |
|---|---|
| Instantly triggers | Lead Replied · Lead Interest Status Changed · Campaign Finished for Lead · Subscriber Updated in Group · Lead Added to List |
| HighLevel actions | Create Contact · Update Contact · Add Lead to Workflow (no combined “Create/Update” — Create Contact upserts by email) |
| Action apps | HighLevel, LeadConnector, HubSpot, Salesforce, Pipedrive, Attio, Zoho CRM, Slack, Mailchimp, HTTP Request, Condition, Delay, and more |
| Field tokens (Lead Replied) | Lead Email, First Name, Last Name, Company Name, Phone, Website, LinkedIn, Campaign Id Label, plus the full reply payload |
# after the Test Step ran, remove the example contact from GHL PIT = "pit-8e4c1579-...8b09a45"; CID = "<contact id from the Data Out tab>" h = {"Authorization": f"Bearer {PIT}", "Version": "2021-07-28", "User-Agent": "Mozilla/5.0 ... Chrome/124.0 Safari/537.36"} # DELETE https://services.leadconnectorhq.com/contacts/{CID} -> {"succeeded": true}
Add Lead to Workflow, pointed at a GHL hot-reply workflow that alerts Dr. DNicole and routes to the Clarity Call. Not built yet, no workflow defined. Driver for the whole build: source/strategy/internal/_auto/inst_finalize.py.
Verified by direct, automated build · 2026-06-16 · Dove Web Consulting. Supersedes the 2026-06-15 help-doc summary, which mis-stated the auth model.