FromlyDocumentation
Get started

API reference

Every endpoint, the permission it needs, and what it answers. Requests go to https://api.fromly.io/v1.

At a glance

EndpointPermission
GET/v1/workspaceworkspace:read
GET/v1/contactscontacts:read
POST/v1/contactscontacts:write
GET/v1/contacts/[contactId]contacts:read
PATCH/v1/contacts/[contactId]contacts:write
POST/v1/contacts/[contactId]/tagscontacts:tag
DELETE/v1/contacts/[contactId]/tags/[tag]contacts:tag
GET/v1/contacts/[contactId]/listscontacts:read
POST/v1/contacts/[contactId]/listscontacts:tag
DELETE/v1/contacts/[contactId]/lists/[listId]contacts:tag
POST/v1/contacts/[contactId]/blockcontacts:write
POST/v1/contacts/[contactId]/restorecontacts:tag
GET/v1/listscontacts:read
POST/v1/listscontacts:write
PATCH/v1/lists/[listId]contacts:write
DELETE/v1/lists/[listId]contacts:write
GET/v1/tagscontacts:read
POST/v1/tagscontacts:write
GET/v1/tags/[tag]contacts:read
PATCH/v1/tags/[tag]contacts:write
DELETE/v1/tags/[tag]contacts:write
GET/v1/eventsevents:read
POST/v1/eventsevents:write
GET/v1/events/typesevents:read
GET/v1/emailsemails:read
POST/v1/emailsemails:write
GET/v1/emails/[emailId]emails:read
PATCH/v1/emails/[emailId]emails:write
DELETE/v1/emails/[emailId]emails:delete
GET/v1/themesemails:read
POST/v1/themesemails:write
GET/v1/themes/[themeId]emails:read
PATCH/v1/themes/[themeId]emails:write
DELETE/v1/themes/[themeId]emails:delete
POST/v1/themes/[themeId]/defaultemails:write
POST/v1/themes/[themeId]/previewemails:read
GET/v1/email-componentsemails:read
POST/v1/email-componentsemails:write
GET/v1/email-components/[componentId]emails:read
PATCH/v1/email-components/[componentId]emails:write
DELETE/v1/email-components/[componentId]emails:delete
POST/v1/email-components/[componentId]/defaultemails:write
GET/v1/broadcastsbroadcasts:read
POST/v1/broadcastsbroadcasts:write
GET/v1/broadcasts/[broadcastId]broadcasts:read
PATCH/v1/broadcasts/[broadcastId]broadcasts:write
POST/v1/broadcasts/[broadcastId]/sendbroadcasts:send
POST/v1/transactionaltransactional:send
GET/v1/messagesmessages:read
GET/v1/messages/[messageId]messages:read
GET/v1/domainsdomains:read
GET/v1/automationsautomations:read
POST/v1/automationsautomations:write
GET/v1/automations/[automationId]automations:read
PATCH/v1/automations/[automationId]automations:write
GET/v1/automations/[automationId]/cohort/[nodeId]automations:read
POST/v1/automations/[automationId]/statusautomations:activate

Retrieve the workspace

GET/v1/workspace

The workspace this key belongs to: its id, name, slug and timezone, with the permissions the key holds. A key reaches exactly one workspace.

Requires workspace:read.

curl https://api.fromly.io/v1/workspace \
  -H "Authorization: Bearer fk_live_…"

Search contacts

GET/v1/contacts

List and search contacts, oldest first by default. Every parameter combines with the others (AND).

Requires contacts:read.

