Docs

API reference.

Everything here works with an API key. Every request and response on this page is a real call, made against a test workspace with made-up data.

Base URL and keys

Send requests to https://craftnudge.com/api. Make a key in Settings, on the API keys tab. It is shown once, so copy it then. Send it in the Authorization header as a bearer token.

A key acts for its whole workspace. It cannot change sign-in details, and the few settings only an owner can change refuse it.

Requests and errors

Bodies are JSON. The lead and blocklist imports take a CSV instead, as the body with Content-Type text/csv, or as a file field in a multipart form.

A request that fails answers with a 4xx status and a JSON body saying why. A missing or unknown key gets 401. Lists of records in the responses below are cut to two, and CSV files to two rows. Where a call needs a service outside CraftNudge, a model, a Google Sheet or a DNS lookup, that service's answer was made up for the example too.

Contents

Inboxes

The mailboxes you send from. A password is stored encrypted and never comes back.

GET /mailboxes

List inboxes

Every inbox in the workspace, with what it has sent today and what it may send today.

Request

curl https://craftnudge.com/api/mailboxes \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "86b394fc-4eef-41ac-bc0d-be1e4d4c7414",
    "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
    "email": "hey@getlumen.example",
    "fromName": "Sam from Lumen",
    "smtpHost": "smtp.gmail.com",
    "smtpPort": 465,
    "smtpSecure": true,
    "imapHost": "imap.gmail.com",
    "imapPort": 993,
    "username": "hey@getlumen.example",
    "dailyCap": 40,
    "rampStart": 10,
    "rampIncrement": 5,
    "rampStartedAt": "2026-09-08",
    "minGapMinutes": 9,
    "gapJitterMinutes": 5,
    "signature": null,
    "tags": [
      "warming"
    ],
    "domainDailyCap": null,
    "status": "active",
    "lastError": null,
    "backoffUntil": null,
    "createdAt": "2026-09-08T10:00:00.000Z",
    "sentToday": 0,
    "capToday": 40,
    "rampShare": 5
  },
  {
    "id": "498bb0fc-09a5-47c7-aa7e-64dd94d6c2ce",
    "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
    "email": "outreach@acme.example",
    "fromName": "Jordan at Acme",
    "smtpHost": "smtp.gmail.com",
    "smtpPort": 465,
    "smtpSecure": true,
    "imapHost": "imap.gmail.com",
    "imapPort": 993,
    "username": "outreach@acme.example",
    "dailyCap": 40,
    "rampStart": 10,
    "rampIncrement": 5,
    "rampStartedAt": "2026-08-21",
    "minGapMinutes": 9,
    "gapJitterMinutes": 5,
    "signature": null,
    "tags": [
      "uk"
    ],
    "domainDailyCap": 60,
    "status": "active",
    "lastError": null,
    "backoffUntil": null,
    "createdAt": "2026-08-21T10:00:00.000Z",
    "sentToday": 0,
    "capToday": 40,
    "rampShare": 6
  }
]

POST /mailboxes

Connect an inbox

Saves the SMTP and IMAP details and starts the inbox at 10 a day, adding 5 a day up to its cap. It does not log in. Test the connection from the Inboxes page before sending. Your plan’s inbox count applies.

Request

curl -X POST https://craftnudge.com/api/mailboxes \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "new@acme.example",
  "from_name": "Jordan at Acme",
  "smtp_host": "smtp.gmail.com",
  "smtp_port": 465,
  "smtp_secure": true,
  "imap_host": "imap.gmail.com",
  "imap_port": 993,
  "username": "new@acme.example",
  "password": "your-app-password",
  "daily_cap": 30
}'

Response, 200

{
  "id": "078eb811-6d54-4824-84aa-c819a4974d84",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "email": "new@acme.example",
  "fromName": "Jordan at Acme",
  "smtpHost": "smtp.gmail.com",
  "smtpPort": 465,
  "smtpSecure": true,
  "imapHost": "imap.gmail.com",
  "imapPort": 993,
  "username": "new@acme.example",
  "dailyCap": 30,
  "rampStart": 10,
  "rampIncrement": 5,
  "rampStartedAt": "2026-09-14",
  "minGapMinutes": 9,
  "gapJitterMinutes": 5,
  "signature": null,
  "tags": [],
  "domainDailyCap": null,
  "status": "active",
  "lastError": null,
  "backoffUntil": null,
  "createdAt": "2026-09-14T22:17:33.094Z"
}

POST /mailboxes/:id/pause

Pause an inbox

Stops it sending. What it has sent today keeps counting.

Request

curl -X POST https://craftnudge.com/api/mailboxes/078eb811-6d54-4824-84aa-c819a4974d84/pause \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "status": "paused"
}

POST /mailboxes/:id/resume

Resume an inbox

Undoes a pause. An inbox its bounce rate stopped has to pass the connection test instead.

Request

curl -X POST https://craftnudge.com/api/mailboxes/078eb811-6d54-4824-84aa-c819a4974d84/resume \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "status": "active"
}

PATCH /mailboxes/:id

Change an inbox

Any of the fields it was connected with, its caps and the gap between sends. Moving it to another server, port or username needs the password again.

Request

curl -X PATCH https://craftnudge.com/api/mailboxes/078eb811-6d54-4824-84aa-c819a4974d84 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "from_name": "Jordan Reyes",
  "daily_cap": 40,
  "min_gap_minutes": 6
}'

Response, 200

{
  "id": "078eb811-6d54-4824-84aa-c819a4974d84",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "email": "new@acme.example",
  "fromName": "Jordan Reyes",
  "smtpHost": "smtp.gmail.com",
  "smtpPort": 465,
  "smtpSecure": true,
  "imapHost": "imap.gmail.com",
  "imapPort": 993,
  "username": "new@acme.example",
  "dailyCap": 40,
  "rampStart": 10,
  "rampIncrement": 5,
  "rampStartedAt": "2026-09-14",
  "minGapMinutes": 6,
  "gapJitterMinutes": 5,
  "signature": null,
  "tags": [],
  "domainDailyCap": null,
  "status": "active",
  "lastError": null,
  "backoffUntil": null,
  "createdAt": "2026-09-14T22:17:33.094Z"
}

POST /mailboxes/bulk

Change several inboxes

The same change to every inbox in ids: daily_cap, domain_daily_cap or min_gap_minutes, or a tag with add_tag or remove_tag.

Request

curl -X POST https://craftnudge.com/api/mailboxes/bulk \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "ids": [
    "078eb811-6d54-4824-84aa-c819a4974d84",
    "86b394fc-4eef-41ac-bc0d-be1e4d4c7414"
  ],
  "add_tag": "q4"
}'

Response, 200

{
  "changed": 2
}

GET /mailboxes/scores

Inbox scores

Each inbox’s score out of 100, the points it lost for bounces, replies, placement and errors, and the counts behind them.

Request

curl https://craftnudge.com/api/mailboxes/scores \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "mailbox_id": "86b394fc-4eef-41ac-bc0d-be1e4d4c7414",
    "score": 50,
    "parts": {
      "bounces": 40,
      "replies": 0,
      "placement": 10,
      "errors": 0
    },
    "inputs": {
      "sent": 24,
      "failed": 0,
      "bounced": 2,
      "replied": 2,
      "placement": 0.6666666666666666
    },
    "enough_sends": true
  },
  {
    "mailbox_id": "078eb811-6d54-4824-84aa-c819a4974d84",
    "score": 100,
    "parts": {
      "bounces": 0,
      "replies": 0,
      "placement": 0,
      "errors": 0
    },
    "inputs": {
      "sent": 0,
      "failed": 0,
      "bounced": 0,
      "replied": 0,
      "placement": null
    },
    "enough_sends": false
  }
]

GET /mailboxes/domain-health

Check domain health

SPF, DKIM, DMARC, MX and the Spamhaus blocklists for every domain the workspace sends from. Each check is ok, warn, fail or unknown, with a fix for anything not ok. selector adds a DKIM selector to try. Only a few checks are allowed in a short time.

Request

curl https://craftnudge.com/api/mailboxes/domain-health \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "checked_at": "2026-09-14T22:17:33.137Z",
  "domains": [
    {
      "domain": "acme.example",
      "checks": [
        {
          "key": "spf",
          "label": "SPF",
          "status": "ok",
          "detail": "v=spf1 include:_spf.google.com ~all",
          "fix": null,
          "value": "v=spf1 include:_spf.google.com ~all"
        },
        {
          "key": "dkim",
          "label": "DKIM",
          "status": "ok",
          "detail": "Signing key found at selector \"google\".",
          "fix": null,
          "value": "google"
        }
      ]
    },
    {
      "domain": "getlumen.example",
      "checks": [
        {
          "key": "spf",
          "label": "SPF",
          "status": "ok",
          "detail": "v=spf1 include:_spf.google.com ~all",
          "fix": null,
          "value": "v=spf1 include:_spf.google.com ~all"
        },
        {
          "key": "dkim",
          "label": "DKIM",
          "status": "fail",
          "value": null,
          "detail": "No signing key at \"google\", \"selector1\", \"selector2\".",
          "fix": "Turn on DKIM where the inbox lives, then publish the record it gives you. Google Workspace: Admin console, Apps, Gmail, Authenticate email. Microsoft 365: Defender portal, Email authentication, DKIM. If your provider uses another selector, type it in and check again."
        }
      ]
    }
  ]
}

DELETE /mailboxes/:id

Delete an inbox

Takes the inbox out of the list, the sending pool and the plan’s count. What it sent and the replies it got stay, so follow-ups keep their thread and the record is kept. Connecting the same address again brings it back.

Request

curl -X DELETE https://craftnudge.com/api/mailboxes/078eb811-6d54-4824-84aa-c819a4974d84 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

Campaigns

A campaign is a draft until it is launched. Send only the fields you want to change.

POST /campaigns

Make a campaign

A draft in the workspace’s timezone with the default schedule: weekdays, 09:00 to 17:00, 30 a day.

Request

curl -X POST https://craftnudge.com/api/campaigns \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "October follow-up"
}'

Response, 200

{
  "id": "dc51bb22-00e5-44dc-9b9f-db781f5d094b",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "name": "October follow-up",
  "status": "draft",
  "timezone": "Europe/London",
  "days": [
    1,
    2,
    3,
    4,
    5
  ],
  "windowStart": "09:00",
  "windowEnd": "17:00",
  "windows": null,
  "leadTimezone": false,
  "dailyLimit": 30,
  "maxNewLeadsPerDay": null,
  "prioritizeNew": false,
  "skipRisky": true,
  "dealValue": null,
  "stopOnReply": true,
  "stopOnAutoReply": false,
  "openTracking": false,
  "clickTracking": false,
  "unsubHeader": true,
  "textOnly": false,
  "companyReplyStop": false,
  "providerMatching": false,
  "abWinnerMetric": null,
  "abOptimizeAfter": 0,
  "cc": null,
  "bcc": null,
  "tags": [],
  "startDate": null,
  "endDate": null,
  "createdAt": "2026-09-14T22:17:33.147Z"
}

GET /campaigns

List campaigns

Every campaign in the workspace, oldest first. leads_pending is how many of its leads are still Pending, with no first email yet.

Request

curl https://craftnudge.com/api/campaigns \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "36b04d47-3648-4f76-9ea6-e18b41cc9eaf",
    "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
    "name": "Q3 · Founder outreach",
    "status": "active",
    "timezone": "Europe/London",
    "days": [
      1,
      2,
      3,
      4,
      5
    ],
    "windowStart": "09:00",
    "windowEnd": "17:00",
    "windows": null,
    "leadTimezone": false,
    "dailyLimit": 40,
    "maxNewLeadsPerDay": null,
    "prioritizeNew": false,
    "skipRisky": true,
    "dealValue": 2400,
    "stopOnReply": true,
    "stopOnAutoReply": false,
    "openTracking": true,
    "clickTracking": false,
    "unsubHeader": true,
    "textOnly": false,
    "companyReplyStop": false,
    "providerMatching": false,
    "abWinnerMetric": "reply",
    "abOptimizeAfter": 40,
    "cc": null,
    "bcc": null,
    "tags": [],
    "startDate": null,
    "endDate": null,
    "createdAt": "2026-09-01T10:00:00.000Z"
  },
  {
    "id": "b6e6f2bc-177e-454c-ae89-2315e526e7ef",
    "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
    "name": "Agency partners, UK",
    "status": "active",
    "timezone": "Europe/London",
    "days": [
      1,
      2,
      3,
      4,
      5
    ],
    "windowStart": "09:00",
    "windowEnd": "17:00",
    "windows": null,
    "leadTimezone": false,
    "dailyLimit": 20,
    "maxNewLeadsPerDay": null,
    "prioritizeNew": false,
    "skipRisky": true,
    "dealValue": 6000,
    "stopOnReply": true,
    "stopOnAutoReply": false,
    "openTracking": false,
    "clickTracking": false,
    "unsubHeader": true,
    "textOnly": false,
    "companyReplyStop": false,
    "providerMatching": false,
    "abWinnerMetric": null,
    "abOptimizeAfter": 0,
    "cc": null,
    "bcc": null,
    "tags": [],
    "startDate": null,
    "endDate": null,
    "createdAt": "2026-09-05T10:00:00.000Z"
  }
]

PATCH /campaigns/:id

Change a campaign

Any of its settings, and mailbox_ids for the inboxes it sends from. windows takes up to four sending windows, each with days (0 is Sunday) and a start and end as HH:MM in the campaign’s timezone, and replaces days, window_start and window_end until it is set back to null. A window that runs past midnight, or overlaps another on the same day, is refused with 400, and so is changing days or hours while windows are set. lead_timezone reads the days and hours in each lead’s own timezone where the lead has one. A timezone that isn’t a real zone name is refused with 400.

Request

curl -X PATCH https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "timezone": "Europe/London",
  "days": [
    1,
    2,
    3,
    4,
    5
  ],
  "window_start": "09:00",
  "window_end": "17:00",
  "daily_limit": 40,
  "mailbox_ids": [
    "86b394fc-4eef-41ac-bc0d-be1e4d4c7414"
  ]
}'

Response, 200

{
  "id": "dc51bb22-00e5-44dc-9b9f-db781f5d094b",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "name": "October follow-up",
  "status": "draft",
  "timezone": "Europe/London",
  "days": [
    1,
    2,
    3,
    4,
    5
  ],
  "windowStart": "09:00",
  "windowEnd": "17:00",
  "windows": null,
  "leadTimezone": false,
  "dailyLimit": 40,
  "maxNewLeadsPerDay": null,
  "prioritizeNew": false,
  "skipRisky": true,
  "dealValue": null,
  "stopOnReply": true,
  "stopOnAutoReply": false,
  "openTracking": false,
  "clickTracking": false,
  "unsubHeader": true,
  "textOnly": false,
  "companyReplyStop": false,
  "providerMatching": false,
  "abWinnerMetric": null,
  "abOptimizeAfter": 0,
  "cc": null,
  "bcc": null,
  "tags": [],
  "startDate": null,
  "endDate": null,
  "createdAt": "2026-09-14T22:17:33.147Z"
}

PUT /campaigns/:id/steps

Set the steps

Replaces every step. Positions run from 1 with no gaps, and wait_minutes counts from the step before. A follow-up with an empty subject replies in the same thread. condition says who a step is for: no_reply (the default), interested or not_interested after a classified reply, or clicked for a lead still in the sequence who clicked a tracked link in an earlier email. A click in the first 5 minutes after its email went out, two links of one email hit within seconds, or a HEAD request is taken for a mail scanner and doesn’t count. Step 1 can’t wait on a reply or a click, and a task step can’t wait on a click. A follow-up email with no condition or no_reply needs wait_minutes of 1440 or more, a day, so it never lands minutes after the email before it. Once the campaign has sent (or a task-first one has closed a task), its steps stay in place: send each existing step back with its id, change the words, waits or condition, and add steps at the end. Fewer steps, a step moved, or an email turned into a task (or back) at a position already in place is refused with 409.

