BUILD WITH OUTBOX STACK
Your first email.
Your next connection.
A clear path from your workspace to your first send. Connect your backend, send transactional email, and follow every message.
https://outboxstack.appYou’ll need a verified account, a verified sender domain, a transactional route, and a workspace API key. Use managed sending or connect your own AWS SES account.
- Create an account, verify your email and sign in.
- Open Providers, choose managed sending, and complete the sending-use and consent questions. Check Billing for account status and your daily limit.
- Open Domains, add a domain you control and copy the generated DNS records. Use “Check records” until verification succeeds. Keep your existing root MX records for receiving mail; the bounce-subdomain MX is separate.
- Confirm transactional routing in Providers. Open Developers and create an API key. Choose Sending only for apps that just send email: the key can't change your workspace and can be locked to one sender domain. Copy it once and store it in your backend's secret manager.
Customers connect to Outbox Stack, using an Outbox Stack API key. Managed sending doesn't require customer AWS credentials. DNS verification records can identify our delivery provider. We don't host customer inboxes or receive replies.
Set MAIL_BASE_URL to https://outboxstack.app and MAIL_API_KEY to your key. Replace both email addresses with your verified sender and a real test recipient.
curl "$MAIL_BASE_URL/v1/messages" \
-H "Authorization: Bearer $MAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reset-request-unique-id" \
--data '{
"from": "accounts@your-verified-domain.com",
"fromName": "Your app",
"to": "customer@example.com",
"toName": "Ada Obi",
"replyTo": "support@your-verified-domain.com",
"subject": "Reset your password",
"text": "Use the password-reset link generated by your app."
}'A new request returns 202 {"id":"…","status":"queued"}. Repeating the same idempotency key returns the original message with HTTP 200. Use a different key for a different reset request. Accepted means queued, not delivered.
Optional fields: toName, replyTo, html, template with data in place of subject and body, cc and bcc, up to 10 attachments (base64, 10 MB in total), up to 20 custom headers, and up to 10 metadata strings that come back in GET and webhooks. to, cc and bcc take one address or a list, written ada@example.com or Ada Obi <ada@example.com>.
Each recipient (at most 50 per send) becomes its own message with its own id, status and bounce tracking, and counts as one email. The response's id is the first recipient's; recipients lists every id. Every copy shows the full To and Cc lines; Bcc never appears. Multiple recipients need managed sending, SES or SMTP.
Send up to 100 different messages at once with POST /v1/messages/batch and {"messages":[…]}; each item may carry its own idempotencyKey and succeeds or fails independently. The full contract is in the OpenAPI 3.1 spec.
npm install @getoutbox/sdkDownload the SDK and TypeScript declarations into your backend and import ./mail.js, or install npm install @getoutbox/sdk and use the package import shown below. Requires Node.js 20.3+. The release package is @getoutbox/sdk. The package is public on npm; downloadable builds remain available here.
import { Outbox } from '@getoutbox/sdk';
// Reads OUTBOX_API_KEY; the base URL defaults to https://outboxstack.app.
const mail = new Outbox();
// Your application creates and stores an expiring, single-use reset token.
// resetUrl and resetRequestId come from that trusted backend flow.
const message = await mail.send({
from: 'accounts@your-verified-domain.com',
fromName: 'Your app',
to: user.email,
toName: user.name,
replyTo: 'support@your-verified-domain.com',
subject: 'Reset your password',
text: `Reset your password: ${resetUrl}\nIgnore this if you did not request it.`,
}, { idempotencyKey: `password-reset:${resetRequestId}` });
const status = await mail.getMessage(message.id);Generate reset links in your own backend using a fixed trusted application origin. Outbox Stack delivers the message; your app owns token creation, expiry and password changes. Never expose API keys in browser or mobile code, log reset links, or insert unescaped user input into HTML.
PHP 8.2+. Install the client with Composer and set OUTBOX_API_KEY to a Sending only key.
composer require getoutbox/outbox-phpuse Outbox\Client;
$outbox = new Client(); // reads OUTBOX_API_KEY; base URL defaults to https://outboxstack.app
$result = $outbox->send([
'from' => 'accounts@your-verified-domain.com',
'fromName' => 'Your app',
'to' => 'Ada Obi <customer@example.com>',
'replyTo' => 'support@your-verified-domain.com',
'subject' => 'Reset your password',
'text' => 'Use the password-reset link generated by your app.',
'metadata' => ['user_id' => '42'],
], idempotencyKey: "password-reset:{$request->id}");
$result['id']; // message id; returned once queued, not deliveredAlso: sendBatch($messages) (1–100 messages), getMessage($id) and listMessages(status: 'bounced'). Timeouts, 429 and 5xx responses are retried with the same idempotency key, so a retry never sends twice. Attachment content is raw bytes; the client encodes it.
use Outbox\Exception\ApiException;
use Outbox\Exception\OutboxException;
try {
$outbox->send($message);
} catch (ApiException $e) {
$e->status(); // 422
$e->errorCode(); // "domain_not_verified": branch on this
$e->requestId(); // quote to support
} catch (OutboxException $e) {
// network failure after retries, or invalid configuration
}Verify webhooks with Outbox\Webhook::verify($rawBody, $headers, $secret). Source and full reference: github.com/Outbox-Stack/outbox-php.
Laravel 11, 12 and 13. A mail driver, so your existing Mailables and notifications send through Outbox Stack unchanged.
composer require getoutbox/outbox-laravel// config/mail.php
'mailers' => [
'outbox' => ['transport' => 'outbox'],
],
// .env (a Sending only key from Developers)
MAIL_MAILER=outbox
OUTBOX_API_KEY=mk_...
MAIL_FROM_ADDRESS=hello@your-verified-domain.comNow Mail::to($user)->send(new InvoicePaid($invoice)) goes through Outbox Stack. Each recipient becomes its own message with its own bounce tracking. Add an X-Outbox-Idempotency-Key header to queued Mailables so job retries never send twice, and attach ->metadata('order_id', 42) to get it back in webhooks.
// .env
OUTBOX_WEBHOOK_SECRET=whsec_...
use Outbox\Laravel\Events\MessageBounced;
Event::listen(function (MessageBounced $event) {
User::where('email', $event->email())->update(['email_bounced_at' => now()]);
});The package registers POST /outbox/webhook once the secret is set, checks signatures and dispatches events such as MessageDelivered, MessageBounced and ContactUnsubscribed. Point a webhook at that URL in Developers. Source and full reference: github.com/Outbox-Stack/outbox-laravel.
Inspect GET /v1/messages/:id or add a webhook in Developers for delivery, bounce and complaint events. Verify every webhook's signature before trusting it. Webhooks use the Standard Webhooks format: webhook-id, webhook-timestamp and webhook-signature headers. With the SDK, pass the raw body: await verifyWebhook(rawBody, req.headers, secret) returns the event or throws.
Errors return {"error": "…", "code": "domain_not_verified", "request_id": "req_…"}. Branch on code, show error, and quote request_id (also in the X-Request-Id header) when contacting support.
| Response | Action |
|---|---|
| 400 | Fix the request: invalid_request, invalid_address, invalid_attachment, invalid_header, too_many_recipients. |
| 401 / 403 | Check the key: unauthorized, forbidden (a sending key outside /v1/messages), sender_domain_not_allowed, account_restricted. |
| 402 | quota_exceeded: check allowance and credits. |
| 422 | domain_not_verified, sending_not_configured, missing_template_data, unsupported_by_provider. |
| 429 / 503 / timeout | Retry with backoff and the same idempotency key. A timeout may follow successful acceptance. |
The SDK retries timeouts, network errors, 429 and 5xx up to twice (maxRetries), honouring Retry-After. Every send carries an idempotency key, generated when you don't pass one, so a retry never sends twice. Pass your own key to stay safe across process restarts too. Other errors throw OutboxApiError with status, code, requestId and the response body.
Use the hostname and port shown in Developers. Username: apikey. Password: your Outbox Stack API key. Port 587 requires STARTTLS; port 465 uses implicit TLS when enabled by the operator. Verify the server certificate.
import nodemailer from 'nodemailer';
const smtp = nodemailer.createTransport({
host: process.env.OUTBOX_SMTP_HOST,
port: 587,
secure: false,
requireTLS: true,
auth: { user: 'apikey', pass: process.env.MAIL_API_KEY },
});
await smtp.sendMail({
from: 'accounts@your-verified-domain.com',
to: 'customer@example.com',
subject: 'Reset your password',
text: 'Your trusted application reset link',
headers: { 'X-Outbox-Idempotency-Key': 'reset:unique-request-id' },
});Transactional messages only, one recipient per submission, up to 1 MiB. Attachments are not supported. The From header must match the envelope sender. A 250 response means queued, not delivered. Use the same idempotency header when retrying an uncertain submission.
HTTPS API: available from any backend language. Node/TypeScript SDK: available as a compiled client with TypeScript source and declarations. SMTP submission: implemented with mandatory TLS and API-key authentication. Find the deployed hostname and port in Developers; if not configured yet, use the HTTPS API. Our SMTP provider setting is a separate outbound relay connection. PHP and Laravel: getoutbox/outbox-php and getoutbox/outbox-laravel on Packagist. Other languages: use the HTTPS API directly.