Query parameterMeaning
qPartial match on the email, first name and last name. `q=ada` finds ada@acme.com and Ada Lovelace.
emailExact address, case-insensitive. Comma-separate up to 100 to look several up at once.
status`SUBSCRIBED`, `UNSUBSCRIBED`, `BOUNCED` or `COMPLAINED`. Comma-separate for several.
source`MANUAL`, `IMPORT`, `API`, `STRIPE` or `SUPABASE`. Comma-separate for several.
tagA tag the contact carries, manual or set from Stripe. Repeat it and every tag must be carried.
createdAfter, createdBeforeISO 8601 date or time. After is inclusive, before is exclusive.
updatedAfter, updatedBeforeThe same, on the last change to the contact. Poll with `updatedAfter` and `sort=updatedAt` to sync changes.
sort`createdAt` (default) or `updatedAt`.
order`asc` (default) or `desc`.
fA filter, repeatable: `field:operator:value`, the syntax the app puts in its own URLs (see Filters). Unrecognised filters are ignored.
limitPage size, 1–100. Defaults to 25.
cursorThe `nextCursor` from the previous reply.

An unknown value in any parameter other than `f` is a 400 naming it. A cursor only works with the sort and order it was issued for.

curl https://api.fromly.io/v1/contacts \
  -H "Authorization: Bearer fk_live_…"

Upsert a contact

POST/v1/contacts

Create a contact, or update the one already holding that address.

Requires contacts:write.

Body

{ "email": "a@b.com", "firstName": "Ada", "avatarUrl": "https://…/ada.png", "properties": { "language": "fr" } }

201 when it created, 200 when it updated an existing address. `contact.url` opens it in the dashboard.

Properties merge key by key — setting one never deletes another — and a key sent as null is removed. Keys are lowercase letters, digits and underscores; values are scalars. A new key registers itself in Settings → Properties. Status cannot be set: consent lives in the unsubscribed / bounced / complained timestamps, and status only ever moves one way.

curl -X POST https://api.fromly.io/v1/contacts \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Retrieve a contact

GET/v1/contacts/[contactId]

One contact, with its billing facts and its consent timestamps.

Requires contacts:read.

curl https://api.fromly.io/v1/contacts/[contactId] \
  -H "Authorization: Bearer fk_live_…"

Update a contact

PATCH/v1/contacts/[contactId]

Change a contact’s name, photo or attributes.

Requires contacts:write.

Body

{ "firstName": "Ada", "properties": { "seats": 3 } }

A field left out is left alone; a field sent as null is cleared.

Properties merge key by key, and a key sent as null is removed. The address is not changeable here — it identifies the row.

curl -X PATCH https://api.fromly.io/v1/contacts/[contactId] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Add a tag

POST/v1/contacts/[contactId]/tags

Add a tag to a contact.

Requires contacts:tag.

Body

{ "tag": "beta" }

`added: false` when the contact already had it, which emits nothing.

This can send mail. A tag writes a tag.added event, which enrols the contact into any live flow listening for it — so it needs the highest tier and counts against the strict send budget.

curl -X POST https://api.fromly.io/v1/contacts/[contactId]/tags \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Remove a tag

DELETE/v1/contacts/[contactId]/tags/[tag]

Remove a manual tag. The tag is in the path, not in a body.

Requires contacts:tag.

`removed: false` when the contact did not have it, which is a success.

Nothing is emitted: there is no tag.removed event, so removing a tag cannot start anything. Tags derived from Stripe cannot be removed this way.

curl -X DELETE https://api.fromly.io/v1/contacts/[contactId]/tags/[tag] \
  -H "Authorization: Bearer fk_live_…"

List a contact’s lists

GET/v1/contacts/[contactId]/lists

The lists a contact is on, and the ones they left.

Requires contacts:read.

`{ data: [{ id, name, subscribed, subscribedAt, unsubscribedAt }] }`.

curl https://api.fromly.io/v1/contacts/[contactId]/lists \
  -H "Authorization: Bearer fk_live_…"

Add a contact to a list

POST/v1/contacts/[contactId]/lists

Put a contact on a mailing list.

Requires contacts:tag.

Body

{ "listId": "<list id>" }

`{ contactId, listId, added, left }`. `added: false` when they were already on it.

This can send: it records `list.added`, and a live flow started by that list enrols the contact within a minute. Someone who left the list from the unsubscribe page is not put back (`left: true`); only they can rejoin.

curl -X POST https://api.fromly.io/v1/contacts/[contactId]/lists \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Remove a contact from a list

