> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fuseai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build an AI outbound campaign from a prompt

> Describe your ideal buyer in plain English and launch an AI-written email campaign: search prospects, save an enriched list, ground the copy and approve it.

This workflow turns one sentence about your ideal buyer into an approved, AI-written email campaign. You preview a natural-language prospect search, save the matches into a list with work emails, ground the copy in your knowledge hub and writing style, read what the AI wrote, and approve. It takes about a dozen API calls, and most of the elapsed time is enrichment, which the save call estimates for you.

```mermaid theme={null}
%%{init: {"flowchart": {"rankSpacing": 20, "nodeSpacing": 20}}}%%
flowchart TD
  A[Prospect search] -->|filters| B[Save with emails]
  B -->|listId| C[AI campaign]
  C -->|campaignId| D[Preview and approve]
```

## Before you start

| You need | Why |
| - | - |
| An API key with the `search`, `lists` and `campaigns` [scopes](/concepts/scopes) | Searching, polling the list and creating the campaign each sit on a different surface. |
| A connected mailbox | Approval fails with `CAMPAIGN_NOT_READY` when no sending account is connected. See [Connect a mailbox](/guides/connect-a-mailbox). |
| A [knowledge hub](/guides/knowledge-hubs) (recommended) | Grounds the AI copy in your real product instead of the prompt alone. |
| Credits | 2 per previewed profile, and 20 to 50 per work email found. See [What it costs](#what-it-costs). |

## Step by step

<Steps>
  <Step title="Describe your buyer in plain English">
    Send a natural-language `query` to `POST /business/prospects/search`. Fuse converts the sentence into structured filters, runs the search, and echoes the filters back.

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/prospects/search \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "query": "Heads of finance at UK fintech companies with 50 to 500 employees", "size": 5 }'
    ```

    ```json theme={null}
    {
      "profiles": ["…"],
      "total": 1840,
      "filters": {
        "job_title_role": [{ "status": "include", "value": "finance" }],
        "job_title_levels": [{ "status": "include", "value": "director" }, { "status": "include", "value": "vp" }],
        "industry": [{ "status": "include", "value": "financial services" }],
        "location_country": [{ "status": "include", "value": "united kingdom" }],
        "rangeInputs": { "job_company_employee_count": { "min": 50, "max": 500 } }
      },
      "unresolvedCriteria": []
    }
    ```

    `filters` is what your sentence resolved to, and `total` is the true match count. `unresolvedCriteria` quotes any part of the sentence that could not be mapped to a filter: if it is not empty, the results are broader than you asked for. Tighten them by editing `filters` and searching again with `filters` instead of `query`. [Build a list from search](/guides/build-a-list-from-search#look-up-the-accepted-filter-values) shows how to look up exact filter values.

    Keep `size` small while you tune. Every profile returned costs 2 credits.
  </Step>

  <Step title="Check your credit balance">
    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/credits -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    { "balance": 8250 }
    ```

    Before the save starts, the balance must cover 50 credits for every contact in `limit`, the most a work email can cost. If it cannot, the save is refused with `402 INSUFFICIENT_CREDITS` and nothing is billed. Most emails then cost 20, and a contact with no email found costs nothing. If spending elsewhere runs the balance down while the job runs, the job pauses as `stopped` until you top up and resume it in the app.
  </Step>

  <Step title="Save the matches with work emails">
    `POST /business/prospects/search/save-to-list` runs the search server-side, saves up to `limit` matches and finds their work emails in the same job.

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/prospects/search/save-to-list \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "filters": {
          "job_title_role": [{ "status": "include", "value": "finance" }],
          "job_title_levels": [{ "status": "include", "value": "director" }, { "status": "include", "value": "vp" }],
          "industry": [{ "status": "include", "value": "financial services" }],
          "location_country": [{ "status": "include", "value": "united kingdom" }],
          "rangeInputs": { "job_company_employee_count": { "min": 50, "max": 500 } }
        },
        "listName": "UK fintech finance leaders",
        "limit": 200,
        "enrich": "email"
      }'
    ```

    ```json theme={null}
    {
      "listIds": ["68a1f20b9c41d20014b3e901"],
      "enrich": "email",
      "totalContacts": 200,
      "estimatedMinutes": 10,
      "jobId": "68a2109c9c41d20014b41003",
      "status": "pending"
    }
    ```

    Send the `filters` from step 1 rather than the sentence again, so the save runs exactly the search you previewed. `listName` creates the list, or reuses an existing list with that name. Keep `listIds[0]` and `jobId`: every later step works on the list, and the job tells you when it is full.

    <Note>
      The save runs its own search and does not reuse your preview, so there is no need to page through results first. Saving a hand-picked subset is not supported; narrow the filters instead.
    </Note>
  </Step>

  <Step title="Wait for the enrichment job">
    Poll the job named by the save's `jobId` until its `status` is `completed`. Every 30 seconds is plenty.

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/jobs/68a2109c9c41d20014b41003 -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    {
      "job": {
        "jobId": "68a2109c9c41d20014b41003",
        "type": "bulk_enrichment",
        "status": "completed",
        "listIds": ["68a1f20b9c41d20014b3e901"],
        "progress": { "totalContacts": 200, "processedContacts": 200, "enrichedContacts": 171, "notFoundContacts": 29, "alreadyEnrichedContacts": 0, "creditsCharged": 4020 }
      }
    }
    ```

    `status` is `pending` while another of your enrichment jobs runs, because your jobs run one at a time. Do not wait on the list's `completionStatus` here: a new list reads `complete` until its job starts. A `stopped` job paused because the balance ran out; top up, then resume it from the list's page in the app.

    Once the job is `completed`, `metrics.totalWithProfessionalEmail` on `GET /business/lists/{listId}` is how many contacts the campaign can email.
  </Step>

  <Step title="Choose what grounds the copy">
    Three reads decide what the AI writes from and who it sends as. None of them cost credits.

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/knowledge-hubs -u "$FUSE_API_KEY:"
    curl https://api.tryfuse.ai/api/v1/business/writing-styles -u "$FUSE_API_KEY:"
    curl "https://api.tryfuse.ai/api/v1/business/accounts?channel=email" -u "$FUSE_API_KEY:"
    ```

    * **Knowledge hub:** your team shares one hub, and a campaign that leaves `knowledgeHubId` out is grounded on it. `knowledgeHubId` accepts only a hub you own, so leave it out unless you mean to pick one of yours.
    * **Writing style:** `writingStyles` lists your default style first. Its `id` is the `writingStyleId`.
    * **Mailbox:** make sure at least one account is listed and its `needsReauthAt` is `null`.
  </Step>

  <Step title="Create the AI campaign">
    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/campaigns \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "campaignName": "UK fintech finance leaders - Q4",
        "campaignType": "ai-generated",
        "listId": "68a1f20b9c41d20014b3e901",
        "channels": ["email", "email", "email"],
        "userPrompt": "Book intro calls with finance leaders who still run month-end close by hand. Friendly, concise, no jargon.",
        "writingStyleId": "68a1fd309c41d20014b3f401",
        "ctaLink": "https://example.com/demo",
        "timezone": "Europe/London",
        "excludeContacted": "last_3_months"
      }'
    ```

    ```json theme={null}
    { "campaignId": "68a1f6309c41d20014b3ec21", "status": "initializing" }
    ```

    `channels` creates one sequence step per entry. `userPrompt` says what the campaign should achieve and how it should sound, and `excludeContacted` skips anyone your campaigns reached in the last three months. The full list of options is in [Campaigns](/guides/campaigns).

    An AI campaign cannot be approved without a `ctaLink`. Leave it out and Fuse reuses the link from your most recent campaign, if there is one.

    <Warning>
      Right after enrichment you can get `422 LIST_INCOMPATIBLE_WITH_CHANNELS` even though the contacts have emails. The list's reachability stats update a little after the emails land. Wait a minute or two and send the same request again.
    </Warning>
  </Step>

  <Step title="Wait for the AI to plan the sequence">
    AI campaigns plan their topics in the background. Poll until `initializationStatus` is `completed`:

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/campaigns/68a1f6309c41d20014b3ec21 -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    {
      "campaign": {
        "id": "68a1f6309c41d20014b3ec21",
        "type": "ai-generated",
        "status": "initializing",
        "initializationStatus": "completed",
        "initializationError": null
      }
    }
    ```

    `status` stays `initializing` until you approve. If `initializationStatus` is `failed`, `initializationError` says why.
  </Step>

  <Step title="Preview what each step will say">
    List the steps, then render each one for a sample contact. Previews are never saved or sent.

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/campaigns/68a1f6309c41d20014b3ec21/steps -u "$FUSE_API_KEY:"

    curl -X POST https://api.tryfuse.ai/api/v1/business/campaigns/68a1f6309c41d20014b3ec21/steps/68a1f6409c41d20014b3ec31/generate \
      -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    {
      "stepId": "68a1f6409c41d20014b3ec31",
      "preview": {
        "subject": "Month-end close at Brightpay",
        "body": "<p>Hi Ada, …</p>",
        "channel": "email",
        "contact": { "name": "Ada Nwosu", "jobTitle": "Head of Finance", "companyName": "Brightpay" }
      }
    }
    ```

    To change direction, `PATCH /business/campaigns/{campaignId}/steps/{stepId}` with a new `topic`, or rebuild every topic with `POST /business/campaigns/{campaignId}/steps/regenerate-topics`. Previews have their own limit of 20 requests a minute.
  </Step>

  <Step title="Approve the campaign">
    Approval is the only call in this workflow that sends email.

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/campaigns/68a1f6309c41d20014b3ec21/approve \
      -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    { "campaignId": "68a1f6309c41d20014b3ec21", "status": "active" }
    ```

    Sending starts at `campaignStartDate` (now, by default) and runs between 09:00 and 17:00 in the campaign's `timezone` on its `emailDays`.
  </Step>
</Steps>

## Track results and reuse the setup

`GET /business/campaigns/{campaignId}/stats` returns sent, opened, clicked and replied counts, and `GET /business/campaigns/{campaignId}/leads` shows each lead's progress with `hasReplied` and `hasClicked`. Clicks and replies are the reliable signals: some email clients load tracking pixels automatically, which inflates opens.

When a campaign works, save its sequence and schedule as a template and point it at the next segment:

```bash theme={null}
curl -X POST https://api.tryfuse.ai/api/v1/business/campaign-templates \
  -u "$FUSE_API_KEY:" \
  -H "Content-Type: application/json" \
  -d '{ "campaignId": "68a1f6309c41d20014b3ec21", "name": "Finance leaders - AI intro" }'
