# SpacePost documentation (full text) SpacePost is a Resend-compatible email API on AWS SES. Base URL https://api.spacepost.dev, auth `Authorization: Bearer sp_live_...`. Index: https://docs.spacepost.dev/llms.txt # Introduction URL: https://docs.spacepost.dev/getting-started/introduction/ SpacePost is an email API for developers, built on Amazon SES. It speaks the **Resend API**: the same paths, the same JSON request and response shapes, the same error format and the same webhook signing scheme. If your code works with Resend, it works with SpacePost after changing a base URL. You get the deliverability and price of SES, with the developer experience of a modern email API: a clean REST API, SDKs, a CLI, an MCP server, an SMTP relay for tools that cannot call an API, signed webhooks, scheduling, batch sending, audiences and broadcasts. ## What you can do - **Send transactional email** with one `POST /emails` call: HTML, text, attachments, inline images, tags, custom headers, scheduling, idempotency keys. - **Send up to 100 emails per request** with the batch endpoint. - **Receive events** (`email.delivered`, `email.bounced`, `email.opened`...) on signed webhooks. - **Send newsletters** to audiences with personalization and one-click unsubscribe. - **Relay SMTP** from Supabase Auth, WordPress, Laravel, Django, Rails or any app that speaks SMTP. - **Protect your reputation** with an automatic suppression list and automatic pausing when bounce or complaint rates get dangerous. ## How it works ```text your app ──HTTPS──▶ api.spacepost.dev ──▶ queue ──▶ Amazon SES ──▶ recipient ▲ │ └──── signed webhooks ◀── events (SNS/SQS) ◀──────┘ ``` 1. Your request is validated and counted against your quota, then stored and queued. The API answers with an `id` immediately. 2. A worker builds the MIME message, drops suppressed recipients and hands it to SES with your domain's DKIM identity. 3. SES reports `delivered`, `bounced`, `complained`, `opened` and `clicked`. SpacePost records them on the email, updates `last_event`, adds hard bounces and complaints to your suppression list and calls your webhooks. ## How it compares | | SpacePost | Resend | SendGrid | Raw AWS SES | |---|---|---|---|---| | API shape | Resend-compatible | Resend | Its own | AWS SDK / SMTP | | Free tier | 3,000 / month | 3,000 / month, 100 / day | 100 / day | Sandbox | | Entry paid plan | €9 for 50,000 | USD 20 for 50,000 | Varies | USD 0.10 per 1,000 | | Overage per 1,000 | €0.25 | USD 0.90 | Varies | USD 0.10 | | Dashboard, logs, webhooks | Yes | Yes | Yes | Build it yourself | | Suppression list | Automatic | Automatic | Automatic | Build it yourself | | SMTP relay | Yes | Yes | Yes | Yes | | Audiences and broadcasts | Yes | Yes | Yes | No | | Official Resend SDKs work | Yes (set the base URL) | Yes | No | No | SpacePost prices are in EUR and exclude VAT. Resend's and AWS's prices are their published USD prices (Resend's as of 23 October 2026), shown as published and not converted. > Note: Free: 3,000 emails per month, 3 domains. Pro: €9, 50,000 emails, 10 domains. Scale: €35, 200,000 emails, 1,000 domains. Prices per month, excluding VAT. See [Plans and quotas](/account/plans-and-quotas/). ## Concepts Everything belongs to a team: domains, API keys, emails, webhooks, audiences. Quotas and reputation are tracked per team. You send from addresses on a verified domain, such as `hi@acme.com`. Verification takes three DKIM CNAMEs. Keys start with `sp_live_`. `full_access` keys use every endpoint, `sending_access` keys can only send, optionally from one domain. Each email has a UUIDv7 `id` and a `last_event` such as `delivered`. The full timeline is in the dashboard and on webhooks. ## Next steps --- # Quickstart URL: https://docs.spacepost.dev/getting-started/quickstart/ This guide takes you from nothing to a real email sent from your own domain. 1. **Create an API key** Open the [dashboard](https://app.spacepost.dev), go to **API keys** and click **Create API key**. Choose `full_access` while you experiment. Copy the key right away: it starts with `sp_live_` and is shown only once. ```bash export SPACEPOST_API_KEY="sp_live_YOUR_API_KEY" ``` > Note: Keep keys on the server. Never ship them in browser or mobile code. Create a `sending_access` key per service so you can revoke one without touching the others. 2. **Send a test email** You can send immediately, without a domain, using the shared test sender `onboarding@test.spacepost.dev`. It can only deliver to **your own team members' email addresses** and to the [test addresses](/sending/testing/) such as `delivered@test.spacepost.dev`. **Node.js** ```bash npm install @spacepost/node ``` ```ts title="send.ts" import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data, error } = await spacepost.emails.send({ from: "Acme ", to: ["delivered@test.spacepost.dev"], subject: "Hello from SpacePost", html: "It works!", }); if (error) { console.error(error); // { statusCode, name, message } } else { console.log("Sent", data.id); } ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["delivered@test.spacepost.dev"], "subject": "Hello from SpacePost", "html": "It works!" }' ``` **Python** ```python import os import requests r = requests.post( "https://api.spacepost.dev/emails", headers={"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"}, json={ "from": "Acme ", "to": ["delivered@test.spacepost.dev"], "subject": "Hello from SpacePost", "html": "It works!", }, ) print(r.status_code, r.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { body := `{"from":"Acme ","to":["delivered@test.spacepost.dev"],"subject":"Hello from SpacePost","html":"It works!"}` req, _ := http.NewRequest("POST", "https://api.spacepost.dev/emails", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("SPACEPOST_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` The response is `{ "id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10" }`. Open the email in the dashboard to see its timeline: `sent`, then `delivered`. 3. **Add and verify your domain** To send to anyone, send from a domain you own. ```bash curl -X POST https://api.spacepost.dev/domains \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "acme.com" }' ``` The response lists the DNS `records` to create: three DKIM CNAMEs, plus an MX and a TXT record for the `send` subdomain (the custom return path). Add them at your DNS provider ([guides for Cloudflare, Route 53 and more](/domains/adding-and-verifying/#dns-provider-guides)), then ask SpacePost to check: ```bash curl -X POST https://api.spacepost.dev/domains/$DOMAIN_ID/verify \ -H "Authorization: Bearer $SPACEPOST_API_KEY" ``` Or let the CLI do the work and watch DNS live: ```bash npx spacepost domains doctor acme.com ``` Verification usually completes within minutes. The domain `status` turns `verified` once DKIM is confirmed. 4. **Send from your domain** ```ts await spacepost.emails.send({ from: "Acme ", to: ["you@example.com"], subject: "First real email", html: "

Sent from my own domain.

", }); ``` > Note: For the first 7 days a new account can send 100 emails per day. See [Account sandbox](/deliverability/sandbox/). ## What next --- # Migrating from Resend URL: https://docs.spacepost.dev/getting-started/migrating-from-resend/ SpacePost implements the Resend API: same paths, same field names, same response shapes and the same error format. Most apps migrate by changing a base URL and an API key. 1. **Create a SpacePost API key** in the [dashboard](https://app.spacepost.dev). SpacePost keys start with `sp_live_` (Resend keys start with `re_`). 2. **Add and verify your domain** in SpacePost. DNS records differ from Resend's, so add the new DKIM CNAMEs and the `send` MX/TXT records. You can keep Resend's records in place while you cut over, because DKIM selectors do not collide. See [Adding and verifying](/domains/adding-and-verifying/). Move your SPF/DMARC policy only if you changed the `send` subdomain. 3. **Switch the client.** Pick one of the options below. **Resend SDK, unchanged** Keep the official `resend` package. Set the base URL with an environment variable and use your SpacePost key: ```bash title=".env" RESEND_API_KEY=sp_live_YOUR_API_KEY RESEND_BASE_URL=https://api.spacepost.dev ``` ```ts import { Resend } from "resend"; const resend = new Resend(process.env.RESEND_API_KEY); await resend.emails.send({ from: "Acme ", to: "you@example.com", subject: "Hi", html: "

Hi

" }); ``` Or pass it in code: ```ts const resend = new Resend("sp_live_YOUR_API_KEY", { baseUrl: "https://api.spacepost.dev" }); ``` **@spacepost/node** Swap the import. The method names and `{ data, error }` return shape are the same. ```diff - import { Resend } from "resend"; - const resend = new Resend(process.env.RESEND_API_KEY); + import { SpacePost } from "@spacepost/node"; + const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); - await resend.emails.send({ ... }); + await spacepost.emails.send({ ... }); ``` **Raw HTTP / other languages** Change the host and the key. Paths and bodies are identical. ```diff - curl https://api.resend.com/emails -H "Authorization: Bearer re_..." + curl https://api.spacepost.dev/emails -H "Authorization: Bearer sp_live_..." ``` **SMTP** Change host, port and password, and use STARTTLS. The username `resend` is accepted as an alias for `spacepost`. ```text Host: smtp.spacepost.dev Port: 25976 (STARTTLS) User: spacepost Password: sp_live_YOUR_API_KEY ``` 4. **Recreate your webhooks.** Create each endpoint again in SpacePost (`POST /webhooks`) and store the new `signing_secret`. Verification code written for Resend or Svix keeps working because SpacePost sends the same Standard Webhooks headers (plus `svix-*` duplicates). See [Verifying signatures](/webhooks/verifying/). 5. **Move audiences and contacts.** Create audiences and import contacts through `POST /audiences/{id}/contacts`. Placeholders such as `{{{FIRST_NAME|fallback}}}` and `{{{RESEND_UNSUBSCRIBE_URL}}}` work unchanged in broadcasts. 6. **Send a test, then cut over.** Send to `delivered@test.spacepost.dev`, check the webhook arrives, then deploy. ## Parity table | Resend feature | SpacePost | Notes | |---|---|---| | `POST /emails` | Supported | `to`/`cc`/`bcc`/`reply_to` up to 50 each; camelCase aliases `replyTo`, `scheduledAt` accepted | | `POST /emails/batch` | Supported | Up to 100 emails; attachments and `scheduled_at` are allowed per email; all-or-nothing | | `GET /emails/{id}`, `GET /emails` | Supported | List omits bodies | | `PATCH /emails/{id}` | Supported | Reschedule scheduled emails | | `POST /emails/{id}/cancel` | Supported | Scheduled emails only | | Attachments (`content`, `path`) | Supported | `path` must be https | | Tags, custom headers | Supported | Same constraints on tag characters | | `Idempotency-Key` | Supported | 24 hours, `POST /emails` and `/emails/batch` | | Domains (create, get, list, verify, update, delete) | Supported | `open_tracking`, `click_tracking` | | API keys | Supported | `full_access`, `sending_access`, optional `domain_id` | | Webhooks, Standard Webhooks signing | Supported | Also `svix-*` headers | | Audiences, contacts, broadcasts | Supported | Contacts live under `/audiences/{id}/contacts` | | Suppressions API | Supported | Extra: list, add and remove entries | | Tenants (platform mode) | SpacePost only | Isolated reputation per customer; see [Platform mode](/platform/tenants/). The Resend SDK drops `tenant_id`; use `resend.post` or tenant-owned domains | | SMTP relay | Supported | `smtp.spacepost.dev:25976` with STARTTLS (no implicit TLS on 465 yet) | | Inbound email | Not yet | | | Templates editor | Not yet | Use React Email or your own HTML | ## Differences to know about > Note: SpacePost keys start with `sp_live_`. A `re_...` key is rejected with `403 invalid_api_key`. If you see that error after migrating, an old `RESEND_API_KEY` is still deployed somewhere. - **`last_event` values.** Resend's values (`queued`, `scheduled`, `sent`, `delivered`, `delivery_delayed`, `bounced`, `complained`, `opened`, `clicked`, `canceled`, `failed`) are all present. SpacePost adds `suppressed`, used when every recipient was on your suppression list. Status never moves backwards: an `opened` event arriving before `delivered` keeps the later state. - **Extra webhook event.** `email.suppressed` fires when a recipient is skipped because it is suppressed. Subscribe only to the events you handle. Payloads are otherwise identical to Resend's; `data.tenant_id` is added for tenant mail. - **Rate limit.** 10 requests per second per team (25 on Scale) versus 2 on Resend, with the same `ratelimit-*` headers. - **Pricing.** Pro is €9 for 50,000 emails with €0.25 per extra 1,000, billed in EUR excluding VAT (Resend Pro: USD 20 with USD 0.90 per extra 1,000, Resend's USD prices as of 23 October 2026). After the first 7 days every plan, including Free, has a daily cap that grows with your sending: the larger of 1,000 and twice your best day in the last 14 days, up to 50,000 per day. See [Plans and quotas](/account/plans-and-quotas/). - **What counts as an email.** Every recipient counts: one email to 3 recipients (to, cc and bcc together) counts 3 against your quota. - **Test sender.** `onboarding@test.spacepost.dev` replaces `onboarding@resend.dev`. Test inboxes live at `test.spacepost.dev` instead of `resend.dev`: `delivered@`, `bounced@`, `complained@`, `delayed@`, plus `softbounced@` (SpacePost only). - **New-account sandbox.** The first 7 days are limited to 100 emails per day. See [Verification and limits](/account/verification-and-limits/). - **Automatic pausing.** Teams with abnormal bounce or complaint rates are paused automatically. See [Automatic pausing](/deliverability/auto-pausing/). - **Base URL.** `https://api.spacepost.dev`, with paths at the root. A `/v1` prefix is also accepted (`https://api.spacepost.dev/v1/emails`). --- # Send an email URL: https://docs.spacepost.dev/sending/send-email/ `https://api.spacepost.dev/emails` Send one email to up to 50 `to` recipients, with optional `cc`, `bcc`, attachments, tags, headers and a schedule. The call returns as soon as the email is accepted and queued; delivery happens asynchronously and is reported by [webhooks](/webhooks/overview/). ## Example **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data, error } = await spacepost.emails.send({ from: "Acme ", to: ["user@example.com"], cc: ["manager@example.com"], bcc: ["archive@acme.com"], replyTo: "support@acme.com", subject: "Your invoice", html: "

