Transactional emails
Receipts, notifications and password resets: one email to one person, sent from your code the moment something happens. Write the template once, call the API with the variables, and read back what became of each email.
Quickstart
Create a key in Settings, API and choose the Transactional only level. It can send transactional email and read templates, sent emails and domains, and nothing else, so a leaked key cannot send a campaign.
npm install fromlyimport Fromly from 'fromly'
const fromly = new Fromly({ apiKey: process.env.FROMLY_API_KEY })
const { messageId, status } = await fromly.send({
to: 'ada@example.com',
subject: 'Your export is ready',
html: '<p>Download it here.</p>',
})That is a complete send. The rest of this page is what to do when the email should be designed once and filled in per call.
Templates and variables
Open Transactional in the dashboard and choose New template. A template is an email written in the editor, rendered without an unsubscribe link. Write variables where the value changes:
Hi {{contact.firstName|there}}, you paid {{data.amount}} for order {{data.orderId}}.{{data.*}} is filled from the data object of the call. The text after | is the fallback when a value is missing. {{contact.*}} comes from the contact with that address, if there is one — custom properties included, as {{contact.properties.language|en}}. Values are escaped, so a variable can never inject markup, and a link built from one is refused unless it is a real web address.
The template list shows each template's variables and its id. Copy the id into your call, or create templates from code:
const { email } = await fromly.emails.create({
name: 'Receipt',
kind: 'transactional', // set here, never changed afterwards
subject: 'Your receipt {{data.orderId}}',
})Only a template can be sent by id. A marketing email passed as emailId is refused, which keeps the promise that marketing mail always carries its unsubscribe link.
Sending
curl -X POST https://api.fromly.io/v1/transactional \
-H "Authorization: Bearer $FROMLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "ada@example.com",
"emailId": "<template id>",
"data": { "amount": "12 EUR", "orderId": "4521" },
"idempotencyKey": "order-4521"
}'await fromly.send({
to: 'ada@example.com',
emailId: '<template id>',
data: { amount: '12 EUR', orderId: '4521' },
idempotencyKey: 'order-4521',
})The call answers straight away, once the email has left:
{ "messageId": "…", "status": "sent", "to": "ada@example.com", "replay": false }Send from a particular domain with fromDomainId (list yours with fromly.domains.list()). Without it, the email leaves from your transactional domain, or your default domain if you have not set one.
Retrying safely
A request can time out after the email has already left. Retrying it would send a second receipt, so give every send a key that names the event, not the attempt:
| Key | Effect |
|---|---|
order-4521 | Good: one receipt per order, however many times the call is retried. |
reset-42-2026-10-07 | Good: one reset email per person per day. |
a random id per attempt | Wrong: every retry looks new, which defeats the key. |
A retry with the same key sends nothing and returns the original message with "replay": true. If the original failed, the replay reports the failure rather than trying again: use a new key to attempt it afresh.
Reading what happened
The send call tells you the email left. Whether the recipient's mail server accepted it arrives afterwards, over seconds to minutes. Read it with the id you got back:
const message = await fromly.messages.get(messageId)
// { status: 'delivered', deliveredAt: '…', bounce: null, … }
await fromly.messages.list({ source: 'transactional', status: 'bounced' })| Status | Meaning |
|---|---|
sent | The email has left. |
delivered | The recipient's mail server accepted it. |
opened | It was opened. One open proves little: some mail apps load images in advance. |
clicked | A link in it was followed. |
bounced | It could not be delivered. The bounce type says whether the address is dead. |
complained | The recipient marked it as spam. |
failed | It could not be sent at all. The message has an error. |
Add includeHtml: true to messages.get to read the email as it was sent. It contains the recipient's data, so ask for it only when you need it.
Errors
Every failure rejects with a FromlyError carrying the HTTP status and the API's own sentence.
| Status | Meaning |
|---|---|
400 | The request could not be read: a missing field, a bad address. |
401 / 403 | The key is missing, revoked, or does not have the scope. |
404 | The template or message does not exist. |
409 | Refused: the address has bounced or complained, the email is not a transactional template, the account has no emails left, or no verified domain can send. |
429 | Too many sends. Wait the number of seconds in retryAfterSeconds. |
502 | The email could not be sent. The body carries messageId: the email exists, and messages.get shows why. |
try {
await fromly.send({ … })
} catch (error) {
if (error.status === 429) await sleep(error.retryAfterSeconds * 1000)
if (error.status === 502) console.error('failed', error.messageId)
}Who receives it
Transactional email carries no unsubscribe link, because there is no list to leave. A contact who unsubscribed from your marketing still receives their receipt. An address that has bounced or marked an email as spam never does: sending to it again only damages your reputation.
The recipient does not have to be a contact. An address you never imported works, and no contact is created for it.
Domains and limits
Keep marketing and transactional mail on two different subdomains, such as news.domain.com and notify.domain.com, so a campaign with bounces can never delay a receipt. Mark each domain with what it is used for in Settings, Sending.
Each key can send 120 transactional emails a minute, a budget of its own that campaigns do not spend. Each send uses one of your credits, like any other email.
The full list of endpoints, with the scope each needs, is in the API reference.