Skip to content

Workflows API ​

Create, publish and run automations over the API. A workflow is a small graph of steps — send an SMS, wait two days, check a condition, call your own system — that starts when something happens.

Everything the dashboard's workflow editor can do is available here, and both read and write the same JSON, so a workflow built in one can be managed from the other. For the no-code walkthrough see Building workflows.

Concepts ​

TermMeaning
DefinitionA JSON graph of nodes (steps) and edges (connections between them).
DraftThe editable copy of a definition. It can be saved in any state, valid or not.
VersionPublishing freezes the draft into an immutable, numbered version and makes it live.
RunOne pass of one trigger through one version. A run stays on the version it started on, even if you publish again.
SandboxA run that executes every step but sends nothing and charges nothing.

A workflow has a status: draft (never published), active, paused or archived. Only active workflows start runs.

Every endpoint on this page needs an API key, except the public workflow URL you post to. Workflow endpoints use two scopes:

ScopeGrants
MANAGE_WORKFLOWSCreate, edit, validate, publish, pause, resume and archive workflows; workflow URLs; secrets; the messaging policy
RUN_WORKFLOWSSend events, start runs and launches, cancel runs and launches

Reading workflows, runs and launches accepts either scope. FULL_ACCESS grants both. A sandbox key can only start sandbox runs and launches.

Requests are rate limited per key like the rest of the API; see Rate Limits.

Endpoints ​

All paths are under https://api.msgine.net/api/v1.

MethodPathScopePurpose
GET/developers/workflowseitherList workflows
POST/developers/workflowsMANAGE_WORKFLOWSCreate a workflow
GET/developers/workflows/:ideitherGet a workflow with its draft
PATCH/developers/workflows/:idMANAGE_WORKFLOWSRename, or replace the draft
DELETE/developers/workflows/:idMANAGE_WORKFLOWSArchive a workflow
POST/developers/workflows/:id/validateMANAGE_WORKFLOWSCheck the draft
POST/developers/workflows/:id/publishMANAGE_WORKFLOWSPublish the draft
POST/developers/workflows/:id/pauseMANAGE_WORKFLOWSStop new runs
POST/developers/workflows/:id/resumeMANAGE_WORKFLOWSResume a paused workflow
GET/developers/workflows/:id/versionsMANAGE_WORKFLOWSList published versions
POST/developers/workflows/:id/runsRUN_WORKFLOWSStart a manual run
GET/developers/workflows/:id/runseitherList runs
GET/developers/workflows/runs/:runIdeitherGet a run with its step history
POST/developers/workflows/runs/:runId/cancelRUN_WORKFLOWSCancel a run
POST/developers/eventsRUN_WORKFLOWSSend an event
POST/developers/workflows/:id/launchesRUN_WORKFLOWSLaunch an audience workflow
GET/developers/workflows/:id/launcheseitherList launches
GET/developers/workflows/launches/:launchIdeitherGet a launch
POST/developers/workflows/launches/:launchId/cancelRUN_WORKFLOWSCancel a launch
GET/developers/workflows/:id/hookMANAGE_WORKFLOWSWhether a workflow URL exists
POST/developers/workflows/:id/hookMANAGE_WORKFLOWSCreate the workflow URL
DELETE/developers/workflows/:id/hookMANAGE_WORKFLOWSDelete the workflow URL
GET/developers/workflows/secretsMANAGE_WORKFLOWSList secret names
PUT/developers/workflows/secrets/:nameMANAGE_WORKFLOWSCreate or replace a secret
DELETE/developers/workflows/secrets/:nameMANAGE_WORKFLOWSDelete a secret
GET/developers/workflows/messaging-policyMANAGE_WORKFLOWSRead quiet hours and daily cap
PUT/developers/workflows/messaging-policyMANAGE_WORKFLOWSUpdate them
GET/developers/workflows/node-typeseitherCatalog of step types
GET/developers/workflows/platform-eventseitherCatalog of MsGine events

"either" means MANAGE_WORKFLOWS or RUN_WORKFLOWS. Ids in paths must be UUIDs.

Errors ​

Errors use the standard shape:

json
{
  "statusCode": 409,
  "error": "conflict",
  "message": "Workflow is draft; only active workflows can be run"
}

Request bodies are validated strictly: an unknown property returns 400 with "message": "Validation failed" and a details array of reasons.

Workflows ​

Create a workflow ​

bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order follow-up",
    "description": "Thank customers for big orders",
    "definition": {
      "schemaVersion": 1,
      "nodes": [
        {
          "id": "start",
          "type": "trigger.event",
          "config": { "event": "order.paid", "filter": "trigger.amount > 50000" }
        },
        {
          "id": "thank_you",
          "type": "action.send_sms",
          "config": {
            "to": "{{ trigger.phone }}",
            "message": "Thanks {{ trigger.name }}, we received your order."
          }
        }
      ],
      "edges": [{ "from": "start", "output": "next", "to": "thank_you" }]
    }
  }'
ParameterTypeRequiredDescription
namestringYesMax 150 characters
descriptionstringNoMax 1000 characters
definitionobjectNoThe draft. Omit it to start with a single manual trigger. Saved as-is and checked only when you validate or publish; max 512 KB

Status Code: 201 Created

json
{
  "id": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
  "userId": "0f6c1a6e-7a3e-4b8e-8a43-0d3f2c9b1e22",
  "name": "Order follow-up",
  "description": "Thank customers for big orders",
  "kind": "workflow",
  "status": "draft",
  "draft": { "schemaVersion": 1, "nodes": ["..."], "edges": ["..."] },
  "activeVersionId": null,
  "createdAt": "2026-11-03T09:00:00.000Z",
  "updatedAt": "2026-11-03T09:00:00.000Z"
}

List workflows ​