```

Pass the returned `template.id` as `templateId` when you create the next campaign.

## What it costs

| Step | Credits |
| - | - |
| Previewing the search | 2 per profile returned |
| Saving with `enrich: "email"` | No charge for the save. 20 per work email Fuse's data finds, 50 when a partner provider finds it, nothing when none is found. Contacts that already have an email are not charged again. |
| Reading lists, hubs, styles, accounts and campaign status | Free |

See [Credits & billing](/concepts/credits) for every operation.

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `422 QUERY_FILTERS_INVALID` | The sentence was too vague to produce any filter. | Name concrete criteria: title, seniority, industry, location, company size. |
| `unresolvedCriteria` is not empty | Part of the sentence did not map to a filter, so results are broader than intended. | Add the missing constraint to `filters` by hand. |
| `402 INSUFFICIENT_CREDITS` on the save | The balance cannot cover `limit` × 50, the most a work email can cost. | Lower `limit` or top up. Nothing was billed. |
| `422 LIST_INCOMPATIBLE_WITH_CHANNELS` | The list is empty, or its reachability stats have not caught up with enrichment. | Wait for the enrichment job to reach `completed`, then retry after a minute or two. |
| `initializationStatus: "failed"` | The AI could not plan the sequence. | Read `initializationError`, adjust the prompt or hub, and create the campaign again. |
| `422 CAMPAIGN_NOT_READY` on approve | A step has no topic, the campaign has no `ctaLink`, or no mailbox is connected. | `PATCH` a `topic` onto the step or a `ctaLink` onto the campaign, or connect a mailbox in the app. |
| `429 RATE_LIMITED` | Search allows 20 requests a minute and campaigns 30. | Wait for the `Retry-After` seconds, then continue. |

## Run the whole workflow

<Accordion title="Python script: prompt to approved campaign">
  ```python theme={null}
  """Turn a sentence about your buyer into an approved AI email campaign."""
  import os
  import time

  import requests

  BASE = "https://api.tryfuse.ai/api/v1/business"
  AUTH = (os.environ["FUSE_API_KEY"], "")


  class FuseError(Exception):
      def __init__(self, status, code, message):
          super().__init__(f"{status} {code}: {message}")
          self.code = code


  def call(method, path, **kwargs):
      """Send one request, wait out 429s, and raise FuseError on any other failure."""
      while True:
          response = requests.request(method, BASE + path, auth=AUTH, timeout=60, **kwargs)
          if response.status_code == 429:
              time.sleep(int(response.headers.get("Retry-After", 5)))
              continue
          if response.status_code >= 400:
              try:
                  error = response.json().get("error", {})
              except ValueError:
                  error = {}
              raise FuseError(response.status_code, error.get("code"), error.get("message"))
          return response.json() if response.content else None


  def wait_until(read, done, delay=10, max_delay=60):
      """Poll read() until done(result) is true, waiting before each poll and backing off."""
      while True:
          time.sleep(delay)
          result = read()
          if done(result):
              return result
          delay = min(delay * 1.5, max_delay)


  def wait_for_enrichment(job_id):
      """Poll an enrichment job until it ends. A stopped job waits for a top-up and a resume in the app."""
      return wait_until(
          lambda: call("GET", f"/jobs/{job_id}")["job"],
          lambda job: job["status"] in ("completed", "cancelled", "failed", "stopped"),
      )


  # 1. Describe the buyer and preview five matches
  preview = call("POST", "/prospects/search", json={
      "query": "Heads of finance at UK fintech companies with 50 to 500 employees",
      "size": 5,
  })
  print(f"{preview['total']} matches. Unresolved: {preview.get('unresolvedCriteria')}")

  # 2-3. Check the balance, then save 200 matches with work emails
  print("Credit balance:", call("GET", "/credits")["balance"])
  saved = call("POST", "/prospects/search/save-to-list", json={
      "filters": preview["filters"],
      "listName": "UK fintech finance leaders",
      "limit": 200,
      "enrich": "email",
  })
  list_id = saved["listIds"][0]

  # 4. Wait for the enrichment job, then read the list
  job = wait_for_enrichment(saved["jobId"])
  if job["status"] != "completed":
      raise SystemExit(f"Enrichment {job['status']}: {job['error'] or 'top up and resume it in the app'}")
  fuse_list = call("GET", f"/lists/{list_id}")["list"]
  print("Contacts with a work email:", fuse_list["metrics"]["totalWithProfessionalEmail"])

  # 5. Choose what grounds the copy. Leaving knowledgeHubId out grounds it on your team's hub.
  style = call("GET", "/writing-styles")["writingStyles"][0]  # the default style comes first
  if not call("GET", "/accounts", params={"channel": "email"})["accounts"]:
      raise SystemExit("Connect a mailbox in the Fuse app before you approve.")

  # 6. Create the AI campaign, retrying while the list's reachability settles
  campaign_body = {
      "campaignName": "UK fintech finance leaders - Q4",
      "campaignType": "ai-generated",
      "listId": list_id,
      "channels": ["email", "email", "email"],
      "userPrompt": "Book intro calls with finance leaders who still run month-end close by hand. "
                    "Friendly, concise, no jargon.",
      "writingStyleId": style["id"],
      "ctaLink": "https://example.com/demo",
      "timezone": "Europe/London",
      "excludeContacted": "last_3_months",
  }
  for attempt in range(5):
      try:
          campaign_id = call("POST", "/campaigns", json=campaign_body)["campaignId"]
          break
      except FuseError as error:
          if error.code != "LIST_INCOMPATIBLE_WITH_CHANNELS" or attempt == 4:
              raise
          time.sleep(90)

  # 7. Wait for the AI to plan the sequence
  campaign = wait_until(
      lambda: call("GET", f"/campaigns/{campaign_id}")["campaign"],
      lambda current: current["initializationStatus"] in ("completed", "failed"),
  )
  if campaign["initializationStatus"] == "failed":
      raise SystemExit(campaign["initializationError"])

  # 8. Preview every step for a sample contact
  for step in call("GET", f"/campaigns/{campaign_id}/steps")["steps"]:
      rendered = call("POST", f"/campaigns/{campaign_id}/steps/{step['id']}/generate")["preview"]
      print(f"\nStep {step['sequenceNumber']}: {step['topic']}\n{rendered['subject']}\n{rendered['body']}")

  # 9. Approving sends real email, so ask first
  if input("\nApprove and start sending? [y/N] ").strip().lower() == "y":
      print(call("POST", f"/campaigns/{campaign_id}/approve"))
  ```
</Accordion>

## Do it from Claude

With the [Fuse MCP server](/mcp/connect) connected, one request covers the same ground:

> Find heads of finance at UK fintech companies with 50 to 500 employees, save 200 of them with work emails to a list called "UK fintech finance leaders", and draft a three-email AI campaign on that list grounded in our knowledge hub. Don't launch it yet.

The assistant searches, saves the people with their work emails to the list, then creates the campaign and writes its sequence. Creating a campaign never launches it, so it stays a draft until you ask.

## Related

<CardGroup cols={2}>
  <Card title="Build a list from search" icon="magnifying-glass" href="/guides/build-a-list-from-search">
    Filter vocabularies, saved searches and every way to express a prospect search.
  </Card>

  <Card title="Campaigns" icon="paper-plane" href="/guides/campaigns">
    Every campaign option, manual steps, merge tokens and approval errors.
  </Card>

  <Card title="Knowledge hubs" icon="book" href="/guides/knowledge-hubs">
    Build the product knowledge AI campaigns write from.
  </Card>

  <Card title="Qualify leads before enriching" icon="filter" href="/workflows/qualify-leads-before-enrichment">
    Spend enrichment credits only on the prospects that fit your ICP.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.