REST API reference
REST API reference for Workflow Transactional Email: endpoints for requests, layouts, senders, secrets, history and stats, with access levels.
All paths are relative to https://shopify.workflow-transactional-email.app. Every endpoint except the index needs Authorization: Bearer fak_... - see Authentication and API keys.
Responses are JSON. Errors use the same shape throughout:
{ "error": "Invalid or missing API key" }Index and identity
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1 |
none | API index. Confirms the API is up and reports the current release |
| GET | /api/v1/me |
read | Check a key works and see its access level |
The index needs no key, so it is a safe health check to point a monitor at.
Email layouts
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/email-templates |
read | List email layouts. q searches name, subject and description; category=transactional or category=marketing lists one email type |
| POST | /api/v1/email-templates |
write | Create a layout |
| GET | /api/v1/email-templates/:id |
read | One layout with its design or attachments, its footer texts and the list of its languages |
| PUT / PATCH | /api/v1/email-templates/:id |
write | Change a layout. Only the fields you send change |
| DELETE | /api/v1/email-templates/:id |
write | Delete a layout with its translations and versions |
| GET | /api/v1/layout-schema |
read | How to write a layout: the design format, every block's defaults, the Liquid you can use, limits and an example |
The list accepts page (default 1), limit (default 25, max 100) and q, which searches name, subject and description. There is no filter by type over the API; read category on each layout instead.
A layout has these fields:
| Field | Notes |
|---|---|
name, description |
Name required, at most 200 characters; description at most 2000 |
bodyType |
visual (default) or text. Fixed once created |
defaultLocale |
The language of the layout's own content, en unless set |
category |
transactional (default) or marketing. See below |
subject, previewText |
Liquid allowed. Subject required |
bodyDesign |
Visual layouts: { blocks, global?, header?, footer?, testVariables? }. Send the complete design when changing it; the HTML body is rendered from it |
body |
Text layouts: the HTML body |
attachments |
Text layouts: [{ filename, fileKey, sendAsLink? }] for uploaded files (see files below) or [{ filename, url }] for a file fetched over https when the email is sent |
marketingTexts |
The footer texts of a marketing layout, or null. See below |
Liquid in the subject, preview text, body and every block text must parse, and every fileKey must belong to your shop, or the write is refused with a 400 that names the field. Read GET /api/v1/layout-schema before writing a visual design: it is generated from the builder itself, so it cannot drift.
Every write through this API is kept in the layout's version history, exactly like a save in the app.
Transactional and marketing layouts
category says what a layout is for. A transactional layout is sent to every recipient, unchanged. A marketing layout skips recipients who unsubscribed (a step whose To recipients all unsubscribed is not sent and shows as SKIPPED in history), goes out as one message per To recipient with its own unsubscribe link (at most 20 per step), and ends with the unsubscribe sentence and your company details. {{ unsubscribe_url }} and {{ unsubscribe_link }} place the link yourself; in transactional layouts both are empty. The merchant side of this is in Marketing emails and unsubscribes.
marketingTexts is the footer wording a marketing layout uses instead of the shop-wide texts from Settings:
{
"marketingTexts": {
"unsubscribeText": "You get this because you shop with us. {{ unsubscribe_link }}",
"unsubscribeLinkLabel": "Unsubscribe",
"companyDetails": "Example GmbH, Musterstrasse 1, 10115 Berlin"
}
}Every field is optional. A set field replaces the Settings text for that field; the others stay as they are. null (the default) means the Settings texts for the email's language. Limits: unsubscribeText and companyDetails 2000 characters, unsubscribeLinkLabel 100. An unsubscribeText must contain {{ unsubscribe_link }} or {{ unsubscribe_url }}, otherwise the write is refused. The field is stored for any layout but only sent with marketing ones.
Deleting a layout is never blocked. If Shopify Flow steps still point at it, the response carries a warning naming how many; those steps fail until they use another layout.
Translations
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/email-templates/:id/translations |
read | Every language of a layout with its content |
| GET | /api/v1/email-templates/:id/translations/:locale |
read | One language |
| PUT | /api/v1/email-templates/:id/translations/:locale |
write | Create or replace one language |
| DELETE | /api/v1/email-templates/:id/translations/:locale |
write | Remove one language. Sends in it fall back to the layout |
| POST | /api/v1/email-templates/:id/translations/:locale/promote |
write | Make that language the layout's main language |
:locale is a language code such as de, fr or pt-br; case and separator do not matter. Up to 30 languages per layout. The Shopify Flow step's Language field picks one at send time: exact locale, then the base language, then a regional variant, else the layout itself.
A PUT takes the full translated content: subject (required), previewText, and bodyDesign (visual) or body (text). Keep Liquid tags and URLs unchanged. Two fields are specific to a translation:
| Field | Text layouts | Meaning |
|---|---|---|
attachments |
yes | A list (same shape as the layout's) = this language's own attachments, [] = none in this language. null = send the layout's attachments. Omitted = keep the current choice; a new language uses the layout's. Visual layouts carry their attachments as blocks in each language's bodyDesign and refuse this field |
marketingTexts |
any | This language's footer texts on top of the layout's, field by field. null = the layout's. Omitted = keep the current choice |
A translation is returned with the same two fields: attachments is null while it sends the layout's, and marketingTexts is null while it uses the layout's.
promote swaps the layout and the translation: the layout takes the translation's content and locale, and the content it had becomes the translation for the language it used to be in. Attachments and footer texts trade places too, so every language keeps sending what it sent before. The response reports promoted, previousMainLanguage and the layout. It is one version, so it can be undone.
Versions
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/email-templates/:id/versions |
read | Saved versions, newest first. The app keeps the newest 25 |
| GET | /api/v1/email-templates/:id/versions/:version |
read | The full snapshot of one version: layout and all translations |
| POST | /api/v1/email-templates/:id/versions/:version/restore |
write | Put that version back. Recorded as a new version |
| GET | /api/v1/email-templates/:id/versions/github?page= |
read | Every version kept in the connected GitHub repository, 20 per page |
| GET | /api/v1/email-templates/:id/versions/github/:sha |
read | The layout as it was at one commit |
| POST | /api/v1/email-templates/:id/versions/github/:sha/restore |
write | Put a GitHub version back, checked like a save |
The GitHub endpoints answer 409 while no repository is connected on the Developer page. See Layout version history and Keep unlimited layout history on GitHub.
Email senders
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/smtp-configs |
read | List SMTP senders |
| GET | /api/v1/senders |
read | List connected mailboxes (Microsoft 365, Google) |
SMTP senders report hasUsername and hasPassword rather than the credential itself - both halves are withheld, since a username is as much a part of the credential as the password. A sender stays identifiable by its name, host and from-address. Connected mailboxes report a partially masked address such as so***@example.com and never their sign-in tokens.
Read-only. Connect and edit senders in the app.
Secrets
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/secrets |
read | List secret names and descriptions |
Returns hasValue, never the value. There is no endpoint that reads a secret back. Create and update secrets in the app.
Uploaded files
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/files |
read | List uploaded logos, images and attachments |
| DELETE | /api/v1/files |
write | Delete one, by key in the body |
List accepts page, limit (max 100) and q to search by filename.
Add ?usage=true and each file also reports inUse plus a usedBy array naming the layouts and presets referencing it. That is how you find everything safe to clear out in a single request. It scans your layouts and presets, so it is opt-in rather than the default.
Uploading is not exposed here - add files through the media picker in the app.
HTTP requests
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/http-requests |
read | List HTTP requests |
| POST | /api/v1/http-requests |
write | Create one |
| GET | /api/v1/http-requests/:id |
read | Get one |
| PUT / PATCH | /api/v1/http-requests/:id |
write | Update one |
| DELETE | /api/v1/http-requests/:id |
write | Delete one |
| POST | /api/v1/http-requests/:id/test |
execute | Run it against the real target |
List accepts the same page, limit and q parameters as email layouts.
The target url must be http:// or https://. Other schemes are rejected when you save.
Literal credentials written into a header or body are masked in every response. Values referenced as {{ secrets.KEY }} are returned as written, because the reference itself is not sensitive.
History and statistics
Covers both email sends and HTTP requests.
| Method | Path | Level | Purpose |
|---|---|---|---|
| GET | /api/v1/history |
read | List executions |
| GET | /api/v1/history/:id |
read | Get one execution with request and response data |
| GET | /api/v1/stats |
read | Totals and success rate |
History accepts page, limit (max 100), actionType, status (PENDING, SUCCESS, FAILED, SKIPPED), and actionConfigId to filter to a single layout or request.
SKIPPED is a marketing email whose recipients had all unsubscribed: nothing was sent, no quota was used, and it does not count towards the success rate.
actionType must be exactly EMAIL or HTTP_REQUEST. An unrecognised value is ignored rather than rejected, so a typo returns everything instead of erroring.
Stats accepts days (default 30, max 365).
Status codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 400 | Malformed request or failed validation. The error names the field, for example subject: required |
| 401 | Missing, malformed or revoked API key |
| 403 | Key is valid but its level is too low for this endpoint |
| 404 | Not found, or belongs to a different shop |
| 405 | Wrong method for this path |
| 409 | Refused because the record is still in use (file deletion), or GitHub is not connected (GitHub versions) |
| 429 | Rate limited - see below |
| 502 | The request ran but the third-party target failed |
A record belonging to another shop returns 404 rather than 403, so the API never confirms that an id exists elsewhere.
Rate limits
Two independent budgets, both per key:
- 300 requests per 60 seconds across all endpoints.
- 60 execute calls per hour on top of that, covering
POST /api/v1/http-requests/:id/test.
Exceeding either returns 429 with an explanatory error. The execute budget is deliberately tight: a runaway loop firing live requests at a payment provider is a much worse failure than a slow script.
Budgets are per key, not per store, so one integration cannot exhaust another's allowance.
