Triggering Journeys
Start a journey for a contact from your own code or from any tool that can send an HTTP request, pass it data, and take contacts back out.
Journeys are built in the app and driven from outside it. Besides recording an event that a journey listens for, there are two direct ways to put a contact into a journey:
| Journey hook URL | Enroll endpoint | |
|---|---|---|
| Call | POST /hooks/journeys/{token} | POST /public/journey/{id}/enroll |
| Authentication | None: the URL is the secret | API key with journey:write |
| Unknown contact | Created from email | 404; the contact must exist |
| Result | { "accepted": true }, enrollment happens moments later | The enrollment, or an error saying why not |
| Safe to retry | No | Yes, with idempotencyKey |
| Best for | Form builders, Zapier, a partner's system | Your own backend |
Building the journey itself is covered in the user guides: Journey Builder and Triggers.
Journey hook URLs
A journey whose trigger is a Webhook node gets a URL of its own. Anything that can send JSON can start the journey with it, with no API key.
Get the URL
In the journey builder, add a Webhook trigger and select it. The panel shows its URL, https://api.mailcrate.io/hooks/journeys/<token>, with a copy button. Each Webhook trigger node has its own URL, and it is created as soon as the draft is saved.
Publish the journey
Requests are only turned into enrollments while the journey is active and the published version still contains that Webhook node. Until then, calls are accepted and dropped.
Send a request
curl -X POST https://api.mailcrate.io/hooks/journeys/$JOURNEY_HOOK_TOKEN \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"externalIdentifier": "user_8231",
"properties": {
"plan": "pro",
"order": { "id": "5512", "total": 129.5 }
}
}'{ "accepted": true }The builder shows the last payload the URL received and when, which is the quickest way to check that a tool is sending what you expect. Send the Content-Type: application/json header with a JSON body. Without it the body is not read as JSON, and the request fails with 400 for having no identifier.
The request body
| Field | What it is |
|---|---|
contactId | Mailcrate's contact id (a UUID). |
externalIdentifier | Your own id for the person. |
email | Their email address. |
properties | Optional. Any JSON object: the journey's trigger data, see below. |
At least one of contactId, externalIdentifier and email is required. Mailcrate tries them in that order and uses the first that finds a contact. When none does and an email was sent, it creates the contact, with the externalIdentifier on it if you sent one. That creation is subject to the consent gate. Without an email, an unknown contact is a 404 Contact not found.
What properties does
The object does two things once the contact is enrolled:
- It becomes the trigger data. Every key is available to the journey's steps as
{{ trigger.<key> }}, nested objects included:{{ trigger.order.total }}. Use it, for instance, to fill a Send Email step's template variables. A payload over 16 KB of JSON is dropped from the trigger data, and the run goes ahead without it. - Its top-level text, number and boolean values are written to the contact as properties, before the first step runs. Nested objects and arrays are not. Unknown keys become new text properties, as with the contact API. If a value does not fit a typed property, none of the properties from that request are written, and the run still goes ahead.
Accepted is not enrolled
{ "accepted": true } means Mailcrate recorded the request. The enrollment is decided a few seconds later, and the hook never tells you how that went, so a URL cannot be used to probe a journey's state. A request leads to no enrollment when:
- the journey is a draft, paused or archived, or the Webhook node is not in its published version;
- the journey has re-entry turned off and the contact has been through it before;
- the contact is in the journey right now (a contact has at most one live run per journey);
- the journey is at its active-contact limit.
The last three are recorded in the journey's activity as a skipped enrollment. To learn the outcome in your own system, subscribe a webhook to journey.enrolled.
Every request is a new trigger. A tool that retries a request it already delivered can enroll the contact twice, once the first run has finished, on a journey that allows re-entry.
Errors
| Status | When |
|---|---|
400 | The body failed validation, for example no identifier at all, or contactId is not a UUID. |
403 | A new contact would be created and the workspace has not confirmed the consent statement yet. |
404 | Journey hook not found for an unknown token, or Contact not found. |
429 | More than 600 requests in 5 minutes from one IP address. Wait and retry. |
Keep the URL secret
Whoever has the URL can create contacts in your workspace and put them into the journey, so treat it like an API key: keep it out of client-side code and public repositories. A URL cannot be rotated. To retire one, delete its Webhook node, add a new one (which gets a new URL) and publish. The old URL then stops enrolling anyone, but it still answers { "accepted": true } and still creates contacts from unknown email addresses.
The enroll endpoint
When your own backend decides who enters a journey, call it with an API key. You get the outcome in the response, and retries are safe.
Find the journey's id in the app's URL, or list journeys with GET /public/journey; each one carries its status, its reEnrollment setting and a summary of its triggers.
curl -X POST https://api.mailcrate.io/public/journey/0c5e2b7a-4d1f-4a8e-9c3b-6e2f1a7d8b90/enroll \
-H "Authorization: Api-Key $MAILCRATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contactId": "user_8231",
"data": { "trialEndsAt": "2026-08-17", "seats": 5 },
"idempotencyKey": "trial-onboarding-user_8231"
}'{
"enrollment": {
"id": "e41c9a0d-7b2e-4f6a-8d35-0a9b1c2d3e4f",
"journeyId": "0c5e2b7a-4d1f-4a8e-9c3b-6e2f1a7d8b90",
"status": "active",
"contact": { "id": "3f0d8a52-1c4e-4b8e-9a51-7f3c2b6d9e10", "email": "ada@example.com" },
"goalReachedAt": null,
"enteredAt": "2026-08-03T11:20:05.311Z",
"finishedAt": null
},
"idempotentReplay": false
}| Field | What it is |
|---|---|
contactId | A contact id, external identifier or email address. |
email | An email address. Use either field; at least one is required. |
data | Optional trigger data, available as {{ trigger.<key> }}, dropped above 16 KB. Unlike a hook's properties, it is not written to the contact. |
idempotencyKey | Optional, up to 200 characters of letters, digits, ., _, - and :. |
The endpoint never creates a contact. Create or sync it first.
Retrying safely
An idempotencyKey is scoped to the journey and the contact. Repeating a request with the same key returns the original enrollment with "idempotentReplay": true, even when a fresh enrollment would now be refused, so a retry after a timeout can never enroll twice or turn into a confusing 409.
When enrollment is refused
| Status | Message | What it means |
|---|---|---|
404 | Journey with id: "…" not found, Contact "…" not found | Check the id; the contact must exist. |
409 | Contact is currently in this journey — wait for the run to finish | One live run per contact per journey. |
409 | Contact has already been in this journey and re-enrollment is disabled for it | The journey's re-entry setting is off. |
422 | Journey must be active to enroll contacts | Draft, paused or archived. |
422 | Journey is at its active-contact limit | Try again once runs finish, or raise the limit. |
422 | Journey is not currently accepting enrollments | It changed under the request, for example a new version was published. |
Mailcrate does not check suppression here. A suppressed or unsubscribed contact is enrolled, and the journey's send steps skip mail they may not receive, the same as for every other way in.
Take a contact out
curl -X POST https://api.mailcrate.io/public/journey/0c5e2b7a-4d1f-4a8e-9c3b-6e2f1a7d8b90/exit \
-H "Authorization: Api-Key $MAILCRATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contactId": "user_8231" }'The response is { "exited": 1, "enrollments": [ … ] }. It is safe to repeat: a contact with no live run answers "exited": 0, not an error. It also works on paused and archived journeys. A typical use is stopping a trial-nurture journey the moment the customer upgrades. If the journey has a goal and is set to exit when the goal is reached, recording the goal's event does the same without a call per journey.
Follow what happens
GET /public/journey/{id}/enrollmentslists runs, optionally for one contact with?contactId=(a contact id, external identifier or email).GET /public/journey/{id}/statsreturns the numbers the builder shows, counting real runs only.- The
journey.enrolledandjourney.exitedwebhook events report every run as it starts and ends, whichever way it was triggered.
Next steps
- Syncing Contacts and Events: the contacts and events journeys run on.
- Webhooks: get journey runs reported back to your system.
- The Journeys and Journey Hooks API reference.
Syncing Contacts and Events
Keep Mailcrate's contacts in step with your own users, and record what they do as events that segments and journeys can act on.
Receiving Inbound Mail
Create inbound addresses, receive each parsed message as a webhook, fetch its bodies and attachments, and reply in the same thread.