DELETE/v1/contacts/[contactId]/lists/[listId]

Take a contact off a mailing list.

Requires contacts:tag.

`{ contactId, listId, removed }`. `removed: false` when they were not on it, or left it themselves.

Records `list.removed`, which never starts a flow. A flow that this list started lets them go.

curl -X DELETE https://api.fromly.io/v1/contacts/[contactId]/lists/[listId] \
  -H "Authorization: Bearer fk_live_…"

Block a contact

POST/v1/contacts/[contactId]/block

Stop every email to a contact, transactional ones included.

Requires contacts:write.

Body

{ "reason": "Asked us by phone" }  (optional)

The contact, with `status: "SUPPRESSED"`, the reason and `suppressedAt`.

Their running flows that send email end. Refused (409) when they are already blocked, or reported spam, which already stops everything for good.

curl -X POST https://api.fromly.io/v1/contacts/[contactId]/block \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Restore a contact

POST/v1/contacts/[contactId]/restore

Lift a block, or a bounce once the address was fixed.

Requires contacts:tag.

The contact with the status the rest of its history puts it at: someone who had unsubscribed before being blocked is unsubscribed again.

Refused (409) for an unsubscribe, which only the person can undo from the link in their emails, and for a spam complaint, which is never undone.

curl -X POST https://api.fromly.io/v1/contacts/[contactId]/restore \
  -H "Authorization: Bearer fk_live_…"

List mailing lists

GET/v1/lists

Every mailing list, with how many people can be emailed on each.

Requires contacts:read.

`{ data: [{ id, name, description, subscribers, createdAt }] }`. Not paged.

curl https://api.fromly.io/v1/lists \
  -H "Authorization: Bearer fk_live_…"

Create a list

POST/v1/lists

Create a mailing list nobody is on yet.

Requires contacts:write.

Body

{ "name": "Newsletter", "description": "New features, once a month." }

201 with `{ list }`. 409 when a list already has that name.

The description is what recipients read under the name on the unsubscribe page.

curl -X POST https://api.fromly.io/v1/lists \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Update a list

PATCH/v1/lists/[listId]

Rename a list or change its description.

Requires contacts:write.

Body

{ "name": "Product updates" }

`{ list }`.

curl -X PATCH https://api.fromly.io/v1/lists/[listId] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Delete a list

DELETE/v1/lists/[listId]

Delete a list and every membership in it.

Requires contacts:write.

`{ deleted: true, id }`. 409 for the last list, or while a scheduled or sending campaign targets it.

curl -X DELETE https://api.fromly.io/v1/lists/[listId] \
  -H "Authorization: Bearer fk_live_…"

List tags

GET/v1/tags

Every tag in the workspace, with how many contacts carry it.

Requires contacts:read.

`{ data: [{ name, label, contactCount, reserved, createdAt }] }`, sorted by name. Not paged.

Tags set from Stripe (`lifecycle:*`, `plan:*`, `billing:*`, `customer`, `has-paid`...) are listed with `reserved: true`. They can be searched like any other tag, but not created, renamed, deleted or applied by hand.

curl https://api.fromly.io/v1/tags \
  -H "Authorization: Bearer fk_live_…"

Create a tag

POST/v1/tags

Add a tag to the vocabulary without putting it on anyone.

Requires contacts:write.

Body

{ "name": "Beta testers" }

201 when it created, 200 when the tag already existed (`created: false`).

The name is slugified: `Beta testers` becomes `beta-testers`, and the reply says what it became. Creating a tag sends nothing, which is why it needs only `contacts:write`.

curl -X POST https://api.fromly.io/v1/tags \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Retrieve a tag

GET/v1/tags/[tag]

One tag, with its contact count and the flows that name it.

Requires contacts:read.

`automations` lists the flows using the tag as a trigger or in a step.

curl https://api.fromly.io/v1/tags/[tag] \
  -H "Authorization: Bearer fk_live_…"

Rename a tag

PATCH/v1/tags/[tag]

Rename a tag on every contact carrying it, in one step.

Requires contacts:write.

Body

