---
title: "Try send-mail without authentication (simulation)"
method: POST
path: "/send-mail/demo"
tags: ["Sending"]
---

# Try send-mail without authentication (simulation)

`POST /send-mail/demo`

Unauthenticated sandbox for `POST /send-mail`. Accepts the same request body, validates it identically, and returns a realistic synthetic success envelope with `demo: true`. **No email is sent, queued, or stored.** Use this to exercise the send flow without credentials; to send real mail, sign up at https://primitive.dev/signup for an API key and call `POST /send-mail` with `Authorization: Bearer prim_<key>`.

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `from` string, required — RFC 5322 From header. The sender domain must be a verified outbound domain for your organization.
  - `to` string, required — Recipient address. Recipient eligibility depends on your account's outbound entitlements.
  - `subject` string, required — Subject line for the outbound message
  - `body_text` string — Plain-text message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes.
  - `body_html` string — HTML message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes.
  - `in_reply_to` string — Message-ID of the direct parent email when sending a threaded reply.
  - `references` string[] — Full ordered message-id chain for the thread.
  - `attachments` object[] — Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. Combined raw decoded attachment bytes must be at most 31457280.
    - `filename` string, required — Attachment filename. Control characters are rejected.
    - `content_type` string — Optional MIME content type. Control characters are rejected.
    - `content_base64` string, required — Base64-encoded attachment bytes.
  - `wait` boolean — When true, wait for the first downstream SMTP delivery outcome before returning.
  - `wait_timeout_ms` integer — Maximum time to wait for a delivery outcome when wait is true. Defaults to 30000.

## Response `200`

Simulated send accepted. Identical shape to a fresh asynchronous /send-mail response, plus a top-level `demo` flag. No mail was sent.

- object
  - `success` true, required
  - `demo` true, required
  - `note` string — Human-readable reminder that the response is simulated.
  - `data` object, required
    - `id` string, required — Persisted sent-email attempt ID.
    - `status` 'queued' | 'submitted_to_agent' | 'agent_failed' | 'gate_denied' | 'unknown' | 'delivered' | 'bounced' | 'deferred' | 'wait_timeout', required — Lifecycle status of a sent_emails row. Possible values: - `queued`: pre-call INSERT; the outbound agent has not yet replied. - `submitted_to_agent`: agent accepted; `queue_id` is set. - `agent_failed`: agent rejected; `error_code` and `error_message` carry the reason. - `gate_denied`: a recipient-scope gate denied the send; the agent was never called. The `gates` array carries the denial detail. /send-mail returns 403 in this case so callers see the denial synchronously; /sent-emails additionally records the row for historical lookup, which is when this status appears in a listing. - `unknown`: terminal indeterminate; the on-box log poller couldn't classify the receiver's response. - `delivered` / `bounced` / `deferred` / `wait_timeout`: terminal delivery outcomes (see DeliveryStatus).
    - `from` string, required — Bare from-address actually written on the wire. Echoed on every success branch so callers can confirm what went out, particularly useful for the /emails/{id}/reply path where `from` is server-derived from the inbound's recipient when the caller doesn't override. For sends where the caller passed a from-header that included a display name (e.g. `"Acme Support" <support@acme.test>`), this field is the parsed bare address (`support@acme.test`). The display name was sent on the wire intact; this field just makes the address easy to compare against allowlists.
    - `queue_id` string, nullable, required — Message identifier assigned by Primitive's OUTBOUND relay (the box that signs your mail and submits it to the receiving MTA). NOT the receiver's queue id. The receiver may also report its own queue id in `smtp_response_text` (e.g. `"250 2.0.0 Ok: queued as 99D111927CDA"` from a Postfix receiver). Those two ids refer to different mail systems and are NOT comparable. Treat `queue_id` as Primitive-internal and the receiver's id as remote-system-internal. Null on rows that never reached the relay (queued, gate_denied, agent_failed before signing).
    - `accepted` string[], required — Recipient addresses accepted by the relay.
    - `rejected` string[], required — Recipient addresses rejected by the relay.
    - `client_idempotency_key` string, required — Effective idempotency key used for this send.
    - `request_id` string, required — Server-issued request identifier for support and tracing.
    - `content_hash` string, required — Stable hash of the canonical send payload.
    - `delivery_status` 'delivered' | 'bounced' | 'deferred' | 'wait_timeout' — Narrower enum covering only the four terminal delivery outcomes returned to a synchronous `wait: true` send. On the SendMailResult shape, `delivery_status` is always equal to `status` whenever both are present (i.e. on terminal-state replays and live wait=true responses). The two fields exist so callers that want to type-narrow on "this is a delivery outcome" can pattern-match against the four-value enum without handling the broader SentEmailStatus value set (which also covers `queued`, `submitted_to_agent`, `agent_failed`, `gate_denied`, `unknown`). On async-mode and pre-terminal responses, `delivery_status` is absent and only `status` is populated. Use `status` if you want a single field that's always present.
    - `smtp_response_code` integer, nullable — SMTP response code from the first downstream delivery outcome when wait is true.
    - `smtp_response_text` string — SMTP response text from the first downstream delivery outcome when wait is true.
    - `idempotent_replay` boolean, required — True when the response replays a previously-recorded send keyed by `client_idempotency_key` (same key, same canonical payload). False on a fresh send and on gate-denied responses. Lets callers branch on cache state without diffing fields.

## Other responses

- `400` — Invalid request parameters
- `413` — Request body is too large.
- `415` — Content-Type must be application/json.
- `429` — Rate limit exceeded

---

[API](https://skmtc.dev/primitive/apis/primitive-api.md) · [All operations](https://skmtc.dev/primitive/apis/primitive-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/primitive/primitive-api/revisions/c2299d28022c/schema)
