# Outstamp documentation > Every public page of https://outstamp.scaling.pt/docs/ in one file, for coding agents (DOC-009). # Outstamp Outstamp is a **server-side transactional email API**. You send a request, we send the message from an isolated tenant that belongs only to your project, and we show you exactly what happened to it. You never run a mail server, configure a sending provider, build an event pipeline or name a tenant. ## What you can do in two minutes 1. Create an account and verify your email address. 2. Copy a test API key. 3. Make one request and receive a real email at your own address. 4. Watch its delivery timeline. Adding your own domain comes next. We will not pretend a custom domain is production-ready in two minutes, because DNS propagation is not ours to control. ## The model | Concept | What it is | |---|---| | **Workspace** | Your team and billing boundary. Owns projects, members and the plan. | | **Project** | Your sending, data and reputation boundary. Each one maps to its own isolated tenant at the provider. | | **Environment** | `test` or `live`. Test traffic never reaches the internet. | | **Domain** | A verified sending identity. It belongs to exactly one project. | | **API key** | A machine credential bound to one project and one environment. | | **Email** | One logical send. It may address several recipients. | | **Delivery** | One envelope recipient, with its own outcome and timeline. | | **Event** | An immutable fact about an email or a delivery. | ## What "delivered" means `delivered` means the recipient's mail server accepted the message. It is not proof of inbox placement and not proof anyone read it. No email provider can honestly promise either. ## Next - [Quickstart](quickstart.md) - [Test and Live environments](environments.md) - [Send API reference](send-api.md) - [Webhooks](webhooks.md) --- # Quickstart ## 1. Create a test API key In the dashboard, open **API Keys** and create a key. The default permission is send-only, which is what you want for an application that only sends. The secret is shown **once**. Store it in your environment or a secret manager. Never put it in browser-delivered code — Outstamp is a server-side API. ``` os_test_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Test and live keys have visibly different prefixes so a key pasted into the wrong place is obvious at a glance. ## 2. Send your first request ```bash curl -X POST https://api.outstamp.dev/v1/emails \ -H "Authorization: Bearer $OUTSTAMP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": "delivered@simulator.outstamp.dev", "subject": "Hello from Outstamp", "html": "

It works.

" }' ``` ```json { "id": "em_2n4k8x1q7c5m9r3t6v0w2y4a", "object": "email", "created_at": "2026-09-06T12:00:00Z", "environment": "test", "status": "queued", "recipient_count": 1 } ``` You get `202 Accepted` and an email id **after** the request is durably recorded. Acceptance is not delivery — the status is `queued`, and the timeline tells you the rest. ## 3. Watch what happened Open the email in the dashboard, or ask the API: ```bash curl https://api.outstamp.dev/v1/emails/em_2n4k8x1q7c5m9r3t6v0w2y4a/events \ -H "Authorization: Bearer $OUTSTAMP_API_KEY" ``` ## 4. Send from your own domain Test keys never deliver to the internet. To send real mail, add a domain: 1. **Domains → Add domain.** Use a subdomain such as `send.yourcompany.com`; it keeps this reputation separate from your other mail. 2. Copy the DNS records exactly as shown. DKIM is required. The custom MAIL FROM records are strongly recommended. 3. We check automatically, and you can trigger a check yourself. 4. When DKIM verifies, create a **live** key and send. See [Domains](domains.md) for the full setup, including provider-specific notes. ## Language examples ```js const response = await fetch("https://api.outstamp.dev/v1/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.OUTSTAMP_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `welcome-${user.id}` }, body: JSON.stringify({ from: "Acme ", to: user.email, subject: "Welcome", html: "

Thanks for signing up.

" }) }); const email = await response.json(); ``` ```python import os, requests response = requests.post( "https://api.outstamp.dev/v1/emails", headers={ "Authorization": f"Bearer {os.environ['OUTSTAMP_API_KEY']}", "Idempotency-Key": f"welcome-{user.id}", }, json={ "from": "Acme ", "to": user.email, "subject": "Welcome", "html": "

Thanks for signing up.