Request

curl -X PUT https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/steps \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
  {
    "position": 1,
    "wait_minutes": 0,
    "variants": [
      {
        "subject": "Quick question about {{company}}",
        "body": "Hi {{first_name|there}},\n\nHow are you handling returns at {{company}}?\n\nJordan\n\nNot relevant? {{unsubscribe}}"
      }
    ]
  },
  {
    "position": 2,
    "wait_minutes": 4320,
    "variants": [
      {
        "subject": "",
        "body": "Bumping this in case it got buried, {{first_name|there}}."
      }
    ]
  }
]'

Response, 200

{
  "steps": 2
}

PATCH /campaigns/:id/steps/:position/variants/:variant

Turn a variant on or off

Send enabled (true or false). An off variant gets no new sends, and a lead later in the sequence moves to a variant that is on. A step can’t have every variant off, so turning off the last one on is refused with 400. The same enabled flag can be set per variant when setting the steps.

Request

curl -X PATCH https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/steps/1/variants/1 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": false
}'

Response, 200

{
  "step": 1,
  "variant": 1,
  "enabled": false
}

POST /sequence-templates

Save a sequence as a template

Saves the steps of campaign_id as they are now, under name, for the whole workspace. A campaign with no steps is refused with 400, and a name already used with 409.

Request

curl -X POST https://craftnudge.com/api/sequence-templates \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Two-step returns intro",
  "campaign_id": "dc51bb22-00e5-44dc-9b9f-db781f5d094b"
}'

Response, 200

{
  "id": "b3a6d23a-872f-466d-a7fa-bbffff3cd0f4",
  "name": "Two-step returns intro",
  "steps": 2
}

GET /sequence-templates

List templates

Every template in the workspace by name, with how many steps each has.

Request

curl https://craftnudge.com/api/sequence-templates \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "b3a6d23a-872f-466d-a7fa-bbffff3cd0f4",
    "name": "Two-step returns intro",
    "steps": 2,
    "created_at": "2026-09-14T22:17:34.889Z"
  }
]

POST /campaigns/:id/steps/from-template

Use a template

Replaces every step of the campaign with the template’s, checked by the same rules as setting the steps. Refused with 409 once the campaign has sent anything, or closed a task on a task-first sequence.

Request

curl -X POST https://craftnudge.com/api/campaigns/7649aabc-f62a-4a78-b4a5-3f61f419bb18/steps/from-template \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "template_id": "b3a6d23a-872f-466d-a7fa-bbffff3cd0f4"
}'

Response, 200

{
  "steps": 2
}

DELETE /sequence-templates/:id

Delete a template

Removes it from the workspace. Campaigns that used it keep their steps.

Request

curl -X DELETE https://craftnudge.com/api/sequence-templates/b3a6d23a-872f-466d-a7fa-bbffff3cd0f4 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

GET /campaigns/:id

Read a campaign

The campaign with its steps and the ids of the inboxes it sends from.

Request

curl https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "id": "dc51bb22-00e5-44dc-9b9f-db781f5d094b",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "name": "October follow-up",
  "status": "draft",
  "timezone": "Europe/London",
  "days": [
    1,
    2,
    3,
    4,
    5
  ],
  "windowStart": "09:00",
  "windowEnd": "17:00",
  "windows": null,
  "leadTimezone": false,
  "dailyLimit": 40,
  "maxNewLeadsPerDay": null,
  "prioritizeNew": false,
  "skipRisky": true,
  "dealValue": null,
  "stopOnReply": true,
  "stopOnAutoReply": false,
  "openTracking": false,
  "clickTracking": false,
  "unsubHeader": true,
  "textOnly": false,
  "companyReplyStop": false,
  "providerMatching": false,
  "abWinnerMetric": null,
  "abOptimizeAfter": 0,
  "cc": null,
  "bcc": null,
  "tags": [],
  "startDate": null,
  "endDate": null,
  "createdAt": "2026-09-14T22:17:33.147Z",
  "steps": [
    {
      "id": "8e197d38-2e20-4a49-b893-ecd2a132550f",
      "campaignId": "dc51bb22-00e5-44dc-9b9f-db781f5d094b",
      "position": 1,
      "waitMinutes": 0,
      "variants": [
        {
          "body": "Hi {{first_name|there}},\n\nHow are you handling returns at {{company}}?\n\nJordan\n\nNot relevant? {{unsubscribe}}",
          "subject": "Quick question about {{company}}"
        }
      ],
      "task": null,
      "condition": null
    },
    {
      "id": "244d128e-766e-4c41-b24e-810eb65e5d1e",
      "campaignId": "dc51bb22-00e5-44dc-9b9f-db781f5d094b",
      "position": 2,
      "waitMinutes": 4320,
      "variants": [
        {
          "body": "Bumping this in case it got buried, {{first_name|there}}.",
          "subject": ""
        }
      ],
      "task": null,
      "condition": null
    }
  ],
  "mailbox_ids": [
    "86b394fc-4eef-41ac-bc0d-be1e4d4c7414"
  ]
}

POST /campaigns/:id/leads/manual

Add leads as JSON

Each lead is an email and any fields. Every lead goes through the import rules, and the answer counts where each one went.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/leads/manual \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "leads": [
    {
      "email": "ana@brightside.example",
      "vars": {
        "first_name": "Ana",
        "company": "Brightside Home"
      }
    },
    {
      "email": "leo@tallgrass.example",
      "vars": {
        "first_name": "Leo",
        "company": "Tallgrass Tea"
      }
    }
  ]
}'

Response, 200

{
  "inserted": 2,
  "duplicates": 0,
  "blocklisted": 0,
  "invalid": 0,
  "in_another_campaign": 0,
  "contacted_recently": 0
}

POST /campaigns/:id/leads

Add leads from a CSV

The same rules, from a CSV with an email column. Every other column becomes a field. A timezone column, or else a country column, sets the lead’s timezone, the same for every way leads are added.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/leads \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @leads.csv

leads.csv

email,first_name,company
ivy@northfield.example,Ivy,Northfield Linen
sol@redwing.example,Sol,Redwing Bikes
ana@brightside.example,Ana,Brightside Home

Response, 200

{
  "inserted": 2,
  "duplicates": 1,
  "blocklisted": 0,
  "invalid": 0,
  "in_another_campaign": 0,
  "contacted_recently": 0
}

POST /campaigns/:id/leads/sheet

Add leads from a Google Sheet

url is the link from File, Share, Publish to web in Google Sheets, with CSV chosen. The sheet is read once, only from Google, and every row goes through the same rules as a CSV.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/leads/sheet \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://docs.google.com/spreadsheets/d/e/2PACX-1vDocsExampleSheet/pub?output=csv"
}'

Response, 200

{
  "inserted": 2,
  "duplicates": 0,
  "blocklisted": 0,
  "invalid": 0,
  "in_another_campaign": 0,
  "contacted_recently": 0
}

GET /campaigns/:id/leads

List a campaign’s leads

The campaign’s leads with their status, step and fields, and provider: who receives the address’s mail, read from its domain’s MX, as google, microsoft, other, none or unknown, and null until it has been looked up. Filter with status, interest, verification (or unchecked), provider (or unchecked), list_id for leads on one of your lead lists, and q to search the email. An empty value is no filter. The X-Total-Count header is how many leads match the filters, whatever limit and offset say.

Request

curl https://craftnudge.com/api/campaigns/5fdb0cdd-bc45-4f35-94bc-8bdc89f6f506/leads \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "b2fbb892-25c2-4b76-9b66-b11668bb2b1d",
    "workspaceId": "b03dfd0e-6592-4278-98f3-530d2140a90a",
    "campaignId": "5fdb0cdd-bc45-4f35-94bc-8bdc89f6f506",
    "email": "ana@brightside.example",
    "vars": {
      "company": "Brightside Home",
      "first_name": "Ana"
    },
    "timezone": null,
    "status": "pending",
    "stopReason": null,
    "interest": null,
    "wonAt": null,
    "provider": "none",
    "verification": null,
    "verifiedAt": null,
    "currentStep": 0,
    "nextDueAt": null,
    "awayUntil": null,
    "createdAt": "2026-09-23T12:49:52.868Z",
    "sendZone": "Asia/Kolkata"
  },
  {
    "id": "e390a30c-e4db-42a6-9716-d33da6fba724",
    "workspaceId": "b03dfd0e-6592-4278-98f3-530d2140a90a",
    "campaignId": "5fdb0cdd-bc45-4f35-94bc-8bdc89f6f506",
    "email": "bo@northpeak.example",
    "vars": {
      "company": "Northpeak Outdoor",
      "first_name": "Bo"
    },
    "timezone": null,
    "status": "pending",
    "stopReason": null,
    "interest": null,
    "wonAt": null,
    "provider": "none",
    "verification": null,
    "verifiedAt": null,
    "currentStep": 0,
    "nextDueAt": null,
    "awayUntil": null,
    "createdAt": "2026-09-23T12:49:52.868Z",
    "sendZone": "Asia/Kolkata"
  }
]

GET /campaigns/:id/lead-fields

List lead fields

Every field any of the campaign’s leads carries, in order, up to 200. Each one works as a merge field, such as {{company}}.

Request

curl https://craftnudge.com/api/campaigns/00733de5-8b84-4d6c-8dd5-7b2b1c8977cd/lead-fields \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "fields": [
    "city",
    "company",
    "first_name",
    "linkedin_headline"
  ]
}

POST /lead-filters

Save a leads filter

Saves filter under name for the whole workspace, with the same fields as listing a campaign’s leads. A field or value the leads list does not take, or an empty filter, is refused with 400, and a name already used with 409.

Request

curl -X POST https://craftnudge.com/api/lead-filters \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Risky, still to send",
  "filter": {
    "verification": "risky",
    "status": "pending"
  }
}'

Response, 200

{
  "id": "907e0b8c-3cf5-45e8-9798-1395b437e979",
  "name": "Risky, still to send",
  "filter": {
    "status": "pending",
    "verification": "risky"
  }
}

GET /lead-filters

List saved filters

Every saved filter in the workspace by name. Pass a filter’s fields to the leads list to apply it.

Request

curl https://craftnudge.com/api/lead-filters \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "907e0b8c-3cf5-45e8-9798-1395b437e979",
    "name": "Risky, still to send",
    "filter": {
      "status": "pending",
      "verification": "risky"
    },
    "created_at": "2026-09-14T22:17:34.934Z"
  }
]

DELETE /lead-filters/:id

Delete a saved filter

Removes it from the workspace.

Request

curl -X DELETE https://craftnudge.com/api/lead-filters/907e0b8c-3cf5-45e8-9798-1395b437e979 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

GET /campaigns/:id/leads.csv

Export leads as CSV

Every lead in the campaign with its status, interest, step, stop reason, dates and fields, as a CSV file.

Request

curl https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/leads.csv \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

email,status,interest,current_step,stop_reason,created_at,last_sent_at,replied_at,next_due_at,company,first_name
ana@brightside.example,pending,,0,,2026-09-14T22:17:33.179Z,,,,Brightside Home,Ana
leo@tallgrass.example,pending,,0,,2026-09-14T22:17:33.179Z,,,,Tallgrass Tea,Leo

POST /campaigns/:id/preview

Preview steps

The steps you send, rendered as a lead would get them: fields filled from lead_id, or from one of the campaign’s leads, with vars laid over them, and the sender from one of its inboxes. Nothing is saved or sent.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/preview \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "steps": [
    {
      "subject": "Quick question about {{company}}",
      "body": "Hi {{first_name|there}},\n\nHow are you handling returns at {{company}}?\n\nJordan\n\nNot relevant? {{unsubscribe}}"
    },
    {
      "subject": "",
      "body": "Bumping this in case it got buried, {{first_name|there}}."
    }
  ]
}'

Response, 200

{
  "lead": {
    "id": "56b5895f-e32e-4b7f-b5fa-ed26e0778d06",
    "email": "ana@brightside.example"
  },
  "from": "Sam from Lumen <hey@getlumen.example>",
  "steps": [
    {
      "subject": "Quick question about Brightside Home",
      "text": "Hi Ana,\n\nHow are you handling returns at Brightside Home?\n\nJordan\n\nNot relevant? https://app.craftnudge.example/u/<token>"
    },
    {
      "subject": "Re: Quick question about Brightside Home",
      "text": "Bumping this in case it got buried, Ana."
    }
  ]
}

GET /campaigns/:id/merge-check

Check merge fields

Each merge field in the saved steps that would come out blank, and for how many leads or inboxes. An empty list means none would.

Request

curl https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/merge-check \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[]

GET /campaigns/:id/copy-check

Check the copy

Warnings about the saved steps: spam words, more than two links, capitals, exclamation marks, a long subject or body, images, and no way to opt out. They never stop a campaign.

Request

curl https://craftnudge.com/api/campaigns/7de94972-f78e-4e08-9b90-733605d0541e/copy-check \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "position": 1,
    "variant": 0,
    "code": "spam_words",
    "message": "Words that spam filters notice: click here, free, no obligation, 100%.",
    "terms": [
      "click here",
      "free",
      "no obligation",
      "100%"
    ]
  },
  {
    "position": 1,
    "variant": 0,
    "code": "links",
    "message": "3 links. Cold email with more than 2 is more likely to land in spam.",
    "terms": [
      "https://acme.example/case-studies",
      "https://acme.example/book",
      "https://acme.example/pricing"
    ]
  }
]

GET /campaigns/:id/diagnose

Diagnose a campaign

One list of what to fix for this campaign: the Deliverability page’s problems for its own inboxes and their domains, then the copy warnings on its steps. Each problem has a severity, a code, what is wrong and how to fix it. It only reads.

Request

curl https://craftnudge.com/api/campaigns/7de94972-f78e-4e08-9b90-733605d0541e/diagnose \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "problems": [
    {
      "severity": "medium",
      "code": "domain_dmarc",
      "text": "acme.example has no DMARC record.",
      "fix": "The Inboxes page shows what to change."
    },
    {
      "severity": "medium",
      "code": "inbox_resting",
      "text": "sam@acme.example is resting after its server turned mail away.",
      "fix": "It tries again by itself. If this keeps happening, lower its daily cap."
    },
    {
      "severity": "low",
      "code": "copy_links",
      "text": "Step 1A: 3 links. Cold email with more than 2 is more likely to land in spam.",
      "fix": ""
    }
  ]
}

POST /campaigns/:id/verify

Check mail servers

Looks up whether the domain of each lead still due mail has a mail server, and marks the leads on domains without one as invalid, so nothing is sent to them. Free.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/verify \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "domains_checked": 7,
  "bad_domains": 7,
  "leads_checked": 7,
  "marked_invalid": 7
}

POST /campaigns/:id/verify-addresses

Check addresses

Sends each lead still due mail that has not been checked to the workspace’s verifier, and holds it from sending while it waits, for a day at most. marked is how many will be checked. Refused with 400 until an owner saves a verification key in Settings.

Request

curl -X POST https://craftnudge.com/api/campaigns/b6e6f2bc-177e-454c-ae89-2315e526e7ef/verify-addresses \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "marked": 9
}

GET /campaigns/:id/verification

Address check results

How many of the campaign’s leads came back valid, invalid, risky or unknown, how many are waiting, how many waited too long and went unchecked, how many were never checked, and how many were skipped as invalid, as risky and as catch-all.

Request

curl https://craftnudge.com/api/campaigns/b6e6f2bc-177e-454c-ae89-2315e526e7ef/verification \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "pending": 0,
  "valid": 6,
  "invalid": 1,
  "risky": 2,
  "unknown": 0,
  "expired": 0,
  "unchecked": 5,
  "skipped_invalid": 1,
  "skipped_risky": 2,
  "skipped_catch_all": 1
}

POST /campaigns/:id/activate

Launch a campaign