GET /developers/workflows?page=1&limit=20&status=active
ParameterTypeDescription
pagenumberDefault 1
limitnumberDefault 20, max 100
statusstringdraft, active, paused or archived. Without it, archived workflows are left out

Returns { "data": [...], "total": 1, "page": 1, "pages": 1 }, newest changes first. Campaigns are managed through their own endpoints and do not appear here.

Get, update and archive ​

GET /developers/workflows/:id returns the workflow shown above.

PATCH /developers/workflows/:id takes any of name, description and definition (same limits as create). A definition replaces the whole draft; it does not affect the live version until you publish. Returns the workflow. Archived workflows cannot be changed (409).

DELETE /developers/workflows/:id archives the workflow: it stops starting runs, its in-progress runs are cancelled, and its history is kept. Returns 204.

Validate ​

bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows/WORKFLOW_ID/validate \
  -H "X-Api-Key: YOUR_API_KEY"

Status Code: 200 OK

json
{
  "valid": false,
  "errors": [
    { "nodeId": "thank_you", "message": "definition must have required property 'message'" },
    { "message": "A workflow needs exactly one trigger, found 0" }
  ]
}

Each error has a message and, when it concerns one step, that step's nodeId. Validation is exactly what publishing runs, so a draft that returns "valid": true here publishes.

Publish ​

bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows/WORKFLOW_ID/publish \
  -H "X-Api-Key: YOUR_API_KEY"

Validates the draft, freezes it into a new version (numbered from 1) and makes it live. Runs already in progress finish on the version they started with. Returns the workflow with "status": "active" and the new activeVersionId.

An invalid definition returns 400:

json
{
  "statusCode": 400,
  "error": "validation_error",
  "message": "Workflow definition is not valid",
  "errors": [
    { "nodeId": "thank_you", "message": "definition must have required property 'message'" }
  ]
}

errors has the same form as validate returns.

Publishing also returns 400 when a group the workflow uses is not one of your groups, or when a trigger.date workflow names a field that is not one of your date contact fields.

Pause, resume and versions ​

POST .../pause stops new runs at once. Runs in progress hold where they are until you resume. Only an active workflow can be paused (409 otherwise).

POST .../resume makes a paused workflow live again on the same version (409 otherwise). Occurrences of a recurring workflow that were missed while it was paused are skipped, not caught up.

GET .../versions lists published versions, newest first:

json
[
  {
    "id": "6d0b9f0e-0f2b-4f43-b5d5-0d6d2c0f9f10",
    "version": 2,
    "publishedByUserId": "0f6c1a6e-7a3e-4b8e-8a43-0d3f2c9b1e22",
    "publishedViaApiKeyId": "a8e1b0a2-5b2f-4a5e-8a53-6b1b9c9f5e77",
    "createdAt": "2026-11-03T09:05:00.000Z"
  }
]

publishedViaApiKeyId is null for versions published from the dashboard.

The definition format ​

json
{
  "schemaVersion": 1,
  "settings": {
    "messageType": "marketing",
    "reentry": { "mode": "never" },
    "suppressGroupIds": [],
    "goals": []
  },
  "nodes": [
    { "id": "start", "type": "trigger.event", "name": "Order paid", "config": { "event": "order.paid" } }
  ],
  "edges": []
}
FieldTypeRequiredDescription
schemaVersionnumberYesAlways 1
nodesarrayYesThe steps. Max 500
edgesarrayYesThe connections. Max 1000
settingsobjectNoJourney rules; see Journeys

Unknown properties anywhere in the definition fail validation.

Nodes ​

FieldTypeRequiredDescription
idstringYesUnique in the workflow. Lower case letter first, then lower case letters, digits or _; max 63 characters (^[a-z][a-z0-9_]{0,62}$). Later steps read this step's result as steps.<id>
typestringYesA step type, e.g. action.send_sms
namestringNoDisplay name, max 150 characters
configobjectYesThe step's settings; {} when it has none
uiobjectNoCanvas data such as position. The engine ignores it

Edges ​

An edge connects one output of a step to the next step.

FieldTypeDescription
fromstringId of the step the edge leaves
outputstringWhich output of that step: next for most steps, yes/no for a condition, and so on (see Step types)
tostringId of the step it leads to

Rules checked when you validate or publish:

  • Exactly one trigger, and nothing can lead into it.
  • An output can be connected only once. Branching means using different outputs.
  • Every step must be reachable from the trigger.
  • No loops.
  • A run ends when a step's chosen output has no edge.

Limits ​

LimitValue
Steps per workflow500
Connections per workflow1000
Steps executed in one run1000; a run that goes past it is stopped as runaway
Attempts per step5
Longest wait365 days
Expression length2000 characters
Template length10000 characters
Trigger input per run64 KB
Stored result per step16 KB
Goals per workflow10
Suppression groups per workflow50
Re-entry window (afterDays)1 to 3650 days

Step types ​

Fetch the live catalog, including each step's full JSON Schema, with GET /developers/workflows/node-types. The summary below lists each step's type and its config. Fields marked * are required.

A workflow has exactly one trigger. Steps with a Template field accept {{ expression }} placeholders; fields marked Expression take a bare expression. See Expressions.

Triggers ​

All triggers have the single output next.

trigger.manual — Started manually or by API ​

Runs when you call POST /developers/workflows/:id/runs or press Run now on the dashboard. The request's input becomes trigger. No config.

trigger.event — When an event is received ​

Runs for every matching event sent to POST /developers/events. The event's payload becomes trigger.

ConfigTypeDescription
event *stringEvent name, matched exactly. ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$
filterExpressionStart only when it is true, e.g. trigger.amount > 50000

trigger.platform_event — When something happens in MsGine ​

Runs when MsGine itself records an event, such as a message delivered or a contact added to a group.

