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:

  • The recipient does not need to be a marketing contact. Anyone with an email address can receive one.

  • They do not need to exist in your audience at all. Send to a new address and Spreeflo adds them as a non-marketing contact so the send can be tracked.

  • Marketing unsubscribes do not block them. Someone who opted out of your newsletter still gets their receipt. Transactional email has its own separate opt-out, and that one is respected.

  • Every recipient counts towards your plan's monthly email send limit, exactly like a campaign send.
  • Before you begin

  • An API key. Create one in Settings > API Keys. See Use Spreeflo API. Sending needs a key with editor access; reading a sent email back needs only viewer access.

  • A verified sender address. The 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.

    EndpointBody comes fromUse it when
    /emails/send-templateA template you designed in SpreefloAlmost always. Marketing can edit the wording without a deploy.
    /emails/sendHTML or plain text in the requestThe content is generated by your system and has no stable design.
    /emails/send-rawA raw MIME message you uploadYou 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"
    }

    queued is 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:

    NamespaceWhere 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 to holds more than one address — and cc and bcc count — 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.

    StatusMeaning
    queuedAccepted and about to go out.
    sentHanded to the recipient's mail server.
    bouncedRejected by the recipient's mail server.
    failedCould not be sent. failedReason says why.

    Troubleshooting

  • A variable came out empty. Check the namespace prefix first — {{ 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.

  • The AI paragraphs are blank. Either the workspace is not on a Pro plan, or the prompt referred to data that was empty for this recipient and generation was skipped.

  • The send was rejected as unsubscribed. That recipient opted out of transactional email specifically. This is separate from a marketing unsubscribe and is not something an API call can override.

  • The sender address was rejected. 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.