OpenSequence
DocsAPI referenceSDKs and CLIReference
Get started

Quickstart

From docker compose up to a first enrollment, a sent email and a reply, in six calls.

Edit on GitHub

You need Docker and curl. The compose file starts Postgres and one OpenSequence process with mock providers on: nothing reaches Google, Allo or Attio, sends land in the contact's timeline, and a hook lets you simulate a reply. The operator token is dev-admin-token.

Run it

git clone https://github.com/lguerberg/open-sequence && cd open-sequence
docker compose up
# in another shell
export SEQ=http://localhost:8080
curl $SEQ/readyz          # {"ok":true}

Create a project and its API key

The operator token creates the tenant and returns its first admin-scoped key. The key is shown once.

curl
curl -X POST $SEQ/v1/admin/projects \
  -H "Authorization: Bearer dev-admin-token" -H "Content-Type: application/json" \
  -d '{"slug":"acme","name":"Acme CRM"}'
# {"success":true,"data":{"project":{"id":"prj_…","slug":"acme",…},"api_key":"sk_live_…"},"meta":{"request_id":"req_…"}}
export KEY=sk_live_…

A sender, and connect their Google account

A sender is the acting user an enrollment runs as. In production the connect call returns a consent URL the sender opens once (Gmail and Calendar). With mock providers the mailbox is active at once.

curl
curl -X PUT $SEQ/v1/senders/u_42 -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Max Rivera","email":"max@acme.com","timezone":"America/New_York"}'
curl -X POST $SEQ/v1/senders/u_42/connections/google -H "Authorization: Bearer $KEY" -H "Content-Type: application/json"
# mock: {"success":true,"data":{"status":"active","mock":true,"channel":{"channel":"email","address":"max@acme.com",…}}}
# real: {"success":true,"data":{"auth_url":"https://accounts.google.com/o/oauth2/v2/auth?…","connection_id":"con_…"}}

A contact

PUT by your own id; attrs is free JSON that conditions and templates can read.

curl
curl -X PUT $SEQ/v1/contacts/p_7 -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"first_name":"Dana","email":"dana@example.com","timezone":"Europe/London","attrs":{"company":{"country":"UK"}}}'

A sequence: create, save the definition, publish

Creating a sequence makes a draft version v1. Save a definition into it, then publish: validation errors block a publish, warnings do not.

definition.json
{
  "schema_version": 1,
  "entry": "start",
  "settings": { "reenroll": "never" },
  "exit_on": ["reply", "meeting_booked"],
  "nodes": {
    "start": { "type": "start", "next": { "then": "email_intro" } },
    "email_intro": {
      "type": "outreach", "channel": "email", "action": "send",
      "params": {
        "subject": "Quick question, {{ contact.first_name | \"there\" }}",
        "body": "Hi {{ contact.first_name | \"there\" }}, from {{ sender.name }}.\n\nUnsubscribe: {{ unsubscribe_url }}"
      },
      "next": { "sent": "wait_reply", "skipped": "wait_reply", "failed": "end" }
    },
    "wait_reply": { "type": "wait_for", "params": { "events": ["reply", "meeting_booked"], "timeout": "3d" }, "next": { "reply": "end", "meeting_booked": "end", "timeout": "call" } },
    "call": { "type": "task", "params": { "kind": "call", "title": "Call {{ contact.first_name | \"them\" }}", "due": "1d", "dialer": true }, "next": { "completed": "end", "no_answer": "end" } },
    "end": { "type": "end" }
  }
}
curl
curl -X POST $SEQ/v1/sequences -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"name":"UK intro"}'
# {"success":true,"data":{"sequence":{"id":"seq_…"},"version":{"id":"ver_…","tag":"v1","status":"draft","lockVersion":0}}}
export SEQ_ID=seq_… VER_ID=ver_…
curl -X PUT $SEQ/v1/sequences/$SEQ_ID/versions/$VER_ID/draft -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{\"definition\": $(cat definition.json), \"lock_version\": 0}"
# {"success":true,"data":{"version":{…},"problems":[]}}
curl -X POST $SEQ/v1/sequences/$SEQ_ID/versions/$VER_ID/publish -H "Authorization: Bearer $KEY" -H "Content-Type: application/json"
# {"success":true,"data":{"version":{"status":"published",…},"problems":[]}}

Enroll

Enroll the contact as the sender. A contact can be in one active sequence per project; a second enroll of the same contact is a 409 ALREADY_IN_SEQUENCE and nobody is enrolled.

curl
curl -X POST $SEQ/v1/sequences/$SEQ_ID/enrollments -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"contacts":["p_7"],"sender":"u_42"}'
# {"success":true,"data":{"accepted":[{"contact_id":"ctc_…","external_id":"p_7","first_touch_at":"…"}],"skipped":[],"capacity":{"per_day":30,"days_to_first_touches":1},"enrollments":[{"id":"enr_…","status":"active",…}]}}
export ENR_ID=enr_…

Read the journey and the sent email

Within a few seconds the planner claims the enrollment and the email executor sends through the mock mailbox. The journey shows the runs, the message and the waiting step.

curl
curl $SEQ/v1/enrollments/$ENR_ID -H "Authorization: Bearer $KEY"
# data.enrollment.currentNodeId == "wait_reply"; data.runs: start done, email_intro done, wait_reply waiting
# data.messages[0]: {"direction":"out","subject":"Quick question, Dana","status":"sent","threadRef":"mock-thread:…"}
curl $SEQ/v1/contacts/p_7/timeline -H "Authorization: Bearer $KEY"

Simulate a reply, watch the exit

The mock hook accepts an inbound event. A reply in the thread the service started is matched to the enrollment, attributed to the email step, and the enrollment exits with replied because exit_on lists reply.

curl -X POST $SEQ/hooks/mock/acme -H "Content-Type: application/json" -d '{
  "kind": "reply", "channel": "email", "externalId": "evt_1", "occurredAt": "2026-10-13T10:00:00Z",
  "from": "dana@example.com", "to": "max@acme.com", "threadRef": "<threadRef from the sent message>"
}'
# {"ok":true,"stored":1,"ignored":0}
curl $SEQ/v1/enrollments/$ENR_ID -H "Authorization: Bearer $KEY"
# data.enrollment.status == "exited", exitReason == "replied"
curl $SEQ/v1/sequences/$SEQ_ID/analytics/summary -H "Authorization: Bearer $KEY"
# {"enrolled":1,"active":0,"exited":1,"touches_delivered":1,"replies":1,"reply_rate_per_100_enrolled":100,"replies_by_channel":{"email":1},…}
A POST with Content-Type: application/json and no body is accepted everywhere a body is optional (the connect and publish calls above), which is what curl and most SDKs send.

Where next

  • In production, register your own Google OAuth client before connecting senders: Connect Google.
  • Register a webhook endpoint instead of polling the journey: Receiving webhooks.
  • Tell the service what it cannot see (meetings booked elsewhere, do-not-contact): Contact events.
  • Every node type, with an example: Nodes.

On this page