Needs at least one step, an inbox, and the one-click unsubscribe header on. A column the emails use must have a value for every lead they would reach, or the launch is refused with blank_columns saying which and for how many. Sending starts inside the next window.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/activate \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "status": "active",
  "warnings": []
}

POST /campaigns/:id/pause

Pause a campaign

Stops new sends. Launching again carries on where it stopped.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/pause \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "status": "paused"
}

GET /campaigns/:id/stats

Campaign numbers

Sequence started (leads that got step 1), sent, opened, clicked, replied and bounced, whether open and click tracking are on, positive replies (marked interested, meeting booked, meeting completed or won), opportunities (positive replies) and conversions (won leads) with their value (the lead’s deal value, else the campaign’s), the leads by status, each step and variant, and each day. Each day also has pending, the emails still due that day (an estimate), and the next sending days come as rows with future set. Replies to a one-off email are not counted.

Request

curl https://craftnudge.com/api/campaigns/36b04d47-3648-4f76-9ea6-e18b41cc9eaf/stats \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "variants": [
    {
      "step": 1,
      "variant": 0,
      "sent": 12,
      "replies": 3,
      "positive": 2,
      "reply_rate": 25
    },
    {
      "step": 1,
      "variant": 1,
      "sent": 12,
      "replies": 2,
      "positive": 1,
      "reply_rate": 16.7
    }
  ],
  "by_day": [
    {
      "day": "2026-09-02",
      "sent": 3,
      "replies": 0,
      "bounces": 0
    },
    {
      "day": "2026-09-03",
      "sent": 3,
      "replies": 3,
      "bounces": 0,
      "pending": 2
    },
    {
      "day": "2026-09-04",
      "sent": 0,
      "replies": 0,
      "bounces": 0,
      "pending": 5,
      "future": true
    }
  ],
  "leads": {
    "total": 36,
    "completed": 6,
    "in_sequence": 8,
    "pending": 8,
    "replied": 13,
    "unsubscribed": 1
  },
  "sent": 43,
  "one_off_sent": 0,
  "opened": 19,
  "clicked": 3,
  "replied": 6,
  "positive": 3,
  "bounced": 3,
  "auto_replies": 2,
  "unsubscribed": 1
}

GET /campaigns/:id/activity

A campaign’s activity

The campaign’s events, newest first: sends, replies, auto-replies and bounces, each with the lead, the step, the inbox and a line of what it said. Replies to a one-off email are left out. limit (up to 100) and offset page through it, and more says whether there is another page.

Request

curl https://craftnudge.com/api/campaigns/36b04d47-3648-4f76-9ea6-e18b41cc9eaf/activity \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "rows": [
    {
      "type": "reply",
      "at": "2026-09-03T05:00:00.000Z",
      "email": "maya@northwind.example",
      "step": 1,
      "variant": null,
      "mailbox": "outreach@acme.example",
      "detail": "Re: Quick question about Northwind Interiors: Yes, this is a real problem for us. Thursday 3pm works if you can do it."
    },
    {
      "type": "sent",
      "at": "2026-09-02T09:00:00.000Z",
      "email": "maya@northwind.example",
      "step": 1,
      "variant": 0,
      "mailbox": "outreach@acme.example",
      "detail": "Quick question about Northwind Interiors"
    }
  ],
  "more": false
}

GET /campaigns/:id/bounces

A campaign’s bounces

Every bounce on the campaign, newest first: the lead, the inbox it went from, the bounce code and category, and the reason the receiving server gave. limit (up to 100) and offset page through it, and more says whether there is another page.

Request

curl https://craftnudge.com/api/campaigns/36b04d47-3648-4f76-9ea6-e18b41cc9eaf/bounces \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "rows": [
    {
      "at": "2026-09-02T09:04:12.000Z",
      "email": "ops@harbourlane.example",
      "mailbox": "outreach@acme.example",
      "code": "550",
      "category": "bad_address",
      "reason": "550 5.1.1 The email account that you tried to reach does not exist."
    }
  ],
  "more": false
}

GET /campaigns/:id/sending-status

Why it is or isn’t sending

Whether the campaign would send right now, from the same checks the sender makes. reasons is what stops every send, in the order the sender meets it, and is empty while it sends. notes is what holds back some leads or inboxes while the rest go. Each has a code and a sentence. Also today’s sends against the daily limit and the new leads limit, how many leads are due a step now, and when the next one is due. due_today is about how many go today, as total, follow_ups and new_leads: the smallest of the daily limit left, the leads due before midnight in the campaign’s timezone and what its inboxes have left today. Inboxes are shared with other campaigns, so it is an estimate.

Request

curl https://craftnudge.com/api/campaigns/79977709-a3f4-407c-b0e8-8fe980b4e6bb/sending-status \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "sending": false,
  "reasons": [
    {
      "code": "outside_hours",
      "text": "It is outside the sending hours. It is Wednesday 18:20 in Europe/London, and it sends Mon, Tue, Wed, Thu, Fri 09:00 to 17:00."
    }
  ],
  "notes": [
    {
      "code": "inbox_stopped",
      "text": "sam@acme.example was paused for a high bounce rate. Test it on the Inboxes page to send again."
    }
  ],
  "sent_today": 0,
  "daily_limit": 40,
  "new_today": 0,
  "new_leads_limit": null,
  "due_now": 3,
  "next_due_at": null,
  "due_today": {
    "total": 0,
    "follow_ups": 0,
    "new_leads": 0
  }
}

POST /campaigns/:id/duplicate

Duplicate a campaign

A new draft named with (copy), with the same settings, steps and inboxes, and no leads.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/duplicate \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "id": "f4383386-616a-4197-b82a-14877a116086",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "name": "October follow-up (copy)",
  "status": "draft",
  "timezone": "Europe/London",
  "days": [
    1,
    2,
    3,
    4,
    5
  ],
  "windowStart": "09:00",
  "windowEnd": "17:00",
  "windows": null,
  "leadTimezone": false,
  "dailyLimit": 40,
  "maxNewLeadsPerDay": null,
  "prioritizeNew": false,
  "skipRisky": true,
  "dealValue": null,
  "stopOnReply": true,
  "stopOnAutoReply": false,
  "openTracking": false,
  "clickTracking": false,
  "unsubHeader": true,
  "textOnly": false,
  "companyReplyStop": false,
  "providerMatching": false,
  "abWinnerMetric": null,
  "abOptimizeAfter": 0,
  "cc": null,
  "bcc": null,
  "tags": [],
  "startDate": null,
  "endDate": null,
  "createdAt": "2026-09-14T22:17:33.249Z"
}

DELETE /campaigns/:id

Delete a campaign

Deletes the campaign with its steps and its leads. A campaign that has sent an email cannot be deleted (400), so that who was mailed and their unsubscribe links are kept. Pause it instead.

Request

curl -X DELETE https://craftnudge.com/api/campaigns/f4383386-616a-4197-b82a-14877a116086 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

Lead lists

A list keeps people outside any campaign, so the same set can start more than one.

POST /lead-lists

Make a list

An empty list with a name.

Request

curl -X POST https://craftnudge.com/api/lead-lists \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "November shops"
}'

Response, 200

{
  "id": "a1bc2d84-a52a-40d1-b7ca-e4a9703f0cfd",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "name": "November shops",
  "createdAt": "2026-09-14T22:17:33.258Z"
}

POST /lead-lists/:id/leads

Fill a list from a CSV

A list only checks that each row is an address and new to the list. The campaign rules run when a list starts a campaign.

Request

curl -X POST https://craftnudge.com/api/lead-lists/a1bc2d84-a52a-40d1-b7ca-e4a9703f0cfd/leads \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @leads.csv

leads.csv

email,first_name,company
wren@larchmont.example,Wren,Larchmont Candles
juno@foxhall.example,Juno,Foxhall Prints
not-an-address,,

Response, 200

{
  "added": 2,
  "duplicates": 0,
  "invalid": 1
}

GET /lead-lists

List the lists

Every list with how many people it holds.

Request

curl https://craftnudge.com/api/lead-lists \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "23d191f7-8727-46f1-b905-6a6a2e1ffe9e",
    "name": "UK Woo shops, September",
    "created_at": "2026-09-10T10:00:00.000Z",
    "leads": 39
  },
  {
    "id": "ac12d76a-bb14-4995-ae03-0de273616901",
    "name": "Agency owners",
    "created_at": "2026-09-12T10:00:00.000Z",
    "leads": 17
  }
]

POST /campaigns/:id/leads/from-list

Start a campaign from a list

Adds the list’s people to the campaign through the import rules.

Request

curl -X POST https://craftnudge.com/api/campaigns/dc51bb22-00e5-44dc-9b9f-db781f5d094b/leads/from-list \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "list_id": "a1bc2d84-a52a-40d1-b7ca-e4a9703f0cfd"
}'

Response, 200

{
  "inserted": 2,
  "duplicates": 0,
  "blocklisted": 0,
  "invalid": 0,
  "in_another_campaign": 0,
  "contacted_recently": 0
}

DELETE /lead-lists/:id

Delete a list

Deletes the list and who is on it. Leads it already added to a campaign stay in that campaign.

Request

curl -X DELETE https://craftnudge.com/api/lead-lists/a1bc2d84-a52a-40d1-b7ca-e4a9703f0cfd \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

Leads

One person in one campaign.

GET /leads

Find leads

Leads across the whole workspace, newest first. q matches part of the address or of the company field. Filter with campaign_id for one campaign, and with status, interest, verification, provider and list_id as on a campaign’s leads. An empty value is no filter. The X-Total-Count header is how many leads match the filters, whatever limit and offset say.

Request

curl "https://craftnudge.com/api/leads?q=maya&status=replied" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "57931739-2a3c-4654-b87e-b43edf3077f5",
    "email": "maya@northwind.example",
    "vars": {
      "domain": "northwind.example",
      "company": "Northwind Interiors",
      "first_name": "Maya"
    },
    "status": "replied",
    "stopReason": "replied",
    "interest": "meeting_booked",
    "currentStep": 1,
    "nextDueAt": null,
    "createdAt": "2026-09-02T10:00:00.000Z",
    "campaign_id": "36b04d47-3648-4f76-9ea6-e18b41cc9eaf",
    "campaign": "Q3 · Founder outreach"
  }
]

GET /leads/:id

Read a lead

One lead with its status, step and fields.

Request

curl https://craftnudge.com/api/leads/57931739-2a3c-4654-b87e-b43edf3077f5 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "id": "57931739-2a3c-4654-b87e-b43edf3077f5",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "campaignId": "36b04d47-3648-4f76-9ea6-e18b41cc9eaf",
  "email": "maya@northwind.example",
  "vars": {
    "domain": "northwind.example",
    "company": "Northwind Interiors",
    "first_name": "Maya"
  },
  "timezone": null,
  "status": "replied",
  "stopReason": "replied",
  "interest": "meeting_booked",
  "verification": null,
  "verifiedAt": null,
  "currentStep": 1,
  "nextDueAt": null,
  "awayUntil": null,
  "createdAt": "2026-09-02T10:00:00.000Z"
}

PATCH /leads/:id

Change a lead

Set interest to a label, status to paused or active, or timezone to a zone name such as Europe/London, or null. Only a paused lead can be made active again. deal_value is what this deal is worth, a whole number, and null goes back to its campaign’s. note is up to 2,000 characters for your team. It is never sent, and blank or null clears it. vars sets fields by name, such as { "line": "Saw your new Leeds shop" }: each value is cleaned the way a written column is, one line with no braces, pipes or angle brackets, up to 500 characters, and blank or null removes the field. A new field name is letters, numbers and underscores.

Request

curl -X PATCH https://craftnudge.com/api/leads/dc34e3a1-2b94-4514-85c4-c33444280c31 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "deal_value": 7500,
  "note": "Board meets on the 30th. Send the proposal by the 28th."
}'

Response, 200

{
  "id": "dc34e3a1-2b94-4514-85c4-c33444280c31",
  "workspaceId": "6c9e773a-0972-470d-928b-ede1f0ff220c",
  "campaignId": "29b8efac-9169-419d-b2a1-02c6b80fac09",
  "email": "ana@copperkettle.example",
  "vars": {},
  "timezone": null,
  "status": "replied",
  "stopReason": "replied",
  "interest": "meeting_booked",
  "wonAt": null,
  "dealValue": 7500,
  "note": "Board meets on the 30th. Send the proposal by the 28th.",
  "provider": null,
  "verification": null,
  "verifiedAt": null,
  "currentStep": 1,
  "nextDueAt": null,
  "awayUntil": null,
  "createdAt": "2026-08-29T16:45:23.495Z"
}

GET /leads/:id/activity

A lead’s history

Everything that went to the lead and everything that came back, in order.

Request

curl https://craftnudge.com/api/leads/57931739-2a3c-4654-b87e-b43edf3077f5/activity \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "lead": {
    "id": "57931739-2a3c-4654-b87e-b43edf3077f5",
    "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
    "campaignId": "36b04d47-3648-4f76-9ea6-e18b41cc9eaf",
    "email": "maya@northwind.example",
    "vars": {
      "domain": "northwind.example",
      "company": "Northwind Interiors",
      "first_name": "Maya"
    },
    "timezone": null,
    "status": "replied",
    "stopReason": "replied",
    "interest": "meeting_booked",
    "verification": null,
    "verifiedAt": null,
    "currentStep": 1,
    "nextDueAt": null,
    "awayUntil": null,
    "createdAt": "2026-09-02T10:00:00.000Z"
  },
  "timeline": [
    {
      "type": "sent",
      "at": "2026-09-02T09:00:00.000Z",
      "step": 1,
      "step_label": "Step 1",
      "detail": "\"Quick question about Northwind Interiors\" via outreach@acme.example",
      "variant": 0
    },
    {
      "type": "reply",
      "at": "2026-09-03T05:00:00.000Z",
      "step": null,
      "step_label": null,
      "detail": "Re: Quick question about Northwind Interiors: Yes, this is a real problem for us. Thursday 3pm works if you can do it.",
      "variant": null
    }
  ]
}

POST /leads/one-off

A lead for one address

The lead to send a one-off email to, for an address typed by hand. An address already a lead in the workspace comes back as that lead, with created false. A new one is added to a draft campaign named One-off with no steps, so the blocklist and dedupe apply to it. An address that is blocklisted, or that unsubscribed or bounced as any lead, is refused with 400.

Request

curl -X POST https://craftnudge.com/api/leads/one-off \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "hello@larchwood.example"
}'

Response, 200

{
  "lead_id": "fef6f224-818a-4fab-aa49-b7ff439c83cd",
  "created": true
}

POST /leads/:id/send

Send one email to a lead

Queues one email from mailboxId: a new email with subject, or with replyTo, the id of the lead’s reply, an answer in that thread. Nothing goes out from the call. It goes when the lead’s campaign window is open and the inbox has room under its caps, and scheduled_for is the earliest that can be. The same message queued twice is one email. If the lead replies, unsubscribes or bounces before it goes, it is dropped. While the lead is paused it waits, and goes once the lead is resumed.

Request

curl -X POST https://craftnudge.com/api/leads/40a551f9-2759-4b99-8d48-2a9fab2372be/send \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "mailboxId": "86b394fc-4eef-41ac-bc0d-be1e4d4c7414",
  "subject": "The returns numbers",
  "body": "Hi,\n\nYou asked about returns last month. Here are the numbers from shops your size.\n\nJordan"
}'

Response, 200

{
  "id": "11bd7bc8-498e-464d-a481-73231b6f8ae6",
  "status": "queued",
  "scheduled_for": "2026-09-15T08:00:00.000Z"
}

POST /one-offs/:id/cancel

Cancel a one-off

Takes back a one-off still waiting to go, by the id Send one email to a lead gave. It is dropped with the error Cancelled. One that has already gone, or is going out, is refused with 409.

Request

