Browse the docs

Sending mail

The outbox, scheduling and undo, drafts, mail merges, templates, tracking and unsubscribes.

Send a message

Sending queues an outbox entry for the account and answers it at once (201); the account's provider (Gmail, Microsoft 365 or its SMTP server) sends it in the background. The key needs the send scope, and an account token for that account works too.

curl -X POST "https://api.workwae.com/v1/accounts/$ACCOUNT_ID/outbox" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "to": [{ "name": "Ada Lovelace", "address": "[email protected]" }],
  "subject": "Your invoice",
  "text": "Hi Ada, your invoice is attached.",
  "html": "<p>Hi Ada, your invoice is attached.</p>",
  "attachments": [
    { "filename": "invoice.pdf", "contentType": "application/pdf", "content": "JVBERi0xLjcK..." }
  ],
  "reference": "invoice-1042"
}'
FieldMeaning
to, cc, bcc, replyToArrays of { name?, address }, up to 500 each. At least one of to, cc or bcc (no_recipients otherwise).
subject, text, htmlThe content. With format: "markdown", html holds Markdown and is converted to HTML.
attachmentsUp to 50 of { filename, contentType, content }, content in base64. Set contentId to reference one inline from the HTML as cid:....
fromOne of the account's send-as identities (GET /v1/accounts/{accountId}/identities); omitted sends as the account itself.
signatureAppends the sender identity's signature. Omitted follows the account's signByDefault mail setting.
inReplyToMessageIdThe workwae id of the message being answered: sets In-Reply-To and References so the reply stays in its thread.
priorityhigh, normal (default) or low; written as the provider's priority headers.
referenceYour own identifier, echoed in the send.* events.

An unknown field is refused rather than ignored. Every refusal and its reason is listed under Send a message in the API reference.

Delivery and events

The entry's status moves from queued to sending and ends sent, failed or cancelled. Follow it with events (webhooks or the change feed), each carrying the entry's id as outboxId and your reference:

EventWhen
send.sentThe provider accepted the message.
send.retryingAn attempt failed and the entry will be tried again.
send.failedThe entry failed for good. Code outcome_unknown means the server may have accepted the message (its reply was lost), so it is not sent again: decide yourself whether to resend.
send.rescheduledsendAt was moved.
message.bouncedA bounce for the sent message arrived later.

Delivery is at least once (except outcome_unknown); a retry first looks for a copy that already went out. GET /v1/accounts/{accountId}/outbox lists entries newest first (filter with status), and GET .../outbox/{outboxId} reads one. The sent copy indexed from the account's Sent folder carries the same outboxId on the message.

Scheduling and undo

sendAt schedules a send; omitted or past, it goes as soon as possible. The account's undoSendSeconds setting (0 to 60) holds every send at least that long. While an entry waits, the answer's undoUntil says until when cancelling undoes it.

# Move a queued send
curl -X PATCH "https://api.workwae.com/v1/accounts/$ACCOUNT_ID/outbox/$OUTBOX_ID" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "sendAt": "2026-11-02T09:00:00Z" }'

# Cancel (undo) it
curl -X DELETE "https://api.workwae.com/v1/accounts/$ACCOUNT_ID/outbox/$OUTBOX_ID" \
  -H "Authorization: Bearer $WORKWAE_API_KEY"

Drafts

Drafts live in the account's mailbox at the provider, so the user sees them in Gmail or Outlook too. They take the same compose fields as a send.

# Create
curl -X POST "https://api.workwae.com/v1/accounts/$ACCOUNT_ID/drafts" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "to": [{ "address": "[email protected]" }], "subject": "Draft", "text": "..." }'

# Replace its content
curl -X PUT "https://api.workwae.com/v1/accounts/$ACCOUNT_ID/drafts/$MESSAGE_ID" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "to": [{ "address": "[email protected]" }], "subject": "Final", "text": "..." }'

# Send it
curl -X POST "https://api.workwae.com/v1/accounts/$ACCOUNT_ID/drafts/$MESSAGE_ID/send" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{}'
  • PUT replaces the whole draft: send every field you want to keep.
  • Sending a draft queues an outbox entry like any send (same events, undo window and sendAt). Cancelling it leaves the draft in place.
  • A Gmail or Microsoft 365 draft is sent by the provider as stored, so it is never tracked; asking for tracking answers tracking_unsupported.

Templates and personalization

Name a stored template with template and pass values with render.params; or render inline content by passing render with your own subject, html and text. Content is rendered once, when the request is taken, so later template edits never change a queued send.

SDK
await workwae.outbox.create({
  accountId,
  to: [{ name: "Ada", address: "[email protected]" }],
  template: templateId,
  render: { params: { firstName: "Ada", plan: "Pro" } },
});

See Templates for writing, previewing and managing templates.

Mail merges

A mail merge sends one message per recipient in one call: up to 100 entries, each its own outbox entry addressed to its to alone, rendered with its own params. An entry that cannot be queued fails alone; the call itself answers 200 with one result per entry, in order.

curl -X POST "https://api.workwae.com/v1/accounts/$ACCOUNT_ID/mail-merges" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "template": "'"$TEMPLATE_ID"'",
  "listId": "weekly-news",
  "entries": [
    { "to": { "name": "Ada", "address": "[email protected]" }, "params": { "firstName": "Ada" } },
    { "to": { "name": "Alan", "address": "[email protected]" }, "params": { "firstName": "Alan" } }
  ]
}'

The body is a send's without to, cc, bcc and render. An entry may override sendAt or set its own RFC 5322 messageId. A failed entry's reason is one of the API's reasons (invalid_recipient, suppressed, template_render_failed, plan_limit_sends, ...) with the HTTP status a single send would have answered.

Open and click tracking

With tracking, the HTML part gets a 1x1 pixel (opens) and its links go through a workwae redirect (clicks); text parts are never changed. The account's trackOpens, trackClicks and trackingHoneypot settings are the defaults; a send's own tracking object wins over them.

SDK
const queued = await workwae.outbox.create({
  accountId,
  to: [{ address: "[email protected]" }],
  subject: "Spring sale",
  html: "<p>Our <a href=\"https://shop.example.com/sale\">spring sale</a> starts today.</p>",
  tracking: { opens: true, clicks: true, honeypot: true },
});

// Later: the counts on the entry, and each hit with what its request showed.
const entry = await workwae.outbox.get({ accountId, outboxId: queued.id });
console.log(entry.humanOpenCount, entry.machineOpenCount, entry.humanClickCount);

const hits = await workwae.outbox.tracking({ accountId, outboxId: queued.id });
  • Hits are reported as message.opened and message.clicked events and classified: human, or a machine (proxy such as Gmail's image proxy, prefetch such as Apple Mail Privacy Protection, scanner), or unknown.
  • The honeypot adds one hidden link only machines follow, which sharpens the scanner classification. It needs click tracking (honeypot_needs_click_trackingotherwise).
  • Tracking is per message, not per recipient: every recipient of one send gets the same MIME. Use a mail merge for per-recipient numbers.

Unsubscribes and suppression lists

Name a suppression list with listId to send bulk mail properly: the message gets one-click unsubscribe headers (RFC 8058), {{rcpt.unsubscribeUrl}} renders the recipient's own unsubscribe link, and an address on that list or on the global list is skipped (suppressed). A single send naming a list must have exactly one to and no cc or bcc.

Plan limits

Daily sends

A tenant's plan caps the sends it may queue per day (UTC); past it, a send answers 403 plan_limit_sends and a mail merge entry fails with that reason. A tenant whose payment is past due beyond the grace period is refused new sends with 402 billing_past_due; sends already queued still go out.