Invoice attached.

", text: "Invoice attached.", headers: { "X-Entity-Ref-ID": "inv_1042" }, tags: [{ name: "category", value: "invoice" }], }); if (error) { console.error(error.statusCode, error.name, error.message); } else { console.log("Email id:", data.id); } ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["user@example.com"], "cc": ["manager@example.com"], "bcc": ["archive@acme.com"], "reply_to": "support@acme.com", "subject": "Your invoice", "html": "

Invoice attached.

", "text": "Invoice attached.", "headers": { "X-Entity-Ref-ID": "inv_1042" }, "tags": [{ "name": "category", "value": "invoice" }] }' ``` **Python** ```python import os, requests r = requests.post( "https://api.spacepost.dev/emails", headers={"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"}, json={ "from": "Acme ", "to": ["user@example.com"], "cc": ["manager@example.com"], "bcc": ["archive@acme.com"], "reply_to": "support@acme.com", "subject": "Your invoice", "html": "

Invoice attached.

", "text": "Invoice attached.", "headers": {"X-Entity-Ref-ID": "inv_1042"}, "tags": [{"name": "category", "value": "invoice"}], }, ) print(r.json()) ``` **Go** ```go payload := map[string]any{ "from": "Acme ", "to": []string{"user@example.com"}, "cc": []string{"manager@example.com"}, "reply_to": "support@acme.com", "subject": "Your invoice", "html": "

Invoice attached.

