Topolo Docs Topolo Platform
Open workspace

Enroll contacts in a campaign sequence

Enroll a reviewed audience into an active campaign and schedule its first sequence work.

Outcome

Enroll a reviewed audience into an active campaign and schedule its first sequence work.

Before you start

  • Verify the environment, credential, authorized organization and the published action permission workspace:write.
  • Discover an accessible Engage workspace and bind resourceType=workspace and its resourceId separately from action input. Fixture identifiers grant no access.
  • Approve the exact mutation and its effects before CLI --confirm or MCP confirm:true. This documentation does not authorize outreach.

Discover the target

  • Discover campaignId and confirm campaigns.get status=active. Read its sequence and published version; sequence must be active with published content.
  • Discover seatId through seats.list/seats.get in the same workspace and review mailbox readiness/health. The enrollment handler verifies seat existence; enrollment success alone does not prove the seat is ready to send.
  • Supply exactly one explicit contactIds batch or listId. Review each intended recipient, permissions, consent/contactability and published copy before approving this mutation; it can lead to external outreach.
topolo whoami --json
topolo actions get app_topolo_engage.enrollments.create --json
topolo workspaces list topolo-engage --json

Input fields

FieldRequiredMeaning and constraints
campaignIdYesRequired active campaign id discovered in the selected workspace, trimmed 1–128 characters.
seatIdYesRequired sending seat id discovered in the same workspace, trimmed 1–128 characters; review readiness separately.
contactIdsNoOptional non-empty array of up to 500 discovered contact ids, each trimmed 1–128 characters. Supply exclusively with no listId.
listIdNoOptional discovered active list id, trimmed 1–128 characters. Supply exclusively with no contactIds. At most 500 membership rows are selected, not every member of a larger list.

Validate, plan and execute

Set ENGAGE_WORKSPACE_ID to the identifier of your selected workspace. Prepare payload.json from the current input schema and discovered identifiers. Use the same payload for validation, planning and execution.

: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions validate app_topolo_engage.enrollments.create --data @payload.json --json
topolo actions plan app_topolo_engage.enrollments.create --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json

Review the plan and obtain approval for the exact effects before executing the unchanged request.

: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions call app_topolo_engage.enrollments.create --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json

For MCP, use topolo_get_action, topolo_validate_action, topolo_plan_action and topolo_call_action. Pass the selected resource context separately; confirm:true is required for mutations.

