# 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.
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`