Campaigns

Launch a campaign for a person

Changed on

Launch a campaign for a person

POST /api/v1/campaigns/{id}/launch

What this actually does

Think of a campaign template as a saved, reusable outreach sequence , like "3-email cold outreach" or "InMail + follow-up." It's just a plan sitting in storage; nothing gets sent yet.

Calling this endpoint activates that plan for one specific person. It fills in the blanks (their name, company, etc.), schedules each step, and starts sending. You do this once per person you want to reach with that template.

Before you start, you need three things

  1. A campaign template ID — find this by listing your templates with GET /api/v1/campaigns, or you already know it because you created one.
  2. A person ID — the person you want to send this campaign to, from GET /api/v1/people or wherever you got their record.
  3. Your API token — a secret string that proves who you are. It goes in every request as Authorization: Bearer YOUR_SECRET_TOKEN. Never share it or put it in code that's visible to others.

If you're handing this to an AI to build for you: tell it your API token (or how it should read it, e.g. from an environment variable), the base URL https://api.recruitwithatlas.com, and paste the examples below. Say explicitly which of the two modes you want — "just launch it as-is" or "I want to write my own custom message" — since they're different requests.

There are two ways to launch — pick one

Mode 1: Launch it as-is. Use the template exactly as saved. You just say who it's for. (Most people want this.)

Mode 2: Write your own message for this launch. Override the template's steps completely with your own custom content, just for this one send. This would be used if you have built your own app that customises the content of a campaign for each receipient. For instance, you are taking a candidate to market and you want to customise the reasons for hiring the candidate for each receipient.

Who the campaign sends from

By default the launched campaign belongs to the user your API token belongs to, and the emails go out from that user's connected mailbox. If you run one company-wide token on behalf of several consultants, add ownerEmail to say which consultant this particular launch is for:

{ "personId": "550e8400-e29b-41d4-a716-446655440001", "ownerEmail": "consultant@agency.com" }

That consultant then owns the launched campaign, their mailbox sends the emails, and replies land with them. If the person is new to the project they are also seated under that consultant; if they were already on it, whoever seated them first stays their owner — launching a campaign never reassigns an existing candidate.

Two ways this can be rejected: the email must belong to an active user in your agency (unknown, disabled, subscription-required, AI and other-agency all give 404 Owner not found), and that user must have a mailbox connected in Atlas, or you get a 422 naming them. Leave ownerEmail out, or send it as null, and nothing changes from before.

ownerEmail here is per launch. It is unrelated to the ownerEmail on POST /api/v1/campaigns, which only records who owns the reusable template. The response echoes the resolved user as ownerId.

You choose the mode by whether you include a steps field in your request. Leave it out for Mode 1, include it for Mode 2. Essentially, if you don't give me any steps, I will assume you want to use the template. If you give me steps, I assume you want to replace hte template.


Mode 1: Launch the template as-is

The simplest possible request — just the person:

curl https://api.recruitwithatlas.com/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000/launch \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \
  --data '{
    "personId": "550e8400-e29b-41d4-a716-446655440001"
  }'

That's often all you need. The system automatically fills in things like the person's first name and current company from their record.

Three optional extras, only if you need them:

  • variables — force a specific value instead of letting the system auto-fill it (e.g. you want to say "Acme Corp" even though their profile says something else).
  • aiVariables — some templates have a blank spot meant to be written by AI, like "summarise the job description in one sentence." If your template has one of these, you must generate that text yourself (using your own AI/ChatGPT/whatever) and hand back the finished text here. Find out if a template needs this by calling GET /api/v1/campaigns/{id} first — it lists every AI blank the template contains, under aiVariables, with value: null until you fill it.
  • ownerEmail — launch on behalf of a specific consultant, so the campaign is theirs and the emails send from their mailbox. See Who the campaign sends from below.
curl https://api.recruitwithatlas.com/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000/launch \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \
  --data '{
    "personId": "550e8400-e29b-41d4-a716-446655440001",
    "variables": {
      "person_first_name": "Johnny",
      "current_company": "Acme Corp"
    },
    "aiVariables": [
      { "name": "summarise the job description", "value": "Senior Backend Engineer, 10y, London" }
    ]
  }'

aiVariables isn't optional if the template needs it. If you skip a required one, you'll get an error telling you exactly which one is missing. If you are using an AI to create campaigns, I'd highly recommend using either full customisation or template.