{ "name": "early-access" }

`{ name, renamed }`. 409 while a flow names the tag, when the new name exists, or for a reserved tag.

Nothing is sent: a rename writes no tag.added event. A flow references a tag by its name, so change the flow first.

curl -X PATCH https://api.fromly.io/v1/tags/[tag] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Delete a tag

DELETE/v1/tags/[tag]

Delete a tag and remove it from every contact carrying it.

Requires contacts:write.

`{ name, deleted: true }`. 409 while a flow names the tag, or for a reserved tag.

Cannot be undone. Nothing is sent.

curl -X DELETE https://api.fromly.io/v1/tags/[tag] \
  -H "Authorization: Bearer fk_live_…"

List events

GET/v1/events

The event stream, newest first.

Requires events:read.

Query parameterMeaning
fA filter, repeatable. Prefix a field with `contact.` to filter on the person instead of the event.
limitPage size, 1–100. Defaults to 25.
cursorThe `nextCursor` from the previous reply.
curl https://api.fromly.io/v1/events \
  -H "Authorization: Bearer fk_live_…"

Record an event

POST/v1/events

Record a product event, creating the contact if it is unknown.

Requires events:write.

Body

{ "name": "trial.started", "email": "a@b.com", "properties": {}, "idempotencyKey": "…" }

202. The event is stored; enrolment happens when the sweep next runs, within a minute.

An event can trigger a flow that is already live.

curl -X POST https://api.fromly.io/v1/events \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

List event types

GET/v1/events/types

The events a flow can start from: the built-ins, then the ones your own code sends.

Requires events:read.

`{ data: { builtIn, custom } }`. Each entry carries its `name`, how many `events` arrived and `lastSeenAt`; a built-in also has its `label`, its `origin` and whether anything `emitted` it yet.

A flow trigger compares names exactly. Use a name from this list, never a guess.

curl https://api.fromly.io/v1/events/types \
  -H "Authorization: Bearer fk_live_…"

List emails

GET/v1/emails

The email library, most recently edited first. Without the content.

Requires emails:read.

Query parameterMeaning
limitPage size, 1–100. Defaults to 25.
cursorThe `nextCursor` from the previous reply.
kindOptional: "marketing" or "transactional" to list one kind only.
curl https://api.fromly.io/v1/emails \
  -H "Authorization: Bearer fk_live_…"

Create an email

POST/v1/emails

Create an email, empty or with its content in the same call.

Requires emails:write.

Body

{ "name": "Welcome", "subject": "Hello", "kind": "transactional", "markdown": "# Hi {{contact.firstName|there}}\n\nYour order {{data.orderId}} is on its way." }

201, with `warnings` listing anything the content normaliser discarded. `email.url` opens it in the dashboard.