", "tags": []map[string]string{{"name": "category", "value": "invoice"}}, } b, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", "https://api.spacepost.dev/emails", bytes.NewReader(b)) req.Header.Set("Authorization", "Bearer "+os.Getenv("SPACEPOST_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) ``` **PHP** ```php $ch = curl_init('https://api.spacepost.dev/emails'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SPACEPOST_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'from' => 'Acme ', 'to' => ['user@example.com'], 'reply_to' => 'support@acme.com', 'subject' => 'Your invoice', 'html' => '

Invoice attached.

', 'tags' => [['name' => 'category', 'value' => 'invoice']], ]), ]); echo curl_exec($ch); ``` **Ruby** ```ruby require "net/http" require "json" uri = URI("https://api.spacepost.dev/emails") req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{ENV["SPACEPOST_API_KEY"]}", "Content-Type" => "application/json") req.body = { from: "Acme ", to: ["user@example.com"], reply_to: "support@acme.com", subject: "Your invoice", html: "

Invoice attached.

", tags: [{ name: "category", value: "invoice" }] }.to_json res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) } puts res.body ``` ```json title="Response (200)" { "id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10" } ``` ## Request body - `from` (string, required): Sender, as `email@domain.com` or `Name `. The domain (or a parent domain) must be verified. The only exception is `onboarding@test.spacepost.dev`, see Testing. - `to` (string | string[], required): Recipients. Up to 50 addresses. - `subject` (string, required): 1 to 998 characters. - `html` (string): HTML body. At least one of `html` or `text` is required. - `text` (string): Plain-text body. If you only send `html`, a text alternative is generated for you, which improves deliverability. - `cc` (string | string[]): Up to 50 addresses. - `bcc` (string | string[]): Up to 50 addresses. Never written into message headers. - `reply_to` (string | string[]): Up to 50 addresses. The camelCase alias `replyTo` is also accepted. - `scheduled_at` (string): ISO 8601 date or natural language such as `in 1 hour`, at most 30 days ahead. Alias `scheduledAt`. - `headers` (object): Custom headers as string keys and string values. - `attachments` (object[]): Up to 50 attachments, each with `filename` and exactly one of `content` or `path`. - `tags` (object[]): Up to 50 `{ name, value }` pairs. Names and values: 1 to 256 characters of `A-Z a-z 0-9 _ -`. - `tenant_id` (string): Platform mode: send on behalf of a tenant (our id or your `external_id`). Optional when the `from` domain belongs to a tenant. Alias `tenantId` in the Node SDK. See Platform mode. More: [domain verification](/domains/adding-and-verifying/), [Testing](/sending/testing/), [Scheduling](/sending/scheduling/), [Custom headers](/sending/headers/), [Attachments](/sending/attachments/), [Tags](/sending/tags/), [Platform mode](/platform/tenants/). Addresses in every field may use the `Name ` form. ### Limits at a glance | Limit | Value | |---|---| | Recipients per field (`to`, `cc`, `bcc`, `reply_to`) | 50 | | Subject length | 998 characters | | Attachments | 50 per email, `filename` up to 255 characters | | Tags | 50 per email, name and value 1 to 256 characters of `A-Z a-z 0-9 _ -` | | Total message size | 40 MB including attachments and encoding overhead | | Scheduling horizon | 30 days | | Emails per batch request | 100 ([Batch sending](/sending/batch/)) | > Note: Quota is counted per recipient: an email with 1 `to`, 1 `cc` and 2 `bcc` addresses counts as 4 emails. See [Plans and quotas](/account/plans-and-quotas/). ## Response Success returns `200` with the new email `id` (a UUIDv7). Use it with [`GET /emails/{id}`](/api-reference/) to read the email and its `last_event`. ## What can go wrong | Status | `name` | Meaning | |---|---|---| | 401 | `missing_api_key` | No `Authorization: Bearer` header. | | 403 | `invalid_api_key` | Key is unknown, revoked or not a SpacePost key (`sp_live_...`). | | 401 | `restricted_api_key` | A domain-restricted key was used with a different `from` domain. | | 403 | `invalid_from_address` | The `from` domain is not verified for your team. | | 403 | `validation_error` | Using `test.spacepost.dev` as sender with a recipient that is not a team member or a test address. | | 403 | `sending_paused` | Your team is [paused](/deliverability/auto-pausing/). | | 422 | `validation_error` | A field is missing or invalid. `message` names the field, for example `` `to`: Invalid email address ``. | | 429 | `daily_quota_exceeded` | Daily limit reached: the new-account [sandbox](/deliverability/sandbox/), your [trust-level cap](/account/verification-and-limits/) or a tenant's `daily_limit`. | | 429 | `monthly_quota_exceeded` | Free plan monthly limit reached. | | 429 | `rate_limit_exceeded` | More than your requests-per-second limit. See [Rate limits](/api/rate-limits/). | See the complete list in [Errors](/api/errors/). ## After the call The email moves through statuses, exposed as `last_event`: `queued` (or `scheduled`), `sent`, then `delivered`, `delivery_delayed`, `bounced`, `complained`, `opened` or `clicked`. If every recipient is on your [suppression list](/deliverability/bounces-and-complaints/), nothing is sent and the status becomes `suppressed`. --- # Batch sending URL: https://docs.spacepost.dev/sending/batch/ `https://api.spacepost.dev/emails/batch` The body is a JSON **array** of up to 100 objects, each with the same fields as [Send an email](/sending/send-email/). Batching saves round trips and counts as a single request against your [rate limit](/api/rate-limits/). **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data, error } = await spacepost.batch.send( [ { from: "Acme ", to: ["a@example.com"], subject: "Hi Ada", html: "

Hi Ada

" }, { from: "Acme ", to: ["b@example.com"], subject: "Hi Bob", html: "

Hi Bob

" }, ], { idempotencyKey: "weekly-digest-2026-w43" }, ); if (error) console.error(error); else console.log(data.data.map((e) => e.id)); // ids in input order ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails/batch \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: weekly-digest-2026-w43" \ -d '[ { "from": "Acme ", "to": ["a@example.com"], "subject": "Hi Ada", "html": "

Hi Ada

" }, { "from": "Acme ", "to": ["b@example.com"], "subject": "Hi Bob", "html": "

Hi Bob

" } ]' ``` **Python** ```python r = requests.post( "https://api.spacepost.dev/emails/batch", headers={"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"}, json=[ {"from": "Acme ", "to": ["a@example.com"], "subject": "Hi Ada", "html": "

Hi Ada

"}, {"from": "Acme ", "to": ["b@example.com"], "subject": "Hi Bob", "html": "

Hi Bob

"}, ], ) print(r.json()) # {"data": [{"id": "..."}, {"id": "..."}]} ``` ```json title="Response (200)" { "data": [ { "id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10" }, { "id": "0192e1c4-7a3c-7a02-8e10-6c0f9d7a2b31" } ] } ``` Ids are returned in the same order as the input array. ## Behavior - **All or nothing.** If any email in the array is invalid, the whole request fails with a `validation_error` and nothing is stored. The `message` says which email (`(email 1)`). - **Quota is checked once** for the total recipient count. If it would exceed your limit, the whole batch is rejected. - **Everything is allowed per email**: attachments, tags, custom headers, `scheduled_at` and [`tenant_id`](/platform/tenants/). A paused tenant or an over-cap tenant fails the whole batch. - **Idempotency keys** apply to the whole batch. Retry safely with the same key. - The batch size limit is **100 emails**; larger arrays return `422`. Chunk bigger lists. > Note: Use [broadcasts](/audiences/broadcasts/) to an audience. They handle personalization, unsubscribe links and suppression for you. ## Chunking helper ```ts import { SpacePost, type SendEmailOptions } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); export async function sendAll(emails: SendEmailOptions[], jobId: string) { for (let i = 0; i < emails.length; i += 100) { const chunk = emails.slice(i, i + 100); const { error } = await spacepost.batch.send(chunk, { idempotencyKey: `${jobId}-chunk-${i / 100}` }); if (error) throw new Error(`${error.name}: ${error.message}`); } } ``` --- # Scheduling URL: https://docs.spacepost.dev/sending/scheduling/ Add `scheduled_at` to any send request to deliver later. The email is accepted immediately, gets the status `scheduled` and an `email.scheduled` webhook fires. ## Formats | Format | Example | |---|---| | ISO 8601 | `2026-11-01T09:00:00Z`, `2026-11-01T09:00:00+01:00` | | Natural language | `in 30 seconds`, `in 10 minutes`, `in 2 hours`, `in 1 day`, `in 1 week` | Natural language is `in ` where unit is one of `second`, `sec`, `minute`, `min`, `hour`, `hr`, `day`, `week` (plural `s` optional). The text is case-insensitive. Other rules: - **Maximum 30 days** in the future. Beyond that the API returns `422 validation_error`. - A time in the past, or less than about a second ahead, is treated as **send now**. - ISO strings without a timezone offset are interpreted by the server's date parser; always include `Z` or an offset. - Both `scheduled_at` and `scheduledAt` are accepted. **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data, error } = await spacepost.emails.send({ from: "Acme ", to: "user@example.com", subject: "Your trial ends tomorrow", html: "

Reminder.

", scheduledAt: "in 1 day", // or an ISO date: "2026-11-01T09:00:00Z" }); if (error) console.error(error); else console.log("Scheduled", data.id); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["user@example.com"], "subject": "Your trial ends tomorrow", "html": "

Reminder.

", "scheduled_at": "2026-11-01T09:00:00Z" }' ``` **Python** ```python requests.post( "https://api.spacepost.dev/emails", headers={"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"}, json={ "from": "Acme ", "to": ["user@example.com"], "subject": "Your trial ends tomorrow", "html": "

Reminder.

", "scheduled_at": "in 1 day", }, ) ``` ## Reschedule Only emails with status `scheduled` can be changed. `PATCH /emails/{id}` replaces the time; a new job is queued and the old one becomes a no-op. **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { error } = await spacepost.emails.update({ id: "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", scheduledAt: "in 2 hours", }); if (error) console.error(error); ``` **cURL** ```bash curl -X PATCH https://api.spacepost.dev/emails/$EMAIL_ID \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-11-02T09:00:00Z" }' ``` ```json title="Response (200)" { "object": "email", "id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10" } ``` ## Cancel **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { error } = await spacepost.emails.cancel("0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10"); if (error) console.error(error); // 422 validation_error if it is no longer scheduled ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails/$EMAIL_ID/cancel -H "Authorization: Bearer $SPACEPOST_API_KEY" ``` The status becomes `canceled` and the email is never sent. Cancelling or rescheduling an email that is no longer `scheduled` returns `422 validation_error` ("Only scheduled emails can be canceled"). > Note: Recipients are counted against your quota when the email is accepted, not when it is sent. Cancelling does not currently refund quota. ## Over SMTP Set the `X-SpacePost-Scheduled-At` header to an ISO date or natural-language value. See [SMTP relay](/tools/smtp/). --- # Attachments URL: https://docs.spacepost.dev/sending/attachments/ `attachments` is an array of up to **50** objects. Each attachment needs a `filename` and **exactly one** of `content` or `path`. - `filename` (string, required): 1 to 255 characters. Shown to the recipient. - `content` (string): File bytes as base64. The SDK also accepts a `Buffer`/`Uint8Array` and encodes it for you. Use this or `path`. - `path` (string): A public https URL. SpacePost downloads it when the email is sent. Use this or `content`. - `content_type` (string): MIME type, such as `application/pdf`. Alias `contentType` in the SDK. - `content_id` (string): Makes the attachment inline, referenceable as `cid:` in your HTML. Alias `contentId` in the SDK. ## Limits - The whole message, including all attachments after base64 encoding, must be under **40 MB**. Base64 inflates files by about 33%, so keep the raw total under roughly 30 MB. - URL attachments (`path`) must use `https`, must resolve to a public address (private networks are refused), are fetched with a 10 second timeout and must not redirect. Fetch failures mark the email `failed`. - Attachment bytes are removed from storage once the email is sent; only metadata (name, type, size) is kept in the log. - Attachments don't cost extra on any standard plan. SpacePost is built for transactional and marketing email, not bulk file delivery: if your attachment volume is unusually high compared with your email volume, we may contact you about a custom plan (see the [Acceptable Use Policy](https://spacepost.dev/legal/acceptable-use)). ## Attach a file by content **Node.js** ```ts import { readFile } from "node:fs/promises"; import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data, error } = await spacepost.emails.send({ from: "Acme ", to: "user@example.com", subject: "Invoice 1042", text: "Your invoice is attached.", attachments: [{ filename: "invoice-1042.pdf", content: await readFile("invoice-1042.pdf"), contentType: "application/pdf" }], }); console.log(error ?? data); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d "{ \"from\": \"Acme \", \"to\": [\"user@example.com\"], \"subject\": \"Invoice 1042\", \"text\": \"Your invoice is attached.\", \"attachments\": [{ \"filename\": \"invoice-1042.pdf\", \"content_type\": \"application/pdf\", \"content\": \"$(base64 -w0 invoice-1042.pdf)\" }] }" ``` On macOS use `base64 -i invoice-1042.pdf` (no `-w0`). **Python** ```python import base64 with open("invoice-1042.pdf", "rb") as f: content = base64.b64encode(f.read()).decode() requests.post( "https://api.spacepost.dev/emails", headers={"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"}, json={ "from": "Acme ", "to": ["user@example.com"], "subject": "Invoice 1042", "text": "Your invoice is attached.", "attachments": [{"filename": "invoice-1042.pdf", "content_type": "application/pdf", "content": content}], }, ) ``` ## Attach a file by URL ```json { "from": "Acme ", "to": ["user@example.com"], "subject": "Your report", "text": "Report attached.", "attachments": [{ "filename": "report.pdf", "path": "https://files.acme.com/reports/2026-10.pdf" }] } ``` > Note: Giving both `content` and `path`, or neither, returns `422`: "Each attachment needs exactly one of `content` or `path`". ## Inline images Set a `content_id` and reference it with a `cid:` URL in your HTML. ```ts import { readFile } from "node:fs/promises"; import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); await spacepost.emails.send({ from: "Acme ", to: "user@example.com", subject: "Welcome", html: 'Acme

Welcome aboard.

', attachments: [{ filename: "logo.png", content: await readFile("logo.png"), contentType: "image/png", contentId: "logo" }], }); ``` Inline images are sent as part of the message and display without a network request, but many clients (Gmail on the web in particular) show them best when they are small. For large images host them on your own CDN and use a normal `https://` URL. --- # Tags URL: https://docs.spacepost.dev/sending/tags/ Tags are `{ name, value }` pairs attached to an email. They are stored with the email, returned by `GET /emails/{id}` and echoed in every [webhook payload](/webhooks/event-types/) for that email (as an object, `"tags": { "category": "welcome" }`). Use them to tie events back to your own records without storing the SpacePost `id`. **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); await spacepost.emails.send({ from: "Acme ", to: "user@example.com", subject: "Welcome", html: "

Hi

", tags: [ { name: "category", value: "welcome" }, { name: "user_id", value: "u_1042" }, ], }); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome", "html": "

Hi

", "tags": [{ "name": "category", "value": "welcome" }, { "name": "user_id", "value": "u_1042" }] }' ``` **SMTP** ```text X-SpacePost-Tags: category=welcome, user_id=u_1042 ``` ## Rules - Up to **50** tags per email. - `name` and `value` are each **1 to 256 characters** and may contain only ASCII letters, numbers, underscores and dashes (`A-Z a-z 0-9 _ -`). - Spaces, dots, `@` and non-ASCII characters are rejected with a `422`. Hash or slug values such as email addresses first, for example `user_1042` or a SHA-256 hex digest. > Note: Good tag names: `category`, `campaign`, `user_id`, `order_id`, `environment`. Do not put personal data in tags. --- # Custom headers URL: https://docs.spacepost.dev/sending/headers/ `headers` is an object of string keys to string values, written into the message as-is. **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); await spacepost.emails.send({ from: "Acme ", to: "user@example.com", subject: "Weekly digest", html: "

...

", headers: { "List-Unsubscribe": ", ", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", "X-Entity-Ref-ID": "digest-2026-w43-1042", }, }); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["user@example.com"], "subject": "Weekly digest", "html": "

...

", "headers": { "List-Unsubscribe": "", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" } }' ``` ## Common uses | Header | Purpose | |---|---| | `List-Unsubscribe` and `List-Unsubscribe-Post` | One-click unsubscribe (RFC 8058). Required by Gmail and Yahoo for bulk senders. Broadcasts add it automatically. | | `X-Entity-Ref-ID` | A unique value per email. Stops Gmail from collapsing similar messages into one thread or hiding the footer. | | `In-Reply-To`, `References` | Thread a message under an existing conversation. | | `X-*` | Any application-specific metadata. Over SMTP, `X-*` headers are forwarded, except SpacePost's own `X-SpacePost-*` control headers. | > Note: Do not set `From`, `To`, `Subject`, `Date`, `Message-ID` or `DKIM-Signature` through `headers`. Use the dedicated fields; SES manages the rest. --- # Idempotency keys URL: https://docs.spacepost.dev/sending/idempotency/ Networks fail. If a request times out you cannot tell whether the email was accepted. Send an `Idempotency-Key` header and retry freely: the same key with the same body returns the **original response** and sends the email **once**. **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data, error } = await spacepost.emails.send( { from: "Acme ", to: "user@example.com", subject: "Receipt", html: "

Thanks

" }, { idempotencyKey: "receipt-order-1042" }, ); console.log(error ?? data); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Idempotency-Key: receipt-order-1042" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["user@example.com"], "subject": "Receipt", "html": "

Thanks

" }' ``` **Python** ```python requests.post( "https://api.spacepost.dev/emails", headers={ "Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}", "Idempotency-Key": "receipt-order-1042", }, json={"from": "Acme ", "to": ["user@example.com"], "subject": "Receipt", "html": "

Thanks