curl -X POST https://craftnudge.com/api/one-offs/11bd7bc8-498e-464d-a481-73231b6f8ae6/cancel \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "id": "11bd7bc8-498e-464d-a481-73231b6f8ae6",
  "status": "dropped"
}

DELETE /leads/:id

Delete a lead

Only a lead that has never been emailed. One that has is refused, so it cannot be added again and mailed twice. Pause it instead.

Request

curl -X DELETE https://craftnudge.com/api/leads/bc6194f9-9f6a-4de0-857b-fd87d5fbce3f \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

Columns

Values worked out for every lead of a campaign, each kept as the lead variable {{name}} for the steps to use. A website column reads the lead’s homepage, from its website field or else its address’s domain, and keeps up to 8,000 characters of its text. A prompt column asks assisted for a short line from a prompt you write with the lead’s {{variables}}, on the workspace’s own key and under its monthly cap. An answer with a link or an email address in it, or one that looks steered by what the lead’s details say, is flagged for review and never used. A column never sends anything, and a campaign cannot launch while a column its emails use is blank for a lead they would reach.

GET /campaigns/:id/blocks

List columns

Each column with its prompt, whether a run is going, how many leads the run took on and how many are left, why the last run stopped early if it did, and how many leads are done, failed, flagged or edited by hand.

Request

curl https://craftnudge.com/api/campaigns/40255eb8-bd7f-4cd4-a020-4d0ee13e5532/blocks \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "e3923816-8519-4c45-96db-84f85005d835",
    "name": "website_text",
    "kind": "website",
    "template": null,
    "max_chars": 300,
    "running": false,
    "run_total": 2,
    "run_left": 0,
    "run_note": null,
    "done": 2,
    "failed": 0,
    "flagged": 0,
    "edited": 0,
    "created_at": "2026-09-25T19:49:22.435Z"
  },
  {
    "id": "4f9656a7-692c-405b-b4b1-e584ad3be87d",
    "name": "opener",
    "kind": "prompt",
    "template": "One friendly sentence to open a cold email to {{first_name|them}} at {{company}}, about something specific from their website: {{website_text}}",
    "max_chars": 160,
    "running": true,
    "run_total": 2,
    "run_left": 2,
    "run_note": null,
    "done": 0,
    "failed": 0,
    "flagged": 0,
    "edited": 0,
    "created_at": "2026-09-25T19:49:22.477Z"
  }
]

POST /campaigns/:id/blocks

Add a column

name is the variable it fills: lower-case letters, numbers and underscores. kind is website or prompt. A prompt column needs template, with at least one {{variable}}, and takes max_chars, the longest answer kept, 300 by default. A name the leads already have as a field is refused.

Request

curl -X POST https://craftnudge.com/api/campaigns/40255eb8-bd7f-4cd4-a020-4d0ee13e5532/blocks \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "opener",
  "kind": "prompt",
  "template": "One friendly sentence to open a cold email to {{first_name|them}} at {{company}}, about something specific from their website: {{website_text}}",
  "max_chars": 200
}'

Response, 200

{
  "id": "4f9656a7-692c-405b-b4b1-e584ad3be87d",
  "name": "opener",
  "kind": "prompt",
  "template": "One friendly sentence to open a cold email to {{first_name|them}} at {{company}}, about something specific from their website: {{website_text}}",
  "max_chars": 200,
  "running": false,
  "run_total": 0,
  "run_left": 0,
  "run_note": null,
  "done": 0,
  "failed": 0,
  "flagged": 0,
  "edited": 0,
  "created_at": "2026-09-25T19:49:22.477Z"
}

POST /campaigns/:id/blocks/preview

Preview a column

The same fields as Add a column, tried on five of the campaign’s leads, with what went in and what came out for each. Nothing is saved. A prompt column’s calls count under the monthly cap.

Request

curl -X POST https://craftnudge.com/api/campaigns/40255eb8-bd7f-4cd4-a020-4d0ee13e5532/blocks/preview \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "opener",
  "kind": "prompt",
  "template": "One friendly sentence to open a cold email to {{first_name|them}} at {{company}}, about something specific from their website: {{website_text}}",
  "max_chars": 200
}'

Response, 200

{
  "rows": [
    {
      "lead_id": "831d7c45-60a0-4955-b3a6-c7727322b286",
      "status": "done",
      "value": "Congratulations on the Great Taste star for Smoky Hills, fifty tins a batch is a proper way to blend.",
      "note": null,
      "review": null,
      "input": {
        "first_name": "Leo",
        "company": "Tallgrass Tea",
        "website_text": "Tallgrass Tea\nLoose leaf tea, blended in small batches.\nSmall-batch loose leaf tea\nWe blend every tea in batches of fifty tins. Our Smoky Hills blend won a Great Taste star this year."
      },
      "email": "leo@tallgrass.example"
    },
    {
      "lead_id": "fe589138-f206-4e3d-97d0-481235d29299",
      "status": "done",
      "value": "The new Hebden table lamp in oak looks lovely, and wiring every one by hand in Leeds shows.",
      "note": null,
      "review": null,
      "input": {
        "first_name": "Ana",
        "company": "Brightside Home",
        "website_text": "Brightside Home | Lamps and linen made in Leeds\nHand-finished lamps and linen from our Leeds workshop.\nMade slowly, in Leeds\nEvery lamp is wired by hand in our workshop. Free delivery on orders over £60.\nNew this autumn: the Hebden table lamp in oak."
      },
      "email": "ana@brightside.example"
    }
  ]
}

GET /campaigns/:id/blocks/cells

Read results

Each column’s result for the leads in lead_ids, up to 200 separated by commas: done, failed, flagged or edited, why it failed or was flagged, a flagged answer kept for review, and whether the lead is waiting in a run. The values themselves are the leads’ own variables.

Request

curl "https://craftnudge.com/api/campaigns/40255eb8-bd7f-4cd4-a020-4d0ee13e5532/blocks/cells?lead_ids=fe589138-f206-4e3d-97d0-481235d29299,831d7c45-60a0-4955-b3a6-c7727322b286" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "block_id": "e3923816-8519-4c45-96db-84f85005d835",
    "lead_id": "fe589138-f206-4e3d-97d0-481235d29299",
    "status": "done",
    "note": null,
    "review": null,
    "queued": false
  },
  {
    "block_id": "e3923816-8519-4c45-96db-84f85005d835",
    "lead_id": "831d7c45-60a0-4955-b3a6-c7727322b286",
    "status": "done",
    "note": null,
    "review": null,
    "queued": false
  },
  {
    "block_id": "4f9656a7-692c-405b-b4b1-e584ad3be87d",
    "lead_id": "fe589138-f206-4e3d-97d0-481235d29299",
    "status": "done",
    "note": null,
    "review": null,
    "queued": false
  },
  {
    "block_id": "4f9656a7-692c-405b-b4b1-e584ad3be87d",
    "lead_id": "831d7c45-60a0-4955-b3a6-c7727322b286",
    "status": "done",
    "note": null,
    "review": null,
    "queued": false
  }
]

PATCH /blocks/:id

Change a column

template and max_chars. The name and kind stay. Refused while a run is going.

Request

curl -X PATCH https://craftnudge.com/api/blocks/4f9656a7-692c-405b-b4b1-e584ad3be87d \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "max_chars": 160
}'

Response, 200

{
  "id": "4f9656a7-692c-405b-b4b1-e584ad3be87d",
  "name": "opener",
  "kind": "prompt",
  "template": "One friendly sentence to open a cold email to {{first_name|them}} at {{company}}, about something specific from their website: {{website_text}}",
  "max_chars": 160,
  "running": false,
  "run_total": 0,
  "run_left": 0,
  "run_note": null,
  "done": 0,
  "failed": 0,
  "flagged": 0,
  "edited": 0,
  "created_at": "2026-09-25T19:49:22.477Z"
}

DELETE /blocks/:id

Delete a column

The column goes, and its variable with it from every lead. Refused while a step uses it.

Request

curl -X DELETE https://craftnudge.com/api/blocks/e3923816-8519-4c45-96db-84f85005d835 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

POST /blocks/:id/run

Run a column

Works out every lead still without a value, a few at a time in the background. all: true runs every lead except those edited by hand. queued is how many leads the run took on. One run at a time for each column.

Request

curl -X POST https://craftnudge.com/api/blocks/4f9656a7-692c-405b-b4b1-e584ad3be87d/run \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Response, 200

{
  "queued": 2
}

POST /blocks/:id/cancel

Cancel a run

Leads not yet worked out are left as they were. Values already written stay.

Request

curl -X POST https://craftnudge.com/api/blocks/4f9656a7-692c-405b-b4b1-e584ad3be87d/cancel \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "cancelled": true
}

PUT /blocks/:id/leads/:leadId

Edit a lead’s value

value becomes the lead’s variable. A run of all leads leaves it alone.

Request

curl -X PUT https://craftnudge.com/api/blocks/4f9656a7-692c-405b-b4b1-e584ad3be87d/leads/831d7c45-60a0-4955-b3a6-c7727322b286 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "value": "Congratulations on the Great Taste star for Smoky Hills."
}'

Response, 200

{
  "lead_id": "831d7c45-60a0-4955-b3a6-c7727322b286",
  "status": "edited",
  "value": "Congratulations on the Great Taste star for Smoky Hills.",
  "note": null,
  "review": null,
  "input": ""
}

POST /blocks/:id/leads/:leadId/run

Run one lead again

Works out this one lead now and saves it in place of what it had, a hand edit included.

Request

curl -X POST https://craftnudge.com/api/blocks/4f9656a7-692c-405b-b4b1-e584ad3be87d/leads/fe589138-f206-4e3d-97d0-481235d29299/run \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "lead_id": "fe589138-f206-4e3d-97d0-481235d29299",
  "status": "done",
  "value": "The new Hebden table lamp in oak looks lovely, and wiring every one by hand in Leeds shows.",
  "note": null,
  "review": null,
  "input": {
    "first_name": "Ana",
    "company": "Brightside Home",
    "website_text": "Brightside Home | Lamps and linen made in Leeds\nHand-finished lamps and linen from our Leeds workshop.\nMade slowly, in Leeds\nEvery lamp is wired by hand in our workshop. Free delivery on orders over £60.\nNew this autumn: the Hebden table lamp in oak."
  }
}

Replies

What the unibox shows: replies, bounces and auto-replies, each matched to the send it answers.

GET /replies

List replies

Newest first. Filter with kind (reply, bounce or auto_reply), status, unread, campaign_id, mailbox_id and q. todo=true lists the replies from a person still owed an answer: not answered, not marked as needing none, and the newest from its lead. A row carries the draft assisted wrote for it and that draft’s note, when there is one.

Request

curl "https://craftnudge.com/api/replies?todo=true&limit=2" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "5a5a6148-b862-423b-8389-38e8bb224a92",
    "kind": "reply",
    "status": "lead",
    "is_read": true,
    "replied_at": null,
    "assisted_interest": null,
    "assisted_confidence": null,
    "from_email": "sam@larkbakery.example",
    "subject": "Re: Lark Bakery on mobile",
    "snippet": "Yes please, send the three things over.",
    "body_text": "Yes please, send the three things over.\n\nSam\n\nOn Fri Sep 18 2026, Maya Patel <maya@northwind.example> wrote:\n> Hi Sam,\n>\n> Google's speed test clocks the Lark Bakery homepage at about 11 seconds to show its main content on a mid-range phone.\n>\n> It might be costing you a few sales, people giving up while the page loads.\n>\n> I looked closer and found three things I'd check first. Want me to send them over?\n>\n> Maya",
    "campaign_id": "2d86e02f-892c-42a0-90e2-067b892fce62",
    "mailbox_id": "a01a8033-7350-4a2d-8272-d6a531ffb9b7",
    "lead_id": "4dbcc6f5-b23e-4f21-81f1-f24994044b98",
    "received_at": "2026-09-22T12:42:32.943Z",
    "lead_away_until": null,
    "assigned_user_id": null,
    "snoozed_until": null,
    "done_at": null,
    "assisted_draft": "Hi Sam,\n\nHappy to share. I will send the three things over shortly.\n\nMaya",
    "assisted_draft_note": "The three checks that would speed up a slow homepage, so a reply can list them.",
    "asks_removal": false
  },
  {
    "id": "a674e860-42b1-4d8d-a804-1c074c438b7a",
    "kind": "reply",
    "status": "lead",
    "is_read": true,
    "replied_at": null,
    "assisted_interest": null,
    "assisted_confidence": null,
    "from_email": "jo@fernandfig.example",
    "subject": "Re: Fern and Fig on mobile",
    "snippet": "Thanks for this. What would it cost to fix?",
    "body_text": "Thanks for this. What would it cost to fix?\n\nJo\n\nOn Thu Sep 17 2026, Maya Patel <maya@northwind.example> wrote:\n> Hi Jo,\n>\n> Google's speed test clocks the Fern and Fig homepage at about 9 seconds to show its main content on a mid-range phone.\n>\n> It might be costing you a few sales, people giving up while the page loads.\n>\n> I looked closer and found three things I'd check first. Want me to send them over?\n>\n> Maya",
    "campaign_id": "2d86e02f-892c-42a0-90e2-067b892fce62",
    "mailbox_id": "a01a8033-7350-4a2d-8272-d6a531ffb9b7",
    "lead_id": "6559263a-2a5b-4dde-b829-3aca437a4415",
    "received_at": "2026-09-21T12:42:32.950Z",
    "lead_away_until": null,
    "assigned_user_id": null,
    "snoozed_until": null,
    "done_at": null,
    "assisted_draft": "Hi Jo,\n\nGlad it was useful. The first audit is free, and I can give you a price for the fixes once I have looked at the store properly.\n\nMaya",
    "assisted_draft_note": "Your prices for fixing a slow homepage.",
    "asks_removal": false
  }
]

GET /replies/counts

Unread counts

How many are unread, how many of those are replies, how many have been answered, and how many still need a reply.

Request

curl https://craftnudge.com/api/replies/counts \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "unread": 2,
  "unread_replies": 1,
  "answered": 1,
  "to_reply": 3
}

PATCH /replies/:id

Mark a reply

Set status to an interest label, is_read, or done. The label is copied onto the lead. done true marks the reply as needing no reply, which takes it off the to-reply list, and false puts it back.

Request

curl -X PATCH https://craftnudge.com/api/replies/5c4773ad-b16a-4a91-aee9-f10264c620d0 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "status": "interested"
}'

Response, 200

{
  "updated": true
}

GET /replies/:id/thread

A reply’s conversation

Oldest first: the campaign emails sent to the lead, each with its body where it was kept, what came back from them, and what was answered from the unibox. A reply with no lead is the only message in it.

Request

curl https://craftnudge.com/api/replies/81c55f30-43ae-442c-897b-0b4d2be061f7/thread \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "ea7834cc-4bf9-42f6-b93f-8243446b33d7",
    "type": "sent",
    "at": "2026-09-14T12:42:32.974Z",
    "from": "maya@northwind.example",
    "subject": "Oakmoss Candles on mobile",
    "body": null,
    "step": 1
  },
  {
    "id": "81c55f30-43ae-442c-897b-0b4d2be061f7",
    "type": "reply",
    "at": "2026-09-18T12:42:32.976Z",
    "from": "lee@oakmoss.example",
    "subject": "Re: Oakmoss Candles on mobile",
    "body": "Go on then, send it over.\n\nLee\n\nOn Mon Sep 14 2026, Maya Patel <maya@northwind.example> wrote:\n> Hi Lee,\n>\n> Google's speed test clocks the Oakmoss Candles homepage at about 10 seconds to show its main content on a mid-range phone.\n>\n> It might be costing you a few sales, people giving up while the page loads.\n>\n> I looked closer and found three things I'd check first. Want me to send them over?\n>\n> Maya",
    "step": null
  },
  {
    "id": "da0d7feb-895a-4f91-a0cc-480f9dacadb5",
    "type": "answered",
    "at": "2026-09-19T12:42:32.978Z",
    "from": "maya@northwind.example",
    "subject": null,
    "body": "Here you go, Lee. The three checks are in the audit attached to this email.\n\nMaya",
    "step": null
  }
]

