Skip to main content

Overview

Use POST /v1/campaigns to create a draft campaign from the public steps format. This endpoint is designed for external integrations. You send a single ordered steps array and Enginy compiles it into the internal campaign graph.

Quick Start

  1. Start steps with the first action that should happen.
  2. Add each next action in the exact order it should run.
  3. Use condition to branch on lead data.
  4. Use linkedin_connection to send a connection request and then branch on acceptance.
  5. Put shared continuation steps after the branch instead of duplicating them inside both sides.

Rules

  • Build the campaign as an ordered steps array from first action to last action.
  • Use condition and linkedin_connection when you need branches.
  • Put shared follow-up work after the branching step instead of duplicating the same steps in both branches.
  • Use { "type": "end" } only when that branch should stop completely.
  • waitForAcceptance.unit must always be days.
  • waitForAcceptance.value must be an integer greater than or equal to 1.
  • Validation errors return full paths such as steps[0].onTrue[1].subject.

Step Types

email.subject is required for the first email on every live path; follow-up emails in the same thread can omit it. email.thread picks Reply or New thread for the step; see Email threading. linkedin_connection.waitForAcceptance is optional — omit it (and the branches) for a fire-and-forget connection request that just sends the invite and continues with whatever follows. add_to_another_campaign.campaignId must reference a campaign owned by the same client as the API key.

Email threading (Reply or New thread)

email.thread is the campaign editor’s Reply / New thread choice for an email step:
  • "reply" sends the email in the thread of the previous email, under its subject. The step needs no subject; if it has one, that subject is only used for a contact who reaches the step without an earlier email.
  • "new_thread" starts a separate thread under the step’s own subject, which is required unless the step uses aiMessageId.
  • Omitted: a follow-up without subject replies, and one with subject is sent under that subject. For aiMessageId follow-ups that means the generated subject, so set thread: "reply" to keep them in the thread.
GET /v1/campaign/{campaignId} returns thread on every email step that has an explicit choice. Send it back when you update the campaign to keep that choice.

AI messages on steps (aiMessageId)

email, linkedin_message, linkedin_inmail, and linkedin_connection steps accept aiMessageId instead of manual content. The linked AI message writes a personalized message per contact at send time (for email and linkedin_inmail it generates the subject too, so omit subject).
  • Reuse a public prompt-library entry: list them with GET /v1/prompt-library/ai-messages and pass the entry id.
  • Reuse a workspace AI message: list them with GET /v1/ai-variables/ai-messages.
  • Create a new AI message with full control over prompt, toneId, model, outputLength, and splitMessages: POST /v1/ai-variables/ai-messages (tones via GET /v1/ai-variables/ai-message-tones), then reference the returned id. model must be one of the ids the app’s model picker offers; send enginy-default unless a specific model is needed.
Rules:
  • The AI message channel must match the step type: EMAIL, LINKEDIN, LINKEDIN_INMAIL, or LINKEDIN_CONNECTION.
  • aiMessageId is mutually exclusive with manual content, subject, and attachment fields — the AI message replaces the step’s message entirely.
  • Each step receives its own copy of the AI message; campaign reads return the copy’s id.
  • Requires the AI variable split to be enabled for the workspace.

Personalization placeholders

Manual content/subject accepts single-brace placeholders that must reference real fields — discover them with GET /v1/contacts/fields. Examples: {firstName}, {companyName}, {identity.name}. Unknown placeholders are rejected with a 422. The company aliases {company}, {companyName}, and {companyDomain} are accepted and stored as the canonical company columns ({name}, {domain}).

linkedin_message_bundle

Sends several LinkedIn messages back-to-back in the same step. Each entry in messages can carry text, an attachment, or both:

linkedin_voice_message

Sends a LinkedIn voice message. You can either generate audio from text via text-to-speech (content + voiceId) or provide a pre-recorded audio attachment (attachment with contentType: "AUDIO"). Use GET /v1/voices to discover valid voiceId values (system voices and identity-cloned voices owned by the client). The step must satisfy one of: (a) both content and voiceId are provided so we synthesize speech, or (b) attachment is provided to send a pre-recorded audio file. voiceSettings only applies in mode (a). voiceSettings fields (all optional):

Branching Step Types

Condition Types

condition.type supports exactly these values: lead_field lets you branch on any built-in column or custom field. operator accepts: EQUALS, NOT_EQUALS, CONTAINS, NOT_CONTAINS, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUALS, LESS_THAN_OR_EQUALS, IS_EMPTY, IS_NOT_EMPTY. value is required for every operator except IS_EMPTY and IS_NOT_EMPTY.