Worked requests and responses

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: inbound replies thread into the inbox > creates the thread on the first reply with an SLA, the message and its sender hash, and alerts the campaign creator. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_2",
  "contactIds": [
    "contact_2"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_2",
      "campaignId": "eng_cmp_2",
      "sequenceVersionId": "eng_seqv_2",
      "contactId": "contact_2",
      "seatId": "eng_seat_2",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: the run ledger > records a scheduled pass: the heartbeat and one row per workspace touched. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1",
    "contact_2",
    "contact_3"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    },
    {
      "id": "eng_enr_2",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_2",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    },
    {
      "id": "eng_enr_3",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_3",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: reply inbox actions > threads.list and threads.get > lists the queue with counts, overdue first, and filters it. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1",
    "contact_2",
    "contact_3",
    "contact_4"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    },
    {
      "id": "eng_enr_2",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_2",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    },
    {
      "id": "eng_enr_3",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_3",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    },
    {
      "id": "eng_enr_4",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_4",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: engagement analytics > reports first-touch rates by campaign: each contact once, bounced never replied, test campaigns excluded. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_2",
  "contactIds": [
    "contact_5"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_5",
      "campaignId": "eng_cmp_2",
      "sequenceVersionId": "eng_seqv_2",
      "contactId": "contact_5",
      "seatId": "eng_seat_2",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: engagement analytics > reports first-touch rates by campaign: each contact once, bounced never replied, test campaigns excluded. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_3",
  "seatId": "eng_seat_2",
  "contactIds": [
    "contact_6"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_6",
      "campaignId": "eng_cmp_3",
      "sequenceVersionId": "eng_seqv_3",
      "contactId": "contact_6",
      "seatId": "eng_seat_2",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: engagement analytics > reports first-touch rates by campaign: each contact once, bounced never replied, test campaigns excluded. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_2",
  "contactIds": [
    "contact_4",
    "contact_6"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_7",
      "campaignId": "eng_cmp_2",
      "sequenceVersionId": "eng_seqv_2",
      "contactId": "contact_4",
      "seatId": "eng_seat_2",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-07T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-07T08:30:00.000Z",
      "updatedAt": "2026-10-07T08:30:00.000Z"
    },
    {
      "id": "eng_enr_8",
      "campaignId": "eng_cmp_2",
      "sequenceVersionId": "eng_seqv_2",
      "contactId": "contact_6",
      "seatId": "eng_seat_2",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-07T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-07T08:30:00.000Z",
      "updatedAt": "2026-10-07T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: engagement analytics > reports first-touch rates by campaign: each contact once, bounced never replied, test campaigns excluded. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-09T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: the runner on a task step > not_interested stops the enrollment, records the negative outcome and a suppression ledger event. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1",
    "contact_2"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    },
    {
      "id": "eng_enr_2",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_2",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: the run ledger > records holds by reason. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_2"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_2",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_2",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: runDueSends > hold order > global cap first: holds once the workspace reached its daily cap, until the next day's window. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-07T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: the runner on a task step > never renders a task step ahead for approval. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_legacy",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_legacy",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: message approval > a message rendered after a change is approved under the new version; an unstored version falls back to the current one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_7"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_9",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_7",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-07T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-07T08:30:00.000Z",
      "updatedAt": "2026-10-07T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: engagement analytics > refreshes one enrollment, drops its fact when it was never sent, and never lets analytics stop a send. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-05T09:00:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-05T09:00:00.000Z",
      "updatedAt": "2026-10-05T09:00:00.000Z"
    }
  ],
  "skipped": []
}

Source test: render ahead for approval > queues a message due tomorrow today, sends it in the window once approved, and never renders twice. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_3"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_3",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_3",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: runner with a footer connected to Brand > follows a kit version change on the next render, resolving once per workspace per run. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1",
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: enrollment routes > enrolls eligible contacts, pins the published version and schedules the first message. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "listId": "list_1"
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_1",
      "campaignId": "eng_cmp_1",
      "sequenceVersionId": "eng_seqv_1",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": [
    {
      "contactId": "contact_2",
      "reason": "ineligible",
      "reasons": [
        "manual_block"
      ]
    },
    {
      "contactId": "contact_3",
      "reason": "contact_not_found"
    }
  ]
}

Source test: enrollment routes > enrolls a list and reports every contact it skipped and why. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 200

Action input

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [],
  "skipped": [
    {
      "contactId": "contact_1",
      "reason": "already_enrolled"
    }
  ]
}

Source test: enrollment routes > never puts one contact in two active sequences across the organization. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 201

Action input

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [
    {
      "id": "eng_enr_3",
      "campaignId": "eng_cmp_2",
      "sequenceVersionId": "eng_seqv_2",
      "contactId": "contact_1",
      "seatId": "eng_seat_1",
      "status": "active",
      "stopReason": null,
      "nextStepPosition": 1,
      "nextSendAt": "2026-10-06T08:30:00.000Z",
      "firstMessageId": null,
      "firstSubject": null,
      "outcome": null,
      "enrolledAt": "2026-10-06T08:30:00.000Z",
      "updatedAt": "2026-10-06T08:30:00.000Z"
    }
  ],
  "skipped": []
}

Source test: enrollment routes > never puts one contact in two active sequences across the organization. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 200

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "enrolled": [],
  "skipped": [
    {
      "contactId": "contact_1",
      "reason": "no_email_address"
    }
  ]
}

Source test: enrollment routes > skips contacts without an email address. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Effects and response meaning

  • Checks active contacts, email points, organization-wide active enrollment uniqueness and permanent eligibility reasons. Returns enrolled[] and skipped[]; skips include contact_not_found, no_email_address, already_enrolled or ineligible with reasons. Temporary sending holds may still prevent later delivery.
  • contactIds is deduplicated and capped at 500. listId selects at most 500 stored membership ids ordered by addedAt then contactId before the active-contact check; larger lists are not fully enrolled and archived contacts can occupy selected rows. No automatic pagination is offered.
  • Each enrolled contact pins the latest published sequence version and gets a first scheduled message, due after the first step delay. HTTP 201 means at least one enrolled; HTTP 200 can mean every contact was skipped. Neither proves rendering, approval, provider acceptance or delivery.
  • Enrollment/message insertion is batched per contact; the whole audience is not one transaction and an interrupted request may leave partial progress. No request-level idempotency key exists; the active-enrollment uniqueness guard is not permanent replay protection after an enrollment ends.