"}, ) ``` ## Rules - Supported on `POST /emails` and `POST /emails/batch`. - The key is **1 to 256 characters**. - Keys are scoped to your team and remembered for **24 hours**. - Same key and same request body: the stored response is returned (same status, same body), nothing new is sent. - Same key and a **different** body: `409 invalid_idempotent_request`. - Same key while the first request is still running: `409 concurrent_idempotent_requests`. Wait a moment and retry. - A key outside 1 to 256 characters: `400 invalid_idempotency_key`. - Requests that fail are not stored, so you can fix the problem and reuse the key. "Same body" means the parsed JSON is identical. Changing any field, including `scheduled_at: "in 1 hour"` (which is evaluated at request time), counts as a different request. ## Choosing keys Derive the key from the business event, not from a random value generated at retry time: | Good | Bad | |---|---| | `receipt-order-1042` | `crypto.randomUUID()` on each attempt | | `welcome-user-u_1042` | `Date.now()` | | `digest-2026-w43-chunk-3` | The same constant for every email | > Note: A random UUID is fine if you generate it **once** per logical send (for example when you enqueue a job) and reuse it on every retry of that job. --- # Testing URL: https://docs.spacepost.dev/sending/testing/ Never test with made-up addresses at real domains: they bounce and hurt your sender reputation. SpacePost provides test inboxes at `test.spacepost.dev`. Mail to them is never delivered anywhere, but the full event pipeline runs, including webhooks. | Address | Simulates | Events you will see | |---|---|---| | `delivered@test.spacepost.dev` | Successful delivery | `email.sent`, `email.delivered` | | `bounced@test.spacepost.dev` | Hard bounce (permanent) | `email.sent`, `email.bounced` | | `complained@test.spacepost.dev` | Recipient marks as spam | `email.sent`, `email.delivered`, `email.complained` | | `delayed@test.spacepost.dev` | Temporary delivery problem | `email.sent`, `email.delivery_delayed` | | `softbounced@test.spacepost.dev` | Soft bounce (mailbox full); the third within 30 days suppresses the address | `email.sent`, `email.delivery_delayed` | ## Labels Add `+label` to run multiple scenarios in parallel and tell them apart: `delivered+signup@test.spacepost.dev`, `bounced+case-17@test.spacepost.dev`. The label is ignored when choosing the outcome. > Note: Simulated hard bounces and complaints behave like real ones: the address is added to your [suppression list](/deliverability/bounces-and-complaints/) and counts toward [automatic pausing](/deliverability/auto-pausing/) thresholds. Delete test entries from **Suppressions** in the dashboard, or with `DELETE /suppressions/{email}`, after running a bounce test. ## The test sender Before you verify a domain you can send from **`onboarding@test.spacepost.dev`**. It is restricted: - it can send **only to your own team members' email addresses** and to `@test.spacepost.dev` addresses; - any other recipient is rejected with `403 validation_error`; - once your domain is verified, send from your own address. **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); await spacepost.emails.send({ from: "Acme ", to: ["delivered@test.spacepost.dev", "bounced+case-1@test.spacepost.dev"], subject: "Test", html: "

Test

", }); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["delivered@test.spacepost.dev"], "subject": "Test", "html": "

Test