POST /replies/:id/reply

Answer a reply

Sends body from the inbox that got the reply, in the same thread.

Request

curl -X POST https://craftnudge.com/api/replies/5c4773ad-b16a-4a91-aee9-f10264c620d0/reply \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "body": "Thanks. Thursday at 3pm works, I will send an invite."
}'

Response, 200

{
  "sent": true
}

PATCH /replies/:id/assign

Assign a reply

user_id is someone on the team, or null to take it off them.

Request

curl -X PATCH https://craftnudge.com/api/replies/5c4773ad-b16a-4a91-aee9-f10264c620d0/assign \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "user_id": "1549d98d-8a73-492b-88dc-32e153790893"
}'

Response, 200

{
  "assigned_user_id": "1549d98d-8a73-492b-88dc-32e153790893"
}

POST /replies/:id/unsubscribe

Unsubscribe from a reply

Unsubscribes the lead the reply came from, the same as the link in the email: no more mail, their address on the blocklist, and the unsubscribe event. The reply is marked not_interested. Calling it again changes nothing and fires no second event. 404 when the reply has no lead.

Request

curl -X POST https://craftnudge.com/api/replies/c7a7d672-d95e-48d5-adbe-5780b349ed18/unsubscribe \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "unsubscribed": true
}

PATCH /replies/:id/snooze

Snooze a reply

until is a date and time. The reply leaves the list until then and comes back where it was. null brings it back now.

Request

curl -X PATCH https://craftnudge.com/api/replies/c7a7d672-d95e-48d5-adbe-5780b349ed18/snooze \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "until": "2026-09-21T09:00:00.000Z"
}'

Response, 200

{
  "snoozed_until": "2026-09-21T09:00:00.000Z"
}

Sent

What each campaign email we sent actually said.

GET /sends

List sent emails

Newest first: when, from which inbox, to which lead, the campaign, the step and the subject. Filter with campaign_id and mailbox_id. has_body says whether the email as sent was kept.

Request

curl "https://craftnudge.com/api/sends?limit=1" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "ea7834cc-4bf9-42f6-b93f-8243446b33d7",
    "sent_at": "2026-09-14T12:42:32.974Z",
    "mailbox_id": "5f0a2c1e-9b4d-4e7a-8c3f-2d1e6b7a9c40",
    "mailbox": "maya@northwind.example",
    "lead_id": "c2b7e0d4-1a6f-4b8e-9d3c-7e5a1f2b8c91",
    "lead_email": "lee@oakmoss.example",
    "campaign_id": "0b2c6d9e-3f4a-4c5b-8e7d-1a2b3c4d5e6f",
    "campaign": "Woo speed UK",
    "step": 1,
    "subject": "Oakmoss Candles on mobile",
    "has_body": true
  }
]

GET /sends/:id

One sent email

The same row with body_text, the email as it went out. It is null on a send made before the sender kept bodies, and body_kept_from is when the first kept one went out, so a reader can say which sends never will have one.

Request

curl https://craftnudge.com/api/sends/ea7834cc-4bf9-42f6-b93f-8243446b33d7 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "id": "ea7834cc-4bf9-42f6-b93f-8243446b33d7",
  "sent_at": "2026-09-14T12:42:32.974Z",
  "mailbox_id": "5f0a2c1e-9b4d-4e7a-8c3f-2d1e6b7a9c40",
  "mailbox": "maya@northwind.example",
  "lead_id": "c2b7e0d4-1a6f-4b8e-9d3c-7e5a1f2b8c91",
  "lead_email": "lee@oakmoss.example",
  "campaign_id": "0b2c6d9e-3f4a-4c5b-8e7d-1a2b3c4d5e6f",
  "campaign": "Woo speed UK",
  "step": 1,
  "subject": "Oakmoss Candles on mobile",
  "has_body": true,
  "body_text": "Hi Lee,\n\nGoogle's speed test clocks the Oakmoss Candles homepage at about 10 seconds to show its main content on a mid-range phone.\n\nIt might be costing you a few sales, people giving up while the page loads.\n\nI looked closer and found three things I'd check first. Want me to send them over?\n\nMaya",
  "body_kept_from": "2026-09-14T12:42:32.974Z"
}

Saved replies

Answers written once and filled in for a thread. Sending one is an ordinary answer to a reply.

GET /saved-replies

List saved replies

Every saved reply in the workspace, by name.

Request

curl https://craftnudge.com/api/saved-replies \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "b1f1d379-0d0b-41df-9d3a-aa9f71a8378d",
    "name": "Back from holiday",
    "subject": null,
    "body": "Hi {{firstName|there}},\n\nI hope the break was good. Is returns still something you want to look at this quarter?\n\n{{sendingAccountFirstName}}"
  },
  {
    "id": "913df982-382b-446a-8ce8-59905a62c4ea",
    "name": "Book a call",
    "subject": null,
    "body": "Hi {{firstName|there}},\n\nGreat to hear. Would a 15 minute call this week work? You can pick a time here: [add your booking link]\n\n{{sendingAccountFirstName}}"
  }
]

GET /saved-replies/examples

Example replies

The three examples the empty Saved replies page offers. Nothing is saved.

Request

curl https://craftnudge.com/api/saved-replies/examples \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "name": "Not now",
    "body": "Hi {{firstName|there}},\n\nThanks for letting me know. I will leave it for now and check back in a few months.\n\n{{sendingAccountFirstName}}"
  },
  {
    "name": "Send information",
    "body": "Hi {{firstName|there}},\n\nHere is a short overview of what we do and what it costs: [add your link]\n\nHappy to answer any questions.\n\n{{sendingAccountFirstName}}"
  }
]

POST /saved-replies

Save a reply

name and body, with merge fields as in a campaign step, and an optional subject for a new email. Names are unique in the workspace.

Request

curl -X POST https://craftnudge.com/api/saved-replies \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Back from holiday",
  "body": "Hi {{firstName|there}},\n\nI hope the break was good. Is returns still something you want to look at this quarter?\n\n{{sendingAccountFirstName}}"
}'

Response, 201

{
  "id": "b1f1d379-0d0b-41df-9d3a-aa9f71a8378d",
  "name": "Back from holiday",
  "subject": null,
  "body": "Hi {{firstName|there}},\n\nI hope the break was good. Is returns still something you want to look at this quarter?\n\n{{sendingAccountFirstName}}"
}

PATCH /saved-replies/:id

Change a saved reply

Any of name, subject and body.

Request

curl -X PATCH https://craftnudge.com/api/saved-replies/b1f1d379-0d0b-41df-9d3a-aa9f71a8378d \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Back from a break"
}'

Response, 200

{
  "id": "b1f1d379-0d0b-41df-9d3a-aa9f71a8378d",
  "name": "Back from a break",
  "subject": null,
  "body": "Hi {{firstName|there}},\n\nI hope the break was good. Is returns still something you want to look at this quarter?\n\n{{sendingAccountFirstName}}"
}

POST /saved-replies/:id/render

Fill in a saved reply

The subject and body with merge fields filled in, for a reply with reply_id, or for a lead and an inbox with lead_id and mailbox_id. Nothing is sent.

Request

curl -X POST https://craftnudge.com/api/saved-replies/b1f1d379-0d0b-41df-9d3a-aa9f71a8378d/render \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "reply_id": "5c4773ad-b16a-4a91-aee9-f10264c620d0"
}'

Response, 200

{
  "subject": null,
  "body": "Hi Rosa,\n\nI hope the break was good. Is returns still something you want to look at this quarter?\n\nSam"
}

DELETE /saved-replies/:id

Delete a saved reply

Removes it for everyone in the workspace.

Request

curl -X DELETE https://craftnudge.com/api/saved-replies/b1f1d379-0d0b-41df-9d3a-aa9f71a8378d \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

Tasks

Manual steps waiting on someone, such as a LinkedIn connection or a call. The sequence waits until the task is closed.

GET /tasks

List open tasks

Every task waiting on someone, the one due first at the top, with its lead, its campaign and owner_id, who works it (null for nobody). A task whose lead no longer fits its step does not show.

Request

curl https://craftnudge.com/api/tasks \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "c1902a40-88d4-45cd-81fb-1358bc21a298",
    "kind": "linkedin_connect",
    "text": "Connect with Femi on LinkedIn and mention the referral terms.",
    "step_position": 2,
    "owner_id": null,
    "due_at": "2026-09-14T19:17:15.635Z",
    "campaign": {
      "id": "b6e6f2bc-177e-454c-ae89-2315e526e7ef",
      "name": "Agency partners, UK"
    },
    "lead": {
      "id": "20b8ccc9-5d69-422f-bedd-9a35021a77b1",
      "email": "femi@greenway.example",
      "linkedin": null,
      "phone": null,
      "vars": {
        "company": "Greenway Web Studio",
        "first_name": "Femi"
      }
    }
  },
  {
    "id": "96332202-d172-412b-8a7d-7dfcaac29fc3",
    "kind": "linkedin_connect",
    "text": "Connect with Kai on LinkedIn and mention the referral terms.",
    "step_position": 2,
    "owner_id": null,
    "due_at": "2026-09-14T20:17:15.635Z",
    "campaign": {
      "id": "b6e6f2bc-177e-454c-ae89-2315e526e7ef",
      "name": "Agency partners, UK"
    },
    "lead": {
      "id": "d674ae2b-7f37-4fce-9376-58b326ce2ee2",
      "email": "kai@ashdown.example",
      "linkedin": null,
      "phone": null,
      "vars": {
        "company": "Ashdown Commerce",
        "first_name": "Kai"
      }
    }
  }
]

PATCH /tasks/:id

Give a task an owner

owner_id is a member of the workspace who works the task, or null for nobody. A viewer or anyone outside the workspace is refused with 400. New tasks start with no owner.

Request

curl -X PATCH https://craftnudge.com/api/tasks/c1902a40-88d4-45cd-81fb-1358bc21a298 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "owner_id": "1549d98d-8a73-492b-88dc-32e153790893"
}'

Response, 200

{
  "owner_id": "1549d98d-8a73-492b-88dc-32e153790893"
}

POST /tasks/:id/done

Mark a task done

Closes the task and moves the lead on to its next step, as an email would.

Request

curl -X POST https://craftnudge.com/api/tasks/c1902a40-88d4-45cd-81fb-1358bc21a298/done \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "status": "done"
}

POST /tasks/:id/skip

Skip a task

Closes the task without doing it. The lead moves on the same way, and the task keeps that it was skipped.

Request

curl -X POST https://craftnudge.com/api/tasks/96332202-d172-412b-8a7d-7dfcaac29fc3/skip \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "status": "skipped"
}

Pipeline

Leads who replied with interest, by how far they got. Each is worth its own deal_value, set with Change a lead, or else its campaign’s deal_value.

GET /analytics/pipeline

Read the pipeline

The five stages from interested to lost, each with its count, its value and its leads, and the value won so far. Each lead has its value, its own deal_value if it has one, its note (null for a viewer), and last_contact_at, when the last email went either way: a send, their reply or your answer.

Request

curl https://craftnudge.com/api/analytics/pipeline \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "stages": [
    {
      "stage": "interested",
      "count": 3,
      "value": 11900,
      "leads": [
        {
          "id": "4dacd0f4-e0d0-418d-beb2-78785ec0af8a",
          "email": "sam@larkbakery.example",
          "campaign_id": "7b95fa3d-9eb5-453b-bd9d-62e0de1abdc1",
          "campaign": "Mobile speed audit",
          "value": 2400,
          "deal_value": null,
          "note": null,
          "last_contact_at": "2026-09-21T16:45:23.472Z"
        },
        {
          "id": "300bc66a-b75b-46bc-b145-1f921d189991",
          "email": "priya@tidewater.example",
          "campaign_id": "7b95fa3d-9eb5-453b-bd9d-62e0de1abdc1",
          "campaign": "Mobile speed audit",
          "value": 3500,
          "deal_value": 3500,
          "note": "Wants the audit before their October sale. Call after the 15th. Sent the case study on 23 Sept.",
          "last_contact_at": "2026-09-11T16:45:23.481Z"
        }
      ]
    },
    {
      "stage": "meeting_booked",
      "count": 2,
      "value": 9900,
      "leads": [
        {
          "id": "0347270a-2284-48e5-9866-7972b34b38fc",
          "email": "jo@fernandfig.example",
          "campaign_id": "7b95fa3d-9eb5-453b-bd9d-62e0de1abdc1",
          "campaign": "Mobile speed audit",
          "value": 2400,
          "deal_value": null,
          "note": null,
          "last_contact_at": "2026-09-18T16:45:23.494Z"
        },
        {
          "id": "dc34e3a1-2b94-4514-85c4-c33444280c31",
          "email": "ana@copperkettle.example",
          "campaign_id": "29b8efac-9169-419d-b2a1-02c6b80fac09",
          "campaign": "Agency partners",
          "value": 7500,
          "deal_value": 7500,
          "note": "Board meets on the 30th. Send the proposal by the 28th.",
          "last_contact_at": "2026-09-14T16:45:23.500Z"
        }
      ]
    },
    {
      "stage": "meeting_completed",
      "count": 1,
      "value": 2400,
      "leads": [
        {
          "id": "ef76c18e-8b32-417d-aa8a-873f0eed6869",
          "email": "tom@harbourlight.example",
          "campaign_id": "7b95fa3d-9eb5-453b-bd9d-62e0de1abdc1",
          "campaign": "Mobile speed audit",
          "value": 2400,
          "deal_value": null,
          "note": null,
          "last_contact_at": "2026-09-20T16:45:23.504Z"
        }
      ]
    },
    {
      "stage": "won",
      "count": 2,
      "value": 8400,
      "leads": [
        {
          "id": "845a5afb-865b-4ed1-9904-99e70a06c14f",
          "email": "eve@saltandsage.example",
          "campaign_id": "7b95fa3d-9eb5-453b-bd9d-62e0de1abdc1",
          "campaign": "Mobile speed audit",
          "value": 2400,
          "deal_value": null,
          "note": null,
          "last_contact_at": "2026-09-02T16:45:23.508Z"
        },
        {
          "id": "f88c024d-a482-4797-8e01-eef97e237b7f",
          "email": "raj@ninebirds.example",
          "campaign_id": "29b8efac-9169-419d-b2a1-02c6b80fac09",
          "campaign": "Agency partners",
          "value": 6000,
          "deal_value": null,
          "note": null,
          "last_contact_at": "2026-08-14T16:45:23.513Z"
        }
      ]
    },
    {
      "stage": "lost",
      "count": 1,
      "value": 2400,
      "leads": [
        {
          "id": "98c7a931-bdb8-4c1e-b049-119aaab09efd",
          "email": "kai@driftwood.example",
          "campaign_id": "7b95fa3d-9eb5-453b-bd9d-62e0de1abdc1",
          "campaign": "Mobile speed audit",
          "value": 2400,
          "deal_value": null,
          "note": null,
          "last_contact_at": "2026-08-11T16:45:23.518Z"
        }
      ]
    }
  ],
  "won_value": 8400
}

PATCH /analytics/pipeline/:leadId

Move a lead

stage is meeting_booked, meeting_completed, won or lost. Nothing moves into interested from here, and only a lead who replied can move. Any other is refused with 400. A move sends nothing, stops any step still to come for that lead, and fires the interest_changed event.

Request

curl -X PATCH https://craftnudge.com/api/analytics/pipeline/f2b2b504-7aab-4370-954a-83184844f2e6 \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "stage": "meeting_booked"
}'

Response, 200

{
  "id": "f2b2b504-7aab-4370-954a-83184844f2e6",
  "stage": "meeting_booked"
}

Blocklist