ConfigTypeDescription
event *stringOne of the platform event names
filterExpressione.g. trigger.groupId == '9c2e…'

trigger.audience — Send to an audience ​

Runs once for every contact in the chosen groups when you launch the workflow. Publishing alone sends nothing.

ConfigTypeDescription
groupIds *string[]Group ids (UUIDs), 1 to 20, no duplicates

trigger.schedule — On a schedule ​

Runs for every contact in the chosen groups each day, week or month at a set time, in your account's timezone (see Messaging policy).

ConfigTypeDescription
groupIds *string[]Group ids, 1 to 20
frequency *stringdaily, weekly or monthly
time *stringHH:MM, 24-hour
daysOfWeeknumber[]Required for weekly: 0 (Sunday) to 6 (Saturday)
dayOfMonthnumberRequired for monthly: 1 to 31. 29 to 31 fall on the last day of shorter months

trigger.date — On a contact's date ​

Runs for each contact whose date field falls on the day you choose, for example 3 days before due_date or on a birthday. Checked once a day at time.

ConfigTypeDescription
field *stringKey of one of your date contact fields
offsetDays *numberWhole days from -365 to 365. Negative is before the date: -3 is three days before
time *stringHH:MM, 24-hour
yearlybooleanMatch month and day every year, for birthdays and anniversaries
groupIdsstring[]Only contacts in these groups; everyone with the field when omitted or empty

trigger.webhook — When a form or app posts to a URL ​

Runs each time something posts to the workflow's secret Workflow URL. The posted fields become trigger.

ConfigTypeDescription
phoneExpressionWhere the contact's phone is in the posted fields, e.g. trigger.phone
nameExpressionThe contact's name; used only when creating a contact
createContactbooleanAdd the phone as a new contact when none has it
filterExpressionStart only when true, e.g. trigger.form == "signup"

phone is required if you set name or createContact.

Actions ​

Sending steps (send_sms, send_whatsapp, send_email, send_push) share behaviour: a retry never sends twice; the account's messaging policy applies to marketing workflows; and a recipient who has opted out skips the message without failing the run. Their only output is next, and the step's result is available to later steps as steps.<id>.

action.send_sms — Send SMS ​

Charged like any other SMS.

ConfigTypeDescription
to *TemplatePhone number(s), comma-separated, e.g. {{ contact.phone }}
message *TemplateMessage text
fromstringSender ID, max 11 characters

Result: messageId, status, cost, recipients.

action.send_whatsapp — Send WhatsApp ​

Outside the 24-hour customer-service window Meta requires an approved template. A message Meta refuses, or later reports undeliverable, is refunded.

ConfigTypeDescription
to *TemplateWhatsApp number(s)
messageTemplateMessage text; required unless templateId is set
messageTypestringMeta pricing category: marketing (default), utility, authentication or service
templateIdstringAn approved Meta template's name, max 512 characters
templateLanguagestringThe template's language code, e.g. en (default) or en_US
templateParamsTemplate[]Values for the template's {{1}}, {{2}}, … in order, up to 20

Result: messageId, status, cost, recipients.

action.send_email — Send email ​

ConfigTypeDescription
from *stringAn address on a verified domain, e.g. Shop <hello@shop.ug>. Max 320 characters. Not a template
to *TemplateAddress(es), comma-separated, e.g. {{ contact.email }}
subject *TemplateSubject line
textTemplatePlain text body
htmlTemplateHTML body

Give text, html or both. Result: emailId, status, cost, recipients.

action.send_push — Send push notification ​

Sends to your app's users by the user ids you registered their devices under.

ConfigTypeDescription
externalUserIds *TemplateComma-separated, e.g. {{ trigger.userId }}
title *TemplateNotification title
body *TemplateNotification body

Result: notificationId, status, recipientCount.

action.update_contact — Update contact ​

Sets the run's contact's name or email, for example from a form the workflow received. Needs a contact on the run. At least one of the two is required.

ConfigTypeDescription
nameTemplateNew name
emailTemplateNew email address

Output next. Result: updated (the keys changed).

action.add_to_group — Add to group ​

Adds the run's contact to a group. Workflows triggered by "contact added to group" start for them. Needs a contact on the run.

ConfigTypeDescription
groupId *stringGroup id (UUID)

action.remove_from_group — Remove from group ​

Takes the run's contact out of a group, if they are in it. Same config as add_to_group. Result: groupId, removed.

action.http_request — HTTP request ​

Calls an external URL. Every request carries an Idempotency-Key header that stays the same across retries, and a User-Agent of MsGine-Workflows/1.0.

ConfigTypeDescription
method *stringGET, POST, PUT, PATCH or DELETE
url *Templatehttp(s) URL, max 2048 characters. Must resolve to a public address; redirects are not followed
headersobjectUp to 20 name/value pairs; values are templates, max 4096 characters. Host, Content-Length, Idempotency-Key, Transfer-Encoding and Connection are set by MsGine and rejected
bodyTemplateRequest body, max 64 KB. Content-Type defaults to application/json
timeoutSecondsnumber1 to 10 (default 10)

Outputs: success when the response status is below 300, failed for other 4xx and for 3xx. Statuses 429 and 5xx and network errors are retried with backoff. Result: status and body (parsed JSON when the response is JSON, otherwise text truncated at 8 KB). The request itself is never stored.

Only this step can read secrets: {{ secrets.NAME }}.

Logic and timing ​

logic.condition — Condition ​

Leaves by yes or no depending on an expression.

ConfigTypeDescription
expression *ExpressionMust evaluate to true or false, e.g. trigger.plan == 'pro' && steps.lookup.score > 70

Outputs: yes, no. An expression that errors (for example a missing field) or does not return a boolean fails the step instead of silently taking no. Result: matched.

