Send your first transactional email.
With a verified domain (Chapter 2), you're two API calls away from a delivered message: author a template, send. No mailbox needed — mailboxes are for receiving.
1
Author a template
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"name": "welcome",
"subject": "Welcome to {{product}}, {{name}}!",
"body_html": "<h1>Hi {{name}},</h1><p>Thanks for signing up for {{product}}. Get started at {{startUrl}}.</p>",
"body_text": "Hi {{name}}, thanks for signing up for {{product}}. Get started: {{startUrl}}",
"tags": ["onboarding"]
}'name is the lookup key — lowercase letters, digits, _, -. Posting the same name again overwrites (the endpoint is an upsert). body_text is optional — Mail derives a plain-text body from the HTML if you omit it.
Name variables in camelCase ({{startUrl}}, not {{start_url}}). Keys inside data are normalized on the wire — start_url arrives as startUrl, so a snake_case variable never receives its value.
2
Preview the rendered output
Before sending to a real recipient, render the template with sample data and inspect the output. Pure validation; doesn't dispatch anything.
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/welcome/render \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"data": {
"name": "Ada",
"product": "Acme",
"startUrl": "https://app.acme.com/onboarding"
}
}'{
"subject": "Welcome to Acme, Ada!",
"html": "<!doctype html>\n<html>\n<head>...</head>\n<body ...><h1>Hi Ada,</h1><p>Thanks for signing up for Acme. Get started at https://app.acme.com/onboarding.</p></body></html>",
"text": "Hi Ada, thanks for signing up for Acme. Get started: https://app.acme.com/onboarding"
}The html comes back wrapped in a dark-mode-safe document head — that's what actually ships. Missing variables render as empty strings (Handlebars default), not errors — eyeball the preview.
3
Send to a test recipient
The test-send endpoint targets one address with the template fully rendered — for “does this actually look right in Gmail” checks. It bypasses the suppression list and rate limits, prefixes the subject with [TEST], and renders unsupplied variables as [varname] placeholders.
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/welcome/test-send \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"from": "noreply@acme.com",
"to": "yourself@acme.com",
"data": { "name": "Ada", "product": "Acme", "startUrl": "https://..." }
}'from must be an address on a verified workspace domain — same rule as a production send.
4
Production send
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/welcome/send \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-H 'idempotency-key: welcome-2026-05-22-ada' \
-d '{
"from": "noreply@acme.com",
"to": "ada@example.com",
"data": {
"name": "Ada",
"product": "Acme",
"startUrl": "https://app.acme.com/onboarding"
}
}'{
"accepted": true,
"from": "noreply@acme.com",
"to": "ada@example.com",
"subject": "Welcome to Acme, Ada!"
}You can pass a top-level subject in the body to override the template's subject. The send is asynchronous — the 202 confirms queued for delivery, not delivered to inbox. The webhook (Chapter 5) and the message log (Chapter 6) are how you learn what actually happened.
Pre-flight failures (immediate, nothing queued):
- 403 — the
fromdomain isn't registered to this workspace - 404 — template name doesn't exist
- 409 — domain registered but not yet verified; or same idempotency key reused with a different body
- 422 — recipient on the suppression list, render failure, or invalid
fromaddress - 429 — rate limited
Surface the message from the error body in your operator UI so config errors don't silently swallow sends.
5
Batch send
For sending one template to many recipients with per-recipient data, use the batch endpoint. Each row is processed independently; partial failures don't abort the batch.
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/order-receipt/send-batch \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"messages": [
{ "from": "billing@acme.com", "to": "ada@example.com", "data": { "name": "Ada", "total": "$29" } },
{ "from": "billing@acme.com", "to": "alan@example.com", "data": { "name": "Alan", "total": "$49" } }
]
}'{
"data": [
{ "index": 0, "id": "6f0c...", "status": "accepted" },
{ "index": 1, "status": "rejected", "error": { "code": "INVALID_INPUT", "message": "Recipient \"alan@example.com\" is on this workspace's suppression list" } }
],
"summary": { "accepted": 1, "rejected": 1 }
}Accepted rows carry the outbound message id; rejected rows carry an error. Rows can also take subject and idempotency_key per message. Hard limit: 100 messages per call (400 above that) — chunk client-side for larger blasts. An unverified from domain fails the whole batch upfront with a 422.