Addresses and whole domains that are never contacted. Checked on every import and again at the moment of sending.

POST /blocklist

Block addresses and domains

Each entry is an address or a whole domain.

Request

curl -X POST https://craftnudge.com/api/blocklist \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "emails": [
    "nobody@rival.example",
    "spamtrap.example"
  ]
}'

Response, 200

{
  "added": 2
}

POST /blocklist/import

Import a blocklist

A CSV of addresses and domains. An email or domain column is used when the file has one, otherwise the first column.

Request

curl -X POST https://craftnudge.com/api/blocklist/import \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @blocklist.csv

blocklist.csv

email
old@list.example
stale.example
not an address

Response, 200

{
  "added": 2,
  "already": 0,
  "invalid": 1
}

GET /blocklist

Read the blocklist

Every entry and why it is there: manual, import, unsubscribe or hard_bounce.

Request

curl https://craftnudge.com/api/blocklist \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
    "email": "old@list.example",
    "reason": "import",
    "createdAt": "2026-09-14T22:17:33.580Z"
  },
  {
    "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
    "email": "stale.example",
    "reason": "import",
    "createdAt": "2026-09-14T22:17:33.580Z"
  }
]

DELETE /blocklist/:email

Unblock

Removes one address or domain.

Request

curl -X DELETE https://craftnudge.com/api/blocklist/stale.example \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

Reports

The numbers behind the Analytics and Deliverability pages.

GET /reports/summary

Workspace summary

Campaigns, inboxes, and everything sent, opened, replied and bounced so far.

Request

curl https://craftnudge.com/api/reports/summary \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "campaigns": 4,
  "active_campaigns": 2,
  "mailboxes": 4,
  "sent": 53,
  "opened": 19,
  "replied": 7,
  "bounced": 4,
  "open_rate": 35.8,
  "reply_rate": 13.2
}

GET /reports/analytics

Analytics

Totals, by day, by campaign and by inbox between from and to, written YYYY-MM-DD. The last 30 days when they are left out. campaign_id narrows every number to one campaign. started counts the leads whose first step went out. won counts the leads moved to won in the range, and won_value adds up their campaigns’ deal values. An email opened or clicked more than once counts once. open_rate and click_rate count only campaigns with that tracking on, and are null when none has it. campaigns lists each campaign that has sent, to narrow to.

Request

curl "https://craftnudge.com/api/reports/analytics?from=2026-08-24&to=2026-09-23" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "from": "2026-08-24T00:00:00.000Z",
  "to": "2026-09-24T00:00:00.000Z",
  "totals": {
    "sent": 209,
    "started": 123,
    "replies": 15,
    "bounces": 9,
    "positive": 7,
    "reply_rate": 7.2,
    "bounce_rate": 4.3,
    "opened": 48,
    "clicked": 12,
    "open_rate": 37.2,
    "click_rate": 9.3,
    "won": 3,
    "won_value": 7500
  },
  "by_day": [
    {
      "day": "2026-08-24",
      "sent": 21,
      "replies": 0,
      "bounces": 0,
      "opened": 5,
      "clicked": 2
    },
    {
      "day": "2026-08-25",
      "sent": 11,
      "replies": 1,
      "bounces": 0,
      "opened": 5,
      "clicked": 1
    }
  ],
  "by_campaign": [
    {
      "id": "7763dac2-afbf-4bf9-8ce9-61b456211582",
      "label": "Founders, Q3",
      "sent": 129,
      "replies": 9,
      "bounces": 7,
      "positive": 4,
      "reply_rate": 7,
      "bounce_rate": 5.4
    },
    {
      "id": "d8650d6d-d38a-4be7-9c92-7ab0f9704910",
      "label": "Agencies, UK",
      "sent": 80,
      "replies": 6,
      "bounces": 2,
      "positive": 3,
      "reply_rate": 7.5,
      "bounce_rate": 2.5
    }
  ],
  "by_mailbox": [
    {
      "id": "0a42766f-a009-47a8-b23a-c8f4390768de",
      "label": "maya@northwind.example",
      "sent": 111,
      "replies": 8,
      "bounces": 5,
      "positive": 5,
      "reply_rate": 7.2,
      "bounce_rate": 4.5
    },
    {
      "id": "406ff00a-e450-429b-9335-97bc13d7fd26",
      "label": "sam@northwind.example",
      "sent": 98,
      "replies": 7,
      "bounces": 4,
      "positive": 2,
      "reply_rate": 7.1,
      "bounce_rate": 4.1
    }
  ],
  "campaigns": [
    {
      "id": "d8650d6d-d38a-4be7-9c92-7ab0f9704910",
      "name": "Agencies, UK"
    },
    {
      "id": "7763dac2-afbf-4bf9-8ce9-61b456211582",
      "name": "Founders, Q3"
    }
  ]
}

GET /reports/deliverability

Deliverability

The last 7 days: SMTP answers by code, bounces by cause, and each inbox’s sends, deferrals, rejections and answered replies.

Request

curl https://craftnudge.com/api/reports/deliverability \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "window_days": 7,
  "smtp": {
    "accepted": 34,
    "deferred": 4,
    "rejected": 2,
    "by_code": {
      "250": 34,
      "421": 4,
      "550": 2
    }
  },
  "bounce_reasons": {
    "bad_address": 3,
    "blocked": 1
  },
  "per_inbox": [
    {
      "email": "hey@getlumen.example",
      "sent": 14,
      "deferred": 4,
      "rejected": 0,
      "answered": 1
    },
    {
      "email": "outreach@acme.example",
      "sent": 11,
      "deferred": 0,
      "rejected": 0,
      "answered": 0
    }
  ]
}

GET /deliverability/fixes

What to fix

Problems we can already see, most urgent first: inboxes that cannot send or are resting, bounces and server delays over the last 7 days, failing domain records and blocklists, a poor last placement test, and live campaigns that are stuck or low on new leads. Each has a severity (high, medium or low), a code, what is wrong and what to do, and inbox_id or campaign_id when pausing it would help. An empty list means nothing to fix.

Request

curl https://craftnudge.com/api/deliverability/fixes \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "fixes": [
    {
      "severity": "high",
      "code": "inbox_needs_sign_in",
      "text": "sam@acme.example needs signing in again.",
      "fix": "Sign it in again on the Inboxes page."
    },
    {
      "severity": "medium",
      "code": "domain_dmarc",
      "text": "acme.example has no DMARC record.",
      "fix": "The Inboxes page shows what to change."
    },
    {
      "severity": "low",
      "code": "campaign_leads_low",
      "text": "October follow-up has 3 new leads left, which runs out within two days at 40 a day.",
      "fix": "Add leads to keep it going."
    }
  ]
}

GET /reports/analytics.csv

Analytics as CSV

The same numbers between from and to, for one campaign with campaign_id, as a CSV file, a row for each campaign, mailbox or day, chosen with group.

Request

curl "https://craftnudge.com/api/reports/analytics.csv?group=campaign&from=2026-08-14&to=2026-09-13" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

campaign,sent,replies,reply_rate,positive,bounces,bounce_rate
Q3 · Founder outreach,42,6,14.3,3,3,7.1
"Agency partners, UK",10,1,10,1,1,10

GET /reports/domains

Sending by domain

Each sending domain’s sends for each day against what it was allowed, over the last days, 14 by default.

Request

curl "https://craftnudge.com/api/reports/domains?days=7" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "days": [
    "2026-09-08",
    "2026-09-09",
    "2026-09-10",
    "2026-09-11",
    "2026-09-12",
    "2026-09-13",
    "2026-09-14"
  ],
  "domains": [
    {
      "domain": "acme.example",
      "inboxes": 2,
      "domain_cap": 60,
      "points": [
        {
          "day": "2026-09-08",
          "sent": 4,
          "allowed": 60
        },
        {
          "day": "2026-09-09",
          "sent": 5,
          "allowed": 60
        }
      ]
    },
    {
      "domain": "getlumen.example",
      "inboxes": 1,
      "domain_cap": null,
      "points": [
        {
          "day": "2026-09-08",
          "sent": 1,
          "allowed": 10
        },
        {
          "day": "2026-09-09",
          "sent": 3,
          "allowed": 15
        }
      ]
    }
  ]
}

GET /analytics/best-time

When people reply

Replies over the last days, 90 by default, counted by weekday and hour in the workspace timezone. grid has a row for each weekday and a count for each hour.

Request

curl "https://craftnudge.com/api/analytics/best-time?days=30" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "timezone": "Europe/London",
  "days": 30,
  "replies": 7,
  "grid": [
    [
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0
    ],
    [
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0
    ]
  ]
}

GET /reports/dmarc

DMARC reports

A summary of the DMARC aggregate reports read over the last days, 30 by default: the messages, how many passed, and where they came from.

Request

curl "https://craftnudge.com/api/reports/dmarc?days=30" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "report_address": "dmarc@acme.example",
  "window_days": 30,
  "summary": {
    "reports": 0,
    "messages": 0,
    "passed": 0,
    "dkim_passed": 0,
    "spf_passed": 0,
    "quarantined": 0,
    "rejected": 0,
    "sources": []
  },
  "latest": []
}

PATCH /reports/dmarc

Set the DMARC report address

report_address is the connected inbox your DMARC record sends reports to, so its mail is read as reports and not as replies. null turns it off.

Request

curl -X PATCH https://craftnudge.com/api/reports/dmarc \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "report_address": "dmarc@acme.example"
}'

Response, 200

{
  "report_address": "dmarc@acme.example"
}

Domains and placement tests

The weekly warmup ramp for each sending domain, and placement tests to seed addresses you own.

GET /domains

List sending domains

Each domain the workspace sends from, with its inboxes, its ramp week and the daily cap that week split across its active inboxes, why it is held back if it is, and its sends and bounces over 7 days.

Request

curl https://craftnudge.com/api/domains \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "domain": "acme.example",
    "mailboxes": 2,
    "active_mailboxes": 2,
    "warmup_started_at": "2026-08-28",
    "current_week": 3,
    "weekly_cap": 12,
    "per_mailbox_cap": [
      6,
      6
    ],
    "held_back_reason": null,
    "sent_7d": 20,
    "bounced_7d": 0
  },
  {
    "domain": "getlumen.example",
    "mailboxes": 1,
    "active_mailboxes": 1,
    "warmup_started_at": "2026-09-08",
    "current_week": 1,
    "weekly_cap": 5,
    "per_mailbox_cap": [
      5
    ],
    "held_back_reason": null,
    "sent_7d": 14,
    "bounced_7d": 1
  }
]

POST /domains

Start a domain’s ramp

Starts the weekly warmup ramp today for a domain the workspace has an inbox on. A domain already on its ramp answers already.

Request

curl -X POST https://craftnudge.com/api/domains \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "domain": "tallpine.example"
}'

Response, 200

{
  "id": "8f4a1c82-50b7-434d-af0e-56f2035faadf",
  "workspaceId": "998eec8c-7a1c-4a30-a6cf-6afb82f89677",
  "domain": "tallpine.example",
  "warmupStartedAt": "2026-09-14",
  "currentWeek": 1,
  "lastCheckedAt": "2026-09-14T22:17:33.649Z",
  "heldBackReason": null,
  "createdAt": "2026-09-14T22:17:33.653Z"
}

DELETE /domains/:domain

Take a domain off its ramp

Removes the domain’s ramp. Its inboxes stay.

Request

curl -X DELETE https://craftnudge.com/api/domains/tallpine.example \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

GET /seeds

List seed addresses

The addresses you own that placement tests go to.

Request

curl https://craftnudge.com/api/seeds \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "email": "acme.seedbox@gmail.example",
    "label": "Gmail"
  },
  {
    "email": "acme.seedbox@outlook.example",
    "label": "Outlook"
  }
]

POST /seeds

Add a seed address

email, and a label such as Gmail. Use addresses you own, one on each provider you care about.

Request

curl -X POST https://craftnudge.com/api/seeds \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "acme.seedbox@yahoo.example",
  "label": "Yahoo"
}'

Response, 200

{
  "added": true
}

DELETE /seeds/:email

Remove a seed address

Takes one address off the seed list.

Request

curl -X DELETE https://craftnudge.com/api/seeds/acme.seedbox@yahoo.example \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

POST /placement-tests

Run a placement test

Sends subject and body from mailbox_id to every seed address. These are real emails, counted against the inbox’s cap and its domain’s, and refused when either is spent.

Request

curl -X POST https://craftnudge.com/api/placement-tests \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "mailbox_id": "86b394fc-4eef-41ac-bc0d-be1e4d4c7414",
  "subject": "Quick question about returns",
  "body": "Hi,\n\nHow are you handling returns this quarter?\n\nJordan"
}'

Response, 200

{
  "id": "d4933290-aab8-49d3-9706-79217da529df",
  "sent_to": 3
}

PATCH /placement-tests/:id

Record where it landed

seed and placement: inbox, promotions, spam or missing, as you saw it in that inbox.

Request

curl -X PATCH https://craftnudge.com/api/placement-tests/d4933290-aab8-49d3-9706-79217da529df \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "seed": "acme.seedbox@gmail.example",
  "placement": "inbox"
}'

Response, 200

{
  "recorded": true
}

GET /placement-tests

List placement tests

The last 20 tests with what was recorded for each seed.

Request

curl https://craftnudge.com/api/placement-tests \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "d4933290-aab8-49d3-9706-79217da529df",
    "subject": "Quick question about returns",
    "created_at": "2026-09-14T22:17:33.717Z",
    "mailbox": "hey@getlumen.example",
    "results": [
      {
        "seed": "acme.seedbox@gmail.example",
        "error": null,
        "placement": "inbox"
      },
      {
        "seed": "acme.seedbox@outlook.example",
        "error": null,
        "placement": null
      }
    ]
  },
  {
    "id": "abe5059b-2958-4b46-b942-95904e1c1acb",
    "subject": "Quick question about Northwind Interiors",
    "created_at": "2026-09-12T08:15:00.000Z",
    "mailbox": "outreach@acme.example",
    "results": [
      {
        "seed": "acme.seedbox@gmail.example",
        "error": null,
        "placement": "promotions"
      },
      {
        "seed": "acme.seedbox@outlook.example",
        "error": null,
        "placement": "inbox"
      }
    ]
  }
]

GET /placement-history

Placement history

The last 50 tests, newest first, for one inbox with mailbox_id or for all. Each has how many seeds it went to, how many were recorded, the seeds still waiting for a result, and by provider how many landed in each tab. A provider is the seed’s label, or its domain when it has none.

Request

curl "https://craftnudge.com/api/placement-history?mailbox_id=aea15df0-fdf2-4c63-af5e-589f2a410e4e" \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "eeb975ef-ec62-4fac-815a-f9ccd484fc49",
    "subject": "Your checkout speed",
    "created_at": "2026-09-14T18:10:51.816Z",
    "mailbox_id": "aea15df0-fdf2-4c63-af5e-589f2a410e4e",
    "mailbox": "rae@nudgeone.test",
    "seeds": 3,
    "sent": 3,
    "failed": 0,
    "recorded": 2,
    "waiting_for": [
      "seed3@outlook.test"
    ],
    "providers": [
      {
        "provider": "Gmail",
        "sent": 2,
        "recorded": 2,
        "inbox": 1,
        "promotions": 0,
        "spam": 1,
        "missing": 0
      },
      {
        "provider": "Outlook",
        "sent": 1,
        "recorded": 0,
        "inbox": 0,
        "promotions": 0,
        "spam": 0,
        "missing": 0
      }
    ]
  },
  {
    "id": "fd3e9b19-8303-47d8-8120-0c558d790e72",
    "subject": "Your checkout speed",
    "created_at": "2026-09-07T18:10:51.809Z",
    "mailbox_id": "aea15df0-fdf2-4c63-af5e-589f2a410e4e",
    "mailbox": "rae@nudgeone.test",
    "seeds": 3,
    "sent": 3,
    "failed": 0,
    "recorded": 3,
    "waiting_for": [],
    "providers": [
      {
        "provider": "Gmail",
        "sent": 2,
        "recorded": 2,
        "inbox": 2,
        "promotions": 0,
        "spam": 0,
        "missing": 0
      },
      {
        "provider": "Outlook",
        "sent": 1,
        "recorded": 1,
        "inbox": 1,
        "promotions": 0,
        "spam": 0,
        "missing": 0
      }
    ]
  }
]