logic.wait — Wait ​

Pauses the run for a fixed time.

ConfigTypeDescription
amount *numberWhole number, at least 1
unit *stringminutes, hours or days

Output next. The total cannot exceed 365 days.

logic.wait_until — Wait until a time ​

Continues at the next set time of day, optionally only on some weekdays, in your account's timezone.

ConfigTypeDescription
time *stringHH:MM, 24-hour
daysOfWeeknumber[]0 (Sunday) to 6 (Saturday). Any day when omitted or empty

Output next. Result: until (ISO 8601).

logic.wait_for_event — Wait for event ​

Waits until an event happens for this run's contact, or the timeout passes. The run needs a contact (start it with contactId or phone), otherwise the step fails.

ConfigTypeDescription
source *stringplatform (recorded by MsGine) or custom (sent to POST /developers/events)
event *stringEvent name, max 100 characters. For platform, a platform event
filterExpressionOnly a matching event resumes the run. trigger is the arriving event's data
timeout *object{ "amount": <whole number ≥ 1>, "unit": "minutes" | "hours" | "days" }, at most 365 days

Outputs: received, timeout.

logic.split — Split (A/B) ​

Sends each contact down one branch by percentage, for example 50/50 to compare two messages. The branch is chosen from the run id, so a retried step takes the same branch.

ConfigTypeDescription
branches *array2 to 5 items of { "output": string, "percent": number }. Names follow the node id pattern and must differ; each percent is 1 to 99 and they must add up to 100

The step's outputs are the branch names you choose. Result: branch.

logic.exit — End ​

Ends the run. No config and no outputs.

Expressions ​

Conditions, filters and {{ }} placeholders are written in CEL, a small expression language. It is not JavaScript: it cannot loop forever, call functions you did not get from it, or reach anything outside the variables listed below. Regular expressions use RE2.

There are two places expressions appear:

  • Templates (message text, recipients, URLs, headers, bodies) are ordinary text with {{ expression }} placeholders: Hi {{ contact.name }}, order {{ trigger.orderId }} is ready.
  • Expression fields (a condition's expression, a trigger's filter, phone and name on the webhook trigger, goal and wait filters) take one bare expression with no braces: trigger.amount > 50000.

If any placeholder in a template fails, for example it names a field that does not exist, the whole step fails instead of sending a half-filled message. Test for optional fields with has(trigger.email).

Variables ​

VariableContents
triggerWhat started the run: the request input of a manual run, an event's payload, the posted fields of a workflow URL, or the fields of a platform event (plus trigger.event, see below)
contactThe run's contact as it is now, or null when the run has none: id, name, phone, email and fields (custom fields by key, typed: contact.fields.due_date)
stepsResults of steps that have run, by step id: steps.thank_you.messageId
runid, startedAt and sandbox
workflowid and the running version
secretsOnly inside an action.http_request step: {{ secrets.NAME }}

Trigger filters, goal filters and wait filters are evaluated before a run exists, so they see only trigger (contact is null and steps is empty).

For runs started by a platform event, trigger.event holds id, type, subjectId and occurredAt.

Examples ​

trigger.amount > 50000
trigger.name == 'Amina' && 'vip' in trigger.tags
trigger.address.city.startsWith('Kam')
has(trigger.email)
size(trigger.tags)
contact.fields.balance > 100000
steps.lookup.score >= 80
"balance" in trigger.changed

Reading a field that is not there is an error, not false. In a trigger filter that means "does not match".

Run a workflow manually ​

Only workflows whose trigger is trigger.manual can be started this way.

bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows/WORKFLOW_ID/runs \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": { "phone": "+256701234567", "name": "Amina" },
    "phone": "+256701234567",
    "idempotencyKey": "signup-1042"
  }'
ParameterTypeRequiredDescription
inputobjectNoBecomes trigger. Max 64 KB
contactIdstringNoUUID of the contact this run is about
phonestringNoIdentifies the contact by phone instead; exact match, max 20 characters
sandboxbooleanNoSend nothing and charge nothing. Always on for sandbox keys
idempotencyKeystringNoMax 100 characters. Retrying with the same key returns the original run

Giving a contact switches on the journey rules. A contactId or phone that is not one of your contacts returns 404.

Status Code: 201 Created, with the run:

json
{
  "id": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31",
  "userId": "0f6c1a6e-7a3e-4b8e-8a43-0d3f2c9b1e22",
  "workflowId": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
  "versionId": "6d0b9f0e-0f2b-4f43-b5d5-0d6d2c0f9f10",
  "status": "running",
  "sandbox": false,
  "contactId": "d2b8a7c4-3c0a-4c68-bb59-3b8e6c0e8f55",
  "exitReason": null,
  "input": { "phone": "+256701234567", "name": "Amina" },
  "steps": {},
  "stepCount": 0,
  "currentNodeId": "start",
  "error": null,
  "idempotencyKey": "signup-1042",
  "endedAt": null,
  "createdAt": "2026-11-03T09:10:00.000Z",
  "updatedAt": "2026-11-03T09:10:00.000Z"
}

Errors:

StatusWhen
404The workflow does not exist, or contactId/phone is not one of your contacts
409The workflow is not active; its trigger is not trigger.manual; or the contact was not let in (below)

When the contact was not let in, the message says why:

json
{
  "statusCode": 409,
  "error": "conflict",
  "message": "The contact was not let in: they are already in this workflow",
  "reason": "already_active"
}

reason is one of suppressed, already_active, already_entered, reentry_window.

The reasons are: they are in a suppression group; they are already in this workflow; they have been through it before and re-entry is off; they went through it too recently to re-enter.

Send an event ​

Starts a run in every active workflow whose trigger.event matches the name and filter.