Mode 2: Write your own message just for this launch

Add a steps field with your own list of messages. This completely replaces what the template would have sent — Atlas just remembers it came from this template, for your own record-keeping.

curl https://api.recruitwithatlas.com/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000/launch \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \
  --data '{
    "personId": "550e8400-e29b-41d4-a716-446655440001",
    "steps": [
      {
        "position": 1,
        "type": "email",
        "dispatchType": "new_thread",
        "subject": "Exciting opportunity at {{client_name}}",
        "body": "Hi {{person_first_name}},\n\nAre you open to hearing about a new role?",
        "scheduledOn": "2026-08-01"
      },
      {
        "position": 2,
        "type": "email",
        "dispatchType": "reply_to",
        "body": "Just following up on my last note!",
        "intervalDays": 3,
        "intervalTime": "09:00"
      }
    ]
  }'

Each item in steps is one message. Here's what every field means, in order:

FieldWhat it isExample
positionOrder — 1, 2, 3... no skipping numbers1
typeThe channel: email, <br>li_inmail (LinkedIn InMail),<br> li_request (LinkedIn connection request), <br>phone_call, <br>or todo"email"
dispatchTypeEmail only. <br>new_thread = brand new email, <br>reply_to = reply on the same thread as the previous email"new_thread"
subjectEmail only, and only required if starting a new thread"Exciting opportunity..."
bodyThe message text. See formatting belowsee below
scheduledOnAn exact calendar date (YYYY-MM-DD) to send this step. Only usable on step 1"2026-08-01"
intervalDaysInstead of a fixed date: wait this many days after the previous step3
intervalTimeWhat time of day to send it"09:00"

You need either scheduledOn or intervalDays on the first step (not both) — that's what tells the system when to start.

The template's scheduling protections — businessDays (only send on working days) and businessHours (only send during working hours) — always carry over from the template, as does the step timezone (resolved from the person's location, falling back to the campaign owner's). One value applies to the whole launched sequence: the template's first step by position, the same value the campaign UI shows. Sending any of the three in steps is accepted and ignored, not rejected — so a 201 does not mean your value was applied. Change them on the template instead.

If your custom message text has an AI blank like {{ai:...}} in it, you still need to fill that in via aiVariables, same as Mode 1.


Writing the message text (body) — paragraphs and bullet points

The body field is plain text by default, but understands two simple rules:

  • A blank line between sentences → starts a new paragraph.
  • A single line break → a line break within the same paragraph (no blank line before it).
"body": "Hi {{person_first_name}},\n\nI wanted to reach out about an exciting opportunity at {{client_name}}.\n\nLooking forward to hearing from you!"

(That \n\n is just "type Enter twice" — it's how you write a blank line inside a JSON text string.)

There's no automatic bullet-point support. Typing a dash or asterisk at the start of a line just shows up as a literal dash or asterisk, it won't turn into a bullet.

To get real bullet points, bold text, or links, write actual HTML tags in body instead:

"body": "<p>Hi {{person_first_name}},</p><p>I wanted to reach out about an exciting opportunity at <strong>{{client_name}}</strong>. This role involves:</p><ul><li>Backend architecture</li><li>API design</li><li>Mentoring the team</li></ul><p>Let me know if you'd like to chat!</p>"

Supported HTML: paragraphs (<p>), line breaks (<br>), bold (<strong>), italics (<em>), underline (<u>), links (<a href="...">), bullet/numbered lists (<ul>/<ol>/<li>), headings (<h1>–<h6>), and basic tables. Anything else gets silently removed for safety.

One rule to remember: once your body contains any HTML tag, the whole thing switches into "HTML mode" and the blank-line-means-new-paragraph trick above stops working for the rest of it. So don't mix the two styles — if you use any HTML tag, wrap every paragraph in <p>...</p> explicitly.

One thing not to do: don't copy the body text you see when you fetch a campaign (GET /api/v1/campaigns/{id}) and paste it back in when creating or launching one — that's the finished, rendered version, not something you should feed back in as input.


What you get back when it works (success)

You'll get an HTTP status of 201 and a response like this:

{
  "status": "ok",
  "data": {
    "id": "8c1f2e3a-...",
    "outreachCampaignId": "550e8400-e29b-41d4-a716-446655440000",
    "personId": "550e8400-e29b-41d4-a716-446655440001",
    "ownerId": "550e8400-e29b-41d4-a716-446655440002",
    "type": "candidate_outreach",
    "status": "active",
    "createdAt": "2026-07-21T20:00:00.000Z",
    "steps": [ ... ]
  }
}

status: "active" means the first message is scheduled and will go out. status: "ready" means it's briefly paused — usually because we're still figuring out the person's timezone (e.g. their LinkedIn import hasn't finished yet) — it'll become active automatically shortly after.