PUT /placement-schedule

Schedule placement tests

Sends subject and body from every active inbox to every seed address, once every every_days days: 7 by default and never fewer. An inbox without room under its cap or its domain’s is skipped that time, and last_note says so. A new schedule’s first run is due at once. Changing one keeps its next run. When a provider’s results for a test are all recorded and it landed in the inbox less than on that inbox’s test before, the workspace’s reply notifications say so once.

Request

curl -X PUT https://craftnudge.com/api/placement-schedule \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "subject": "Your checkout speed",
  "body": "Hi, a quick one about your checkout.",
  "every_days": 14
}'

Response, 200

{
  "subject": "Your checkout speed",
  "body": "Hi, a quick one about your checkout.",
  "every_days": 14,
  "next_run_at": "2026-09-21T18:10:51.818Z",
  "last_run_at": "2026-09-14T18:10:51.818Z",
  "last_note": "Sent from rae@nudgeone.test to 3 seed addresses. Skipped sam@nudgetwo.test: sam@nudgetwo.test is at its daily cap: 1 left today, 3 seed addresses."
}

GET /placement-schedule

Read the schedule

The schedule with its next run, its last run and what that run sent and skipped. null when there is none.

Request

curl https://craftnudge.com/api/placement-schedule \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "subject": "Your checkout speed",
  "body": "Hi, a quick one about your checkout.",
  "every_days": 7,
  "next_run_at": "2026-09-21T18:10:51.818Z",
  "last_run_at": "2026-09-14T18:10:51.818Z",
  "last_note": "Sent from rae@nudgeone.test to 3 seed addresses. Skipped sam@nudgetwo.test: sam@nudgetwo.test is at its daily cap: 1 left today, 3 seed addresses."
}

DELETE /placement-schedule

Stop the schedule

No more scheduled tests. Past tests and their results stay.

Request

curl -X DELETE https://craftnudge.com/api/placement-schedule \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

Assisted

Help from a model on the workspace’s own key. Nothing here sends. While a key and a cap are set, each new reply from a lead also gets a draft written ahead. An owner sets the key and a monthly spending cap in the app. Without a key, or with the cap reached, a call answers 400 with a reason.

GET /assisted/settings

Assisted settings

Whether a key is saved, the monthly cap, and what is spent this month, in millionths of a dollar.

Request

curl https://craftnudge.com/api/assisted/settings \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "has_key": true,
  "monthly_cap_micros": 5000000,
  "spent_this_month_micros": 0
}

POST /assisted/replies/:id/draft

Draft an answer

A draft answer to one reply, kept on the reply in place of any draft it had, to read and change before sending it with Answer a reply. note says what the workspace’s facts lack to answer it well, or is null.

Request

curl -X POST https://craftnudge.com/api/assisted/replies/5c4773ad-b16a-4a91-aee9-f10264c620d0/draft \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "draft": "Hi Rosa,\n\nHappy to explain. Agencies that refer a shop to us get 10% of what that shop pays us for the first year, paid every month. There is nothing to sign up for until the first shop is live.\n\nWould a short call on Thursday help?\n\nJordan"
}

POST /assisted/replies/:id/suggestions

Suggest answers

Up to three short answers to one reply, each a different way to answer, drawing on up to ten of the team’s saved replies.

Request

curl -X POST https://craftnudge.com/api/assisted/replies/5c4773ad-b16a-4a91-aee9-f10264c620d0/suggestions \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "suggestions": [
    {
      "intent": "Explain the referral",
      "text": "Hi Rosa,\n\nAgencies that refer a shop get 10% of what that shop pays us for the first year, paid monthly. Happy to walk you through it.\n\nJordan"
    },
    {
      "intent": "Book a call",
      "text": "Hi Rosa,\n\nGreat to hear. Would a 15 minute call this week work? Thursday afternoon is open on my side.\n\nJordan"
    }
  ]
}

POST /assisted/subjects

Subject ideas

Up to three subject lines for a body you wrote. A line may use only merge fields the body already uses.

Request

curl -X POST https://craftnudge.com/api/assisted/subjects \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "body": "Hi {{first_name|there}},\n\nHow are you handling returns at {{company}}?\n\nJordan\n\nNot relevant? {{unsubscribe}}"
}'

Response, 200

{
  "subjects": [
    "Returns at {{company}}",
    "What a return costs {{company}} today",
    "A question about your returns"
  ]
}

POST /assisted/rewrite

Rewrite a body

style is shorter, plainer or spintax. Every merge field in the body is kept, and none is added. Spintax comes back as {a|b} groups, and the sender picks one option on each send.

Request

curl -X POST https://craftnudge.com/api/assisted/rewrite \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "style": "shorter",
  "body": "Hi {{first_name|there}},\n\nI saw {{company}} has been growing fast this year, and I wanted to ask how you are handling returns now that volume is up. We work with shops your size and usually cut the cost per return by about a third.\n\nWould a short call be worth it?\n\nJordan\n\nNot relevant? {{unsubscribe}}"
}'

Response, 200

{
  "body": "Hi {{first_name|there}},\n\nHow is {{company}} handling returns? We cut the cost per return by about a third for shops your size.\n\nWorth a short call?\n\nJordan\n\nNot relevant? {{unsubscribe}}"
}

POST /assisted/ask

Ask about the workspace

messages is the conversation so far, each with a role (user or assistant) and its text, ending with the question. The answer comes from the same read-only tools as the MCP endpoint, and looked lists the tools it used. Nothing is changed or sent. Each round of looking things up counts toward the monthly cap, and after five rounds it gives up with 502.

Request

curl -X POST https://craftnudge.com/api/assisted/ask \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "messages": [
    {
      "role": "user",
      "text": "What campaigns do I have?"
    }
  ]
}'

Response, 200

{
  "answer": "You have 2 campaigns: October follow-up (draft) and Spring list (draft).",
  "looked": [
    "list_campaigns"
  ]
}

POST /assisted/sequence

Write a sequence

Emails written from a brief, using the workspace’s facts when it has some. offer is required. audience, proof, steps (1 to 5, 3 by default) and fields, the lead columns it may use as merge fields, are optional. Only the first email has a subject, so the rest reply in the same thread. Nothing is saved.

Request

curl -X POST https://craftnudge.com/api/assisted/sequence \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "offer": "Returns handling for online shops: we collect, check and restock returned parcels.",
  "audience": "Owners of UK shops selling homeware online",
  "proof": "Marlow Candles cut the cost of a return by about a third, and a return went from 11 days to 4.",
  "steps": 3,
  "fields": [
    "first_name",
    "company"
  ]
}'

Response, 200

{
  "steps": [
    {
      "subject": "Returns at {{company}}",
      "body": "Hi {{first_name|there}},\n\nMost shops your size lose money on every return before the parcel is even back. We cut that by about a third for Marlow Candles last quarter.\n\nWould a short call next week help?\n\n{{accountSignature}}",
      "wait_minutes": 0
    },
    {
      "subject": "",
      "body": "Hi {{first_name|there}},\n\nOne number from the Marlow work: a return went from 11 days to 4 to process.\n\nWorth a look for {{company}}?\n\n{{accountSignature}}",
      "wait_minutes": 4320
    },
    {
      "subject": "",
      "body": "Hi {{first_name|there}},\n\nI will leave it here. If returns ever become a headache at {{company}}, reply and I will send over how we did it.\n\n{{accountSignature}}",
      "wait_minutes": 5760
    }
  ]
}

POST /assisted/craft

Craft a sequence

Emails written from a brief. who and offer are required. pains, outcomes, method and proof sharpen it, and blank pains and outcomes are worked out from the brief and the workspace’s facts. variants holds up to three versions of the first email, each with an approach: offer, question or value. ask is interest, video, audit, case_study, right_person or meeting. follow_ups is 0 to 4, 3 by default, and the last one closes the thread. fields lists the lead columns, up to 100. use lists the ones to work in, up to 10, and each goes in every version of the first email with a fallback. With none, the emails use no lead field. The emails hold no HTML, and a link or an address only when the brief or the facts give it. samples gives a value one lead has for a field in use, so the writing fits it. quotes, off by default, first searches the web for what buyers like the reader wrote, up to three searches at one cent each on top of the tokens, and writes from their words. Answers with the steps, the pains, outcomes and method names it used, the variant to test first and why, and what the brief lacks, with quotes the found words and each one’s link, kept only when the search returned that link. Nothing is saved.

Request

curl -X POST https://craftnudge.com/api/assisted/craft \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "who": "Owners of UK homeware shops on WooCommerce doing £30k a month or more",
  "offer": "We make slow WooCommerce shops load in under two seconds, working on a copy of the site first.",
  "method": "The Speed Sprint",
  "proof": "Marlow Candles went from a 6 second checkout to 1.8 seconds.",
  "variants": [
    {
      "approach": "offer"
    },
    {
      "approach": "question"
    },
    {
      "approach": "value"
    }
  ],
  "ask": "video",
  "follow_ups": 3,
  "use": [
    "first_name",
    "personalization"
  ],
  "samples": {
    "personalization": "Making kitchen knives in Sheffield since 2011 is a long run at one craft."
  },
  "fields": [
    "first_name",
    "company",
    "city",
    "linkedin_headline",
    "personalization"
  ]
}'

Response, 200

{
  "steps": [
    {
      "wait_minutes": 0,
      "variants": [
        {
          "subject": "thoughts?",
          "body": "Hi {{first_name|there}},\n\n{{personalization|Came across your shop while looking at homeware brands on WooCommerce.}}\n\nWe help homeware shops load in under two seconds with the Speed Sprint, working on a copy of the site so nothing breaks.\n\nMarlow Candles went from a 6 second checkout to 1.8 seconds.\n\nIs it OK if I send over a short video on how you could do the same?"
        },
        {
          "subject": "{{first_name|there}}?",
          "body": "Hi {{first_name|there}},\n\n{{personalization|Came across your shop while looking at homeware brands on WooCommerce.}}\n\nCurious, does your checkout still take a while to load on a phone?"
        },
        {
          "subject": "ideas for your shop",
          "body": "Hi {{first_name|there}},\n\n{{personalization|Came across your shop while looking at homeware brands on WooCommerce.}}\n\nHad a few ideas on how you could turn more visits into orders:\n- Load product photos only as people scroll\n- Cut the checkout to one page\n- Drop the plugins nobody uses\n\nIs it OK if I send a short video walking through them?"
        }
      ]
    },
    {
      "wait_minutes": 2880,
      "variants": [
        {
          "subject": "",
          "body": "Hi {{first_name|there}},\n\nFollowing up on my last note about a faster shop. Would a free audit with a few quick wins be useful?"
        }
      ]
    },
    {
      "wait_minutes": 4320,
      "variants": [
        {
          "subject": "",
          "body": "Hi {{first_name|there}},\n\nIs a faster checkout a priority for you this year?"
        }
      ]
    },
    {
      "wait_minutes": 5760,
      "variants": [
        {
          "subject": "",
          "body": "Hi {{first_name|there}},\n\nHaven't heard back, so I'll close this off. Where are you at with it?\n\nA) Interested, send me more\nB) Not a priority right now\nC) I'm not the right person\nD) We have this covered"
        }
      ]
    }
  ],
  "used": {
    "pains": [
      "Checkout drags on a phone and people give up",
      "Ad costs keep climbing while orders stay flat",
      "Every plugin update seems to slow the shop down"
    ],
    "outcomes": [
      "A shop that loads in under two seconds on a phone",
      "More of the paid traffic turning into orders"
    ],
    "method_names": [
      "The Speed Sprint",
      "The Two Second Shop",
      "Checkout Tune-up"
    ],
    "test_first": {
      "variant": 2,
      "why": "The question asks nothing of them yet and reads as a peer, so it should earn the most replies on a cold list."
    },
    "gaps": [
      "A number on what the faster shop earned Marlow Candles",
      "A second customer, in a different trade",
      "A price or a range"
    ]
  }
}

POST /assisted/review

Review a sequence

What works and what to change in each email of steps, each change with the exact words it is about, notes on the waits between emails, and a verdict. Nothing is saved.

Request

curl -X POST https://craftnudge.com/api/assisted/review \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "steps": [
    {
      "wait_minutes": 0,
      "variants": [
        {
          "subject": "Quick question about {{company}}",
          "body": "Hi {{first_name|there}},\n\nHow are you handling returns at {{company}}?\n\nJordan\n\nNot relevant? {{unsubscribe}}"
        }
      ]
    },
    {
      "wait_minutes": 4320,
      "variants": [
        {
          "subject": "",
          "body": "Bumping this in case it got buried, {{first_name|there}}."
        }
      ]
    }
  ]
}'

Response, 200

{
  "verdict": "Short and clear. The first email asks a fair question but gives no reason to answer it, and the second adds nothing new.",
  "timing": [
    "Three days before the follow-up is fine for two emails."
  ],
  "emails": [
    {
      "step": 1,
      "variant": 1,
      "works": [
        "One plain question that is easy to answer.",
        "Short enough to read on a phone."
      ],
      "changes": [
        {
          "quote": "How are you handling returns at {{company}}?",
          "note": "Add one line on why you ask, such as what a return usually costs a shop this size."
        }
      ]
    },
    {
      "step": 2,
      "variant": 1,
      "works": [
        "Stays in the same thread."
      ],
      "changes": [
        {
          "quote": "Bumping this in case it got buried",
          "note": "Give them something new instead, such as one result from a similar shop."
        }
      ]
    }
  ]
}

GET /assisted/columns/presets

Column presets

The fields assisted can write for each lead: a clean company name, a clean first name, what the business does from its website, personal first lines, and custom, your own instruction. line marks a personal first line: from their website, where they are, a pain in their industry, ideas for them, a light joke, or from a column you have. source marks one that needs a lead field to read, and context one that takes what you help with.

Request

curl https://craftnudge.com/api/assisted/columns/presets \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "presets": [
    {
      "id": "clean_company",
      "name": "Clean the company name",
      "text": "The name the way its own people write it: no Ltd, Inc or .com, normal capitals.",
      "column": "company_clean",
      "web": false,
      "line": false,
      "source": false,
      "context": false
    },
    {
      "id": "clean_first_name",
      "name": "Clean the first name",
      "text": "The first name for a greeting, or blank when the field is not a person, such as info or sales.",
      "column": "first_name_clean",
      "web": false,
      "line": false,
      "source": false,
      "context": false
    },
    {
      "id": "opener",
      "name": "Opening line from their website",
      "text": "One sentence about something specific on their own site, for the first line of an email.",
      "column": "opener",
      "web": true,
      "line": true,
      "source": false,
      "context": false
    },
    {
      "id": "company_summary",
      "name": "What they do, from their website",
      "text": "One or two sentences on what the business sells and to whom.",
      "column": "company_summary",
      "web": true,
      "line": false,
      "source": false,
      "context": false
    },
    {
      "id": "line_place",
      "name": "Where they are",
      "text": "One line about their town or region, from the lead’s city, state or country. It never says you were there.",
      "column": "personalization",
      "web": false,
      "line": true,
      "source": false,
      "context": false
    },
    {
      "id": "line_pain",
      "name": "A pain in their industry",
      "text": "One line naming a problem people in their role and industry often have. No statistics and no news.",
      "column": "personalization",
      "web": false,
      "line": true,
      "source": false,
      "context": true
    },
    {
      "id": "line_ideas",
      "name": "Ideas for them",
      "text": "Two short, practical ideas for their business, close to what you help with. The value first, before any ask.",
      "column": "personalization",
      "web": false,
      "line": true,
      "source": false,
      "context": true
    },
    {
      "id": "line_joke",
      "name": "A light joke",
      "text": "A clean one-line joke about their industry or job, like a dad joke. It won’t suit every reader, so read the preview.",
      "column": "personalization",
      "web": false,
      "line": true,
      "source": false,
      "context": false
    },
    {
      "id": "line_column",
      "name": "From a column you have",
      "text": "One line from a field you uploaded, such as a LinkedIn headline, about section or recent post.",
      "column": "personalization",
      "web": false,
      "line": true,
      "source": true,
      "context": false
    },
    {
      "id": "custom",
      "name": "Your own prompt",
      "text": "Write the instruction. {{field}} points at one of the lead’s fields, which go with it.",
      "column": "custom",
      "web": false,
      "line": false,
      "source": false,
      "context": false
    }
  ]
}