bash
curl -X POST https://api.msgine.net/api/v1/developers/events \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order.paid",
    "payload": {
      "orderId": "A-1042",
      "phone": "+256701234567",
      "name": "Amina",
      "amount": 85000
    },
    "idempotencyKey": "order-A-1042-paid"
  }'
ParameterTypeRequiredDescription
namestringYesEvent name: ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$
payloadobjectNoBecomes trigger. Max 64 KB
contactIdstringNoUUID of the contact this event is about
phonestringNoIdentifies the contact by phone instead; exact match, max 20 characters
idempotencyKeystringNoMax 100 characters. Sending the same event with the same key starts no new runs

Giving a contact switches on the journey rules, and lets the event resume the contact's wait for event steps and end runs whose goal it satisfies. A contactId or phone that is not one of your contacts returns 404.

A phone or contactId at the top level identifies a contact. A phone inside payload is only data for your templates.

Status Code: 202 Accepted

json
{
  "event": "order.paid",
  "runs": [
    {
      "workflowId": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
      "runId": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31",
      "status": "running"
    }
  ],
  "skipped": [],
  "exited": 0,
  "resumed": 0
}
FieldDescription
runsThe runs started. A repeated idempotencyKey lists the original runs
skippedWorkflows the contact was not let into: { "workflowId", "reason" } with reason one of suppressed, already_active, already_entered, reentry_window
exitedRuns of this contact ended because the event met a goal
resumedRuns of this contact resumed from a wait for event step

All runs start or none do. An event that matches no workflow returns an empty runs list, not an error.

Runs ​

List runs ​

GET /developers/workflows/:id/runs?page=1&limit=20&status=completed
ParameterTypeDescription
pagenumberDefault 1
limitnumberDefault 20, max 100
statusstringrunning, waiting (asleep until its next step is due), completed, failed or cancelled

Returns { "data": [...runs], "total", "page", "pages" }, newest first.

Get a run ​

GET /developers/workflows/runs/:runId returns the run with a history array of every attempt of every step:

json
{
  "id": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31",
  "status": "completed",
  "sandbox": true,
  "stepCount": 2,
  "steps": {
    "start": {},
    "thank_you": {
      "messageId": "1f0c2d4e-6b0a-4c1e-9d36-2f8e1b7a5c90",
      "status": "sent",
      "cost": 0,
      "recipients": ["+256701234567"]
    }
  },
  "exitReason": null,
  "error": null,
  "history": [
    {
      "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
      "runId": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31",
      "nodeId": "start",
      "nodeType": "trigger.event",
      "attempt": 1,
      "status": "succeeded",
      "output": "next",
      "result": {},
      "error": null,
      "durationMs": 4,
      "createdAt": "2026-11-03T09:10:01.000Z"
    }
  ]
}

(Other run fields omitted; they match the run shown above.) exitReason is set when a run ended early, for example goal:order.paid. error holds why a run failed. Each history item's status is succeeded or failed; a failed attempt may be retried, up to 5 attempts per step.

Cancel a run ​

POST /developers/workflows/runs/:runId/cancel ends a running or waiting run and returns it with its history (200). A run that has already ended returns 409.

Journeys ​

When a run has a contact, three kinds of rules apply. They are set in the definition's settings. Runs without a contact (a manual run with no phone, a trigger.event with no contact) are never limited by them.

json
{
  "settings": {
    "messageType": "transactional",
    "reentry": { "mode": "after", "afterDays": 30 },
    "suppressGroupIds": ["8f6e0c1a-5d2b-4f0e-9a41-7c2d3b4a5e60"],
    "goals": [
      { "source": "custom", "event": "order.paid", "filter": "trigger.amount > 0" }
    ]
  }
}
SettingDescription
messageTypemarketing (default) or transactional. See Messaging policy
reentry.modenever (default), always or after. Whether a contact may go through the workflow again
reentry.afterDaysRequired with after, and only allowed with it. Whole days, 1 to 3650
suppressGroupIdsUp to 50 group ids. Contacts in any of these groups never enter
goalsUp to 10 { source, event, filter? }. When one of these events happens to the contact, their run ends at once as converted. source is platform or custom; filter is an expression
  • A contact is never in the same workflow twice at once, whatever reentry says.
  • reentry defaults to never, except for workflows that start with trigger.schedule or trigger.date, which default to always because they exist to reach the same contacts again.
  • Events about a contact are checked against goals first, then waits, then triggers, so an event that meets a goal ends the run instead of resuming it.

Launch an audience workflow ​

A workflow that starts with trigger.audience sends when you launch it. The launch walks the groups in batches of 500 contacts, starting one run per contact; entry rules, suppression groups, opt-outs and the messaging policy apply to each. A launch is pinned to the version live when you launched.

bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows/WORKFLOW_ID/launches \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "at": "2026-12-01T08:00:00Z", "sandbox": true }'
ParameterTypeRequiredDescription
atstringNoISO 8601 start time, at most a year ahead. Omit to start now
sandboxbooleanNoSend nothing and charge nothing. Always on for sandbox keys

Status Code: 201 Created

json
{
  "id": "e5d4c3b2-a190-4f8e-8d7c-6b5a49382716",
  "userId": "0f6c1a6e-7a3e-4b8e-8a43-0d3f2c9b1e22",
  "workflowId": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
  "versionId": "6d0b9f0e-0f2b-4f43-b5d5-0d6d2c0f9f10",
  "status": "scheduled",
  "sandbox": true,
  "runAt": "2026-12-01T08:00:00.000Z",
  "occurrenceAt": null,
  "matchDate": null,
  "cursor": null,
  "attempts": 0,
  "lockedUntil": null,
  "entered": 0,
  "skipped": 0,
  "completedAt": null,
  "createdAt": "2026-11-03T09:20:00.000Z",
  "updatedAt": "2026-11-03T09:20:00.000Z"
}