If something goes wrong

Every error response looks like { "status": "error", "error": "..." } with an explanation in plain English, plus one of these HTTP status codes:

Status codePlain-English meaning
401Your token is missing or invalid — check the Authorization header
403Your token doesn't have permission to launch campaigns
404The campaign, the person, a file you referenced, or the ownerEmail user doesn't exist (or belongs to someone else). ownerEmail must name an active user — disabled, subscription-required and AI accounts all read as not found
409This person already has this campaign (or a conflicting one) running — you can't launch it twice
422Something in your request needs fixing — including an ownerEmail user who has no connected mailbox to send from — check the error message, it names the exact field (e.g. "missing AI variable" or "step must have a body")
429You're sending requests too fast — slow down and retry after a pause
501This feature is turned off for your account — this isn't a mistake on your end, don't retry, contact support instead

If you're getting an AI to build this integration, tell it to always check the status code and surface the error message to you rather than silently failing.

post/api/v1/campaigns/{id}/launch

Request

Path parameters

idstring uuid required

ID of the campaign template to launch

Example:550e8400-e29b-41d4-a716-446655440000

Request body

personIdstring uuid required

ID of the person to launch the campaign for. The system automatically fills in things like their first name and current company from their record — often this field is all you need to send.

ownerEmailstring email nullable

Optional — the Atlas user this launched campaign belongs to, and whose connected mailbox the email steps send from. Omit it (or send null) and the campaign is owned by the user your API token belongs to (the previous, unchanged behaviour). Supply it when one company-wide token launches on behalf of several consultants. Must be an active user in your agency: an unknown, disabled, subscription-required, AI or other-agency email is rejected with 404, and an active user with no connected mailbox with 422. This does not change who owns the template — that is set once on POST /api/v1/campaigns.

variablesobject

Optional overrides — force a specific value instead of letting the system auto-fill it (e.g. say "Acme Corp" even though the profile says something else). Keys must be overridable static variable types: person_first_name, person_last_name, client_name, job_role, advertised_job_role, job_link, target_company, current_company. Time-sensitive types (today, tomorrow, etc.) are resolved at send time and cannot be overridden. Values must be non-empty strings; unknown or time-sensitive keys are rejected with 422.

Example request

{
  "personId": "550e8400-e29b-41d4-a716-446655440001",
  "ownerEmail": "consultant@agency.com",
  "variables": {
    "person_first_name": "Johnny",
    "current_company": "Acme Corp"
  },
  "aiVariables": [
    {
      "name": "summarise the job description",
      "value": "Senior Backend Engineer, 10y, London"
    }
  ],
  "steps": [
    {
      "position": 1,
      "type": "email",
      "dispatchType": "new_thread",
      "subject": "Exciting {{job_role}} role at {{client_name}}",
      "body": "Hi {{person_first_name}},\n\n{{ai:one-line personalised hook}}\n\nWe are hiring for your team.",
      "scheduledOn": "2026-07-25",
      "intervalTime": "09:00",
      "fileIds": []
    },
    {
      "position": 2,
      "type": "email",
      "dispatchType": "reply_to",
      "body": "Hi {{person_first_name}}, floating this back to the top of your inbox.",
      "intervalDays": 3,
      "fileIds": []
    }
  ]
}

Response

Campaign launched successfully

status'ok' required

Example response

{
  "data": {
    "steps": [
      {
        "position": 1,
        "status": "scheduled",
        "scheduledOn": "2026-07-01",
        "intervalTime": "09:00",
        "timezone": "Europe/London",
        "variables": [
          {
            "type": "person_first_name",
            "value": "Johnny"
          }
        ],
        "aiVariables": [
          {
            "name": "List the full and entire job description including formatting"
          }
        ]
      }
    ]
  }
}

Changes

    • warning

      added the new undefined enum value to the /// response property for the response status

    • info

      added the optional property / to the response with the status