# Furnace Client API — full docs corpus (v1.15.0) Plain markdown export for agents. HTML docs live under /docs/. --- # Introduction

Furnace runs personalized cold email campaigns. Create campaigns, add people, launch sending, and handle replies — all from your own code.

Quickstart API Reference
Furnace sends sequences of personalized emails to a list of people, from one or more of your inboxes. It waits between steps and can branch based on how people reply. ## What you can build Create a campaign, add an email sequence, and launch it. Add, import, and move the people a campaign emails. Find replies, send responses, and track message jobs. Get notified when emails send, replies arrive, and more. ## Start here 1. [Quickstart](/docs/guides/quickstart/) — get an API key and make your first request. 2. [Campaign setup](/docs/guides/campaign-setup/) — build and launch a campaign end to end. 3. [Lead management](/docs/guides/lead-management/) and [Handling replies](/docs/guides/handling-replies/) — day-two operations. Every endpoint and object is documented in the [API Reference](/docs/reference/). New keys are created in Furnace Account Settings and sent as `Authorization: Bearer f_...` — see [Authentication](/docs/guides/authentication/). --- # Quickstart This gets you from an API key to your first successful request in a couple of minutes. ## Before you start - An API key. Create one in Furnace under **Account Settings → API keys** and send it as `Authorization: Bearer f_...` (see [Authentication](/docs/guides/authentication/)). - At least one connected mailbox to send from. ## 1. Make your first request List the inboxes connected to your account: ```bash curl -sS 'https://api.getfurnace.io/v1/mailboxes' \ -H 'Authorization: Bearer f_your_key_here' ``` You get back your mailboxes and their ids. Keep one id handy — a campaign sends from it. ## 2. Create a draft campaign ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d '{ "name": "My first campaign" }' ``` This returns a campaign with `status: draft`. Nothing sends until you launch it. ## Next You now have an empty draft. Add an email sequence, add people, and launch it in the [Campaign setup](/docs/guides/campaign-setup/) guide. --- # Authentication Every request needs an API key. Keys are tied to a single Furnace account, so requests never touch another account’s data. ## Get a key Create an API key in Furnace under **Account Settings → API keys**. Keys start with `f_`. Copy it once and store it somewhere safe — you can revoke and recreate keys at any time. ## Send it with every request Pass your key in the `Authorization` header: ```http Authorization: Bearer f_your_key_here ``` The base URL is your Furnace Client API host, for example `https://api.getfurnace.io`. All endpoints live under `/v1/`. ```bash curl -sS 'https://api.getfurnace.io/v1/mailboxes' \ -H 'Authorization: Bearer f_your_key_here' ``` ## If a key is wrong A missing, revoked, expired, or unknown key returns `401` with an `authentication_error`. Double-check the header format and that the key is still active. Next: [Quickstart](/docs/guides/quickstart/). --- # Campaigns A campaign is one outbound effort: a set of people, an email sequence, and the inboxes it sends from. Everything you do through the API hangs off a campaign. A campaign moves through a few simple states: | State | What it means | | --- | --- | | **Draft** | You are still building it. Add or change anything — steps, people, inboxes. Nothing sends yet. | | **Scheduled** | Launched, but waiting for `lifecycle_schedule.start_on`. Nothing sends until that local date. | | **Running** | It is live and sending. You can still edit email copy and timing, but not add or remove steps. | | **Paused** | Sending is on hold. You can restructure the sequence again, then resume. | | **Stopped** | Finished. No more sending and no more edits. | The usual path is **draft → launch → running** (or **scheduled**, if you set a future start date), with pause and resume whenever you need to make bigger changes. Ready to build one? Follow the [Campaign setup](/docs/guides/campaign-setup/) guide. To see the exact fields, check the [API Reference](/docs/reference/). > Campaigns imported from Smartlead are read-only through this API. --- # Leads and people A **person** is someone in your account — an email address with details like name and company. The same person can be in more than one campaign. A **lead** is a person as they appear inside a specific campaign. When you add someone to a campaign, you create a lead there. Removing a lead from a campaign does not delete the underlying person. A **saved list** is a reusable group of people you can manage once and reference elsewhere. ## Custom fields Beyond standard details (name, company, and so on), a campaign can require **custom fields** — extra values you use to personalize emails, like a recent signup date or plan name. If a campaign’s sequence uses a custom field, you must include that value when you add or update a person in that campaign. Otherwise the request is rejected. ## Tags vs custom fields **Lead tags** are account-level and person-keyed (`global_lead_id`). The same tag follows a person across campaigns. Use them for provider, live signals, and operator filters (for example Hunter, Running Meta Ads, Do Not Send). `custom_lead_data` is per campaign lead. Use it for sequence personalization tokens such as `{{custom.company}}`. **Email verification** is a structured fact on the person (`ok`, `catch_all`, `invalid`, `unknown`, `disposable`), not a tag. Send it on import when you already have a vendor check. Catch-All Domain and Role Account tags are optional operator labels and are not auto-synced from verification. To add and manage people, follow the [Lead management](/docs/guides/lead-management/) guide. Personalization is covered in [Email sequences](/docs/concepts/sequences/). --- # Mailboxes A **mailbox** is one of your connected inboxes — the email account a campaign sends from and receives replies in. A campaign sends from one or more mailboxes. Spreading sends across several mailboxes keeps volume per inbox lower, which helps deliverability. You pick which mailboxes a campaign uses by their ids. List your connected mailboxes with `GET /v1/mailboxes` to get those ids. **Tags** let you group mailboxes (for example, by domain or team) so you can find and assign them more easily. You will use a mailbox id in the first step of the [Campaign setup](/docs/guides/campaign-setup/) guide. Full fields are in the [API Reference](/docs/reference/). --- # Email sequences A **sequence** is the ordered set of steps a campaign runs for each person. You build it once; every person follows the same path. ## Step types | Step | What it does | | --- | --- | | **Email** | Sends an email. You can add A/B variants and Furnace picks between them. | | **Wait** | Pauses for a set amount of time before the next step. | | **Branch on reply** | Reads a reply and sends the person down a different path — for example, interested vs not interested. | | **Send data** | Posts information to another system of yours at that point in the sequence. | Steps connect in order, starting from your list of people and moving forward. A person stops when they reach the end of their path or reply in a way that ends the sequence. ## Personalizing emails Use `{{ }}` tokens in a subject or body to drop in each person’s details: - `{{first_name}}` — standard details like name, company, or website. - `{{custom.company}}` — a custom field you defined on the campaign. Example: ```text Subject: Quick question for {{first_name}} Body: Hi {{first_name}}, saw {{custom.company}} is hiring and wanted to reach out. ``` If you reference a token that the campaign does not know about, Furnace flags it so you can add the field or fix the copy before going live. Put this into practice in the [Campaign setup](/docs/guides/campaign-setup/) guide. Exact field names live in the [API Reference](/docs/reference/). --- # Webhooks Webhooks let Furnace tell your systems when something happens — instead of you polling the API for changes. You give Furnace an HTTPS URL. When an event occurs, Furnace sends a small JSON message to that URL. Common events include: - an email was sent - a reply arrived - a reply was categorized (for example, interested) - a bounce was detected - people finished importing in bulk Each message says what happened and includes the relevant details, so you can update a CRM, trigger a workflow, or log activity. To set up a URL, verify messages, and see example payloads, follow the [Webhook integration](/docs/guides/webhook-integration/) guide. --- # Campaign setup Build and launch a real campaign end to end. New here? Read [Campaigns](/docs/concepts/campaigns/) and [Email sequences](/docs/concepts/sequences/) first. Replace `f_your_key_here` with your API key and the example ids with your own. ## 1. Pick a mailbox List your inboxes and copy a mailbox id to send from: ```bash curl -sS 'https://api.getfurnace.io/v1/mailboxes' \ -H 'Authorization: Bearer f_your_key_here' ``` ## 2. Create a draft campaign Give it a name and the mailbox it sends from: ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d ' { "name": "Q2 Outbound", "mailbox_ids": [ "c23da7b6-df4e-4d2f-b100-4bb07b7d38d7" ] } ' ``` The response includes the new campaign’s `id`. Use it in the next steps. ## 3. Add your email sequence Send the steps people will move through. This example sends an email, waits, then sends a follow-up: ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/flow' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d ' { "nodes": [ { "id": "leadSource-1", "type": "leadSource", "position": { "x": 0, "y": 0 }, "data": { "label": "Lead Bucket", "customFieldKeys": [ "company" ], "mappedStandardFieldKeys": [ "email", "first_name", "last_name" ], "isRequired": true }, "deletable": false }, { "id": "email-1", "type": "email", "position": { "x": 220, "y": 0 }, "data": { "label": "Intro Email", "priority": false, "variants": [ { "id": "11111111-1111-4111-8111-111111111111", "label": "A", "subject": "Quick question for {{first_name}}", "template": "Hi {{first_name}} - reaching out about {{custom.company}}.", "isActive": true, "order": 0 }, { "id": "11111111-1111-4111-8111-111111111112", "label": "B", "subject": "Following up for {{first_name}}", "template": "Hi {{first_name}} - wanted to share a quick idea for {{custom.company}}.", "isActive": true, "order": 1 } ] } }, { "id": "waitTime-1", "type": "waitTime", "position": { "x": 460, "y": 0 }, "data": { "label": "Wait 1 day", "duration": "1", "unit": "days", "wait_duration_seconds": 86400 } }, { "id": "email-2", "type": "email", "position": { "x": 700, "y": 0 }, "data": { "label": "Follow-up", "priority": false, "variants": [ { "id": "22222222-2222-4222-8222-222222222221", "label": "A", "subject": "Bumping this for {{first_name}}", "template": "Hi {{first_name}} - circling back in case this is relevant for {{custom.company}}.", "isActive": true, "order": 0 }, { "id": "22222222-2222-4222-8222-222222222222", "label": "B", "subject": "Any thoughts, {{first_name}}?", "template": "Hi {{first_name}} - should I close the loop or send more detail?", "isActive": true, "order": 1 } ] } } ], "edges": [ { "id": "e1", "source": "leadSource-1", "target": "email-1" }, { "id": "e2", "source": "email-1", "target": "waitTime-1" }, { "id": "e3", "source": "waitTime-1", "target": "email-2" } ] } ' ``` The copy uses tokens like `{{first_name}}` and `{{custom.company}}`. Any `{{custom.*}}` token becomes a value you provide for each person in the next step. See [Email sequences](/docs/concepts/sequences/). ## 4. Add a person Add someone to the campaign. Include any custom fields the sequence uses: ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d ' { "email": "alex@acme.com", "first_name": "Alex", "last_name": "Rivera", "custom_lead_data": { "company": "Acme Corp" } } ' ``` Adding more people, updating, or importing in bulk is covered in [Lead management](/docs/guides/lead-management/). ## 5. Launch Start sending. This puts everyone you added into the sequence: ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/launch' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d '{}' ``` The campaign is now `running`. ## Editing after launch | While the campaign is | You can | You cannot | | --- | --- | --- | | **draft** | Change anything | — | | **running** | Edit email copy and timing | Add, remove, or reorder steps | | **paused** | Change anything, then resume | — | | **stopped** | — | Edit anything | To add, remove, or reorder steps on a live campaign, pause it first, make the change, then resume. If you try a structural change while running, the request is rejected with a message telling you to pause. Pause, resume, and stop through the campaign status endpoint — see the [API Reference](/docs/reference/). --- # Lead management By the end of this guide you can add people to a campaign, fix missing personalization fields, import a list (sync or async), and move existing people between campaigns. A person added to a campaign is a **lead** in that campaign. Read [Leads and people](/docs/concepts/leads-people/) first if those words are new. Replace `f_your_key_here` and the example ids with your own. ## Before you start - A campaign with an email sequence. The [Campaign setup](/docs/guides/campaign-setup/) guide builds one. - If the sequence uses custom fields (for example `{{custom.company}}`), every person you add must include those keys in `custom_lead_data` or the request is rejected / the lead is marked incomplete. ## 1. Add one person Include every custom field the sequence requires: ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d ' { "email": "alex@acme.com", "first_name": "Alex", "last_name": "Rivera", "custom_lead_data": { "company": "Acme Corp" } } ' ``` **Success:** the response includes the lead `id` and the person's `global_lead_id`. If the campaign is already running, they enter the sequence when enrollment succeeds. ## 2. Fix or remove a person Update fields (for example a missing company name) with `PATCH`: ```bash curl -sS -X PATCH 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads/f2a1b9c4-8e7d-4a3b-9c1e-2d5f6a7b8c9d' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d '{ "first_name": "Alexandra", "custom_lead_data": { "company": "Acme Corp" } }' ``` Remove them from **this** campaign only: ```bash curl -sS -X DELETE 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads/f2a1b9c4-8e7d-4a3b-9c1e-2d5f6a7b8c9d' \ -H 'Authorization: Bearer f_your_key_here' ``` That does not delete the underlying person from your account. They stay available for other campaigns. ## 3. Import a list (up to 100) Use sync bulk when the batch fits in one request (max 100 people): ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads/bulk' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d ' { "leads": [ { "email": "alex@acme.com", "first_name": "Alex", "custom_lead_data": { "company": "Acme Corp" } }, { "email": "sam@example.com", "first_name": "Sam", "custom_lead_data": { "company": "Example Inc" } } ] } ' ``` **Success:** the response reports `imported`, `incomplete`, `failed`, and an `errors` array. `incomplete` means the lead was created but is missing required personalization fields — fix those with `PATCH` before you expect emails to send. ## 4. Import a large list (async job) For bigger lists, queue a job (up to 1000 people per request, and up to 3 jobs running at once): ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads/bulk/async' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d ' { "leads": [ { "email": "alex@acme.com", "first_name": "Alex", "custom_lead_data": { "company": "Acme Corp" } } ] } ' ``` Poll until the job finishes: ```bash curl -sS 'https://api.getfurnace.io/v1/jobs/9f8e7d6c-5b4a-3210-9876-543210fedcba' \ -H 'Authorization: Bearer f_your_key_here' ``` **Success:** `status` is `completed` (or `failed`). Counts live in `result` (`created`, `updated`, `incomplete`, `failed`, and so on). Prefer webhooks for completion if you already use them — see [Webhook integration](/docs/guides/webhook-integration/). ## 5. Move existing people between campaigns When people already exist in your account (you have their `global_lead_id`), add them to another campaign without re-posting full contact rows: ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads:add' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d '{ "global_lead_ids": [ "sha256:example-person-id" ] }' ``` Remove them from a campaign with `POST /v1/campaigns/{id}/leads:remove`, or from every campaign with `POST /v1/leads:remove-from-all-campaigns`. **Success:** the response counts `created` / `enrolled` / `skipped` (add) or `removed` / `skipped` (remove). ## Common mistakes | Symptom | Likely cause | | --- | --- | | Request rejected or lead incomplete | Missing a required `custom_lead_data` key from the sequence | | Person never receives email | Campaign still `draft` (launch it), no mailbox assigned, or enrollment paused | | Bulk import times out / too large | Use `/leads/bulk/async` and poll `GET /v1/jobs/{id}` | | DELETE removed them from one campaign only | Expected — the account person remains for other campaigns | ## 6. Tags and email verification Tags are **account-level and person-keyed**. They describe the person (`global_lead_id`), not one campaign lead row. `custom_lead_data` stays per campaign. Pass `tags` as names or aliases. Furnace matches the built-in catalog case-insensitively (for example `Hunter.io` → Hunter, `GMB` → Google Maps) and find-or-creates an account-owned tag when nothing matches. Built-in catalog tags cannot be deleted. **Provider** (how you found them): Apollo, Hunter, Prospeo, Clay, Apify, Serper, Google Maps, LinkedIn Sales Navigator, Website Crawl, SkipSherpa, Meta Ad Library, LinkedIn Ad Library, Google Ads Transparency, Business Registry, License Roster, HubSpot, Salesforce, Client CSV, Webinar Registrant List, Demo Request, Content Download, Referral. **Signal** (why they are in the list): Running Meta Ads, Running Google Ads, Running LinkedIn Ads, Webinar Or Event Ad, Qualifying Ad Copy, Ads Paused, Hiring Intent, Recently Funded, Tech Match, Regulated Or Licensed, Verified Business, ICP Fit, Decision Maker, Owner Operator, Local Business, Engaged, Webinar Attendee, Webinar No Show. **Other** (operator filters): Catch-All Domain, Role Account, Needs Review, Do Not Send, Existing Customer, Open Opportunity, Previously Contacted, High Fit, Low Fit, EU Contact. If you already have MillionVerifier (or similar) output, send `email_verification` with `status` of `ok`, `catch_all`, `invalid`, `unknown`, or `disposable`. Do **not** auto-apply Catch-All Domain or Role Account tags from that payload — those tags are for operators who want them in filters. Omit `email_verification` unless you have a real check. Do not invent a status. Bulk and staged import use the same `LeadCreate` fields. Omitting them behaves as before. ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/campaigns/1d8dc901-3d2d-4d9f-9dcc-4f8b3aa1a1fb/leads' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d ' { "email": "alex@acme.com", "first_name": "Alex", "custom_lead_data": { "company": "Acme Corp" }, "tags": [ "Hunter", "ICP Fit" ], "email_verification": { "status": "ok", "provider": "millionverifier" } } ' ``` ## Next - Read replies in [Handling replies](/docs/guides/handling-replies/). - Field-level detail is in the [API Reference](/docs/reference/). --- # Handling replies By the end of this guide you can find a reply, read the conversation, send a response, and track whether that response actually sent. When someone replies, Furnace groups the exchange into a **thread** on the campaign's mailbox. Replace `f_your_key_here` and the example ids with your own. ## Before you start - A running campaign that has already sent mail (so replies can arrive). - An API key with access to the account inbox. ## 1. List conversations ```bash curl -sS 'https://api.getfurnace.io/v1/threads' \ -H 'Authorization: Bearer f_your_key_here' ``` Filter and sort in the [API Reference](/docs/reference/) (for example by campaign or latest inbound activity). Copy a thread `id` for the next steps. **Success:** you get a page of threads. New replies typically show a recent `last_inbound_at`. ## 2. Read the messages ```bash curl -sS 'https://api.getfurnace.io/v1/threads/b7e2c1a4-3f5d-4e8a-9b0c-1d2e3f4a5b6c/messages' \ -H 'Authorization: Bearer f_your_key_here' ``` **Success:** messages include direction (`sent` / `received`), subject, and body fields you can display or pass to your own agent. ## 3. Send a reply Sending is **queued**. The API returns a **message job**, not proof that the email left the mailbox yet. ```bash curl -sS -X POST 'https://api.getfurnace.io/v1/threads/b7e2c1a4-3f5d-4e8a-9b0c-1d2e3f4a5b6c/reply' \ -H 'Authorization: Bearer f_your_key_here' \ -H 'Content-Type: application/json' \ -d '{ "body_html": "