status is scheduled, running (contacts are being let in), completed (everyone has been offered entry) or cancelled. entered counts contacts who started a run; skipped counts contacts refused by the entry rules.

EndpointDescription
GET /developers/workflows/:id/launchesLaunches of a workflow, newest first (an array)
GET /developers/workflows/launches/:launchIdOne launch
POST /developers/workflows/launches/:launchId/cancelStop letting contacts in. Contacts already inside carry on; archive the workflow to stop them too. Returns the launch; 409 if it is already completed or cancelled

Launching returns 409 when the workflow is not active or does not start with trigger.audience, and 400 when at is more than a year ahead. trigger.schedule and trigger.date workflows create their own launches on schedule; you do not launch them.

Messaging policy ​

Account-wide rules for marketing workflows. Workflows with "messageType": "transactional" ignore both rules, so receipts and codes never wait or get dropped. Sandbox runs do not use up a contact's daily allowance.

bash
curl -X PUT https://api.msgine.net/api/v1/developers/workflows/messaging-policy \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "timezone": "Africa/Kampala",
    "quietHoursStart": "21:00",
    "quietHoursEnd": "08:00",
    "dailyMarketingCapPerContact": 2
  }'
ParameterTypeDescription
timezonestringIANA timezone, max 64 characters. Default Africa/Kampala. Quiet hours, the daily cap's "day", and recurring workflows (trigger.schedule, trigger.date, logic.wait_until) all use it
quietHoursStartstring or nullHH:MM, 24-hour. A marketing send that falls in quiet hours waits until they end. null clears
quietHoursEndstring or nullHH:MM. The window may cross midnight, as above
dailyMarketingCapPerContactnumber or nullWhole number 0 to 100: the most marketing messages one contact receives per day across all workflows. Further sends are skipped. null clears

Set both quiet-hour values or neither, otherwise the request returns 400, as does an unknown timezone. Changing the timezone re-plans your recurring workflows. A quiet-hours deferral spends no attempt and records no step; the step simply runs again when quiet hours end. A message skipped by the cap appears in the step result as { "skipped": "frequency_cap" } and the run continues.

Status Code: 200 OK (also the response of GET)

json
{
  "userId": "0f6c1a6e-7a3e-4b8e-8a43-0d3f2c9b1e22",
  "timezone": "Africa/Kampala",
  "quietHoursStart": "21:00",
  "quietHoursEnd": "08:00",
  "dailyMarketingCapPerContact": 2,
  "createdAt": "2026-11-03T09:25:00.000Z",
  "updatedAt": "2026-11-03T09:25:00.000Z"
}

Before you have saved a policy, GET returns the defaults: timezone Africa/Kampala, no quiet hours and no cap (the three values null).

Opted-out recipients ​

Every send checks the account's opt-out list before charging. Inside a workflow, a recipient who has opted out does not fail the run: the step records { "skipped": "opted_out" } and the run continues to the next step.

Secrets ​

Secrets hold credentials for HTTP steps. They are encrypted at rest and write-only: the API never returns a value.

bash
curl -X PUT https://api.msgine.net/api/v1/developers/workflows/secrets/CRM_TOKEN \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": "sk_live_..." }'
ParameterTypeRequiredDescription
valuestringYes1 to 4096 characters

The name in the path is UPPER_SNAKE_CASE: a capital letter, then capitals, digits or _, up to 64 characters. Anything else returns 400, as does creating a 51st secret (the limit is 50 per account). Setting an existing name replaces its value. Returns { "name": "CRM_TOKEN" }.

GET /developers/workflows/secrets lists names and timestamps:

json
[
  {
    "name": "CRM_TOKEN",
    "createdAt": "2026-11-03T09:30:00.000Z",
    "updatedAt": "2026-11-03T09:30:00.000Z"
  }
]

DELETE /developers/workflows/secrets/:name returns 204, or 404 if there is no such secret.

Use a secret in an HTTP step's headers or body, for example Authorization: Bearer {{ secrets.CRM_TOKEN }}. The variable exists nowhere else, so it cannot end up in a message or a stored step result.

Workflow URLs ​

A workflow that starts with trigger.webhook can be started by anything that can send an HTTP POST (a website form, a shop or payment callback, Zapier) with no API key and no code. For a walkthrough see Workflow URLs.

Manage the URL ​

MethodPathResult
POST/developers/workflows/:id/hookCreates the URL (201)
GET/developers/workflows/:id/hook{ "createdAt": ... } if a URL exists, an empty response if not
DELETE/developers/workflows/:id/hookDeletes it (204)
bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows/WORKFLOW_ID/hook \
  -H "X-Api-Key: YOUR_API_KEY"
json
{
  "url": "https://api.msgine.net/api/v1/hooks/wfh_Qm9yZGVyLWZvcm0tZXhhbXBsZS10b2tlbi0wMDAxMjM",
  "createdAt": "2026-11-03T09:40:00.000Z"
}

Save the URL now

The URL is shown once. MsGine stores only a hash of the token, so it cannot be shown again. Creating a URL again retires the old one immediately and returns a new one.

Use the URL exactly as returned. The token is the credential, so treat it like a password. The workflow must be published and active for posts to start it.

Post to the URL ​

bash
curl -X POST "https://api.msgine.net/api/v1/hooks/wfh_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-1042" \
  -d '{ "phone": "0701234567", "name": "Amina", "form": "signup" }'
  • No X-Api-Key. The endpoint is public; the token in the path is the only credential.
  • The body is a JSON object or ordinary form fields (application/x-www-form-urlencoded). Its fields become trigger. A JSON array or a non-object body returns 400.
  • Idempotency-Key is optional. Posting again with the same key starts nothing new: you get the original run back. Use a key that identifies the thing that happened (an order id, a form submission id), and keep it under 95 characters.
  • The URL is rate limited to 120 requests per minute per workflow, and the body to 64 KB.