" }' ``` ## Testing your webhook handler 1. Create a webhook that points at a tunnel to your machine (for example `https://abc123.ngrok.app/webhooks/spacepost`), subscribed to the events you handle. 2. Send to `delivered@`, `bounced@`, `complained@` and `delayed@`. 3. Check that your handler verifies the signature and responds `2xx`. Test sends count toward your monthly quota like any other email. --- # Adding and verifying a domain URL: https://docs.spacepost.dev/domains/adding-and-verifying/ To send from `hello@acme.com` you prove you own `acme.com` by publishing DNS records. Verification sets up DKIM signing (so receivers know the mail is really yours) and a custom return path (so SPF and DMARC align with your domain). > Note: New teams complete a short **sending profile** first and some domains are refused (free mail providers, domains registered in the last 7 days). See [Account verification and sending limits](/account/verification-and-limits/). 1. **Add the domain** **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data: domain, error } = await spacepost.domains.create({ name: "acme.com" }); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(domain.id, domain.status); // "not_started" or "pending" for (const r of domain.records ?? []) { console.log(`${r.type.padEnd(5)} ${r.name} -> ${r.value}${r.priority ? ` (priority ${r.priority})` : ""}`); } ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/domains \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "acme.com" }' ``` **Python** ```python r = requests.post( "https://api.spacepost.dev/domains", headers={"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"}, json={"name": "acme.com"}, ) for rec in r.json()["records"]: print(rec["type"], rec["name"], rec["value"]) ``` You can also use the dashboard or `spacepost domains add acme.com`. 2. **Create the DNS records** The response contains a `records` array and a `recommended_records` array (DMARC): ```json title="Response (abridged)" { "object": "domain", "id": "0192e1c4-0000-7000-8000-000000000001", "name": "acme.com", "status": "not_started", "region": "eu-central-1", "open_tracking": false, "click_tracking": false, "records": [ { "record": "DKIM", "name": "a1b2c3d4e5f6._domainkey", "type": "CNAME", "ttl": "Auto", "status": "not_started", "value": "a1b2c3d4e5f6.dkim.amazonses.com" }, { "record": "DKIM", "name": "g7h8i9j0k1l2._domainkey", "type": "CNAME", "ttl": "Auto", "status": "not_started", "value": "g7h8i9j0k1l2.dkim.amazonses.com" }, { "record": "DKIM", "name": "m3n4o5p6q7r8._domainkey", "type": "CNAME", "ttl": "Auto", "status": "not_started", "value": "m3n4o5p6q7r8.dkim.amazonses.com" }, { "record": "SPF", "name": "send", "type": "MX", "ttl": "Auto", "status": "not_started", "value": "feedback-smtp.eu-central-1.amazonses.com", "priority": 10 }, { "record": "SPF", "name": "send", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "\"v=spf1 include:amazonses.com ~all\"" } ], "recommended_records": [ { "record": "DMARC", "name": "_dmarc", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "\"v=DMARC1; p=none;\"" } ] } ``` Add them at your DNS provider. See [the records explained](#the-records-explained) below and the [provider guides](#dns-provider-guides). 3. **Verify** DNS changes can take a few minutes (rarely hours). SpacePost rechecks pending domains automatically, and you can trigger a check at any time: **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const id = "0192e1c4-0000-7000-8000-000000000001"; const { error } = await spacepost.domains.verify(id); // asks SpacePost to re-check DNS now if (error) throw new Error(error.message); const { data: domain } = await spacepost.domains.get(id); console.log(domain?.status); // "verified" once DKIM is confirmed ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/domains/$DOMAIN_ID/verify -H "Authorization: Bearer $SPACEPOST_API_KEY" curl https://api.spacepost.dev/domains/$DOMAIN_ID -H "Authorization: Bearer $SPACEPOST_API_KEY" ``` **CLI** ```bash spacepost domains verify acme.com spacepost domains doctor acme.com # looks up each record live and tells you what is missing or wrong ``` ## The records explained Record names in the API are **relative** to your domain (`send`, `_dmarc`, `a1b2c3d4e5f6._domainkey`). Some DNS providers want the full name (`send.acme.com`); the [provider guides](#dns-provider-guides) say which. | Record | Type | Name | Value | Purpose | |---|---|---|---|---| | DKIM (x3) | CNAME | `._domainkey` | `.dkim.amazonses.com` | Publishes the public keys Amazon SES rotates for you ("Easy DKIM"). Receivers use them to verify that each message was signed by your domain and not altered. **Required**: verification depends on all three. | | Return path | MX | `send` | `feedback-smtp..amazonses.com`, priority `10` | Receives bounce and complaint notifications addressed to `send.acme.com`. Together with the TXT below it forms a custom MAIL FROM domain. | | Return path | TXT | `send` | `v=spf1 include:amazonses.com ~all` | SPF for the return-path subdomain. Because the envelope sender is on your domain, SPF aligns with your `From` address, which DMARC needs. | | DMARC (recommended) | TXT | `_dmarc` | `v=DMARC1; p=none;` | Tells receivers what to do with mail that fails authentication, and is required by Gmail and Yahoo for bulk senders. See [DMARC](/domains/dmarc/). | > Note: The API shows TXT values wrapped in quotes (`"v=spf1 ..."`). Most DNS dashboards add the quotes themselves; enter the value **without** them. > Note: The `send` subdomain can be renamed with `custom_return_path` when you add the domain. See [Custom return path](/domains/return-path/). Building a platform that sends for your own customers? Pass `tenant_id` (a tenant id or your `external_id`) to make the domain tenant-owned; mail from it is then attributed to that tenant automatically. See [Platform mode](/platform/tenants/). ## Statuses | Status | Meaning | |---|---| | `not_started` | Domain added, DNS not yet seen. | | `pending` | SpacePost is checking DNS. | | `verified` | DKIM is confirmed. You can send from this domain. | | `temporary_failure` | A lookup failed temporarily. It is retried automatically. | | `failed` | DNS was not found within 72 hours, or a record is wrong. Fix the records and call verify again. | The domain is `verified` as soon as DKIM passes. If only the return-path records are missing, mail still sends but SPF alignment falls back to Amazon's domain, which weakens DMARC alignment. Fix it for best deliverability. Each record in `records` has its own `status` with the same values, so you can see exactly which one is missing. ## Sending from subdomains If `acme.com` is verified you can send from any subdomain (`news@mail.acme.com`). Verifying the subdomain itself (`mail.acme.com`) is better practice for marketing mail because it keeps reputation separate from your transactional domain. ## Limits | Plan | Domains | |---|---| | Free | 3 | | Pro | 10 | | Scale | 1,000 | Adding a domain that another team already registered returns `422 validation_error` ("Domain already registered"). ## DNS provider guides Stuck? See [Troubleshooting](/domains/troubleshooting/). --- # DMARC URL: https://docs.spacepost.dev/domains/dmarc/ DMARC tells receiving servers what to do when a message claiming to be from your domain fails SPF and DKIM alignment, and where to send reports. Gmail and Yahoo require it from anyone sending in volume, and it protects your domain from spoofing. SpacePost returns a recommended starter record with every domain: | Type | Name | Value | |---|---|---| | TXT | `_dmarc` | `v=DMARC1; p=none;` | It is not required for verification, but add it. ## How alignment works with SpacePost - **DKIM**: messages are signed with `d=acme.com` via your three Easy DKIM CNAMEs. This aligns with `From: hello@acme.com`. - **SPF**: the envelope sender (return path) is `send.acme.com`, a subdomain of your From domain, so SPF aligns in relaxed mode. This is why the `send` MX and TXT records matter. Either one aligning is enough for DMARC to pass; having both is the most robust. ## Policies | Policy | Effect | When | |---|---|---| | `p=none` | Monitor only. Nothing is blocked. | Start here. | | `p=quarantine` | Failing mail goes to spam. | After reports are clean for a few weeks. | | `p=reject` | Failing mail is rejected. | Full protection. | A typical progression with reporting: ```text v=DMARC1; p=none; rua=mailto:dmarc@acme.com; fo=1; v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc@acme.com; v=DMARC1; p=reject; rua=mailto:dmarc@acme.com; ``` `rua` is where aggregate XML reports go. Use a DMARC reporting service to read them. > Note: Before moving to `quarantine` or `reject`, make sure **every** system that sends as your domain (your marketing tool, support desk, CRM) is aligned. Otherwise you will block your own mail. ## Subdomains A `_dmarc` record on `acme.com` also covers subdomains unless you add `sp=`. If you send from `mail.acme.com`, you can publish a separate `_dmarc.mail` record. --- # Open and click tracking URL: https://docs.spacepost.dev/domains/tracking/ Tracking is configured **per domain** and is **off by default**. | Setting | What it does | Webhook event | |---|---|---| | `open_tracking` | Inserts a 1x1 tracking pixel into the HTML body. | `email.opened` | | `click_tracking` | Rewrites links in the HTML body so clicks are recorded before redirecting. | `email.clicked` | **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { error } = await spacepost.domains.update({ id: "0192e1c4-0000-7000-8000-000000000001", openTracking: true, clickTracking: true, }); if (error) console.error(error); ``` **cURL** ```bash curl -X PATCH https://api.spacepost.dev/domains/$DOMAIN_ID \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "open_tracking": true, "click_tracking": true }' ``` The change applies to emails sent afterwards. Both fields are optional; send only the one you want to change. ## Considerations - **Opens are approximate.** Apple Mail Privacy Protection and some security scanners load images automatically, inflating opens. Gmail caches images. Treat opens as a rough signal, never as proof a person read the email. - **Clicks are more reliable** but security scanners can also pre-click links. - **Privacy.** Tracking records IP address and user agent in the event payload. Disclose it in your privacy policy, and consider leaving open tracking off for sensitive transactional mail (password resets, receipts). - **Plain-text parts are never tracked.** Tracking only affects HTML. - **Deliverability.** Link rewriting changes the URLs recipients see. Some spam filters dislike this on transactional mail; if you see a deliverability dip, turn click tracking off for that domain and use a separate subdomain for marketing mail. An `email.opened` or `email.clicked` event sets the email's `last_event`, but never moves it backwards from a more severe state such as `bounced`. --- # Custom return path URL: https://docs.spacepost.dev/domains/return-path/ The **return path** (also called MAIL FROM or envelope sender) is where bounces are sent. By default SpacePost uses the `send` subdomain: `send.acme.com`. It gets an MX record, so bounce notifications reach Amazon SES, and an SPF TXT record. Using your own subdomain instead of Amazon's default means SPF is evaluated against **your** domain, so it aligns with your `From` address for DMARC. ## Change the subdomain Set `custom_return_path` when you add the domain. It must be 1 to 63 characters of lowercase letters, digits and hyphens. **Node.js** ```ts await spacepost.domains.create({ name: "acme.com", customReturnPath: "bounces" }); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/domains \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "acme.com", "custom_return_path": "bounces" }' ``` The returned MX and TXT records then use the name `bounces` instead of `send`. > Note: The return path is fixed when the domain is created. To change it later, delete the domain and add it again, then update your DNS. ## Pick a name that is not in use The return-path subdomain must not already have other MX or TXT records you rely on (for example, a support tool using `send.acme.com`). Choose something unique such as `bounces` or `pk-send`. --- # Domain troubleshooting URL: https://docs.spacepost.dev/domains/troubleshooting/ Start with the CLI, which looks up every record live: ```bash spacepost domains doctor acme.com ``` Or compare by hand with `dig`: ```bash dig +short CNAME a1b2c3d4e5f6._domainkey.acme.com dig +short MX send.acme.com dig +short TXT send.acme.com dig +short TXT _dmarc.acme.com ``` ## The domain stays `pending` or `not_started` | Cause | Fix | |---|---| | Records not created yet, or DNS has not propagated | Wait 10 to 30 minutes, then call `POST /domains/{id}/verify`. TTLs of an hour or more can delay it. | | **Domain name doubled** (`a1b2._domainkey.acme.com.acme.com`) | Your DNS provider appends the domain automatically; enter only the relative name (`a1b2._domainkey`). Check with `dig`. | | **Name missing the domain** (`a1b2._domainkey` is unresolvable) | Your provider needs the full name (`a1b2._domainkey.acme.com`), common with Route 53. | | **Cloudflare proxy on** | CNAME records for DKIM must be **DNS only** (grey cloud), not proxied. | | Trailing dot or stray whitespace in the value | Re-paste the value exactly. | | Quotes around TXT value | Enter the value without quotes. | | Conflicting existing record on `send` | A CNAME on `send.acme.com` cannot coexist with MX/TXT. Use a different [custom return path](/domains/return-path/). | | Two SPF records on the same name | Merge them into one TXT value. Only one `v=spf1` record per name is allowed. | ## The domain turned `failed` SpacePost gives up on a domain whose DNS has not appeared after **72 hours**. Fix the records, then call verify again; the status returns to `pending` and then `verified`. ## `invalid_from_address` `403 invalid_from_address`: "The example.com domain is not verified." The domain in `from` must be a verified domain on **your team**, or a subdomain of one. Check for typos and for the wrong team's API key. ## `Domain already registered` A domain can belong to only one team. If you re-registered a domain after leaving an old team, delete it there first or contact support. ## Return path verified but DMARC fails Open a received message's headers (Gmail: Show original) and check `Authentication-Results`. You want `dkim=pass header.d=acme.com` and `spf=pass` with `smtp.mailfrom=send.acme.com`. If SPF shows `amazonses.com`, the return-path records are missing or not yet verified. ## Still stuck Email [support@spacepost.dev](mailto:support@spacepost.dev) with the domain name and the output of `spacepost domains doctor`. --- # Webhooks overview URL: https://docs.spacepost.dev/webhooks/overview/ Webhooks push email events to your server as they happen, so you do not have to poll `GET /emails/{id}`. SpacePost signs every request with the [Standard Webhooks](https://www.standardwebhooks.com) scheme (the one Svix and Resend use), so existing verification code works unchanged. 1. **Create an endpoint** that accepts `POST` with a JSON body and answers `2xx` quickly. 2. **Register it** **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data: webhook, error } = await spacepost.webhooks.create({ endpoint: "https://acme.com/webhooks/spacepost", events: ["email.delivered", "email.bounced", "email.complained"], }); if (error) throw new Error(`${error.name}: ${error.message}`); // Store this secret: you need it to verify signatures. It starts with "whsec_". console.log(webhook.id, webhook.signing_secret); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/webhooks \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "endpoint": "https://acme.com/webhooks/spacepost", "events": ["email.delivered", "email.bounced", "email.complained"] }' ``` **Python** ```python r = requests.post( "https://api.spacepost.dev/webhooks", headers={"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"}, json={"endpoint": "https://acme.com/webhooks/spacepost", "events": ["email.delivered", "email.bounced"]}, ) print(r.json()["signing_secret"]) ``` ```json title="Response" { "object": "webhook", "id": "0192e1c4-0000-7000-8000-0000000000aa", "endpoint": "https://acme.com/webhooks/spacepost", "events": ["email.delivered", "email.bounced", "email.complained"], "status": "enabled", "created_at": "2026-10-23T14:00:00.000Z", "signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" } ``` 3. **Verify the signature** on every request. See [Verifying signatures](/webhooks/verifying/). 4. **Return `2xx`.** Anything else (including redirects and timeouts) is retried. See [Retries](/webhooks/retries/). ## Request format Every delivery is a `POST` with `Content-Type: application/json` and these headers: | Header | Value | |---|---| | `webhook-id` | Unique id of this delivery. **The same id on every retry**, so use it to deduplicate. | | `webhook-timestamp` | Unix time in seconds when this attempt was sent. | | `webhook-signature` | `v1,`. A space-separated list if there are several signatures. | | `svix-id`, `svix-timestamp`, `svix-signature` | Identical duplicates of the three headers above, so Svix libraries work as is. | The body is `{ "type": "...", "created_at": "...", "data": { ... } }`. See [Event types](/webhooks/event-types/). ## Managing webhooks `POST /webhooks`, `GET /webhooks`, `GET /webhooks/{id}`, `PATCH /webhooks/{id}` (change `endpoint`, `events` or `status`: `enabled` or `disabled`) and `DELETE /webhooks/{id}`. The `signing_secret` is returned when you create or fetch a single webhook, not in lists. ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const id = "0192e1c4-0000-7000-8000-0000000000aa"; // Subscribe to more events and temporarily disable delivery. await spacepost.webhooks.update({ id, events: ["email.delivered", "email.bounced", "email.opened"], status: "disabled" }); const { data } = await spacepost.webhooks.list({ limit: 20 }); console.log(data?.data.map((w) => `${w.id} ${w.status} ${w.endpoint}`)); await spacepost.webhooks.remove(id); ``` ## Requirements for endpoints - `https://` in production (plain `http://` is accepted only in development). - The host must resolve to a **public** address. Localhost and private networks are rejected. For local development use a tunnel such as ngrok or Cloudflare Tunnel. - Respond within **15 seconds**. - Redirects are not followed. --- # Event types URL: https://docs.spacepost.dev/webhooks/event-types/ Subscribe to any combination of these event types when you create a webhook. | Event | Fires when | Sets `last_event` to | |---|---|---| | `email.scheduled` | An email with `scheduled_at` was accepted. | `scheduled` | | `email.sent` | The email was handed to Amazon SES. | `sent` | | `email.delivered` | The recipient's mail server accepted the email. | `delivered` | | `email.delivery_delayed` | Delivery is delayed or temporarily failing; SES keeps retrying. | `delivery_delayed` | | `email.bounced` | Permanent failure (the address does not exist, domain rejects). | `bounced` | | `email.complained` | The recipient marked the email as spam. | `complained` | | `email.opened` | The recipient opened the email (needs [open tracking](/domains/tracking/)). | `opened` | | `email.clicked` | The recipient clicked a link (needs [click tracking](/domains/tracking/)). | `clicked` | | `email.failed` | SpacePost or SES could not send the email (rejected, rendering failure, paused team or tenant). | `failed` | | `email.suppressed` | A recipient was skipped because it is on your [suppression list](/deliverability/bounces-and-complaints/). | `suppressed` | ## Payload shape Webhook bodies are exactly [Resend's](https://resend.com/docs/dashboard/webhooks/event-types), so handlers written for Resend keep working. ```json { "type": "email.delivered", "created_at": "2026-10-23T14:02:10.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z", "broadcast_id": "0192e1c4-0000-7000-8000-0000000000bb", "tenant_id": "0192e1c4-5555-7000-8000-0000000000cc", "tags": { "category": "welcome" } } } ``` - `type` is the event; `created_at` is when the event happened. - `data.email_id`, `from`, `to` (all recipients of the email, not only the affected one), `subject` and `created_at` (when the email was created) are always present. - `data.broadcast_id` is present only for [broadcast](/audiences/broadcasts/) emails. - `data.tenant_id` is present only when the email was sent for a [tenant](/platform/tenants/) (platform mode). It is our tenant id, not your `external_id`. - `data.tags` is present only if the email had tags, as an **object** (`{ "name": "value" }`). - Some events add one more object to `data`, named after the event: `bounce`, `click`, `open`, `failed` or `suppressed`. They are listed below. Nothing else is added: there are no top-level `bounceType`, `delayType` or `reason` keys. ## Examples ### email.scheduled ```json { "type": "email.scheduled", "created_at": "2026-10-23T14:02:09.700Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Your trial ends tomorrow", "created_at": "2026-10-23T14:02:09.512Z" } } ``` ### email.sent ```json { "type": "email.sent", "created_at": "2026-10-23T14:02:10.100Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z" } } ``` ### email.delivered ```json { "type": "email.delivered", "created_at": "2026-10-23T14:02:11.350Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z" } } ``` ### email.delivery_delayed Fired for SES delivery delays and for transient bounces. There are no extra fields for a plain delay. A transient bounce also carries the `bounce` object (see below, with `type: "Transient"`). ```json { "type": "email.delivery_delayed", "created_at": "2026-10-23T14:07:00.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z" } } ``` ### email.bounced Only permanent bounces reach this event, and they add the address to your suppression list. `data.bounce` has `type` (`Permanent`), `subType` and the receiving server's `message`. ```json { "type": "email.bounced", "created_at": "2026-10-23T14:02:12.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["nobody@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z", "bounce": { "type": "Permanent", "subType": "General", "message": "smtp; 550 5.1.1 The email account that you tried to reach does not exist." } } } ``` Bounce sub types include `General`, `NoEmail`, `Suppressed`, `OnAccountSuppressionList`, `MailboxFull`, `MessageTooLarge`, `ContentRejected` and `AttachmentRejected`. ### email.complained No extra fields. The address is added to your suppression list. ```json { "type": "email.complained", "created_at": "2026-10-23T15:30:00.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Our monthly newsletter", "created_at": "2026-10-23T14:02:09.512Z" } } ``` ### email.opened ```json { "type": "email.opened", "created_at": "2026-10-23T14:05:44.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z", "open": { "ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0 (Macintosh)" } } } ``` ### email.clicked ```json { "type": "email.clicked", "created_at": "2026-10-23T14:06:10.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z", "click": { "link": "https://acme.com/get-started", "ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0 (Macintosh)", "timestamp": "2026-10-23T14:06:10.000Z" } } } ``` ### email.failed ```json { "type": "email.failed", "created_at": "2026-10-23T14:02:10.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z", "failed": { "reason": "Attachment https://files.acme.com/report.pdf returned HTTP 404" } } } ``` ### email.suppressed `suppressed.reason` is `suppression_list` (the recipient is on your [suppression list](/deliverability/bounces-and-complaints/)) or `recipient_domain_does_not_accept_mail` (the recipient's domain has no mail server). ```json { "type": "email.suppressed", "created_at": "2026-10-23T14:02:10.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme ", "to": ["user@example.com", "other@example.com"], "subject": "Welcome to Acme", "created_at": "2026-10-23T14:02:09.512Z", "suppressed": { "recipients": ["other@example.com"], "reason": "suppression_list" } } } ``` > Note: `data.to` always lists **all** recipients of the email. For multi-recipient emails, use the delivery events together with your own records to work out who was affected. `suppressed.recipients` names the skipped addresses. ## Typing events in TypeScript The Node SDK exports a `WebhookPayload` union discriminated by `type`, and `spacepost.webhooks.verify()` returns it, so narrowing on `event.type` gives you the right `data` fields: ```ts function handle(event: WebhookPayload) { switch (event.type) { case "email.bounced": return event.data.bounce?.subType; case "email.clicked": return event.data.click?.link; case "email.suppressed": return event.data.suppressed.recipients; case "email.failed": return event.data.failed.reason; default: return event.data.tenant_id; // present when the email was sent for a tenant } } ``` --- # Verifying signatures URL: https://docs.spacepost.dev/webhooks/verifying/ Anyone who learns your endpoint URL can post to it. Always verify the signature before trusting a payload. ## How signing works Each delivery carries: ```text webhook-id: 0192e1c4-0000-7000-8000-0000000000cc webhook-timestamp: 1761228131 webhook-signature: v1,K5oZfzN0mYI+Hx3Yb0v8j3H5kq2tqZ2dA8s0r6sF9Vw= ``` The signature is the base64 **HMAC-SHA256** of `"{webhook-id}.{webhook-timestamp}.{raw body}"`, keyed with the secret's bytes: take your `signing_secret`, drop the `whsec_` prefix and base64-decode the rest. Reject requests whose timestamp is more than 5 minutes from your clock to stop replays. > Note: Verify against the **raw request body** exactly as received. If a framework parses and re-serializes the JSON first, the signature will not match. **Node.js** The SpacePost SDK verifies with WebCrypto and returns the parsed event. It throws if the signature or timestamp is invalid. ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); /** Works as a Bun, Deno, Cloudflare Workers or Next.js route handler. */ export async function POST(request: Request): Promise { const payload = await request.text(); // the RAW body: do not parse it first try { const event = await spacepost.webhooks.verify({ payload, headers: request.headers, secret: process.env.SPACEPOST_WEBHOOK_SECRET!, }); switch (event.type) { case "email.bounced": case "email.complained": console.warn("Stop mailing", event.data.to); break; case "email.delivered": console.log("Delivered", event.data.email_id); break; } return new Response("ok"); } catch { return new Response("invalid signature", { status: 400 }); } } ``` Headers can be a `Headers` object or a plain object (for example Express' `req.headers`). With Express, mount `express.raw({ type: "application/json" })` on the webhook route so `req.body` is a Buffer. **With the `standardwebhooks` package:** ```ts import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.SPACEPOST_WEBHOOK_SECRET!); // "whsec_..." is accepted const event = wh.verify(rawBody, { "webhook-id": req.headers["webhook-id"] as string, "webhook-timestamp": req.headers["webhook-timestamp"] as string, "webhook-signature": req.headers["webhook-signature"] as string, }); ``` **With `svix`** (or the `resend` package's verifier): use the `svix-*` headers, which SpacePost also sends. ```ts import { Webhook } from "svix"; const wh = new Webhook(process.env.SPACEPOST_WEBHOOK_SECRET!); const event = wh.verify(rawBody, { "svix-id": req.headers["svix-id"] as string, "svix-timestamp": req.headers["svix-timestamp"] as string, "svix-signature": req.headers["svix-signature"] as string, }); ``` **Python** ```bash pip install standardwebhooks ``` ```python import os from flask import Flask, request from standardwebhooks.webhooks import Webhook, WebhookVerificationError app = Flask(__name__) wh = Webhook(os.environ["SPACEPOST_WEBHOOK_SECRET"]) # "whsec_..." is accepted @app.post("/webhooks/spacepost") def spacepost_webhook(): try: event = wh.verify(request.get_data(as_text=True), dict(request.headers)) except WebhookVerificationError: return "invalid signature", 400 print(event["type"], event["data"]["email_id"]) return "", 200 ``` Without a library: ```python import base64, hashlib, hmac, time def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool: msg_id = headers["webhook-id"] ts = headers["webhook-timestamp"] if abs(time.time() - int(ts)) > tolerance: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{ts}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() return any(hmac.compare_digest(s.split(",", 1)[1], expected) for s in headers["webhook-signature"].split() if s.startswith("v1,")) ``` **Go** ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "io" "math" "net/http" "os" "strconv" "strings" "time" ) func verify(secret string, h http.Header, body []byte) bool { id := h.Get("webhook-id") ts := h.Get("webhook-timestamp") n, err := strconv.ParseInt(ts, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, s := range strings.Fields(h.Get("webhook-signature")) { if v, ok := strings.CutPrefix(s, "v1,"); ok && hmac.Equal([]byte(v), []byte(expected)) { return true } } return false } func main() { http.HandleFunc("/webhooks/spacepost", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if !verify(os.Getenv("SPACEPOST_WEBHOOK_SECRET"), r.Header, body) { http.Error(w, "invalid signature", http.StatusBadRequest) return } fmt.Println("event:", string(body)) w.WriteHeader(http.StatusOK) }) http.ListenAndServe(":8080", nil) } ``` Or use the official library: `go get github.com/standard-webhooks/standard-webhooks/libraries/go` and call `wh, _ := standardwebhooks.NewWebhook(secret); err := wh.Verify(body, r.Header)`. **PHP** ```php $tolerance) { return false; } $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true)); foreach (explode(' ', $headers['webhook-signature'] ?? '') as $sig) { if (str_starts_with($sig, 'v1,') && hash_equals($expected, substr($sig, 3))) { return true; } } return false; } $body = file_get_contents('php://input'); if (!verify_spacepost_webhook(getenv('SPACEPOST_WEBHOOK_SECRET'), getallheaders(), $body)) { http_response_code(400); exit('invalid signature'); } $event = json_decode($body, true); error_log($event['type'] . ' ' . $event['data']['email_id']); http_response_code(200); ``` ## Using the Resend SDK's verifier The official `resend` package's webhook verification takes the same Standard Webhooks headers, so it works with your SpacePost `signing_secret`. ## Rotating secrets The `signing_secret` is fixed per webhook. To rotate, create a new webhook with the same endpoint, switch your handler to accept the new secret (try both during the changeover), then delete the old webhook. ## Testing locally Send an email to `delivered@test.spacepost.dev` with a tunnel URL registered as your endpoint. See [Testing](/sending/testing/). --- # Retries URL: https://docs.spacepost.dev/webhooks/retries/ A delivery succeeds when your endpoint answers with a `2xx` status within **15 seconds**. Anything else (a non-2xx status, a redirect, a timeout, a connection error, a TLS error) is a failure and is retried. ## Schedule SpacePost makes up to **8 attempts**: the first immediately, then retries with increasing delays after each failure. | Attempt | Delay after the previous failure | Roughly after the event | |---|---|---| | 1 | immediate | 0 | | 2 | 5 seconds | 5 s | | 3 | 5 minutes | 5 min | | 4 | 30 minutes | 35 min | | 5 | 2 hours | 2 h 35 min | | 6 | 5 hours | 7 h 35 min | | 7 | 10 hours | 17 h 35 min | | 8 | 10 hours | 27 h 35 min | After the 8th failed attempt the delivery is marked **failed** and no further automatic attempts are made. You can [replay](/webhooks/replay/) it manually. ## Things to know - **The `webhook-id` is the same on every attempt**, and the body is identical. The `webhook-timestamp` and signature are regenerated for each attempt. - **Delivery is at least once.** A slow `2xx` that arrives after the timeout is treated as a failure, so your endpoint may see the same event twice. Deduplicate on `webhook-id`. - **Ordering is not guaranteed.** A retried `email.delivered` can arrive after a later `email.opened`. Use `created_at` in the payload and never move an email's state backwards. - **Disabled or deleted webhooks** stop receiving deliveries; pending deliveries for them are marked failed. - Response bodies (first 1 KB) and status codes of recent attempts are visible in the dashboard delivery log, which keeps 30 days of history. > Note: Return `200` as soon as you have verified the signature and stored the event. Do slow work in a background job. A handler that takes more than 15 seconds will be retried and cause duplicate work. --- # Webhook best practices URL: https://docs.spacepost.dev/webhooks/best-practices/ ## Respond fast, process later Verify the signature, enqueue the event (database row, queue, background job) and return `2xx` immediately. SpacePost waits at most 15 seconds, and a slow handler turns into retries and duplicates. ## Be idempotent Delivery is at least once. Store the `webhook-id` of each processed delivery (a unique constraint is enough) and skip repeats. ```ts const inserted = await db.insert(processedWebhooks).values({ id: webhookId }).onConflictDoNothing().returning(); if (inserted.length === 0) return new Response("duplicate", { status: 200 }); ``` ## Always verify signatures Use the raw body, reject timestamps older than 5 minutes, compare signatures in constant time (the SDK and libraries do). See [Verifying signatures](/webhooks/verifying/). ## Do not trust order Events can arrive out of order, especially after retries. Compare the payload's `created_at` with what you have stored, and only apply changes that move state forward. SpacePost's own `last_event` follows the same rule. ## Act on the events that matter | Event | Typical action | |---|---| | `email.bounced` | Mark the address invalid in your database; stop sending to it. | | `email.complained` | Unsubscribe the user from non-essential mail immediately. | | `email.delivery_delayed` | Usually nothing; alert if it is a time-critical email (password reset). | | `email.failed` | Log `data.failed.reason`, alert, possibly retry with a fix. | | `email.suppressed` | A recipient was on your suppression list; show it in your admin UI. | Subscribe only to the events you handle. Fewer events means less traffic and fewer failures. ## Secure the endpoint - Serve over HTTPS with a valid certificate. - Do not log secrets or full payloads containing personal data. - Use a dedicated path with no other behavior and return a generic body. - Keep the `signing_secret` in a secret manager, not in source control. ## Monitor Watch the dashboard delivery log or alert on repeated failures: if your endpoint is down for more than about 27 hours all retries are used up, after which you must [replay](/webhooks/replay/) manually. --- # Platform mode (tenants) URL: https://docs.spacepost.dev/platform/tenants/ Platform mode lets one SpacePost account send for many of **your customers**. Each customer is a **tenant**. Use it when you build a product that sends email for other businesses, for example an invoicing or support SaaS where every customer connects their own domain and one bad actor must not hurt everybody else. If you only send your own product's email, you do not need tenants. ## What a tenant gives you | | | |---|---| | **Isolated reputation** | Every tenant gets its own Amazon SES tenant. Complaints and bounces from one customer show up on that customer only. | | **Its own reputation guard** | The same [automatic pausing](/deliverability/auto-pausing/) thresholds (over 5% bounces or 0.2% complaints across the tenant's last 1,000 emails, with minimum sample sizes) pause **only that tenant**. | | **Your account stays clean** | Tenant mail does not count toward your own team's reputation check, so one bad customer cannot pause your account or your other customers. | | **Pause switch** | Pause and resume any tenant yourself, with a reason. | | **Daily cap** | Optional `daily_limit` in recipients per UTC day, per tenant. | | **Your IDs** | Address a tenant by your own `external_id` instead of storing ours. | | **Attribution** | Emails, domains and [webhook events](/webhooks/event-types/) carry `tenant_id`. | Tenants share your plan: every tenant recipient counts toward your [monthly quota](/account/plans-and-quotas/), your team's daily limits and your API rate limit. Content filtering applies to tenant mail too, including tenants of an account that is otherwise exempt from it. ## Create a tenant `POST /tenants` takes a `name` and optionally an `external_id` (unique within your account) and a `daily_limit`. All tenant endpoints need a `full_access` API key. **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); // One tenant per customer. `externalId` is your own customer id and works anywhere a tenant id does. const { data: tenant, error } = await spacepost.tenants.create({ name: "Acme Inc", externalId: "cust_123", dailyLimit: 500, // optional cap, in recipients per UTC day }); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(tenant.id, tenant.status); // "active" // Optional: give the tenant its own sending domain. Mail from it is attributed to the tenant automatically. const { data: domain } = await spacepost.domains.create({ name: "mail.acme-inc.com", tenantId: "cust_123" }); console.log(domain?.tenant_id === tenant.id); // true ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/tenants \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Inc", "external_id": "cust_123", "daily_limit": 500 }' ``` **CLI** ```bash spacepost tenants create --name "Acme Inc" --external-id cust_123 --daily-limit 500 ``` ```json title="Response" { "object": "tenant", "id": "0192e1c4-5555-7000-8000-0000000000cc", "name": "Acme Inc", "external_id": "cust_123", "daily_limit": 500, "status": "active", "paused_reason": null, "created_at": "2026-10-23T14:02:09.512Z" } ``` ### `external_id` Everywhere a tenant is addressed (`/tenants/:ref`, `tenant_id` on emails and domains, `spacepost tenants get `) you can pass either our `id` or your `external_id`. Store your own customer id once and you never need to keep a mapping table. Webhooks always carry **our** tenant id; look it up with `GET /tenants/{id}` if you need the `external_id`. ## Domains: tenant-owned or team-level | | Tenant-owned domain | Team-level domain | |---|---|---| | Created with | `tenant_id` on `POST /domains` | `POST /domains` without `tenant_id` | | Typical owner | The customer's own domain (`mail.acme-inc.com`) | Your domain (`yourapp.com`) | | Attribution | Automatic: every email from it belongs to the tenant | You pass `tenant_id` on each email, or the email is your own | | Who may send from it | Only that tenant | Your own mail, and any tenant | If an email names a `tenant_id` that differs from its domain's tenant, the request fails with `422 validation_error`. A `tenant_id` is optional when the `from` domain already belongs to a tenant. Add a tenant-owned domain exactly like any domain, with one extra field, then add the DNS records at the customer's DNS provider. See [Adding and verifying](/domains/adding-and-verifying/). **cURL** ```bash curl -X POST https://api.spacepost.dev/domains \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "mail.acme-inc.com", "tenant_id": "cust_123" }' ``` **CLI** ```bash spacepost domains add mail.acme-inc.com --tenant cust_123 ``` ## Send for a tenant **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); // From a team-level domain (yours), name the tenant on each email. const { data, error } = await spacepost.emails.send({ from: "Acme Inc via YourApp ", to: "buyer@example.com", subject: "Invoice #1042", html: "