Timed Conditions (waitFor)

email_opened, email_clicked, linkedin_message_viewed, task_completed, and connection_accepted require a waitFor object on the condition:
  • waitFor.value must be a positive number.
  • waitFor.unit must be one of seconds, minutes, hours, or days.
  • email_opened and email_clicked must be placed immediately after an email step.
  • linkedin_message_viewed must be placed immediately after a LinkedIn message step (linkedin_message, linkedin_message_bundle, linkedin_voice_message, or linkedin_inmail).
  • task_completed must be placed immediately after a task step.
  • connection_accepted checks whether the lead has accepted a LinkedIn connection request that was sent earlier in the campaign. Use this when you want to fan out work after the request without inlining the acceptance check on the linkedin_connection step itself.

Validation Checklist

  • steps must contain at least one step.
  • Every branch array you provide must contain at least one step.
  • Use { "type": "end" } if a branch should stop explicitly.
  • waitForAcceptance.unit must always be days.
  • waitForAcceptance.value must be a whole integer greater than or equal to 1.
  • If GET /v1/tasks/owners returns owners, every task step must include a valid ownerId.
  • Every linkedin_voice_message step must reference a voiceId returned by GET /v1/voices.
  • Every aiMessageId must reference a workspace AI message or a prompt-library entry whose channel matches the step type, and must not be combined with manual content/subject.
  • Placeholders in manual content must reference fields returned by GET /v1/contacts/fields (unknown placeholders are rejected).
  • The sequence must not loop: a step cannot connect back to an earlier step. A cyclic campaign is rejected instead of created (it could be stored before, but never ran — the sending engine requires an acyclic sequence).

Task Owners

If your campaign contains task steps, call GET /v1/tasks/owners first.
  • If that endpoint returns owners, every task step must include a valid ownerId.
  • If no owners are returned, ownerId can be omitted.

Senders

A campaign sends from one or more sender identities. Find their IDs with GET /v1/identities.
  • identityId sets a single sender. To send from several identities, pass identityIds with the primary sender first: the campaign spreads its contacts across them. When both fields are sent, identityId must equal identityIds[0].
  • More than one sender requires the Multi-identity campaigns beta on the workspace (a workspace admin enables it in Settings > Beta features). Without it the request fails with 400 and details.code: "MULTI_IDENTITY_CAMPAIGNS_NOT_ENABLED"; a single sender always works.
  • Every identity must belong to the workspace, must not be deleted, and must be one you may send as. Otherwise the request fails with 400 (404 when cloning) and names the identities that are not usable.
  • allowedEmails restricts the mailboxes of a single sender, so it cannot be combined with more than one sender identity.
  • Campaign reads (GET /v1/campaign/{campaignId}) return identityIds and senderIdentities with the senders that can still send, primary first; identityId and senderIdentity are the primary.

Voices

If your campaign contains linkedin_voice_message steps, call GET /v1/voices to discover the available voiceId values:
  • source: "system" voices are available to every client.
  • source: "identity" voices are cloned from one of the client’s identities.
  • A voiceId that is not in the list will fail validation when creating or validating the campaign.

Reading saved campaign content

GET /v1/campaign/{campaignId} and MCP get_a_single_campaign include manual content saved in node-owned templates. Emails return HTML bodies and saved subjects. LinkedIn bundles return their ordered messages and supported attachments. Connection steps return their saved notes. InMail returns its body and subject. Campaign-read permissions cover these reads. No AI-variable-read permission or AI-variable migration flag is required. AI-generated steps retain their aiMessageId; contact-specific values are generated at send time. Unavailable, foreign, deleted, malformed, or unsupported template bindings return 422 with a node-scoped reason. These errors contain no partial steps. Unsupported campaign graphs also return 422. Stored validate-draft checks the resolved saved content, including ACTIVE campaigns. Its 200 envelope reports resolution failures as validation errors. A valid stored campaign can still be ineligible for steps replacement.

Updating a Draft Campaign