Status Code: 202 Accepted

json
{ "accepted": true, "runId": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31" }

A 202 can also mean the post was understood but started nothing:

json
{ "accepted": false, "reason": "filtered" }
reasonMeaning
filteredThe trigger's filter was not true (or could not be evaluated) for this post
suppressedThe contact is in one of the workflow's suppression groups
already_activeThe contact is already in this workflow
already_enteredThe contact has been through it and re-entry is off
reentry_windowThe contact went through it too recently to re-enter

Errors:

StatusWhen
400The body is not a JSON object or form fields; or Idempotency-Key is over 200 characters
404Unknown token, or the workflow is not active. The two look the same on purpose
409The live version of the workflow no longer starts from a URL
413The body is over 64 KB
429More than 120 requests in a minute for this workflow

Naming and creating the contact ​

On the trigger.webhook step, phone is an expression that reads the contact's phone from the posted fields (for example trigger.phone). The phone is matched to your contacts however it was typed (07…, 256…, +256…). When a contact matches, the run is about that contact and the journey rules apply.

  • With createContact on, a phone no contact has is added as a new contact, named by the name expression if you set one. Only Ugandan mobile numbers (+2567XXXXXXXX once normalised) are created.
  • If no phone is set, the phone is missing from the post, it is not a valid number, or there is no contact and createContact is off, the run starts without a contact. Steps that need one (such as update_contact) then fail.

payment.completed for your own top-ups ​

MsGine records a payment.completed event when a deposit to your balance goes through. A trigger.platform_event workflow on it can, for example, send you an SMS receipt.

Platform events ​

Facts MsGine records as it works. Start a workflow from one with trigger.platform_event, or end or pause a journey on one with a goal or a wait for event step whose source is platform.

Platform events are separate from the events you send to POST /developers/events: an event you send can never start a trigger.platform_event workflow, so nothing can pass itself off as something MsGine recorded. Likewise trigger.event only listens to events you send.

Fetch the list, with examples, at GET /developers/workflows/platform-events.

EventTitleFires when
sms.sentSMS sentAn SMS was accepted by the carrier.
sms.deliveredSMS deliveredThe carrier confirmed an SMS reached the recipient.
sms.failedSMS failedAn SMS could not be sent (stage "submission", refunded) or the carrier reported it undeliverable (stage "delivery").
email.sentEmail sentAn email was handed off to the mail provider.
email.failedEmail failedAn email could not be sent.
push.sentPush sentA push notification reached every target device.
push.partialPush partly sentA push notification reached some but not all target devices.
push.failedPush failedA push notification reached no devices.
whatsapp.sentWhatsApp sentMeta accepted a WhatsApp message.
whatsapp.deliveredWhatsApp deliveredA WhatsApp message was delivered to the recipient.
whatsapp.readWhatsApp readA WhatsApp message was read by the recipient.
whatsapp.failedWhatsApp failedA WhatsApp message could not be delivered.
contact.createdContact createdA contact was added, one at a time or by import.
contact.added_to_groupContact added to groupA contact joined a group. The usual way into a journey. Re-adding a member does not fire it.
contact.updatedContact updatedA contact was edited. changed lists the custom fields whose values changed, so a workflow can react to e.g. a new balance: "balance" in trigger.changed.
contact.opted_outOpted outSomeone asked not to be messaged on a channel (added to the opt-out list). contactId is set when the address belongs to a contact. Sends to them are blocked from now on.
contact.opted_inOpted back inSomeone who had opted out can be messaged again on a channel.
bot.conversation_startedBot conversation startedSomeone messaged one of your bots for the first time.
bot.message_receivedBot message receivedOne of your bots received a message and replied.
payment.completedPayment completedA deposit to (type credit) or payout from your MsGine balance went through.

The trigger of a run started by a platform event holds the fields in the example payloads below, plus trigger.event (id, type, subjectId, occurredAt). Events about a contact carry contactId, which is how the run gets its contact. Delivery is at least once, and a repeat never starts a second run.

Example payloads
sms.sent
json
{
  "to": [
    "+256701234567"
  ],
  "from": "MsGine",
  "message": "Hello",
  "sid": "ATXid_1",
  "cost": 30,
  "timestamp": "2026-10-18T10:00:00.000Z"
}
sms.delivered
json
{
  "to": "+256701234567",
  "from": "MsGine",
  "sid": "ATXid_1",
  "timestamp": "2026-10-18T10:00:05.000Z"
}
sms.failed
json
{
  "to": "+256701234567",
  "from": "MsGine",
  "sid": "ATXid_1",
  "stage": "delivery",
  "error": "AbsentSubscriber",
  "timestamp": "2026-10-18T10:00:05.000Z"
}
email.sent
json
{
  "from": "hello@shop.ug",
  "to": "amina@example.com",
  "subject": "Welcome",
  "sesMessageId": "0100018b…",
  "recipientCount": 1,
  "cost": 5
}
email.failed
json
{
  "from": "hello@shop.ug",
  "to": "amina@example.com",
  "subject": "Welcome",
  "error": "Mailbox unavailable"
}
push.sent
json
{
  "title": "Order shipped",
  "body": "Your order A-1042 is on its way.",
  "recipientCount": 2,
  "successCount": 2,
  "failureCount": 0
}
push.partial
json
{
  "title": "Order shipped",
  "body": "Your order A-1042 is on its way.",
  "recipientCount": 2,
  "successCount": 1,
  "failureCount": 1
}
push.failed
json
{
  "title": "Order shipped",
  "body": "Your order A-1042 is on its way.",
  "error": "No registered devices"
}
whatsapp.sent
json
{
  "to": "+256701234567",
  "from": "MsGine",
  "message": "Hello",
  "sid": "wamid.HBgM…",
  "status": "sent",
  "cost": 85,
  "timestamp": "2026-10-18T10:00:05.000Z"
}
whatsapp.delivered
json
{
  "to": "+256701234567",
  "from": "MsGine",
  "message": "Hello",
  "sid": "wamid.HBgM…",
  "status": "delivered",
  "cost": 85,
  "timestamp": "2026-10-18T10:00:05.000Z"
}
whatsapp.read
json
{
  "to": "+256701234567",
  "from": "MsGine",
  "message": "Hello",
  "sid": "wamid.HBgM…",
  "status": "read",
  "cost": 85,
  "timestamp": "2026-10-18T10:00:05.000Z"
}
whatsapp.failed
json
{
  "to": "+256701234567",
  "from": "MsGine",
  "message": "Hello",
  "sid": "wamid.HBgM…",
  "status": "failed",
  "cost": 85,
  "timestamp": "2026-10-18T10:00:05.000Z"
}
contact.created
json
{
  "contactId": "6a1f…",
  "name": "Amina",
  "phone": "+256701234567",
  "email": "amina@example.com"
}
contact.added_to_group
json
{
  "contactId": "6a1f…",
  "name": "Amina",
  "phone": "+256701234567",
  "email": "amina@example.com",
  "groupId": "9c2e…",
  "groupName": "VIP customers"
}
contact.updated
json
{
  "contactId": "6a1f…",
  "name": "Amina",
  "phone": "+256701234567",
  "email": "amina@example.com",
  "fields": {
    "balance": 150000,
    "due_date": "2027-01-15"
  },
  "changed": [
    "balance"
  ]
}
contact.opted_out
json
{
  "channel": "sms",
  "address": "256701234567",
  "source": "api",
  "contactId": "6a1f…"
}
contact.opted_in
json
{
  "channel": "sms",
  "address": "256701234567",
  "source": "api",
  "contactId": "6a1f…"
}
bot.conversation_started
json
{
  "botId": "b71d…",
  "botName": "Support",
  "conversationId": "c4a0…",
  "channel": "telegram",
  "text": "Hi"
}
bot.message_received
json
{
  "botId": "b71d…",
  "botName": "Support",
  "conversationId": "c4a0…",
  "channel": "web",
  "text": "What are your opening hours?",
  "reply": "We're open 8am to 6pm, Monday to Saturday."
}
payment.completed
json
{
  "transactionId": "9e2b…",
  "type": "credit",
  "amount": 50000,
  "currency": "UGX",
  "phone": "+256701234567",
  "name": "Amina Nakato",
  "email": "amina@example.com",
  "reference": "JP-48213"
}

:::

The sms.*, email.*, push.* and whatsapp.* delivery events are also available as webhooks.

Worked example ​

Thank customers who place orders over UGX 50,000, using a sandbox key so nothing is sent while you test. Replace YOUR_API_KEY with a key that has both MANAGE_WORKFLOWS and RUN_WORKFLOWS (or FULL_ACCESS).

1. Create the workflow

bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order follow-up",
    "definition": {
      "schemaVersion": 1,
      "settings": { "messageType": "transactional" },
      "nodes": [
        {
          "id": "start",
          "type": "trigger.event",
          "config": { "event": "order.paid", "filter": "trigger.amount > 50000" }
        },
        {
          "id": "thank_you",
          "type": "action.send_sms",
          "config": {
            "to": "{{ trigger.phone }}",
            "message": "Thanks {{ trigger.name }}, we received your order {{ trigger.orderId }}."
          }
        }
      ],
      "edges": [{ "from": "start", "output": "next", "to": "thank_you" }]
    }
  }'

