Send Transactional Email
Send transactional email from your other apps with the Tinbox SDK, a Resend-compatible client running on your own Cloudflare account.
The sending API is a transactional email API your other applications call: receipts, password resets, alerts. It runs on the same Cloudflare account as your inbox, so nothing transits a Tinbox relay. The surface is Resend-compatible-ish, so existing SDK habits transfer.
Use the SDK to send
When your app needs to send email through Tinbox, use the tinbox/sdk client:
- send with
tinbox.emails.send(...) - check delivery with
tinbox.emails.get(sendId) - send many messages with
tinbox.emails.batch(...)
Those calls go to your live deployment's /api/send API. Every send is recorded
in the send ledger before dispatch, so emails.get can tell you what was
attempted, sent, retried, suppressed, or failed. Git is useful elsewhere in
Tinbox for reviewing authored mail, but application code should send and check
delivery through the SDK.
Install the SDK
The SDK ships inside the main tinbox package — one install, imported from the
tinbox/sdk subpath. The client is dependency-free (fetch-only) and runs on
Workers, Node 18+, browsers, Bun, and Deno.
npm install tinbox
Create an API key
Transactional sending uses a sending-scoped API key. It is different from an inbox agent token:
| Need | Create | Use it for |
|---|---|---|
| Your app sends receipts, password resets, alerts, or other SDK/API email | Settings → API keys, or tinbox tokens create-send-key | tinbox/sdk, tinbox.emails.send, tinbox send |
| An agent or MCP client reads inbox mail, drafts replies, or sends thread replies | tinbox tokens create --inbox … | inbox CLI commands, MCP, reply/draft workflows |
For most teams, open Settings → API keys in your Tinbox instance and create the key there. Choose the narrowest sending scope that fits your app:
- one identity, such as
receipts@acme.dev - one verified domain, such as
*@acme.dev - the whole account
Copy the tbx_… token when it is shown. Store it in your app as
TINBOX_API_KEY; it cannot be recovered later.
TINBOX_API_KEY is the canonical name used in Tinbox examples. Your app can use
another secret name, but keep one canonical name per app so deployments and
incident debugging stay obvious.
Use the CLI when you are bootstrapping an environment or scripting setup:
tinbox tokens create-send-key --account --name receipts-api --json
tinbox tokens create-send-key --domain sd_abc --name receipts-api --json
tinbox tokens create-send-key --identity sid_abc --name receipts-api --json
This requires a caller that can manage API keys: an org admin, or a user/token
with the Developer/API product area. The CLI prints the token once in the JSON
payload as token.
The create-send-key scope controls which from addresses the key may use.
--identity is the tightest scope, --domain allows any address on one verified
domain, and --account allows any sending identity in the account. The default
permission is send, which is enough for tinbox/sdk; --permission full is
for operational keys that also need key/domain administration.
Construct a client
host is your Tinbox instance origin. The SDK derives the send-API mount
(/api/send) and the typed API mount (/api/trpc) from it. apiKey is the
tbx_… sending API key you created in Settings → API keys or with
tinbox tokens create-send-key.
import { Tinbox } from "tinbox/sdk";
const tinbox = new Tinbox({
apiKey: process.env.TINBOX_API_KEY,
host: "https://acme.dev",
});
Validate provisioning without sending a real email:
await tinbox.auth.check();
await tinbox.emails.validateSendAccess({
from: "Acme <hello@acme.dev>",
});
auth.check() verifies the host and API key. validateSendAccess() also checks
the key's sender scope when you pass from, then returns canSend: true or
canSend: false without writing to the send ledger or dispatching mail.
Send a message
const res = await tinbox.emails.send({
from: "Acme <hello@acme.dev>",
to: "ada@example.com",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
replyTo: "support@acme.dev",
});
console.log(res.id, res.ok, res.message_id);
The response carries a stable id (the correlation key for this send), an ok
flag (true if every recipient ended sent, duplicate, or an intentional
dropped_as_test), the outbound message_id, a per-recipient recipients
array, and any suppressed addresses dropped before dispatch. to, cc, and
bcc each accept a bare address, an
{ address, name } object, or an array. Extra headers must be X-* only;
anything else is rejected with a 422. See
emails.send for the full shape.
Mock-only envelope domains under .local, .localhost, and .invalid (for
example, test.local) are accepted and recorded, but Tinbox does not contact
Cloudflare or another mail transport for them. The recipient result has
status: "dropped_as_test" with a reason, and the delivery
timeline receives a dropped_as_test event. This terminal status is idempotent:
retrying the same send cannot later emit the fixture as real mail.
Attach reply logic to the thread — onReply and drip
This is the one thing no send-only API can do: because Tinbox owns both send and receive on one box, the reply lands back where you started the thread — so you can attach follow-up logic to the send itself. Send the email, and in the same call declare what happens when they reply and how to follow up if they don't:
await tinbox.emails.send({
from: "sales@acme.dev",
to: "lead@corp.com",
subject: "Your quote",
text: "Here's the quote you asked for …",
// When they reply on this thread, draft an answer in your voice (it lands in
// your Review queue — nothing is sent without you unless you opt into it).
onReply: { mode: "draft", tone: "warm, concise", instructions: "Answer pricing questions; offer a call." },
// If no reply arrives, follow up on a schedule — a bump at 3 days, a heads-up
// at 7. Each step fires only if the thread is still silent.
drip: [
{ after: "3d", ifNoReply: true, mode: "draft", instructions: "Gentle bump — restate the offer." },
{ after: "7d", ifNoReply: true, mode: "notify" },
],
});
One call, one thread, zero new client concepts — the same emails.send you
already use, with two optional fields. This works identically whether you author
it here, from the composer's "After sending…" menu, or by asking the
assistant ("if they reply, draft an answer in my voice").
action.mode — how autonomous each follow-up is
onReply and each drip step take a mode that maps to how much you want to
stay in the loop:
| Mode | Behavior |
|---|---|
draft (default) | An agent drafts the reply; it lands in your Review queue. Nothing is sent. |
send | Auto-sends the reply (subject to the deployment's policy and any agent HITL). |
ask | Parks a check-in question in Review before acting — the case-by-case option. |
notify | No content action — a notification only (e.g. "no reply after 7 days"). |
draft is the safe default. mode: "send" is only honored when the deployment's
tinbox.config.ts sets directives: { allowAutoSend: true } — otherwise the SDK
rejects the send with a clear error (it never silently downgrades your intent
on the programmatic path). Every directive also carries a hard expiry (default 14
days) and a maxFires cap (default 1), so a thread can never loop.
drip timing
Each drip step's after is a duration ("3d", "7d", "2h", "30m", "1w")
or a raw millisecond number. ifNoReply: true (the default) makes the step a
no-reply follow-up — it fires only if the thread is still silent when the timer
comes due; ifNoReply: false fires unconditionally.
Manage directives after the send
The typed client lists, reads, and cancels the directives on a thread:
const { directives } = await tinbox.api.directives.list.query({ threadId });
await tinbox.api.directives.cancel.mutate({ id: directives[0].id });
The types DirectiveAction, DripStep, and ThreadDirective are exported from
tinbox/sdk. Snake on_reply is accepted alongside onReply (same
dual-acceptance as reply_to).
JS-friendly field names
The SDK accepts both JavaScript-friendly camelCase (replyTo,
idempotencyKey, contentType) and wire-format snake case (reply_to,
idempotency_key, content_type). Prefer camelCase in TypeScript apps. If both
forms are present with different values, the SDK throws before making a request.
Idempotency keys
Pass an idempotencyKey and a retry of the exact same send is a safe no-op
replay against the send ledger. The SDK also lifts the
key into the JSON request body. Keeping opaque keys out of HTTP headers also
allows values containing spaces or line breaks without client-side rejection.
await tinbox.emails.send({
from: "hello@acme.dev",
to: "ada@example.com",
subject: "Your receipt",
text: "Thanks for your order.",
idempotencyKey: "order-4821-receipt",
});
Idempotency is durable ledger behavior, not a short client-side timeout. Tinbox trims your supplied key and stores it in the send ledger. A retry with the same key:
- replays or short-circuits if the prior row is already
sentor stillpending; - reclaims the row if the prior attempt is
failedorcanceled; - uses the same logical key across recipients by suffixing ledger rows per recipient internally.
Use stable keys for business events that should send at most once:
BOOKING-CONFIRM-<bookingId>
BOOKING-CANCEL-<bookingId>
RECEIPT-<receiptNumber>
Use a unique key, or omit the key, when each attempt should send independently:
OTP-<userId>-<timestamp>
SMOKE-<timestamp>
If you omit the key, Tinbox derives a deterministic key from the generated
Message-ID. The CLI's tinbox send prints the key it uses; the
SDK leaves explicit business keys to your app.
Send a batch
emails.batch takes an array and returns per-item results: each entry is either
a sent message (carrying ok and recipients) or a { index, error } for an
item that failed validation.
const { results } = await tinbox.emails.batch([
{ from: "hello@acme.dev", to: "ada@example.com", subject: "Hi Ada", text: "..." },
{ from: "hello@acme.dev", to: "grace@example.com", subject: "Hi Grace", text: "..." },
]);
See emails.batch.
Suppressions
Hard bounces, complaints, and unsubscribes land on a suppression list and are
never re-sent. A suppressed recipient is reported in the suppressed array of a
send response, not dispatched. Manage the list directly:
await tinbox.suppressions.add({ address: "bounced@example.com", reason: "bounce" });
await tinbox.suppressions.list();
await tinbox.suppressions.remove("bounced@example.com");
See suppressions.add.
Check delivery status
emails.get(id) returns the roll-up status, the per-recipient ledger (attempts,
last error, sent time), and the event timeline (queued, delivered,
bounced, opened, clicked).
const status = await tinbox.emails.get(res.id);
console.log(status.status, status.recipients, status.events);
From the terminal, tinbox status <ledger-id> prints the
same picture. See emails.get.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot find module "@tinbox/engine" while type-checking tinbox/sdk | An old SDK declaration leaked Tinbox workspace internals | Upgrade to a build where dist/sdk.d.ts is self-contained |
Tinbox request failed before response: POST ... ENOTFOUND | DNS, host, local sandbox, or CI egress blocked the request before Tinbox saw it | Check host, network access, and CI/sandbox permissions |
401 | Missing, revoked, expired, or wrong TINBOX_API_KEY | Recreate the key in Settings → API keys and update the secret |
403 | The key lacks send permission or cannot send from that from address | Use validateSendAccess({ from }), then grant the right sender scope |
| A duplicate did not send again | The same idempotency key already has a terminal sent ledger row | Use stable keys only for single-send business events |
For smoke tests, prefer a real .mjs file or node --input-type=module. Some
tsx -e one-liners run as CommonJS and reject top-level await, which is a
local runner issue rather than a Tinbox API issue.
Next steps
- Send a newsletter to a whole audience.
- Browse every SDK method and the send_email tool an agent uses for the same job.