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
| Term | Meaning |
|---|---|
| Definition | A JSON graph of nodes (steps) and edges (connections between them). |
| Draft | The editable copy of a definition. It can be saved in any state, valid or not. |
| Version | Publishing freezes the draft into an immutable, numbered version and makes it live. |
| Run | One pass of one trigger through one version. A run stays on the version it started on, even if you publish again. |
| Sandbox | A 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:
| Scope | Grants |
|---|---|
MANAGE_WORKFLOWS | Create, edit, validate, publish, pause, resume and archive workflows; workflow URLs; secrets; the messaging policy |
RUN_WORKFLOWS | Send 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.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /developers/workflows | either | List workflows |
POST | /developers/workflows | MANAGE_WORKFLOWS | Create a workflow |
GET | /developers/workflows/:id | either | Get a workflow with its draft |
PATCH | /developers/workflows/:id | MANAGE_WORKFLOWS | Rename, or replace the draft |
DELETE | /developers/workflows/:id | MANAGE_WORKFLOWS | Archive a workflow |
POST | /developers/workflows/:id/validate | MANAGE_WORKFLOWS | Check the draft |
POST | /developers/workflows/:id/publish | MANAGE_WORKFLOWS | Publish the draft |
POST | /developers/workflows/:id/pause | MANAGE_WORKFLOWS | Stop new runs |
POST | /developers/workflows/:id/resume | MANAGE_WORKFLOWS | Resume a paused workflow |
GET | /developers/workflows/:id/versions | MANAGE_WORKFLOWS | List published versions |
POST | /developers/workflows/:id/runs | RUN_WORKFLOWS | Start a manual run |
GET | /developers/workflows/:id/runs | either | List runs |
GET | /developers/workflows/runs/:runId | either | Get a run with its step history |
POST | /developers/workflows/runs/:runId/cancel | RUN_WORKFLOWS | Cancel a run |
POST | /developers/events | RUN_WORKFLOWS | Send an event |
POST | /developers/workflows/:id/launches | RUN_WORKFLOWS | Launch an audience workflow |
GET | /developers/workflows/:id/launches | either | List launches |
GET | /developers/workflows/launches/:launchId | either | Get a launch |
POST | /developers/workflows/launches/:launchId/cancel | RUN_WORKFLOWS | Cancel a launch |
GET | /developers/workflows/:id/hook | MANAGE_WORKFLOWS | Whether a workflow URL exists |
POST | /developers/workflows/:id/hook | MANAGE_WORKFLOWS | Create the workflow URL |
DELETE | /developers/workflows/:id/hook | MANAGE_WORKFLOWS | Delete the workflow URL |
GET | /developers/workflows/secrets | MANAGE_WORKFLOWS | List secret names |
PUT | /developers/workflows/secrets/:name | MANAGE_WORKFLOWS | Create or replace a secret |
DELETE | /developers/workflows/secrets/:name | MANAGE_WORKFLOWS | Delete a secret |
GET | /developers/workflows/messaging-policy | MANAGE_WORKFLOWS | Read quiet hours and daily cap |
PUT | /developers/workflows/messaging-policy | MANAGE_WORKFLOWS | Update them |
GET | /developers/workflows/node-types | either | Catalog of step types |
GET | /developers/workflows/platform-events | either | Catalog of MsGine events |
"either" means MANAGE_WORKFLOWS or RUN_WORKFLOWS. Ids in paths must be UUIDs.
Errors
Errors use the standard shape:
{
"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
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" }]
}
}'| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Max 150 characters |
description | string | No | Max 1000 characters |
definition | object | No | The 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
{
"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| Parameter | Type | Description |
|---|---|---|
page | number | Default 1 |
limit | number | Default 20, max 100 |
status | string | draft, 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
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
{
"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
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:
{
"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:
[
{
"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
{
"schemaVersion": 1,
"settings": {
"messageType": "marketing",
"reentry": { "mode": "never" },
"suppressGroupIds": [],
"goals": []
},
"nodes": [
{ "id": "start", "type": "trigger.event", "name": "Order paid", "config": { "event": "order.paid" } }
],
"edges": []
}| Field | Type | Required | Description |
|---|---|---|---|
schemaVersion | number | Yes | Always 1 |
nodes | array | Yes | The steps. Max 500 |
edges | array | Yes | The connections. Max 1000 |
settings | object | No | Journey rules; see Journeys |
Unknown properties anywhere in the definition fail validation.
Nodes
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique 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> |
type | string | Yes | A step type, e.g. action.send_sms |
name | string | No | Display name, max 150 characters |
config | object | Yes | The step's settings; {} when it has none |
ui | object | No | Canvas data such as position. The engine ignores it |
Edges
An edge connects one output of a step to the next step.
| Field | Type | Description |
|---|---|---|
from | string | Id of the step the edge leaves |
output | string | Which output of that step: next for most steps, yes/no for a condition, and so on (see Step types) |
to | string | Id 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
| Limit | Value |
|---|---|
| Steps per workflow | 500 |
| Connections per workflow | 1000 |
| Steps executed in one run | 1000; a run that goes past it is stopped as runaway |
| Attempts per step | 5 |
| Longest wait | 365 days |
| Expression length | 2000 characters |
| Template length | 10000 characters |
| Trigger input per run | 64 KB |
| Stored result per step | 16 KB |
| Goals per workflow | 10 |
| Suppression groups per workflow | 50 |
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.
| Config | Type | Description |
|---|---|---|
event * | string | Event name, matched exactly. ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$ |
filter | Expression | Start 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.
| Config | Type | Description |
|---|---|---|
event * | string | One of the platform event names |
filter | Expression | e.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.
| Config | Type | Description |
|---|---|---|
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).
| Config | Type | Description |
|---|---|---|
groupIds * | string[] | Group ids, 1 to 20 |
frequency * | string | daily, weekly or monthly |
time * | string | HH:MM, 24-hour |
daysOfWeek | number[] | Required for weekly: 0 (Sunday) to 6 (Saturday) |
dayOfMonth | number | Required 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.
| Config | Type | Description |
|---|---|---|
field * | string | Key of one of your date contact fields |
offsetDays * | number | Whole days from -365 to 365. Negative is before the date: -3 is three days before |
time * | string | HH:MM, 24-hour |
yearly | boolean | Match month and day every year, for birthdays and anniversaries |
groupIds | string[] | 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.
| Config | Type | Description |
|---|---|---|
phone | Expression | Where the contact's phone is in the posted fields, e.g. trigger.phone |
name | Expression | The contact's name; used only when creating a contact |
createContact | boolean | Add the phone as a new contact when none has it |
filter | Expression | Start 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.
| Config | Type | Description |
|---|---|---|
to * | Template | Phone number(s), comma-separated, e.g. {{ contact.phone }} |
message * | Template | Message text |
from | string | Sender 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.
| Config | Type | Description |
|---|---|---|
to * | Template | WhatsApp number(s) |
message | Template | Message text; required unless templateId is set |
messageType | string | Meta pricing category: marketing (default), utility, authentication or service |
templateId | string | An approved Meta template's name, max 512 characters |
templateLanguage | string | The template's language code, e.g. en (default) or en_US |
templateParams | Template[] | Values for the template's {{1}}, {{2}}, … in order, up to 20 |
Result: messageId, status, cost, recipients.
action.send_email — Send email
| Config | Type | Description |
|---|---|---|
from * | string | An address on a verified domain, e.g. Shop <hello@shop.ug>. Max 320 characters. Not a template |
to * | Template | Address(es), comma-separated, e.g. {{ contact.email }} |
subject * | Template | Subject line |
text | Template | Plain text body |
html | Template | HTML 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.
| Config | Type | Description |
|---|---|---|
externalUserIds * | Template | Comma-separated, e.g. {{ trigger.userId }} |
title * | Template | Notification title |
body * | Template | Notification 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.
| Config | Type | Description |
|---|---|---|
name | Template | New name |
email | Template | New 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.
| Config | Type | Description |
|---|---|---|
groupId * | string | Group 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.
| Config | Type | Description |
|---|---|---|
method * | string | GET, POST, PUT, PATCH or DELETE |
url * | Template | http(s) URL, max 2048 characters. Must resolve to a public address; redirects are not followed |
headers | object | Up 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 |
body | Template | Request body, max 64 KB. Content-Type defaults to application/json |
timeoutSeconds | number | 1 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.
| Config | Type | Description |
|---|---|---|
expression * | Expression | Must 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.
| Config | Type | Description |
|---|---|---|
amount * | number | Whole number, at least 1 |
unit * | string | minutes, 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.
| Config | Type | Description |
|---|---|---|
time * | string | HH:MM, 24-hour |
daysOfWeek | number[] | 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.
| Config | Type | Description |
|---|---|---|
source * | string | platform (recorded by MsGine) or custom (sent to POST /developers/events) |
event * | string | Event name, max 100 characters. For platform, a platform event |
filter | Expression | Only 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.
| Config | Type | Description |
|---|---|---|
branches * | array | 2 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'sfilter,phoneandnameon 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
| Variable | Contents |
|---|---|
trigger | What 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) |
contact | The 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) |
steps | Results of steps that have run, by step id: steps.thank_you.messageId |
run | id, startedAt and sandbox |
workflow | id and the running version |
secrets | Only 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.changedReading 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.
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"
}'| Parameter | Type | Required | Description |
|---|---|---|---|
input | object | No | Becomes trigger. Max 64 KB |
contactId | string | No | UUID of the contact this run is about |
phone | string | No | Identifies the contact by phone instead; exact match, max 20 characters |
sandbox | boolean | No | Send nothing and charge nothing. Always on for sandbox keys |
idempotencyKey | string | No | Max 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:
{
"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:
| Status | When |
|---|---|
404 | The workflow does not exist, or contactId/phone is not one of your contacts |
409 | The 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:
{
"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.
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"
}'| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Event name: ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$ |
payload | object | No | Becomes trigger. Max 64 KB |
contactId | string | No | UUID of the contact this event is about |
phone | string | No | Identifies the contact by phone instead; exact match, max 20 characters |
idempotencyKey | string | No | Max 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
{
"event": "order.paid",
"runs": [
{
"workflowId": "3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1",
"runId": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31",
"status": "running"
}
],
"skipped": [],
"exited": 0,
"resumed": 0
}| Field | Description |
|---|---|
runs | The runs started. A repeated idempotencyKey lists the original runs |
skipped | Workflows the contact was not let into: { "workflowId", "reason" } with reason one of suppressed, already_active, already_entered, reentry_window |
exited | Runs of this contact ended because the event met a goal |
resumed | Runs 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| Parameter | Type | Description |
|---|---|---|
page | number | Default 1 |
limit | number | Default 20, max 100 |
status | string | running, 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:
{
"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.
{
"settings": {
"messageType": "transactional",
"reentry": { "mode": "after", "afterDays": 30 },
"suppressGroupIds": ["8f6e0c1a-5d2b-4f0e-9a41-7c2d3b4a5e60"],
"goals": [
{ "source": "custom", "event": "order.paid", "filter": "trigger.amount > 0" }
]
}
}| Setting | Description |
|---|---|
messageType | marketing (default) or transactional. See Messaging policy |
reentry.mode | never (default), always or after. Whether a contact may go through the workflow again |
reentry.afterDays | Required with after, and only allowed with it. Whole days, 1 to 3650 |
suppressGroupIds | Up to 50 group ids. Contacts in any of these groups never enter |
goals | Up 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
reentrysays. reentrydefaults tonever, except for workflows that start withtrigger.scheduleortrigger.date, which default toalwaysbecause 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.
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 }'| Parameter | Type | Required | Description |
|---|---|---|---|
at | string | No | ISO 8601 start time, at most a year ahead. Omit to start now |
sandbox | boolean | No | Send nothing and charge nothing. Always on for sandbox keys |
Status Code: 201 Created
{
"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.
| Endpoint | Description |
|---|---|
GET /developers/workflows/:id/launches | Launches of a workflow, newest first (an array) |
GET /developers/workflows/launches/:launchId | One launch |
POST /developers/workflows/launches/:launchId/cancel | Stop 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.
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
}'| Parameter | Type | Description |
|---|---|---|
timezone | string | IANA 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 |
quietHoursStart | string or null | HH:MM, 24-hour. A marketing send that falls in quiet hours waits until they end. null clears |
quietHoursEnd | string or null | HH:MM. The window may cross midnight, as above |
dailyMarketingCapPerContact | number or null | Whole 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)
{
"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.
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_..." }'| Parameter | Type | Required | Description |
|---|---|---|---|
value | string | Yes | 1 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:
[
{
"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
| Method | Path | Result |
|---|---|---|
POST | /developers/workflows/:id/hook | Creates the URL (201) |
GET | /developers/workflows/:id/hook | { "createdAt": ... } if a URL exists, an empty response if not |
DELETE | /developers/workflows/:id/hook | Deletes it (204) |
curl -X POST https://api.msgine.net/api/v1/developers/workflows/WORKFLOW_ID/hook \
-H "X-Api-Key: YOUR_API_KEY"{
"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
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 becometrigger. A JSON array or a non-object body returns400. Idempotency-Keyis 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
{ "accepted": true, "runId": "c9a3f5de-2b7f-4f60-9a58-0b0c2f2c6a31" }A 202 can also mean the post was understood but started nothing:
{ "accepted": false, "reason": "filtered" }reason | Meaning |
|---|---|
filtered | The trigger's filter was not true (or could not be evaluated) for this post |
suppressed | The contact is in one of the workflow's suppression groups |
already_active | The contact is already in this workflow |
already_entered | The contact has been through it and re-entry is off |
reentry_window | The contact went through it too recently to re-enter |
Errors:
| Status | When |
|---|---|
400 | The body is not a JSON object or form fields; or Idempotency-Key is over 200 characters |
404 | Unknown token, or the workflow is not active. The two look the same on purpose |
409 | The live version of the workflow no longer starts from a URL |
413 | The body is over 64 KB |
429 | More 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
createContacton, a phone no contact has is added as a new contact, named by thenameexpression if you set one. Only Ugandan mobile numbers (+2567XXXXXXXXonce 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
createContactis off, the run starts without a contact. Steps that need one (such asupdate_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.
| Event | Title | Fires when |
|---|---|---|
sms.sent | SMS sent | An SMS was accepted by the carrier. |
sms.delivered | SMS delivered | The carrier confirmed an SMS reached the recipient. |
sms.failed | SMS failed | An SMS could not be sent (stage "submission", refunded) or the carrier reported it undeliverable (stage "delivery"). |
email.sent | Email sent | An email was handed off to the mail provider. |
email.failed | Email failed | An email could not be sent. |
push.sent | Push sent | A push notification reached every target device. |
push.partial | Push partly sent | A push notification reached some but not all target devices. |
push.failed | Push failed | A push notification reached no devices. |
whatsapp.sent | WhatsApp sent | Meta accepted a WhatsApp message. |
whatsapp.delivered | WhatsApp delivered | A WhatsApp message was delivered to the recipient. |
whatsapp.read | WhatsApp read | A WhatsApp message was read by the recipient. |
whatsapp.failed | WhatsApp failed | A WhatsApp message could not be delivered. |
contact.created | Contact created | A contact was added, one at a time or by import. |
contact.added_to_group | Contact added to group | A contact joined a group. The usual way into a journey. Re-adding a member does not fire it. |
contact.updated | Contact updated | A 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_out | Opted out | Someone 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_in | Opted back in | Someone who had opted out can be messaged again on a channel. |
bot.conversation_started | Bot conversation started | Someone messaged one of your bots for the first time. |
bot.message_received | Bot message received | One of your bots received a message and replied. |
payment.completed | Payment completed | A 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
{
"to": [
"+256701234567"
],
"from": "MsGine",
"message": "Hello",
"sid": "ATXid_1",
"cost": 30,
"timestamp": "2026-10-18T10:00:00.000Z"
}sms.delivered
{
"to": "+256701234567",
"from": "MsGine",
"sid": "ATXid_1",
"timestamp": "2026-10-18T10:00:05.000Z"
}sms.failed
{
"to": "+256701234567",
"from": "MsGine",
"sid": "ATXid_1",
"stage": "delivery",
"error": "AbsentSubscriber",
"timestamp": "2026-10-18T10:00:05.000Z"
}email.sent
{
"from": "hello@shop.ug",
"to": "amina@example.com",
"subject": "Welcome",
"sesMessageId": "0100018b…",
"recipientCount": 1,
"cost": 5
}email.failed
{
"from": "hello@shop.ug",
"to": "amina@example.com",
"subject": "Welcome",
"error": "Mailbox unavailable"
}push.sent
{
"title": "Order shipped",
"body": "Your order A-1042 is on its way.",
"recipientCount": 2,
"successCount": 2,
"failureCount": 0
}push.partial
{
"title": "Order shipped",
"body": "Your order A-1042 is on its way.",
"recipientCount": 2,
"successCount": 1,
"failureCount": 1
}push.failed
{
"title": "Order shipped",
"body": "Your order A-1042 is on its way.",
"error": "No registered devices"
}whatsapp.sent
{
"to": "+256701234567",
"from": "MsGine",
"message": "Hello",
"sid": "wamid.HBgM…",
"status": "sent",
"cost": 85,
"timestamp": "2026-10-18T10:00:05.000Z"
}whatsapp.delivered
{
"to": "+256701234567",
"from": "MsGine",
"message": "Hello",
"sid": "wamid.HBgM…",
"status": "delivered",
"cost": 85,
"timestamp": "2026-10-18T10:00:05.000Z"
}whatsapp.read
{
"to": "+256701234567",
"from": "MsGine",
"message": "Hello",
"sid": "wamid.HBgM…",
"status": "read",
"cost": 85,
"timestamp": "2026-10-18T10:00:05.000Z"
}whatsapp.failed
{
"to": "+256701234567",
"from": "MsGine",
"message": "Hello",
"sid": "wamid.HBgM…",
"status": "failed",
"cost": 85,
"timestamp": "2026-10-18T10:00:05.000Z"
}contact.created
{
"contactId": "6a1f…",
"name": "Amina",
"phone": "+256701234567",
"email": "amina@example.com"
}contact.added_to_group
{
"contactId": "6a1f…",
"name": "Amina",
"phone": "+256701234567",
"email": "amina@example.com",
"groupId": "9c2e…",
"groupName": "VIP customers"
}contact.updated
{
"contactId": "6a1f…",
"name": "Amina",
"phone": "+256701234567",
"email": "amina@example.com",
"fields": {
"balance": 150000,
"due_date": "2027-01-15"
},
"changed": [
"balance"
]
}contact.opted_out
{
"channel": "sms",
"address": "256701234567",
"source": "api",
"contactId": "6a1f…"
}contact.opted_in
{
"channel": "sms",
"address": "256701234567",
"source": "api",
"contactId": "6a1f…"
}bot.conversation_started
{
"botId": "b71d…",
"botName": "Support",
"conversationId": "c4a0…",
"channel": "telegram",
"text": "Hi"
}bot.message_received
{
"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
{
"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
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
curl -X POST https://api.msgine.net/api/v1/developers/workflows/3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1/publish \
-H "X-Api-Key: YOUR_API_KEY"{
"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
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
}
}'{
"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
curl "https://api.msgine.net/api/v1/developers/workflows/3b1f6f3e-52f1-4d4e-9a52-6c7ad1f7c0a1/runs?limit=5" \
-H "X-Api-Key: YOUR_API_KEY"{
"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.