Use PATCH /v1/campaign/{campaignId} to modify a campaign that is still in DRAFT status — both its settings (name, description, identity, tracking flags, …) and its step sequence.
  • Every field is optional; provide only what you want to change.
  • When steps is provided it replaces the whole stored sequence. Fetch the current steps with GET /v1/campaign/{campaignId}, modify them, and resubmit. The read-only stepId, connectionId, nextConnectionId, and advanced fields are accepted and ignored. Unchanged text retains its saved rich documents; changed email HTML regenerates editor documents with its links and formatting.
  • Reply follow-up emails may keep the subject: "" that GET returns; it means “reply in the existing thread”.
  • identityIds replaces the whole sender set (primary first), and contacts already added to the draft that have not been contacted yet are re-distributed across the new senders. A different identityId sent on its own replaces the set with that one sender. Echoing back the current senders — or only the current primary as identityId — leaves them unchanged. Changing to more than one sender (or to none) clears allowedEmails.
  • Keep the thread value GET returns on email steps. A step sent without it falls back to the omitted-thread behaviour described in Email threading.
  • Campaigns that are not in DRAFT status return 409 Conflict: clone them (POST /v1/campaign/{campaignId}/clone) and edit the clone instead. A draft that already has send history from a previous launch also returns 409 for step replacement.
Template-email conversion currently supports static bodies. A steps PATCH returns 422 with EMAIL_TEMPLATE_DYNAMIC_CONTENT_NOT_CONVERTIBLE if an original template-email body contains a dynamic reference. Replacement email bodies must also be static when converting template emails. This includes snippets, research, built-in fields, unknown placeholders, and rich-text mentions. The restriction applies even when removing or rewriting a referenced email. Rejection occurs before graph or collaboration-state writes. Settings-only PATCH remains available. GET retains dynamic tokens for reading and translation. Template sending fills references before rendering HTML; inline sending fills them afterwards. Converting dynamic template bodies would change how links and formatting in future values are rendered. Stored validation does not certify this conversion. For accepted static conversions, retain the returned HTML and thread choice. Explicit Reply steps may carry a saved fallback subject for contacts without an earlier email. Successful replacement stores inline snapshots; the old template binding is not retained. Concurrent editor changes are not protected by a revision token.

Stopping a Campaign

To stop a campaign sending, use PATCH /v1/campaign/{campaignId}/status with status: "PENDING". Note the asymmetry between the stored status and the user-facing one. GET /v1/campaign/{campaignId} and GET /v1/campaigns return the stored status, so a paused campaign reads back as PENDING there and neither response carries isPaused. Only GET /v1/campaign/{campaignId}/analytics maps it to the user-facing status the app shows, returning status: "PAUSED" with isPaused: true. Poll that endpoint if you want to confirm a pause the way the UI displays it. A launched campaign cannot be returned to DRAFT — the request is rejected with 400 — because DRAFT does not stop conversations that are already running. DRAFT is accepted only for campaigns that have never launched. To edit a launched campaign, clone it (POST /v1/campaign/{campaignId}/clone) and edit the clone. To stop outreach to a single contact rather than the whole campaign, use POST /v1/pause-contact-in-campaign.
  • POST /v1/campaigns creates the draft campaign.
  • PATCH /v1/campaign/{campaignId} updates a draft campaign’s settings and/or steps.
  • GET /v1/campaigns lists campaigns after creation.
  • GET /v1/tasks/owners returns the valid task owners for task steps.
  • GET /v1/voices returns the valid voices for linkedin_voice_message steps.
  • POST /v1/add-contact-to-campaign adds one contact to an existing campaign.
  • POST /v1/add-contact-group-to-campaign adds every contact in a contact group to an existing campaign.

Adding Contacts After Creation

Use the campaign creation endpoint to build the sequence first, then use one of the audience endpoints below to start adding contacts to it.
  • POST /v1/add-contact-to-campaign is for one contact at a time.
  • POST /v1/add-contact-group-to-campaign is for an entire contact group and returns counts for newly added, reactivated, already-present, and skipped contacts.

Example: Add a Contact Group to a Campaign

Example: Condition First

Example: LinkedIn Acceptance Branch

Example: LinkedIn Message Bundle

Send a short text message followed by a file attachment in the same LinkedIn step.

Example: Branch on Email Click

Follow up on LinkedIn only if the lead clicks the tracked email link within three days, otherwise bump via email.

Example: Task Completion Branch

Wait up to five days for a task to be marked complete; otherwise fall back to another channel.

Example: Explicitly Stop One Branch

Working days required to launch or resume

PATCH /v1/campaign/{campaignId}/status with status: "ACTIVE" checks the effective schedule of every live campaign sender before launching or resuming. A custom campaign schedule must contain at least one working day; an empty custom schedule remains authoritative even if the sender has hours. When the campaign follows sender schedules, every sender must have at least one working day. The route returns HTTP 400 without changing the campaign status when this check fails:
This validation also applies when changing campaign status through MCP. Pausing a campaign remains available. A valid custom campaign schedule can be used even if a sender has no identity working hours.