", }, ) email = response.json() ``` --- # Test and Live Every project has two environments. A key belongs to exactly one of them and cannot be used against the other. | | Test | Live | |---|---|---| | Key prefix | `os_test_` | `os_live_` | | Reaches the internet | **No** | Yes | | Needs a verified domain | No | Yes | | Counts toward billing | No | Yes | | Produces events and webhooks | Yes, labelled `test` | Yes | ## Test mode never sends real mail A test send is accepted, validated, queued and processed exactly like a live send. It produces the same email record, the same delivery states, the same events and the same webhooks. It simply never leaves Outstamp. The one exception is the **onboarding email**, which is a real message from an Outstamp address to a verified email address of a workspace member. It exists so you can prove the whole path works before you own a domain. It is limited to a small number per day, cannot address arbitrary recipients, and cannot carry attachments. ## Simulator recipients Test mode gives you deterministic outcomes so you can build and test your handling of every state without waiting for real mail. | Recipient | What happens | |---|---| | `delivered@simulator.outstamp.dev` | sent → delivered | | `bounce@simulator.outstamp.dev` | sent → hard bounce, and the address is suppressed | | `transient@simulator.outstamp.dev` | sent → delayed → delivered | | `complaint@simulator.outstamp.dev` | sent → delivered → complaint, and the address is suppressed | | `suppressed@simulator.outstamp.dev` | suppressed before submission | | `reject@simulator.outstamp.dev` | the provider rejects the content | | `fail@simulator.outstamp.dev` | a generic failure | | `unknown@simulator.outstamp.dev` | the provider's response is lost, then a delivery event confirms it | Any other address in test mode is accepted and simulated as delivered. These scenarios do not touch your sending reputation, and they produce exactly the same public event structure as live traffic. --- # Send API `POST /v1/emails` ## Authentication ``` Authorization: Bearer os_live__ ``` HTTPS only. The key needs the `email.send` permission. ## Request | Field | Required | Notes | |---|:---:|---| | `from` | yes | `Name
` or a bare address. In live, the domain must be verified for this project. | | `to` | yes | One address or an array. | | `cc` | no | One address or an array. | | `bcc` | no | One address or an array. Never exposed to other recipients. | | `reply_to` | no | One address or an array. | | `subject` | yes | UTF-8, up to 998 characters, no line breaks. | | `html` | conditional | Required unless `text` is given. | | `text` | conditional | Generated from `html` when omitted. An explicit empty string disables generation. | | `headers` | no | Custom headers. Routing, authentication and Outstamp-reserved headers are rejected. | | `tags` | no | Searchable, non-sensitive key/value metadata. | | `attachments` | no | Base64 content or an uploaded asset id. Not available on the free plan. | | `scheduled_at` | no | RFC 3339, between 1 minute and 30 days ahead. | | `tracking` | no | `{ "opens": bool, "clicks": bool }`, within project and domain policy. | Between 1 and 50 recipients across `to`, `cc` and `bcc`. ## Response `202 Accepted` ```json { "id": "em_2n4k8x1q7c5m9r3t6v0w2y4a", "object": "email", "created_at": "2026-09-06T12:00:00Z", "environment": "live", "status": "queued", "scheduled_at": null, "recipient_count": 1, "warnings": [] } ``` The id is returned only after the request is durably stored. If Outstamp cannot record it, you get a retryable `503` and **no** email id — never a fabricated one. ## Idempotency Send `Idempotency-Key` with any value of 1–256 characters from `A-Z a-z 0-9 _ . : -`. Scope is your project, environment, method and path, and records are kept for at least 24 hours. | Situation | Result | |---|---| | Same key, same body | The original response, with `Idempotent-Replay: true`. No second email. | | Same key, different body | `409 idempotency_key_reused` | | Same key, concurrently | One winner. Others get the stored result, or a retryable `409 idempotency_request_in_progress`. | "Same body" means the same normalised request: key order does not matter, addresses are compared in their normalised form, and attachment content is compared by hash. Idempotency makes **your retries** safe. It is not a claim of exactly-once email delivery, and no honest provider makes that claim. ## One delivery per recipient Outstamp submits one provider delivery per envelope recipient. Each one gets its own provider message id and its own timeline, so a single bad address does not obscure the rest. The visible `To` and `Cc` headers still carry every address you wrote; `Bcc` stays envelope-only. If recipients end differently, the email's aggregate status is `partial` and the per-recipient outcomes are on the deliveries. ## Sizes The complete encoded message must not exceed 40 MB. Above roughly 10 MB, expect lower throughput and stricter mailbox behaviour — we warn you rather than let you discover it in production. ## Attachments Not available on the free plan. Each attachment is either inline base64 or a reference to a file you uploaded with `POST /v1/assets`: ```json "attachments": [ { "filename": "invoice.pdf", "content": "JVBERi0xLjcK...", "content_type": "application/pdf" }, { "filename": "logo.png", "content": "iVBORw0KGgo...", "content_type": "image/png", "content_id": "logo" }, { "filename": "terms.pdf", "asset_id": "ast_..." } ] ``` | Field | Notes | |---|---| | `filename` | Required. No path separators or line breaks. Executable types are rejected. | | `content` | Standard, padded base64. Line breaks are fine; a `data:` prefix is not. | | `asset_id` | An asset uploaded to the same project. Use it to send one file in many emails. | | `content_type` | A MIME type such as `application/pdf`. Defaults to `application/octet-stream`, or to the asset's type. | | `content_id` | Makes the part inline, for `` in your HTML. | Each attachment may be at most **15 MB**, and the whole encoded message — attachments, referenced assets and body — at most **40 MB**. Every attachment is checked for malware before it is sent. A delivery waits for that check; it never leaves without its attachments. If an attachment cannot be sent, the delivery is `failed`, you receive an `email.failed` event, and the reason says why: | Reason | Meaning | |---|---| | `attachment_scan_failed` | The file failed the malware check, or the check did not finish within 10 minutes. | | `attachment_unavailable` | The file's content is no longer stored — for example, it was removed by a retention policy before the send. | Attachment content is encrypted at rest. It is kept no longer than the message body, and is deleted 24 hours after sending to the email's last recipient has finished — or straight away if your workspace is set to delete attachments after submission. An uploaded asset expires 24 hours after upload, extended for as long as an email that references it still needs it. ## Reading emails ``` GET /v1/emails GET /v1/emails/{email_id} GET /v1/emails/{email_id}/events GET /v1/emails/{email_id}/deliveries ``` Filters: `environment`, `status`, `since`, `until`, `domain`, `recipient`, `api_key_id`, `tag`, `request_id`. Lists use cursor pagination: pass `next_cursor` back as `cursor`. ## Scheduling ``` PATCH /v1/emails/{email_id} # reschedule POST /v1/emails/{email_id}/cancel # cancel ``` Both work only while the message is still scheduled and has not entered the dispatch window (the last 60 seconds before its time). After that it is on its way and cannot be recalled. Revoking the API key that created a scheduled message does **not** cancel it unless you explicitly choose that option when revoking. ## Batch `POST /v1/emails/batch` accepts 1–100 emails. The whole batch is validated before anything is accepted: if any item fails, nothing is enqueued and you get indexed field errors such as `emails[7].to[0]`. Returned ids preserve request order. Batch acceptance does not mean simultaneous delivery. Every email still follows the same quota, suppression, dispatch and event rules. --- # Statuses and events ## Why a status is not one field Provider events duplicate, arrive late and arrive out of order. If status were a single value, a `sent` event arriving after `delivered` would erase the fact of delivery, and a complaint would erase it too. So Outstamp records four independent facts per delivery, and each one only ever moves forward. | Dimension | Values | What it records | |---|---|---| | Submission | `scheduled`, `queued`, `sending`, `sent`, `submission_unknown`, `rejected`, `failed`, `suppressed`, `canceled` | Whether anyone accepted responsibility for an attempt | | Transport | `pending`, `delayed`, `delivered`, `bounced` | What the recipient's server did | | Feedback | `none`, `complained` | Whether it was reported as spam | | Engagement | open and click counts with first and last times | Approximate interaction | The single status you see is **derived** from those, and it is a cache, never an input to a decision. | Order | Condition | Shown as | |---:|---|---| | 1 | cancelled | `canceled` | | 2 | suppressed | `suppressed` | | 3 | rejected or failed | that value | | 4 | submission unknown | `submission_unknown` | | 5 | bounced | `bounced` | | 6 | delivered | `delivered` | | 7 | delayed | `delayed` | | 8 | sent | `sent` | | 9 | otherwise | `scheduled` / `queued` / `sending` | A complaint is shown as a flag alongside the status, not instead of it — a message that was delivered and then reported as spam is both of those things. ## `submission_unknown` If a provider call times out, we genuinely do not know whether the message was accepted. Retrying could send it twice. So the delivery enters `submission_unknown` and waits for a correlated provider event to resolve it. Most resolve to `sent` within seconds. If nothing arrives within 72 hours, it becomes `failed`. You will see this state honestly rather than a confident status we cannot back up. ## Events | Event | Meaning | |---|---| | `email.accepted` | The request was durably accepted. Not a delivery. | | `email.scheduled` | Waiting for its scheduled time. | | `email.queued` | Released to the dispatch queue. | | `email.sent` | The provider accepted the message. | | `email.delivered` | The recipient's server accepted it. | | `email.delivery_delayed` | Temporary problem; the provider may still retry. | | `email.bounced` | Final failure, with a normalised classification. | | `email.complained` | Reported as spam. | | `email.rejected` | Content rejected before sending, including virus findings. | | `email.failed` | Could not be submitted or rendered. | | `email.suppressed` | Skipped because the address is suppressed. | | `email.opened` | Tracking pixel loaded. Approximate. | | `email.clicked` | Tracked link requested. Automated scanners create these too. | | `email.canceled` | Cancelled before dispatch. | | `email.submission_unknown` | Provider acceptance could not be determined. | | `domain.created` / `domain.status_changed` / `domain.deleted` | Domain lifecycle. | | `suppression.created` / `suppression.deleted` | Suppression list changes. | | `project.quota_warning` | A usage or trust threshold was crossed. | | `project.reputation_warning` | Reputation entered watch or worse. | | `project.sending_paused` / `project.sending_resumed` | Sending state changed, with the source. | Every event carries both `occurred_at` (the provider's time) and `received_at` (ours), because the gap between them is often the answer to "why did this look slow?". ## Bounce classification A bounce event includes a normalised classification alongside the raw provider diagnostic: ```json { "bounce_type": "hard", "bounce_subtype": "nonexistent", "likely_cause": "The mailbox does not exist.", "retry_recommended": false, "suppression_action": "suppress", "diagnostic": "smtp; 550 5.1.1 user unknown" } ``` - **hard** — permanent. The address is suppressed automatically. - **transient** — temporary. Not suppressed on the first occurrence; repeated failures eventually are. - **undetermined** — the receiving server did not explain itself. Never auto-suppressed. ## Open and click accuracy Tracking is off by default. When you enable it, treat the numbers as approximate: mail privacy features pre-load images (inflating opens) and security scanners follow links (inflating clicks). We say so in the dashboard too, rather than presenting these as precise. --- # Webhooks ## Creating an endpoint Endpoints must be public HTTPS URLs. Choose the event types you want — subscribing to "all current and future events" is possible but has to be a deliberate choice, so a future event type cannot surprise your handler. The signing secret is shown **once**. ## What we send ``` POST /your/endpoint Outstamp-Id: whd_9r3t6v0w2y4a7c1e8g2j4l5n Outstamp-Timestamp: 1789041600 Outstamp-Signature: v1=5d41402abc4b2a76b9719d911017c592… Content-Type: application/json User-Agent: Outstamp-Webhooks/1.0 ``` ```json { "id": "evt_2n4k8x1q7c5m9r3t6v0w2y4a", "object": "event", "type": "email.delivered", "api_version": "2026-09-06", "environment": "live", "project_id": "prj_7h2k4m8n1q5r9t3v6w0x2y4a", "occurred_at": "2026-09-06T12:00:00Z", "received_at": "2026-09-06T12:00:01Z", "data": { "email_id": "em_…", "delivery_id": "dlv_…", "recipient": "user@example.com" } } ``` `Outstamp-Id` is the **delivery** id. A replay of the same event gets a new delivery id and keeps the original event id. ## Verifying the signature Sign the delivery id, the timestamp and the **exact raw body**, joined by periods: ``` signed = delivery_id + "." + timestamp + "." + raw_body v1 = HMAC_SHA256(secret, signed) // hex ``` ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(request, rawBody, secret) { const id = request.headers["outstamp-id"]; const timestamp = request.headers["outstamp-timestamp"]; const header = request.headers["outstamp-signature"] ?? ""; // Reject replays. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = createHmac("sha256", secret) .update(`${id}.${timestamp}.${rawBody}`) .digest("hex"); // Accept any signature in the header so a secret rotation is seamless. return header.split(",") .map((part) => part.trim().replace(/^v1=/, "")) .some((candidate) => candidate.length === expected.length && timingSafeEqual(Buffer.from(candidate, "hex"), Buffer.from(expected, "hex"))); } ``` ```python import hmac, hashlib, time def verify(headers, raw_body: bytes, secret: str) -> bool: delivery_id = headers["Outstamp-Id"] timestamp = headers["Outstamp-Timestamp"] if abs(time.time() - int(timestamp)) > 300: return False signed = f"{delivery_id}.{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() provided = [p.strip().removeprefix("v1=") for p in headers["Outstamp-Signature"].split(",")] return any(hmac.compare_digest(candidate, expected) for candidate in provided) ``` **Use the raw body.** Most frameworks parse and re-serialise JSON, which changes the bytes and breaks verification. Capture the raw body before parsing. **Compare in constant time.** A naive `==` leaks timing information. ## Delivery guarantees Delivery is **at least once** and **unordered**. Deduplicate on the event `id`, and do not assume `email.sent` arrives before `email.delivered`. Acknowledge quickly with any `2xx` and process asynchronously. We wait 10 seconds for a response. ## Retries | Attempt | When | |---:|---| | 1 | immediately | | 2 | +5 seconds | | 3 | +1 minute | | 4 | +5 minutes | | 5 | +30 minutes | | 6 | +2 hours | | 7 | +6 hours | | 8 | +12 hours | | 9 | +24 hours | | 10 | +48 hours | - Any `2xx` is a success. - `410 Gone` disables the endpoint immediately. - **Redirects are not followed.** A `3xx` is a failed attempt — update the URL. - We notify you when an endpoint starts failing and when it is disabled. ## Replay Any retained event (30 days) can be replayed from the dashboard or the API. The replay keeps the original event id and payload, and creates a new delivery attempt marked as a replay — so your deduplication works exactly as it would for a duplicate. ## Endpoint safety Endpoint URLs are checked at creation **and at every delivery**, and every resolved address must be public. Loopback, link-local, private and reserved ranges are refused. This protects your infrastructure as much as ours. --- # Domains ## Choose a subdomain Use a dedicated subdomain such as `send.yourcompany.com` or `notifications.yourcompany.com` rather than your root domain. It keeps your transactional reputation separate from your other mail, and it means a mistake in one stream cannot damage another. ## Records When you add a domain we show the exact records to publish, each with its own live status. | Purpose | Type | Required | Why | |---|---|:---:|---| | DKIM (three records) | CNAME | **yes** | Cryptographically signs your mail so receivers can verify it came from you. | | MAIL FROM | MX | recommended | Routes bounce notifications for your custom envelope domain. | | MAIL FROM SPF | TXT | recommended | Authorises the sending infrastructure for that domain. | | DMARC | TXT | recommended | Tells receivers what to do with unauthenticated mail, and where to send reports. | DKIM is what unlocks sending. Without a verified custom MAIL FROM the domain shows as **partially verified**: it can send, but bounce handling and SPF alignment are weaker. We do not call that "verified", because it isn't. ## Automatic setup When you add a domain, Outstamp looks up who hosts its DNS. If the host supports [Domain Connect](https://www.domainconnect.org/), the domain page shows **Auto configure**: you sign in to your DNS host, it shows you the exact records, and you approve them there. Outstamp never sees your DNS host login, and the approval is one-time — it does not let Outstamp change your DNS later. Auto configure adds the DKIM and MAIL FROM records. It never adds or replaces a DMARC record, because two DMARC records cancel each other out, and it merges with any SPF record already at the MAIL FROM name. When you come back, Outstamp checks the records straight away. Manual setup is always available, and is the only option for hosts that do not support Domain Connect. `GET /v1/domains/{id}/dns-host` returns the detected host and whether Auto configure is available. ## Common DNS provider notes - **Cloudflare** — set DKIM CNAMEs to *DNS only*. A proxied record cannot be verified. We detect and tell you when this is the problem. - **Route 53, Google Domains, Namecheap** — these append your domain to the name automatically. Enter only the part before your domain. - **GoDaddy** — use `@` for the root and omit the trailing dot. - **TXT values** — paste the value exactly, including any quotes shown. ## Verification We check automatically and you can trigger a check yourself. Initial verification keeps trying for 72 hours. | Status | Meaning | |---|---| | `pending_dns` | Waiting for you to publish the records. | | `verifying` | We can see some records and are still checking. | | `partially_verified` | DKIM is verified. A recommended record is not. You can send. | | `verified` | Everything required and recommended is in place. | | `temporary_failure` | A DNS lookup did not complete. This is not the same as a removed record, and it gets a grace period. | | `failed` | Verification did not complete within 72 hours. You can retry without creating a duplicate. | Verified domains are re-checked periodically. If records disappear, we tell you and give you a grace window before live sending is blocked. ## Ownership conflicts A domain, and any overlapping parent or child of it, can only be active in one project. `mail.example.com` and `example.com` overlap; `mail.example.com` and `notify.example.com` do not, so two projects can own one each. If a domain is already claimed, you can start an ownership claim by publishing a unique TXT record. The existing holder keeps sending until the claim is verified and explicitly completed — there is no automatic takeover. ## Deleting a domain New sends stop immediately. Scheduled and queued messages that have not started dispatching are cancelled with a visible reason. Messages already handed to the provider cannot be recalled. Provider cleanup then runs in the background and its status stays visible until it completes. --- # Suppressions, bounces and complaints ## What suppression does A suppression stops your project sending to an address. It is checked **before** the message reaches the provider, so a suppressed recipient costs you no quota, no money and no reputation. The send still produces an email record and an `email.suppressed` event, so your system sees a definite outcome rather than silence. ## What gets suppressed automatically | Cause | Suppressed? | |---|---| | Hard bounce | Yes, immediately | | Complaint | Yes, immediately | | Transient bounce | Not at first; repeated failures eventually suppress | | Undetermined bounce | No | Suppressions are per project. Two projects have separate lists, and a bounce in one never suppresses the address for another. ## Managing the list ```bash curl -X POST https://api.outstamp.dev/v1/suppressions \ -H "Authorization: Bearer $OUTSTAMP_API_KEY" \ -d '{"address": "user@example.com", "reason": "unsubscribe"}' ``` Reasons: `hard_bounce`, `complaint`, `manual`, `unsubscribe`, `policy`, `repeated_transient_failure`. Removing an automatic hard-bounce or complaint suppression requires explicit confirmation, because it is usually a mistake: - a **hard bounce** means the address permanently rejected mail; sending again will bounce again and damage your reputation; - a **complaint** means someone marked your mail as spam; mailbox providers weigh repeat complaints heavily. ## Why the absence of complaints is not consent Not every mailbox provider reports complaints back to a sender. A low complaint rate is a useful signal, but it is not evidence that recipients want your mail. Send to people who asked to hear from you. ## Reputation We watch your hard bounce rate and complaint rate on a rolling window, and we warn you **before** the thresholds that put your sending at risk: | Signal | Warning | Intervention | |---|---:|---:| | Hard bounce rate | 3% | 5% | | Complaint rate | 0.05% | 0.1% | Rates are only meaningful above a minimum volume, so a single bounce on a small sample never triggers anything. If a threshold is crossed we reduce your limits, and if it keeps rising we pause sending, tell you exactly which metric and period caused it, and give you an appeal route. Other projects in your workspace are unaffected — that is what the per-project isolation is for. --- # Limits and errors ## Five different limits These are separate constraints, and a `429` always tells you which one applied. | Limit | Default | Set by | |---|---|---| | API request rate | 10 requests/second | Your plan | | Recipient send rate | 1–5 recipients/second | Your trust stage | | Rolling 24 hours | 100 → 2,000 recipients | Your trust stage | | Calendar month | Plan allowance | Your plan | | Outstanding scheduled | Plan allowance | Your plan | The effective limit is always the **lower** of your plan and your trust stage. A generous plan on a new project is still bound by the trust stage; a mature project on a small plan is still bound by the plan. ## Trust stages New senders start small and grow automatically. This protects you as much as us: a mistake at 100 messages a day is recoverable, the same mistake at 100,000 is not. | Stage | Daily | Rate | Reached by | |---|---:|---:|---| | Onboarding | verified member addresses only | 1/s | verifying your account | | New production | 100 | 1/s | verifying a domain | | Established | 500 | 2/s | a few days of healthy delivery | | Trusted | 2,000 | 5/s | sustained volume with low bounce and complaint rates | | Plan capacity | plan allowance | plan rate | payment verification or a review | ## Rate headers ``` Outstamp-RateLimit-Limit: 10 Outstamp-RateLimit-Remaining: 7 Outstamp-RateLimit-Reset: 1 Outstamp-Usage-Day-Used: 42 Outstamp-Usage-Day-Limit: 100 Outstamp-Usage-Month-Used: 1204 Outstamp-Usage-Month-Limit: 5000 Retry-After: 1 ``` ## Error shape ```json { "error": { "type": "validation_error", "code": "recipient_limit_exceeded", "message": "A single email may address at most 50 recipients across to, cc and bcc.", "parameter": "to", "request_id": "req_5m9r3t6v0w2y4a7c1e8g2j4l", "retryable": false, "docs_url": "https://docs.outstamp.dev/errors#recipient_limit_exceeded" } } ``` Always log `request_id`. It is the fastest route to an answer in support, and it links the request to the email record in your dashboard. ## Status codes | Code | Meaning | |---:|---| | 400 | Malformed request or an invalid idempotency key | | 401 | Missing or invalid credential | | 403 | Valid credential, but not permitted — includes a paused project and an unauthorised sender domain | | 404 | Not found in your scope | | 409 | Idempotency conflict, a lock, or a domain ownership conflict | | 413 | Body or upload too large | | 422 | Well-formed, but a field failed validation | | 429 | A rate or quota limit; the code says which | | 500 | An unexpected Outstamp error | | 503 | Temporary unavailability, including a project still being provisioned | ## Codes worth handling explicitly | Code | What to do | |---|---| | `daily_trust_limit_exceeded` | Retry later. Your limit rises automatically as your history builds. | | `monthly_plan_limit_exceeded` | Upgrade, or wait for the period to reset. | | `api_rate_limit_exceeded` | Back off using `Retry-After`. | | `domain_not_authorized` | Verify the sending domain in this project. | | `payment_method_required` | Add a payment method to send live on the free plan. | | `paid_plan_required` | The free allowance is one per person. Choose a paid plan for this account, or send from your free one. | | `project_paused` | Sending is stopped. `data.pause_source` says whether it was us, you, billing, or provider enforcement. | | `idempotency_key_reused` | You reused a key with a different body. Use a new key. | | `storage_unavailable` | Nothing was created. Retry safely. | We never return an internal hostname, a stack trace, a provider account id or a provider resource name in an error. --- # Data retention and privacy ## Defaults | Data | Kept for | You can change it to | |---|---:|---| | Delivery metadata and events | 30 days | — | | Recipient addresses and subject | 30 days | 0, 1, 7 or 30 days | | HTML and text bodies | 7 days | 0, 1, 7 or 30 days | | Attachments | until delivery, plus up to 24 hours | delete immediately after submission | | API request bodies | 7 days | can be disabled | | Webhook events and attempts | 30 days | — | | Audit and security events | at least 12 months | — | ## Turning off content storage is free Reducing or disabling message-content retention is a setting, not an add-on, and it costs nothing. Privacy is not a paid feature here. With content retention off, we keep encrypted bodies only long enough to dispatch the message, safely retry a failure that happened *before* submission, or honour a schedule. After that they are deleted, and an audit fact records that content expired. ## What expiry does Expiry removes the bytes **and** the searchable derivatives. Your delivery metadata and event timeline remain, so you can still answer "did this message reach them?" long after the content itself is gone. When you open an email whose content has expired, the dashboard says so with the date and the reason, rather than showing an empty panel that looks broken. ## Who can see content - Roles decide it inside your workspace. Analyst access to content is a setting you control. - Outstamp support needs elevated permission **and** a recorded reason for every access, and every access is audited. You can disable support access to your content entirely. - Diagnostic bundles never contain secrets, and contain personal content only if you explicitly include it. ## Commitments - Your email content is never used to train models and never used for advertising. - Our product analytics never collect message bodies, API keys, webhook secrets, attachment names or full recipient addresses. - Mail is delivered through Amazon Web Services, our subprocessor, from the EU region eu-north-1 (Stockholm). Each project shows its sending region in the dashboard. --- # Deliverability Outstamp handles authentication, isolation, suppression and monitoring. What lands in an inbox still depends on what you send and who you send it to. ## The things that actually matter 1. **Authenticate properly.** DKIM is required. Add the custom MAIL FROM records so SPF aligns, then publish DMARC. Unauthenticated mail is increasingly rejected outright by large providers. 2. **Use a dedicated sending subdomain.** `send.yourcompany.com` keeps transactional reputation separate from marketing and from your corporate mail. 3. **Send to people who expect it.** Complaint rate is the single strongest negative signal, and it is almost entirely determined by consent. 4. **Include a plain-text part.** HTML-only mail is treated as lower quality by several filters. 5. **Keep HTML small.** Above roughly 100 KB, Gmail clips the message. 6. **Make links match your domain.** Links pointing somewhere unrelated to the sending domain look like a phishing pattern, because they usually are one. 7. **Accept replies.** A `no-reply` sender removes one of the strongest positive engagement signals there is. 8. **Warm up gradually.** Our trust stages do this for you automatically. ## What we check for you The dashboard flags: a missing DMARC record, a missing plain-text part, an oversized HTML body, a no-reply sender, link domains that do not match your sending domain, and use of a shared tracking domain. ## On open and click tracking Tracking is off by default, deliberately. It rewrites your links and embeds a pixel, which some filters and many recipients treat as a negative signal, and the resulting numbers are unreliable: privacy features pre-load images and security scanners follow links. If you enable it, treat the numbers as directional and never as proof that a person read anything. ## What we will not claim We do not promise inbox placement. `delivered` means the recipient's mail server accepted the message. Anyone promising more than that is selling something they cannot control. --- # Migration ## From another email API The request shape is deliberately familiar, so most migrations are a base URL, a key and a header. ```diff - const response = await fetch("https://api.otherprovider.com/emails", { + const response = await fetch("https://api.outstamp.dev/v1/emails", { method: "POST", headers: { - Authorization: `Bearer ${OTHER_API_KEY}`, + Authorization: `Bearer ${OUTSTAMP_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ from, to, subject, html }) }); ``` Differences worth knowing before you switch: | Behaviour | What to expect | |---|---| | Acceptance | `202`, not `200`. Acceptance is not delivery. | | Multi-recipient | One delivery per envelope recipient, each with its own timeline. The aggregate is `partial` when they differ. | | Ambiguous provider responses | Surfaced as `submission_unknown` rather than reported as sent or silently retried. | | Suppression | Checked locally before submission, so a suppressed send costs no quota. | | Idempotency | `Idempotency-Key`, scoped to project + environment + endpoint, retained 24 hours. | | Webhooks | Signed over the delivery id, timestamp and raw body. Redirects are not followed. | ### Checklist 1. Add and verify your domain in Outstamp while the old provider still runs. 2. **Export your suppression list and import it** — `POST /v1/suppressions/batch`. Skipping this step is the most common cause of a bad first week. 3. Point a small share of traffic at Outstamp and watch the delivery rate. 4. Move the rest once the numbers match. 5. Keep the old provider's DNS records until you have fully cut over. ## From a sending provider you operate yourself If you send through a raw provider account today, Outstamp takes over the operational surface: | You manage today | With Outstamp | |---|---| | Identities, DKIM, custom MAIL FROM | Generated records with per-record status | | Event configuration and destinations | Managed for you | | Queues and consumers for events | A normalised event timeline and signed webhooks | | Account-level suppression | Per-project suppression, checked before submission | | Shared account reputation | A dedicated tenant per project | | Alarms you wrote | Reputation monitoring with warnings before the provider's thresholds | Your existing domain can move: add it in Outstamp, publish the new DKIM records alongside your current ones, verify, then switch your sending. Both sets of DKIM records can coexist during the transition. --- # Changelog and API policy ## Versioning The major version is in the path: `/v1`. Every response carries `Outstamp-Api-Version`. - **Additive changes** — new fields on a response, new optional request fields, new event types, new error codes — can happen within `v1`. Write clients that ignore fields they do not recognise. - **Breaking changes** require a new major version and at least 12 months of notice for generally available endpoints. Adding a new event type is additive. If you subscribed to specific types you will not receive it until you ask for it; if you deliberately chose "all current and future events", you will. ## Deprecation policy When something is deprecated we publish the replacement, the date, and the migration path at the same time. Deprecated endpoints keep working for the full notice period and start returning a `Deprecation` header. ## Changelog ### 2026-10-06 — attachments are delivered - **Fixed:** attachments sent to `POST /v1/emails` were accepted but never reached the recipient, with no error or event. They are now stored encrypted with the email and included in every delivery. Emails accepted before this fix did not keep their attachment content and cannot be resent with it. - An attachment that fails the malware check, or whose content is no longer available, now fails the delivery with `attachment_scan_failed` or `attachment_unavailable` and an `email.failed` event, instead of the message leaving without it. - Attachment `content` must be standard base64; `content_type` and `content_id` are validated. Each attachment may be at most 15 MB (previously 30 MB for uploaded assets), within the 40 MB message limit, which now also counts referenced assets. - An `asset_id` must belong to the sending project. - Non-ASCII attachment filenames are encoded correctly. ### 2026-09-06 — `v1` initial - `POST /v1/emails` with idempotency and per-recipient deliveries - Domains with DKIM, custom MAIL FROM and DMARC checks - Signed webhooks with retries, attempt history and replay - Project suppression lists, checked before submission - Usage, quotas and trust stages - Test mode with deterministic simulator recipients