The response is 201 with the workflow; note its id.

2. Publish it

bash
curl -X POST https://api.msgine.net/api/v1/developers/workflows/3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1/publish \
  -H "X-Api-Key: YOUR_API_KEY"
json
{
  "id": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
  "name": "Order follow-up",
  "status": "active",
  "activeVersionId": "6d0b9f0e-0f2b-4f43-b5d5-0d6d2c0f9f10"
}

(Shortened; the response is the full workflow.)

3. Send an event

bash
curl -X POST https://api.msgine.net/api/v1/developers/events \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order.paid",
    "payload": {
      "orderId": "A-1042",
      "phone": "+256701234567",
      "name": "Amina",
      "amount": 85000
    }
  }'
json
{
  "event": "order.paid",
  "runs": [
    {
      "workflowId": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
      "runId": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31",
      "status": "running"
    }
  ],
  "skipped": [],
  "exited": 0,
  "resumed": 0
}

4. Check the runs

bash
curl "https://api.msgine.net/api/v1/developers/workflows/3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1/runs?limit=5" \
  -H "X-Api-Key: YOUR_API_KEY"
json
{
  "data": [
    {
      "id": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31",
      "workflowId": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
      "status": "completed",
      "sandbox": true,
      "contactId": null,
      "stepCount": 2,
      "steps": {
        "start": {},
        "thank_you": {
          "messageId": "1f0c2d4e-6b0a-4c1e-9d36-2f8e1b7a5c90",
          "status": "sent",
          "cost": 0,
          "recipients": ["+256701234567"]
        }
      },
      "createdAt": "2026-11-03T09:10:00.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "pages": 1
}

(Run fields trimmed to the interesting ones.) GET /developers/workflows/runs/c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31 adds the step-by-step history. Once the flow behaves, switch to a production key to send for real.

An event payload of { "amount": 20000 } would have returned an empty runs list: the trigger's filter does not match, so no run starts.

Released under the MIT License.