Verify the result

  • Compare enrolled and skipped contact ids with the reviewed audience and inspect every skip reason. Retain each enrolled[].id and sequenceVersionId.
  • Read enrollments.get and messages.list for each retained enrollment to verify active state and scheduled work. Do not trigger the runner or broaden the audience merely because some rows skipped.

Recover

After an uncertain response, read enrollments.list for the campaign and then exact enrollment/message state for the reviewed contact ids. The list is capped, so absence from it is not conclusive. Do not retry blindly: active rows may skip while ended rows can enroll again, and list membership may have changed. Stop only exactly identified unintended active enrollments through a separately approved enrollments.stop; it cannot recall messages already sent.

Refused responses

Captured enrollments.create · HTTP 404

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "listId": "eng_lst_x"
}

Actual local response

{
  "error": {
    "code": "not_found",
    "message": "List was not found in this workspace."
  }
}

Source test: enrollment routes > enrolls a list and reports every contact it skipped and why. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 409

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "error": {
    "code": "state_conflict",
    "message": "Activate the campaign before enrolling contacts."
  }
}

Source test: enrollment routes > requires an active campaign, a published sequence and a seat. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 422

Refused input; do not execute

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ],
  "listId": "x"
}

Actual local response

{
  "error": {
    "code": "invalid_action_input",
    "message": "Input for enrollments.create is invalid.",
    "details": [
      {
        "code": "custom",
        "path": [],
        "message": "Provide exactly one of contactIds or listId."
      }
    ]
  }
}

Source test: enrollment routes > requires an active campaign, a published sequence and a seat. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 404

Action input

{
  "campaignId": "eng_cmp_x",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "error": {
    "code": "not_found",
    "message": "Campaign was not found in this workspace."
  }
}

Source test: enrollment routes > requires an active campaign, a published sequence and a seat. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 409

Action input

{
  "campaignId": "eng_cmp_1",
  "seatId": "eng_seat_1",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "error": {
    "code": "state_conflict",
    "message": "Publish the campaign's sequence before enrolling contacts."
  }
}

Source test: enrollment routes > requires an active campaign, a published sequence and a seat. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 404

Action input

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_x",
  "contactIds": [
    "contact_1"
  ]
}

Actual local response

{
  "error": {
    "code": "not_found",
    "message": "Seat was not found in this workspace."
  }
}

Source test: enrollment routes > requires an active campaign, a published sequence and a seat. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Captured enrollments.create · HTTP 422

Refused input; do not execute

{
  "campaignId": "eng_cmp_2",
  "seatId": "eng_seat_1"
}

Actual local response

{
  "error": {
    "code": "invalid_action_input",
    "message": "Input for enrollments.create is invalid.",
    "details": [
      {
        "code": "custom",
        "path": [],
        "message": "Provide exactly one of contactIds or listId."
      }
    ]
  }
}

Source test: enrollment routes > requires an active campaign, a published sequence and a seat. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

Error recovery

  • HTTP 403 permission_denied: Stop and inspect the permission required by this action in the current credential. Do not broaden permissions or switch organization automatically.
  • HTTP 422 invalid_action_input: Validate the current strict input schema. Creation requires campaignId, seatId and exactly one of contactIds or listId; do not provide both or neither.
  • HTTP 404 not_found: Rediscover the exact campaign, seat, list or enrollment in the same authorized workspace. Inspect the refusal message to identify which lookup failed; do not substitute another organization or guessed resource id.
  • HTTP 409 state_conflict: Read the campaign, published sequence and enrollment state before planning again. Creation requires an active campaign and active sequence with a published version; stop requires an active enrollment. Do not activate a campaign or publish content automatically to bypass a refusal.

Evidence and limits

  • contracts/sending.mjs

  • apps/api/src/enrollments/enrollments-handlers.ts

  • apps/api/src/enrollments/enrollments-repository.ts

  • apps/api/src/enrollments/enrollments-domain.ts

  • apps/api/src/enrollments/messages-repository.ts

  • apps/api/src/audience/lists-repository.ts

  • apps/api/src/tasks/tasks-repository.ts

  • apps/api/src/sending/outreach-eligibility.ts

  • Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.

  • Widget/bootstrap use actual app handler but mocked worker runtime and app identity.

  • No gateway execution, live permissions, external delivery or deployed proof.

The examples are local route captures with synthetic context and simulated providers. They do not prove live authorization, CLI/MCP gateway execution, production state or delivery.