Your invoice is attached.

", tenantId: "cust_123", // our tenant id or your external_id }); if (error?.name === "sending_paused") { console.warn("This customer is paused:", error.message); } else if (error?.name === "daily_quota_exceeded") { console.warn("This customer hit its daily cap; retry tomorrow."); } else if (error) { throw new Error(`${error.name}: ${error.message}`); } else { console.log("Queued", data.id); } // batch.send accepts tenantId per email. await spacepost.batch.send([ { from: "invoices@yourapp.com", to: "a@example.com", subject: "Hello A", text: "Hi A", tenantId: "cust_123" }, { from: "invoices@yourapp.com", to: "b@example.com", subject: "Hello B", text: "Hi B", tenantId: "cust_456" }, ]); ``` **cURL** ```bash curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Inc via YourApp ", "to": ["buyer@example.com"], "subject": "Invoice #1042", "html": "

Your invoice is attached.

", "tenant_id": "cust_123" }' ``` `POST /emails/batch` accepts `tenant_id` on each item. **Resend SDK** The official `resend` package works against SpacePost unchanged. Point it at us with `RESEND_BASE_URL=https://api.spacepost.dev` or `new Resend(key, { baseUrl })`. **Easiest: send from a tenant-owned domain.** The tenant is inferred from the `from` domain, so your existing `resend.emails.send` calls need no change at all. **From a team-level domain, use `resend.post`.** The Resend SDK builds the request body from a fixed list of fields, so an extra `tenant_id` passed to `resend.emails.send`, `resend.batch.send` or `resend.domains.create` is silently dropped before it reaches SpacePost. `resend.post` sends the body you give it as is, so use it with snake_case fields: ```ts import { Resend } from "resend"; const resend = new Resend(process.env.SPACEPOST_API_KEY, { baseUrl: "https://api.spacepost.dev" }); const { data, error } = await resend.post<{ id: string }>("/emails", { from: "Acme Inc via YourApp ", to: ["buyer@example.com"], subject: "Invoice #1042", html: "