"kind" is set at creation and cannot be changed later: "transactional" makes a template the transactional endpoint accepts, rendered without an unsubscribe link. Omitted means marketing. "themeId" picks its look (`GET /themes`); omitted, it wears the workspace default. Send `markdown` (headings, paragraphs, **bold**, *italic*, [links](https://…), lists, > quotes, ---): the simplest way, framed by the workspace header and footer. Or send `content`, a TipTap document: call `email_block_types` for every element, its attributes and an example. Pass `themeId` (from `list_email_themes`) to choose the look; left out, the email keeps its theme, or gets the workspace default. Merge tags work in text and URLs: {{contact.firstName|fallback}}, {{contact.properties.<key>}}, {{data.<key>}} for transactional templates. Unknown elements and unsafe links are removed and reported in `warnings`.

curl -X POST https://api.fromly.io/v1/emails \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Retrieve an email

GET/v1/emails/[emailId]

One email, with its content in the TipTap format.

Requires emails:read.

curl https://api.fromly.io/v1/emails/[emailId] \
  -H "Authorization: Bearer fk_live_…"

Update an email

PATCH/v1/emails/[emailId]

Save an email. There is no status to refuse against: an email is always editable.

Requires emails:write.

Body

{ "subject": "Hello again" }

`{ email, warnings }`. `email.url` opens it in the dashboard.

`content` or `markdown` replaces the whole body, never patched, but not its design: the theme, the email’s own overrides and, with Markdown, its header and footer stay unless the new content says otherwise. "themeId" changes the theme alone ("base" for the brand, null for the default). What a campaign already sent is its own frozen snapshot, so editing here cannot reach into the past.

curl -X PATCH https://api.fromly.io/v1/emails/[emailId] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Delete an email

DELETE/v1/emails/[emailId]

Remove an email from the library.

Requires emails:delete.

200 with { deleted: true }. 409 when a campaign that has been sent, or a live automation, still uses it. 404 when it does not exist.

Needs its own scope, held by the full-access level only. Nothing can tell whether code of yours sends a transactional email by this id, so deleting a template can break calls that are running.

curl -X DELETE https://api.fromly.io/v1/emails/[emailId] \
  -H "Authorization: Bearer fk_live_…"

List themes

GET/v1/themes

The workspace’s email themes, Base (the brand alone) first, with their styles.

Requires emails:read.

`{ data: [{ id, name, styles, isDefault, builtIn, createdAt, updatedAt, url }] }`. Not paged.

The theme marked `isDefault` is what an email wears when it names none. An email names another with `themeId`.

curl https://api.fromly.io/v1/themes \
  -H "Authorization: Bearer fk_live_…"

Create a theme

POST/v1/themes

A theme from `styles`, a copy of `fromId`, or a blank one.

Requires emails:write.

Body

{ "name": "Acme", "styles": { "button": { "backgroundColor": "#ee5a1a", "borderRadius": "6px" }, "paragraph": { "fontSize": "16px", "color": "#333333" } } }

201 with `{ theme, warnings }`. `warnings` lists what the theme format refused.

`styles` holds, for each part of an email (body, container, h1–h3, paragraph, list, listItem, link, image, button), CSS properties: colours in hex, sizes in px or %. Never the default when created.

curl -X POST https://api.fromly.io/v1/themes \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Retrieve a theme

GET/v1/themes/[themeId]

One theme with its styles. `base` is the brand alone.

Requires emails:read.

curl https://api.fromly.io/v1/themes/[themeId] \
  -H "Authorization: Bearer fk_live_…"

Update a theme

PATCH/v1/themes/[themeId]

Rename a theme or replace its styles, whole.

Requires emails:write.

Body

{ "styles": { "button": { "backgroundColor": "#111111" } } }

`{ theme, warnings }`. 400 for Base, which is the brand itself.

Every email wearing the theme changes with it, campaigns and flows not sent yet included.

curl -X PATCH https://api.fromly.io/v1/themes/[themeId] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Delete a theme

DELETE/v1/themes/[themeId]

Delete a theme. Emails that wore it fall back to the default.

Requires emails:delete.

`{ deleted: true, id }`.

Needs the full-access level: it changes how emails look without anyone opening them.

curl -X DELETE https://api.fromly.io/v1/themes/[themeId] \
  -H "Authorization: Bearer fk_live_…"

Set the default theme

POST/v1/themes/[themeId]/default

Make a theme, or `base`, the one emails wear when they name none.

Requires emails:write.

`{ theme }`.

Nothing is sent, but every email without a theme of its own changes look.

curl -X POST https://api.fromly.io/v1/themes/[themeId]/default \
  -H "Authorization: Bearer fk_live_…"

Preview a theme

POST/v1/themes/[themeId]/preview

The HTML of a sample email, or of `emailId`, drawn in the theme.

Requires emails:read.

Body

{ "styles": { "button": { "backgroundColor": "#ee5a1a" } } }

`{ html, warnings }`.

`styles` replaces the saved ones for this preview only, so a theme can be checked before it is written. Writes nothing.

curl -X POST https://api.fromly.io/v1/themes/[themeId]/preview \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

List headers and footers

GET/v1/email-components

The workspace’s headers and footers, with their blocks.

Requires emails:read.

`{ data: [{ id, kind, name, content, isDefault, createdAt, updatedAt, url }] }`. Not paged.

An email’s `brandHeader` and `brandFooter` show one by `attrs.componentId`, or the default of its kind.

curl https://api.fromly.io/v1/email-components \
  -H "Authorization: Bearer fk_live_…"

Create a header or footer

POST/v1/email-components

A header or footer from email blocks, or a copy of `fromId`.

Requires emails:write.

Body

{ "kind": "header", "name": "Launch", "content": [{ "type": "logoName", "attrs": { "src": "https://example.com/logo.png", "text": "Acme" } }] }

201 with `{ component, warnings }`.

Blocks are those of an email (`email_block_types` on the MCP), without a header or footer inside. A footer should hold an `unsubscribeLink`; without one, marketing emails get a plain unsubscribe line.

curl -X POST https://api.fromly.io/v1/email-components \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Retrieve a header or footer

GET/v1/email-components/[componentId]

One header or footer, with its blocks.

Requires emails:read.

curl https://api.fromly.io/v1/email-components/[componentId] \
  -H "Authorization: Bearer fk_live_…"

Update a header or footer

PATCH/v1/email-components/[componentId]

Rename it or replace its blocks, whole.

Requires emails:write.

Body

{ "name": "Launch header" }

`{ component, warnings }`.

Every email showing it changes with it.

curl -X PATCH https://api.fromly.io/v1/email-components/[componentId] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Delete a header or footer

DELETE/v1/email-components/[componentId]

Delete it. Emails that showed it fall back to the default of its kind.

Requires emails:delete.

`{ deleted: true, id }`. 409 for the default.

curl -X DELETE https://api.fromly.io/v1/email-components/[componentId] \
  -H "Authorization: Bearer fk_live_…"

Set the default header or footer

POST/v1/email-components/[componentId]/default

Make it the one emails show when they name none.

Requires emails:write.

`{ component }`.

curl -X POST https://api.fromly.io/v1/email-components/[componentId]/default \
  -H "Authorization: Bearer fk_live_…"

List campaigns

GET/v1/broadcasts

Campaigns and their results, newest first.

Requires broadcasts:read.

Query parameterMeaning
limitPage size, 1–100. Defaults to 25.
cursorThe `nextCursor` from the previous reply.
curl https://api.fromly.io/v1/broadcasts \
  -H "Authorization: Bearer fk_live_…"

Create a campaign

POST/v1/broadcasts

Create a campaign draft and the email it carries, in one write.

Requires broadcasts:write.

Body

{ "name": "March update", "subject": "What shipped", "markdown": "# What shipped\n\n…", "fromDomainId": "…" }

201. Always a draft. `broadcast.url` opens it in the dashboard, where it is reviewed and sent.

Scheduling is not accepted here: setting a time runs the sendability checks and moves the campaign to SCHEDULED, which is a deferred send and needs broadcasts:send.

curl -X POST https://api.fromly.io/v1/broadcasts \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Retrieve a campaign

GET/v1/broadcasts/[broadcastId]

One campaign. The top-level subject and content are the departure snapshot; `email` is what it would send if it left now.

Requires broadcasts:read.

curl https://api.fromly.io/v1/broadcasts/[broadcastId] \
  -H "Authorization: Bearer fk_live_…"

Update a campaign

PATCH/v1/broadcasts/[broadcastId]

Save a campaign draft.

Requires broadcasts:write.

Body

{ "name": "March update", "subject": "What shipped" }

409 once the campaign is SENDING or SENT — editing then would give some recipients one email and some another.

This writes the email the campaign carries, never the departure snapshot. A verified domain of this workspace is the only sender accepted; anything else falls back to the workspace default.

curl -X PATCH https://api.fromly.io/v1/broadcasts/[broadcastId] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Send a campaign

POST/v1/broadcasts/[broadcastId]/send

Send a campaign.

Requires broadcasts:send.

Body

{ "audience": { "lists": ["<list id>"], "filters": ["status:is:SUBSCRIBED"] } }  (optional: omitting it means everyone on every list)

202 with the recipient count. 409 when the campaign has already been sent, has nobody to send to, has no verified domain, needs more emails than the account has left, or another campaign is already in flight.

Counted against the strict send budget, not the ordinary one. Only people still on one of the lists receive it; someone who left a list from the unsubscribe page is skipped even if a filter matches them.

curl -X POST https://api.fromly.io/v1/broadcasts/[broadcastId]/send \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Send a transactional email

POST/v1/transactional

Send one email to one address, immediately. Either "emailId" (a transactional template from your library, rendered with your variables as {{data.*}}) or raw "subject" and "html", passed through untouched.

Requires transactional:send.

Body

{ "to": "ada@example.com", "emailId": "…", "data": { "amount": "12 €" }, "idempotencyKey": "order-42" }

200 with { messageId, status, to, replay }: status is "sent" once the email has left. A replayed idempotencyKey returns the original message with "replay": true and sends nothing. 409 when the address has bounced or complained, the emailId is not a transactional template, the account has no emails left, or no verified domain can send. 502 with { error, messageId, status: "failed" } when the email could not be sent: the id lets you find it with GET /messages.

Has its own budget of 120 sends a minute per key. No unsubscribe link or List-Unsubscribe header is added: an unsubscribed contact still receives transactional mail, a bounced or complained address never does. Sent from "fromDomainId" when given, else your transactional domain, else the default. Delivered, opened and bounced arrive after the send: read them with GET /messages/[messageId].

curl -X POST https://api.fromly.io/v1/transactional \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

List sent emails

GET/v1/messages

Every email that left, newest first, from campaigns, flows and the transactional endpoint. Test sends are not included.

Requires messages:read.

Query parameterMeaning
limitPage size, 1–100. Defaults to 25.
cursorThe `nextCursor` from the previous reply.
sourceOptional: "broadcast", "automation" or "transactional".
statusOptional: sent, delivered, opened, clicked, bounced, complained or failed.
qOptional: search the address, the subject or the contact name.

A page of messages with their timestamps, bounce details and source.

curl https://api.fromly.io/v1/messages \
  -H "Authorization: Bearer fk_live_…"

Retrieve a sent email

GET/v1/messages/[messageId]

One email and what became of it: delivered, opened, clicked, bounced (with its type) or marked as spam. The id is the messageId returned by POST /transactional.

Requires messages:read.

Query parameterMeaning
includeOptional: "html" adds the email as it was sent. It contains the recipient's data.

404 when the id is unknown, belongs to another workspace, or is a test send.

curl https://api.fromly.io/v1/messages/[messageId] \
  -H "Authorization: Bearer fk_live_…"

List sending domains

GET/v1/domains

Your sending domains with their status, purpose and sender, to choose a "fromDomainId" for a send. DNS records are not included.

Requires domains:read.

curl https://api.fromly.io/v1/domains \
  -H "Authorization: Bearer fk_live_…"

List automations

GET/v1/automations

Every flow, with its status and how many people are in it.

Requires automations:read.

curl https://api.fromly.io/v1/automations \
  -H "Authorization: Bearer fk_live_…"

Create an automation

POST/v1/automations

Create a flow, empty or with a complete graph in the same call.

Requires automations:write.

Body

{ "name": "Onboarding", "graph": { "trigger": { "type": "event", "event": "user.signed_up" }, "nodes": [ … ] } }

201, with `warnings` listing anything the graph normaliser discarded, and a trigger event this workspace has never received with the likeliest real name. `automation.url` opens it in the dashboard.

Always a draft. A flow created active would send immediately on automations:write alone, which is exactly what automations:activate stands in front of.

curl -X POST https://api.fromly.io/v1/automations \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Automation figures

GET/v1/automations/[automationId]

Run totals and per-step figures for one flow. Every step is listed, including those nobody has reached.

Requires automations:read.

curl https://api.fromly.io/v1/automations/[automationId] \
  -H "Authorization: Bearer fk_live_…"

Update an automation

PATCH/v1/automations/[automationId]

Replace a flow’s name, its graph, or both.

Requires automations:write.

Body

{ "graph": { "trigger": { … }, "nodes": [ … ] } }

`{ automation, warnings }`. `automation.url` opens it in the dashboard.

A live flow may be edited through the API, deliberately: the editor pauses first only because it autosaves every keystroke, which a single complete write does not do. The status still cannot be changed here.

curl -X PATCH https://api.fromly.io/v1/automations/[automationId] \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

List a step’s cohort

GET/v1/automations/[automationId]/cohort/[nodeId]

The people who entered one step, oldest first.

Requires automations:read.

Query parameterMeaning
limitPage size, 1–100. Defaults to 25.
cursorThe `nextCursor` from the previous reply.
curl https://api.fromly.io/v1/automations/[automationId]/cohort/[nodeId] \
  -H "Authorization: Bearer fk_live_…"

Change the status

POST/v1/automations/[automationId]/status

Turn a flow on or off.

Requires automations:activate.

Body

{ "status": "ACTIVE" }  — one of DRAFT, ACTIVE, PAUSED.

409 when the flow is already in that state, or when activating a graph that cannot run.

Counted against the strict send budget: an active flow sends on its own.

curl -X POST https://api.fromly.io/v1/automations/[automationId]/status \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Searching contacts

/v1/contacts takes readable parameters for the questions an integration asks most, and f for everything else. They all combine with AND.

Find someone from a fragment of their name or address

https://api.fromly.io/v1/contacts?q=lovelace

Look up known addresses

https://api.fromly.io/v1/contacts?email=ada@acme.com,grace@navy.mil

Subscribed beta testers who signed up this month, newest first

https://api.fromly.io/v1/contacts?tag=beta&status=SUBSCRIBED&createdAfter=2026-10-01&order=desc

Everything that changed since your last sync

https://api.fromly.io/v1/contacts?updatedAfter=2026-10-08T09:00:00Z&sort=updatedAt

A value that makes no sense, such as an unknown status or a malformed date, is a 400 that names the parameter. Keep the same sort and order while you follow nextCursor: a cursor issued for one order is refused under another.

Tags

A tag is a label on a contact. Contacts carry two kinds, returned together in tags:

KindSet byWhat you can do
ManualYou, from the app, the API or an automation step. Listed in manualTags.Create, rename, delete, add to a contact, remove from a contact.
ReservedStripe: lifecycle:*, plan:*, billing:*, customer, has-paid…Search and filter on them. They are recomputed at every sync, so they cannot be changed by hand.

Names are slugified when they are created: Beta testers becomes beta-testers. Use the slug everywhere else, in paths and in tag=.

Managing the vocabulary (/v1/tags) needs contacts:write and sends nothing. Adding a tag to a contact is different: it records a tag.added event, and any live flow triggered by that tag enrols the contact. That is why it needs contacts:tag, which only a full-access key holds, and why it counts against the stricter send budget.

A flow refers to a tag by its name. While one does, renaming or deleting the tag is refused with a 409 that lists the flows, and /v1/tags/{tag} shows them in automations.

# Create a tag, put it on a contact, list who carries it
curl -X POST https://api.fromly.io/v1/tags -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" -d '{ "name": "beta" }'
curl -X POST https://api.fromly.io/v1/contacts/<contactId>/tags -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" -d '{ "tag": "beta" }'
curl "https://api.fromly.io/v1/contacts?tag=beta" -H "Authorization: Bearer fk_live_…"

Filters

Lists that accept f use the same syntax the application puts in its own URLs: field:operator:value, repeated for each condition, combined with AND. A filter that is not recognised is ignored rather than refused, so a hand-written query narrows the list or does nothing — it never fails.

https://api.fromly.io/v1/contacts?f=status:is:SUBSCRIBED&f=plan:contains:pro

A field written prop.<key> filters on a custom contact property: f=prop.language:is:fr. The same form works in broadcast audiences and automation conditions.

On /v1/events, a field prefixed with contact. asks about the person rather than the event. Because an event need not have a contact, asking anything about the person drops events with nobody attached.