{"openapi":"3.1.0","info":{"title":"Flomailr REST API","version":"1.0.0","summary":"Contacts, lists, campaigns, forms, automations, events and transactional email.","description":"The Flomailr REST API lets your own code manage contacts, lists, campaigns, forms and automations in one Flomailr workspace, record events from your app so automations can react, and send transactional email.\n\nThis document describes every route under `/api/v1`. It is written so a person or an AI coding agent (Claude Code, Cursor, Codex) can integrate from the spec alone. AI apps that speak MCP can instead use the hosted Flomailr MCP connector at https://flomailr.com/api/mcp. The connector is a separate interface and is not described here.\n\n## Authentication\n\nSend an API key as a bearer token on every request: `Authorization: Bearer flo_sk_live_...`. A workspace admin creates keys in the dashboard at `/dashboard/settings/api-keys`. The full key is shown once, when it is created, and cannot be retrieved later, so store it as a secret. A key belongs to one workspace and every route only sees that workspace's data. A contact, list, campaign, form, automation or key from another workspace is reported as `404`, the same as one that does not exist. A missing, empty, unknown or revoked key gets `401`.\n\n## Responses\n\nA single object comes back as `{ \"data\": { ... } }`. A list comes back as `{ \"data\": [ ... ], \"pagination\": { \"total\", \"limit\", \"hasMore\", \"nextCursor\" } }`. An error comes back as `{ \"error\": { \"code\", \"message\" } }`. Successful `DELETE` requests return `204` with no body, except `DELETE /keys/{id}` (returns the revoked key's id and `revokedAt`) and `DELETE /campaigns/{id}/schedule` (returns the campaign), which return `200`. Timestamps are ISO 8601 UTC strings.\n\nMost validation failures are `422`. `POST /events` and `POST /send` answer `400` when the body is not valid JSON. Most other routes treat an unreadable body as an empty one and then fail validation with `422`.\n\n## Pagination\n\nList routes take `limit` (default 20, maximum 100) and `after` (the previous response's `nextCursor`). `nextCursor` is null on the last page. Lists are newest first. `total` counts every item matching the filters, not just the current page.\n\n## Consent and contact status\n\nOnly SUBSCRIBED contacts receive campaigns. Read the rules before importing people, because they decide whether someone gets marketing email.\n\n- `POST /events` creates an unknown email address as PENDING, never SUBSCRIBED. A purchase or signup event is not a marketing opt-in.\n- `POST /contacts` creates a new address with the `status` you send, and SUBSCRIBED when you send none. Calling it means you are stating the person agreed to receive marketing email. If you are not sure, send `status: \"PENDING\"`.\n- An address that has opted out (UNSUBSCRIBED or BOUNCED, or a deleted contact whose opt-out was kept as a suppression entry) is not moved back to SUBSCRIBED by `status` alone. That needs `consent: \"explicit\"`, which you may only send when the person themselves said yes. Details are on `POST /contacts`.\n- Setting a contact to UNSUBSCRIBED or BOUNCED suppresses the address and removes it from every list.\n\n## Sending limits\n\nSending is where the guardrails are. Campaign sends (`POST /campaigns/{id}/send`) skip suppressed addresses and blocked domains and are refused as a whole when they would pass the workspace's daily cap or monthly plan limit, or when they start inside quiet hours. Transactional sends (`POST /send`) skip addresses suppressed for a hard bounce, a complaint or a manual block and blocked domains, and count toward the monthly plan limit, but quiet hours and the daily cap do not apply to them. Neither can send while the workspace is suspended or its guardrails switch is off.\n\nThe routes in this document do not apply a request rate limit of their own. Limits on sending are the ones above.\n\nEverything here changes real data in a real workspace. The send routes email real people and cannot be undone."},"servers":[{"url":"https://flomailr.com/api/v1","description":"Production"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Contacts","description":"The people you email. Status and consent rules decide who receives marketing email."},{"name":"Lists","description":"Named groups of contacts that campaigns are sent to."},{"name":"Campaigns","description":"Create, schedule and send one-off emails to a list. Sending emails real people."},{"name":"Forms","description":"Signup forms and their public submit URL."},{"name":"Automations","description":"Visual flows that react to triggers such as a new subscriber or an event."},{"name":"Events","description":"Record things that happen in your app so automations can react."},{"name":"Transactional send","description":"One message a person's own action asked for, to 1 to 5 recipients."},{"name":"API keys","description":"List, create and revoke the workspace's API keys."}],"externalDocs":{"description":"Flomailr documentation","url":"https://flomailr.com/docs"},"paths":{"/contacts":{"get":{"operationId":"listContacts","summary":"List contacts","description":"List the workspace's contacts, newest first, with optional filters. Use `after` to page.","tags":["Contacts"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"},{"name":"status","in":"query","required":false,"description":"Only contacts with this status. Any other value is a `422`.","schema":{"$ref":"#/components/schemas/ContactStatus"}},{"name":"tag","in":"query","required":false,"description":"Only contacts that have exactly this tag.","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Case-insensitive search in email, first name and last name.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of contacts.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"description":"`status` is not PENDING, SUBSCRIBED, UNSUBSCRIBED or BOUNCED.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"upsertContact","summary":"Create or update a contact by email","description":"Create a contact, or update the one that already has this email address (the email is the key inside a workspace). Returns `201` for a new contact and `200` for an existing one.\n\nStatus and consent rules:\n\n- A new address with no earlier opt-out gets the status you send. With no `status` it is created SUBSCRIBED, which is you stating that the person agreed to marketing email. Send `status: \"PENDING\"` when you are not sure.\n- An existing PENDING contact becomes SUBSCRIBED when the request's status is SUBSCRIBED (the default on `POST /contacts`). A SUBSCRIBED contact is never downgraded to PENDING.\n- An opt-out is not undone by a bare request. A contact that is UNSUBSCRIBED stays UNSUBSCRIBED unless the request has `status: \"SUBSCRIBED\"` (or no status) together with `consent: \"explicit\"`. A BOUNCED contact is never raised. An address that has no contact row but still has a suppression entry is treated the same way (as BOUNCED when the suppression reason is a hard bounce, otherwise as UNSUBSCRIBED).\n- A request can always lower a contact. `status: \"UNSUBSCRIBED\"`, or `consent: \"declined\"` (which beats any status in the same request), makes the contact UNSUBSCRIBED. `status: \"BOUNCED\"` makes a PENDING or SUBSCRIBED contact BOUNCED and never overwrites an earlier UNSUBSCRIBED or BOUNCED.\n- `consent: \"explicit\"` only counts together with SUBSCRIBED (or no status). Sent with PENDING it is ignored.\n- `consent` is recorded in the contact's consent log with source `api`: `declined` logs a withdrawal, and `explicit` logs consent given when the resulting status is SUBSCRIBED. The log is not readable through this API.\n- Raising a status never lifts a suppression. An address on the workspace suppression list is still skipped when campaigns are sent, whatever its contact status says.\n- When the resulting status is UNSUBSCRIBED or BOUNCED the address is added to the suppression list and removed from every list. When it is SUBSCRIBED, live automations with a new-subscriber trigger may start.\n\nOn an existing contact, `tags` replaces the tag list and an omitted `tags` empties it. An empty or omitted `firstName` or `lastName` leaves that field as it is.","tags":["Contacts"],"requestBody":{"description":"The contact to create or update.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactUpsertRequest"},"example":{"email":"ada@example.com","firstName":"Ada","tags":["customer"],"status":"PENDING"}}}},"responses":{"200":{"description":"An existing contact was updated. The status in the body is the status after the rules above were applied.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}},"required":["data"]},"example":{"data":{"id":"clx8k2m4p0001abcd","email":"ada@example.com","firstName":"Ada","lastName":"Lovelace","tags":["customer"],"status":"SUBSCRIBED","createdAt":"2026-10-04T12:00:00.000Z"}}}}},"201":{"description":"A new contact was created.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"description":"`email` is missing or invalid, `consent` is not \"explicit\" or \"declined\", or the tags are too many or too long. A body that is not valid JSON is read as empty and fails with a missing email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/contacts/{id}":{"parameters":[{"$ref":"#/components/parameters/ContactId"}],"get":{"operationId":"getContact","summary":"Get a contact","description":"Fetch one contact by id.","tags":["Contacts"],"responses":{"200":{"description":"The contact.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No contact with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"operationId":"updateContact","summary":"Update a contact","description":"Change some fields of a contact. Send only the fields to change. `status` and `consent` follow the same rules as `POST /contacts` (below), with these differences: an unrecognized `status` is a `422`, and the contact's own current status is used (there is no separate suppression lookup).\n\nStatus and consent rules:\n\n- A new address with no earlier opt-out gets the status you send. With no `status` it is created SUBSCRIBED, which is you stating that the person agreed to marketing email. Send `status: \"PENDING\"` when you are not sure.\n- An existing PENDING contact becomes SUBSCRIBED when the request's status is SUBSCRIBED (the default on `POST /contacts`). A SUBSCRIBED contact is never downgraded to PENDING.\n- An opt-out is not undone by a bare request. A contact that is UNSUBSCRIBED stays UNSUBSCRIBED unless the request has `status: \"SUBSCRIBED\"` (or no status) together with `consent: \"explicit\"`. A BOUNCED contact is never raised. An address that has no contact row but still has a suppression entry is treated the same way (as BOUNCED when the suppression reason is a hard bounce, otherwise as UNSUBSCRIBED).\n- A request can always lower a contact. `status: \"UNSUBSCRIBED\"`, or `consent: \"declined\"` (which beats any status in the same request), makes the contact UNSUBSCRIBED. `status: \"BOUNCED\"` makes a PENDING or SUBSCRIBED contact BOUNCED and never overwrites an earlier UNSUBSCRIBED or BOUNCED.\n- `consent: \"explicit\"` only counts together with SUBSCRIBED (or no status). Sent with PENDING it is ignored.\n- `consent` is recorded in the contact's consent log with source `api`: `declined` logs a withdrawal, and `explicit` logs consent given when the resulting status is SUBSCRIBED. The log is not readable through this API.\n- Raising a status never lifts a suppression. An address on the workspace suppression list is still skipped when campaigns are sent, whatever its contact status says.\n- When the resulting status is UNSUBSCRIBED or BOUNCED the address is added to the suppression list and removed from every list. When it is SUBSCRIBED, live automations with a new-subscriber trigger may start.\n\nIf the rules leave the status as it was, the request still succeeds with `200` and the response shows the unchanged status. `consent: \"declined\"` with no `status` makes the contact UNSUBSCRIBED. `consent: \"explicit\"` with no `status` does nothing. An empty body changes nothing.","tags":["Contacts"],"requestBody":{"description":"The fields to change.","required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactUpdateRequest"},"example":{"tags":["customer","vip"]}}}},"responses":{"200":{"description":"The updated contact.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No contact with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Another contact in the workspace already has the new `email`. Code `CONFLICT`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid `email`, `status` or `consent`, `tags` is not an array, or there are too many or too long tags.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"deleteContact","summary":"Delete a contact","description":"Permanently delete a contact. If the contact is UNSUBSCRIBED or BOUNCED, the address is first added to the suppression list so deleting it cannot erase the opt-out; if that step fails the contact is not deleted and the call returns `500`. Deleting a contact also removes it from lists.","tags":["Contacts"],"responses":{"204":{"description":"The contact was deleted."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No contact with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/contacts/{id}/lists":{"parameters":[{"$ref":"#/components/parameters/ContactId"}],"get":{"operationId":"getContactLists","summary":"List the lists a contact is on","description":"Return every list the contact belongs to. Not paginated: the response is `{ data: [...] }` with no `pagination` block. To add or remove a contact, use the Lists routes.","tags":["Contacts"],"responses":{"200":{"description":"The lists the contact is on.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ContactListMembership"}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No contact with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/lists":{"get":{"operationId":"listLists","summary":"List email lists","description":"List the workspace's lists, newest first. A list is a named group of contacts a campaign can be sent to.","tags":["Lists"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"},{"name":"q","in":"query","required":false,"description":"Case-insensitive search in the list name.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of lists.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EmailList"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"createList","summary":"Create a list","description":"Create an empty list.","tags":["Lists"],"requestBody":{"description":"The new list.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Required. Trimmed."}},"required":["name"]},"example":{"name":"Newsletter"}}}},"responses":{"201":{"description":"The list was created.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EmailList"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"description":"`name` is missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/lists/{id}":{"parameters":[{"$ref":"#/components/parameters/ListId"}],"get":{"operationId":"getList","summary":"Get a list","description":"Fetch one list with its contact count.","tags":["Lists"],"responses":{"200":{"description":"The list.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EmailList"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No list with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"operationId":"updateList","summary":"Rename a list","description":"Rename a list. `name` is the only field and it is required.","tags":["Lists"],"requestBody":{"description":"The new name.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Required. Trimmed. Cannot be empty."}},"required":["name"]},"example":{"name":"Customers"}}}},"responses":{"200":{"description":"The renamed list.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EmailList"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No list with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`name` is missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"deleteList","summary":"Delete a list","description":"Delete a list. The contacts on it are not deleted.","tags":["Lists"],"responses":{"204":{"description":"The list was deleted."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No list with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/lists/{id}/contacts":{"parameters":[{"$ref":"#/components/parameters/ListId"}],"get":{"operationId":"listListContacts","summary":"List the contacts on a list","description":"List the contacts that belong to a list, newest first. Items have the same shape as `GET /contacts`.","tags":["Lists"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"},{"name":"status","in":"query","required":false,"description":"Only contacts with this status. Any other value is a `422`.","schema":{"$ref":"#/components/schemas/ContactStatus"}},{"name":"q","in":"query","required":false,"description":"Case-insensitive search in email, first name and last name.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of contacts.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No list with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`status` is not PENDING, SUBSCRIBED, UNSUBSCRIBED or BOUNCED.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"addContactsToList","summary":"Add contacts to a list","description":"Add one contact or several to a list. Send `contactId` for one, or `contactIds` for many.\n\n- Every id must be a contact in this workspace. If any is unknown the whole request fails with `404` and nothing is added. Send each id once: a repeated id in `contactIds` makes the check fail.\n- Contacts already on the list are ignored.\n- Contacts whose status is UNSUBSCRIBED or BOUNCED are never added. They are counted in `skipped`, not rejected, so a mixed batch still succeeds.","tags":["Lists"],"requestBody":{"description":"One contact id or an array of them.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"contactId":{"type":"string","description":"A single contact id."},"contactIds":{"type":"array","items":{"type":"string"},"description":"Several contact ids. Non-string entries are dropped."}}},"example":{"contactIds":["clx8k2m4p0001abcd","clx8k2m4p0002abcd"]}}}},"responses":{"200":{"description":"How many contacts were added and skipped.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AddContactsResult"}},"required":["data"]},"example":{"data":{"added":2,"skipped":0}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"The list, or at least one contact id, is not in the workspace. Code `NOT_FOUND`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Neither `contactId` nor a non-empty `contactIds` was provided.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/lists/{id}/contacts/{contactId}":{"parameters":[{"$ref":"#/components/parameters/ListId"},{"$ref":"#/components/parameters/ListContactId"}],"delete":{"operationId":"removeContactFromList","summary":"Remove a contact from a list","description":"Take a contact off a list. The contact itself is not deleted. It is not an error if the contact was not on the list.","tags":["Lists"],"responses":{"204":{"description":"The contact is no longer on the list."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"The list or the contact is not in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/campaigns":{"get":{"operationId":"listCampaigns","summary":"List campaigns","description":"List the workspace's campaigns, newest first, with their stats.","tags":["Campaigns"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"},{"name":"status","in":"query","required":false,"description":"Only campaigns with this status. Any other value is a `422`.","schema":{"type":"string","enum":["DRAFT","SCHEDULED","SENT"]}},{"name":"q","in":"query","required":false,"description":"Case-insensitive search in name and subject.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of campaigns.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Campaign"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"description":"`status` is not DRAFT, SCHEDULED or SENT.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"createCampaign","summary":"Create a draft campaign","description":"Create a campaign from raw HTML. It is always created as a DRAFT and nothing is sent. To send it, set a list (here or with `PATCH /campaigns/{id}`) and call `POST /campaigns/{id}/send`, or schedule it with `POST /campaigns/{id}/schedule`.\n\nCampaigns made here carry only HTML. The visual block builder and the AI design tools in the MCP connector are separate ways to author a campaign.","tags":["Campaigns"],"requestBody":{"description":"The campaign to create.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignCreateRequest"},"example":{"name":"October newsletter","subject":"What's new this month","body":"<html><body><p>Hi {{firstName}},</p></body></html>","listId":"clx8k2m4p0001abcd"}}}},"responses":{"201":{"description":"The draft campaign. `stat` is null.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Campaign"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`listId` is not a list in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`name`, `subject` or `body` is missing, empty or over its limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/campaigns/{id}":{"parameters":[{"$ref":"#/components/parameters/CampaignId"}],"get":{"operationId":"getCampaign","summary":"Get a campaign","description":"Fetch one campaign with its stats. Poll this after a send that returned `complete: false` until `status` is SENT. The HTML body is not returned.","tags":["Campaigns"],"responses":{"200":{"description":"The campaign.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Campaign"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No campaign with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"operationId":"updateCampaign","summary":"Edit a campaign","description":"Change a campaign that has not been sent. Send only the fields to change. A SENT campaign cannot be edited (`422` with code `CAMPAIGN_ALREADY_SENT`). A SCHEDULED campaign can be edited.","tags":["Campaigns"],"requestBody":{"description":"The fields to change.","required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignUpdateRequest"},"example":{"subject":"New subject"}}}},"responses":{"200":{"description":"The updated campaign.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Campaign"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No campaign with this id, or `listId` is not a list in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"The campaign was already sent (code `CAMPAIGN_ALREADY_SENT`), or a field is empty or over its limit (code `VALIDATION_ERROR`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"deleteCampaign","summary":"Delete a campaign","description":"Permanently delete a campaign and its stats, whatever its status. This route does not check whether a send is in progress.","tags":["Campaigns"],"responses":{"204":{"description":"The campaign was deleted."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No campaign with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/campaigns/{id}/send":{"parameters":[{"$ref":"#/components/parameters/CampaignId"}],"post":{"operationId":"sendCampaign","summary":"Send a campaign now","description":"Send a campaign to its list immediately. THIS EMAILS REAL PEOPLE AND CANNOT BE UNDONE. Create and review the campaign first, and confirm with the user before calling. The audience is the SUBSCRIBED contacts on the campaign's list, so the campaign needs a `listId`.\n\nGuardrails (the workspace's settings; they apply to every campaign send):\n- Suppressed addresses, addresses in `excludeEmails`, blocked domains and invalid addresses are skipped, as are recipients who opted out of this kind of email in the preference centre.\n- The WHOLE send is refused, and nothing is sent, when guardrails are switched off, when it would pass the daily send cap, when it would pass the monthly plan limit, when it starts inside quiet hours (retry after they end, or pass `overrideQuietHours: true`), or when the workspace has no postal address set (it is printed in every footer).\n- A suspended workspace cannot send (`403`).\n\nRecipients go out in batches of 100. The request stops starting new batches after about 45 seconds. If recipients are still owed the response has `complete: false` and the rest goes out in the background, picked up by a job that runs every minute. Do not call again to finish it; a repeated call never mails anyone twice. Poll `GET /campaigns/{id}` until `status` is SENT.\n\nThe request body is optional.","tags":["Campaigns"],"requestBody":{"description":"Optional exclusions and the quiet-hours override. The body may be left out.","required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignSendRequest"}}}},"responses":{"200":{"description":"The send was accepted. `sent` and `failed` count this call only.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CampaignSendResult"}},"required":["data"]},"example":{"data":{"sent":800,"failed":0,"remaining":400,"complete":false}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Code `QUOTA_EXCEEDED`. The send would pass the workspace's monthly plan limit; nothing was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The workspace is suspended. Code `FORBIDDEN`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No campaign with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"The campaign was already sent (code `CAMPAIGN_ALREADY_SENT`), or the send was refused (code `VALIDATION_ERROR`) for a reason in `error.message`: no audience or no subscribed contacts, everyone excluded or suppressed, guardrails off, over the daily cap, quiet hours in effect, no postal address in settings, or the campaign is already being sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/campaigns/{id}/schedule":{"parameters":[{"$ref":"#/components/parameters/CampaignId"}],"post":{"operationId":"scheduleCampaign","summary":"Schedule a campaign","description":"Schedule a campaign to be sent at a future time. The campaign needs a list first. It moves to SCHEDULED, and a job that runs every minute sends it once it is due, through the same guardrails as `POST /campaigns/{id}/send`. A scheduled send that starts inside quiet hours is retried after they end. A scheduled send that fails for a reason that needs a person (for example over the monthly limit) can be put back to DRAFT.\n\nScheduling a campaign that is already SCHEDULED replaces its time.","tags":["Campaigns"],"requestBody":{"description":"When to send.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignScheduleRequest"},"example":{"scheduledAt":"2026-10-10T15:00:00Z"}}}},"responses":{"200":{"description":"The campaign is scheduled.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CampaignScheduleResult"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No campaign with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"The campaign was already sent (code `CAMPAIGN_ALREADY_SENT`), it has no list, or `scheduledAt` is missing, not a valid date, or less than 30 seconds ahead (code `VALIDATION_ERROR`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"unscheduleCampaign","summary":"Cancel a scheduled send","description":"Cancel a scheduled send. The campaign goes back to DRAFT and `scheduledAt` is cleared. Only a SCHEDULED campaign can be unscheduled.","tags":["Campaigns"],"responses":{"200":{"description":"The campaign is a draft again.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CampaignScheduleResult"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No campaign with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"The campaign is not SCHEDULED.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/forms":{"get":{"operationId":"listForms","summary":"List signup forms","description":"List the workspace's signup forms, newest first. Each has an `embedUrl` that visitors' browsers can post signups to.","tags":["Forms"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"}],"responses":{"200":{"description":"A page of forms.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Form"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"createForm","summary":"Create a signup form","description":"Create a signup form. The API does not expose a form's display mode or trigger settings; set those in the dashboard. Whether a signup is subscribed straight away or must confirm by email first depends on the workspace's signup rules.","tags":["Forms"],"requestBody":{"description":"The form to create.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormCreateRequest"},"example":{"name":"Footer signup","listId":"clx8k2m4p0001abcd","fields":["firstName","lastName"]}}}},"responses":{"201":{"description":"The form was created.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Form"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`listId` is not a list in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`name` is missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/forms/{id}":{"parameters":[{"$ref":"#/components/parameters/FormId"}],"get":{"operationId":"getForm","summary":"Get a form","description":"Fetch one form.","tags":["Forms"],"responses":{"200":{"description":"The form.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Form"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No form with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"operationId":"updateForm","summary":"Update a form","description":"Change some fields of a form. Send only the fields to change.","tags":["Forms"],"requestBody":{"description":"The fields to change.","required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormUpdateRequest"},"example":{"successMessage":"You're in."}}}},"responses":{"200":{"description":"The updated form.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Form"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No form with this id, or `listId` is not a list in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`name` is empty or `fields` is not an array.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"deleteForm","summary":"Delete a form","description":"Delete a form. Its `embedUrl` stops accepting signups.","tags":["Forms"],"responses":{"204":{"description":"The form was deleted."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No form with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/automations":{"get":{"operationId":"listAutomations","summary":"List automations","description":"List the workspace's automations, newest first. The flow itself is not included.","tags":["Automations"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"},{"name":"status","in":"query","required":false,"description":"Only automations with this status. Any other value is a `422`.","schema":{"$ref":"#/components/schemas/AutomationStatus"}}],"responses":{"200":{"description":"A page of automations.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Automation"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"description":"`status` is not DRAFT, LIVE or PAUSED.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"createAutomation","summary":"Create an automation","description":"Create a DRAFT automation containing one trigger node, \"New subscriber joins\". Add steps with `PATCH /automations/{id}` (`flowJson`) or in the visual builder, then publish it by patching `status` to LIVE.","tags":["Automations"],"requestBody":{"description":"Optional name. The body may be left out.","required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AutomationCreateRequest"},"example":{"name":"Welcome series"}}}},"responses":{"201":{"description":"The automation was created.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Automation"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/automations/{id}":{"parameters":[{"$ref":"#/components/parameters/AutomationId"}],"get":{"operationId":"getAutomation","summary":"Get an automation","description":"Fetch one automation. The flow (`flowJson`) is never returned.","tags":["Automations"],"responses":{"200":{"description":"The automation.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Automation"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No automation with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"operationId":"updateAutomation","summary":"Rename, edit the flow of, publish or pause an automation","description":"Change an automation's `name`, its `flowJson` or its `status`. Send only the fields to change.\n\n`flowJson` is checked before it is saved: it must have `nodes` (each with a string `id`, a string `type` and an object `data`) and `edges` (each with a string `source` and `target`), otherwise `422`. The flow cannot be read back through this API, so keep your own copy.\n\nStatus changes: LIVE from DRAFT or PAUSED, PAUSED from LIVE, DRAFT only from DRAFT. Anything else is `422` with code `AUTOMATION_WRONG_STATE`.","tags":["Automations"],"requestBody":{"description":"The fields to change.","required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AutomationUpdateRequest"},"example":{"status":"LIVE"}}}},"responses":{"200":{"description":"The updated automation.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Automation"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No automation with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`flowJson` has the wrong shape (code `VALIDATION_ERROR`), or the status change is not allowed (code `AUTOMATION_WRONG_STATE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"deleteAutomation","summary":"Delete an automation","description":"Delete an automation and its enrollments.","tags":["Automations"],"responses":{"204":{"description":"The automation was deleted."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No automation with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/automations/{id}/enrollments":{"parameters":[{"$ref":"#/components/parameters/AutomationId"}],"get":{"operationId":"listAutomationEnrollments","summary":"List an automation's enrollments","description":"List the contacts that have entered an automation, newest enrolment first, and where each one is.","tags":["Automations"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"},{"name":"status","in":"query","required":false,"description":"Only enrollments with this status. Any other value is a `422`.","schema":{"type":"string","enum":["ACTIVE","COMPLETED","FAILED"]}}],"responses":{"200":{"description":"A page of enrollments.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AutomationEnrollment"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No automation with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`status` is not ACTIVE, COMPLETED or FAILED.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/events":{"get":{"operationId":"listEvents","summary":"List recorded events","description":"List events recorded with `POST /events`, newest first. `name` is matched exactly against the stored, normalized name, so filter with the snake_case form (for example `order_placed`).","tags":["Events"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"},{"name":"name","in":"query","required":false,"description":"Only events with exactly this normalized name.","schema":{"type":"string"}},{"name":"contactId","in":"query","required":false,"description":"Only events for this contact.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of events.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"recordEvent","summary":"Record an event from your app","description":"Record something that happened in your own application (a purchase, a signup, a cart abandoned) against a contact, so an automation with a matching custom-event trigger can react. Recording an event also passes it to the workspace's lead-scoring rules. If starting the automation fails, the event is still recorded and the request still succeeds.\n\nContacts: send `email` or `contactId`. With `contactId` the contact must exist in the workspace (`404` otherwise). With `email`, an existing contact is used; if there is none, a new contact is created as PENDING, NEVER SUBSCRIBED, because an event is not a marketing opt-in. To subscribe someone, use `POST /contacts` with their consent.\n\nRetries: send your own `externalId` so a retry after a timeout does not record the event or run the automation twice.","tags":["Events"],"requestBody":{"description":"The event to record.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventRequest"},"example":{"name":"order_placed","email":"ada@example.com","properties":{"order_id":"1042","items":3},"value":59.9,"currency":"USD","externalId":"order-1042"}}}},"responses":{"200":{"description":"An event with this `externalId` was already recorded. Nothing new was recorded and no automation ran.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EventDuplicate"}},"required":["data"]}}}},"201":{"description":"The event was recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EventRecorded"}},"required":["data"]}}}},"400":{"description":"The body is not valid JSON. Code `VALIDATION_ERROR`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`contactId` is not a contact in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`name` is missing or normalizes to nothing, neither `email` nor `contactId` was given, `email` is invalid, `properties` is not an object or is too big, or a money value is invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/send":{"post":{"operationId":"sendTransactionalEmail","summary":"Send a transactional email","description":"Send one message that a person's own action asked for (a password reset, a receipt, a confirmation code) to 1 to 5 recipients. THIS EMAILS REAL PEOPLE. Do not use it for marketing or bulk mail; use a campaign.\n\nIt is deliberately not the campaign path:\n- No unsubscribe footer, no `List-Unsubscribe` header, no postal-address requirement, and no open or click tracking.\n- A marketing unsubscribe (a GLOBAL_UNSUBSCRIBE suppression) does not block it.\n- Quiet hours and the daily send cap do not apply.\n\nWhat still applies:\n- Addresses suppressed for a hard bounce, a spam complaint or a manual block are skipped and listed in `blocked`. So are addresses on the workspace's blocked-domains list.\n- If every recipient is blocked the request fails with `422`. If some are blocked, the rest are sent and the response is `200`.\n- The workspace must not be suspended and its guardrails switch must be on (`403` otherwise).\n- The send counts toward the monthly plan limit. One that would pass it fails with `402` `QUOTA_EXCEEDED`.\n\nThe message is sent from the workspace's sending address (its verified custom domain if it has one, otherwise the shared Flomailr address) with the workspace's from name.\n\nIdempotency: send an `Idempotency-Key` header to make retries safe. A repeat with the same key and the same body returns the original result with `replayed: true` and sends nothing. The same key with a different body is `409`. The same key while the first request is still running is `409`. A request that failed (for example `402` or `502`) is also stored under its key and replayed as the same error, so use a new key after fixing the cause.","tags":["Transactional send"],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Any string you choose, unique per logical message (for example `receipt-1042`). Makes retries safe, see above.","schema":{"type":"string"}}],"requestBody":{"description":"The message to send.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransactionalSendRequest"},"example":{"to":"ada@example.com","subject":"Your receipt","text":"Thanks for your order."}}}},"responses":{"200":{"description":"At least one recipient was accepted by the email provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransactionalSendResponse"},"example":{"data":{"sent":1,"failed":0,"blocked":[]}}}}},"400":{"description":"The body is not valid JSON. Code `VALIDATION_ERROR`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"The send would pass the monthly plan limit. Code `QUOTA_EXCEEDED`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The workspace is suspended, or sending is switched off by its guardrails. Code `FORBIDDEN`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The workspace for this key no longer exists. Not expected in normal use.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The `Idempotency-Key` was already used with a different request, or the first request with it is still running. Code `CONFLICT`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"A recipient address is invalid, `to` is empty or has too many addresses, `subject` is missing, neither `html` nor `text` was sent, `replyTo` is invalid, every recipient was blocked, or no sending address is configured. Code `VALIDATION_ERROR`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"},"502":{"description":"Every allowed recipient failed at the email provider. Code `INTERNAL_ERROR`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/keys":{"get":{"operationId":"listApiKeys","summary":"List API keys","description":"List the workspace's API keys, newest first, including revoked ones. The secret key itself is never returned. Any valid key can call this.","tags":["API keys"],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/After"}],"responses":{"200":{"description":"A page of API keys.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"createApiKey","summary":"Create an API key","description":"Create another API key for the workspace. The full secret is in the response ONCE and cannot be retrieved later: store it immediately. Any valid key can call this, so a key can mint more keys; use one key per integration so each can be revoked on its own. Keys can also be created in the dashboard by a workspace admin.","tags":["API keys"],"requestBody":{"description":"A name for the key.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Required. At most 100 characters.","maxLength":100}},"required":["name"]},"example":{"name":"CRM sync"}}}},"responses":{"201":{"description":"The key was created. `key` is shown only now.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyCreated"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"description":"`name` is missing, empty or longer than 100 characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/keys/{id}":{"parameters":[{"$ref":"#/components/parameters/ApiKeyId"}],"get":{"operationId":"getApiKey","summary":"Get an API key","description":"Fetch one key's details (never the secret).","tags":["API keys"],"responses":{"200":{"description":"The key.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKey"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No API key with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"revokeApiKey","summary":"Revoke an API key","description":"Revoke a key. It stops working immediately and stays in the list marked as revoked. Revoking an already revoked key is not an error. A key may revoke itself, which ends its own access. Unlike other `DELETE` routes this returns `200` with a body, not `204`.","tags":["API keys"],"responses":{"200":{"description":"The key is revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyRevoked"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No API key with this id in the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"flo_sk_live_<32 hex characters>","description":"A workspace API key, for example `flo_sk_live_...`. A workspace admin creates keys at /dashboard/settings/api-keys. The full key is shown once when it is created. Send it as `Authorization: Bearer <key>`."}},"parameters":{"Limit":{"name":"limit","in":"query","required":false,"description":"Items per page. Default 20, maximum 100. A value outside 1 to 100 is clamped into that range, a fraction is rounded down, and a value that is not a number uses the default.","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},"After":{"name":"after","in":"query","required":false,"description":"Cursor for the next page: the `nextCursor` from the previous response, passed back unchanged. Leave it out for the first page.","schema":{"type":"string"}},"ContactId":{"name":"id","in":"path","required":true,"description":"Contact id.","schema":{"type":"string"}},"ListId":{"name":"id","in":"path","required":true,"description":"List id.","schema":{"type":"string"}},"ListContactId":{"name":"contactId","in":"path","required":true,"description":"Contact id.","schema":{"type":"string"}},"CampaignId":{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}},"FormId":{"name":"id","in":"path","required":true,"description":"Form id.","schema":{"type":"string"}},"AutomationId":{"name":"id","in":"path","required":true,"description":"Automation id.","schema":{"type":"string"}},"ApiKeyId":{"name":"id","in":"path","required":true,"description":"API key id.","schema":{"type":"string"}}},"responses":{"Unauthorized":{"description":"The Authorization header is missing, is not `Bearer <key>`, the key is empty or unknown, or the key was revoked. Code `UNAUTHORIZED`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"InternalError":{"description":"An unexpected failure. Code `INTERNAL_ERROR`. Retrying later may work.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"schemas":{"ErrorCode":{"type":"string","enum":["UNAUTHORIZED","FORBIDDEN","NOT_FOUND","VALIDATION_ERROR","CONFLICT","QUOTA_EXCEEDED","CAMPAIGN_ALREADY_SENT","AUTOMATION_WRONG_STATE","INTERNAL_ERROR"],"description":"Machine-readable error code. Typical HTTP status for each:\n- `UNAUTHORIZED`: 401, missing, malformed, unknown or revoked API key.\n- `FORBIDDEN`: 403, the workspace is suspended or sending is switched off.\n- `NOT_FOUND`: 404, the resource is not in your workspace.\n- `VALIDATION_ERROR`: 422 for a bad field or a send blocker, 400 for a body that is not valid JSON on `POST /events` and `POST /send`.\n- `CONFLICT`: 409, a duplicate contact email on update, or an `Idempotency-Key` problem on `POST /send`.\n- `QUOTA_EXCEEDED`: 402, a send would pass the monthly plan limit.\n- `CAMPAIGN_ALREADY_SENT`: 422, the campaign was already sent.\n- `AUTOMATION_WRONG_STATE`: 422, the automation status change is not allowed from its current status.\n- `INTERNAL_ERROR`: 500 for an unexpected failure, 502 on `POST /send` when every recipient failed."},"ErrorResponse":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"$ref":"#/components/schemas/ErrorCode"},"message":{"type":"string","description":"Human-readable explanation. Safe to show to a user."}},"required":["code","message"]}},"required":["error"]},"Pagination":{"type":"object","properties":{"total":{"type":"integer","description":"Number of items matching the filters across all pages."},"limit":{"type":"integer","description":"Page size that was applied."},"hasMore":{"type":"boolean","description":"True when another page exists."},"nextCursor":{"type":["string","null"],"description":"Pass this as `after` to get the next page. Null on the last page."}},"required":["total","limit","hasMore","nextCursor"]},"ContactStatus":{"type":"string","enum":["PENDING","SUBSCRIBED","UNSUBSCRIBED","BOUNCED"],"description":"- `PENDING`: known but has not agreed to marketing email. Not mailed by campaigns.\n- `SUBSCRIBED`: agreed to marketing email. The only status campaigns are sent to.\n- `UNSUBSCRIBED`: opted out.\n- `BOUNCED`: the address does not accept mail."},"Contact":{"type":"object","properties":{"id":{"type":"string","description":"Contact id."},"email":{"type":"string","description":"Lowercase email address. Unique within a workspace."},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"tags":{"type":"array","items":{"type":"string","maxLength":100},"description":"Free-form labels.","maxItems":50},"status":{"$ref":"#/components/schemas/ContactStatus"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","email","firstName","lastName","tags","status","createdAt"]},"ContactUpsertRequest":{"type":"object","properties":{"email":{"type":"string","description":"Required. Trimmed and lowercased, and must look like name@domain.tld, otherwise `422`.","format":"email"},"firstName":{"type":"string","description":"Trimmed. An empty or missing value leaves an existing contact's first name unchanged."},"lastName":{"type":"string","description":"Trimmed. An empty or missing value leaves an existing contact's last name unchanged."},"tags":{"type":"array","items":{"type":"string","maxLength":100},"description":"At most 50 tags, each at most 100 characters, otherwise `422`. Entries are trimmed and empty ones are dropped.\nOn an EXISTING contact this REPLACES the whole tag list, and leaving `tags` out replaces it with an empty list. Always send the full list you want to keep.","maxItems":50},"status":{"$ref":"#/components/schemas/ContactStatus","description":"Optional. Defaults to SUBSCRIBED. A value that is not one of the four statuses is ignored, as if it was left out. See the status and consent rules on this operation."},"consent":{"type":["string","null"],"enum":["explicit","declined",null],"description":"Optional claim about the person's own consent. Case-insensitive. Null or omitted means no claim.\n- `explicit`: the person themselves agreed to marketing email. Only send this when you hold that consent. It can lift an UNSUBSCRIBED contact back to SUBSCRIBED when the status is SUBSCRIBED.\n- `declined`: the person refused or withdrew consent. The contact becomes UNSUBSCRIBED whatever `status` says.\nAny other value is a `422`."}},"required":["email"]},"ContactUpdateRequest":{"type":"object","properties":{"email":{"type":"string","description":"Trimmed and lowercased. `422` if it is not a valid address, `409` if another contact in the workspace already has it.","format":"email"},"firstName":{"type":["string","null"],"description":"Trimmed. An empty string, null or any non-string value clears the field."},"lastName":{"type":["string","null"],"description":"Trimmed. An empty string, null or any non-string value clears the field."},"tags":{"type":"array","items":{"type":"string","maxLength":100},"description":"Replaces the whole tag list. Must be an array (otherwise `422`), at most 50 tags of at most 100 characters. Entries are trimmed and empty ones are dropped. Adding a tag the contact did not have may start live automations with a tag-added trigger.","maxItems":50},"status":{"$ref":"#/components/schemas/ContactStatus","description":"Any other value is a `422`. The change is subject to the status and consent rules on this operation, so the response can show a status different from the one requested."},"consent":{"type":["string","null"],"enum":["explicit","declined",null],"description":"Optional claim about the person's own consent. Case-insensitive. Null or omitted means no claim.\n- `explicit`: the person themselves agreed to marketing email. Only send this when you hold that consent. It can lift an UNSUBSCRIBED contact back to SUBSCRIBED when the status is SUBSCRIBED.\n- `declined`: the person refused or withdrew consent. The contact becomes UNSUBSCRIBED whatever `status` says.\nAny other value is a `422`."}}},"ContactListMembership":{"type":"object","properties":{"id":{"type":"string","description":"List id."},"name":{"type":"string"},"createdAt":{"type":"string","description":"When the list was created.","format":"date-time"},"addedAt":{"type":"string","description":"When the contact joined the list.","format":"date-time"}},"required":["id","name","createdAt","addedAt"]},"EmailList":{"type":"object","properties":{"id":{"type":"string","description":"List id."},"orgId":{"type":"string","description":"Workspace id. Present on `GET` and `PATCH` responses, absent from the create response."},"name":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"contactCount":{"type":"integer","description":"Number of contacts on the list, whatever their status."}},"required":["id","name","createdAt","contactCount"]},"AddContactsResult":{"type":"object","properties":{"added":{"type":"integer","description":"Contacts that were eligible. This includes any that were already on the list, which are ignored silently."},"skipped":{"type":"integer","description":"Contacts left out because their status is UNSUBSCRIBED or BOUNCED."}},"required":["added","skipped"]},"CampaignStatus":{"type":"string","enum":["DRAFT","SCHEDULED","SENT","SENDING"],"description":"DRAFT, SCHEDULED or SENT in normal use. SENDING is declared in the database but the send path behind this API does not set it. Only DRAFT, SCHEDULED and SENT can be used as a list filter."},"CampaignStat":{"type":"object","description":"Aggregate counters for the campaign.","properties":{"sent":{"type":"integer"},"opened":{"type":"integer"},"clicked":{"type":"integer"},"bounced":{"type":"integer"},"unsubscribed":{"type":"integer"}},"required":["sent","opened","clicked","bounced","unsubscribed"]},"Campaign":{"type":"object","description":"A campaign. The HTML body is write-only: it can be set on create and update but no route returns it.","properties":{"id":{"type":"string","description":"Campaign id."},"name":{"type":"string"},"subject":{"type":"string"},"status":{"$ref":"#/components/schemas/CampaignStatus"},"listId":{"type":["string","null"],"description":"The list the campaign is sent to, or null."},"scheduledAt":{"type":["string","null"],"description":"When a SCHEDULED campaign will be sent.","format":"date-time"},"sentAt":{"type":["string","null"],"description":"When the campaign finished sending.","format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"stat":{"oneOf":[{"$ref":"#/components/schemas/CampaignStat"},{"type":"null"}],"description":"Null until the campaign has stats."}},"required":["id","name","subject","status","listId","scheduledAt","sentAt","createdAt","stat"]},"CampaignCreateRequest":{"type":"object","properties":{"name":{"type":"string","description":"Required. At most 200 characters.","maxLength":200},"subject":{"type":"string","description":"Required. At most 998 characters. Merge tags such as {{firstName}} are filled in per recipient.","maxLength":998},"body":{"type":"string","description":"Required. The email as HTML, at most 512 KB (524288 bytes of UTF-8).\nIt is sent as written. At send time Flomailr personalizes the merge tags `{{firstName}}`, `{{lastName}}`, `{{fullName}}` and `{{email}}`, and wraps the message with the unsubscribe link, the workspace's postal address and open and click tracking. Write email-safe HTML (tables, inline styles)."},"listId":{"type":"string","description":"Optional. The list to send to. Must be a list in this workspace, otherwise `404`. A value that is not a string is ignored."}},"required":["name","subject","body"]},"CampaignUpdateRequest":{"type":"object","properties":{"name":{"type":"string","description":"Cannot be empty. At most 200 characters.","maxLength":200},"subject":{"type":"string","description":"Cannot be empty. At most 998 characters.","maxLength":998},"body":{"type":"string","description":"Cannot be empty. HTML, at most 512 KB."},"listId":{"type":["string","null"],"description":"A list id in this workspace (`404` otherwise), or null to detach the list."}}},"CampaignSendRequest":{"type":"object","properties":{"excludeEmails":{"type":"array","items":{"type":"string"},"description":"Addresses to skip for this send. Non-string entries are ignored. Matching is case-insensitive."},"overrideQuietHours":{"type":"boolean","description":"Send even if the workspace's quiet hours are in effect. Only `true` counts. Default false."}}},"CampaignSendResult":{"type":"object","properties":{"sent":{"type":"integer","description":"Messages accepted by the email provider in THIS call."},"failed":{"type":"integer","description":"Messages the provider rejected in THIS call."},"remaining":{"type":"integer","description":"Recipients still owed this campaign."},"complete":{"type":"boolean","description":"False when the call stopped early and the rest will go out in the background. Do not call again to finish it."}},"required":["sent","failed","remaining","complete"]},"CampaignScheduleRequest":{"type":"object","properties":{"scheduledAt":{"type":"string","description":"Required. When to send, an ISO 8601 date and time at least 30 seconds in the future. Include a UTC offset or `Z`: a value without one is read in the server's timezone.","format":"date-time"}},"required":["scheduledAt"]},"CampaignScheduleResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"$ref":"#/components/schemas/CampaignStatus"},"scheduledAt":{"type":["string","null"],"format":"date-time"}},"required":["id","name","status","scheduledAt"]},"Form":{"type":"object","properties":{"id":{"type":"string","description":"Form id."},"name":{"type":"string"},"listId":{"type":["string","null"],"description":"The list new subscribers join, or null."},"fields":{"type":"array","items":{"type":"string"},"description":"Optional fields shown next to email, for example firstName and lastName. The API does not check the values."},"style":{"type":"object","description":"Free-form look of the form. The dashboard builder stores keys such as buttonText, buttonColor, buttonTextColor, bgColor and borderRadius. It is an empty object until set."},"successMessage":{"type":"string","description":"Shown after a successful signup."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"embedUrl":{"type":"string","description":"The public URL the form posts signups to (`POST /api/forms/{id}/submit`, no API key needed). It is built from the app URL configured on the server."}},"required":["id","name","listId","fields","style","successMessage","createdAt","updatedAt","embedUrl"]},"FormCreateRequest":{"type":"object","properties":{"name":{"type":"string","description":"Required."},"listId":{"type":"string","description":"Optional list that new subscribers join. Must be a list in this workspace, otherwise `404`. A value that is not a string is ignored."},"fields":{"type":"array","items":{"type":"string"},"description":"Optional fields shown next to email. Defaults to [\"firstName\"]. Non-string entries are dropped, and a value that is not an array uses the default."},"successMessage":{"type":"string","description":"Defaults to \"Thanks! You're subscribed.\"."}},"required":["name"]},"FormUpdateRequest":{"type":"object","properties":{"name":{"type":"string","description":"Cannot be empty."},"listId":{"type":["string","null"],"description":"A list id in this workspace (`404` otherwise), or null to detach."},"fields":{"type":"array","items":{"type":"string"},"description":"Must be an array (otherwise `422`). Non-string entries are dropped."},"successMessage":{"type":"string"},"style":{"type":"object","description":"Replaces the form's style object."}}},"AutomationStatus":{"type":"string","enum":["DRAFT","LIVE","PAUSED"]},"Automation":{"type":"object","description":"An automation. The flow itself (`flowJson`) is never returned by any route; it can only be written.","properties":{"id":{"type":"string","description":"Automation id."},"orgId":{"type":"string","description":"Workspace id. Not present on the create response."},"name":{"type":"string"},"status":{"$ref":"#/components/schemas/AutomationStatus"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"webhookToken":{"type":["string","null"],"description":"Token for starting the automation through its inbound webhook, or null. Treat it as a secret. Not present on the create response."},"activeEnrollments":{"type":"integer","description":"Contacts currently running through the automation."}},"required":["id","name","status","createdAt","updatedAt","activeEnrollments"]},"FlowNode":{"type":"object","description":"A step in the flow. The engine understands node types `trigger`, `wait`, `condition`, `action` and `end`. The API only checks the three required fields.","properties":{"id":{"type":"string","description":"Unique within the flow."},"type":{"type":"string"},"data":{"type":"object","description":"Settings for the step. Must be an object, not an array."}},"required":["id","type","data"]},"FlowEdge":{"type":"object","description":"A link between two nodes. A condition node uses `sourceHandle` values `yes` and `no` for its two branches.","properties":{"source":{"type":"string","description":"Id of the node the edge leaves."},"target":{"type":"string","description":"Id of the node the edge enters."},"sourceHandle":{"type":"string"}},"required":["source","target"]},"FlowJson":{"type":"object","description":"The flow graph. A trigger node carries `data.triggerType`; the default flow of a new automation is one trigger node with `triggerType` `subscriber_joins`. Events posted to `POST /events` start triggers of type `custom_event` whose `data.eventName` matches the event name.","properties":{"nodes":{"type":"array","items":{"$ref":"#/components/schemas/FlowNode"}},"edges":{"type":"array","items":{"$ref":"#/components/schemas/FlowEdge"}}},"required":["nodes","edges"]},"AutomationCreateRequest":{"type":"object","properties":{"name":{"type":"string","description":"Optional. Defaults to \"Untitled automation\"."}}},"AutomationUpdateRequest":{"type":"object","properties":{"name":{"type":"string","description":"An empty name becomes \"Untitled automation\"."},"flowJson":{"$ref":"#/components/schemas/FlowJson"},"status":{"$ref":"#/components/schemas/AutomationStatus","description":"Allowed changes: LIVE from DRAFT or PAUSED, PAUSED from LIVE, and DRAFT only from DRAFT (a published automation cannot go back to draft). Anything else is `422` with code `AUTOMATION_WRONG_STATE`.\nValues outside DRAFT, LIVE and PAUSED are not checked up front and make the request fail."}}},"AutomationEnrollment":{"type":"object","properties":{"id":{"type":"string"},"contactId":{"type":"string"},"currentNodeId":{"type":"string","description":"The next node the engine will run for this contact."},"status":{"type":"string","enum":["ACTIVE","COMPLETED","FAILED"]},"nextRunAt":{"type":"string","description":"When the engine will next process this enrollment.","format":"date-time"},"enrolledAt":{"type":"string","format":"date-time"},"completedAt":{"type":["string","null"],"format":"date-time"},"errorMessage":{"type":["string","null"],"description":"Set when the enrollment failed."}},"required":["id","contactId","currentNodeId","status","nextRunAt","enrolledAt","completedAt","errorMessage"]},"EventRequest":{"type":"object","properties":{"name":{"type":"string","description":"Required. Normalized to lowercase snake_case and cut to 64 characters, so \"Order Placed\", \"order-placed\" and \"orderPlaced\" all become `order_placed`. Automation triggers are normalized the same way."},"email":{"type":"string","description":"The contact's email address. Send `email` or `contactId`. If the address is unknown, a new contact is created as PENDING, never SUBSCRIBED.","format":"email"},"contactId":{"type":"string","description":"The contact's id. Used instead of `email` when both are sent. It must be a contact in this workspace, otherwise `404`."},"properties":{"type":"object","description":"Optional free-form data stored with the event. At most 50 keys and 8 KB when serialized."},"value":{"type":"number","description":"Optional money amount in major units (for example dollars). Stored as whole minor units, rounded, so 19.99 becomes 1999. Ignored when `valueCents` is sent."},"valueCents":{"type":"integer","description":"Optional money amount as a whole number of minor units. Wins over `value`. Implausibly large amounts are rejected with `422`."},"currency":{"type":"string","description":"Optional three-letter currency code, stored uppercase. Any other value is ignored."},"externalId":{"type":"string","description":"Optional id from your own system, truncated to 200 characters. Sending the same `externalId` again records nothing new and runs no automation again; you get `200` with `duplicate: true`."}},"required":["name"]},"EventRecorded":{"type":"object","properties":{"id":{"type":"string","description":"Event id."},"name":{"type":"string","description":"The normalized event name."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","name","createdAt"]},"EventDuplicate":{"type":"object","properties":{"id":{"type":"string","description":"Id of the event recorded earlier under this `externalId`."},"duplicate":{"type":"boolean","const":true},"createdAt":{"type":"string","format":"date-time"}},"required":["id","duplicate","createdAt"]},"Event":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","description":"Normalized event name."},"properties":{"type":"object","description":"The data sent with the event."},"valueCents":{"type":["integer","null"]},"currency":{"type":["string","null"]},"contactId":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"}},"required":["id","name","properties","valueCents","currency","contactId","createdAt"]},"TransactionalSendRequest":{"type":"object","properties":{"to":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Required. One address, or an array of 1 to 5. Addresses are lowercased and duplicates removed. Any invalid address rejects the whole request. More than 5 is rejected: use a campaign for bulk mail."},"subject":{"type":"string","description":"Required. Control characters are stripped and the subject is cut at 500 characters."},"html":{"type":"string","description":"HTML body. Send `html`, `text` or both."},"text":{"type":"string","description":"Plain-text body. If you send only `text`, it is wrapped in a minimal HTML page for the HTML part."},"replyTo":{"type":"string","description":"Optional reply-to address. Defaults to the workspace's reply-to address, if one is set.","format":"email"}},"required":["to","subject"]},"TransactionalBlocked":{"type":"object","properties":{"email":{"type":"string"},"reason":{"type":"string","enum":["SUPPRESSED_HARD_BOUNCE","SUPPRESSED_COMPLAINT","SUPPRESSED_MANUAL","BLOCKED_DOMAIN"]}},"required":["email","reason"]},"TransactionalSendResult":{"type":"object","properties":{"sent":{"type":"integer","description":"Recipients the email provider accepted."},"failed":{"type":"integer","description":"Allowed recipients the provider rejected."},"blocked":{"type":"array","items":{"$ref":"#/components/schemas/TransactionalBlocked"},"description":"Recipients that were skipped and why. Skipped recipients do not fail the request unless every recipient is blocked."}},"required":["sent","failed","blocked"]},"TransactionalSendResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TransactionalSendResult"},"replayed":{"type":"boolean","description":"Only present when the request carried an `Idempotency-Key`. True when this response is a replay of an earlier request with the same key and nothing was sent again."}},"required":["data"]},"ApiKey":{"type":"object","properties":{"id":{"type":"string","description":"Key id."},"name":{"type":"string"},"prefix":{"type":"string","description":"The first 12 characters of the key (`flo_sk_live_`), so keys can be told apart."},"lastUsedAt":{"type":["string","null"],"format":"date-time"},"revokedAt":{"type":["string","null"],"description":"Set once the key is revoked.","format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","name","prefix","lastUsedAt","revokedAt","createdAt"]},"ApiKeyCreated":{"type":"object","properties":{"id":{"type":"string","description":"Key id."},"name":{"type":"string"},"prefix":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"key":{"type":"string","description":"The full secret key, `flo_sk_live_` followed by 32 hex characters. Returned ONLY here. Flomailr stores a hash and cannot show it again."}},"required":["id","name","prefix","createdAt","key"]},"ApiKeyRevoked":{"type":"object","properties":{"id":{"type":"string"},"revokedAt":{"type":"string","format":"date-time"}},"required":["id","revokedAt"]}}}}