Your invoice is attached.

", tenant_id: "cust_123", }); ``` Unlike `resend.emails.send`, `resend.post` does not convert camelCase: write `reply_to` and `scheduled_at`. The same works for `resend.post("/domains", { name, tenant_id })` and `resend.post("/emails/batch", [...])`. > Note: Type definitions of the Resend SDK do not know `tenant_id`, so passing it to `emails.send` also needs a cast (`as never`) in TypeScript, and it would still be dropped at runtime. Prefer `resend.post` or tenant-owned domains. `@spacepost/node` supports `tenantId` natively. **CLI** ```bash spacepost send --from invoices@yourapp.com --to buyer@example.com \ --subject "Invoice #1042" --text "Your invoice is attached." --tenant cust_123 ``` ## Pause, resume and caps **Node.js** ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data: page } = await spacepost.tenants.list({ limit: 20 }); for (const t of page?.data ?? []) console.log(t.name, t.external_id, t.status, t.paused_reason); await spacepost.tenants.get("cust_123"); // by external_id or our id await spacepost.tenants.update({ id: "cust_123", dailyLimit: 1000 }); // dailyLimit: null removes the cap await spacepost.tenants.pause({ id: "cust_123", reason: "Investigating spam reports" }); const { error } = await spacepost.tenants.unpause("cust_123"); if (error?.statusCode === 403) console.warn("Paused by the reputation guard; contact support to review it."); // Domains first, then the tenant (422 while it still has domains). const { data: domains } = await spacepost.domains.list({ limit: 100 }); for (const d of domains?.data ?? []) if (d.tenant_id) console.log(d.name, "belongs to", d.tenant_id); await spacepost.tenants.remove("cust_123"); ``` **cURL** ```bash # Pause (the body is optional) curl -X POST https://api.spacepost.dev/tenants/cust_123/pause \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "reason": "Investigating spam reports" }' # Resume curl -X POST https://api.spacepost.dev/tenants/cust_123/unpause -H "Authorization: Bearer $SPACEPOST_API_KEY" # Change the cap (null removes it) curl -X PATCH https://api.spacepost.dev/tenants/cust_123 \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "daily_limit": 1000 }' # Delete (422 while the tenant still has domains) curl -X DELETE https://api.spacepost.dev/tenants/cust_123 -H "Authorization: Bearer $SPACEPOST_API_KEY" ``` **CLI** ```bash spacepost tenants list spacepost tenants get cust_123 spacepost tenants pause cust_123 --reason "Investigating spam reports" spacepost tenants unpause cust_123 spacepost tenants rm cust_123 ``` | Situation | Result | |---|---| | Sending for a **paused** tenant | `403 sending_paused`. Emails already queued fail with an `email.failed` event. | | Tenant over its `daily_limit` | `429 daily_quota_exceeded` (the day resets at 00:00 UTC). Nothing from that request is sent. | | `tenant_id` conflicts with the domain's tenant | `422 validation_error` | | Unknown `tenant_id` | `404 not_found` | | Unpausing a tenant paused by the reputation guard | `403 invalid_access`, unless your team is `trusted` or `internal`. Contact [support@spacepost.dev](mailto:support@spacepost.dev) after fixing the cause. Pauses you made yourself can always be lifted. | | Deleting a tenant that still has domains | `422 validation_error`. Delete its domains first. | > Note: A tenant paused automatically by the reputation guard is a reputation signal for your whole account, which is why only reviewed teams can resume it themselves. Treat `paused_reason` ("Bounce rate 7.2% over the last 500 emails") as something to act on with the customer. ## Webhooks Your normal [webhooks](/webhooks/overview/) receive events for tenant mail too, with `data.tenant_id` set to our tenant id. Emails without a tenant have no `tenant_id` key. ```json { "type": "email.bounced", "created_at": "2026-10-23T14:02:12.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme Inc via YourApp ", "to": ["nobody@example.com"], "subject": "Invoice #1042", "created_at": "2026-10-23T14:02:09.512Z", "tenant_id": "0192e1c4-5555-7000-8000-0000000000cc", "bounce": { "type": "Permanent", "subType": "General", "message": "smtp; 550 5.1.1 user unknown" } } } ``` ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); export async function handle(request: Request): Promise { const payload = await request.text(); try { const event = await spacepost.webhooks.verify({ payload, headers: request.headers, secret: process.env.SPACEPOST_WEBHOOK_SECRET!, }); // `tenant_id` is our tenant id (not your external_id). It is absent on the team's own mail. const tenantId = event.data.tenant_id; if (event.type === "email.bounced" && tenantId) { console.log(`Bounce for tenant ${tenantId}: ${event.data.bounce?.subType}`); } return new Response("ok"); } catch { return new Response("invalid signature", { status: 400 }); } } ``` `GET /emails/{id}` and `GET /domains/{id}` also return `tenant_id` (or `null`), so you can reconcile in your own database. ## Checklist for a SaaS integration 1. Create a tenant when a customer signs up, with your customer id as `external_id`. 2. Decide per customer: their own domain (`tenant_id` on `POST /domains`) or your shared domain (`tenant_id` on every send). 3. Set a conservative `daily_limit` for new customers and raise it as they prove themselves. 4. Subscribe to `email.bounced`, `email.complained` and `email.failed`, and surface `tenant_id` problems in your admin UI. 5. Pause a customer yourself the moment you see abuse. It is instant and reversible. --- # Audiences and contacts URL: https://docs.spacepost.dev/audiences/contacts/ An **audience** is a list of **contacts** you can send [broadcasts](/audiences/broadcasts/) to. A contact belongs to exactly one audience; the same email address in two audiences is two contacts, each with its own unsubscribe state. ## Audiences **cURL** ```bash curl -X POST https://api.spacepost.dev/audiences \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Newsletter" }' ``` ```json title="Response" { "object": "audience", "id": "0192e1c4-0000-7000-8000-0000000000a1", "name": "Newsletter" } ``` **Node.js** ```ts const { data } = await spacepost.audiences.create({ name: "Newsletter" }); ``` `GET /audiences`, `GET /audiences/{id}` and `DELETE /audiences/{id}` complete the set. Deleting an audience deletes its contacts. Audience names are 1 to 100 characters. ## Contacts Contacts live under their audience: `/audiences/{audienceId}/contacts`. - `email` (string, required): Unique within the audience. Stored lower-cased. Creating a duplicate returns `422`. - `first_name` (string): Up to 100 characters. Used by `{{{FIRST_NAME}}}`. - `last_name` (string): Up to 100 characters. Used by `{{{LAST_NAME}}}`. - `unsubscribed` (boolean): Create the contact already unsubscribed (for example when importing a suppression list). Defaults to `false`. ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const { data: audience, error } = await spacepost.audiences.create({ name: "Newsletter" }); if (error) throw new Error(error.message); // Create await spacepost.contacts.create({ audienceId: audience.id, email: "ada@example.com", firstName: "Ada", lastName: "Lovelace", }); // Read by email (or by id) const { data: contact } = await spacepost.contacts.get({ audienceId: audience.id, email: "ada@example.com" }); console.log(contact?.unsubscribed); // Update await spacepost.contacts.update({ audienceId: audience.id, email: "ada@example.com", firstName: "Augusta" }); // List, newest first const { data: page } = await spacepost.contacts.list({ audienceId: audience.id, limit: 50 }); console.log(page?.data.length, page?.has_more); // Unsubscribe manually, then delete await spacepost.contacts.update({ audienceId: audience.id, email: "ada@example.com", unsubscribed: true }); await spacepost.contacts.remove({ audienceId: audience.id, email: "ada@example.com" }); ``` **cURL** ```bash AUD=0192e1c4-0000-7000-8000-0000000000a1 # create curl -X POST https://api.spacepost.dev/audiences/$AUD/contacts \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace" }' # => { "object": "contact", "id": "..." } # get by email (URL-encode the @) or by id curl "https://api.spacepost.dev/audiences/$AUD/contacts/ada%40example.com" -H "Authorization: Bearer $SPACEPOST_API_KEY" # update curl -X PATCH "https://api.spacepost.dev/audiences/$AUD/contacts/ada%40example.com" \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "unsubscribed": true }' # delete curl -X DELETE "https://api.spacepost.dev/audiences/$AUD/contacts/ada%40example.com" -H "Authorization: Bearer $SPACEPOST_API_KEY" ``` **Python** ```python base = f"https://api.spacepost.dev/audiences/{audience_id}/contacts" h = {"Authorization": f"Bearer {os.environ['SPACEPOST_API_KEY']}"} requests.post(base, headers=h, json={"email": "ada@example.com", "first_name": "Ada"}) requests.patch(f"{base}/ada@example.com", headers=h, json={"unsubscribed": True}) ``` A contact looks like this: ```json { "object": "contact", "id": "0192e1c4-0000-7000-8000-0000000000c1", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace", "created_at": "2026-10-23T14:00:00.000Z", "unsubscribed": false } ``` `DELETE` returns `{ "object": "contact", "contact": "ada@example.com", "deleted": true }`. Where an `idOrEmail` is expected you can pass either the contact id or the email address. ## Unsubscribes A contact with `unsubscribed: true` is skipped by every broadcast. Contacts unsubscribe themselves through the link in your emails ([Unsubscribe](/audiences/unsubscribe/)), and you can set the flag through the API when you learn about an opt-out elsewhere. > Note: Unsubscribing is per contact and only affects broadcasts. The [suppression list](/deliverability/bounces-and-complaints/) is team-wide and applies to **every** email, including transactional ones. Hard bounces and complaints add to it automatically. ## Importing Import with a loop, respecting the [rate limit](/api/rate-limits/) (10 requests per second on Free and Pro): ```ts for (const row of rows) { const { error } = await spacepost.contacts.create({ audienceId, email: row.email, firstName: row.first }); if (error?.name === "rate_limit_exceeded") await new Promise((r) => setTimeout(r, 1000)); } ``` Only import people who agreed to hear from you. See [Compliance](/audiences/compliance/). --- # Broadcasts URL: https://docs.spacepost.dev/audiences/broadcasts/ A broadcast sends one message to every **subscribed** contact of an audience. SpacePost fans it out into one personalized email per contact, adds unsubscribe links and the `List-Unsubscribe` headers, skips suppressed addresses and tracks everything like any other email. ## Lifecycle | Status | Meaning | |---|---| | `draft` | Created, editable, not sent. | | `queued` | `send` was called; waiting for its start time (immediately, or `scheduled_at`). | | `sending` | Being fanned out to contacts. | | `sent` | Every email has been accepted for delivery. `sent_at` is set. | | `failed` | Fan-out stopped, for example because a quota was reached or the team was paused. | | `canceled` | Canceled before sending. | ```ts import { SpacePost } from "@spacepost/node"; const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY); const audienceId = "0192e1c4-0000-7000-8000-0000000000a1"; // 1. Create a draft const { data: draft, error } = await spacepost.broadcasts.create({ audienceId, name: "October newsletter", from: "Acme ", replyTo: "support@acme.com", subject: "What's new at Acme, {{{FIRST_NAME|friend}}}", previewText: "Three things we shipped this month", html: `

