Mail guides
03 · First send

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

POST /workspaces/:ws/templates
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.

POST /workspaces/:ws/templates/:name/render
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.

POST /workspaces/:ws/templates/:name/test-send
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

POST /workspaces/:ws/templates/:name/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"
    }
  }'
202 Accepted
{
  "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 from domain 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 from address
  • 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.

POST /workspaces/:ws/templates/:name/send-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" } }
    ]
  }'
200 OK
{
  "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.