Send transactional emails
A transactional email is one your system sends because something happened: an order was confirmed, a password reset was requested, an invoice became due. You design it in Spreeflo, then trigger it from your own backend through the API.
What makes them different
Campaigns and journeys decide who to email and when. Transactional emails leave both decisions to you — your code names the recipient and picks the moment. That changes a few rules:
Before you begin
fromEmail in every request must be an address you have registered and verified under Settings > Senders. See Set up your domain. Choose how to send
Three endpoints, differing only in where the email body comes from.
| Endpoint | Body comes from | Use it when |
|---|---|---|
/emails/send-template | A template you designed in Spreeflo | Almost always. Marketing can edit the wording without a deploy. |
/emails/send | HTML or plain text in the request | The content is generated by your system and has no stable design. |
/emails/send-raw | A raw MIME message you upload | You already produce full MIME messages and want them sent as-is. |
All three personalise the same way, return the same response, and are tracked identically. The rest of this guide leads with templates.
Send from a template
Build the template
Go to Transactional Emails and create one. You get the same editor as marketing emails — drag-and-drop blocks, or a rich text editor if you prefer. Write the subject and body, drop in variables where the copy should change per recipient, and save.
Click Send in the editor at any point to see a ready-made request for that exact template, with its id already filled in.
Send it
curl -X POST https://api.spreeflo.com/1.0/transactional/emails/send-template \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--header 'Content-Type: application/json' \
--data '{
"message": {
"templateId": "<TEMPLATE_ID>",
"fromEmail": "orders@yourdomain.com",
"fromName": "Your Store",
"to": [
{ "email": "customer@example.com", "name": "John Doe", "type": "to" }
],
"vars": [
{ "key": "order_id", "value": "A-1042" }
]
}
}' You get back an emailId and a status. Store the id — it is how you look the email up later.
{
"emailId": "968333c3-d13b-479a-a62c-cee41162812f",
"email": "customer@example.com",
"status": "queued"
}
queuedis the normal answer. The email is accepted and sent moments later, which is what lets the endpoint respond quickly enough to sit in a checkout flow.
Personalise the email
Variables are written as {{ namespace.name }}. The namespace says where the value comes from — and that is the whole model:
| Namespace | Where the value comes from |
|---|---|
{{ var.* }} | The vars you send in the request |
{{ contact.* }} | The contact record matching the recipient |
{{ spree.* }} | Spreeflo — unsubscribe link, sender, date |
{{ <objectType>.* }} | A custom object related to that contact. The namespace is that object type's own key — a type keyed order gives {{ order.total }}, one keyed subscription gives {{ subscription.renews_on }}. |
{{ ai.* }} | AI variables declared on the template |
Only the first one comes from you. Everything else Spreeflo resolves at send time, so your request stays small — you send the order number, not the customer's name and plan and unsubscribe link.
Values you pass in
Put them in vars, reference them with the var. prefix. Nothing to declare on the template first.
"vars": [
{ "key": "order_id", "value": "A-1042" },
{ "key": "ship_date", "value": "Friday" }
] In the template, write Order {{ var.order_id }} ships {{ var.ship_date }}.
The contact record
Send nothing and {{ contact.first_name }} still works: Spreeflo matches the recipient's address to a contact in your audience and reads the value from there. Use the attribute's key, the one shown in Contacts > Attributes. Custom attributes behave identically, so {{ contact.plan_tier }} works the moment that attribute exists.
If the contact is linked to a custom object — an order, a subscription, an account — its attributes are reachable too. There is no fixed namespace for these: the root is the object type's key, whatever you named it. A type keyed order with an attribute keyed status gives {{ order.status }}; a type keyed support_ticket gives {{ support_ticket.subject }}. You will find both keys on the object type in Objects.
Contact lookup only runs for a single recipient. If
toholds more than one address — andccandbcccount — no contact is matched, so{{ contact.* }}, object variables and the unsubscribe link all render empty. Send one email per recipient when the copy is personalised.
System values
{{ spree.unsubscribe_url }} and {{ spree.unsubscribe }} give the recipient a way out of transactional email. {{ spree.signature }}, {{ spree.sender_full_name }} and the date parts ({{ spree.day }}, {{ spree.month }}, {{ spree.year }}) are also available. Nothing to configure.
Fallbacks
A variable nobody supplies renders as nothing. That is not an error and the rest of the sentence is untouched — but "Hi ," reads badly, so give anything optional a fallback:
Hi {{ contact.first_name | default: "there" }},
your order {{ var.order_id }} is confirmed.Let AI write part of it
Instead of a value, send a prompt and Spreeflo writes that piece of copy per recipient. Same vars array, same var. prefix in the template — you are still supplying the variable, you are just describing it rather than dictating it.
"vars": [
{ "key": "order_id", "value": "A-1042" },
{ "key": "intro", "prompt": "one warm line acknowledging a repeat order" }
] Prompts see your data. Interpolate a value into the prompt and the writer receives the real thing, so "greet {{ contact.first_name }} warmly" reaches it as the customer's actual name. Naming an attribute in prose instead — "greet them by their first name" — reaches nothing.
If a prompt refers to data that turns out to be empty for a recipient, Spreeflo skips generation for that variable rather than letting the model invent around the gap. Confident copy about a fact nobody supplied is worse than a blank.
Template authors can also declare AI variables in the editor, referenced as {{ ai.headline }}. Those belong to the template and cannot be changed from a request — if you need a slot your code controls, ask for the template to use {{ var.headline }} instead. AI variables of either kind need a Pro plan; without one they render empty and the email still sends.
Personalize with AI variables goes deeper on writing good prompts.
Send inline HTML
When the body is generated by your system, send it directly. Provide html, text, or both — plain text alone cannot carry open or click tracking.
curl -X POST https://api.spreeflo.com/1.0/transactional/emails/send \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--header 'Content-Type: application/json' \
--data '{
"message": {
"subject": "Order {{ var.order_id }} confirmed",
"html": "<p>Hi {{ contact.first_name | default: \"there\" }},</p><p>Order {{ var.order_id }} is on its way.</p>",
"fromEmail": "orders@yourdomain.com",
"to": [{ "email": "customer@example.com", "type": "to" }],
"vars": [{ "key": "order_id", "value": "A-1042" }]
}
}'Variables work exactly as they do in a template, including contact and system values.
Send a raw MIME message
If you already produce RFC 5322 messages, upload one and send it by key. Variables work in the headers — including Subject: — as well as the body. The From: and To: headers in your file are placeholders; Spreeflo overwrites them from the request.
# 1. Get an upload URL, then PUT your .eml file to it (see Attachments below)
# 2. Send using the key it returned
curl -X POST https://api.spreeflo.com/1.0/transactional/emails/send-raw \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--header 'Content-Type: application/json' \
--data '{
"message": {
"rawMessageKey": "<UPLOAD_KEY>",
"fromEmail": "orders@yourdomain.com",
"fromName": "Your Store",
"to": ["customer@example.com"]
}
}'Attachments
Upload the file first, then reference the key it returns. This is a two-step exchange: ask Spreeflo for an upload URL, PUT the file to that URL, then pass the key in attachmentKeys when you send.
# 1. Ask for an upload URL
curl -G https://api.spreeflo.com/1.0/transactional/uploads/presigned-url \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--data-urlencode 'fileName=invoice.pdf' \
--data-urlencode 'contentType=application/pdf'
# → { "key": "3da993df-...", "uploadUrl": "https://s3..." }
# 2. Upload the file to that URL
curl -X PUT --upload-file ./invoice.pdf \
--header 'Content-Type: application/pdf' \
'<UPLOAD_URL>'
# 3. Reference the key when sending
# "attachmentKeys": ["3da993df-..."]Check what happened
The emailId from the send response looks up two things. /emails/info/{emailId} returns delivery status plus whether the recipient opened it or clicked a link. /emails/content/{emailId} returns the email exactly as it was sent, with variables already resolved — which is the fastest way to see what a recipient actually received.
| Status | Meaning |
|---|---|
queued | Accepted and about to go out. |
sent | Handed to the recipient's mail server. |
bounced | Rejected by the recipient's mail server. |
failed | Could not be sent. failedReason says why. |
Troubleshooting
{{ order_id }} resolves to nothing, whereas {{ var.order_id }} resolves to what you sent. Then check the single-recipient rule above if it was a {{ contact.* }} variable. fromEmail has to be verified under Settings > Senders; a plausible-looking address on a domain you own is not enough on its own. Full request and response detail for every endpoint lives in the API reference.