Hi {{{FIRST_NAME|there}}},

Here is what is new.

Unsubscribe

`, }); if (error) throw new Error(error.message); // 2. Send it now, or schedule it const { error: sendError } = await spacepost.broadcasts.send(draft.id, { scheduledAt: "in 1 hour" }); if (sendError) throw new Error(sendError.message); // 3. Check progress const { data: broadcast } = await spacepost.broadcasts.get(draft.id); console.log(broadcast?.status); // "queued" | "sending" | "sent" | "failed" ``` ## Create a broadcast `POST /broadcasts` creates a draft and returns `{ "id": "..." }`. - `audience_id` (string (uuid), required): The audience to send to. - `from` (string, required): Sender on a verified domain. - `subject` (string, required): 1 to 998 characters. Placeholders are supported. - `html` (string): HTML body. Provide `html`, `text` or both before sending. - `text` (string): Plain-text body. - `reply_to` (string | string[]): Up to 50 addresses. - `preview_text` (string): Up to 200 characters. Shown after the subject in inboxes; inserted as a hidden preheader. - `name` (string): Up to 100 characters. An internal label. ## Update and delete `PATCH /broadcasts/{id}` accepts the same fields (all optional) and works **only on drafts**. `DELETE /broadcasts/{id}` works on drafts and on broadcasts that are scheduled for the future. ## Send **cURL** ```bash # now curl -X POST https://api.spacepost.dev/broadcasts/$ID/send -H "Authorization: Bearer $SPACEPOST_API_KEY" # scheduled (ISO or natural language, up to 30 days ahead) curl -X POST https://api.spacepost.dev/broadcasts/$ID/send \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-11-01T09:00:00Z" }' ``` **Node.js** ```ts await spacepost.broadcasts.send(id); // now await spacepost.broadcasts.send(id, { scheduledAt: "in 1 hour" }); // later ``` Only drafts with an `html` or `text` body can be sent, from a verified domain. The response is `{ "id": "..." }`. ## Readiness checks Before you send, `GET /broadcasts/{id}/readiness` returns a deliverability checklist (the dashboard shows the same list in the editor). It is a SpacePost extension and not part of Resend's API. ```json { "ready": true, "checks": [{ "id": "html_size", "level": "warn", "title": "Gmail will clip this message", "detail": "..." }] } ``` | Check | Level | When | |---|---|---| | `sender_domain` | error | The `from` domain is missing or not verified. | | `dmarc` | warn | No `v=DMARC1` record at `_dmarc.`, or the lookup failed. `p=none` passes. | | `subject` | error / warn | Error when empty. Warning for ALL CAPS, `!!!` or `$$$`, or more than 90 characters. | | `html_size` | warn | HTML over 102 KB, which Gmail clips. | | `images` | warn | Image-only, or four or more images with little text. | | `shorteners` | warn | Links through bit.ly, tinyurl.com, t.co and similar. | | `spam_phrases` | warn | Common spam wording in the subject or body. | | `unsubscribe` | ok | An unsubscribe footer is added automatically when your body has no placeholder, so this never blocks. | | `audience` | error | No subscribed contacts. | | `role_addresses` | warn | More than 20% of contacts are addresses like `info@` or `admin@`. | `ready` is `false` when any check has level `error`. Sending then fails with a `validation_error` (422) that lists what to fix. Warnings never block a send. Broadcast emails also carry `List-Id` and `Feedback-ID` headers (`::broadcast:spacepost`) so Gmail Postmaster Tools can attribute complaints to a broadcast. Transactional emails do not. ## What a recipient gets - **Personalization** is applied to `subject`, `html` and `text` ([Personalization](/audiences/personalization/)). - **Unsubscribe:** if your body has no `{{{SPACEPOST_UNSUBSCRIBE_URL}}}` / `{{{RESEND_UNSUBSCRIBE_URL}}}` placeholder, an unsubscribe footer is appended automatically. The `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers are always added. - **Preview text** becomes a hidden preheader at the top of the HTML. - Contacts that are unsubscribed, and addresses on your suppression list, are skipped. ## Quota and limits Every recipient counts as one email against your plan quota, and the 100-per-day [sandbox](/deliverability/sandbox/) applies to new accounts. If the quota is exhausted mid-send the broadcast ends as `failed`; emails already created are still delivered. Fan-out is resumable if a worker restarts. Each broadcast email is a normal email: it appears in `GET /emails`, carries `broadcast_id` in webhook payloads and can bounce or be opened. > Note: Send yourself a test first: create a one-contact audience containing your own address, create and send a broadcast to it, and check rendering and the unsubscribe flow. --- # Personalization URL: https://docs.spacepost.dev/audiences/personalization/ Broadcasts render each message per contact. Use triple braces in `subject`, `html` and `text`. | Placeholder | Replaced with | |---|---| | `{{{FIRST_NAME}}}` | The contact's first name, or empty text if there is none. | | `{{{FIRST_NAME\|fallback}}}` | The first name, or `fallback` when it is empty. | | `{{{LAST_NAME}}}` | The contact's last name, or empty text. | | `{{{EMAIL}}}` | The contact's email address. | | `{{{SPACEPOST_UNSUBSCRIBE_URL}}}` | A signed, per-contact unsubscribe link. | | `{{{RESEND_UNSUBSCRIBE_URL}}}` | Alias of the above, so templates migrated from Resend keep working. | ```html

Hi {{{FIRST_NAME|there}}},

We sent this to {{{EMAIL}}}.

Unsubscribe

``` Subject example: `Welcome back, {{{FIRST_NAME|friend}}}`. ## Rules - Whitespace inside the braces is ignored: `{{{ FIRST_NAME | there }}}` works. - The fallback is trimmed and may contain spaces, but not `}`. - Names are case-sensitive and uppercase. - Values inserted into **HTML are escaped** (`<` becomes `<`), so a contact named `