POST /campaigns/:id/assisted-columns/preview

Preview a column

Answers preset for the first 3 leads still due mail, or for lead_ids, up to 3 of the campaign’s leads you pick, and writes nothing. column names the field. custom takes prompt, your instruction, where {{field}} points at one of the lead’s fields, and web to read the lead’s website first. A preset marked context takes prompt as what you help with, and one marked source takes source, the lead field it reads. overwrite includes leads that already have the field. Answers with each lead’s id, email and value or why it has none, what the preview cost in millionths of a dollar, and how many leads a run would cover.

Request

curl -X POST https://craftnudge.com/api/campaigns/d6a146d1-790e-43a5-8679-256b788a1f9c/assisted-columns/preview \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "preset": "clean_company",
  "column": "company_clean"
}'

Response, 200

{
  "rows": [
    {
      "email": "mila@quayside.example",
      "value": "Quayside Ceramics"
    },
    {
      "email": "ines@fernbrook.example",
      "value": "Fernbrook Tea"
    },
    {
      "email": "noah@hollowell.example",
      "value": "Hollowell Knives"
    }
  ],
  "cost_micros": 6150,
  "leads": 3
}

POST /campaigns/:id/assisted-columns

Fill a column

The same fields as the preview. The worker writes the field for every lead still due mail, a few at a time, then for each lead added later, until the run is cancelled or the monthly cap is reached. With no lead to fill yet, it waits for leads added later. Each lead is tried once. One run per field at a time. A second is refused with 409 while the first still has leads left, and takes over from it once it has caught up.

Request

curl -X POST https://craftnudge.com/api/campaigns/d6a146d1-790e-43a5-8679-256b788a1f9c/assisted-columns \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "preset": "clean_company",
  "column": "company_clean"
}'

Response, 201

{
  "id": "04485d1d-2dca-4ac1-938b-519cbf974303",
  "column": "company_clean",
  "preset": "clean_company",
  "status": "running",
  "stop_reason": null,
  "total": 3,
  "done": 0,
  "failed": 0,
  "left": 3,
  "created_at": "2026-09-23T17:28:20.943Z",
  "finished_at": null
}

GET /campaigns/:id/assisted-columns

List column runs

Up to 20 of the campaign’s runs, the running ones first, then newest first, with how many leads each has done and how many had no value, and why a stopped run stopped. left is how many leads a running run has still to try. With none left, it has caught up and fills new leads as they come.

Request

curl https://craftnudge.com/api/campaigns/d6a146d1-790e-43a5-8679-256b788a1f9c/assisted-columns \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "runs": [
    {
      "id": "04485d1d-2dca-4ac1-938b-519cbf974303",
      "column": "company_clean",
      "preset": "clean_company",
      "status": "running",
      "stop_reason": null,
      "total": 3,
      "done": 0,
      "failed": 0,
      "left": 3,
      "created_at": "2026-09-23T17:28:20.943Z",
      "finished_at": null
    }
  ]
}

POST /assisted-columns/:id/cancel

Cancel a column run

Stops before the next lead, and fills no new leads. Values already written stay.

Request

curl -X POST https://craftnudge.com/api/assisted-columns/04485d1d-2dca-4ac1-938b-519cbf974303/cancel \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "id": "04485d1d-2dca-4ac1-938b-519cbf974303",
  "column": "company_clean",
  "preset": "clean_company",
  "status": "cancelled",
  "stop_reason": null,
  "total": 3,
  "done": 0,
  "failed": 0,
  "left": 0,
  "created_at": "2026-09-23T17:28:20.943Z",
  "finished_at": "2026-09-23T17:28:20.954Z"
}

AI assistants (MCP)

An AI assistant that speaks MCP can read this workspace with an API key. Add https://craftnudge.com/api/mcp to it as an HTTP server, with the header Authorization: Bearer and your key. It only reads: each tool is one of the GET routes on this page, answered for the key’s own workspace, and nothing sends or changes.

POST /mcp

The MCP endpoint

JSON-RPC 2.0 in a POST, with JSON back: initialize, ping, tools/list and tools/call, and no event stream. The tools are list_campaigns, get_campaign, campaign_stats, campaign_sending_status, list_campaign_leads, search_leads, lead_activity, list_replies, reply_thread, list_inboxes, inbox_scores and deliverability_fixes. A tool’s answer is its route’s JSON, with isError set when the route refused.

Request

curl -X POST https://craftnudge.com/api/mcp \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "campaign_sending_status",
    "arguments": {
      "campaign_id": "ac4a15a9-c904-4a19-bd50-99e453e5fb8d"
    }
  }
}'

Response, 200

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"sending\":false,\"reasons\":[{\"code\":\"outside_hours\",\"text\":\"It is outside the sending hours. It is Wednesday 19:02 in Europe/London, and it sends Mon, Tue, Wed, Thu, Fri 09:00 to 17:00.\"}],\"notes\":[{\"code\":\"inbox_stopped\",\"text\":\"sam@acme.example was paused for a high bounce rate. Test it on the Inboxes page to send again.\"}],\"sent_today\":0,\"daily_limit\":40,\"new_today\":0,\"new_leads_limit\":null,\"due_now\":3,\"next_due_at\":null,\"due_today\":{\"total\":0,\"follow_ups\":0,\"new_leads\":0}}"
      }
    ],
    "isError": false
  }
}

Workspace

The workspace’s settings and where notifications go.

GET /workspace

Read the workspace

Its name, timezone, re-contact window, unsubscribe wording, default from name and tracking domain, and the agency name, logo key and dashboard domain it is branded with.

Request

curl https://craftnudge.com/api/workspace \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "name": "Acme Outreach",
  "timezone": "Europe/London",
  "unsub_text": null,
  "unsub_why": null,
  "default_from_name": null,
  "assisted_facts": null,
  "recontact_days": 180,
  "system_mailbox_id": null,
  "tracking_domain": "links.acme.example",
  "tracking_domain_ok": true,
  "tracking_cname_target": "app.craftnudge.example",
  "delete_at": null,
  "brand_name": null,
  "brand_logo_key": null,
  "dashboard_domain": null
}

GET /workspace/activity

Recent changes

The latest 200 changes in the workspace, newest first: campaigns started and paused, settings, inboxes and their caps, team members, client report links and a request to delete the workspace. Each has its details, who made it and when. A change made with an API key has no person, so by is null.

Request

curl https://craftnudge.com/api/workspace/activity \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "entries": [
    {
      "action": "inbox.removed",
      "detail": {
        "email": "hello@tallpine.example"
      },
      "by": null,
      "at": "2026-09-14T22:17:33.686Z"
    },
    {
      "action": "inbox.added",
      "detail": {
        "email": "hello@tallpine.example",
        "daily_cap": 20
      },
      "by": null,
      "at": "2026-09-14T22:17:33.644Z"
    }
  ]
}

GET /workspace/onboarding

Setup checklist

The first steps a new workspace goes through, each done or not, and whether all of them are.

Request

curl https://craftnudge.com/api/workspace/onboarding \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "steps": [
    {
      "key": "inbox",
      "label": "Connect an inbox",
      "href": "/accounts",
      "done": true
    },
    {
      "key": "tracking_domain",
      "label": "Set up a tracking domain",
      "href": "/settings/workspace",
      "done": true
    }
  ],
  "complete": true
}

GET /settings

Lead and ramp settings

Whether leads are deduped across campaigns, the weekly ramp schedule, and the bounce rate that holds a ramp back.

Request

curl https://craftnudge.com/api/settings \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "dedupe_across_campaigns": true,
  "ramp_schedule": [
    5,
    10,
    12,
    15,
    20,
    25
  ],
  "ramp_bounce_limit": 5
}

PATCH /settings

Change lead and ramp settings

Any of dedupe_across_campaigns, ramp_schedule and ramp_bounce_limit. Any other field is refused.

Request

curl -X PATCH https://craftnudge.com/api/settings \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "ramp_bounce_limit": 4
}'

Response, 200

{
  "dedupe_across_campaigns": true,
  "ramp_schedule": [
    5,
    10,
    12,
    15,
    20,
    25
  ],
  "ramp_bounce_limit": 4
}

GET /verification/settings

Verification settings

The verifier the workspace checks addresses with, whether a key is saved, and how many addresses were checked this month. The key is never returned. An owner saves it in the app.

Request

curl https://craftnudge.com/api/verification/settings \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "provider": "reoon",
  "has_key": true,
  "checks_this_month": 9
}

GET /notifications

Notification settings

Email, Telegram and the webhook, each on or off, the webhook’s address and events, and how many people have linked Telegram.

Request

curl https://craftnudge.com/api/notifications \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "notify_email": "jordan@acme.example",
  "notify_email_on": true,
  "telegram_on": true,
  "telegram_available": true,
  "telegram_linked_as": null,
  "telegram_linked_count": 0,
  "webhook_url": "https://hooks.acme.example/craftnudge",
  "webhook_on": true,
  "webhook_events": [
    "reply",
    "interested",
    "bounce"
  ]
}

PATCH /notifications

Change notifications

Any of notify_email, notify_email_on, telegram_on, webhook_url, webhook_on and webhook_events. A webhook address must be on the public internet. Telegram is linked by a person, in the app.

Request

curl -X PATCH https://craftnudge.com/api/notifications \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "notify_email_on": true,
  "webhook_events": [
    "reply",
    "interested",
    "bounce"
  ]
}'

Response, 200

{
  "notify_email": "jordan@acme.example",
  "notify_email_on": true,
  "telegram_on": true,
  "telegram_available": true,
  "telegram_linked_as": null,
  "telegram_linked_count": 0,
  "webhook_url": "https://hooks.acme.example/craftnudge",
  "webhook_on": true,
  "webhook_events": [
    "reply",
    "interested",
    "bounce"
  ]
}

Webhooks and REST hooks

Webhook events has what each event sends and how to check a delivery.

GET /integrations/webhooks/events

The events

Every event, when it fires, and an example body.

Request

curl https://craftnudge.com/api/integrations/webhooks/events \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "event": "sent",
    "when": "An email left one of your inboxes. Announced within a minute of going out, once per email.",
    "example": {
      "event": "sent",
      "email": "owner@shop.example",
      "campaign": "Woo speed UK",
      "mailbox": "rae@yourdomain.example",
      "step": "1",
      "subject": "Your checkout on Shop Example",
      "sent_at": "2026-09-13T09:14:02.000Z"
    }
  },
  {
    "event": "reply",
    "when": "A human answered. Out-of-office and bounces are not replies.",
    "example": {
      "event": "reply",
      "domain": "shop.example",
      "person": "Rebecca",
      "text": "Sounds good, call me Thursday?",
      "campaign": "Woo speed UK",
      "mailbox": "rae@yourdomain.example",
      "reply_id": "0b5e8f7a-2c1d-4e3f-9a8b-7c6d5e4f3a2b",
      "link": "https://app.craftnudge.example/unibox?reply=0b5e8f7a-2c1d-4e3f-9a8b-7c6d5e4f3a2b"
    }
  },
  {
    "event": "bounce",
    "when": "The address rejected the email.",
    "example": {
      "event": "bounce",
      "email": "nobody@shop.example",
      "campaign": "Woo speed UK",
      "mailbox": "rae@yourdomain.example",
      "code": "5.1.1",
      "category": "bad_address",
      "link": "https://app.craftnudge.example/unibox?reply=0b5e8f7a-2c1d-4e3f-9a8b-7c6d5e4f3a2b"
    }
  },
  {
    "event": "unsubscribe",
    "when": "Someone used the unsubscribe link. They are on your blocklist from this moment.",
    "example": {
      "event": "unsubscribe",
      "email": "owner@shop.example",
      "campaign": "Woo speed UK"
    }
  },
  {
    "event": "interested",
    "when": "You marked a reply as interested. Sent each time it is marked, including again.",
    "example": {
      "event": "interested",
      "email": "owner@shop.example",
      "link": "https://app.craftnudge.example/unibox?reply=0b5e8f7a-2c1d-4e3f-9a8b-7c6d5e4f3a2b"
    }
  },
  {
    "event": "interest_changed",
    "when": "You moved a reply to a different status. Not sent when the status did not change.",
    "example": {
      "event": "interest_changed",
      "email": "owner@shop.example",
      "from": "lead",
      "to": "meeting_booked",
      "link": "https://app.craftnudge.example/unibox?reply=0b5e8f7a-2c1d-4e3f-9a8b-7c6d5e4f3a2b"
    }
  },
  {
    "event": "task_due",
    "when": "A lead reached a manual step, so a task is waiting for someone. Sent once per task.",
    "example": {
      "event": "task_due",
      "email": "owner@shop.example",
      "campaign": "Woo speed UK",
      "kind": "linkedin",
      "text": "Connect on LinkedIn and mention the checkout speed."
    }
  }
]

POST /hooks/subscribe

Subscribe a URL to an event

For Zapier and tools like it. The URL gets the same signed, retried deliveries as the workspace webhook. Subscribing the same URL twice returns the first subscription.

Request

curl -X POST https://craftnudge.com/api/hooks/subscribe \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "event": "reply",
  "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/"
}'

Response, 201

{
  "id": "8370d89c-0dc4-4a8b-b84a-0b408550954c",
  "event": "reply",
  "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/"
}

GET /hooks/sample/:event

A sample event

The documented example for an event, never real data.

Request

curl https://craftnudge.com/api/hooks/sample/reply \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "event": "reply",
    "domain": "shop.example",
    "person": "Rebecca",
    "text": "Sounds good, call me Thursday?",
    "campaign": "Woo speed UK",
    "mailbox": "rae@yourdomain.example",
    "reply_id": "0b5e8f7a-2c1d-4e3f-9a8b-7c6d5e4f3a2b",
    "link": "https://app.craftnudge.example/unibox?reply=0b5e8f7a-2c1d-4e3f-9a8b-7c6d5e4f3a2b"
  }
]

DELETE /hooks/subscribe/:id

Unsubscribe

Ends a subscription. A URL that answers 410 ends its own.

Request

curl -X DELETE https://craftnudge.com/api/hooks/subscribe/8370d89c-0dc4-4a8b-b84a-0b408550954c \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

{
  "deleted": true
}

GET /integrations/webhooks/deliveries

Recent deliveries

Deliveries to your webhook and REST hooks, with each one’s status, attempts, last answer or error, and when the next try is due.

Request

curl https://craftnudge.com/api/integrations/webhooks/deliveries \
  -H "Authorization: Bearer $CRAFTNUDGE_API_KEY"

Response, 200

[
  {
    "id": "6d7167e4-9f0c-4763-95c0-c3daeb23830c",
    "event": "interested",
    "status": "pending",
    "attempts": 1,
    "last_status": null,
    "last_error": "that webhook address is not on the public internet",
    "next_attempt_at": "2026-09-14T22:18:33.339Z",
    "delivered_at": null,
    "created_at": "2026-09-14T22:17:33.332Z",
    "to": "hooks.acme.example"
  }
]