Thanks Alex — how about Thursday?

" }' ``` Forward instead with `POST /v1/threads/{id}/forward` when you need to hand the thread off. **Success:** the response includes a message job `id`. ## 4. Track the send Poll the job until it finishes: ```bash curl -sS 'https://api.getfurnace.io/v1/message-jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890' \ -H 'Authorization: Bearer f_your_key_here' ``` While it is still queued you can: ```bash # Cancel curl -sS -X POST 'https://api.getfurnace.io/v1/message-jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/cancel' \ -H 'Authorization: Bearer f_your_key_here' # Or send immediately curl -sS -X POST 'https://api.getfurnace.io/v1/message-jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/send-now' \ -H 'Authorization: Bearer f_your_key_here' ``` **Success:** the job reaches a terminal status (sent or cancelled). Prefer `email.sent` webhooks if you do not want to poll. ## 5. Organize the thread (optional) Typical triage actions: - **Categorize, mark read, or change status:** `PATCH /v1/threads/{id}` - **Set or clear out-of-office:** `POST /v1/threads/{id}/out-of-office` - **Add or remove tags:** `POST /v1/threads/{id}/tags:add` and `POST /v1/threads/{id}/tags:remove` ## 6. Get notified when replies arrive Instead of polling threads, wire [Webhook integration](/docs/guides/webhook-integration/) and enable email-activity events such as `reply.received` and `reply.categorized`. Payload examples live under **Webhook events** in the sidebar. ## Common mistakes | Symptom | Likely cause | | --- | --- | | Reply endpoint returned but mail never sent | Message job still queued or failed — poll `GET /v1/message-jobs/{id}` | | Empty thread list | Wrong account key, or no inbound mail yet on those mailboxes | | Missing reply body in your app | Reading thread list only — load `…/messages` for bodies | ## Next - Manage who is in the campaign with [Lead management](/docs/guides/lead-management/). - Every thread and message field is in the [API Reference](/docs/reference/). --- # Webhook integration Outbound webhooks notify your systems when Furnace events occur. Furnace POSTs JSON to your HTTPS endpoint; your endpoint must return any **2xx** response. New to webhooks? Start with the short [Webhooks](/docs/concepts/webhooks/) concept page. ## Quick start 1. Open **Account Settings → Webhooks** (or a campaign override in Mission Control). 2. **Configure** — paste an HTTPS URL, optionally set a signing secret, and select individual events (expand groups to pick specific types). Only selected event types are delivered. 3. Click **Next** to open the **Test** step. Use **View sample** to inspect JSON for each event type, then **Send test webhook** to POST a sample to your URL. 4. Click **Done** to save. Deliveries start immediately when matching events occur. Campaign overrides replace the account URL (and optionally secret or enabled events) for that campaign only. Leave the override URL empty to inherit the account default. ## Receiving webhooks Furnace sends: ```http POST {your_url} Content-Type: application/json X-Furnace-Event: email.sent X-Furnace-Delivery: {delivery_id} X-Furnace-Signature: sha256=... # when a signing secret is configured ``` Body envelope: ```json { "id": "event-uuid", "type": "email.sent", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { ... } } ``` - `id` — unique event id (stable across delivery retries for that event). - `type` — event constant (matches `X-Furnace-Event`). - `occurred_at` — ISO-8601 timestamp. - `data` — event-specific payload (see **Webhook events** in the sidebar). ### Test webhooks When you use **Send test webhook** in Furnace, the payload uses real event types with `"test": true` inside `data`. The examples in this guide show the **live** shape (no `test` field). ### Retries and failures Furnace retries failed deliveries up to **3** times. Your endpoint must return any **2xx** HTTP status. Non-2xx responses or network errors are recorded in Account Settings → **Failed deliveries**. Use `X-Furnace-Delivery` as a unique delivery id for idempotency on your side. ## Verifying signatures When a signing secret is configured, Furnace sets `X-Furnace-Signature` to `sha256=` followed by the hex-encoded HMAC-SHA256 of the **raw JSON request body** (exact bytes POSTed). Node.js example: ```javascript import crypto from 'node:crypto'; function verifyFurnaceSignature(secret, rawBody, signatureHeader) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader)); } ``` Most no-code tools (Zoho Flow, Zapier, Make) can ignore the signature and accept the POST directly. ## Single actions vs bulk A single action (adding one person, one send) fires its own event. A bulk action (an import, or anything touching more than one person at once) fires **one** completion event for the whole operation instead of one per person. Per-row `lead.created` / `lead.updated` / `lead.deleted` events are **never** emitted during bulk processing. ### Single actions | Action | Event | | --- | --- | | `POST /v1/campaigns/{id}/leads` (single) | `lead.created` / `lead.updated` | | `PATCH …/leads/{leadId}` | `lead.updated` | | `DELETE …/leads/{leadId}` | `lead.deleted` | | Campaign pause/stop/resume | `campaign.paused` / `campaign.stopped` / `campaign.resumed` | | Worker: email sent, reply, bounce | `email.sent` / `reply.received` / `bounce.detected` | | Block list add or remove | `blocklist.entry_added` / `blocklist.entry_removed` | | Thread category assign/change/clear | `reply.categorized` | ### Bulk actions | Operation | Completion event | | --- | --- | | `api_lead_import` / `csv_lead_import_staged` | `lead.bulk_import.completed` | | `add_to_campaign` | `lead.added_to_campaign.completed` | | `remove_from_campaign` | `lead.removed_from_campaign.completed` | | `remove_from_all_campaigns` | `lead.removed_from_all_campaigns.completed` | | `add_to_lead_list` | `lead.added_to_list.completed` | | `remove_from_lead_list` | `lead.removed_from_list.completed` | | `export_leads` | `lead.export.completed` | | `pause_enrollments` | `enrollment.pause_completed` | | `resume_enrollments` | `enrollment.resume_completed` | Sync bulk shortcuts use the same completion events with `source: "sync"` and `job_id: null`. Batch completion `data` matches the `BatchCompletionWebhookPayload` schema in the **Schemas** section. `enrollment.created` and `enrollment.updated` are **not** emitted. ## Campaign overrides When a webhook event includes a `campaign_id`, Furnace resolves delivery settings in this order: 1. **URL** — campaign `webhook_url_override` if set, otherwise account `webhook_url`. If no URL is configured, the event is not delivered. 2. **Signing secret** — campaign override if set, otherwise account secret. 3. **Enabled events** — campaign `webhook_enabled_events_override` if set (array), otherwise account `webhook_enabled_events`. If the resolved list is **empty**, no events are delivered. If non-empty, only listed types are delivered. When the campaign override URL is empty, the account URL and account signing secret are used. ## Shared lead identity fields Every lead-scoped email-activity event (`email.sent`, `reply.received`, `reply.categorized`, `bounce.detected`) repeats the same identity block so a CRM can match a contact without a follow-up API call. Block list email examples include this block when a lead exists; domain examples do not. | Field | Notes | | --- | --- | | `email` | Lead address for CRM matching. Reply events also keep `from_email`. | | `mailbox_email` | Sending or receiving inbox. | | `campaign_name` | Human-readable campaign name. | | `first_name`, `last_name`, `full_name`, `company_name`, `title`, `website`, `linkedin_url` | Present only when stored on the lead. `title` is promoted from `custom_lead_data`. | | `custom_fields` | Nested object of `leads.custom_lead_data`. Keys that collide with reserved fields stay nested. | | `custom_fields_truncated` | `true` only when `custom_fields` exceeded the 8 KB byte budget. | Empty or whitespace-only values are omitted. Furnace never sends `""` for these fields. `custom_fields` is capped at **8192 UTF-8 bytes**; overflow keys are dropped and `custom_fields_truncated` is set. `body_text` is capped at **16,000 characters**. ## No-code tools (Zoho Flow, Zapier, Make) 1. Create an incoming webhook trigger in your tool and copy its HTTPS URL. 2. Paste the URL in Furnace **Account Settings → Webhooks** and enable **Email activity** (or other groups you need). 3. On the **Test** step, send `email.sent` or `reply.received` and map fields from the sample JSON. 4. No echo-token or custom verification handler is required. ## Event payloads Live JSON examples for every event type: - [Lead added / updated](/docs/webhooks/lead-added-updated/) — Single-lead changes and bulk import or add-to-campaign completions. - [Lead lists / export](/docs/webhooks/lead-list-and-export/) — Saved-list membership and people export job completions. - [Lead removed](/docs/webhooks/lead-removed/) — Single-lead deletes and bulk removal from one or all campaigns. - [Enrollment pause / resume](/docs/webhooks/enrollment-pause-resume/) — Manual enrollment holds and bulk pause/resume completions. - [Campaign status](/docs/webhooks/campaign-status/) — Campaign paused, resumed, or stopped. - [Email activity](/docs/webhooks/email-activity/) — Sends, replies, categorization, and bounces. - [Block list](/docs/webhooks/block-list/) — Emails and domains added to or removed from the account block list. ## Troubleshooting | Symptom | Likely cause | | --- | --- | | No webhooks received | URL empty, event type filtered out, or campaign override blocking delivery | | Test works, live events missing | Event group not enabled, or non-2xx response on live delivery | | Duplicate deliveries | Retries after timeout; dedupe on `X-Furnace-Delivery` | | Signature verification fails | Body parsed/re-serialized before verify; use raw body bytes | --- # MCP Furnace runs a hosted MCP (Model Context Protocol) server so AI clients like Cursor, Claude, and ChatGPT can work with your account directly — create campaigns, add people, read replies, and more. The tools mirror the Client API and update automatically. ## Server URL Add this as a remote (HTTP) MCP server in your client: ``` https://mcp.getfurnace.io/mcp ``` You can also copy this from **Account Settings → MCP** in Furnace. ## Connect with OAuth 1. In your MCP client, add a new **remote / HTTP** MCP server using the URL above. 2. When prompted, sign in to Furnace and click **Approve**. 3. Your client receives an access token automatically — there is no API key to paste. Server updates apply on your next session without any change to your MCP config. ## What you get Tools mirror the Furnace Client API — campaigns, flows, leads, inbox threads, webhooks, API keys, and mailbox connect sessions. The [API Reference](/docs/reference/) documents the underlying endpoints and objects. When adding people, tag by **name** (`Hunter`, `Running Meta Ads`) rather than inventing UUIDs. Send `email_verification` only when you already have a verifier result; never guess `ok`. Tags are person-level; `custom_lead_data` is campaign-level personalization. ## Advanced: API key For scripts or clients that do not support OAuth, you can authenticate with an API key instead: ```http Authorization: Bearer f_your_key_here ``` Create a key under **Account Settings → API keys** — see [Authentication](/docs/guides/authentication/). Prefer OAuth for interactive MCP clients. --- # Lead added / updated Single-lead changes and bulk import or add-to-campaign completions. These pages are payload reference. For setup, verification, and retries, follow [Webhook integration](/docs/guides/webhook-integration/). Examples use placeholder UUIDs. Live deliveries use real ids from your account. ### `lead.created` A single lead was created via `POST /v1/campaigns/{id}/leads`. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.created", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com" } } ``` ### `lead.updated` A single lead was updated via `PATCH /v1/campaigns/{id}/leads/{leadId}`. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.updated", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com" } } ``` ### `lead.bulk_import.completed` An async or sync bulk import finished (`POST /v1/jobs`, `POST …/leads/bulk`, or async bulk endpoint). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.bulk_import.completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": "00000000-0000-4000-8000-000000000007", "source": "async", "campaign_id": "22222222-2222-4222-8222-222222222222", "operation": "api_lead_import", "counts": { "created": 2, "updated": 1, "enrolled": 3, "skipped": 0, "failed": 0 }, "errors": [] } } ``` ### `lead.added_to_campaign.completed` A sync bulk add-to-campaign action finished (`POST …/leads:add` or equivalent). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.added_to_campaign.completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": null, "source": "sync", "campaign_id": "22222222-2222-4222-8222-222222222222", "operation": "add_to_campaign", "counts": { "enrolled": 1, "skipped": 0, "failed": 0 }, "errors": [], "global_lead_ids": [ "00000000-0000-4000-8000-000000000008" ] } } ``` --- # Lead lists / export Saved-list membership and people export job completions. These pages are payload reference. For setup, verification, and retries, follow [Webhook integration](/docs/guides/webhook-integration/). Examples use placeholder UUIDs. Live deliveries use real ids from your account. ### `lead.added_to_list.completed` A scoped or ID-list add-to-lead-list job finished (`POST /v1/lead-lists/{id}/members:update` or async job). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.added_to_list.completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": "00000000-0000-4000-8000-000000000007", "source": "async", "campaign_id": null, "operation": "add_to_lead_list", "counts": { "added": 2, "skipped": 0, "failed": 0 }, "errors": [], "global_lead_ids": [ "00000000-0000-4000-8000-000000000008" ] } } ``` ### `lead.removed_from_list.completed` A scoped or ID-list remove-from-lead-list job finished (`POST /v1/lead-lists/{id}/members:update` or async job). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.removed_from_list.completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": "00000000-0000-4000-8000-000000000007", "source": "async", "campaign_id": null, "operation": "remove_from_lead_list", "counts": { "removed": 1, "skipped": 0, "failed": 0 }, "errors": [], "global_lead_ids": [ "00000000-0000-4000-8000-000000000008" ] } } ``` ### `lead.export.completed` A people/leads export job finished (`POST /v1/people:export` or async `export_leads`). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.export.completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": "00000000-0000-4000-8000-000000000007", "source": "async", "campaign_id": null, "operation": "export_leads", "counts": { "rows_exported": 10, "failed": 0 }, "errors": [] } } ``` --- # Lead removed Single-lead deletes and bulk removal from one or all campaigns. These pages are payload reference. For setup, verification, and retries, follow [Webhook integration](/docs/guides/webhook-integration/). Examples use placeholder UUIDs. Live deliveries use real ids from your account. ### `lead.deleted` A single lead was deleted via `DELETE /v1/campaigns/{id}/leads/{leadId}`. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.deleted", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com" } } ``` ### `lead.removed_from_campaign.completed` A sync bulk remove-from-campaign action finished (`POST …/leads:remove` or equivalent). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.removed_from_campaign.completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": null, "source": "sync", "campaign_id": "22222222-2222-4222-8222-222222222222", "operation": "remove_from_campaign", "counts": { "removed": 1, "skipped": 0, "failed": 0 }, "errors": [], "global_lead_ids": [ "00000000-0000-4000-8000-000000000008" ] } } ``` ### `lead.removed_from_all_campaigns.completed` A sync bulk remove-from-all-campaigns action finished (`POST …/leads:remove-from-all-campaigns`). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "lead.removed_from_all_campaigns.completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": null, "source": "sync", "campaign_id": null, "operation": "remove_from_all_campaigns", "counts": { "removed": 1, "skipped": 0, "failed": 0 }, "errors": [], "global_lead_ids": [ "00000000-0000-4000-8000-000000000008" ] } } ``` --- # Enrollment pause / resume Manual enrollment holds and bulk pause/resume completions. These pages are payload reference. For setup, verification, and retries, follow [Webhook integration](/docs/guides/webhook-integration/). Examples use placeholder UUIDs. Live deliveries use real ids from your account. ### `enrollment.pause_completed` A sync bulk enrollment pause finished (`POST …/enrollments:pause` or equivalent). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "enrollment.pause_completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": null, "source": "sync", "campaign_id": "22222222-2222-4222-8222-222222222222", "operation": "pause_enrollments", "counts": { "paused": 1, "skipped": 0, "failed": 0 }, "errors": [], "global_lead_ids": [ "00000000-0000-4000-8000-000000000008" ] } } ``` ### `enrollment.resume_completed` A sync bulk enrollment resume finished (`POST …/enrollments:resume` or equivalent). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "enrollment.resume_completed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "job_id": null, "source": "sync", "campaign_id": "22222222-2222-4222-8222-222222222222", "operation": "resume_enrollments", "counts": { "resumed": 1, "skipped": 0, "failed": 0 }, "errors": [], "global_lead_ids": [ "00000000-0000-4000-8000-000000000008" ] } } ``` --- # Campaign status Campaign paused, resumed, or stopped. These pages are payload reference. For setup, verification, and retries, follow [Webhook integration](/docs/guides/webhook-integration/). Examples use placeholder UUIDs. Live deliveries use real ids from your account. ### `campaign.paused` The campaign was paused. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "campaign.paused", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222" } } ``` ### `campaign.resumed` The campaign was resumed. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "campaign.resumed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222" } } ``` ### `campaign.stopped` The campaign was stopped. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "campaign.stopped", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222" } } ``` --- # Email activity Sends, replies, categorization, and bounces. These pages are payload reference. For setup, verification, and retries, follow [Webhook integration](/docs/guides/webhook-integration/). Examples use placeholder UUIDs. Live deliveries use real ids from your account. ### `email.sent` An outbound campaign email was sent. `data.email` is the lead recipient address for CRM matching. Includes the shared lead identity block, outbound `body_text`, and `step_number` when the scheduler persisted it. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "email.sent", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222", "campaign_name": "Example campaign", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com", "mailbox_id": "00000000-0000-4000-8000-000000000004", "mailbox_email": "sender@example.com", "first_name": "Casey", "last_name": "Reed", "full_name": "Casey Reed", "company_name": "Wasatch Corridor", "title": "VP Sales", "website": "https://wasatch.example", "linkedin_url": "https://linkedin.com/in/casey-reed", "custom_fields": { "title": "VP Sales", "region": "west" }, "enrollment_id": "00000000-0000-4000-8000-000000000002", "message_job_id": "00000000-0000-4000-8000-000000000003", "provider_message_id": "test-provider-message-id", "sent_at": "2026-06-25T12:00:00.000Z", "subject": "Example outbound subject (test)", "body_text": "Hi Casey — quick check-in for next week.", "step_number": 1, "node_id": "00000000-0000-4000-8000-000000000003", "flow_node_id": "email-1" } } ``` ### `reply.received` An inbound reply was received on a campaign thread (before categorization completes). `data.from_email` is the reply sender; `data.email` is the matched lead. `data.body_text` is the plain-text display body (quoted history stripped). ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "reply.received", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "thread_id": "00000000-0000-4000-8000-000000000005", "email_message_id": "00000000-0000-4000-8000-000000000006", "campaign_id": "22222222-2222-4222-8222-222222222222", "campaign_name": "Example campaign", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com", "mailbox_id": "00000000-0000-4000-8000-000000000004", "mailbox_email": "sender@example.com", "first_name": "Casey", "last_name": "Reed", "full_name": "Casey Reed", "company_name": "Wasatch Corridor", "title": "VP Sales", "website": "https://wasatch.example", "linkedin_url": "https://linkedin.com/in/casey-reed", "custom_fields": { "title": "VP Sales", "region": "west" }, "enrollment_id": "00000000-0000-4000-8000-000000000002", "from_email": "lead@example.com", "subject": "Re: Example outbound subject (test)", "body_text": "Thursday works — send a hold.", "received_at": "2026-06-25T12:00:00.000Z" } } ``` ### `reply.categorized` A thread reply category was assigned, changed, or cleared (manual, AI, system, or OOO). Includes the same lead identity block as send/reply. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "reply.categorized", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "thread_id": "00000000-0000-4000-8000-000000000005", "email_message_id": "00000000-0000-4000-8000-000000000006", "campaign_id": "22222222-2222-4222-8222-222222222222", "campaign_name": "Example campaign", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com", "mailbox_id": "00000000-0000-4000-8000-000000000004", "mailbox_email": "sender@example.com", "first_name": "Casey", "last_name": "Reed", "full_name": "Casey Reed", "company_name": "Wasatch Corridor", "title": "VP Sales", "website": "https://wasatch.example", "linkedin_url": "https://linkedin.com/in/casey-reed", "custom_fields": { "title": "VP Sales", "region": "west" }, "enrollment_id": "00000000-0000-4000-8000-000000000002", "category": "Interested", "previous_category": null, "category_source": "ai", "from_email": "lead@example.com", "subject": "Re: Example outbound subject (test)" } } ``` ### `bounce.detected` A hard or soft bounce was detected for a sent message. `data.email` is the matched lead; `candidate_emails` remains for diagnostics. `reason` is `severity` plus the SMTP `code` when present. A hard bounce that writes the block list also emits `blocklist.entry_added`. ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "bounce.detected", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222", "campaign_name": "Example campaign", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com", "mailbox_id": "00000000-0000-4000-8000-000000000004", "mailbox_email": "sender@example.com", "first_name": "Casey", "last_name": "Reed", "full_name": "Casey Reed", "company_name": "Wasatch Corridor", "title": "VP Sales", "website": "https://wasatch.example", "linkedin_url": "https://linkedin.com/in/casey-reed", "custom_fields": { "title": "VP Sales", "region": "west" }, "enrollment_id": "00000000-0000-4000-8000-000000000002", "message_job_id": "00000000-0000-4000-8000-000000000003", "severity": "hard", "code": "550", "reason": "hard 550", "bounce_message_id": "test-bounce-message-id", "bounce_uid": 42, "candidate_emails": [ "lead@example.com" ], "matched_job_count": 1 } } ``` --- # Block list Emails and domains added to or removed from the account block list. These pages are payload reference. For setup, verification, and retries, follow [Webhook integration](/docs/guides/webhook-integration/). Examples use placeholder UUIDs. Live deliveries use real ids from your account. ### `blocklist.entry_added` An email or domain was added to the account block list. A hard bounce that also writes the block list emits this event and `bounce.detected`. #### Email ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "blocklist.entry_added", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222", "campaign_name": "Example campaign", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com", "mailbox_id": "00000000-0000-4000-8000-000000000004", "mailbox_email": "sender@example.com", "first_name": "Casey", "last_name": "Reed", "full_name": "Casey Reed", "company_name": "Wasatch Corridor", "title": "VP Sales", "website": "https://wasatch.example", "linkedin_url": "https://linkedin.com/in/casey-reed", "custom_fields": { "title": "VP Sales", "region": "west" }, "enrollment_id": "00000000-0000-4000-8000-000000000002", "value": "lead@example.com", "type": "email", "reason": "unsubscribed", "source": "reply_opt_out" } } ``` #### Domain ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "blocklist.entry_added", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "value": "example.com", "type": "domain", "reason": "manual", "source": "api" } } ``` ### `blocklist.entry_removed` An email or domain was removed from the account block list. #### Email ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "blocklist.entry_removed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "campaign_id": "22222222-2222-4222-8222-222222222222", "campaign_name": "Example campaign", "lead_id": "00000000-0000-4000-8000-000000000001", "email": "lead@example.com", "mailbox_id": "00000000-0000-4000-8000-000000000004", "mailbox_email": "sender@example.com", "first_name": "Casey", "last_name": "Reed", "full_name": "Casey Reed", "company_name": "Wasatch Corridor", "title": "VP Sales", "website": "https://wasatch.example", "linkedin_url": "https://linkedin.com/in/casey-reed", "custom_fields": { "title": "VP Sales", "region": "west" }, "enrollment_id": "00000000-0000-4000-8000-000000000002", "value": "lead@example.com", "type": "email", "reason": "manual", "source": "inbox" } } ``` #### Domain ```json { "id": "00000000-0000-4000-8000-0000000000aa", "type": "blocklist.entry_removed", "occurred_at": "2026-06-25T12:00:00.000Z", "data": { "value": "example.com", "type": "domain", "reason": "manual", "source": "inbox" } } ``` --- # FAQ ## How do I get an API key? Create one in Furnace under **Account Settings → API keys**. Keys start with `f_` and are sent in the `Authorization` header. See [Authentication](/docs/guides/authentication/). ## What is the base URL? Your Furnace Client API host, for example `https://api.getfurnace.io`. Every endpoint lives under `/v1/`. ## What do I build first? Start with the [Quickstart](/docs/guides/quickstart/) to make your first request, then the [Campaign setup](/docs/guides/campaign-setup/) guide to launch a real campaign. ## Can I change a campaign after it is live? Yes, but with limits. While a campaign is running you can edit email copy and timing. To add, remove, or reorder steps, pause the campaign first, make your changes, then resume. Stopped campaigns cannot be edited. See [Campaigns](/docs/concepts/campaigns/). ## How do I personalize emails? Use `{{first_name}}` for standard details and `{{custom.company}}` for custom fields in the subject or body. See [Email sequences](/docs/concepts/sequences/). ## Why is a person not getting emails? The most common reasons: the campaign is still a draft (launch it), the person is missing a required custom field, or the campaign has no mailbox assigned. The [Campaign setup](/docs/guides/campaign-setup/) guide covers each of these. ## How do I know when something happens? Use webhooks to get notified when emails send, replies arrive, and more — see [Webhooks](/docs/concepts/webhooks/). You can also read status directly through the [API Reference](/docs/reference/). ## Can I use campaigns imported from Smartlead? You can read them, but they are not editable through this API. --- # API Reference Interactive API reference grouped by tag. - OpenAPI JSON: /docs/openapi.json - Schema pages: /docs/reference/schemas/{Name}/ --- # Changelog Version numbers match `info.version` on this API. Breaking changes increment the major version. Additive endpoints and fields increment minor. Patch is reserved for documentation-only or non-contract fixes. --- ## 1.15.0 **CRM-ready webhook identity on every lead email event** ### Added - Shared lead identity block on `email.sent`, `reply.received`, `reply.categorized`, and `bounce.detected`: `email`, `mailbox_email`, `campaign_name`, contact fields, and nested `custom_fields` - `email.sent` `body_text` (plain text, 16,000 character cap) plus `step_number` / `node_id` / `flow_node_id` when known - `bounce.detected` explicit `email` and `reason` (`severity` + SMTP `code`) - Block list group: `blocklist.entry_added` and `blocklist.entry_removed` for every `block_list` insert or delete (inbox, API, reply opt-out, import, bounce suppression). Email rows may include the lead identity block. Domain rows send the host only (`example.com`) with no `email` or identity fields. A hard bounce may emit both `bounce.detected` and `blocklist.entry_added` - `custom_fields_truncated: true` when custom fields exceed the 8 KB byte budget ### Changed - Existing event keys are unchanged. Empty contact fields are omitted rather than sent as empty strings. --- ## 1.14.0 **Campaign start and pause dates** ### Added - `lifecycle_schedule` on campaign create/update/detail: `time_zone`, nullable `start_on`/`pause_on` calendar dates, and read-only derived `start_at`/`pause_at` - Campaign status `scheduled` for launches with a future `start_on` - Launch returns `running` or `scheduled` plus the saved `lifecycle_schedule` ### Changed - Empty `start_on` still launches immediately. Empty `pause_on` never auto-pauses. `pause_on` is exclusive: sending stops before that local day. --- ## 1.13.0 **Webhook identity fields for CRM sync** ### Added - `email.sent` `data` now includes `email` (lead recipient), `mailbox_email`, and `campaign_name` so CRM integrations can match contacts without a Furnace API key - `reply.received` `data` now includes `body_text` (plain-text display body, quoted thread stripped, truncated at 16,000 characters), plus `mailbox_email` and `campaign_name` ### Changed - Existing `email.sent` / `reply.received` keys are unchanged. Omitted new fields are not sent as empty strings; `campaign_name` may be `null` if the campaign row has no name. --- ## 1.12.0 **Lead tags and email verification on import** ### Added - Optional `tags` (array of names/aliases) and `email_verification` on `LeadCreate` — create, bulk, async, and staged import all inherit the same fields - Person-level lead tags (catalog + account-owned) resolved by name; unknown names find-or-create an account tag - Structured verification facts (`ok` / `catch_all` / `invalid` / `unknown` / `disposable`) stored separately from tags ### Changed - Omitting the new fields behaves exactly as 1.11.0. `additionalProperties: false` still rejects unknown keys such as `mv_result`. --- ## 1.11.0 **Bulk-first MCP / Client API** ### Added - `GET /v1/meta/limits` — page sizes, sync/async caps, queued vs running job quotas, supported scopes/operations, file-ingress capabilities - `POST /v1/bulk/preview` — estimate matched/excluded/actionable counts; bind execution with `preview_id` - Staged lead import: `POST /v1/campaigns/{id}/imports/staged`, `POST /v1/jobs/{id}/staging-rows`, `POST /v1/jobs/{id}/finalize` - Optional `POST /v1/uploads/presign` for direct S3 CSV upload (`upload_id`); local filesystem paths are never accepted - `POST /v1/people/export` (`exportPeople`) and compact email/`global_lead_id` projection - `POST /v1/campaigns/{id}/enroll` (`enrollPeople`) with server-side `scope` + `exclusions` - `POST /v1/lead-lists/{id}/members:bulk` (`updateLeadListMembership`) async add/remove - `POST /v1/jobs/{id}/cancel` — cancel queued/uploading immediately; stop running jobs between chunks - Job operations: `add_to_lead_list`, `remove_from_lead_list`, `export_leads`, `csv_lead_import_staged` - Campaign detail now returns attached `mailbox_ids` ### Changed - Async job capacity: only **running** jobs consume the concurrent slot (default 3); additional jobs stay **queued** up to a separate quota - `createAsyncJob` accepts `scope`, `exclusions`, `preview_id`, `target_list_id`, `source_campaign_id`, and export projection fields - Lead-source `bucketId` is normalized to the campaign bucket on create/save; richText variants derive `body_html` from `template`/`body_text` when empty --- ## 1.10.0 **Campaign create defaults to Central business hours** ### Changed - `POST /v1/campaigns` now defaults omitted `schedule` to Central 9–5 Mon–Fri (`America/Chicago`). Pass `"schedule": null` for 24/7. - Omitted `sending_interval_seconds` defaults to `1440` (24 minutes; ~20 emails per mailbox per day on the default window), replacing the previous `300` default. --- ## 1.9.0 **Replace lead preview + structured errors** ### Added - `GET /v1/threads/{id}/replace-lead/preview?email=` — read-only preview of create vs attach, block-list status, match count, and whether the write would be refused (`allowed` / `disallowed_reason`) - MCP tool `previewThreadLeadReplacement` (auto-generated from the new endpoint) - `POST /v1/threads/{id}/replace-lead` accepts `new_mobile_phone_number` - The same response returns `target_lead_id`: the pre-existing contact on `mode = attached`, otherwise `null` ### Changed - Replace-lead business-rule failures now return structured 400/403/404/409 instead of opaque 500s (`same_as_current_lead`, `lead_already_replaced`, `target_missing_enrollment`, `target_already_replaced`, `lead_not_found`, `invalid_reason`, …) - `replaceThreadLead` description documents create vs attach side effects and points agents at the preview tool --- ## 1.8.0 **Replace lead reuses an existing campaign contact** ### Added - `POST /v1/threads/{id}/replace-lead` returns `mode`: `attached` when the replacement address was already a live lead in the campaign, `created` otherwise - The same response returns `retired_sibling_count`, how many duplicate rows of that address had their sequence stopped ### Changed - Replacing to an address that is already in the campaign no longer creates a second lead. The existing contact is reused, the conversation moves to them, and the replaced lead is retired as stopped/replaced instead of archived. `new_lead_id` is that pre-existing contact. - The call now fails if the existing contact has no enrollment in the campaign, since the forward would have nothing to send against --- ## 1.7.0 **Account settings + MCP surface** ### Added - `GET`/`PUT /v1/webhooks` — account webhook URL, signing secret, enabled events - `GET`/`POST /v1/api-keys`, `DELETE /v1/api-keys/{id}` — create returns secret once; list omits secret - `POST`/`GET /v1/mailboxes/connect-sessions` — start mailbox connect handoff and poll status --- ## 1.6.1 **People response shape + request body hygiene** ### Changed - `GET /v1/people` and lead-list people pages now return the same person fields as detail/PATCH, with `latest_activity_at` (list no longer emits `latest_activity` or per-row `total_count`) - `PATCH /v1/campaigns/{id}` accepts `"schedule": null` to clear the send window (24/7) - Unknown keys on primary request bodies are ignored (stripped) so handlers match closed OpenAPI schemas --- ## 1.6.0 **Inbox triage timestamps** ### Added - Thread responses include `last_inbound_at` — latest inbound lead reply timestamp ### Changed - `GET /v1/threads` `date_from` / `date_to` and Newest/Oldest sort now use `last_inbound_at` (lead reply time), not `last_message_at` - `last_message_at` remains latest activity in either direction --- ## 1.5.0 **Inbox thread search** ### Changed - `GET /v1/threads?q=` — free-text search now matches subject, participants, lead name/email/company, campaign name, thread tags, and message bodies (prefix/FTS; minimum 2 characters) --- ## 1.4.3 **Self-hosted docs rebuild (Fumadocs + OpenAPI reference + agent layer)** ### Changed - Replaced Starlight/Scalar with a unified Fumadocs site at `/docs` - API reference at `/docs/reference/` uses fumadocs-openapi inside the Furnace docs shell (read-only, no try-it console) - Documentation and API Reference are separated in the header (Mintlify-style tabs) - Split building campaigns into quickstart, flow, launch, and flow-schemas guides - Auto-generated `llms.txt`, `llms-full.txt`, and per-page `.md` mirrors for agent access --- ## 1.4.2 **Starlight documentation site** ### Changed - Replaced Scalar at `/docs` with a Starlight static docs site (guides + OpenAPI reference via starlight-openapi) - Removed phantom `/documentation/*` OpenAPI paths; guides export from TS builders at build time - Building campaigns guide now documents `POST /flow` as the hero save (PUT remains a deprecated alias) --- ## 1.4.1 **Documentation consolidation** ### Changed - Removed the **Campaign flow reference** guide page — field-level flow object docs now live in **Models** (`CampaignFlow`, `FlowUpdate`, `FlowValidationIssue`, and related node schemas) - **Building campaigns** guide and flow API endpoint descriptions now link to Models schemas --- ## 1.4.0 **Campaign build lifecycle & flow pipeline** ### Added - **Building campaigns** guide in `/docs` (Guide → Building campaigns) with lifecycle rules, copy-pasteable flow JSON, and draft-vs-live locking behavior - **Campaign flow reference** guide (`/documentation/campaign-flow-reference`) — field-by-field flow object reference, merge variables, normalization rules, and full validation error-code catalog - `POST /v1/campaigns` — create a draft campaign with optional mailboxes, tags, schedule, and initial flow - `POST /v1/campaigns/{id}/flow` — hero flow save with `flow_revision`, `field_sync`, and optional `If-Match` concurrency - `POST /v1/campaigns/{id}/flow?dry_run=true` — dry-run alias for flow validation without persisting - `PUT /v1/campaigns/{id}/flow` — write the canonical campaign flow payload (deprecated alias of `POST`) - `POST /v1/campaigns/{id}/flow:validate` — dry-run normalization, validation, and lifecycle gating - `PATCH /v1/campaigns/{id}/status` — pause, resume, or stop live campaigns (`running` | `paused` | `stopped`) - `PATCH /v1/campaigns/{id}/flow/nodes/{nodeId}` — live content-only node patch - `GET /v1/flow-templates` — starter flow graphs - `GET /v1/campaigns/{id}?include=launch_state,lead_field_state` — checklist observability without extra validate calls - `POST /v1/campaigns/{id}/launch` — start a draft campaign after backfilling enrollments - `field_sync` on flow saves — auto-declares merge-variable fields from email copy ### Changed - `GET /v1/campaigns` list responses omit `flow_data`; use `GET /v1/campaigns/{id}` for the full flow - Campaign detail responses include computed `flow_revision` - `POST /v1/campaigns/{id}/launch` returns `{ enrolled: N }` and uses shared launch validation - Live campaign flows are now topology-locked in both the API and the builder UI. Structural edits return `403 permission_error` with code `flow_locked`. - `POST /v1/campaigns/{id}/lead-fields` now writes flow data through the same service-role-safe persistence path as the new flow endpoints. - **Building campaigns** guide expanded with end-to-end curl walkthrough, `flow:validate` response examples, structural change reason codes, launch preconditions, and troubleshooting table - OpenAPI schemas for flow node types (`EmailVariant`, `LeadSourceNodeData`, `EmailNodeData`, `WaitTimeNodeData`, `AICategorizerNodeData`, `DataSenderNodeData`, `FlowNode`, `FlowEdge`, `FlowValidateResult`) now include per-field descriptions and examples --- ## 1.3.0 **Webhooks — categorization and delivery infrastructure** ### Added - Outbound webhook `reply.categorized` when a thread reply category is assigned, changed, or cleared - Granular per-event webhook subscription in Account Settings (expand event groups to pick individual types) ### Changed - Campaign pause, resume, and stop from the Furnace app now emit `campaign.paused`, `campaign.resumed`, and `campaign.stopped` webhooks (previously Client API only) - `PATCH /v1/threads/{id}` category updates emit `reply.categorized` --- ## 1.2.0 **Inbox expansion** — triage, outbound messaging, and ops endpoints for programmatic inbox use. ### Added - **Webhooks** guide in `/docs` (Guide → Webhooks) with example payloads for every outbound event type - Consolidated `/docs` into a single Scalar document with Guide and API sidebar sections **Thread list & triage** - `GET /v1/threads` — new query params: `q`, `unread_only`, `conversation_status`, `category` (`no_category` for uncategorized), `tag_ids`, `date_from`, `date_to`, `has_reply_only` (default `true`) - `PATCH /v1/threads/{id}` — partial update: `category`, `conversation_status`, `read` **Outbound messaging** - `POST /v1/threads/{id}/forward` — queue forward job (`forward_message_id` required) - `GET /v1/message-jobs/{id}` — poll reply/forward job status - `POST /v1/message-jobs/{id}/cancel` — cancel queued/failed outbound job - `POST /v1/message-jobs/{id}/send-now` — expedite queued outbound job **Inbox ops** - `PUT /v1/threads/{id}/out-of-office` — set OOO (`resume_mode`: `scheduled` | `instant` | `none`) - `DELETE /v1/threads/{id}/out-of-office` — clear OOO - `POST /v1/threads/{id}/replace-lead` — replace thread lead; optional `forward_message_id` - `GET /v1/thread-tags` — list account thread tags - `POST /v1/threads/{id}/tags:add` / `tags:remove` — assign or remove tag on thread ### Changed - `POST /v1/threads/{id}/reply` — optional `in_reply_to_message_id` (defaults to latest message) ### Notes - Poll outbound sends with `/v1/message-jobs/{id}`, not `/v1/jobs/{id}` (import jobs only). - Tag create/edit/delete remains in the Furnace app; the API supports list + assign/remove only. --- ## 1.1.0 **People, lists, jobs, and batch webhooks** ### Added - `GET/PATCH /v1/people`, `GET/PATCH /v1/people/{globalLeadId}` - `GET/POST/PATCH/DELETE /v1/lead-lists`, list membership endpoints - `POST /v1/jobs`, `GET /v1/jobs/{id}` — async bulk operations - Campaign/mailbox tag CRUD and filtering - Sync bulk shortcuts: `leads:add`, `leads:remove`, `leads:remove-from-all-campaigns`, enrollment pause/resume - Batch completion webhooks (`*.completed`) for bulk and enrollment actions ### Changed - Webhook allowlist: removed `enrollment.created` / `enrollment.updated`; added batch completion events --- ## 1.0.0 **Initial Client API** ### Added - Campaigns, leads, mailboxes, block list, campaign stats - Basic inbox: `GET /v1/threads`, `GET /v1/threads/{id}`, `GET /v1/threads/{id}/messages`, `POST /v1/threads/{id}/reply` - Atomic webhooks: `lead.*`, campaign lifecycle, `email.sent`, `reply.received`, `bounce.detected` - OpenAPI at `/openapi.json`, Scalar UI at `/docs`