# Anypost Documentation > Anypost is an email-sending platform with an HTTP API and SMTP submission. The documentation covers account setup, sending mail, webhooks, and the full API reference. --- Source: /docs # Introduction Anypost sends email for your application: password resets, receipts, notifications, product announcements. It tells you what happened to every message, and it's built to be quick to integrate, dependable at volume, and cheap enough that you never have to ration your sending. ## What Anypost does Sending email well is harder than running an SMTP server. Mail from an unknown server gets filtered as spam, authentication (SPF, DKIM, DMARC) is easy to get subtly wrong, and once a message leaves your app you have no idea whether it landed. Anypost handles that: - **Deliverability:** verified sending domains, automatic DKIM signing, and reputation management so your mail reaches the inbox. - **Delivery feedback:** every message produces events (delivered, bounced, complained, opened, clicked) that you can react to in real time. - **Operational tooling:** suppression lists, open and click tracking, reusable templates, and analytics, none of which you have to build. It fits a wide range of needs. A new application can wire straight into the HTTP API. An existing app, or off-the-shelf software that already speaks SMTP, can point at Anypost's SMTP service instead and get the same delivery and reporting without any code changes. ## Two ways to send Anypost accepts mail two ways, with near-identical capabilities. Pick whichever fits your stack; everything Anypost reports back is the same regardless of how a message arrives. | Transport | How you send | Best for | |---|---|---| | **HTTP API** | A JSON request to `POST /v1/email`. | New code. The richest option, and what most of these docs describe. | | **SMTP** | Authenticate with an API key and submit mail the way any email library already does. | Existing apps and off-the-shelf software you'd rather not change. | ## The mental model Four concepts cover almost everything in Anypost. Once they click, the rest of these docs are detail. 1. **Domains.** A sending domain is a domain you own that Anypost is authorized to send mail for. You verify it once by adding a few DNS records. From then on your mail is properly authenticated. 2. **API keys.** Every send is authenticated with an API key. One key works for both transports: it's the bearer token for the HTTP API and the password for SMTP. Keys are scoped (sending only, or limited to specific domains) and can be rotated whenever you need to. 3. **Send.** With a verified domain and an API key, you send a message: one request to `POST /v1/email`, or an SMTP submission. Anypost accepts the message, authenticates it, and hands it off for delivery. 4. **Events.** Delivery is neither instant nor guaranteed, so Anypost reports back. As a message progresses it emits events: `email.delivered`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, and more. You receive these as webhooks (an HTTP POST to your endpoint) or browse them in the dashboard. The flow is always the same: domains, then API keys, then send, then events. ## Send your first email Once you have a verified domain and an API key, sending over the HTTP API is a single request: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "you@yourdomain.com", "to": ["someone@example.com"], "subject": "Hello from Anypost", "text": "Hello, inbox!" }' ``` ```ts // npm install anypost import { Anypost } from "anypost"; const anypost = new Anypost("ap_your_api_key"); const { id } = await anypost.email.send({ from: "you@yourdomain.com", to: ["someone@example.com"], subject: "Hello from Anypost", text: "Hello, inbox!", }); ``` ```python # pip install anypost from anypost import Anypost client = Anypost("ap_your_api_key") result = client.email.send({ "from": "you@yourdomain.com", "to": ["someone@example.com"], "subject": "Hello from Anypost", "text": "Hello, inbox!", }) ``` ```php // composer require anypost/anypost-php use Anypost\Anypost; $client = new Anypost("ap_your_api_key"); $result = $client->email->send([ "from" => "you@yourdomain.com", "to" => ["someone@example.com"], "subject" => "Hello from Anypost", "text" => "Hello, inbox!", ]); ``` ```ruby # gem install anypost require "anypost" client = Anypost::Client.new("ap_your_api_key") email = client.email.send( from: "you@yourdomain.com", to: ["someone@example.com"], subject: "Hello from Anypost", text: "Hello, inbox!" ) ``` ```rust // cargo add anypost use anypost::{Client, SendEmail}; let client = Client::new("ap_your_api_key")?; let email = client.email.send( &SendEmail::new("you@yourdomain.com", ["someone@example.com"]) .subject("Hello from Anypost") .text("Hello, inbox!"), ).await?; ``` ```go // go get github.com/anypost/anypost-go import "github.com/anypost/anypost-go" client, _ := anypost.New("ap_your_api_key") email, _ := client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "you@yourdomain.com", To: []string{"someone@example.com"}, Subject: "Hello from Anypost", Text: "Hello, inbox!", }) ``` ```java // implementation("com.anypost:anypost-java:0.1.0") import com.anypost.Anypost; import com.anypost.model.SendEmailRequest; Anypost client = Anypost.create("ap_your_api_key"); var email = client.email.send(SendEmailRequest.builder() .from("you@yourdomain.com") .to("someone@example.com") .subject("Hello from Anypost") .text("Hello, inbox!") .build()); ``` ```csharp // dotnet add package Anypost using Anypost; using Anypost.Models; var client = AnypostClient.Create("ap_your_api_key"); var email = await client.Email.SendAsync(new SendEmailRequest { From = "you@yourdomain.com", To = ["someone@example.com"], Subject = "Hello from Anypost", Text = "Hello, inbox!", }); ``` A `202` response means the message was accepted for delivery. The body returns a message `id` and a `created_at` timestamp: ```json { "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d", "created_at": "2026-04-30T12:00:00.123000Z" } ``` A `202` is not a guarantee of delivery. It means Anypost has the message. What happens next arrives as events. Use the `id` to correlate every event back to the message that produced it. ## Where to go next - **[Quickstart](/docs/quickstart):** go from signup to a delivered email, step by step. - **[Core concepts](/docs/core-concepts):** the objects Anypost is built from and how they relate. - **[Domains & DNS setup](/docs/domains):** verify your first sending domain. - **[Authentication](/docs/authentication):** create, scope, and rotate the API keys you send with. - **[Sending over SMTP](/docs/sending-over-smtp):** point an existing app at Anypost without code changes. - **[Webhooks](/docs/webhooks):** receive delivery events as they happen. --- Source: /docs/quickstart # Quickstart Create an account, verify a domain, make an API key, send. The first three steps happen once; after that, sending is a single request. ## 1. Create an account Sign up for Anypost. Your account belongs to a team, and everything you create next, domains, API keys, sends, belongs to that team. ## 2. Verify a sending domain You can only send from a domain you have verified. In the dashboard, add the domain you will send from. Anypost shows you a few CNAME records; publish them at your DNS provider, then verify the domain. Verification depends on DNS propagation, so it can take anywhere from a few minutes to a few hours. [**Domains & DNS setup**](/docs/domains) covers the records and troubleshooting in full. You need one verified domain before step 4. ## 3. Create an API key In the dashboard, create an API key. Copy the secret, which starts with `ap_`, when it is shown. It is shown once and cannot be retrieved later. This key authenticates both the HTTP API and SMTP. See [**Authentication**](/docs/authentication) for scoping and rotation. ## 4. Send an email Send over the HTTP API. Use your verified domain in `from` and your key as the bearer token. Pick your language with the tabs: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer ap_AbCdEf12KxLmNoPq..." \ -H "Content-Type: application/json" \ -d '{ "from": "you@yourdomain.com", "to": ["someone@example.com"], "subject": "Hello from Anypost", "text": "Hello, inbox!" }' ``` ```ts // npm install anypost import { Anypost } from "anypost"; const anypost = new Anypost("ap_AbCdEf12KxLmNoPq..."); const { id } = await anypost.email.send({ from: "you@yourdomain.com", to: ["someone@example.com"], subject: "Hello from Anypost", text: "Hello, inbox!", }); ``` ```python # pip install anypost from anypost import Anypost client = Anypost("ap_AbCdEf12KxLmNoPq...") result = client.email.send({ "from": "you@yourdomain.com", "to": ["someone@example.com"], "subject": "Hello from Anypost", "text": "Hello, inbox!", }) ``` ```php // composer require anypost/anypost-php use Anypost\Anypost; $client = new Anypost("ap_AbCdEf12KxLmNoPq..."); $result = $client->email->send([ "from" => "you@yourdomain.com", "to" => ["someone@example.com"], "subject" => "Hello from Anypost", "text" => "Hello, inbox!", ]); ``` ```ruby # gem install anypost require "anypost" client = Anypost::Client.new("ap_AbCdEf12KxLmNoPq...") result = client.email.send( from: "you@yourdomain.com", to: ["someone@example.com"], subject: "Hello from Anypost", text: "Hello, inbox!" ) ``` ```rust // cargo add anypost use anypost::{Client, SendEmail}; let client = Client::new("ap_AbCdEf12KxLmNoPq...")?; let result = client.email.send( &SendEmail::new("you@yourdomain.com", ["someone@example.com"]) .subject("Hello from Anypost") .text("Hello, inbox!"), ).await?; ``` ```go // go get github.com/anypost/anypost-go import "github.com/anypost/anypost-go" client, _ := anypost.New("ap_AbCdEf12KxLmNoPq...") result, _ := client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "you@yourdomain.com", To: []string{"someone@example.com"}, Subject: "Hello from Anypost", Text: "Hello, inbox!", }) ``` ```java // implementation("com.anypost:anypost-java:0.1.0") import com.anypost.Anypost; import com.anypost.model.SendEmailRequest; Anypost client = Anypost.create("ap_AbCdEf12KxLmNoPq..."); var result = client.email.send(SendEmailRequest.builder() .from("you@yourdomain.com") .to("someone@example.com") .subject("Hello from Anypost") .text("Hello, inbox!") .build()); ``` ```csharp // dotnet add package Anypost using Anypost; using Anypost.Models; var client = AnypostClient.Create("ap_AbCdEf12KxLmNoPq..."); var result = await client.Email.SendAsync(new SendEmailRequest { From = "you@yourdomain.com", To = ["someone@example.com"], Subject = "Hello from Anypost", Text = "Hello, inbox!", }); ``` A `202` response means the message was accepted: ```json { "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d", "created_at": "2026-04-30T12:00:00.123000Z" } ``` That is your first send. The `202` means Anypost has the message, not that it was delivered; delivery and engagement arrive later as events. ### Sending over SMTP instead The same message can go over SMTP. Authenticate with the username `anypost` and your `ap_` key as the password. See [**Sending over SMTP**](/docs/sending-over-smtp) for the host, ports, and TLS settings. ## What's next - **[Send a single email](/docs/send-email):** every field `POST /v1/email` accepts. - **[Webhooks](/docs/webhooks):** react to deliveries, bounces, opens, and clicks. - **[Sending over SMTP](/docs/sending-over-smtp):** point an existing app at Anypost. --- Source: /docs/core-concepts # Core concepts A handful of objects make up everything in Anypost. This page defines them and shows how they fit together. The rest of the docs assume this vocabulary. ## Team A team owns everything: domains, API keys, templates, webhooks, and the messages you send. Your account belongs to a team, and every resource you create belongs to that same team. Resources never cross team boundaries. A request authenticated for one team cannot see another team's domains or messages, and a `404` is returned for any ID that belongs elsewhere. ## Domain A sending domain is a domain you own that Anypost is authorized to send mail for. You verify it once by publishing DNS records; from then on, mail from addresses at that domain is properly authenticated. The domain in a message's `from` address must be verified for your team. A send from an unverified domain is rejected. See [**Domains & DNS setup**](/docs/domains). ## API key An API key authenticates everything you send. One key works for both transports: it is the bearer token for the HTTP API and the password for SMTP. A key has a public ID (`key_...`) that names it and a secret (`ap_...`) that authenticates requests. Keys are scoped by permission level and can be restricted to specific domains or IP addresses. See [**Authentication**](/docs/authentication). ## Message A message is one email Anypost has accepted. You create one by sending: a JSON request to `POST /v1/email`, or an SMTP submission. Anypost authenticates it, signs it, and hands it off for delivery. Every accepted message gets an ID (`email_...`). Acceptance is not delivery: a `202` response means Anypost has the message, not that it reached an inbox. What happens after acceptance arrives as events. See [**Send a single email**](/docs/send-email). ## Event An event records something that happened to a message: it was delivered, bounced, marked as spam, opened, or had a link clicked. Delivery is neither instant nor guaranteed, so events are how Anypost tells you the outcome. Common event types: | Event | Meaning | |---|---| | `email.sent` | Anypost accepted the message and queued it. | | `email.delivered` | The receiving mail server accepted the message. | | `email.bounced` | Delivery failed permanently. | | `email.complained` | The recipient marked the message as spam. | | `email.opened` | The recipient opened the message. | | `email.clicked` | The recipient clicked a tracked link. | Every event carries the `email_` ID of the message it belongs to, so you can correlate an outcome back to the send that produced it. ## Webhook A webhook delivers events to your application. You register an HTTPS endpoint, Anypost `POST`s each event to it as it happens, and your endpoint reacts: suppress a bounced address, flag a complaint, record an open. Events are also visible in the dashboard, but a webhook is how an application reacts to them in real time. See [**Webhooks**](/docs/webhooks). ## Template A template is stored message content (subject, HTML, and text) with placeholders. Instead of sending a body inline, you reference a template by ID and supply per-recipient variables, and Anypost renders the message. See [**Sending with templates**](/docs/templates). ## Suppression A suppression is an address Anypost will not send to. Hard bounces and spam complaints add an address automatically, which protects your sending reputation: continuing to mail an address that bounces or complains is what damages it. A send to a suppressed address is dropped rather than delivered. See [**Suppressions**](/docs/suppressions). ## Correlation dimensions Four optional labels travel with a message so its events can be sliced and filtered later: `tags`, `topic`, `campaign`, and `template_id`. They carry no delivery meaning. They exist so that when an open or a bounce arrives, you can attribute it to the campaign or template that produced it. See [**Tags, topics & campaigns**](/docs/tags-topics-campaigns). ## How it fits together The path is always the same: 1. Verify a **domain** so Anypost can authenticate your mail. 2. Create an **API key** to authenticate your requests. 3. Send a **message** from a verified domain with that key. 4. Receive **events** about that message through a **webhook**. Everything else (templates, suppressions, correlation dimensions) refines those four steps. It does not change their order. ## Where to go next - **[Quickstart](/docs/quickstart):** walk the four steps end to end. - **[Send a single email](/docs/send-email):** every field a message accepts. - **[Webhooks](/docs/webhooks):** receive events as they happen. --- Source: /docs/sending-limits # Sending limits Anypost applies a monthly quota set by your plan and a daily limit that starts conservative and rises as you build a healthy sending history. On a paid plan you can keep sending past your monthly quota by buying prepaid overage credits; the daily limit is always a hard cap. ## The two limits **Monthly quota.** The volume included with your plan, reset on the first of each month. You can see it on your dashboard, and upgrading your plan raises it. On a paid plan you can continue past the quota by spending prepaid [overage credits](#overage). On the free plan the quota is a hard cap. **Daily limit.** A per-team ceiling on how much you can send in a day, and a hard cap on every plan. Unlike the monthly quota, it is not fixed: a new account or a newly upgraded plan starts with a conservative daily limit, and Anypost raises it automatically as you send consistently to recipients who want your mail. It can also fall if your bounce or complaint rate climbs. The daily limit is how Anypost protects shared delivery reputation while still letting a sender who has earned trust grow into real volume. It adapts on paid plans; the free plan has a fixed daily limit that does not grow. ## Which one applies Every send is checked against both: - The **daily limit** is a hard cap on all plans. A send that would cross it is rejected. - The **monthly quota** is a hard cap on the free plan. On paid plans it is the point where your prepaid [overage credits](#overage) start being spent, and it becomes a hard cap again once they run out or if you have not enabled overage. So on a paid plan with overage credits available, the daily limit is what actually stops a send and the monthly quota only decides when credits start being drawn. Without credits, the monthly quota stops you too. On the free plan, whichever limit you reach first stops you. Both reset on their own schedule: the daily limit at the start of each day, the monthly quota on the first of each month. ## Overage On a paid plan, overage credits let you keep sending after your monthly quota runs out. Credits are prepaid: you turn on overage and buy a balance up front. You buy credits in dollar amounts, with a $5 minimum, and they convert to messages at your plan's per-1,000 rate at the time of purchase. On a plan that charges $0.20 per 1,000, a $5 top-up adds 25,000 credits. Credits roll over and do not expire. Each month your included quota is spent first, then your credit balance. Turn on **auto-reload** to top up without thinking about it: set a balance to watch and an amount to buy, and Anypost charges your card and adds credits whenever the balance falls below that level. If a charge fails, auto-reload pauses and the team owner is emailed; it resumes once you add credits manually. When your credits run out, sending past the monthly quota is rejected with `429` until you add more or the quota resets on the first of the month. Your daily limit still applies while you are spending credits, so it bounds how much you can run up in a single day. The free plan has no overage: it stops at the monthly quota. Manage overage and check your balance in your billing settings. ## Growing your daily limit On a paid plan, the daily limit tracks real sending behavior, so the way to raise it is to be the kind of sender receiving mail servers welcome: - **Send consistently.** Steady, predictable volume builds trust faster than sporadic bursts. - **Keep bounce rates low.** Mail to addresses that don't exist signals a dirty list. Anypost [suppresses](/docs/suppressions) hard bounces automatically; don't work around it. - **Keep complaints low.** Send only to recipients who asked to hear from you, and honor [unsubscribes](/docs/unsubscribe) promptly. - **Authenticate your domains.** A [verified domain](/docs/domains) with proper DNS is a prerequisite for building any reputation at all. **Note.** There is no published formula, target number, or schedule for the daily limit, and that is deliberate: it reflects your actual sending behavior, not a value to engineer toward. Send well and it rises on its own. ## When you hit a limit A send that would exceed your **daily limit** is rejected with `429` before any message is accepted, on every plan. Exceeding your **monthly quota** is rejected the same way: on the free plan always, and on a paid plan once your [overage credits](#overage) are gone or if you have not enabled overage. The response names which limit you hit: ```json { "error": "quota_exceeded", "scope": "daily", "used": 5000, "limit": 5000, "retry_after_seconds": 41820 } ``` - `scope` is `monthly` or `daily`, naming the limit you reached. A `monthly` rejection happens on the free plan, or on a paid plan once your overage credits run out. - `retry_after_seconds` (and the `Retry-After` header) is how long until that limit resets: the next day for `daily`, the first of next month for `monthly`. A `429` rejects the whole request and accepts nothing, so it is safe to retry after the window. If you are sending in [batches](/docs/batch-sending), a batch that would cross a limit is rejected in full. Split it and send the remainder after the reset, or spread large jobs across the day rather than firing them in one burst. ## Account review Separately from these limits, an account can be paused for review if its sending pattern looks harmful to recipients or to shared reputation. A paused account can still sign in and see its dashboard, but new sends are rejected. If your team is paused, the dashboard shows why and how to reach support. ## Where to go next - **[Suppressions](/docs/suppressions):** how bounces and complaints are handled, and why working around them hurts your limit. - **[Batch sending](/docs/batch-sending):** send efficiently without tripping a limit mid-job. - **[API conventions](/docs/api-conventions):** the shared rules for status codes and retries. --- Source: /docs/authentication # Authentication Every request to Anypost is authenticated with an API key. One key works for both transports: it is the bearer token for the HTTP API and the password for SMTP. ## API keys An API key has two parts, and the distinction matters: - The **key ID** (`key_a1b2c3d4-e5f6-7890-abcd-ef1234567890`) names the key. It appears in the dashboard and in API paths like `/v1/api-keys/{id}`. It is not a secret. - The **secret** (`ap_AbCdEf12KxLmNoPq...`) authenticates your requests. Treat it like a password. The secret is returned once, in the response to the request that creates the key. Anypost stores only a hash of it and cannot show it again. If you lose it, create a new key. Each key also has a **prefix**: the first 12 characters of the secret, such as `ap_AbCdEf12Kx`. Lists of keys show the prefix so you can tell them apart without exposing the full secret. **Warning.** The secret grants whatever the key is permitted to do. Keep it server-side. Never embed it in a mobile app, browser code, or a public repository. ## Authenticating requests Over the HTTP API, send the secret as a bearer token: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer ap_AbCdEf12KxLmNoPq..." \ -H "Content-Type: application/json" \ -d '{ "from": "you@yourdomain.com", "to": ["someone@example.com"], "subject": "Hello", "text": "Hello, inbox!" }' ``` ```ts import { Anypost } from "anypost"; // Pass the key once; the SDK sends it as a bearer token on every request. const anypost = new Anypost("ap_AbCdEf12KxLmNoPq..."); // or new Anypost() to read it from ANYPOST_API_KEY ``` ```python from anypost import Anypost # Pass the key once; the SDK sends it as a bearer token on every request. client = Anypost("ap_AbCdEf12KxLmNoPq...") # or Anypost() to read it from ANYPOST_API_KEY ``` ```php use Anypost\Anypost; // Pass the key once; the SDK sends it as a bearer token on every request. $client = new Anypost("ap_AbCdEf12KxLmNoPq..."); // or new Anypost() to read it from ANYPOST_API_KEY ``` ```ruby require "anypost" # Pass the key once; the SDK sends it as a bearer token on every request. client = Anypost::Client.new("ap_AbCdEf12KxLmNoPq...") # or Anypost::Client.new to read it from ANYPOST_API_KEY ``` ```rust use anypost::Client; // Pass the key once; the SDK sends it as a bearer token on every request. let client = Client::new("ap_AbCdEf12KxLmNoPq...")?; // or Client::from_env() to read it from ANYPOST_API_KEY ``` ```go import "github.com/anypost/anypost-go" // Pass the key once; the SDK sends it as a bearer token on every request. client, _ := anypost.New("ap_AbCdEf12KxLmNoPq...") // or anypost.New("") to read it from ANYPOST_API_KEY ``` ```java import com.anypost.Anypost; // Pass the key once; the SDK sends it as a bearer token on every request. Anypost client = Anypost.create("ap_AbCdEf12KxLmNoPq..."); // or Anypost.fromEnv() to read it from ANYPOST_API_KEY ``` ```csharp using Anypost; // Pass the key once; the SDK sends it as a bearer token on every request. var client = AnypostClient.Create("ap_AbCdEf12KxLmNoPq..."); // or AnypostClient.FromEnv() to read it from ANYPOST_API_KEY ``` Over SMTP, authenticate with a fixed username and the secret as the password: | Field | Value | |---|---| | Username | `anypost` | | Password | your API key secret (`ap_...`) | SMTP host, ports, and TLS settings are covered in [**Sending over SMTP**](/docs/sending-over-smtp). ## Permissions Every key has one permission level, set when you create it and changeable later: | Permission | Can do | |---|---| | `full` | Every endpoint: send mail, and manage domains, templates, webhooks, suppressions, and keys. | | `send_only` | Send mail (`POST /v1/email`, `POST /v1/email/batch`) and read its own identity (`GET /v1/whoami`). Every other endpoint returns `403`. | Give an application server that only sends mail a `send_only` key. It limits what an attacker can do if the key leaks. ## Restricting a key Two optional restrictions narrow where a key can be used. Both default to unrestricted. - **allowed_domains:** the sending domains a key may use in the `from` address. When set, a send from any other domain is rejected. Unset means all of your team's verified domains. - **allowed_ips:** the IP addresses or CIDR blocks that may present the key. A request from any other address returns `403`. Unset means any IP. ## Creating and managing keys Manage keys in the dashboard, or through the `/v1/api-keys` endpoints: `POST` to create, `GET` to list, `PATCH` to update, `DELETE` to remove. Creating a key over the API requires an existing `full` key, so your first key comes from the dashboard. The full secret (`key`) is on the create response only — store it then, as it cannot be retrieved later: ```bash curl https://api.anypost.com/v1/api-keys \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production server", "permissions": "send_only" }' ``` ```ts const created = await anypost.apiKeys.create({ name: "Production server", permissions: "send_only", }); console.log(created.key); // store now; it cannot be retrieved later ``` ```python created = client.api_keys.create({ "name": "Production server", "permissions": "send_only", }) print(created["key"]) # store now; it cannot be retrieved later ``` ```php $created = $client->apiKeys->create([ "name" => "Production server", "permissions" => "send_only", ]); echo $created->key; // store now; it cannot be retrieved later ``` ```ruby created = client.api_keys.create(name: "Production server", permissions: "send_only") puts created.key # store now; it cannot be retrieved later ``` ```rust let created = client.api_keys.create(serde_json::json!({ "name": "Production server", "permissions": "send_only" })).await?; println!("{}", created["key"]); // store now; it cannot be retrieved later ``` ```go created, _ := client.APIKeys.Create(ctx, &anypost.APIKeyCreateParams{ Name: "Production server", Permissions: anypost.PermissionSendOnly, }) fmt.Println(created.Key) // store now; it cannot be retrieved later ``` ```java ApiKeyWithSecret created = client.apiKeys.create(ApiKeyCreateParams.builder() .name("Production server") .permissions(Permissions.SEND_ONLY) .build()); System.out.println(created.key()); // store now; it cannot be retrieved later ``` ```csharp var created = await client.ApiKeys.CreateAsync(new ApiKeyCreateParams { Name = "Production server", Permissions = Permissions.SendOnly, }); Console.WriteLine(created.Key); // store now; it cannot be retrieved later ``` `PATCH` changes a key's name, permission level, and restrictions. It never changes the secret. Each key records when it was last used, which helps identify keys that are safe to delete. Changes to a key, including deletion, propagate within about 5 minutes; a deleted or downgraded key may keep working until then. ## Rotating a key There is no rotate operation for API keys. To rotate one: 1. Create a new key with the same permissions and restrictions. 2. Deploy it everywhere the old key is used. 3. Delete the old key. Running both keys during the cutover avoids downtime. ## When authentication fails | Status | Meaning | |---|---| | `401` | The `Authorization` header is missing, or the key is unknown. | | `403` | The key is valid but not allowed here: the request came from an IP outside `allowed_ips`, or a `send_only` key called a non-send endpoint. | Repeated failed attempts from one IP are temporarily blocked. Fix the credential rather than retrying. ## Where to go next - **[Domains & DNS setup](/docs/domains):** verify a domain before you can send from it. - **[Sending over SMTP](/docs/sending-over-smtp):** host, ports, and TLS for the SMTP transport. - **[Send a single email](/docs/send-email):** the full `POST /v1/email` request. --- Source: /docs/domains # Domains & DNS setup Before you can send from an address, the sending domain has to be verified. Verification proves you control the domain and lets Anypost authenticate your mail. ## How verification works Mailbox providers only trust authenticated mail, and authentication relies on DNS: SPF, DKIM, and MX records. Configuring them by hand is error-prone, and the values change over time. Anypost manages those records for you. When you add a domain, Anypost generates its DKIM keys and publishes the authoritative SPF, DKIM, and MX records in its own DNS. You publish a small set of CNAME records pointing your domain at Anypost's. Your DNS holds only CNAMEs; Anypost holds the records that actually change. Once the CNAMEs are published, Anypost can rotate DKIM keys behind them without you touching DNS again. ## Add a domain Add a domain in the dashboard, or with `POST /v1/domains`: ```bash curl https://api.anypost.com/v1/domains \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "example.com" }' ``` ```ts const domain = await anypost.domains.create({ name: "example.com" }); for (const record of domain.dns_records) { console.log(record.type, record.name, "->", record.value); } ``` ```python domain = client.domains.create({"name": "example.com"}) for record in domain["dns_records"]: print(record["type"], record["name"], "->", record["value"]) ``` ```php $domain = $client->domains->create(["name" => "example.com"]); foreach ($domain->dns_records as $record) { echo "{$record->type} {$record->name} -> {$record->value}\n"; } ``` ```ruby domain = client.domains.create(name: "example.com") domain.dns_records.each do |record| puts "#{record.type} #{record.name} -> #{record.value}" end ``` ```rust let domain = client.domains.create(serde_json::json!({ "name": "example.com" })).await?; for record in domain["dns_records"].as_array().unwrap() { println!("{} {} -> {}", record["type"], record["name"], record["value"]); } ``` ```go domain, _ := client.Domains.Create(ctx, &anypost.DomainCreateParams{Name: "example.com"}) for _, record := range domain.DNSRecords { fmt.Printf("%s %s -> %s\n", record.Type, record.Name, record.Value) } ``` ```java Domain domain = client.domains.create(DomainCreateParams.of("example.com")); for (DnsRecord record : domain.dnsRecords()) { System.out.println(record.type() + " " + record.name() + " -> " + record.value()); } ``` ```csharp var domain = await client.Domains.CreateAsync(new DomainCreateParams { Name = "example.com" }); foreach (var record in domain.DnsRecords) { Console.WriteLine($"{record.Type} {record.Name} -> {record.Value}"); } ``` Add the exact domain you will put in `from` addresses. To send from `mail.example.com`, add it as its own domain. The response includes the domain's `id`, a `status` of `pending`, and a `dns_records` array: the records to publish next. ## Publish the DNS records `dns_records` lists every record to create at your DNS provider. Each one is a CNAME: | Field | What it is | |---|---| | `type` | Always `CNAME`. | | `name` | The record name, relative to your domain's registered apex. | | `value` | The target the CNAME points to. | | `purpose` | `verification` or `dkim`. | A typical domain has three records: one `verification` and two `dkim`. Publish each one exactly as given. The `name` is relative to the registered apex, the form most provider UIs expect. For `example.com` the verification record is named `anypost`. For a subdomain like `mail.example.com` it is `anypost.mail`. DNS changes are not instant. Providers and resolvers cache records, so a new CNAME can take anywhere from a few minutes to a few hours to become visible. ## Verify the domain Once the records are published, verify the domain in the dashboard, or call: ```bash curl -X POST https://api.anypost.com/v1/domains/domain_550e8400.../verify \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` ```ts const domain = await anypost.domains.verify("domain_550e8400..."); if (domain.status === "verified") { // ready to send } else { console.log(domain.verification_failure?.code); } ``` ```python domain = client.domains.verify("domain_550e8400...") if domain["status"] == "verified": # ready to send pass else: print(domain["verification_failure"]["code"]) ``` ```php $domain = $client->domains->verify("domain_550e8400..."); if ($domain->status === "verified") { // ready to send } else { echo $domain->verification_failure->code; } ``` ```ruby domain = client.domains.verify("domain_550e8400...") if domain.status == "verified" # ready to send else puts domain.verification_failure.code end ``` ```rust let domain = client.domains.verify("domain_550e8400...").await?; if domain["status"] == "verified" { // ready to send } else { println!("{}", domain["verification_failure"]["code"]); } ``` ```go domain, _ := client.Domains.Verify(ctx, "domain_550e8400...") if domain.Status == "verified" { // ready to send } else { fmt.Println(domain.VerificationFailure.Code) } ``` ```java Domain domain = client.domains.verify("domain_550e8400..."); if (domain.status().equals("verified")) { // ready to send } else { System.out.println(domain.verificationFailure().code()); } ``` ```csharp var domain = await client.Domains.VerifyAsync("domain_550e8400..."); if (domain.Status == "verified") { // ready to send } else { Console.WriteLine(domain.VerificationFailure.Code); } ``` Anypost resolves your CNAMEs and checks that they reach its records. The response returns the domain with an updated `status`: - `verified`: the domain is ready. You can send from it. - `pending`: not all records were found yet. `verification_failure` says why. If you published the records moments ago, verification can fail simply because DNS has not propagated. Wait, then verify again. The call is safe to repeat as often as you like. ## When verification fails `verification_failure.code` identifies the problem. Its `message` restates the same thing with your domain's actual record names filled in. | Code | Meaning | |---|---| | `apex_cname_missing` | The verification CNAME was not found. Check the record name and that it is published. | | `apex_cname_mismatch` | The verification CNAME exists but points to the wrong target. | | `dkim_cname_missing` | A DKIM CNAME was not found. | | `dkim_cname_mismatch` | A DKIM CNAME points to the wrong target. | | `dkim_mismatch` | A DKIM record resolves but its key does not match. Re-check the CNAME target. | | `chain_broken` | A published CNAME resolves, but the chain through to Anypost's records is broken. | | `mx_missing` | The MX record is not resolving through. Usually a propagation delay. | | `spf_missing` | The SPF record is not resolving through. Usually a propagation delay. | Most failures right after publishing are propagation delays. Confirm the record `name` and `value` match what `dns_records` gave you, then verify again. ## DMARC (recommended) DMARC is a TXT record on your domain's apex that tells inboxes what to do with mail from your domain that fails authentication. Gmail and Yahoo require it for any sender of 5,000 or more messages a day to personal accounts. It is optional and separate from the CNAMEs above: it is not part of verification, and your mail authenticates without it. Publish a record named `_dmarc` (or `_dmarc.mail` for a subdomain like `mail.example.com`), starting with `v=DMARC1; p=none`. The `p=none` policy monitors without affecting delivery; once your reports show legitimate mail passing, tighten to `p=quarantine`, then `p=reject` to block spoofing. When you add a domain in the dashboard, Anypost recommends a record for you, and if you already publish one, suggests a merged version that keeps your existing policy and reporting in place. **Warning.** Anypost signs your mail from a subdomain of your domain, so it passes DMARC under relaxed alignment. If you already publish a DMARC record with strict alignment (`adkim=s` or `aspf=s`), relax it, or mail sent through Anypost may fail DMARC. ## Branded tracking (optional) Open and click tracking can run under a branded subdomain of your domain, such as `track.example.com`. It uses one additional CNAME and is verified separately. Tracking is independent of mail flow: a tracking-CNAME problem never blocks sending. See [**Tracking domains**](/docs/tracking-domains). ## Where to go next - **[Send a single email](/docs/send-email):** now that a domain is verified, send from it. - **[Sending over SMTP](/docs/sending-over-smtp):** send through the SMTP transport instead. - **[Tracking domains](/docs/tracking-domains):** add branded open and click tracking. --- Source: /docs/tracking-domains # Tracking domains Open and click tracking serves links and an open image from a tracking domain. Pointing that domain at a branded subdomain of your own keeps tracked links on your brand, and is what turns tracking on for a sending domain. ## Why tracking has its own domain Click tracking rewrites the links in a message to pass through Anypost, and open tracking adds an image that loads from Anypost. Both are URLs, and the domain in those URLs is visible to the recipient and to spam filters. A branded tracking subdomain, such as `track.example.com`, keeps those URLs on a domain the recipient recognizes as yours. It is also a precondition: tracking does not run for a sending domain until its tracking subdomain is verified, no matter what the per-message `tracking` field or the domain defaults say. See [**Open & click tracking**](/docs/tracking). ## Configure tracking on a domain Tracking is a property of a domain you have already added. `PATCH /v1/domains/{id}` sets it: turn on `opens_enabled` or `clicks_enabled`, and name the `subdomain`. ```bash curl -X PATCH https://api.anypost.com/v1/domains/domain_550e8400... \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tracking": { "opens_enabled": true, "clicks_enabled": true, "subdomain": "track" } }' ``` ```ts const domain = await anypost.domains.update("domain_550e8400-...", { tracking: { opens_enabled: true, clicks_enabled: true, subdomain: "track", }, }); ``` ```python domain = client.domains.update("domain_550e8400-...", { "tracking": { "opens_enabled": True, "clicks_enabled": True, "subdomain": "track", }, }) ``` ```php $domain = $client->domains->update("domain_550e8400-...", [ "tracking" => [ "opens_enabled" => true, "clicks_enabled" => true, "subdomain" => "track", ], ]); ``` ```ruby domain = client.domains.update("domain_550e8400-...", tracking: { opens_enabled: true, clicks_enabled: true, subdomain: "track", }) ``` ```rust let domain = client.domains.update("domain_550e8400-...", serde_json::json!({ "tracking": { "opens_enabled": true, "clicks_enabled": true, "subdomain": "track" } })).await?; ``` ```go domain, _ := client.Domains.Update(ctx, "domain_550e8400-...", &anypost.DomainUpdateParams{ Tracking: anypost.DomainTrackingParams{ OpensEnabled: anypost.Bool(true), ClicksEnabled: anypost.Bool(true), Subdomain: anypost.String("track"), }, }) ``` ```java Domain domain = client.domains.update("domain_550e8400-...", DomainUpdateParams.of(DomainTrackingParams.builder() .opensEnabled(true) .clicksEnabled(true) .subdomain("track") .build())); ``` ```csharp var domain = await client.Domains.UpdateAsync("domain_550e8400-...", new DomainUpdateParams { Tracking = new DomainTrackingParams { OpensEnabled = true, ClicksEnabled = true, Subdomain = "track", }, }); ``` `subdomain` is the prefix in front of the sending domain. With `track` on a domain named `example.com`, the tracking host is `track.example.com`. It is required whenever either flag is on, and must be unique within your team. `opens_enabled` and `clicks_enabled` set the domain's default tracking behavior. A per-message `tracking` field overrides that default for one send; see [**Open & click tracking**](/docs/tracking). ## Publish the tracking record The `PATCH` response is the domain, with a record to publish under `tracking.dns_records`: a single `CNAME` from your tracking host to an Anypost tracking host. Publish that CNAME at your DNS provider exactly as given. It is one record, separate from the mail-flow records that verify the domain for sending. The mechanics of publishing a CNAME are the same as for those records; see [**Domains & DNS setup**](/docs/domains). ## Verify the tracking domain Once the CNAME is published, call the same verify endpoint the sending domain uses: ```bash curl -X POST https://api.anypost.com/v1/domains/domain_550e8400.../verify \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` ```ts const domain = await anypost.domains.verify("domain_550e8400-..."); console.log(domain.tracking.status); // disabled | pending | verified ``` ```python domain = client.domains.verify("domain_550e8400-...") print(domain["tracking"]["status"]) # disabled | pending | verified ``` ```php $domain = $client->domains->verify("domain_550e8400-..."); echo $domain->tracking->status; // disabled | pending | verified ``` ```ruby domain = client.domains.verify("domain_550e8400-...") puts domain.tracking.status # disabled | pending | verified ``` ```rust let domain = client.domains.verify("domain_550e8400-...").await?; println!("{}", domain["tracking"]["status"]); // disabled | pending | verified ``` ```go domain, _ := client.Domains.Verify(ctx, "domain_550e8400-...") fmt.Println(domain.Tracking.Status) // disabled | pending | verified ``` ```java Domain domain = client.domains.verify("domain_550e8400-..."); System.out.println(domain.tracking().status()); // disabled | pending | verified ``` ```csharp var domain = await client.Domains.VerifyAsync("domain_550e8400-..."); Console.WriteLine(domain.Tracking.Status); // disabled | pending | verified ``` The response is the domain. Read `tracking.status`: | `tracking.status` | Meaning | |---|---| | `disabled` | Neither tracking flag is on. | | `pending` | Tracking is on, but the CNAME has not yet been observed resolving. | | `verified` | The CNAME resolves. Tracking is live for this domain. | While `pending`, `tracking.verification_failure` reports the reason: a `code` of `tracking_cname_missing` if the record is not found, or `tracking_cname_mismatch` if it points somewhere unexpected. DNS changes take time to propagate; poll the verify endpoint until `tracking.status` flips. ## Tracking is independent of sending A tracking-domain problem never affects mail flow. The domain's sending `status`, its mail-flow records, and your ability to send are all separate. If the tracking CNAME is missing or wrong, messages still send normally; they simply go out without open or click tracking. This independence is why tracking is safe to add to a domain already in production: a misconfigured tracking CNAME costs you tracking data, never delivery. ## Change or turn off tracking Toggling a flag or changing the `subdomain` clears the existing tracking verification. The new tracking host has its own CNAME, so you must publish the record from the new `tracking.dns_records` and verify again. To turn tracking off, `PATCH` the domain with both flags `false` and `subdomain` set to `null`. `tracking.status` returns to `disabled`, and you can retire the CNAME at your DNS provider. ## Where to go next - **[Open & click tracking](/docs/tracking):** turn tracking on per message once the domain is verified. - **[Domains & DNS setup](/docs/domains):** add and verify the sending domain itself. --- Source: /docs/send-email # Send a single email `POST /v1/email` sends one message. It can go to a single recipient or to several who all share one envelope. ## The request ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "reply_to": "support@example.com", "subject": "Welcome to Acme", "html": "

Glad you are here.

", "text": "Glad you are here." }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], reply_to: "support@example.com", subject: "Welcome to Acme", html: "

Glad you are here.

", text: "Glad you are here.", }); ``` ```python client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "reply_to": "support@example.com", "subject": "Welcome to Acme", "html": "

Glad you are here.

", "text": "Glad you are here.", }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "reply_to" => "support@example.com", "subject" => "Welcome to Acme", "html" => "

Glad you are here.

", "text" => "Glad you are here.", ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], reply_to: "support@example.com", subject: "Welcome to Acme", html: "

Glad you are here.

", text: "Glad you are here." ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .reply_to("support@example.com") .subject("Welcome to Acme") .html("

Glad you are here.

") .text("Glad you are here."), ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, ReplyTo: []string{"support@example.com"}, Subject: "Welcome to Acme", HTML: "

Glad you are here.

", Text: "Glad you are here.", }) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .replyTo("support@example.com") .subject("Welcome to Acme") .html("

Glad you are here.

") .text("Glad you are here.") .build()); ``` ```csharp await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], ReplyTo = ["support@example.com"], Subject = "Welcome to Acme", Html = "

Glad you are here.

", Text = "Glad you are here.", }); ``` A `202` response means the message was accepted. ## Fields | Field | Required | Description | |---|---|---| | `from` | Yes | Sender address, on a verified domain. | | `to` | Yes | Array of recipient addresses, 1 to 50. | | `cc` | No | Array of copied recipients. Counts toward the 50-recipient total. | | `bcc` | No | Array of blind-copied recipients. Counts toward the 50-recipient total. | | `reply_to` | No | One address, or an array of up to 10, that replies are directed to. | | `subject` | Yes | Subject line, 1 to 998 characters. | | `text` | One of | Plain-text body, up to 1,000,000 characters. | | `html` | One of | HTML body, up to 1,000,000 characters. | Provide `subject` and at least one of `text` or `html`. Sending with a template can supply all three instead; see [**Sending with templates**](/docs/templates). ## Address format `from` and every recipient field accept two forms: - A bare address: `hello@example.com`. - A name and address: `Acme `. Quote a name that contains a comma: `"Acme, Inc." `. A display name appears in the message headers only. The delivery envelope always uses the bare address. CR and LF characters are stripped from addresses before the message is built. The domain in `from` must be verified for your team. A send from an unverified domain is rejected. See [**Domains & DNS setup**](/docs/domains). ## Recipients `to`, `cc`, and `bcc` together may name at most 50 recipients. Everyone in `to` and `cc` is visible to the others in the message headers; `bcc` recipients are not. All recipients of one `POST /v1/email` share a single envelope: one message, delivered to each address. To send distinct messages, with different subjects or bodies, in one request, use [**Batch sending**](/docs/batch-sending) instead. ## The response ```json { "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d", "created_at": "2026-04-30T12:00:00.123000Z" } ``` The `202` and this `id` mean Anypost accepted the message, not that it was delivered. Delivery, bounces, and engagement arrive later as events; see [**Webhooks**](/docs/webhooks). Use the `id` to correlate every event to this message. An invalid request returns `400` or `422` with a `validation_error` naming the fields at fault. See [**API conventions**](/docs/api-conventions) for the error shape. ## Idempotency A retry is made safe by an `Idempotency-Key` header. If the first request already reached Anypost, the replay returns that original response verbatim and the message is not sent again. A network failure that leaves the `202` in doubt costs you nothing to retry. ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Welcome to Acme", "html": "

Glad you are here.

" }' ``` ```ts const { id } = await anypost.email.send( { from: "Acme ", to: ["alex@customer.com"], subject: "Welcome to Acme", html: "

Glad you are here.

", }, { idempotencyKey: "9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e" }, ); ``` ```python client.email.send( { "from": "Acme ", "to": ["alex@customer.com"], "subject": "Welcome to Acme", "html": "

Glad you are here.

", }, idempotency_key="9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e", ) ``` ```php $client->email->send( [ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "Welcome to Acme", "html" => "

Glad you are here.

", ], "9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e", ); ``` ```ruby client.email.send( { from: "Acme ", to: ["alex@customer.com"], subject: "Welcome to Acme", html: "

Glad you are here.

" }, "9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e" ) ``` ```rust client.email.send_with_idempotency_key( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("Welcome to Acme") .html("

Glad you are here.

"), "9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e", ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "Welcome to Acme", HTML: "

Glad you are here.

", }, anypost.WithIdempotencyKey("9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e")) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("Welcome to Acme") .html("

Glad you are here.

") .build(), RequestOptions.idempotencyKey("9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e")); ``` ```csharp await client.Email.SendAsync( new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "Welcome to Acme", Html = "

Glad you are here.

", }, new RequestOptions { IdempotencyKey = "9f8c2b1e-4a6d-4f1e-8c3a-2b1e4a6d4f1e" }); ``` The SDK generates a key for any send when you omit one, so its built-in retries cannot send twice; pass `idempotencyKey` to set your own. A stored response is kept for 24 hours. Reusing a key with a different body is rejected with `422` `idempotency_mismatch`. See [**API conventions**](/docs/api-conventions) for the full key rules. ## Going further A send can carry much more than a subject and body: - **[Sending with templates](/docs/templates):** reuse stored content with `template_id`. - **[Variables & personalization](/docs/variables):** render per-recipient values into the body. - **[Attachments & inline images](/docs/attachments):** attach files or embed inline images. - **[Headers & custom headers](/docs/headers):** attach application-specific headers. - **[Tags, topics & campaigns](/docs/tags-topics-campaigns):** label a message so its events can be filtered. - **[Open & click tracking](/docs/tracking):** measure engagement. - **[Unsubscribe handling](/docs/unsubscribe):** add one-click unsubscribe headers. - **[Batch sending](/docs/batch-sending):** send many independent messages in one request. --- Source: /docs/sending-over-smtp # Sending over SMTP Anypost accepts mail over SMTP as well as over the HTTP API. If your application, or off-the-shelf software you run, already sends through an SMTP server, point it at Anypost and you get the same authentication, delivery, and event reporting without writing code. ## When to use SMTP The HTTP API is the richer interface and what most of these docs describe. Reach for SMTP when: - An existing app already speaks SMTP and you would rather configure it than rewrite its mail layer. - You run software whose mail transport you do not control. - A library or framework you already use makes SMTP the path of least resistance. Both transports send from the same verified domains, authenticate with the same API keys, and produce the same events. ## Connection settings | Setting | Value | |---|---| | Host | `smtp.anypost.com` | | Ports | `587`, `2587`, or `25` | | Encryption | STARTTLS (required) | | Username | `anypost` | | Password | your API key secret (`ap_...`) | All three ports behave identically: pick whichever your network allows. `587` is the standard submission port and the one to use by default. `2587` is an alternative for networks that block `587`. `25` is also accepted, but many hosting providers and ISPs block outbound traffic on it, so treat it as a last resort rather than a default. STARTTLS is mandatory on every port. The connection opens in plaintext and must upgrade to TLS before authenticating. There is no implicit-TLS port and no unencrypted submission path: Anypost refuses to authenticate, and refuses mail, on a session that has not upgraded to TLS. ## Authentication The username is always the literal string `anypost`. The password is an API key secret, the same `ap_...` value used as the bearer token on the HTTP API. A single key authenticates both transports. See [**Authentication**](/docs/authentication) for creating, scoping, and rotating keys. Use a `send_only` key for an application that only sends. SMTP submission needs no other permission. Authentication failures are reported in the SMTP exchange: a bad key is rejected at the `AUTH` step, and a session that never authenticates is refused at `MAIL FROM`. Repeated failures from one address are temporarily blocked. ## Sender addresses An SMTP submission carries two sender addresses, and Anypost checks both: - The **envelope sender**, set by the SMTP `MAIL FROM` command. It is where delivery failures are returned and is not shown to the recipient. Most SMTP clients set it automatically to match the `From` header. - The **`From` header**, the address the recipient sees in their mail client. The domain of each must be both verified for your team and permitted for the API key: - The domain must be verified, exactly as on the HTTP API. See [**Domains & DNS setup**](/docs/domains). - The key must be allowed to use it. A key with an `allowed_domains` restriction can only send from the domains it lists; an unrestricted key may use any of the team's verified domains. See [**Authentication**](/docs/authentication). The envelope sender and the `From` header do not have to share a domain, but each is checked on its own. A submission is rejected if either one is on a domain that is unverified or not permitted for the key. Checking the `From` header separately is what stops an authenticated sender from passing the envelope check with one domain and then showing the recipient a `From` address on another. ## Message size A submitted message may be up to 5 MB, the same ceiling the HTTP API enforces. This is the size of the full MIME message, including encoded attachments. Anypost advertises the limit over the `SIZE` extension, so a well-behaved client can refuse an oversized message before sending it. 5 MB is ample for transactional and marketing email. For a send that would strain it, host large files elsewhere and link to them rather than attaching them. ## A test submission Any SMTP client works. This example uses [`swaks`](https://www.jetmore.org/john/code/swaks/), a command-line SMTP tester: ```bash swaks \ --server smtp.anypost.com --port 587 --tls \ --auth-user anypost --auth-password "$ANYPOST_API_KEY" \ --from you@yourdomain.com \ --to someone@example.com \ --header "Subject: Hello from Anypost" \ --body "Hello, inbox!" ``` `--tls` performs the required STARTTLS upgrade. A `250 OK` on the `DATA` command means the message was accepted. ## Anypost headers Standard MIME covers most of a message: `From`, `To`, `Cc`, `Bcc`, `Subject`, `Reply-To`, the text and HTML parts, attachments, and inline images are all ordinary parts of the message you submit. Anypost's send-time correlation dimensions have no MIME equivalent, so over SMTP you set them with headers. Anypost reads and then strips these headers, so recipients never see them: | Header | Sets | Value | |---|---|---| | `X-Anypost-Tags` | Tags | A JSON array of strings, for example `["welcome","onboarding"]`. | | `X-Anypost-Tag` | One tag | A single tag value. Repeat the header for several. Cannot be combined with `X-Anypost-Tags`. | | `X-Anypost-Topic` | Topic | A single topic string. | | `X-Campaign` | Campaign | A single campaign string. | Each header may appear at most once per message (apart from repeated `X-Anypost-Tag`). A malformed value is rejected in the SMTP exchange rather than sent. See [**Tags, topics & campaigns**](/docs/tags-topics-campaigns) for what these dimensions do. ## The message ID The `250 OK` reply to the `DATA` command carries an `ids=` field: one `email_` ID per recipient, space-separated. ``` 250 OK ids=email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d ``` Each ID identifies one recipient's copy of the message, and every event for that copy references it. Capture the `ids=` field from the `DATA` response if you want to correlate events back to a submission. ## What differs from the HTTP API SMTP reaches the same delivery and reporting, with a few interface differences: - **Idempotency keys** are an HTTP-only feature. SMTP has no equivalent; a resubmitted message is a new send. - **Batch sending** is HTTP-only. Over SMTP, submit each message separately on the same connection. - **Templates** are an HTTP-API feature. An SMTP submission is an already-rendered message: it cannot reference a stored template or carry per-recipient variables. - The remaining correlation dimensions (`tags`, `topic`, `campaign`) are set through **headers**, as above, rather than as JSON fields. For anything beyond a straightforward send, the HTTP API is the fuller interface. See [**Send a single email**](/docs/send-email). ## Where to go next - **[Authentication](/docs/authentication):** create and scope the key SMTP authenticates with. - **[Domains & DNS setup](/docs/domains):** verify the domain you send from. - **[Webhooks](/docs/webhooks):** receive delivery and engagement events. --- Source: /docs/markdown # Send email as Markdown Write the body of a message in Markdown and the Anypost TypeScript SDK renders it to email-safe HTML and a plain-text alternative for you. You skip the table layouts and inline-style workarounds that hand-written HTML email demands. **Note.** Markdown rendering is a feature of the TypeScript SDK only. It runs on [`emailmd`](https://www.emailmd.dev), which depends on Node 20+ and does not run in edge or browser runtimes. Every other way to send accepts the already-rendered `html`/`text` you produce however you like. ## Why Markdown HTML email is its own dialect. Layouts are built from nested tables, styles have to be inlined, and every client renders them a little differently. Writing that by hand, or keeping a templating pipeline that emits it, is the tax of sending good-looking mail. Markdown moves that work to render time. You write prose; `emailmd` produces a complete, client-tested HTML document and a matching plain-text part in one step. The plain-text body is generated automatically, so the message degrades cleanly where HTML is stripped. ## Install `emailmd` is an optional peer dependency. Install it alongside the SDK only if you send Markdown: ```bash npm install anypost emailmd ``` A Markdown send without `emailmd` installed throws a clear error telling you to add it. Nothing else in the SDK requires it. ## Send Markdown Pass `markdown` in place of `html` and `text`. The SDK renders it before the request and sends the result: ```ts import { Anypost } from "anypost"; const anypost = new Anypost("ap_your_api_key"); await anypost.email.send({ from: "Acme ", to: ["someone@example.com"], subject: "Welcome aboard", markdown: [ "# Welcome aboard", "", "Thanks for signing up. Your workspace is ready.", "", "[Open your dashboard](https://app.example.com)", ].join("\n"), }); ``` `markdown` cannot be combined with `html` or `text`; choose one content source. Everything else on the message — `from`, `to`, attachments, tags, headers — behaves exactly as it does on a normal send. See [**Send a single email**](/docs/send-email). ## Preheader and frontmatter A YAML frontmatter block sets the preheader, the preview line shown in the inbox before a message is opened, and carries any custom keys you want to read back: ```ts await anypost.email.send({ from: "you@yourdomain.com", to: ["someone@example.com"], subject: "Your March report", markdown: [ "---", "preheader: 12% more opens than February", "---", "# Your March report", "", "The numbers are in.", ].join("\n"), }); ``` ## Theme it Pass render options through `markdownOptions` to set a brand color, fonts, and the rest of the theme: ```ts await anypost.email.send({ from: "you@yourdomain.com", to: ["someone@example.com"], subject: "Welcome aboard", markdown: "# Welcome aboard\n\nGlad you are here.", markdownOptions: { theme: { brandColor: "#4f46e5" }, }, }); ``` In a [batch](/docs/batch-sending), set `markdownOptions` once on `defaults` to apply one theme to every entry's Markdown: ```ts await anypost.email.sendBatch({ defaults: { markdownOptions: { theme: { brandColor: "#4f46e5" } } }, emails: [ { from, to: ["a@example.com"], subject: "Hi A", markdown: "# Hi A" }, { from, to: ["b@example.com"], subject: "Hi B", markdown: "# Hi B" }, ], }); ``` The full theme shape, the wrapper and font options, validation levels, and the Markdown extensions `emailmd` supports are covered in the [**emailmd docs**](https://www.emailmd.dev/docs). ## Render once, reuse To render the same Markdown for many recipients, or to inspect the output before sending, call `renderMarkdown` directly. It returns the HTML, the plain-text alternative, and the extracted frontmatter: ```ts import { renderMarkdown } from "anypost"; const { html, text, meta } = await renderMarkdown("# Hi\n\nOne render, many sends."); console.log(meta.preheader); // pass html/text into as many sends as you like ``` ## Where to go next - **[Send a single email](/docs/send-email):** the full send request and every field. - **[Batch sending](/docs/batch-sending):** send up to 100 messages in one request. - **[emailmd docs](https://www.emailmd.dev/docs):** themes, fonts, and the Markdown it renders. --- Source: /docs/templates # Sending with templates A template is stored email content you send by ID. Instead of inlining the subject and body on every `POST /v1/email`, you reference a template and let Anypost supply them. ## How templates work A template has a `name`, a `subject`, and a body. It also has a lifecycle worth understanding before you create one. Every template keeps two slots: a **published** version and an optional **draft**. Sends always use the published version. Edits land in the draft and change nothing until you publish them. A newly created template has only a draft, so it has no sendable content until its first publish. This means a template is only usable for sending once it has been published at least once, and editing a live template never disturbs mail already going out under it. ## Authoring format A template body is authored one of two ways, set by its `kind` when you create the template: - **`markdown`**, the recommended format. You write the body in [emailmd](https://www.emailmd.dev/), a markdown dialect built for email. A few lines of markdown compile to a responsive, well-styled message, with no HTML boilerplate to write or maintain. The dashboard's markdown editor includes an AI assistant that drafts and revises emailmd from a plain description. - **`html`**, a raw HTML body. Use it when you need full control of the markup, or are bringing an existing HTML design across. `kind` is fixed when the template is created and cannot be changed later. To switch formats, create a new template. Whichever format you pick, the plain-text body is derived automatically: Anypost generates a plain-text alternative so recipients whose mail client cannot render HTML still get a readable message. You never supply `text` yourself. ## Create a template Create a template in the dashboard, or with `POST /v1/templates`: ```bash curl https://api.anypost.com/v1/templates \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome email", "kind": "markdown", "subject": "Welcome to Acme, {{first_name}}", "markdown": "# Welcome\n\nHi {{first_name}}, your account is ready." }' ``` ```ts const template = await anypost.templates.create({ name: "Welcome email", kind: "markdown", subject: "Welcome to Acme, {{first_name}}", markdown: "# Welcome\n\nHi {{first_name}}, your account is ready.", }); ``` ```python template = client.templates.create({ "name": "Welcome email", "kind": "markdown", "subject": "Welcome to Acme, {{first_name}}", "markdown": "# Welcome\n\nHi {{first_name}}, your account is ready.", }) ``` ```php $template = $client->templates->create([ "name" => "Welcome email", "kind" => "markdown", "subject" => "Welcome to Acme, {{first_name}}", "markdown" => "# Welcome\n\nHi {{first_name}}, your account is ready.", ]); ``` ```ruby template = client.templates.create( name: "Welcome email", kind: "markdown", subject: "Welcome to Acme, {{first_name}}", markdown: "# Welcome\n\nHi {{first_name}}, your account is ready." ) ``` ```rust let template = client.templates.create(serde_json::json!({ "name": "Welcome email", "kind": "markdown", "subject": "Welcome to Acme, {{first_name}}", "markdown": "# Welcome\n\nHi {{first_name}}, your account is ready." })).await?; ``` ```go template, _ := client.Templates.Create(ctx, &anypost.TemplateCreateParams{ Name: "Welcome email", Kind: anypost.TemplateKindMarkdown, Subject: anypost.String("Welcome to Acme, {{first_name}}"), Markdown: anypost.String("# Welcome\n\nHi {{first_name}}, your account is ready."), }) ``` ```java Template template = client.templates.create(TemplateCreateParams.builder() .name("Welcome email") .kind(TemplateKind.MARKDOWN) .subject("Welcome to Acme, {{first_name}}") .markdown("# Welcome\n\nHi {{first_name}}, your account is ready.") .build()); ``` ```csharp var template = await client.Templates.CreateAsync(new TemplateCreateParams { Name = "Welcome email", Kind = TemplateKind.Markdown, Subject = "Welcome to Acme, {{first_name}}", Markdown = "# Welcome\n\nHi {{first_name}}, your account is ready.", }); ``` For a raw-HTML template, pass `"kind": "html"` with an `html` body instead. The `name` must be unique within your team. The `{{first_name}}` placeholders are optional; see [Personalize with placeholders](#personalize-with-placeholders) below. The response is the new template: ```json { "id": "template_550e8400-e29b-41d4-a716-446655440000", "name": "Welcome email", "kind": "markdown", "has_draft": true, "published_at": null } ``` The subject and body you supplied were stored as the template's **draft**. `published_at` is `null`, so the template cannot be used to send yet. Publish it first. ## Publish a template Publishing promotes the draft into the published slot: ```bash curl -X POST https://api.anypost.com/v1/templates/template_550e8400.../publish \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` ```ts await anypost.templates.publish(template.id); ``` ```python client.templates.publish(template["id"]) ``` ```php $client->templates->publish($template->id); ``` ```ruby client.templates.publish(template.id) ``` ```rust client.templates.publish(template["id"].as_str().unwrap()).await?; ``` ```go client.Templates.Publish(ctx, template.ID) ``` ```java client.templates.publish(template.id()); ``` ```csharp await client.Templates.PublishAsync(template.Id); ``` The response shows `published_at` set and `has_draft` back to `false`. The template is now sendable. A template with no draft to publish returns `422`. ## Send with a template Send with a template by passing `template_id` to `POST /v1/email` in place of a body: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "template_id": "template_550e8400-e29b-41d4-a716-446655440000", "variables": { "first_name": "Alex" } }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], template_id: "template_550e8400-e29b-41d4-a716-446655440000", variables: { first_name: "Alex" }, }); ``` ```python client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "template_id": "template_550e8400-e29b-41d4-a716-446655440000", "variables": {"first_name": "Alex"}, }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "template_id" => "template_550e8400-e29b-41d4-a716-446655440000", "variables" => ["first_name" => "Alex"], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], template_id: "template_550e8400-e29b-41d4-a716-446655440000", variables: {first_name: "Alex"} ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .template_id("template_550e8400-e29b-41d4-a716-446655440000") .variables(serde_json::json!({ "first_name": "Alex" })), ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, TemplateID: "template_550e8400-e29b-41d4-a716-446655440000", Variables: map[string]any{"first_name": "Alex"}, }) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .templateId("template_550e8400-e29b-41d4-a716-446655440000") .variables(Map.of("first_name", "Alex")) .build()); ``` ```csharp await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], TemplateId = "template_550e8400-e29b-41d4-a716-446655440000", Variables = new Dictionary { ["first_name"] = "Alex" }, }); ``` The subject and both body parts come from the template's published content. The response and behavior are otherwise identical to any other send; see [**Send a single email**](/docs/send-email). Two rules govern a templated send: - **A template send carries no inline body.** `template_id` cannot be combined with `text` or `html`. A request with both is rejected with `422`: a send has one body source, not two. - **`subject` is optional, and overrides the template.** Omit it and the template's subject is used. Supply it and your value wins for that send, which is how you vary the subject without forking the template. ## Personalize with placeholders A template body and subject can contain `{{ placeholder }}` markers. Pass a `variables` object on the send and Anypost substitutes the values before delivery, so one template serves many recipients. ```json "variables": { "first_name": "Alex", "plan": "Pro" } ``` A send with no `variables` is delivered exactly as stored, with no substitution, so a template whose literal text happens to contain `{{ }}` is never mangled. Placeholder syntax, nesting, and the size limits are covered in [**Variables & personalization**](/docs/variables). ## Edit a published template Editing is a two-step cycle: update the draft, then publish it. ```bash curl -X PATCH https://api.anypost.com/v1/templates/template_550e8400.../draft \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "subject": "Welcome aboard, {{first_name}}", "markdown": "# Welcome aboard\n\nHi {{first_name}}, welcome to Acme." }' ``` ```ts await anypost.templates.updateDraft("template_550e8400-...", { subject: "Welcome aboard, {{first_name}}", markdown: "# Welcome aboard\n\nHi {{first_name}}, welcome to Acme.", }); ``` ```python client.templates.update_draft("template_550e8400-...", { "subject": "Welcome aboard, {{first_name}}", "markdown": "# Welcome aboard\n\nHi {{first_name}}, welcome to Acme.", }) ``` ```php $client->templates->updateDraft("template_550e8400-...", [ "subject" => "Welcome aboard, {{first_name}}", "markdown" => "# Welcome aboard\n\nHi {{first_name}}, welcome to Acme.", ]); ``` ```ruby client.templates.update_draft("template_550e8400-...", subject: "Welcome aboard, {{first_name}}", markdown: "# Welcome aboard\n\nHi {{first_name}}, welcome to Acme." ) ``` ```rust client.templates.update_draft("template_550e8400-...", serde_json::json!({ "subject": "Welcome aboard, {{first_name}}", "markdown": "# Welcome aboard\n\nHi {{first_name}}, welcome to Acme." })).await?; ``` ```go client.Templates.UpdateDraft(ctx, "template_550e8400-...", &anypost.TemplateDraftParams{ Subject: anypost.String("Welcome aboard, {{first_name}}"), Markdown: anypost.String("# Welcome aboard\n\nHi {{first_name}}, welcome to Acme."), }) ``` ```java client.templates.updateDraft("template_550e8400-...", TemplateDraftParams.builder() .subject("Welcome aboard, {{first_name}}") .markdown("# Welcome aboard\n\nHi {{first_name}}, welcome to Acme.") .build()); ``` ```csharp await client.Templates.UpdateDraftAsync("template_550e8400-...", new TemplateDraftParams { Subject = "Welcome aboard, {{first_name}}", Markdown = "# Welcome aboard\n\nHi {{first_name}}, welcome to Acme.", }); ``` This creates the draft if there is none and updates it otherwise. The published version is untouched, so sends keep using the old content until you run `POST /v1/templates/{id}/publish` again. Discard a draft you do not want with `DELETE /v1/templates/{id}/draft`. To rename a template, `PATCH /v1/templates/{id}` with a new `name`. Body content cannot be changed on that path: it is draft-versioned, and the draft endpoint is the only way to change it. ## Managing templates | Operation | Endpoint | |---|---| | List templates | `GET /v1/templates` | | Retrieve one | `GET /v1/templates/{id}` | | View the draft | `GET /v1/templates/{id}/draft` | | Duplicate | `POST /v1/templates/{id}/duplicate` | | Rename | `PATCH /v1/templates/{id}` | | Delete | `DELETE /v1/templates/{id}` | A duplicate starts as a new, unpublished template seeded from the source's content, so it must be published before use. A team may hold up to 500 templates. ## When a templated send fails A send that references a template can fail validation before any mail is queued. Each returns `400` with a `validation_error`; see [**API conventions**](/docs/api-conventions) for the error shape. | Problem | Cause | |---|---| | Template not found | No template with that ID exists for your team. | | Template has no published content | The template exists but was never published. Publish a draft first. | | `subject` is required | Neither the request nor the template supplied a subject. | ## Where to go next - **[Variables & personalization](/docs/variables):** placeholder syntax and per-recipient values. - **[Batch sending](/docs/batch-sending):** send one template to many recipients in a single request. - **[Send a single email](/docs/send-email):** the full `POST /v1/email` request. --- Source: /docs/variables # Variables & personalization Pass a `variables` object on a send and Anypost fills its values into the subject, body, and headers before delivery. ## The variables field `variables` is an optional object on `POST /v1/email`. Write `{{ key }}` markers anywhere in the message, and Anypost replaces each one with the matching value from `variables`: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Your {{plan}} plan is ready", "html": "

Welcome to the {{plan}} plan. You have {{seats}} seats.

", "variables": { "plan": "Pro", "seats": 5 } }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], subject: "Your {{plan}} plan is ready", html: "

Welcome to the {{plan}} plan. You have {{seats}} seats.

", variables: { plan: "Pro", seats: 5 }, }); ``` ```python client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Your {{plan}} plan is ready", "html": "

Welcome to the {{plan}} plan. You have {{seats}} seats.

", "variables": {"plan": "Pro", "seats": 5}, }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "Your {{plan}} plan is ready", "html" => "

Welcome to the {{plan}} plan. You have {{seats}} seats.

", "variables" => ["plan" => "Pro", "seats" => 5], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], subject: "Your {{plan}} plan is ready", html: "

Welcome to the {{plan}} plan. You have {{seats}} seats.

", variables: {plan: "Pro", seats: 5} ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("Your {{plan}} plan is ready") .html("

Welcome to the {{plan}} plan. You have {{seats}} seats.

") .variables(serde_json::json!({ "plan": "Pro", "seats": 5 })), ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "Your {{plan}} plan is ready", HTML: "

Welcome to the {{plan}} plan. You have {{seats}} seats.

", Variables: map[string]any{"plan": "Pro", "seats": 5}, }) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("Your {{plan}} plan is ready") .html("

Welcome to the {{plan}} plan. You have {{seats}} seats.

") .variables(Map.of("plan", "Pro", "seats", 5)) .build()); ``` ```csharp await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "Your {{plan}} plan is ready", Html = "

Welcome to the {{plan}} plan. You have {{seats}} seats.

", Variables = new Dictionary { ["plan"] = "Pro", ["seats"] = 5 }, }); ``` That send arrives with the subject "Your Pro plan is ready". `variables` works the same way whether the body is inline, as above, or comes from a template; see [**Sending with templates**](/docs/templates). ## Placeholder syntax Markers use [Handlebars](https://handlebarsjs.com/): a key wrapped in double braces. - **Simple value:** `{{plan}}` resolves to `variables.plan`. - **Nested value:** `{{order.total}}` resolves to `variables.order.total`. Objects may nest up to four levels deep. - **Missing key:** a marker whose key is absent from `variables` renders as an empty string. It is not an error, and the marker is not left in the message. A value is HTML-escaped by default, so one containing `<` or `&` is safe to place in an HTML body. To insert a value as raw markup, use triple braces: `{{{ banner_html }}}`. Reach for the triple-brace form only when the value is trusted HTML you intend to render. ## Conditionals and loops The subject and body support Handlebars block helpers, so a message can show a section conditionally or repeat one over a list. The data still comes entirely from `variables`; the blocks only decide which parts of the message render. Include a section conditionally with `{{#if}}`, optionally with an `{{else}}`: ```handlebars {{#if order.gift}}

A gift receipt is enclosed.

{{else}}

Thanks for your order.

{{/if}} ``` `{{#unless}}` is the inverse: it renders only when the value is falsy or absent. Iterate over an array with `{{#each}}`. Inside the block, `{{this}}` is the current item and `{{@index}}` is its zero-based position: ```handlebars
    {{#each items}}
  • {{@index}}. {{this}}
  • {{/each}}
``` When the array holds objects, reference each object's keys directly inside the block: `{{#each items}}{{name}}: {{price}}{{/each}}`. `{{#with}}` shifts the context into a nested object so you can drop the prefix: ```handlebars {{#with order}}Order {{id}}, {{total}} total.{{/with}} ``` To branch on a comparison rather than on presence alone, comparison helpers such as `eq` and `gt` are available as sub-expressions: `{{#if (eq plan "Pro")}}...{{/if}}`. ## Per-recipient values: name and email Two markers are filled from the recipient and need no entry in `variables`: | Marker | Value | |---|---| | `{{name}}` | The recipient's display name, or an empty string if the address has none. | | `{{email}}` | The recipient's email address. | They differ per recipient within a single send, so one `{{name}}` in the body greets everyone in `to` by their own name. If `variables` also defines `name` or `email`, the recipient binding takes precedence. ## The reserved unsubscribe_url variable A send with `unsubscribe.mode: "generate"` fills one more marker, `{{unsubscribe_url}}`, with that recipient's one-click unsubscribe link — the same URL Anypost puts in the `List-Unsubscribe` header. You do not add `unsubscribe_url` to `variables`; if you do, your value overrides the generated link. It is the one marker that renders even on a send with no `variables` object (see [Substitution is off unless you opt in](#substitution-is-off-unless-you-opt-in) below). See [**Unsubscribe handling**](/docs/unsubscribe) for the full one-click setup. ## Where substitution applies When `variables` is present, Anypost renders markers in: - the `subject`, - the `html` body, - the `text` body, - the values of any custom `headers`. Marker-like text in attachments or in addresses is left alone. ## Substitution is off unless you opt in Anypost substitutes only when a request carries a non-empty `variables` object. Send with no `variables`, or with `variables: {}`, and the message goes out exactly as written: any `{{ ... }}` in the body stays as literal text. This protects a body that carries its own braces, such as a code sample or output from another templating system. It also means the `{{name}}` and `{{email}}` bindings do nothing unless the request includes a non-empty `variables` object. One case overrides this: a `mode: "generate"` unsubscribe send. Anypost has to render it to fill `{{unsubscribe_url}}`, so the templating step runs even with no `variables` — and any other `{{ ... }}` in that message is evaluated along with it. See [**Unsubscribe handling**](/docs/unsubscribe). The opposite case is the one to watch. Once `variables` is non-empty, every `{{ ... }}` in the rendered fields is treated as a marker, including any you did not intend as one. ## Limits | Limit | Value | |---|---| | Total size | 64,000 bytes, JSON-encoded | | Keys per object level | 100 | | Nesting depth | 4 | | Value types | Any JSON-serializable value | A `variables` object that exceeds a limit is rejected with `422` `validation_error`; see [**API conventions**](/docs/api-conventions). ## Different values for each recipient Within one `POST /v1/email`, every recipient shares the same `variables` object. Only `{{name}}` and `{{email}}` change from one recipient to the next. To give each recipient genuinely different values, such as an order number or an account balance, send a batch. Every entry in a batch request carries its own `variables`. See [**Batch sending**](/docs/batch-sending). ## Where to go next - **[Sending with templates](/docs/templates):** store the body once and personalize per send. - **[Batch sending](/docs/batch-sending):** distinct `variables` for each recipient in one request. - **[Send a single email](/docs/send-email):** the full `POST /v1/email` request. --- Source: /docs/batch-sending # Batch sending `POST /v1/email/batch` submits up to 100 independent messages in a single request. Each one has its own recipients, subject, and body. Reach for it when you have many distinct messages to send, not one message going to many recipients. A batch is not the same as a multi-recipient send. `POST /v1/email` delivers one message to a shared recipient list; see [**Send a single email**](/docs/send-email). A batch delivers a different message to each entry. ## The request The body is an `emails` array of 1 to 100 entries. Each entry takes the same fields as a `POST /v1/email` body. ```bash curl https://api.anypost.com/v1/email/batch \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "emails": [ { "from": "Acme ", "to": ["alex@customer.com"], "subject": "Your invoice is ready", "html": "

Invoice #1190 is attached.

" }, { "from": "Acme ", "to": ["sam@customer.com"], "subject": "Welcome to Acme", "html": "

Glad you are here.

" } ] }' ``` ```ts const result = await anypost.email.sendBatch({ emails: [ { from: 'Acme ', to: ['alex@customer.com'], subject: 'Your invoice is ready', html: '

Invoice #1190 is attached.

', }, { from: 'Acme ', to: ['sam@customer.com'], subject: 'Welcome to Acme', html: '

Glad you are here.

', }, ], }); ``` ```python result = client.email.send_batch({ "emails": [ {"from": "Acme ", "to": ["alex@customer.com"], "subject": "Your invoice is ready", "html": "

Invoice #1190 is attached.

"}, {"from": "Acme ", "to": ["sam@customer.com"], "subject": "Welcome to Acme", "html": "

Glad you are here.

"}, ], }) ``` ```php $result = $client->email->sendBatch([ "emails" => [ ["from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "Your invoice is ready", "html" => "

Invoice #1190 is attached.

"], ["from" => "Acme ", "to" => ["sam@customer.com"], "subject" => "Welcome to Acme", "html" => "

Glad you are here.

"], ], ]); ``` ```ruby result = client.email.send_batch( emails: [ {from: "Acme ", to: ["alex@customer.com"], subject: "Your invoice is ready", html: "

Invoice #1190 is attached.

"}, {from: "Acme ", to: ["sam@customer.com"], subject: "Welcome to Acme", html: "

Glad you are here.

"}, ] ) ``` ```rust use anypost::{BatchEmail, SendEmail}; let result = client.email.send_batch(&BatchEmail::new([ SendEmail::new("Acme ", ["alex@customer.com"]) .subject("Your invoice is ready") .html("

Invoice #1190 is attached.

"), SendEmail::new("Acme ", ["sam@customer.com"]) .subject("Welcome to Acme") .html("

Glad you are here.

"), ])).await?; ``` ```go result, _ := client.Email.SendBatch(ctx, &anypost.EmailBatchRequest{ Emails: []anypost.SendEmailRequest{ {From: "Acme ", To: []string{"alex@customer.com"}, Subject: "Your invoice is ready", HTML: "

Invoice #1190 is attached.

"}, {From: "Acme ", To: []string{"sam@customer.com"}, Subject: "Welcome to Acme", HTML: "

Glad you are here.

"}, }, }) ``` ```java var result = client.email.sendBatch(EmailBatchRequest.builder() .email(SendEmailRequest.builder().from("Acme ").to("alex@customer.com").subject("Your invoice is ready").html("

Invoice #1190 is attached.

").build()) .email(SendEmailRequest.builder().from("Acme ").to("sam@customer.com").subject("Welcome to Acme").html("

Glad you are here.

").build()) .build()); ``` ```csharp var result = await client.Email.SendBatchAsync(new EmailBatchRequest { Emails = [ new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "Your invoice is ready", Html = "

Invoice #1190 is attached.

" }, new SendEmailRequest { From = "Acme ", To = ["sam@customer.com"], Subject = "Welcome to Acme", Html = "

Glad you are here.

" }, ], }); ``` ## Shared defaults Repeating `from` on every entry is noise. An optional `defaults` object holds fields shared across the batch; an entry inherits each one it does not set for itself. ```bash curl https://api.anypost.com/v1/email/batch \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "defaults": { "from": "Acme ", "tags": ["onboarding"] }, "emails": [ { "to": ["alex@customer.com"], "subject": "Welcome, Alex", "html": "

Hello.

" }, { "to": ["sam@customer.com"], "subject": "Welcome, Sam", "html": "

Hello.

" } ] }' ``` ```ts const result = await anypost.email.sendBatch({ defaults: { from: 'Acme ', tags: ['onboarding'], }, emails: [ { to: ['alex@customer.com'], subject: 'Welcome, Alex', html: '

Hello.

', }, { to: ['sam@customer.com'], subject: 'Welcome, Sam', html: '

Hello.

', }, ], }); ``` ```python result = client.email.send_batch({ "defaults": { "from": "Acme ", "tags": ["onboarding"], }, "emails": [ {"to": ["alex@customer.com"], "subject": "Welcome, Alex", "html": "

Hello.

"}, {"to": ["sam@customer.com"], "subject": "Welcome, Sam", "html": "

Hello.

"}, ], }) ``` ```php $result = $client->email->sendBatch([ "defaults" => [ "from" => "Acme ", "tags" => ["onboarding"], ], "emails" => [ ["to" => ["alex@customer.com"], "subject" => "Welcome, Alex", "html" => "

Hello.

"], ["to" => ["sam@customer.com"], "subject" => "Welcome, Sam", "html" => "

Hello.

"], ], ]); ``` ```ruby result = client.email.send_batch( defaults: { from: "Acme ", tags: ["onboarding"], }, emails: [ {to: ["alex@customer.com"], subject: "Welcome, Alex", html: "

Hello.

"}, {to: ["sam@customer.com"], subject: "Welcome, Sam", html: "

Hello.

"}, ] ) ``` ```rust let result = client.email.send_batch( &BatchEmail::new([ SendEmail::to(["alex@customer.com"]).subject("Welcome, Alex").html("

Hello.

"), SendEmail::to(["sam@customer.com"]).subject("Welcome, Sam").html("

Hello.

"), ]) .defaults(serde_json::json!({ "from": "Acme ", "tags": ["onboarding"] })), ).await?; ``` ```go result, _ := client.Email.SendBatch(ctx, &anypost.EmailBatchRequest{ Defaults: &anypost.SendEmailRequest{ From: "Acme ", Tags: []string{"onboarding"}, }, Emails: []anypost.SendEmailRequest{ {To: []string{"alex@customer.com"}, Subject: "Welcome, Alex", HTML: "

Hello.

"}, {To: []string{"sam@customer.com"}, Subject: "Welcome, Sam", HTML: "

Hello.

"}, }, }) ``` ```java var result = client.email.sendBatch(EmailBatchRequest.builder() .defaults(SendEmailRequest.builder() .from("Acme ") .tags("onboarding") .build()) .email(SendEmailRequest.builder().to("alex@customer.com").subject("Welcome, Alex").html("

Hello.

").build()) .email(SendEmailRequest.builder().to("sam@customer.com").subject("Welcome, Sam").html("

Hello.

").build()) .build()); ``` ```csharp var result = await client.Email.SendBatchAsync(new EmailBatchRequest { Defaults = new SendEmailRequest { From = "Acme ", Tags = ["onboarding"], }, Emails = [ new SendEmailRequest { To = ["alex@customer.com"], Subject = "Welcome, Alex", Html = "

Hello.

" }, new SendEmailRequest { To = ["sam@customer.com"], Subject = "Welcome, Sam", Html = "

Hello.

" }, ], }); ``` Not every field merges the same way: | Fields | How a default and an entry combine | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `from`, `subject`, `reply_to`, `text`, `html`, `campaign`, `topic`, `unsubscribe` | The entry's value replaces the default. | | `cc`, `bcc`, `attachments` | The entry's list is appended to the default list. | | `headers`, `variables` | Shallow-merged by key; the entry's value wins on a collision. | | `tags` | Concatenated, default tags first, then de-duplicated. | | `template_id` | The entry's `template_id` wins. An entry that inlines `html` or `text` drops the default `template_id`. | Every per-message limit is checked against the merged result, not against `defaults` or the entry alone. A batch cannot, for example, slip past the 50-recipient cap by splitting `to` between `defaults` and an entry. ## Validation is all-or-nothing Before any message is sent, every merged entry is validated. If one entry fails, the whole batch is rejected with `400` and nothing is sent. Error paths are prefixed with the entry index: a missing subject on the fourth entry reports as `emails.3.subject`. Quota is enforced the same way. If the batch as a whole would exceed your daily limit or your monthly quota, the entire request is rejected with `429` before any entry is sent. Split it into smaller batches and retry after the limit resets. On a paid plan you can continue past the monthly quota while you have prepaid overage credits. See [**Sending limits**](/docs/sending-limits). ## The response Once validation passes, each entry proceeds on its own. The response reports every entry's outcome under `data`, in request order, with a `summary` of the totals. ```json { "summary": { "total": 2, "queued": 1, "failed": 1 }, "data": [ { "status": "queued", "index": 0, "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d", "created_at": "2026-04-30T12:00:00.123000Z" }, { "status": "failed", "index": 1, "error": { "type": "permission_error", "message": "domain_not_verified" } } ] } ``` A `queued` entry carries the `id` and `created_at` you would get from a single send. A `failed` entry carries an `error` with a `type` and a `message`. The `index` is the entry's zero-based position in the request `emails` array. | `error.type` | Meaning | | ------------------ | --------------------------------------------------------------------------------------------------------- | | `validation_error` | The entry referenced a template that does not exist or is unpublished, or supplied no subject of its own. | | `permission_error` | The sender domain is not on the API key's allowlist, or is not verified for your team. | | `internal_error` | Anypost could not accept the entry. Retry that entry. | ## HTTP status The top-level status reflects the mix of outcomes: | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------- | | `202` | Every entry queued. | | `207` | Mixed: some entries queued, some failed. Inspect `data`. | | `400` | The batch failed validation, or every entry failed for a reason you can correct (unverified domain, missing template). | | `502` | Every entry failed and at least one failure was a temporary server-side error. Retry the whole batch with backoff. | A `207` is a success envelope, not an error: per-entry failures live inside `data[i].error`, not in a top-level error object. See [**API conventions**](/docs/api-conventions) for the canonical error shape a `400` uses. The SDK resolves a `207` rather than throwing. Inspect each entry's `status`: ```ts for (const entry of result.data) { if (entry.status === 'queued') { console.log(entry.index, entry.id); } else { console.error(entry.index, entry.error.type, entry.error.message); } } ``` ```python for entry in result["data"]: if entry["status"] == "queued": print(entry["index"], entry["id"]) else: print(entry["index"], entry["error"]["type"], entry["error"]["message"]) ``` ```php foreach ($result->data as $entry) { if ($entry->status === "queued") { echo "{$entry->index} {$entry->id}\n"; } else { echo "{$entry->index} {$entry->error->type} {$entry->error->message}\n"; } } ``` ```ruby result.data.each do |entry| if entry.status == "queued" puts "#{entry.index} #{entry.id}" else puts "#{entry.index} #{entry.error.type} #{entry.error.message}" end end ``` ```rust for entry in result["data"].as_array().unwrap() { if entry["status"] == "queued" { println!("{} {}", entry["index"], entry["id"]); } else { println!("{} {} {}", entry["index"], entry["error"]["type"], entry["error"]["message"]); } } ``` ```go for _, entry := range result.Data { if entry.Status == "queued" { fmt.Println(entry.Index, entry.ID) } else { fmt.Println(entry.Index, entry.Error.Type, entry.Error.Message) } } ``` ```java for (BatchItemResult entry : result.data()) { if (entry.isQueued()) { System.out.println(entry.index() + " " + entry.id()); } else { System.out.println(entry.index() + " " + entry.error().type() + " " + entry.error().message()); } } ``` ```csharp foreach (var entry in result.Data) { if (entry.IsQueued) { Console.WriteLine($"{entry.Index} {entry.Id}"); } else { Console.WriteLine($"{entry.Index} {entry.Error!.Type} {entry.Error.Message}"); } } ``` ## Idempotency A batch retry is made safe the same way a single send is: include an `Idempotency-Key` header. A replay returns the original `data` array verbatim, so entries that queued on the first attempt are never sent twice, even when the first response was a `207`. See [**API conventions**](/docs/api-conventions). ## Sizing a batch The hard cap is 100 entries, but the request body is also capped at 5 MB. - Text-only entries reach 100 long before the body cap. - Entries with attachments hit 5 MB well before 100. Aim for 25 entries or fewer when attaching files. When every entry carries the same file, put it in `defaults.attachments` so its bytes are sent once, not once per entry. An over-large body is rejected with `413` before any entry is processed. ## Per-recipient content A batch is how you send genuinely different content to each recipient. Set a template as a batch default and give each entry its own `variables`: ```json { "defaults": { "from": "Acme ", "template_id": "template_550e8400-e29b-41d4-a716-446655440000" }, "emails": [ { "to": ["alex@customer.com"], "variables": { "first_name": "Alex", "balance": "$40" } }, { "to": ["sam@customer.com"], "variables": { "first_name": "Sam", "balance": "$0" } } ] } ``` Within a single `POST /v1/email`, `variables` is one object shared by all recipients. A batch lifts that limit. See [**Variables & personalization**](/docs/variables) for placeholder syntax. ## Where to go next - **[Send a single email](/docs/send-email):** one message to a shared recipient list. - **[Sending with templates](/docs/templates):** store body content and send it by ID. - **[Variables & personalization](/docs/variables):** render per-entry values into the body. --- Source: /docs/attachments # Attachments & inline images A message can carry files. Add them as downloadable attachments, or mark one inline and reference it from the HTML body so it renders in place. ## Attach a file `attachments` is an array on `POST /v1/email`. Each entry is one file, with the content base64-encoded: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Your invoice", "html": "

Invoice #1190 is attached.

", "attachments": [ { "filename": "invoice-1190.pdf", "content_type": "application/pdf", "content": "JVBERi0xLjQKJeLjz9MK..." } ] }' ``` ```ts import { readFile } from "node:fs/promises"; const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], subject: "Your invoice", html: "

Invoice #1190 is attached.

", attachments: [ { filename: "invoice-1190.pdf", content_type: "application/pdf", content: await readFile("invoice-1190.pdf"), // raw bytes; the SDK base64-encodes }, ], }); ``` ```python from pathlib import Path client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Your invoice", "html": "

Invoice #1190 is attached.

", "attachments": [ { "filename": "invoice-1190.pdf", "content_type": "application/pdf", "content": Path("invoice-1190.pdf").read_bytes(), # raw bytes; the SDK base64-encodes }, ], }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "Your invoice", "html" => "

Invoice #1190 is attached.

", "attachments" => [ [ "filename" => "invoice-1190.pdf", "content_type" => "application/pdf", "content" => file_get_contents("invoice-1190.pdf"), // raw bytes; the SDK base64-encodes ], ], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], subject: "Your invoice", html: "

Invoice #1190 is attached.

", attachments: [ { filename: "invoice-1190.pdf", content_type: "application/pdf", content: File.binread("invoice-1190.pdf") # raw bytes; the SDK base64-encodes } ] ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("Your invoice") .html("

Invoice #1190 is attached.

") .attachment( Attachment::new("invoice-1190.pdf", std::fs::read("invoice-1190.pdf")?) // raw bytes; the SDK base64-encodes .content_type("application/pdf"), ), ).await?; ``` ```go pdf, _ := os.ReadFile("invoice-1190.pdf") // raw bytes; the SDK base64-encodes client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "Your invoice", HTML: "

Invoice #1190 is attached.

", Attachments: []anypost.Attachment{ {Filename: "invoice-1190.pdf", ContentType: "application/pdf", Content: pdf}, }, }) ``` ```java byte[] pdf = Files.readAllBytes(Path.of("invoice-1190.pdf")); // raw bytes; the SDK base64-encodes client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("Your invoice") .html("

Invoice #1190 is attached.

") .attachment(Attachment.of("invoice-1190.pdf", pdf, "application/pdf")) .build()); ``` ```csharp byte[] pdf = File.ReadAllBytes("invoice-1190.pdf"); // raw bytes; the SDK base64-encodes await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "Your invoice", Html = "

Invoice #1190 is attached.

", Attachments = [Attachment.Create("invoice-1190.pdf", pdf, "application/pdf")], }); ``` The recipient sees `invoice-1190.pdf` as a download. Pass the raw file bytes (a `Uint8Array` or Node `Buffer`) as `content` and the SDK base64-encodes them; pass an already-encoded `string` and it is sent as-is. ## The attachment object | Field | Required | Description | |---|---|---| | `filename` | Yes | Name shown to the recipient, 1 to 255 characters. Path components and any NUL, CR, or LF are stripped; a name that strips to empty is rejected. | | `content` | Yes | The file bytes, base64-encoded. | | `content_type` | No | MIME type, as `type/subtype`. Defaults to `application/octet-stream`; an unrecognized value falls back to the same. | | `content_id` | No | Marks the attachment inline and names it for a `cid:` reference. Omit it for an ordinary download. | ## Encoding the content `content` is the raw file, base64-encoded. Encode it before building the request body: ```bash base64 -i invoice-1190.pdf # macOS base64 -w0 invoice-1190.pdf # Linux ``` Base64 inflates the data by about a third, and the whole request body is capped at 5 MB. That puts the practical ceiling for one file's raw size near 3.5 MB, lower once other fields and any further attachments are counted. A request over the body limit is rejected with `413` before any processing. ## Inline images To show an image inside the HTML body rather than as a download, give the attachment a `content_id` and reference that ID from the body with a `cid:` URL: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Welcome to Acme", "html": "

\"Acme\"

Glad you are here.

", "attachments": [ { "filename": "logo.png", "content_type": "image/png", "content_id": "logo-1", "content": "iVBORw0KGgoAAAANSUhEUg..." } ] }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], subject: "Welcome to Acme", html: '

Acme

Glad you are here.

', attachments: [ { filename: "logo.png", content_type: "image/png", content_id: "logo-1", content: await readFile("logo.png"), }, ], }); ``` ```python from pathlib import Path client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Welcome to Acme", "html": '

Acme

Glad you are here.

', "attachments": [ { "filename": "logo.png", "content_type": "image/png", "content_id": "logo-1", "content": Path("logo.png").read_bytes(), }, ], }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "Welcome to Acme", "html" => '

Acme

Glad you are here.

', "attachments" => [ [ "filename" => "logo.png", "content_type" => "image/png", "content_id" => "logo-1", "content" => file_get_contents("logo.png"), ], ], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], subject: "Welcome to Acme", html: '

Acme

Glad you are here.

', attachments: [ { filename: "logo.png", content_type: "image/png", content_id: "logo-1", content: File.binread("logo.png") } ] ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("Welcome to Acme") .html(r#"

Acme

Glad you are here.

"#) .attachment( Attachment::new("logo.png", std::fs::read("logo.png")?) .content_type("image/png") .content_id("logo-1"), ), ).await?; ``` ```go logo, _ := os.ReadFile("logo.png") client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "Welcome to Acme", HTML: `

Acme

Glad you are here.

`, Attachments: []anypost.Attachment{ {Filename: "logo.png", ContentType: "image/png", ContentID: "logo-1", Content: logo}, }, }) ``` ```java byte[] logo = Files.readAllBytes(Path.of("logo.png")); client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("Welcome to Acme") .html("

\"Acme\"

Glad you are here.

") .attachment(Attachment.inline("logo.png", logo, "image/png", "logo-1")) .build()); ``` ```csharp byte[] logo = File.ReadAllBytes("logo.png"); await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "Welcome to Acme", Html = "

\"Acme\"

Glad you are here.

", Attachments = [Attachment.Inline("logo.png", logo, "logo-1", "image/png")], }); ``` The `content_id` value and the `cid:` reference must match. Angle brackets and CR/LF are stripped from `content_id` before the message is built, so write it bare: `logo-1`, not ``. An attachment with a `content_id` is marked inline; one without is offered as a download. The same file can only be one or the other in a given message. ## Limits | Limit | Value | |---|---| | Attachments per message | 20 | | Filename length | 255 characters | | Request body | 5 MB total, base64-encoded | The 5 MB ceiling covers the entire request: every attachment, the body, and all other fields together. ## Attachments in a batch In a [**batch send**](/docs/batch-sending), an attachment list set in `defaults` is appended to each entry's own list. When every message in the batch carries the same file, put it in `defaults.attachments` so its bytes count once against the 5 MB body limit instead of once per entry. ## Where to go next - **[Send a single email](/docs/send-email):** the full `POST /v1/email` request. - **[Batch sending](/docs/batch-sending):** many messages, and shared attachments, in one request. --- Source: /docs/headers # Headers & custom headers The `headers` field carries application-specific headers on a message, such as a reference ID your own systems read back. Headers that carry transport, authentication, or routing meaning are managed by Anypost and cannot be set this way. ## Set a header `headers` is a name-to-value object on `POST /v1/email`: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Order confirmed", "html": "

Your order is on its way.

", "headers": { "X-Order-Id": "1190", "X-Entity-Ref": "customer-4471" } }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], subject: "Order confirmed", html: "

Your order is on its way.

", headers: { "X-Order-Id": "1190", "X-Entity-Ref": "customer-4471", }, }); ``` ```python client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Order confirmed", "html": "

Your order is on its way.

", "headers": { "X-Order-Id": "1190", "X-Entity-Ref": "customer-4471", }, }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "Order confirmed", "html" => "

Your order is on its way.

", "headers" => [ "X-Order-Id" => "1190", "X-Entity-Ref" => "customer-4471", ], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], subject: "Order confirmed", html: "

Your order is on its way.

", headers: { "X-Order-Id" => "1190", "X-Entity-Ref" => "customer-4471" } ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("Order confirmed") .html("

Your order is on its way.

") .header("X-Order-Id", "1190") .header("X-Entity-Ref", "customer-4471"), ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "Order confirmed", HTML: "

Your order is on its way.

", Headers: map[string]string{ "X-Order-Id": "1190", "X-Entity-Ref": "customer-4471", }, }) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("Order confirmed") .html("

Your order is on its way.

") .header("X-Order-Id", "1190") .header("X-Entity-Ref", "customer-4471") .build()); ``` ```csharp await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "Order confirmed", Html = "

Your order is on its way.

", Headers = new Dictionary { ["X-Order-Id"] = "1190", ["X-Entity-Ref"] = "customer-4471", }, }); ``` Each pair becomes one header on the delivered message. ## Names and values A header name must be alphanumeric and hyphens only, start with a letter or digit, and be at most 78 characters. A name that breaks those rules is dropped from the map. A message may carry at most 25 custom headers. ## Headers Anypost manages Some headers are set by Anypost itself, or carry trust signals that a sender cannot legitimately originate. If you include one in `headers`, it is dropped rather than applied. Use the `headers` map for your own application headers, not to override transport, identity, authentication, or trace headers. | Category | Examples | Why it is managed | |---|---|---| | Addressing and identity | `From`, `To`, `Cc`, `Bcc`, `Subject`, `Reply-To`, `Sender` | Set from the request's own fields. | | Message metadata | `Date`, `Message-ID`, `MIME-Version`, `Content-Type`, `Content-Transfer-Encoding`, `Content-Disposition` | Set by Anypost or generated when the message is encoded. | | Authentication | `DKIM-Signature`, `Authentication-Results`, `Received-SPF` | Owned by the platform; forging them would misrepresent the message. | | Trace | `Received`, `Return-Path`, `Delivered-To`, `X-Original-To`, and the `Resent-*` and `ARC-*` families | Added by receiving servers. A sender cannot legitimately originate them. | | Loop control | `Auto-Submitted`, `Precedence` | Affect vacation responders and bounce handling. | | Anypost-managed | `X-Campaign` and the `X-Anypost-*` namespace | Set from dedicated request fields that apply their own validation. | For a campaign label, use the top-level `campaign` field rather than a header: the field is validated, and a header of the same name is dropped. The one carve-out in the `X-Anypost-*` namespace is `X-Anypost-Track-Opens` / `X-Anypost-Track-Clicks`; tracking is covered in [**Open & click tracking**](/docs/tracking). ## What errors, and what is dropped Header handling is forgiving by design. A malformed name, an over-long or empty value, and a managed header are all dropped silently, so a small mistake never blocks a send. Exceeding the 25-header cap is the one condition that fails the request: it returns `400` with a `validation_error`. See [**API conventions**](/docs/api-conventions) for the error shape. The trade-off is that a dropped header is not reported back. If a custom header does not arrive, check it against the name and value rules above. ## Headers in a batch In a [**batch send**](/docs/batch-sending), a `headers` map in `defaults` is shallow-merged with each entry's own map. On a name collision the entry's value wins. The 25-header cap applies to the merged result. ## Where to go next - **[Send a single email](/docs/send-email):** the full `POST /v1/email` request. - **[Batch sending](/docs/batch-sending):** shared and per-entry headers in one request. --- Source: /docs/tags-topics-campaigns # Tags, topics & campaigns `tags`, `topic`, and `campaign` are labels you attach to a message. They have no effect on delivery. Their job is to travel with the message onto every event it produces, so you can filter and report on the event stream. ## What they have in common All three are optional fields on `POST /v1/email`. A value you set is copied onto every event for that message: not just `email.sent`, but the delivery, bounce, open, click, and complaint events that follow. ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "March newsletter", "html": "

This month at Acme.

", "topic": "newsletter", "campaign": "march-2026", "tags": ["newsletter", "us-region"] }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], subject: "March newsletter", html: "

This month at Acme.

", topic: "newsletter", campaign: "march-2026", tags: ["newsletter", "us-region"], }); ``` ```python client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "subject": "March newsletter", "html": "

This month at Acme.

", "topic": "newsletter", "campaign": "march-2026", "tags": ["newsletter", "us-region"], }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "March newsletter", "html" => "

This month at Acme.

", "topic" => "newsletter", "campaign" => "march-2026", "tags" => ["newsletter", "us-region"], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], subject: "March newsletter", html: "

This month at Acme.

", topic: "newsletter", campaign: "march-2026", tags: ["newsletter", "us-region"] ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("March newsletter") .html("

This month at Acme.

") .topic("newsletter") .campaign("march-2026") .tags(["newsletter", "us-region"]), ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "March newsletter", HTML: "

This month at Acme.

", Topic: "newsletter", Campaign: "march-2026", Tags: []string{"newsletter", "us-region"}, }) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("March newsletter") .html("

This month at Acme.

") .topic("newsletter") .campaign("march-2026") .tags("newsletter", "us-region") .build()); ``` ```csharp await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "March newsletter", Html = "

This month at Acme.

", Topic = "newsletter", Campaign = "march-2026", Tags = ["newsletter", "us-region"], }); ``` None of the three is a per-message identifier. To correlate one specific message, use the `email_` ID returned from the send; see [**Send a single email**](/docs/send-email). Putting a unique value such as an order ID into a tag or a campaign defeats their purpose, and for `campaign` will hit a rate limit. ## Tags `tags` is an array of grouping labels. Each tag is 1 to 64 characters of `A-Za-z0-9_-`, and a message may carry up to 10. Duplicates are removed server-side, keeping first-occurrence order. Use tags for the dimensions you want to slice events by: a cohort, a region, a mailing list, transactional versus marketing. A message can carry several at once, which is what makes them a grouping tool rather than a single category. ## Topic `topic` is a single label, 1 to 64 characters of lowercase `a-z0-9_.-`. It names the kind of mail this message is: `newsletter`, `receipts`, `product-updates`. Topic is also the unsubscribe and suppression boundary. A recipient who unsubscribes does so from one topic, and a suppression applies within the topic it was recorded under. A message sent with no `topic` is treated as transactional: it skips topic-scoped suppression entirely, which is what keeps a password reset or a one-time code deliverable. Topic is required when a message generates an unsubscribe link. See [**Unsubscribe handling**](/docs/unsubscribe) and [**Suppressions**](/docs/suppressions). ## Campaign `campaign` is a single label, 1 to 64 characters of `A-Za-z0-9_-`. It marks a message as part of one send effort: `march-2026`, `black-friday`, `onboarding-drip`. Campaign is coarse by design. A team may use at most 200 distinct campaign values per rolling hour, and at most 1,000 per rolling 72 hours — the lifetime of a campaign's delivery queues. A send that would cross either cap is rejected with `429`; the response's `limit` field tells you which window tripped. Those ceilings are generous for real stream segmentation and deliberately too low to use `campaign` as a per-message identifier. If you hit one, you are almost certainly using a unique value per send where a tag or the `email_` ID belongs. ## Choosing between them | Field | How many | Use it for | |---|---|---| | `tags` | Up to 10 per message | Slicing events by cohort, region, list, or message type. | | `topic` | One per message | The kind of mail, and the unsubscribe and suppression boundary. | | `campaign` | One per message | Grouping a single send effort for reporting. | A send can set all three, none, or any combination. A fourth label, the `template_id` of a [**templated send**](/docs/templates), filters events the same way. ## In a batch In a [**batch send**](/docs/batch-sending), `topic` and `campaign` set in `defaults` are each overridden by an entry that supplies its own. `tags` are concatenated, batch defaults first, then de-duplicated, so a default tag applies to every entry on top of its own. ## Where to go next - **[Send a single email](/docs/send-email):** the full `POST /v1/email` request. - **[Unsubscribe handling](/docs/unsubscribe):** generate one-click unsubscribe links scoped to a topic. - **[Suppressions](/docs/suppressions):** the addresses Anypost will not send to, and how topic scopes them. --- Source: /docs/tracking # Open & click tracking Tracking records when a recipient opens a message or clicks one of its links. Each event is reported through webhooks, the same way a delivery or a bounce is. ## The tracking field `tracking` is an optional object on `POST /v1/email` with two boolean fields, `opens` and `clicks`: ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Your weekly digest", "html": "

Read more on the site.

", "tracking": { "opens": true, "clicks": true } }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], subject: "Your weekly digest", html: '

Read more on the site.

', tracking: { opens: true, clicks: true }, }); ``` ```python client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "subject": "Your weekly digest", "html": '

Read more on the site.

', "tracking": {"opens": True, "clicks": True}, }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "Your weekly digest", "html" => '

Read more on the site.

', "tracking" => ["opens" => true, "clicks" => true], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], subject: "Your weekly digest", html: '

Read more on the site.

', tracking: {opens: true, clicks: true} ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("Your weekly digest") .html(r#"

Read more on the site.

"#) .tracking(anypost::Tracking::new().opens(true).clicks(true)), ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "Your weekly digest", HTML: `

Read more on the site.

`, Tracking: &anypost.Tracking{ Opens: anypost.Bool(true), Clicks: anypost.Bool(true), }, }) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("Your weekly digest") .html("

Read more on the site.

") .tracking(Tracking.of(true, true)) .build()); ``` ```csharp await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "Your weekly digest", Html = "

Read more on the site.

", Tracking = Tracking.Of(opens: true, clicks: true), }); ``` Each domain carries its own default for open and click tracking. The `tracking` field overrides that default for one message: set `opens` or `clicks` to turn a feature on or off for this send only. Omit `tracking`, or omit one of its fields, and the domain default applies. ## What each feature does - **Open tracking** adds a small invisible image to the HTML body. When a mail client loads that image, Anypost emits an `email.opened` event. - **Click tracking** rewrites the links in the HTML body to pass through Anypost first. A recipient who clicks is redirected to the original destination, and Anypost emits an `email.clicked` event. Both features operate on the HTML body. A message with only a `text` body has no image to load and no links to rewrite, so it cannot be tracked. ## The tracking domain requirement Tracking links and the open image are served from a tracking domain: a branded subdomain, such as `track.example.com`, that you verify separately from your sending domain. Until that tracking domain is verified, tracking is off. This is a hard requirement: a message with `tracking: { opens: true }` is still sent without an open image if the tracking domain is not verified. The mail is delivered normally; only the tracking is skipped. See [**Tracking domains**](/docs/tracking-domains) for the setup. ## The events A tracked interaction arrives as an event on your registered webhooks: | Event | Emitted when | Carries | |---|---|---| | `email.opened` | The recipient's mail client loads the open image. | The recipient's IP address and user agent. | | `email.clicked` | The recipient clicks a rewritten link. | The destination URL, plus the IP address and user agent. | Each event also carries the message's `email_` ID and its tags, topic, and campaign, so an open or a click can be tied back to the send. See [**Webhooks**](/docs/webhooks) for the delivery mechanism and payload shape. ## Automated opens and clicks An open or click is not always a person. Mail scanners, security proxies, and link prefetchers load images and follow links on a recipient's behalf, often within seconds of delivery. Anypost classifies this traffic. Interactions that look like a link prefetch or an automation script are dropped: they never become an `email.opened` or `email.clicked` event, so they cannot inflate your engagement numbers. One class of automated traffic does still reach you. A mailbox-provider image proxy, such as the one Gmail uses, loads the open image as a side effect of showing the message to a real person, so an event from a proxy is genuine signal. Anypost keeps it and labels it: the event carries a `tracking.bot` object naming the `source` (for example `google`) and a `kind` of `proxy`. An open or click with no `tracking.bot` came directly from the recipient's client. The label travels with the event everywhere it appears — the webhook payload, the [events API](/docs/events), and the dashboard, where a proxied open shows a **Bot** badge. To count only unproxied interactions, ignore the events that carry a `tracking.bot` object. ## In a batch and over SMTP In a [**batch send**](/docs/batch-sending), a `tracking` object in `defaults` applies to every entry that does not set its own. An SMTP submission has no request body, so it carries the same two toggles as the `X-Anypost-Track-Opens` and `X-Anypost-Track-Clicks` headers; see [**Sending over SMTP**](/docs/sending-over-smtp). ## Where to go next - **[Send a single email](/docs/send-email):** the full `POST /v1/email` request. - **[Tracking domains](/docs/tracking-domains):** verify the subdomain that serves tracking links. - **[Webhooks](/docs/webhooks):** receive `email.opened` and `email.clicked` events. --- Source: /docs/unsubscribe # Unsubscribe handling Marketing mail needs a working unsubscribe path. Anypost can generate a one-click unsubscribe for a message, leave it off for transactional mail, or forward an unsubscribe header you supply yourself. **Note.** One-click unsubscribe is not optional for bulk mail. Gmail and Yahoo require any sender of 5,000 or more messages a day to personal accounts to offer one-click unsubscribe on promotional mail and to honor the request within two days; Microsoft requires a working unsubscribe link on bulk mail. `mode: "generate"` satisfies these requirements. Transactional mail is exempt. ## Three behaviors The optional `unsubscribe` object on `POST /v1/email` selects one of two modes. A third behavior, forwarding your own header, needs no mode at all. | You want | What to send | |---|---| | Anypost to generate a one-click unsubscribe | `unsubscribe: { "mode": "generate" }` | | No unsubscribe header at all | `unsubscribe: { "mode": "none" }`, or omit `unsubscribe` | | To use your own list-management unsubscribe | A `List-Unsubscribe` header; see [Forward your own header](#forward-your-own-header) | ## Let Anypost generate it `mode: "generate"` mints a signed, per-recipient unsubscribe and injects the `List-Unsubscribe` and `List-Unsubscribe-Post` headers. Mail clients that support RFC 8058, including Gmail and Apple Mail, render a native Unsubscribe button from them. ```bash curl https://api.anypost.com/v1/email \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["alex@customer.com"], "subject": "March newsletter", "html": "

This month at Acme.

", "topic": "newsletter", "unsubscribe": { "mode": "generate", "display_name": "Acme newsletter" } }' ``` ```ts const { id } = await anypost.email.send({ from: "Acme ", to: ["alex@customer.com"], subject: "March newsletter", html: "

This month at Acme.

", topic: "newsletter", unsubscribe: { mode: "generate", display_name: "Acme newsletter" }, }); ``` ```python client.email.send({ "from": "Acme ", "to": ["alex@customer.com"], "subject": "March newsletter", "html": "

This month at Acme.

", "topic": "newsletter", "unsubscribe": {"mode": "generate", "display_name": "Acme newsletter"}, }) ``` ```php $client->email->send([ "from" => "Acme ", "to" => ["alex@customer.com"], "subject" => "March newsletter", "html" => "

This month at Acme.

", "topic" => "newsletter", "unsubscribe" => ["mode" => "generate", "display_name" => "Acme newsletter"], ]); ``` ```ruby client.email.send( from: "Acme ", to: ["alex@customer.com"], subject: "March newsletter", html: "

This month at Acme.

", topic: "newsletter", unsubscribe: {mode: "generate", display_name: "Acme newsletter"} ) ``` ```rust client.email.send( &SendEmail::new("Acme ", ["alex@customer.com"]) .subject("March newsletter") .html("

This month at Acme.

") .topic("newsletter") .unsubscribe(anypost::Unsubscribe::generate().display_name("Acme newsletter")), ).await?; ``` ```go client.Email.Send(ctx, &anypost.SendEmailRequest{ From: "Acme ", To: []string{"alex@customer.com"}, Subject: "March newsletter", HTML: "

This month at Acme.

", Topic: "newsletter", Unsubscribe: &anypost.Unsubscribe{ Mode: anypost.UnsubscribeGenerate, DisplayName: "Acme newsletter", }, }) ``` ```java client.email.send(SendEmailRequest.builder() .from("Acme ") .to("alex@customer.com") .subject("March newsletter") .html("

This month at Acme.

") .topic("newsletter") .unsubscribe(Unsubscribe.generate("Acme newsletter")) .build()); ``` ```csharp await client.Email.SendAsync(new SendEmailRequest { From = "Acme ", To = ["alex@customer.com"], Subject = "March newsletter", Html = "

This month at Acme.

", Topic = "newsletter", Unsubscribe = Unsubscribe.Generate("Acme newsletter"), }); ``` A generated unsubscribe requires a `topic`. The topic is the bucket the recipient unsubscribes from: a send with `topic: "newsletter"` unsubscribes them from your newsletter, not from your receipts. A `mode: "generate"` send with no `topic` is rejected with a `validation_error`. See [**Tags, topics & campaigns**](/docs/tags-topics-campaigns). `display_name` is optional. It is a human-readable label, up to 120 characters, shown on the confirmation page Anypost hosts when a recipient unsubscribes. When a recipient unsubscribes, Anypost records a suppression scoped to that topic and emits an `email.unsubscribed` event. Later sends to that address on the same topic are dropped; other topics are unaffected. See [**Suppressions**](/docs/suppressions). ### The link in the body A native client button is not always visible, so put an unsubscribe link in the body too. On a `mode: "generate"` send, write `{{unsubscribe_url}}` in the HTML body and Anypost replaces it with the same per-recipient unsubscribe URL it put in the header: ```html

Unsubscribe

``` The placeholder is filled whether or not the send carries other `variables`. ## No unsubscribe header `mode: "none"` injects no unsubscribe header. It is the default: a send that omits `unsubscribe` behaves the same way. Use it for transactional mail that must not carry unsubscribe semantics, such as a password reset or a one-time code. A recipient cannot opt out of a security email, and such a send usually carries no `topic` at all. ## Forward your own header If you already run list management elsewhere, set your own `List-Unsubscribe` header in the [**headers**](/docs/headers) map. Anypost forwards it rather than generating its own. A customer-supplied header always wins: it is used even when `unsubscribe.mode` is `generate`. A supplied header is checked and lightly normalized before it is sent. Common, recoverable mistakes are fixed and reported back in the `warnings` array of the response: - CR, LF, and tab characters are stripped, and runs of whitespace collapsed. - Missing `<` `>` angle brackets around a URI are added. - A `mailto:` URI listed before an `https:` URI is reordered, because one-click receivers scan for the first HTTPS URI. Shapes that cannot be made valid are rejected with `422` and an `error.code`. The most common causes: a `List-Unsubscribe-Post` header without a matching `List-Unsubscribe`, a one-click header whose `List-Unsubscribe` carries no HTTPS URL, and a non-TLS `http:` URL. See [**API conventions**](/docs/api-conventions). ## In a batch In a [**batch send**](/docs/batch-sending), an `unsubscribe` object in `defaults` applies to every entry that does not set its own. The `topic`-required rule for `mode: "generate"` is checked per entry, against each entry's merged `topic`. ## Where to go next - **[Suppressions](/docs/suppressions):** the addresses Anypost will not send to, and how a topic scopes them. - **[Tags, topics & campaigns](/docs/tags-topics-campaigns):** the topic an unsubscribe is scoped to. - **[Webhooks](/docs/webhooks):** receive the `email.unsubscribed` event. --- Source: /docs/suppressions # Suppressions A suppression is an address Anypost will not deliver to. The list is your team's, and it protects your sending reputation by stopping mail to addresses that bounced, complained, or opted out. ## How an address gets suppressed Most suppressions are added automatically. You can also add one yourself. | `reason` | Added when | |---|---| | `permanent_bounce` | A receiving server permanently rejected delivery, usually a non-existent address. | | `complaint` | A recipient marked a message as spam. | | `unsubscribed` | A recipient used a one-click unsubscribe link. | | `manual` | You added the address through the API or dashboard. | A complaint or a stream of bounces damages how mailbox providers see your domain. Suppressing those addresses on the first signal is what keeps the rest of your mail in the inbox. ## Topic scope Every suppression is scoped to a topic, and the scope decides which sends it blocks. - A **global** suppression has the topic `*`. It blocks every send to that address. Bounces and complaints always write a global suppression: a dead address is dead for all mail. - A **topic-scoped** suppression names one topic, such as `marketing`. It blocks only sends carrying that same `topic`. A one-click unsubscribe writes one of these, scoped to the topic the recipient unsubscribed from. This scoping is the transactional carve-out. A recipient who unsubscribes from `marketing` is suppressed for `marketing` only, so a later password reset or one-time code, sent with a different `topic` or none at all, still reaches them. A send with no `topic` is checked against global suppressions only. See [**Tags, topics & campaigns**](/docs/tags-topics-campaigns). ## What happens on a send to a suppressed address When a send names an address that is suppressed for that send's topic, the address is dropped before delivery. The rest of the recipients are unaffected, and you are not billed for the dropped recipient. Anypost emits an `email.suppressed` event so the outcome is visible on your webhooks. ## Listing suppressions `GET /v1/suppressions` returns the list, newest first, with cursor pagination. The TypeScript `list` returns a page you can iterate to walk every page: ```bash curl https://api.anypost.com/v1/suppressions \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` ```ts for await (const s of await anypost.suppressions.list({ reason: "complaint" })) { console.log(s.email, s.topic, s.suppressed_at); } ``` ```python for s in client.suppressions.list({"reason": "complaint"}): print(s["email"], s["topic"], s["suppressed_at"]) ``` ```php foreach ($client->suppressions->list(["reason" => "complaint"]) as $s) { echo "{$s->email} {$s->topic} {$s->suppressed_at}\n"; } ``` ```ruby client.suppressions.list(reason: "complaint").each do |s| puts "#{s.email} #{s.topic} #{s.suppressed_at}" end ``` ```rust use anypost::SuppressionListParams; for s in client.suppressions.list_all(SuppressionListParams::new().reason("complaint")).await? { println!("{} {} {}", s["email"], s["topic"], s["suppressed_at"]); } ``` ```go page, _ := client.Suppressions.List(ctx, anypost.SuppressionListParams{Reason: anypost.SuppressionReasonComplaint}) for s, _ := range page.All(ctx) { fmt.Println(s.Email, s.Topic, s.SuppressedAt) } ``` ```java Page page = client.suppressions.list(SuppressionListParams.builder().reason(SuppressionReason.COMPLAINT).build()); for (Suppression s : page.all()) { System.out.println(s.email() + " " + s.topic() + " " + s.suppressedAt()); } ``` ```csharp var page = await client.Suppressions.ListAsync(new SuppressionListParams { Reason = SuppressionReason.Complaint }); await foreach (var s in page.AllAsync()) { Console.WriteLine($"{s.Email} {s.Topic} {s.SuppressedAt}"); } ``` Narrow the list with query parameters: | Parameter | Restricts to | |---|---| | `email_contains` | Addresses containing this substring. | | `topic` | One topic. Pass `*` for global entries only. | | `reason` | `permanent_bounce`, `complaint`, `unsubscribed`, or `manual`. | | `origin` | `auto` for automatic entries, `manual` for ones you added. | To check one address, `GET /v1/suppressions/{email}` returns every record on file for it, across all topics. The `{email}` segment is URL-encoded. ## Adding a suppression `POST /v1/suppressions` adds an address yourself, for example on a request from a customer: ```bash curl https://api.anypost.com/v1/suppressions \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "alice@example.com", "topic": "marketing", "note": "customer requested removal" }' ``` ```ts await anypost.suppressions.create({ email: "alice@example.com", topic: "marketing", note: "customer requested removal", }); ``` ```python client.suppressions.create({ "email": "alice@example.com", "topic": "marketing", "note": "customer requested removal", }) ``` ```php $client->suppressions->create([ "email" => "alice@example.com", "topic" => "marketing", "note" => "customer requested removal", ]); ``` ```ruby client.suppressions.create(email: "alice@example.com", topic: "marketing", note: "customer requested removal") ``` ```rust client.suppressions.create(serde_json::json!({ "email": "alice@example.com", "topic": "marketing", "note": "customer requested removal" })).await?; ``` ```go client.Suppressions.Create(ctx, &anypost.SuppressionCreateParams{ Email: "alice@example.com", Topic: "marketing", Note: "customer requested removal", }) ``` ```java client.suppressions.create(SuppressionCreateParams.builder() .email("alice@example.com") .topic("marketing") .note("customer requested removal") .build()); ``` ```csharp await client.Suppressions.CreateAsync(new SuppressionCreateParams { Email = "alice@example.com", Topic = "marketing", Note = "customer requested removal", }); ``` `topic` is optional and defaults to `*`, a global suppression. Set a specific topic to block only that stream. `note` is an optional internal annotation. A manual suppression never expires. Adding an address that is already suppressed for the same `(email, topic)` pair returns `422`. ## Removing a suppression Removing an address makes it eligible to receive mail again on the next send. - `DELETE /v1/suppressions/{email}` removes the address across every topic. - `DELETE /v1/suppressions/{email}/{topic}` removes one topic's entry and leaves the others. Use `%2A` for the global topic `*`. Removal does not stop a fresh suppression later: a future bounce, complaint, or unsubscribe for that address writes a new record. Lift a suppression only when you have reason to believe the address is good again. ## Expiry A `permanent_bounce` suppression expires automatically after 90 days, on the chance the address is reassigned or the failure was temporary. `complaint`, `unsubscribed`, and `manual` suppressions do not expire. An expired entry is no longer enforced and no longer appears in the list. ## Where to go next - **[Tags, topics & campaigns](/docs/tags-topics-campaigns):** the topic that scopes a suppression. - **[Unsubscribe handling](/docs/unsubscribe):** how a one-click unsubscribe creates a topic-scoped suppression. - **[Webhooks](/docs/webhooks):** receive `email.bounced`, `email.complained`, and `email.suppressed` events. --- Source: /docs/webhooks # Webhooks A webhook is an HTTPS endpoint you register so Anypost can push events to it. Every delivery, bounce, complaint, open, and click is POSTed to your endpoint as it happens, signed so you can verify it came from Anypost. ## Register a webhook `POST /v1/webhooks` registers an endpoint. Give it a name, an `https://` URL, and the events to subscribe to: ```bash curl https://api.anypost.com/v1/webhooks \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production events", "url": "https://hooks.example.com/anypost", "events": ["email.delivered", "email.bounced", "email.complained"] }' ``` ```ts const webhook = await anypost.webhooks.create({ name: "Production events", url: "https://hooks.example.com/anypost", events: ["email.delivered", "email.bounced", "email.complained"], }); console.log(webhook.signing_secret); // store now; returned only here ``` ```python webhook = client.webhooks.create({ "name": "Production events", "url": "https://hooks.example.com/anypost", "events": ["email.delivered", "email.bounced", "email.complained"], }) print(webhook["signing_secret"]) # store now; returned only here ``` ```php $webhook = $client->webhooks->create([ "name" => "Production events", "url" => "https://hooks.example.com/anypost", "events" => ["email.delivered", "email.bounced", "email.complained"], ]); echo $webhook->signing_secret; // store now; returned only here ``` ```ruby webhook = client.webhooks.create( name: "Production events", url: "https://hooks.example.com/anypost", events: ["email.delivered", "email.bounced", "email.complained"] ) puts webhook.signing_secret # store now; returned only here ``` ```rust let webhook = client.webhooks.create(serde_json::json!({ "name": "Production events", "url": "https://hooks.example.com/anypost", "events": ["email.delivered", "email.bounced", "email.complained"] })).await?; println!("{}", webhook["signing_secret"]); // store now; returned only here ``` ```go webhook, _ := client.Webhooks.Create(ctx, &anypost.WebhookCreateParams{ Name: "Production events", URL: "https://hooks.example.com/anypost", Events: []anypost.WebhookEventType{anypost.WebhookEventDelivered, anypost.WebhookEventBounced, anypost.WebhookEventComplained}, }) fmt.Println(webhook.SigningSecret) // store now; returned only here ``` ```java var webhook = client.webhooks.create(WebhookCreateParams.builder() .name("Production events") .url("https://hooks.example.com/anypost") .events(WebhookEventType.DELIVERED, WebhookEventType.BOUNCED, WebhookEventType.COMPLAINED) .build()); System.out.println(webhook.signingSecret()); // store now; returned only here ``` ```csharp var webhook = await client.Webhooks.CreateAsync(new WebhookCreateParams { Name = "Production events", Url = "https://hooks.example.com/anypost", Events = [WebhookEventType.Delivered, WebhookEventType.Bounced, WebhookEventType.Complained], }); Console.WriteLine(webhook.SigningSecret); // store now; returned only here ``` The response includes the `signing_secret`, a `whsec_...` value used to verify deliveries. It is returned only once, at creation. Store it securely; later reads return only its prefix. ```json { "id": "wh_550e8400-e29b-41d4-a716-446655440000", "name": "Production events", "url": "https://hooks.example.com/anypost", "events": ["email.delivered", "email.bounced", "email.complained"], "status": "active", "signing_secret": "whsec_AbCdEfGhIjKlMnOp..." } ``` ## Event types A webhook receives only the events it subscribes to. The nine types: | Event | Emitted when | |---|---| | `email.sent` | Anypost accepted the message and handed it to the outbound mail system. | | `email.delivered` | The receiving server accepted the message. | | `email.delayed` | A delivery attempt failed temporarily and will be retried. | | `email.bounced` | Delivery failed permanently. | | `email.complained` | The recipient marked the message as spam. | | `email.suppressed` | The send was dropped because the address is on your suppression list. | | `email.unsubscribed` | The recipient used a one-click unsubscribe link. | | `email.opened` | The recipient opened the message. | | `email.clicked` | The recipient clicked a tracked link. | ## The delivery request Anypost POSTs a JSON body that batches one or more events: ```json { "batch_id": "9c1f2a7b8e3d4c5a6f0b1d2e3a4b5c6d...", "timestamp": 1730000000, "events": [ { "id": "evt_018f4f3e7b2c7c808e211a3a4f5b6c7d", "type": "email.delivered", "occurred_at": "2026-04-30T12:00:01.500000Z", "data": { "email_id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d", "recipient": "alex@customer.com", "subject": "Welcome to Acme", "topic": "newsletter", "tags": ["onboarding"] } } ] } ``` Each event carries an `id`, a `type`, an `occurred_at`, and a `data` object. `data` always has the `email_id`; the rest depends on the event. A bounce adds a `bounce` object, a click adds a `tracking` object with the destination URL, and an open or click from a mailbox image proxy adds a `bot` label inside that `tracking` object (see [**Open & click tracking**](/docs/tracking)). Fields that do not apply to an event are omitted, not sent as null. The request also carries these headers: | Header | Value | |---|---| | `Anypost-Signature` | `t=,v1=`. One `v1=` per active signing secret. | | `Anypost-Timestamp` | The Unix timestamp that is also inside the signature. | | `Anypost-Batch-Id` | Identifier for this batch. Identical across retries of the same batch. | | `User-Agent` | `Anypost-Webhooks/1.0`. | ## Verify the signature Verify every delivery before trusting it. The signature is an HMAC-SHA256 over the timestamp and the raw request body: 1. Read the `Anypost-Signature` and `Anypost-Timestamp` headers. 2. Reject the request if the timestamp is outside a tolerance you choose; a few minutes is typical. This bounds replay of a captured request. 3. Compute `HMAC-SHA256(signing_secret, "{timestamp}.{raw_body}")` and encode it as lowercase hex. Use the exact bytes of the request body, before any JSON parsing. 4. Compare your result, with a constant-time comparison, against each `v1=` value in the header. A match on any one is a pass. The header carries more than one `v1=` only while a secret rotation is in progress. Checking every component is what lets a delivery keep verifying through a rotation. The [TypeScript](/docs/api-conventions#code-samples), [Python](/docs/api-conventions#code-samples), [PHP](/docs/api-conventions#code-samples), [Ruby](/docs/api-conventions#code-samples), [Rust](/docs/api-conventions#code-samples), [Go](/docs/api-conventions#code-samples), [Java](/docs/api-conventions#code-samples), and [.NET](/docs/api-conventions#code-samples) SDKs do these four steps for you. The unwrap helper verifies the signature and returns the parsed delivery; it rejects deliveries older than five minutes by default and raises a webhook verification error on any mismatch: ```ts import { unwrapWebhookEvent, WebhookVerificationError } from "anypost"; try { const delivery = await unwrapWebhookEvent(rawBody, signatureHeader, secret); for (const event of delivery.events) { // handle event.type, event.data.email_id, ... } } catch (err) { if (err instanceof WebhookVerificationError) return reject(400); throw err; } ``` ```python from anypost import unwrap_webhook_event, WebhookVerificationError try: delivery = unwrap_webhook_event(raw_body, signature_header, secret) except WebhookVerificationError: return reject(400) for event in delivery["events"]: handle(event) # event["type"], event["data"]["email_id"], ... ``` ```php use Anypost\Webhook\WebhookSignature; use Anypost\Webhook\WebhookVerificationException; try { $delivery = WebhookSignature::unwrap($rawBody, $signatureHeader, $secret); } catch (WebhookVerificationException $e) { return reject(400); } foreach ($delivery->events as $event) { handle($event); // $event->type, $event->data->email_id, ... } ``` ```ruby require "anypost" begin delivery = Anypost::WebhookSignature.unwrap(raw_body, signature_header, secret) rescue Anypost::WebhookVerificationError return reject(400) end delivery.events.each do |event| handle(event) # event.type, event.data.email_id, ... end ``` ```rust use anypost::webhook; match webhook::unwrap(raw_body, signature_header, secret) { Ok(delivery) => { for event in delivery["events"].as_array().unwrap() { // handle event["type"], event["data"]["email_id"], ... } } Err(_) => return reject(400), } ``` ```go delivery, err := anypost.UnwrapWebhookEvent(rawBody, signatureHeader, secret) if err != nil { return reject(400) } for _, event := range delivery.Events { // handle event.Type, event.Data["email_id"], ... } ``` ```java WebhookDelivery delivery; try { delivery = WebhookVerifier.unwrap(rawBody, signatureHeader, secret); } catch (WebhookVerificationException e) { return reject(400); } for (WebhookDeliveryEvent event : delivery.events()) { // handle event.type(), event.data().get("email_id"), ... } ``` ```csharp WebhookDelivery delivery; try { delivery = WebhookVerifier.Unwrap(rawBody, signatureHeader, secret); } catch (WebhookVerificationException) { return reject(400); } foreach (var ev in delivery.Events) { // handle ev.Type, ev.Data?["email_id"], ... } ``` Pass the raw request body, not a re-serialized object: the signature is over the exact bytes Anypost sent. When your framework has already parsed the body, reach for the verify-only helper instead. It runs the verify step alone and returns nothing; keep the raw bytes for it, then use your parsed object once it passes: ```ts import { verifyWebhookSignature, WebhookVerificationError } from "anypost"; app.post("/anypost", async (req, res) => { try { await verifyWebhookSignature(req.rawBody, req.header("Anypost-Signature"), secret); } catch (err) { if (err instanceof WebhookVerificationError) return res.status(400).end(); throw err; } for (const event of req.body.events) handle(event); // req.body is already parsed res.status(204).end(); }); ``` ```python from anypost import verify_webhook_signature, WebhookVerificationError @app.post("/anypost") async def anypost_webhook(request): raw = await request.body() try: verify_webhook_signature(raw, request.headers["anypost-signature"], secret) except WebhookVerificationError: return Response(status_code=400) for event in (await request.json())["events"]: handle(event) # the parsed body is reused once the raw bytes verify return Response(status_code=204) ``` ```php use Anypost\Webhook\WebhookSignature; use Anypost\Webhook\WebhookVerificationException; $raw = file_get_contents("php://input"); try { WebhookSignature::verify($raw, $_SERVER["HTTP_ANYPOST_SIGNATURE"] ?? "", $secret); } catch (WebhookVerificationException $e) { http_response_code(400); return; } foreach (json_decode($raw, true)["events"] as $event) { handle($event); // the parsed body is reused once the raw bytes verify } http_response_code(204); ``` ```ruby require "anypost" post "/anypost" do raw = request.body.read begin Anypost::WebhookSignature.verify(raw, request.env["HTTP_ANYPOST_SIGNATURE"], secret) rescue Anypost::WebhookVerificationError halt 400 end JSON.parse(raw)["events"].each { |event| handle(event) } # the parsed body is reused once the raw bytes verify status 204 end ``` ```rust use anypost::webhook; // `raw` is the exact request bytes; `signature` is the Anypost-Signature header. if webhook::verify(raw, signature, secret).is_err() { return reject(400); } let body: serde_json::Value = serde_json::from_slice(raw)?; for event in body["events"].as_array().unwrap() { handle(event); // the parsed body is reused once the raw bytes verify } ``` ```go // raw is the exact request bytes; signature is the Anypost-Signature header. if err := anypost.VerifyWebhookSignature(raw, signature, secret); err != nil { return reject(400) } var body anypost.WebhookDelivery json.Unmarshal(raw, &body) for _, event := range body.Events { handle(event) // the parsed body is reused once the raw bytes verify } ``` ```java // raw is the exact request bytes; signature is the Anypost-Signature header. try { WebhookVerifier.verifySignature(raw, signature, secret); } catch (WebhookVerificationException e) { return reject(400); } for (WebhookDeliveryEvent event : body.events()) { handle(event); // your already-parsed body is reused once the raw bytes verify } ``` ```csharp // raw is the exact request bytes; signature is the Anypost-Signature header. try { WebhookVerifier.VerifySignature(raw, signature, secret); } catch (WebhookVerificationException) { return reject(400); } foreach (var ev in body.Events) { handle(ev); // your already-parsed body is reused once the raw bytes verify } ``` `req.rawBody` is not an Express default — capture it with the `verify` hook on `express.json()` so the exact bytes survive parsing. ## Respond to a delivery Return a `2xx` status as soon as you have stored the batch. Anything else, a `4xx`, a `5xx`, a redirect, or a connection that does not complete within five seconds, is treated as a failure and retried. Do the real work after responding, not before. A webhook handler that runs a slow job inline risks the five-second timeout, which turns a successful receipt into a retry. ## Retries and delivery guarantees A failed batch is retried with exponential backoff, from 30 seconds up to one hour between attempts, with jitter. Retries continue for up to 12 hours from the first attempt; a batch still failing after that is dropped. Two properties to design your handler around: - **At-least-once.** If your `2xx` response is lost in transit, Anypost retries a batch it already delivered. The `batch_id` is stable across retries, and each event `id` is unique. De-duplicate on one of them. - **Order is not guaranteed.** Events can arrive out of the order they occurred. Sort by `occurred_at` if order matters; do not assume, for instance, that `email.delivered` arrives before `email.opened`. ## When an endpoint keeps failing Anypost tracks the health of each endpoint. After a run of consecutive failures it pauses delivery to that endpoint — the webhook's `status` reads `circuit_disabled` — and retries on a slow probe schedule rather than hammering a URL that is down. Events are still queued during this window, so a brief outage loses nothing. If the outage is sustained, the `status` becomes `disabled` automatically. A disabled webhook, whether you disabled it or Anypost did, receives no deliveries, and events that occur while it is disabled are not queued for it. To recover that gap, page your [event history](/docs/events) with `GET /v1/events` once the endpoint is healthy and you have re-enabled the webhook. ## Rotate the signing secret `POST /v1/webhooks/{id}/rotate-secret` issues a new signing secret and returns it once. The previous secret stays valid for a 24-hour grace window. During the window every delivery is signed with both secrets, each as its own `v1=` component, so an endpoint still holding the old secret keeps verifying while you redeploy with the new one. Rotating again before the window ends returns `409`. ## Test a webhook `POST /v1/webhooks/{id}/test` sends a single `webhook.test` event to the endpoint right away and reports the outcome, the status code, the latency, and any error: ```bash curl -X POST https://api.anypost.com/v1/webhooks/wh_550e8400.../test \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` ```ts const result = await anypost.webhooks.test("wh_550e8400-..."); if (!result.delivered) console.error(result.status_code, result.error); ``` ```python result = client.webhooks.test("wh_550e8400-...") if not result["delivered"]: print(result["status_code"], result["error"]) ``` ```php $result = $client->webhooks->test("wh_550e8400-..."); if (! $result->delivered) { echo "{$result->status_code} {$result->error}"; } ``` ```ruby result = client.webhooks.test("wh_550e8400-...") puts "#{result.status_code} #{result.error}" unless result.delivered ``` ```rust let result = client.webhooks.test("wh_550e8400-...").await?; if !result["delivered"].as_bool().unwrap_or(false) { eprintln!("{} {}", result["status_code"], result["error"]); } ``` ```go result, _ := client.Webhooks.Test(ctx, "wh_550e8400-...") if !result.Delivered { fmt.Println("test failed:", *result.Error) } ``` ```java var result = client.webhooks.test("wh_550e8400-..."); if (!result.delivered()) { System.out.println("test failed: " + result.error()); } ``` ```csharp var result = await client.Webhooks.TestAsync("wh_550e8400-..."); if (!result.Delivered) { Console.WriteLine("test failed: " + result.Error); } ``` The test is one-shot: it is not retried and does not appear in delivery history. It works on a disabled webhook too, so you can confirm an endpoint is reachable before re-enabling it. `webhook.test` is reserved for this endpoint and is never emitted by real mail. ## Manage webhooks | Operation | Endpoint | |---|---| | List webhooks | `GET /v1/webhooks` | | Retrieve one | `GET /v1/webhooks/{id}` | | Update name, URL, or events | `PATCH /v1/webhooks/{id}` | | Pause or resume | `PATCH /v1/webhooks/{id}` with `status` | | Delete | `DELETE /v1/webhooks/{id}` | To pause delivery without losing the configuration, `PATCH` the webhook with `status` set to `disabled`; set it back to `active` to resume. Updating the events list changes what the webhook receives from the next event onward. ## Where to go next - **[Events](/docs/events):** the same events, queryable on demand — the pull counterpart to webhooks. - **[Send a single email](/docs/send-email):** every send produces the events a webhook delivers. - **[Open & click tracking](/docs/tracking):** what `email.opened` and `email.clicked` carry. - **[Suppressions](/docs/suppressions):** the list behind `email.suppressed`. --- Source: /docs/events # Events The event stream is every delivery and engagement event for your sent mail, queryable on demand. Where a webhook pushes each event to you as it happens, the events API lets you pull a window of history back whenever you need it. ## Events vs webhooks Both surfaces carry the same event types. They differ in how you consume them. - **Webhooks push.** Anypost POSTs each event to your endpoint as it happens. Best for reacting in real time. See [**Webhooks**](/docs/webhooks). - **Events pull.** You query the stored stream when you want it. Best for backfilling a gap, reconciling your own records, or one-off lookups. The two compose. When a webhook endpoint is down long enough to be disabled, events that occur while it is disabled are not queued. Page the events API once the endpoint is healthy to recover that gap. ## Event types Only customer-facing events are returned. Operational events are excluded. | Type | Emitted when | |---|---| | `email.sent` | Anypost accepted the message and handed it to the outbound mail system. | | `email.delivered` | The receiving server accepted the message. | | `email.delayed` | A delivery attempt failed temporarily and will be retried. | | `email.bounced` | Delivery failed permanently. | | `email.complained` | The recipient marked the message as spam. | | `email.suppressed` | The send was dropped because the address is on your suppression list. | | `email.unsubscribed` | The recipient used a one-click unsubscribe link. | | `email.opened` | The recipient opened the message. | | `email.clicked` | The recipient clicked a tracked link. | ## Listing events `GET /v1/events` returns events newest-first, with cursor pagination. The TypeScript `list` returns a page you can iterate to walk every page: ```bash curl https://api.anypost.com/v1/events \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` ```ts for await (const event of await anypost.events.list({ event_type: "email.bounced" })) { console.log(event.occurred_at, event.recipient, event.bounce_classification); } ``` ```python for event in client.events.list({"event_type": "email.bounced"}): print(event["occurred_at"], event["recipient"], event["bounce_classification"]) ``` ```php foreach ($client->events->list(["event_type" => "email.bounced"]) as $event) { echo "{$event->occurred_at} {$event->recipient} {$event->bounce_classification}\n"; } ``` ```ruby client.events.list(event_type: "email.bounced").each do |event| puts "#{event.occurred_at} #{event.recipient} #{event.bounce_classification}" end ``` ```rust use anypost::EventListParams; for event in client.events.list_all(EventListParams::new().event_type("email.bounced")).await? { println!("{} {} {}", event["occurred_at"], event["recipient"], event["bounce_classification"]); } ``` ```go page, _ := client.Events.List(ctx, anypost.EventListParams{EventType: anypost.EventBounced}) for event, _ := range page.All(ctx) { fmt.Println(event.OccurredAt, *event.Recipient, *event.BounceClassification) } ``` ```java for (Event event : client.events.list(EventListParams.builder().eventType(EventType.BOUNCED).build()).all()) { System.out.println(event.occurredAt() + " " + event.recipient() + " " + event.bounceClassification()); } ``` ```csharp await foreach (var ev in (await client.Events.ListAsync(new EventListParams { EventType = EventType.Bounced })).AllAsync()) { Console.WriteLine($"{ev.OccurredAt} {ev.Recipient} {ev.BounceClassification}"); } ``` Events are read-only and not addressable by id — there is no `GET /v1/events/{id}`. ## Time window The `start` and `end` parameters take ISO 8601 timestamps. The default window is the last 24 hours. The window is clamped to your plan's event retention — 3, 7, or 30 days. A request for 30 days of history on a 3-day-retention plan silently narrows to the last 3 days rather than erroring, so the same call keeps working as you upgrade. ## Filters All filters are exact-match, except `tags`. A value that matches no stored row returns an empty page; there is no substring search. | Parameter | Restricts to | |---|---| | `event_type` | One of the nine event types above. | | `recipient` | One exact recipient address, lowercased server-side. | | `email_id` | One message's `email_` id. | | `message_id` | An exact `Message-ID:` header match. | | `domain` | A sending domain hostname (not the `domain_` id). An unknown domain returns `400`. | | `topic` | The send-time topic the message was tagged with. | | `campaign` | The send-time `campaign` value. Case-sensitive. | | `template_id` | The template the originating send used. | | `tags` | Events carrying *any* of the given tags. Pass comma-separated (`tags=a,b`); up to 10. | Pass several at once to narrow further. To trace one message end to end, filter by its `email_id`: ```bash curl -G https://api.anypost.com/v1/events \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ --data-urlencode "email_id=email_019e1972-e87e-7000-bf74-ba09e0ed0d62" \ --data-urlencode "limit=100" ``` ```ts const page = await anypost.events.list({ email_id: "email_019e1972-e87e-7000-bf74-ba09e0ed0d62", limit: 100, }); for (const event of page.data) console.log(event.type, event.occurred_at); ``` ```python page = client.events.list({ "email_id": "email_019e1972-e87e-7000-bf74-ba09e0ed0d62", "limit": 100, }) for event in page.data: print(event["type"], event["occurred_at"]) ``` ```php $page = $client->events->list([ "email_id" => "email_019e1972-e87e-7000-bf74-ba09e0ed0d62", "limit" => 100, ]); foreach ($page->data as $event) { echo "{$event->type} {$event->occurred_at}\n"; } ``` ```ruby page = client.events.list(email_id: "email_019e1972-e87e-7000-bf74-ba09e0ed0d62", limit: 100) page.data.each do |event| puts "#{event.type} #{event.occurred_at}" end ``` ```rust use anypost::EventListParams; let page = client.events.list( EventListParams::new() .email_id("email_019e1972-e87e-7000-bf74-ba09e0ed0d62") .limit(100), ).await?; for event in &page.data { println!("{} {}", event["type"], event["occurred_at"]); } ``` ```go page, _ := client.Events.List(ctx, anypost.EventListParams{ EmailID: "email_019e1972-e87e-7000-bf74-ba09e0ed0d62", ListParams: anypost.ListParams{Limit: 100}, }) for _, event := range page.Data { fmt.Println(event.Type, event.OccurredAt) } ``` ```java Page page = client.events.list(EventListParams.builder() .emailId("email_019e1972-e87e-7000-bf74-ba09e0ed0d62") .limit(100) .build()); for (Event event : page.data()) { System.out.println(event.type() + " " + event.occurredAt()); } ``` ```csharp var page = await client.Events.ListAsync(new EventListParams { EmailId = "email_019e1972-e87e-7000-bf74-ba09e0ed0d62", Limit = 100, }); foreach (var ev in page.Data) { Console.WriteLine($"{ev.Type} {ev.OccurredAt}"); } ``` ## The event object Every event carries the same fields. The ones that do not apply to a given type are `null` rather than omitted: `smtp_code` is null on an open, `bounce_type` and `bounce_classification` are set only on `email.bounced`, `attempt` is null on non-delivery events, and `tracking` is null except on opens and clicks. The [**Events API reference**](/docs/reference/events) lists every field. The correlation dimensions — `tags`, `topic`, `campaign`, and `template_id` — travel onto every event for a message, engagement events included, so you can measure a campaign or template at open and click time, not just at send. On an open or click, a non-null `tracking` object carries a `bot` block when the interaction came from a mailbox image proxy rather than the recipient's own client. Machine traffic that is pure noise — prefetchers and scanners — never becomes an event at all, so it cannot inflate a count here, in the dashboard, or on your webhooks. See [**Open & click tracking**](/docs/tracking) for the full model. ## Where to go next - **[Webhooks](/docs/webhooks):** the same events, pushed to your endpoint in real time. - **[Open & click tracking](/docs/tracking):** what `email.opened` and `email.clicked` capture. - **[Tags, topics & campaigns](/docs/tags-topics-campaigns):** the dimensions you filter events by. --- Source: /docs/ai # AI assistants These docs are published for machines as well as people. Every page has a plain-Markdown version, and the whole documentation is available as one file following the llms.txt convention. An assistant that can fetch a URL can integrate Anypost without you pasting docs into the chat. ## Machine-readable docs | URL | Contents | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | An index of every page with a one-line description, per [llmstxt.org](https://llmstxt.org). | | [`/llms-full.txt`](/llms-full.txt) | The entire documentation as a single Markdown file. | | `/docs/.md` | One page as plain Markdown. Append `.md` to any docs path, for example [`/docs/send-email.md`](/docs/send-email.md). | All three are public, require no API key, and are regenerated from the same source as these pages on every deploy, so they never lag what you are reading. For most tasks `llms-full.txt` is the right file: it fits comfortably in a modern model's context window and saves the assistant a page-by-page crawl. ## Give your assistant the docs The fastest path is a single prompt: ```text Read https://anypost.com/llms-full.txt, then add email sending to this app using Anypost. Read the API key from an environment variable. ``` ### Claude Code Point sessions at the docs from your project's `CLAUDE.md` so you never have to repeat yourself: ```markdown ## Email This app sends email through Anypost. Full API docs: https://anypost.com/llms-full.txt ``` ### Cursor Add `https://anypost.com/llms-full.txt` as a custom docs source (**@Docs**, then **Add new doc**). Cursor indexes it and pulls relevant sections into context when you reference it. ### ChatGPT Paste the URL into the conversation and ask your question. With browsing enabled, ChatGPT fetches the file and answers from it: ```text Using the API described at https://anypost.com/llms-full.txt, write a Python function that sends a templated email with two variables. ``` ## Open a page in your assistant Every page of these docs has a copy menu next to the breadcrumb. It copies the page as Markdown for pasting into any chat, links to the raw `.md` version, and opens the page directly in Claude or ChatGPT with the Markdown URL preloaded as context. ## Where to go next - **[Quickstart](/docs/quickstart):** the four steps your assistant will automate. - **[Authentication](/docs/authentication):** create the API key the generated code will read. - **[API conventions](/docs/api-conventions):** the request and error shapes the whole API shares. --- Source: /docs/supabase # Supabase Supabase Auth emails its users at every turn: signup confirmations, magic links, password resets, invites. Its built-in email service is for prototyping only. Point Supabase at Anypost and auth email sends from your verified domain, with the same delivery, bounce, and engagement events as everything else you send. There are two ways in, and they solve different problems: - **Custom SMTP** is a settings change. Supabase keeps rendering its own auth templates and hands the finished message to Anypost for delivery. Right for most projects. - **A Send Email Hook** replaces Supabase's rendering entirely. Supabase calls an Edge Function with the token, and the function sends whatever it wants through the Anypost API. Reach for it when you need full control of the markup, localization, or Anypost templates. Application email that has nothing to do with Auth sends from [Edge Functions with the HTTP API](#send-application-email-from-edge-functions). ## Before you start - Verify the domain you will send from. See [**Domains & DNS setup**](/docs/domains). Supabase recommends keeping auth email off your marketing domain; a subdomain such as `auth.yourdomain.com` verifies like any other domain. - Create an API key. A `send_only` key is enough for both paths. See [**Authentication**](/docs/authentication). ## Custom SMTP In your Supabase project, open **Project Settings**, then **Authentication**, and enable **Custom SMTP**. Fill in: | Setting | Value | | ------------ | ---------------------------------- | | Host | `smtp.anypost.com` | | Port | `587` | | Username | `anypost` | | Password | your API key secret (`ap_...`) | | Sender email | an address on your verified domain | | Sender name | the name recipients see | Supabase performs the required STARTTLS upgrade on port `587` automatically. If your network blocks `587`, use `2587`. See [**Sending over SMTP**](/docs/sending-over-smtp) for the full connection contract. ### Raise the Auth rate limit After you enable custom SMTP, Supabase caps auth email at 30 messages per hour by default. Raise it under **Authentication**, then **Rate Limits**, to match your signup volume. Anypost applies no per-hour cap of its own; auth sends count toward your plan like any other message. See [**Sending limits**](/docs/sending-limits). ### Test it Trigger a password reset or a signup from your app. The message appears in the Anypost dashboard within seconds, and delivery, bounce, open, and click events fire for it exactly as for an API send. See [**Events**](/docs/events). ## Send Email Hook Supabase's Send Email Hook hands each auth email to an HTTP endpoint instead of sending it. An Edge Function receives the user, the one-time token, and the action type, and is free to build the message however it likes. While the hook is enabled it replaces SMTP for auth email entirely. ### Write the function ```bash supabase functions new send-auth-email ``` The function verifies the hook signature, builds a confirmation URL from the token, and sends through the Anypost API: ```ts // supabase/functions/send-auth-email/index.ts import { Webhook } from 'npm:standardwebhooks@1.0.0'; const hookSecret = Deno.env .get('SEND_EMAIL_HOOK_SECRET')! .replace('v1,whsec_', ''); const anypostKey = Deno.env.get('ANYPOST_API_KEY')!; const subjects: Record = { signup: 'Confirm your email', invite: 'You have been invited', magiclink: 'Your login link', recovery: 'Reset your password', email_change: 'Confirm your new email address', }; Deno.serve(async (req) => { const payload = await req.text(); let user, email_data; try { ({ user, email_data } = new Webhook(hookSecret).verify( payload, Object.fromEntries(req.headers), ) as { user: { email: string }; email_data: Record }); } catch { return Response.json({ error: 'invalid signature' }, { status: 401 }); } const { token, token_hash, email_action_type, redirect_to } = email_data; const confirmUrl = `${Deno.env.get('SUPABASE_URL')}/auth/v1/verify` + `?token=${token_hash}&type=${email_action_type}` + `&redirect_to=${encodeURIComponent(redirect_to)}`; const res = await fetch('https://api.anypost.com/v1/email', { method: 'POST', headers: { Authorization: `Bearer ${anypostKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: 'Acme ', to: [user.email], subject: subjects[email_action_type] ?? 'Your verification code', html: `

Confirm, ` + `or enter the code ${token}.

`, topic: 'auth', }), }); if (!res.ok) { return Response.json({ error: await res.text() }, { status: 500 }); } return Response.json({}); }); ``` A `topic` of `auth` keeps these sends filterable in your event stream; see [**Tags, topics & campaigns**](/docs/tags-topics-campaigns). To render stored content instead of inline HTML, send `template_id` and `variables`; see [**Sending with templates**](/docs/templates). Two action types carry extra fields: with secure email change enabled, `email_change` includes `token_new` and `token_hash_new` for the second message to the old address, and `reauthentication` carries a code but no link. Handle them if your app uses those flows. ### Deploy and enable Deploy without JWT verification; Supabase authenticates hook calls with the signature, not a user token: ```bash supabase functions deploy send-auth-email --no-verify-jwt supabase secrets set ANYPOST_API_KEY="ap_..." ``` Then, in the dashboard under **Authentication**, then **Hooks**, add a **Send Email** hook pointing at the function. Copy the secret Supabase generates and store it: ```bash supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_..." ``` **Careful.** While the hook is enabled, a non-200 response fails the auth request itself: the user's signup or reset attempt errors and no email is sent. Keep the function minimal and watch its logs after enabling. Test each flow you use (signup, magic link, recovery) before relying on it. ## Send application email from Edge Functions Any Edge Function can send through the HTTP API directly; there is nothing Supabase-specific about it: ```ts const res = await fetch('https://api.anypost.com/v1/email', { method: 'POST', headers: { Authorization: `Bearer ${Deno.env.get('ANYPOST_API_KEY')}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: 'Acme ', to: ['someone@example.com'], subject: 'Your export is ready', html: '

Download it from your dashboard.

', }), }); const { id } = await res.json(); ``` A `202` and the `id` mean Anypost accepted the message; delivery and engagement arrive later as [events](/docs/events). Pair this with a Supabase Database Webhook to email on a table insert. Webhook deliveries can retry, so set an `Idempotency-Key` header derived from the row to keep a retried delivery from sending twice. See [**Send a single email**](/docs/send-email). ## Where to go next - **[Domains & DNS setup](/docs/domains):** verify the domain your auth email sends from. - **[Authentication](/docs/authentication):** create and scope the API key Supabase uses. - **[Sending over SMTP](/docs/sending-over-smtp):** the full SMTP connection contract. - **[Events](/docs/events):** every delivery and engagement event these sends produce. --- Source: /docs/zapier # Zapier The Anypost integration for Zapier connects your sending and your event stream to thousands of apps without writing code. Send a message from any Zap, or start a Zap whenever Anypost reports what happened to one. **Connect.** [Connect Anypost to Zapier](https://zapier.com/apps/anypost/integrations) to add the trigger and action to your Zaps. ## Connect your account Zapier authenticates with an Anypost API key. Create one in the dashboard under **Settings**, then **API Keys**, and paste it when you add Anypost to a Zap. A `send_only` key is enough for the Send Email action; the New Email Event trigger needs a `full` key, because it creates a webhook subscription on your behalf. See [**Authentication**](/docs/authentication) for creating and scoping keys. ## Trigger: New Email Event Starts a Zap when an email event occurs. Pick one event type per Zap: | Event | When it fires | |---|---| | `email.sent` | The message was accepted for delivery. | | `email.delivered` | The receiving server accepted the message. | | `email.delayed` | Delivery was deferred and will be retried. | | `email.bounced` | Delivery failed permanently. | | `email.complained` | The recipient marked the message as spam. | | `email.suppressed` | A send was blocked by the suppression list. | | `email.unsubscribed` | The recipient used a one-click unsubscribe. | | `email.opened` | The recipient opened the message. | | `email.clicked` | The recipient clicked a tracked link. | Turning the Zap on creates an Anypost webhook scoped to that event; turning it off removes it. Each event carries the message id, recipient, and the type-specific details. See [**Events**](/docs/events) for the full payload. ## Action: Send Email Sends a transactional message through Anypost. Provide a `from` address on a verified domain and at least one recipient, then supply the body as HTML, plain text, or a stored template. Cc, Bcc, reply-to, tags, topic, campaign, and an optional idempotency key are all available. Map fields from earlier steps in your Zap into any of these, so a form submission, new row, or chat message can become a sent email. ## Templates and variables Select a published [template](/docs/templates) from the **Template** field to render stored content instead of an inline body, and fill its `{{ markers }}` with the **Variables** field. Variables are substituted per recipient at send time. To send Markdown, author a Markdown template in Anypost and select it here; it is rendered server-side. See [**Variables & personalization**](/docs/variables). ## Where to go next - **[Authentication](/docs/authentication):** create and scope the API key Zapier uses. - **[Domains & DNS setup](/docs/domains):** verify the domain you send from. - **[Events](/docs/events):** the shape of every event the trigger receives. - **[Webhooks](/docs/webhooks):** how Anypost signs and retries the deliveries behind the trigger. --- Source: /docs/api-conventions # API conventions These conventions hold for every endpoint of the Anypost HTTP API: how requests are shaped, how responses and errors come back, and how pagination and idempotency work. Individual endpoint docs assume them. ## Base URL ``` https://api.anypost.com/v1 ``` Every request goes over HTTPS to a path under `/v1`. The version is part of the path; `v1` is the only version. ## Code samples Most examples in these docs carry a language picker: use the tabs above a code block to switch between `curl`, the TypeScript SDK, the Python SDK, the PHP SDK, the Ruby SDK, the Rust SDK, the Go SDK, the Java SDK, and the .NET SDK. Your choice is remembered as you move between pages. The API is plain JSON over HTTPS, so any HTTP client works. An official TypeScript SDK is available for Node 18+, Bun, and Deno ([source on GitHub](https://github.com/anypost/anypost-node)): ```bash npm install anypost ``` ```ts import { Anypost } from 'anypost'; const anypost = new Anypost('ap_your_api_key'); const { id } = await anypost.email.send({ from: 'you@yourdomain.com', to: ['someone@example.com'], subject: 'Hello from Anypost', text: 'Hello, inbox!', }); ``` The TypeScript SDK can also render a message body written in Markdown. See [**Send email as Markdown**](/docs/markdown). An official Python SDK is available for Python 3.9+, with sync and async clients ([source on GitHub](https://github.com/anypost/anypost-python)): ```bash pip install anypost ``` ```python from anypost import Anypost client = Anypost("ap_your_api_key") result = client.email.send({ "from": "you@yourdomain.com", "to": ["someone@example.com"], "subject": "Hello from Anypost", "text": "Hello, inbox!", }) ``` An official PHP SDK is available for PHP 8.1+ ([source on GitHub](https://github.com/anypost/anypost-php)): ```bash composer require anypost/anypost-php ``` ```php use Anypost\Anypost; $client = new Anypost("ap_your_api_key"); $email = $client->email->send([ "from" => "you@yourdomain.com", "to" => ["someone@example.com"], "subject" => "Hello from Anypost", "text" => "Hello, inbox!", ]); ``` An official Ruby SDK is available for Ruby 3.2+ ([source on GitHub](https://github.com/anypost/anypost-ruby)): ```bash gem install anypost ``` ```ruby require "anypost" client = Anypost::Client.new("ap_your_api_key") email = client.email.send( from: "you@yourdomain.com", to: ["someone@example.com"], subject: "Hello from Anypost", text: "Hello, inbox!" ) ``` An official Rust SDK is available for Rust 1.75+, async with an optional blocking client ([source on GitHub](https://github.com/anypost/anypost-rust)): ```bash cargo add anypost ``` ```rust use anypost::{Client, SendEmail}; let client = Client::new("ap_your_api_key")?; let email = client.email.send( &SendEmail::new("you@yourdomain.com", ["someone@example.com"]) .subject("Hello from Anypost") .text("Hello, inbox!"), ).await?; ``` An official Go SDK is available for Go 1.23+, with zero dependencies ([source on GitHub](https://github.com/anypost/anypost-go)): ```bash go get github.com/anypost/anypost-go ``` ```go import "github.com/anypost/anypost-go" client, _ := anypost.New("ap_your_api_key") sent, _ := client.Email.Send(context.Background(), &anypost.SendEmailRequest{ From: "you@yourdomain.com", To: []string{"someone@example.com"}, Subject: "Hello from Anypost", Text: "Hello, inbox!", }) ``` An official Java SDK is available for Java 17+ ([source on GitHub](https://github.com/anypost/anypost-java)): ```xml com.anypost anypost-java 0.1.0 ``` ```java import com.anypost.Anypost; import com.anypost.model.SendEmailRequest; Anypost client = Anypost.create("ap_your_api_key"); var sent = client.email.send(SendEmailRequest.builder() .from("you@yourdomain.com") .to("someone@example.com") .subject("Hello from Anypost") .text("Hello, inbox!") .build()); ``` An official .NET SDK is available for .NET 8+ ([source on GitHub](https://github.com/anypost/anypost-dotnet)): ```bash dotnet add package Anypost ``` ```csharp using Anypost; using Anypost.Models; var client = AnypostClient.Create("ap_your_api_key"); var sent = await client.Email.SendAsync(new SendEmailRequest { From = "you@yourdomain.com", To = ["someone@example.com"], Subject = "Hello from Anypost", Text = "Hello, inbox!", }); ``` ## Requests Send request bodies as JSON with `Content-Type: application/json`, encoded as UTF-8. Authenticate with a bearer token; see [**Authentication**](/docs/authentication). Request bodies are capped at 5 MB. A larger body is rejected with `413` before authentication or validation runs. ## Resource IDs Every resource has an ID prefixed with its type, in the form `{prefix}_{uuid}`. The prefix tells you what an ID refers to at a glance, and routes reject a mismatched prefix rather than silently missing. | Prefix | Resource | | ----------- | ---------------- | | `email_` | A sent message | | `key_` | An API key | | `domain_` | A sending domain | | `template_` | A template | | `wh_` | A webhook | ## Responses and status codes Successful responses return JSON, except `204`, which has no body. | Status | Meaning | | ------------ | ----------------------------------------------------------------------------------------------- | | `200` | The request succeeded. | | `201` | A resource was created. | | `202` | A message was accepted for delivery. | | `204` | Success, with no response body. | | `207` | A batch send completed with mixed per-entry outcomes; read the body. | | `400`, `422` | The request was invalid. | | `401` | Authentication failed. | | `403` | Authenticated, but not permitted to do this. | | `404` | No such resource. | | `409` | A request conflict — see Idempotency below. | | `413` | The request body exceeded 5 MB. | | `429` | A rate limit or send-volume limit was exceeded. See [**Sending limits**](/docs/sending-limits). | | `5xx` | A server error. `502` and `503` are safe to retry. | ## Errors Every error response uses the same shape: ```json { "error": { "type": "validation_error", "message": "The from field is required.", "errors": { "from": ["The from field is required."] } } } ``` - `type` is a stable, machine-readable string. Branch on this, not on the HTTP status or the human-readable `message`. - `message` is a single human-readable sentence. - `errors` appears only on `validation_error`. It maps each rejected field to a list of problems. | `type` | Status | Meaning | | ------------------------------ | ------------ | -------------------------------------------------------------- | | `validation_error` | `400`, `422` | The request body or query failed validation. | | `authentication_error` | `401` | The API key is missing or invalid. | | `permission_error` | `403` | The key may not perform this action. | | `not_found` | `404` | No such resource for this team. | | `idempotency_concurrent` | `409` | A request with the same `Idempotency-Key` is still in flight. | | `idempotency_mismatch` | `422` | An `Idempotency-Key` was reused with a different body. | | `webhook_rotation_in_progress` | `409` | A webhook signing-secret rotation is already in progress. | | `rate_limit_exceeded` | `429` | A rate limit was exceeded. | | `provisioning_error` | `503` | Anypost could not complete a provisioning step. Safe to retry. | | `internal_error` | `5xx` | An unexpected server error. | Two responses on the send endpoints fall outside this envelope: a `413` (oversized body, rejected at the transport layer) and a `429` send-volume rejection, which returns a flat `{ "error": "quota_exceeded", "scope", ... }` body. See [**Sending limits**](/docs/sending-limits) for the `429` shape. ## Pagination List endpoints return results newest-first, in pages, using an opaque cursor. | Parameter | What it does | | --------- | -------------------------------------------------- | | `limit` | Items per page, 1 to 100. Defaults to 20. | | `after` | A cursor from a previous response's `next_cursor`. | Each page is shaped: ```json { "data": [], "has_more": true, "next_cursor": "MjAyNi0wNC0zMFQx..." } ``` To fetch the next page, pass `next_cursor` as `after`. When `has_more` is `false`, `next_cursor` is `null` and there are no more pages. Cursors are opaque: do not parse or construct them. The [TypeScript](#code-samples) and [Python](#code-samples) SDKs return a page you can read directly or iterate to walk every page: ```ts const page = await anypost.domains.list({ limit: 50 }); page.data; // first page; page.next_cursor for the next for await (const domain of await anypost.domains.list()) { console.log(domain.name); // every domain, fetching pages as needed } ``` ```python page = client.domains.list({"limit": 50}) page.data # first page; page.next_cursor for the next for domain in client.domains.list(): print(domain["name"]) # every domain, fetching pages as needed ``` ```php $page = $client->domains->list(["limit" => 50]); $page->data; // first page; $page->nextCursor for the next foreach ($client->domains->list() as $domain) { echo $domain->name; // every domain, fetching pages as needed } ``` ```ruby page = client.domains.list(limit: 50) page.data # first page; page.next_cursor for the next client.domains.list.each do |domain| puts domain.name # every domain, fetching pages as needed end ``` ```rust use anypost::ListParams; let page = client.domains.list(ListParams::new().limit(50)).await?; page.data; // first page; page.next_cursor for the next for domain in client.domains.list_all(ListParams::new()).await? { println!("{}", domain["name"]); // every domain, fetching pages as needed } ``` ```go page, _ := client.Domains.List(ctx, anypost.ListParams{Limit: 50}) page.Data // first page; page.NextCursor for the next all, _ := client.Domains.List(ctx, anypost.ListParams{}) for domain, _ := range all.All(ctx) { fmt.Println(domain.Name) // every domain, fetching pages as needed } ``` ```java Page page = client.domains.list(ListParams.builder().limit(50).build()); page.data(); // first page; page.nextCursor() for the next for (Domain domain : client.domains.list().all()) { System.out.println(domain.name()); // every domain, fetching pages as needed } ``` ```csharp var page = await client.Domains.ListAsync(new ListParams { Limit = 50 }); var domains = page.Data; // first page; page.NextCursor for the next await foreach (var domain in (await client.Domains.ListAsync()).AllAsync()) { Console.WriteLine(domain.Name); // every domain, fetching pages as needed } ``` ## Idempotency The send endpoints, `POST /v1/email` and `POST /v1/email/batch`, accept an optional `Idempotency-Key` header so a retry cannot send twice. The key is 1 to 255 printable-ASCII characters. - **First use:** the request runs normally and its response is stored for 24 hours. - **Reused with the same body:** the stored response is returned verbatim. The message is not sent again. - **Reused with a different body:** the request is rejected with `422` `idempotency_mismatch`. Use a fresh key or send the original body. - **Reused while the first request is still in flight:** the second request is rejected with `409` `idempotency_concurrent`. Retry once it settles. A request with no `Idempotency-Key` runs with no idempotency guarantee. Server errors (`5xx`) are not stored, so retrying after one genuinely retries the send. ## Timestamps All timestamps are ISO 8601 in UTC, with the `Z` marker and microsecond precision: ``` 2026-04-30T17:42:11.123456Z ``` --- Source: /docs/reference/identity # Identity API Inspect the identity behind the API key making the request. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## Identify the current API key `GET /v1/whoami` Returns the team and permission level behind the API key on the request. Unlike every other management endpoint, this one is reachable by `send_only` keys — it is the only way such a key can confirm its own identity. Takes no parameters. #### Example request ```bash curl https://api.anypost.com/v1/whoami \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `team` (object, optional) A minimal summary of the team the API key belongs to, or `null` if it could not be resolved. May be null. `api_key` (object, optional) The API key that authenticated the request. ```json { "team": { "id": "team_550e8400-e29b-41d4-a716-446655440000", "name": "Acme Inc." }, "api_key": { "id": "key_550e8400-e29b-41d4-a716-446655440000", "permissions": "full" } } ``` #### Responses | Status | Description | |---|---| | `200` | The resolved identity. | | `401` | Missing or invalid credentials. | --- Source: /docs/reference/api-keys # API Keys API Manage API keys for authenticating requests. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## List API keys `GET /v1/api-keys` Returns the authenticated team's API keys, newest-first, with cursor pagination. #### Parameters `limit` (integer, in query, optional) Number of items to return. `after` (string, in query, optional) Opaque cursor from a previous response's `next_cursor`. Do not parse. #### Example request ```bash curl https://api.anypost.com/v1/api-keys \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `data` (array of ApiKey, optional) `has_more` (boolean, optional) `next_cursor` (string, optional) May be null. ```json { "data": [ { "id": "key_550e8400-e29b-41d4-a716-446655440000", "name": "Production server", "key_prefix": "ap_AbCdEf12Kx", "permissions": "full", "allowed_domains": [ "string" ], "allowed_ips": [ "string" ], "last_used_at": "string", "created_at": "string" } ], "has_more": true, "next_cursor": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | Paginated list of API keys. | | `401` | Missing or invalid credentials. | ## Create an API key `POST /v1/api-keys` Issues a new API key for the authenticated team. The full plaintext secret is returned only in the response to this request — store it securely; it cannot be retrieved later. #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, required) `permissions` (string, required) One of: `full`, `send_only`. `allowed_domains` (array of string, optional) May be null. `allowed_ips` (array of string, optional) May be null. #### Example request ```bash curl https://api.anypost.com/v1/api-keys \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "permissions": "full" }' ``` #### Response body On success (`201`), the response body is: `id` (string, optional) `name` (string, optional) `key_prefix` (string, optional) The first 12 characters of the key, shown for identification. `permissions` (string, optional) One of: `full`, `send_only`. `allowed_domains` (array of string, optional) Domains this key may send from. Null means all verified domains. May be null. `allowed_ips` (array of string, optional) IP addresses or CIDR blocks allowed to use this key. Null means all IPs. May be null. `last_used_at` (string (date-time), optional) May be null. `created_at` (string (date-time), optional) `key` (string, optional) The full API key. Returned only once at creation. Store it securely. ```json { "id": "key_550e8400-e29b-41d4-a716-446655440000", "name": "Production server", "key_prefix": "ap_AbCdEf12Kx", "permissions": "full", "allowed_domains": [ "string" ], "allowed_ips": [ "string" ], "last_used_at": "string", "created_at": "string", "key": "ap_AbCdEf12KxLmNoPq..." } ``` #### Responses | Status | Description | |---|---| | `201` | API key created. The full secret is returned only in this response. | | `401` | Missing or invalid credentials. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | ## Retrieve an API key `GET /v1/api-keys/{id}` Returns metadata for a single API key. The full secret is never returned here. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/api-keys/key_550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `key_prefix` (string, optional) The first 12 characters of the key, shown for identification. `permissions` (string, optional) One of: `full`, `send_only`. `allowed_domains` (array of string, optional) Domains this key may send from. Null means all verified domains. May be null. `allowed_ips` (array of string, optional) IP addresses or CIDR blocks allowed to use this key. Null means all IPs. May be null. `last_used_at` (string (date-time), optional) May be null. `created_at` (string (date-time), optional) ```json { "id": "key_550e8400-e29b-41d4-a716-446655440000", "name": "Production server", "key_prefix": "ap_AbCdEf12Kx", "permissions": "full", "allowed_domains": [ "string" ], "allowed_ips": [ "string" ], "last_used_at": "string", "created_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The API key. | | `404` | Resource not found. | ## Update an API key `PATCH /v1/api-keys/{id}` Updates an existing API key's name, permissions, and allowed domain / IP restrictions. The plaintext secret is intentionally not rotated here — rotation will be a separate flow. Pass an empty array (or omit) for `allowed_domains` / `allowed_ips` to lift the restriction. Changes typically take effect on the next request, but updated permissions and restrictions may take up to 5 minutes to propagate due to gateway caching. #### Parameters `id` (string, in path, required) #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, required) `permissions` (string, required) One of: `full`, `send_only`. `allowed_domains` (array of string, optional) May be null. `allowed_ips` (array of string, optional) May be null. #### Example request ```bash curl https://api.anypost.com/v1/api-keys/key_550e8400-e29b-41d4-a716-446655440000 \ -X PATCH \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "permissions": "full" }' ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `key_prefix` (string, optional) The first 12 characters of the key, shown for identification. `permissions` (string, optional) One of: `full`, `send_only`. `allowed_domains` (array of string, optional) Domains this key may send from. Null means all verified domains. May be null. `allowed_ips` (array of string, optional) IP addresses or CIDR blocks allowed to use this key. Null means all IPs. May be null. `last_used_at` (string (date-time), optional) May be null. `created_at` (string (date-time), optional) ```json { "id": "key_550e8400-e29b-41d4-a716-446655440000", "name": "Production server", "key_prefix": "ap_AbCdEf12Kx", "permissions": "full", "allowed_domains": [ "string" ], "allowed_ips": [ "string" ], "last_used_at": "string", "created_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The updated API key. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | ## Delete an API key `DELETE /v1/api-keys/{id}` Permanently deletes the key. Requests using a deleted key may continue to authenticate for up to 5 minutes due to gateway caching. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/api-keys/key_550e8400-e29b-41d4-a716-446655440000 \ -X DELETE \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Responses | Status | Description | |---|---| | `204` | Deleted. | | `404` | Resource not found. | --- Source: /docs/reference/domains # Domains API Manage sending domains and their DKIM verification status. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## List domains `GET /v1/domains` Returns the authenticated team's domains, newest-first, with cursor pagination. #### Parameters `limit` (integer, in query, optional) Number of items to return. `after` (string, in query, optional) Opaque cursor from a previous response's `next_cursor`. Do not parse. #### Example request ```bash curl https://api.anypost.com/v1/domains \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `data` (array of Domain, optional) `has_more` (boolean, optional) `next_cursor` (string, optional) May be null. ```json { "data": [ { "id": "domain_550e8400-e29b-41d4-a716-446655440000", "name": "example.com", "status": "pending", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "verification_failure": { "code": "string", "message": "string" }, "tracking": { "opens_enabled": true, "clicks_enabled": true, "subdomain": "track", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "status": "disabled", "verification_failure": { "code": "string", "message": "string" }, "verified_at": "string" }, "created_at": "string", "verified_at": "string" } ], "has_more": true, "next_cursor": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | Paginated list of domains. | | `401` | Missing or invalid credentials. | ## Create a domain `POST /v1/domains` Adds a new sending domain for the authenticated team and generates its DKIM keys. Anypost publishes the authoritative DKIM/SPF/MX records in its own DNS; the customer only needs to publish the CNAME records returned in the response's `dns_records` array, then call `POST /domains/{id}/verify`. If Anypost cannot publish the domain's records, the create is rolled back atomically and a `503` is returned — no half-created domain is left behind. Safe to retry. #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, required) #### Example request ```bash curl https://api.anypost.com/v1/domains \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "example.com" }' ``` #### Response body On success (`201`), the response body is: `id` (string, optional) `name` (string, optional) `status` (string, optional) `pending` until the customer's CNAMEs resolve to our authoritative records; flips to `verified` after a successful verify call. Reflects mail-flow verification only — tracking-CNAME issues are reported separately via `tracking.status` and never affect this field. One of: `pending`, `verified`. `dns_records` (array of DnsRecord, optional) The mail-flow DNS records the customer should publish at their provider to verify the domain and route DKIM/SPF/MX through us. Iterate and publish each record verbatim. Always one verification CNAME plus one CNAME per active DKIM selector (typically two). Tracking-related records are surfaced separately under `tracking.dns_records`. `verification_failure` (VerificationFailure or null, optional) Most recent mail-flow verification failure, or null when the domain is verified or has never been checked. `code` is one of `apex_cname_missing`, `apex_cname_mismatch`, `dkim_cname_missing`, `dkim_cname_mismatch`, `dkim_mismatch`, `chain_broken`, `mx_missing`, `spf_missing`. `tracking` (DomainTracking, optional) Branded open / click tracking configuration for a domain. Tracking is opt-in and entirely independent of mail-flow verification — failures here never affect `Domain.status` or interrupt sending. `created_at` (string (date-time), optional) `verified_at` (string (date-time), optional) When the domain most recently transitioned to `verified`. May be null. ```json { "id": "domain_550e8400-e29b-41d4-a716-446655440000", "name": "example.com", "status": "pending", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "verification_failure": { "code": "string", "message": "string" }, "tracking": { "opens_enabled": true, "clicks_enabled": true, "subdomain": "track", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "status": "disabled", "verification_failure": { "code": "string", "message": "string" }, "verified_at": "string" }, "created_at": "string", "verified_at": "string" } ``` #### Responses | Status | Description | |---|---| | `201` | Domain created. Status is `pending` until verified. | | `401` | Missing or invalid credentials. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | | `503` | Anypost could not publish the domain's authoritative records. The domain was rolled back, so it is safe to retry. Persistent failures are an operational issue on Anypost's side. | ## Retrieve a domain `GET /v1/domains/{id}` Returns metadata for a single domain, including the CNAMEs the customer must publish, the active DKIM selectors, the most recent verification failure category (if any), and the current tracking configuration and status. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/domains/domain_550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `status` (string, optional) `pending` until the customer's CNAMEs resolve to our authoritative records; flips to `verified` after a successful verify call. Reflects mail-flow verification only — tracking-CNAME issues are reported separately via `tracking.status` and never affect this field. One of: `pending`, `verified`. `dns_records` (array of DnsRecord, optional) The mail-flow DNS records the customer should publish at their provider to verify the domain and route DKIM/SPF/MX through us. Iterate and publish each record verbatim. Always one verification CNAME plus one CNAME per active DKIM selector (typically two). Tracking-related records are surfaced separately under `tracking.dns_records`. `verification_failure` (VerificationFailure or null, optional) Most recent mail-flow verification failure, or null when the domain is verified or has never been checked. `code` is one of `apex_cname_missing`, `apex_cname_mismatch`, `dkim_cname_missing`, `dkim_cname_mismatch`, `dkim_mismatch`, `chain_broken`, `mx_missing`, `spf_missing`. `tracking` (DomainTracking, optional) Branded open / click tracking configuration for a domain. Tracking is opt-in and entirely independent of mail-flow verification — failures here never affect `Domain.status` or interrupt sending. `created_at` (string (date-time), optional) `verified_at` (string (date-time), optional) When the domain most recently transitioned to `verified`. May be null. ```json { "id": "domain_550e8400-e29b-41d4-a716-446655440000", "name": "example.com", "status": "pending", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "verification_failure": { "code": "string", "message": "string" }, "tracking": { "opens_enabled": true, "clicks_enabled": true, "subdomain": "track", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "status": "disabled", "verification_failure": { "code": "string", "message": "string" }, "verified_at": "string" }, "created_at": "string", "verified_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The domain. | | `404` | Resource not found. | ## Update a domain `PATCH /v1/domains/{id}` Updates the domain's tracking configuration. The domain `name` is immutable through this endpoint — only the fields under `tracking` may be changed. When either tracking flag is enabled, `tracking.subdomain` is required. The server composes the full tracking host as `{subdomain}.{Domain.name}`, which is what the customer publishes as a CNAME and what we look up to verify. Tracking hosts are unique within a team. Toggling a flag or changing the subdomain clears any previous tracking verification — the customer must re-publish the CNAME shown in `tracking.dns_records` and call `POST /domains/{id}/verify` again. Mail-flow verification is unaffected. #### Parameters `id` (string, in path, required) #### Request body Send as JSON with `Content-Type: application/json`. `tracking` (object, required) Tracking config patch. Any of the inner fields may be omitted — only supplied keys are updated. #### Example request ```bash curl https://api.anypost.com/v1/domains/domain_550e8400-e29b-41d4-a716-446655440000 \ -X PATCH \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tracking": {} }' ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `status` (string, optional) `pending` until the customer's CNAMEs resolve to our authoritative records; flips to `verified` after a successful verify call. Reflects mail-flow verification only — tracking-CNAME issues are reported separately via `tracking.status` and never affect this field. One of: `pending`, `verified`. `dns_records` (array of DnsRecord, optional) The mail-flow DNS records the customer should publish at their provider to verify the domain and route DKIM/SPF/MX through us. Iterate and publish each record verbatim. Always one verification CNAME plus one CNAME per active DKIM selector (typically two). Tracking-related records are surfaced separately under `tracking.dns_records`. `verification_failure` (VerificationFailure or null, optional) Most recent mail-flow verification failure, or null when the domain is verified or has never been checked. `code` is one of `apex_cname_missing`, `apex_cname_mismatch`, `dkim_cname_missing`, `dkim_cname_mismatch`, `dkim_mismatch`, `chain_broken`, `mx_missing`, `spf_missing`. `tracking` (DomainTracking, optional) Branded open / click tracking configuration for a domain. Tracking is opt-in and entirely independent of mail-flow verification — failures here never affect `Domain.status` or interrupt sending. `created_at` (string (date-time), optional) `verified_at` (string (date-time), optional) When the domain most recently transitioned to `verified`. May be null. ```json { "id": "domain_550e8400-e29b-41d4-a716-446655440000", "name": "example.com", "status": "pending", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "verification_failure": { "code": "string", "message": "string" }, "tracking": { "opens_enabled": true, "clicks_enabled": true, "subdomain": "track", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "status": "disabled", "verification_failure": { "code": "string", "message": "string" }, "verified_at": "string" }, "created_at": "string", "verified_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The updated domain. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | ## Delete a domain `DELETE /v1/domains/{id}` Permanently deletes the domain and its DKIM keys. Anypost removes the authoritative records it published in its own DNS asynchronously — the response returns immediately and any leftover records are cleaned up shortly after. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/domains/domain_550e8400-e29b-41d4-a716-446655440000 \ -X DELETE \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Responses | Status | Description | |---|---| | `204` | Deleted. | | `404` | Resource not found. | ## Verify a domain `POST /v1/domains/{id}/verify` Resolves the customer's published CNAMEs through to our authoritative DKIM/SPF/MX records and, on success, persists `status = verified`. The response always returns 200 with the current domain — read `status` (`pending` vs `verified`) and `verification_failure` to learn the outcome. DNS propagation can lag; callers may poll this endpoint until the status flips. If the domain has tracking configured, this endpoint also re-checks the branded tracking CNAME (the host named in `tracking.dns_records`) and updates `tracking.status` / `tracking.verification_failure` independently. A failed tracking check never flips the mail-flow `status` to `pending` and never disrupts sending. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/domains/domain_550e8400-e29b-41d4-a716-446655440000/verify \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `status` (string, optional) `pending` until the customer's CNAMEs resolve to our authoritative records; flips to `verified` after a successful verify call. Reflects mail-flow verification only — tracking-CNAME issues are reported separately via `tracking.status` and never affect this field. One of: `pending`, `verified`. `dns_records` (array of DnsRecord, optional) The mail-flow DNS records the customer should publish at their provider to verify the domain and route DKIM/SPF/MX through us. Iterate and publish each record verbatim. Always one verification CNAME plus one CNAME per active DKIM selector (typically two). Tracking-related records are surfaced separately under `tracking.dns_records`. `verification_failure` (VerificationFailure or null, optional) Most recent mail-flow verification failure, or null when the domain is verified or has never been checked. `code` is one of `apex_cname_missing`, `apex_cname_mismatch`, `dkim_cname_missing`, `dkim_cname_mismatch`, `dkim_mismatch`, `chain_broken`, `mx_missing`, `spf_missing`. `tracking` (DomainTracking, optional) Branded open / click tracking configuration for a domain. Tracking is opt-in and entirely independent of mail-flow verification — failures here never affect `Domain.status` or interrupt sending. `created_at` (string (date-time), optional) `verified_at` (string (date-time), optional) When the domain most recently transitioned to `verified`. May be null. ```json { "id": "domain_550e8400-e29b-41d4-a716-446655440000", "name": "example.com", "status": "pending", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "verification_failure": { "code": "string", "message": "string" }, "tracking": { "opens_enabled": true, "clicks_enabled": true, "subdomain": "track", "dns_records": [ { "type": "CNAME", "name": "anypost", "value": "dxyz12345.c.anypost.email", "purpose": "verification" } ], "status": "disabled", "verification_failure": { "code": "string", "message": "string" }, "verified_at": "string" }, "created_at": "string", "verified_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The domain after the verification attempt. | | `404` | Resource not found. | --- Source: /docs/reference/templates # Templates API Manage reusable email templates stored per team. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## List templates `GET /v1/templates` Returns the authenticated team's templates, newest-first, with cursor pagination. #### Parameters `limit` (integer, in query, optional) Number of items to return. `after` (string, in query, optional) Opaque cursor from a previous response's `next_cursor`. Do not parse. #### Example request ```bash curl https://api.anypost.com/v1/templates \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `data` (array of Template, optional) `has_more` (boolean, optional) `next_cursor` (string, optional) May be null. ```json { "data": [ { "id": "template_550e8400-e29b-41d4-a716-446655440000", "name": "Welcome email", "subject": "Welcome to Anypost!", "kind": "html", "html": "string", "text": "string", "markdown": "string", "has_draft": true, "published_at": "string", "created_at": "string", "updated_at": "string" } ], "has_more": true, "next_cursor": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | Paginated list of templates. | | `401` | Missing or invalid credentials. | ## Create a template `POST /v1/templates` Creates a new template for the authenticated team. `name` is required and must be unique within the team. `kind` defaults to `html` and is immutable thereafter: an `html` template supplies an `html` body, a `markdown` template supplies a `markdown` source. The plain-text body is always derived server-side and is never accepted as input. The new template starts unpublished — publish it before sending. Each team is capped at 500 templates; exceeding it returns `422 validation_error`. #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, required) `subject` (string, optional) May be null. `kind` (string, optional) Defaults to `html` if omitted. Immutable once the template exists. One of: `html`, `markdown`. `html` (string, optional) May be null. `markdown` (string, optional) emailmd source. Required when `kind=markdown`; rejected when `kind=html`. May be null. #### Example request ```bash curl https://api.anypost.com/v1/templates \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome email" }' ``` #### Response body On success (`201`), the response body is: `id` (string, optional) `name` (string, optional) Identifier for the template, unique within the team. `subject` (string, optional) Published default subject line. `null` until the template is first published. May be null. `kind` (string, optional) Authoring format. `html` templates author a raw `html` body; the `text` body is derived from it automatically. `markdown` templates store an emailmd source document in `markdown`; `html` and `text` are server-rendered on save. Both kinds expose `html`/`text` for read. Immutable once a template exists. One of: `html`, `markdown`. `html` (string, optional) Published HTML body. Up to 256 KB (262 144 bytes). `null` until the template is first published. May be null. `text` (string, optional) Published plain-text body, up to 64 KB (65 536 bytes). Always machine-derived — from the `html` body for `kind=html`, or rendered for `kind=markdown` — and never set directly. `null` until the template is first published. May be null. `markdown` (string, optional) Published emailmd markdown source. Only set when `kind=markdown`; `null` for `kind=html` or until the template is first published. May be null. `has_draft` (boolean, optional) Whether the template has an unpublished draft pending. Fetch the draft content from `/templates/{id}/draft`. `published_at` (string (date-time), optional) When the template was last published. `null` if it has never been published — such a template holds no sendable content. May be null. `created_at` (string (date-time), optional) `updated_at` (string (date-time), optional) ```json { "id": "template_550e8400-e29b-41d4-a716-446655440000", "name": "Welcome email", "subject": "Welcome to Anypost!", "kind": "html", "html": "string", "text": "string", "markdown": "string", "has_draft": true, "published_at": "string", "created_at": "string", "updated_at": "string" } ``` #### Responses | Status | Description | |---|---| | `201` | Template created. | | `401` | Missing or invalid credentials. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | ## Retrieve a template `GET /v1/templates/{id}` #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) Identifier for the template, unique within the team. `subject` (string, optional) Published default subject line. `null` until the template is first published. May be null. `kind` (string, optional) Authoring format. `html` templates author a raw `html` body; the `text` body is derived from it automatically. `markdown` templates store an emailmd source document in `markdown`; `html` and `text` are server-rendered on save. Both kinds expose `html`/`text` for read. Immutable once a template exists. One of: `html`, `markdown`. `html` (string, optional) Published HTML body. Up to 256 KB (262 144 bytes). `null` until the template is first published. May be null. `text` (string, optional) Published plain-text body, up to 64 KB (65 536 bytes). Always machine-derived — from the `html` body for `kind=html`, or rendered for `kind=markdown` — and never set directly. `null` until the template is first published. May be null. `markdown` (string, optional) Published emailmd markdown source. Only set when `kind=markdown`; `null` for `kind=html` or until the template is first published. May be null. `has_draft` (boolean, optional) Whether the template has an unpublished draft pending. Fetch the draft content from `/templates/{id}/draft`. `published_at` (string (date-time), optional) When the template was last published. `null` if it has never been published — such a template holds no sendable content. May be null. `created_at` (string (date-time), optional) `updated_at` (string (date-time), optional) ```json { "id": "template_550e8400-e29b-41d4-a716-446655440000", "name": "Welcome email", "subject": "Welcome to Anypost!", "kind": "html", "html": "string", "text": "string", "markdown": "string", "has_draft": true, "published_at": "string", "created_at": "string", "updated_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The template. | | `404` | Resource not found. | ## Update template metadata `PATCH /v1/templates/{id}` Updates the template's `name`. Body content is draft-versioned — `subject`, `html`, `text`, `markdown` (and `kind`) are rejected here; use `PATCH /templates/{id}/draft` instead. #### Parameters `id` (string, in path, required) #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, optional) #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000 \ -X PATCH \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) Identifier for the template, unique within the team. `subject` (string, optional) Published default subject line. `null` until the template is first published. May be null. `kind` (string, optional) Authoring format. `html` templates author a raw `html` body; the `text` body is derived from it automatically. `markdown` templates store an emailmd source document in `markdown`; `html` and `text` are server-rendered on save. Both kinds expose `html`/`text` for read. Immutable once a template exists. One of: `html`, `markdown`. `html` (string, optional) Published HTML body. Up to 256 KB (262 144 bytes). `null` until the template is first published. May be null. `text` (string, optional) Published plain-text body, up to 64 KB (65 536 bytes). Always machine-derived — from the `html` body for `kind=html`, or rendered for `kind=markdown` — and never set directly. `null` until the template is first published. May be null. `markdown` (string, optional) Published emailmd markdown source. Only set when `kind=markdown`; `null` for `kind=html` or until the template is first published. May be null. `has_draft` (boolean, optional) Whether the template has an unpublished draft pending. Fetch the draft content from `/templates/{id}/draft`. `published_at` (string (date-time), optional) When the template was last published. `null` if it has never been published — such a template holds no sendable content. May be null. `created_at` (string (date-time), optional) `updated_at` (string (date-time), optional) ```json { "id": "template_550e8400-e29b-41d4-a716-446655440000", "name": "Welcome email", "subject": "Welcome to Anypost!", "kind": "html", "html": "string", "text": "string", "markdown": "string", "has_draft": true, "published_at": "string", "created_at": "string", "updated_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The updated template. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `422` | Request validation failed. | ## Delete a template `DELETE /v1/templates/{id}` Deletes the template and its draft, if any. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000 \ -X DELETE \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Responses | Status | Description | |---|---| | `204` | Deleted. | | `404` | Resource not found. | ## Duplicate a template `POST /v1/templates/{id}/duplicate` Creates a copy of the template. The copy is a brand-new template that starts **unpublished**, with a draft seeded from the source's current editable content — the source's draft if it has one, otherwise its published content. The copy must be published before it can be used for sending. `name` is optional: when omitted the server names the copy `" (copy)"` (disambiguated with `(copy 2)`, `(copy 3)`, … when needed). When supplied it must be unique within the team. The per-team 500-template cap applies — exceeding it returns `422 validation_error`. #### Parameters `id` (string, in path, required) #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, optional) Name for the copy. Defaults to `" (copy)"` when omitted. #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000/duplicate \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` #### Response body On success (`201`), the response body is: `id` (string, optional) `name` (string, optional) Identifier for the template, unique within the team. `subject` (string, optional) Published default subject line. `null` until the template is first published. May be null. `kind` (string, optional) Authoring format. `html` templates author a raw `html` body; the `text` body is derived from it automatically. `markdown` templates store an emailmd source document in `markdown`; `html` and `text` are server-rendered on save. Both kinds expose `html`/`text` for read. Immutable once a template exists. One of: `html`, `markdown`. `html` (string, optional) Published HTML body. Up to 256 KB (262 144 bytes). `null` until the template is first published. May be null. `text` (string, optional) Published plain-text body, up to 64 KB (65 536 bytes). Always machine-derived — from the `html` body for `kind=html`, or rendered for `kind=markdown` — and never set directly. `null` until the template is first published. May be null. `markdown` (string, optional) Published emailmd markdown source. Only set when `kind=markdown`; `null` for `kind=html` or until the template is first published. May be null. `has_draft` (boolean, optional) Whether the template has an unpublished draft pending. Fetch the draft content from `/templates/{id}/draft`. `published_at` (string (date-time), optional) When the template was last published. `null` if it has never been published — such a template holds no sendable content. May be null. `created_at` (string (date-time), optional) `updated_at` (string (date-time), optional) ```json { "id": "template_550e8400-e29b-41d4-a716-446655440000", "name": "Welcome email", "subject": "Welcome to Anypost!", "kind": "html", "html": "string", "text": "string", "markdown": "string", "has_draft": true, "published_at": "string", "created_at": "string", "updated_at": "string" } ``` #### Responses | Status | Description | |---|---| | `201` | The duplicated template. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `422` | Request validation failed. | ## Retrieve the template draft `GET /v1/templates/{id}/draft` Returns the template's unpublished draft content. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000/draft \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `subject` (string, optional) May be null. `html` (string, optional) May be null. `text` (string, optional) Draft plain-text body. Always machine-derived from the draft's `html`/`markdown`; never set directly. May be null. `markdown` (string, optional) May be null. `updated_at` (string (date-time), optional) ```json { "subject": "string", "html": "string", "text": "string", "markdown": "string", "updated_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The draft content. | | `404` | Resource not found. | ## Create or update the template draft `PATCH /v1/templates/{id}/draft` Idempotent upsert of the template's draft. Creates the draft if none exists, otherwise updates it. The published content is untouched until the draft is published. Always responds 200. #### Parameters `id` (string, in path, required) #### Request body Send as JSON with `Content-Type: application/json`. `subject` (string, optional) May be null. `html` (string, optional) May be null. `markdown` (string, optional) May be null. #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000/draft \ -X PATCH \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` #### Response body On success (`200`), the response body is: `subject` (string, optional) May be null. `html` (string, optional) May be null. `text` (string, optional) Draft plain-text body. Always machine-derived from the draft's `html`/`markdown`; never set directly. May be null. `markdown` (string, optional) May be null. `updated_at` (string (date-time), optional) ```json { "subject": "string", "html": "string", "text": "string", "markdown": "string", "updated_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The saved draft. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | ## Discard the template draft `DELETE /v1/templates/{id}/draft` Deletes the template's draft without affecting the published content. Idempotent — succeeds even when no draft exists. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000/draft \ -X DELETE \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Responses | Status | Description | |---|---| | `204` | Draft discarded. | | `404` | Resource not found. | ## Publish the template draft `POST /v1/templates/{id}/publish` Promotes the template's draft into the published slot. The previous published content, if any, is overwritten — not archived. The draft is consumed: the template has no pending draft immediately after publishing. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/templates/template_550e8400-e29b-41d4-a716-446655440000/publish \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) Identifier for the template, unique within the team. `subject` (string, optional) Published default subject line. `null` until the template is first published. May be null. `kind` (string, optional) Authoring format. `html` templates author a raw `html` body; the `text` body is derived from it automatically. `markdown` templates store an emailmd source document in `markdown`; `html` and `text` are server-rendered on save. Both kinds expose `html`/`text` for read. Immutable once a template exists. One of: `html`, `markdown`. `html` (string, optional) Published HTML body. Up to 256 KB (262 144 bytes). `null` until the template is first published. May be null. `text` (string, optional) Published plain-text body, up to 64 KB (65 536 bytes). Always machine-derived — from the `html` body for `kind=html`, or rendered for `kind=markdown` — and never set directly. `null` until the template is first published. May be null. `markdown` (string, optional) Published emailmd markdown source. Only set when `kind=markdown`; `null` for `kind=html` or until the template is first published. May be null. `has_draft` (boolean, optional) Whether the template has an unpublished draft pending. Fetch the draft content from `/templates/{id}/draft`. `published_at` (string (date-time), optional) When the template was last published. `null` if it has never been published — such a template holds no sendable content. May be null. `created_at` (string (date-time), optional) `updated_at` (string (date-time), optional) ```json { "id": "template_550e8400-e29b-41d4-a716-446655440000", "name": "Welcome email", "subject": "Welcome to Anypost!", "kind": "html", "html": "string", "text": "string", "markdown": "string", "has_draft": true, "published_at": "string", "created_at": "string", "updated_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The published template. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `422` | Request validation failed. | --- Source: /docs/reference/emails # Email API Send transactional email messages. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## Send an email `POST /v1/email` Submits a transactional message for delivery. Anypost validates the request, generates a public message id, and queues the message for delivery. A 202 response means the message was accepted for delivery — not that it has been delivered. Delivery events (delivered, bounced, complained) are surfaced via webhooks. Multi-recipient sends in this endpoint use a single envelope with multiple `RCPT TO`s — one logical message goes to all recipients. To send many _independent_ messages in one HTTP request — different `from`s, subjects, bodies — use `POST /v1/email/batch` instead. The two are not interchangeable. See `EmailSendRequest` for the full set of supported fields. At least one of `text`, `html`, or `template_id` must be present. ### Idempotency Pass an optional `Idempotency-Key` header to make retries safe. See the parameter description for full semantics. #### Parameters `Idempotency-Key` (string, in header, optional) Optional client-supplied key (1–255 printable-ASCII bytes) that de-duplicates retries of the same logical request. Scoped per team — the same key reused across `POST /v1/email` and `POST /v1/email/batch` shares one cache entry, so different request shapes under the same key will collide. - **Replay** — if the same key is reused with the **same** request body within 24 hours, the original response (status, body) is returned verbatim. The message is **not** re-sent. - **Mismatch (different body)** — if the same key is reused with a **different** request body, the gateway returns `422 idempotency_mismatch`. - **Concurrent** — if a request with the same key is still in-flight, the second request returns `409 idempotency_concurrent`. - **Server errors** — `5xx` responses are not cached, so a retry with the same key actually retries the operation. `2xx` and `4xx` (including `207 Multi-Status` on batch) are cached and replayed. #### Request body Send as JSON with `Content-Type: application/json`. `from` (string, required) Sender address on a verified domain. Accepts either a bare address (`addr@host`) or RFC 5322 name-addr form with a friendly display name (`Display Name `, or with the name quoted: `"Last, First" `). The display name appears in the `From:` header only — the SMTP envelope (`MAIL FROM`) always uses the bare address. CR/LF in either part is stripped before the message is built. The recipient fields (`to`, `cc`, `bcc`, `reply_to`) accept the same two forms. `to` (array of string, required) One or more primary recipient addresses. All recipients receive the same envelope (a single submission with multiple `RCPT TO`s) and appear in the `To:` header. Each entry accepts either a bare address (`addr@host`) or RFC 5322 name-addr form (`Display Name `, or quoted: `"Last, First" `); the display name flows into the rendered `To:` header. Combined with `cc` and `bcc`, the total recipient count must not exceed 50. `cc` (array of string, optional) Courtesy-copy recipients. Visible to all other recipients via the `Cc:` header. Each entry accepts a bare address or RFC 5322 name-addr form (see `to`). Counts against the combined 50-recipient limit. `bcc` (array of string, optional) Blind-carbon-copy recipients. Receive the message but are not listed in any header — hidden from all other recipients. Each entry accepts a bare address or RFC 5322 name-addr form (see `to`); display names on bcc are accepted for symmetry but never surface anywhere in the rendered message. Counts against the combined 50-recipient limit. `reply_to` (string or array of string, optional) One or more addresses to put in the `Reply-To` header. Accepts a single string or an array (maximum 10). Each entry accepts a bare address or RFC 5322 name-addr form (see `to`). `subject` (string, optional) Subject header value. CR and LF characters are stripped before the message is built (header injection guard); a subject that sanitizes to empty is rejected. The 998-character cap matches RFC 5322's line-length limit. Required unless `template_id` is supplied and the referenced template carries a subject. When both are present, the request value wins. `template_id` (string, optional) Reference to a stored template (see `POST /v1/templates`). When supplied, the server resolves the template and uses its pre-rendered `html`/`text` bodies and, if the request omits its own `subject`, the template's subject. Cannot be combined with inline `html` or `text` — pick one source of body content per send. The lookup is scoped to the authenticated team; a `template_id` from another team is indistinguishable from a missing template (both return `400` with `errors.template_id`). The referenced template must have been published. Sends are always rendered from a template's published content; a template that has only a draft (never published) is rejected with `400` and an `errors.template_id` message stating it has no published content. Publish a draft via `POST /v1/templates/{id}/publish` first. `text` (string, optional) Plain-text body. UTF-8. Maximum 1,000,000 characters. At least one of `text`, `html`, or `template_id` is required. `text` and `html` may both be set (sent as a multipart/alternative message); neither may be combined with `template_id`. `html` (string, optional) HTML body. UTF-8. Maximum 1,000,000 characters. At least one of `text`, `html`, or `template_id` is required. Cannot be combined with `template_id`. `headers` (EmailHeaderMap, optional) Custom message headers as a name → value map. Names must be alphanumeric/hyphen, start with a letter or digit, and be at most 78 characters; malformed names are silently dropped. Headers that the platform sets itself or that carry trust signals from receiving infrastructure are also dropped — use this map for application-specific custom headers, not to override transport, authentication, routing, or trace headers. The 25-entry cap applies after dropping; exceeding it is the only condition that errors. `attachments` (array of EmailAttachment, optional) Inline attachments. The total request body is bounded by the 5 MB gateway limit; base64 encoding inflates binary content by roughly 33%, so plan for an effective payload ceiling of about 3.5 MB of binary data. `tags` (array of string, optional) Free-form labels for grouping this message's events in the events UI and webhook payloads. Up to 10 tags, each matching `[A-Za-z0-9_-]{1,64}`. Duplicates are removed, preserving first-occurrence order. Stripped before delivery — recipients never see them. Tags are for grouping (cohort, mailing list, marketing vs. transactional), not for per-message identifiers such as `user_id` or `order_id`. Anypost enforces a per-team cap on the number of distinct tag values used per rolling hour; exceeding it returns `429` with `scope: "tag_cardinality"`. For per-message correlation use the `id` returned in the response. `tracking` (object, optional) Per-message override of the sending domain's default open / click tracking. Each field, when set, overrides the domain default for this message only; omit a field to inherit it. Tracking only takes effect when the sending domain has a verified tracking subdomain (see `Domain.tracking`). `variables` (object, optional) Per-send substitution map. When set and non-empty, the server renders Handlebars `{{ markers }}` in `subject`, `text`, `html`, and customer-supplied header values, per recipient, before delivery. Attachments are never templated. Omit `variables` (or send an empty object) and the bodies pass through untouched — any literal `{{ … }}` is left as written. The one exception is an `unsubscribe.mode = "generate"` send: it always renders so the reserved `unsubscribe_url` marker can resolve, so any literal `{{ … }}` in such a send is evaluated too. Limits: the encoded JSON must be at most 64 KB, with at most 100 keys per object level and at most 4 levels of nesting. See [Variables & personalization](/docs/variables) for the marker syntax, conditionals and loops, the per-recipient `name`/`email` bindings, and the reserved `unsubscribe_url` variable. `campaign` (string, optional) Optional stream-segmentation label (e.g. `newsletter`, `receipts`, `password-reset`). When set, Anypost gives this send its own delivery queue, so a high-volume marketing stream cannot delay transactional mail to the same destination domain. Stripped before delivery — recipients never see it — and surfaced on every event derived from this send. Use this as a coarse stream classifier, not a per-message identifier. Anypost enforces per-team caps on the number of distinct `campaign` values: per rolling hour, and a wider cap per rolling 72 hours (the lifetime of a campaign's delivery queues). Exceeding either returns `429` with `scope: "campaign_cardinality"`; the `limit` field tells you which window tripped. For per-message correlation, use the `id` returned in the response. For free-form labels, use `tags`. `topic` (string, optional) Customer-namespaced bucket the message belongs to. Used as the suppression scope: recipients who unsubscribe from a specific topic stop receiving sends in that topic but continue receiving sends in other topics (and vice versa). Sends with no topic skip every topic-scoped suppression check — the explicit transactional carve-out for OTP, password-reset, and other mail that must reach the recipient regardless of marketing-opt-out state. Required when `unsubscribe.mode = "generate"`, since the topic is embedded in the signed unsubscribe token and becomes the suppression scope for any resulting `email.unsubscribed` event. Case-sensitive, `[a-z0-9_.-]{1,64}`. The wildcard `*` is reserved for cross-topic suppressions and is not a valid topic value on a send. Use topics as coarse stream buckets, not per-message identifiers. Anypost enforces a per-team cap on the number of distinct `topic` values used per rolling hour; exceeding it returns `429` with `scope: "topic_cardinality"`. `unsubscribe` (object, optional) One-click unsubscribe behavior. - `mode: "generate"` — anypost mints a per-recipient signed token, injects RFC 8058 `List-Unsubscribe` and `List-Unsubscribe-Post` headers, and auto-populates the reserved `unsubscribe_url` template variable (see `variables`). Requires `topic` to scope the suppression to a meaningful bucket. Use this for marketing / newsletter sends. - `mode: "none"` (default for new teams) — no header injection, no auto-populated template variable. Use this for transactional sends that explicitly should NOT carry unsubscribe semantics (password reset, OTP). Customer-supplied `List-Unsubscribe` header (Sendy / Mautic / in-house mailers) always passes through after a recoverable lint, regardless of `mode`. The presence of a caller-supplied header is the opt-out signal — no explicit "passthrough" mode is needed. #### Example request ```bash curl https://api.anypost.com/v1/email \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Support ", "to": [ "recipient@example.com" ] }' ``` #### Response body On success (`202`), the response body is: `id` (string, optional) Public message identifier (`email_`). `created_at` (string (date-time), optional) ```json { "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d", "created_at": "2026-04-30T12:00:00.123000Z" } ``` #### Responses | Status | Description | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `202` | Message accepted for delivery. The response body contains the public message id and creation timestamp. If some recipients (but not all) were rejected at send time, this status is still returned — those failures are not yet surfaced in the response. Surfacing them via a `rejected_recipients` field is planned. | | `400` | Validation failed for an unfixable reason — invalid email address, invalid base64 in an attachment, an oversized body, exceeding a count cap (recipients, headers, attachments), or every recipient being rejected at send time. Recoverable issues (CR/LF in `subject` or header values, reserved/malformed header names, path components or NUL in filenames) are sanitized in place rather than surfaced as errors. Response uses the canonical error shape. | | `401` | Missing or invalid credentials. | | `409` | A request with the supplied `Idempotency-Key` is already in progress. Wait and retry. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | The supplied `Idempotency-Key` was previously used with a different request body. Either reuse the original request body or pick a new key. | | `429` | A send-volume or cardinality limit was exceeded: your team's daily limit, your monthly quota (on a paid plan, once your included volume and any prepaid overage credits are spent), or the per-team cap on distinct `campaign` values per rolling hour. Nothing was accepted. The flat body names the `scope` and `retry_after_seconds`; the `Retry-After` header carries the same value. See [**Sending limits**](/docs/sending-limits). | | `502` | Anypost's mail pipeline returned a server error, timed out, or was unreachable. Safe to retry with backoff. | ## Send a batch of independent emails `POST /v1/email/batch` Submits 1–100 independent transactional messages in a single HTTP request. Each item is the same shape as the body of `POST /v1/email` — different `from`s, subjects, bodies, recipients are all fine. Use this endpoint when you have N logical messages to send; use `POST /v1/email` when you have one logical message that goes to multiple recipients. ### Validation: all-or-nothing If _any_ item fails per-item validation (bad email address, oversized body, invalid attachment, etc.), the entire batch is rejected with `400` and no message is sent. Field paths in the error map are prefixed with the item index, e.g. `emails.3.subject`. ### Partial outcomes Once validation passes, items proceed independently. Each item may succeed (queued for delivery), fail at the domain-checks stage (sender domain not on the API key allowlist or not verified for the team), or fail because Anypost's mail pipeline timed out, returned an error, or was unreachable. The response surfaces each item's outcome individually under `data[]`, in request order. The top-level HTTP status reflects the overall mix: - `202 Accepted` — every item queued. - `207 Multi-Status` — some queued, some failed. - `502 Bad Gateway` — every item failed and at least one failure was on Anypost's side (server error / timeout / unreachable). Safe to retry with backoff. - `400 Bad Request` — either the batch failed validation outright, or every item failed for a caller-attributable reason (domain check or message rejection). ### Sizing The hard cap is 100 items per request, but the practical ceiling is the 5 MB request-body limit. Tiny text-only items hit 100 long before the body cap; batches that include attachments will hit the body cap well before 100 items — aim for ≤25 items when attaching files. ### Idempotency Pass an optional `Idempotency-Key` header to make retries safe. The cached response includes the full `data[]` with each item's `id` and `created_at`, so a retry of a `207` replays the original outcome verbatim — items that were queued the first time are NOT re-sent. See the parameter description for full semantics. #### Parameters `Idempotency-Key` (string, in header, optional) Optional client-supplied key (1–255 printable-ASCII bytes) that de-duplicates retries of the same logical request. Scoped per team — the same key reused across `POST /v1/email` and `POST /v1/email/batch` shares one cache entry, so different request shapes under the same key will collide. - **Replay** — if the same key is reused with the **same** request body within 24 hours, the original response (status, body) is returned verbatim. The message is **not** re-sent. - **Mismatch (different body)** — if the same key is reused with a **different** request body, the gateway returns `422 idempotency_mismatch`. - **Concurrent** — if a request with the same key is still in-flight, the second request returns `409 idempotency_concurrent`. - **Server errors** — `5xx` responses are not cached, so a retry with the same key actually retries the operation. `2xx` and `4xx` (including `207 Multi-Status` on batch) are cached and replayed. #### Request body Send as JSON with `Content-Type: application/json`. `emails` (array of EmailSendRequest, required) 1–100 messages in the batch. When `defaults` is set, each entry inherits any field it does not specify. `from` and `subject` may be omitted on an entry if `defaults` supplies them; the merged result must still satisfy the same shape as `POST /v1/email`. `to` is always per-entry and cannot appear in `defaults`. `defaults` (EmailBatchDefaults, optional) Optional batch-wide defaults. Each field, when present, is applied to every entry in `emails` that does not specify its own value. `to` is excluded — recipients are always per-entry. Merge semantics: - Scalars (`from`, `subject`, `text`, `html`, `reply_to`): the entry's value wins; otherwise the default is used. - `headers`: shallow-merged. The entry's value wins on key collision. - `cc`, `bcc`, `attachments`, `tags`: **concatenated** (defaults first, then the entry). The combined result is bounded by the same caps as a single send (50 total recipients across `to`+`cc`+`bcc`, 20 attachments, 10 tags after de-duplication). Putting a shared attachment in `defaults` lets a batch reach many recipients without repeating the bytes inbound — useful given the 5 MB request body limit. #### Example request ```bash curl https://api.anypost.com/v1/email/batch \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "emails": [ { "from": "Acme Support ", "to": [ "recipient@example.com" ] } ] }' ``` #### Response body On success (`202`), the response body is: `summary` (object, optional) `data` (array of EmailBatchItemResult, optional) Per-item outcome, in the same order as the request `emails` array. `data[i].index === i` always, but the explicit `index` field lets consumers re-key after filtering or sorting `data`. ```json { "summary": { "total": 0, "queued": 0, "failed": 0 }, "data": [ { "status": "queued", "index": 0, "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d", "created_at": "2026-04-30T12:00:00.123000Z" } ] } ``` #### Responses | Status | Description | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `202` | Every item queued for delivery. | | `207` | Mixed outcomes — some items queued, some failed. The `data` array reports each item's status; `summary` gives the totals. The top-level response is _not_ a canonical error envelope; per-item failures are nested inside `data[i].error`. | | `400` | Either the batch failed validation (response uses the canonical error envelope; `errors` paths are prefixed with the item index, e.g. `emails.2.from`), or every item failed at a caller-attributable stage (response uses the `EmailBatchResponse` shape with `summary.queued` equal to zero). | | `401` | Missing or invalid credentials. | | `409` | A request with the supplied `Idempotency-Key` is already in progress. Wait and retry. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | The supplied `Idempotency-Key` was previously used with a different request body. Either reuse the original request body or pick a new key. | | `429` | Rate-limited, in one of two shapes. A send-volume limit (daily, or monthly quota once your included volume and any prepaid overage credits are spent) rejects the batch as a whole: nothing is queued and the flat body names the `scope` and `retry_after_seconds` — split the batch and retry after the reset. Alternatively, every item was denied by a label-cardinality cap (distinct `campaign` / `topic` / tag values): the body is the normal `EmailBatchResponse` shape with each item failed as `quota_exceeded`. When only some items trip a cardinality cap, the response is a `207` — those items fail individually and the rest are queued. See [**Sending limits**](/docs/sending-limits). | | `502` | Every item failed and at least one failure was on Anypost's side (server error, timeout, or unreachable). Safe to retry the whole batch with backoff. Response uses the `EmailBatchResponse` shape so callers can still inspect per-item outcomes. | --- Source: /docs/reference/webhooks # Webhooks API Manage webhook subscriptions for event notifications. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## List webhooks `GET /v1/webhooks` Returns the authenticated team's webhooks, newest-first, with cursor pagination. #### Parameters `limit` (integer, in query, optional) Number of items to return. `after` (string, in query, optional) Opaque cursor from a previous response's `next_cursor`. Do not parse. #### Example request ```bash curl https://api.anypost.com/v1/webhooks \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `data` (array of Webhook, optional) `has_more` (boolean, optional) `next_cursor` (string, optional) May be null. ```json { "data": [ { "id": "wh_550e8400-e29b-41d4-a716-446655440000", "name": "Production deliverability hook", "url": "https://hooks.example.com/anypost", "events": [ "email.sent" ], "status": "active", "signing_secret_prefix": "whsec_AbCdEf", "signing_secret_previous_prefix": "whsec_XyZwVu", "signing_secret_grace_expires_at": "string", "last_delivery_at": "string", "created_at": "string" } ], "has_more": true, "next_cursor": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | Paginated list of webhooks. | | `401` | Missing or invalid credentials. | ## Create a webhook `POST /v1/webhooks` Creates a new webhook subscription for the authenticated team. The full signing secret is returned only in the response to this request — store it securely; subsequent reads return only the prefix. Outbound delivery payloads are HMAC-signed with this secret so the receiver can verify authenticity. #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, required) `url` (string (uri), required) HTTPS endpoint that will receive signed event payloads. Must use the `https://` scheme. `events` (array of string, required) #### Example request ```bash curl https://api.anypost.com/v1/webhooks \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production deliverability hook", "url": "https://hooks.example.com/anypost", "events": [ "email.sent" ] }' ``` #### Response body On success (`201`), the response body is: `id` (string, optional) `name` (string, optional) `url` (string (uri), optional) HTTPS endpoint that will receive signed event payloads. Must use the `https://` scheme. `events` (array of string, optional) Event types this webhook subscribes to. `status` (string, optional) `active` webhooks receive event deliveries; `disabled` webhooks are paused — no deliveries are attempted, but the configuration is preserved so it can be resumed. `circuit_disabled` is a transient state set automatically after consecutive delivery failures: events are still queued and retried, and the webhook returns to `active` on recovery. Only `active` and `disabled` may be set through the API; `circuit_disabled` is server-managed. One of: `active`, `circuit_disabled`, `disabled`. `signing_secret_prefix` (string, optional) The first 12 characters of the signing secret, shown for identification. `signing_secret_previous_prefix` (string, optional) The first 12 characters of the previous signing secret while a rotation grace window is active, else `null`. During the window deliveries are signed with both secrets. May be null. `signing_secret_grace_expires_at` (string (date-time), optional) When the current rotation's 24h grace window ends and the previous signing secret stops being honored, or `null` if no rotation is in progress. May be null. `last_delivery_at` (string (date-time), optional) May be null. `created_at` (string (date-time), optional) `signing_secret` (string, optional) The full signing secret. Returned only once at creation. Store it securely; future deliveries will be HMAC-signed with it. ```json { "id": "wh_550e8400-e29b-41d4-a716-446655440000", "name": "Production deliverability hook", "url": "https://hooks.example.com/anypost", "events": [ "email.sent" ], "status": "active", "signing_secret_prefix": "whsec_AbCdEf", "signing_secret_previous_prefix": "whsec_XyZwVu", "signing_secret_grace_expires_at": "string", "last_delivery_at": "string", "created_at": "string", "signing_secret": "whsec_AbCdEfGhIjKlMnOp..." } ``` #### Responses | Status | Description | |---|---| | `201` | Webhook created. The full signing secret is returned only in this response. | | `401` | Missing or invalid credentials. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | ## Retrieve a webhook `GET /v1/webhooks/{id}` Returns metadata for a single webhook. The full signing secret is never returned here — only `signing_secret_prefix`. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/webhooks/wh_550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `url` (string (uri), optional) HTTPS endpoint that will receive signed event payloads. Must use the `https://` scheme. `events` (array of string, optional) Event types this webhook subscribes to. `status` (string, optional) `active` webhooks receive event deliveries; `disabled` webhooks are paused — no deliveries are attempted, but the configuration is preserved so it can be resumed. `circuit_disabled` is a transient state set automatically after consecutive delivery failures: events are still queued and retried, and the webhook returns to `active` on recovery. Only `active` and `disabled` may be set through the API; `circuit_disabled` is server-managed. One of: `active`, `circuit_disabled`, `disabled`. `signing_secret_prefix` (string, optional) The first 12 characters of the signing secret, shown for identification. `signing_secret_previous_prefix` (string, optional) The first 12 characters of the previous signing secret while a rotation grace window is active, else `null`. During the window deliveries are signed with both secrets. May be null. `signing_secret_grace_expires_at` (string (date-time), optional) When the current rotation's 24h grace window ends and the previous signing secret stops being honored, or `null` if no rotation is in progress. May be null. `last_delivery_at` (string (date-time), optional) May be null. `created_at` (string (date-time), optional) ```json { "id": "wh_550e8400-e29b-41d4-a716-446655440000", "name": "Production deliverability hook", "url": "https://hooks.example.com/anypost", "events": [ "email.sent" ], "status": "active", "signing_secret_prefix": "whsec_AbCdEf", "signing_secret_previous_prefix": "whsec_XyZwVu", "signing_secret_grace_expires_at": "string", "last_delivery_at": "string", "created_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The webhook. | | `404` | Resource not found. | ## Update a webhook `PATCH /v1/webhooks/{id}` Updates an existing webhook's name, URL, subscribed events, and status. The signing secret is not rotated here — use `POST /webhooks/{id}/rotate-secret` for that. Pause delivery without losing configuration by setting `status` to `disabled`. #### Parameters `id` (string, in path, required) #### Request body Send as JSON with `Content-Type: application/json`. `name` (string, required) `url` (string (uri), required) HTTPS endpoint that will receive signed event payloads. Must use the `https://` scheme. `events` (array of string, required) `status` (string, required) One of: `active`, `disabled`. #### Example request ```bash curl https://api.anypost.com/v1/webhooks/wh_550e8400-e29b-41d4-a716-446655440000 \ -X PATCH \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "url": "string", "events": [ "email.sent" ], "status": "active" }' ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `url` (string (uri), optional) HTTPS endpoint that will receive signed event payloads. Must use the `https://` scheme. `events` (array of string, optional) Event types this webhook subscribes to. `status` (string, optional) `active` webhooks receive event deliveries; `disabled` webhooks are paused — no deliveries are attempted, but the configuration is preserved so it can be resumed. `circuit_disabled` is a transient state set automatically after consecutive delivery failures: events are still queued and retried, and the webhook returns to `active` on recovery. Only `active` and `disabled` may be set through the API; `circuit_disabled` is server-managed. One of: `active`, `circuit_disabled`, `disabled`. `signing_secret_prefix` (string, optional) The first 12 characters of the signing secret, shown for identification. `signing_secret_previous_prefix` (string, optional) The first 12 characters of the previous signing secret while a rotation grace window is active, else `null`. During the window deliveries are signed with both secrets. May be null. `signing_secret_grace_expires_at` (string (date-time), optional) When the current rotation's 24h grace window ends and the previous signing secret stops being honored, or `null` if no rotation is in progress. May be null. `last_delivery_at` (string (date-time), optional) May be null. `created_at` (string (date-time), optional) ```json { "id": "wh_550e8400-e29b-41d4-a716-446655440000", "name": "Production deliverability hook", "url": "https://hooks.example.com/anypost", "events": [ "email.sent" ], "status": "active", "signing_secret_prefix": "whsec_AbCdEf", "signing_secret_previous_prefix": "whsec_XyZwVu", "signing_secret_grace_expires_at": "string", "last_delivery_at": "string", "created_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The updated webhook. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `413` | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. | | `422` | Request validation failed. | ## Delete a webhook `DELETE /v1/webhooks/{id}` Permanently deletes the webhook subscription. In-flight deliveries already enqueued at the time of deletion may still be attempted. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/webhooks/wh_550e8400-e29b-41d4-a716-446655440000 \ -X DELETE \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Responses | Status | Description | |---|---| | `204` | Deleted. | | `404` | Resource not found. | ## Send a test event `POST /v1/webhooks/{id}/test` Synthesize a signed `webhook.test` event and POST it once to the webhook's configured URL. Useful for verifying that your endpoint receives, parses, and validates Anypost signatures end-to-end before going live. **Synchronous and one-shot.** No retries; no row is added to your delivery history. The response carries the outcome (status code, latency, optional response body preview, optional error). Allowed on any webhook status — including `disabled` and `circuit_disabled` — so you can verify connectivity before flipping a webhook back to active. The request is signed and wrapped in the same outer envelope (`batch_id`, `timestamp`, `events[]`) as a real delivery, so your signature-verification path is exercised end-to-end. The single event inside is a synthetic `webhook.test` event with its own shape — distinct from the `id` / `type` / `occurred_at` / `data` shape that real email events use: ```json { "batch_id": "wbt_…", "timestamp": 1730000000, "events": [ { "schema_version": 1, "event_id": "evt_test_…", "event": "webhook.test", "occurred_at": "…", "received_at": "…", "webhook_id": "wh_…", "message": "This is a test event from Anypost. No real email was sent." } ] } ``` The `webhook.test` event type is reserved for this endpoint — it cannot be subscribed to via the `events` field on a webhook, and is never emitted by real email traffic. Use the `event_id` (`evt_test_…`) or the `webhook.test` type to filter it out of your analytics on receipt. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/webhooks/wh_550e8400-e29b-41d4-a716-446655440000/test \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `delivered` (boolean, optional) True only when the customer endpoint returned a 2xx status. `status_code` (integer, optional) HTTP status returned by the customer endpoint, or `null` on network failure. May be null. `latency_ms` (integer, optional) Wall-clock time from request start to response or error, in milliseconds. `error` (string, optional) Short error description if the request never completed (DNS failure, TLS failure, timeout, …). `null` when the customer endpoint responded, regardless of status code. May be null. `response_body_preview` (string, optional) First ~1 KiB of the customer endpoint's response body, or `null` if empty. May be null. ```json { "delivered": true, "status_code": 200, "latency_ms": 142, "error": null, "response_body_preview": "{\"ok\":true}" } ``` #### Responses | Status | Description | |---|---| | `200` | The test attempt completed. Inspect `delivered` and `status_code` to determine whether the customer endpoint accepted the payload — a 200 response here does NOT imply success. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | ## Rotate the signing secret `POST /v1/webhooks/{id}/rotate-secret` Generates a new signing secret for the webhook and returns it exactly once in this response — store it securely; subsequent reads return only the prefix. The previous secret stays valid for a **24-hour grace window**. During the window every delivery is signed with both secrets (the `Anypost-Signature` header carries a `v1=` component for each), so a verifier that still holds the old secret keeps matching while you redeploy with the new one. Rotating again while a grace window is still active returns `409` — wait for the window to end (see `signing_secret_grace_expires_at`) before rotating again. #### Parameters `id` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/webhooks/wh_550e8400-e29b-41d4-a716-446655440000/rotate-secret \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) `name` (string, optional) `url` (string (uri), optional) HTTPS endpoint that will receive signed event payloads. Must use the `https://` scheme. `events` (array of string, optional) Event types this webhook subscribes to. `status` (string, optional) `active` webhooks receive event deliveries; `disabled` webhooks are paused — no deliveries are attempted, but the configuration is preserved so it can be resumed. `circuit_disabled` is a transient state set automatically after consecutive delivery failures: events are still queued and retried, and the webhook returns to `active` on recovery. Only `active` and `disabled` may be set through the API; `circuit_disabled` is server-managed. One of: `active`, `circuit_disabled`, `disabled`. `signing_secret_prefix` (string, optional) The first 12 characters of the signing secret, shown for identification. `signing_secret_previous_prefix` (string, optional) The first 12 characters of the previous signing secret while a rotation grace window is active, else `null`. During the window deliveries are signed with both secrets. May be null. `signing_secret_grace_expires_at` (string (date-time), optional) When the current rotation's 24h grace window ends and the previous signing secret stops being honored, or `null` if no rotation is in progress. May be null. `last_delivery_at` (string (date-time), optional) May be null. `created_at` (string (date-time), optional) `signing_secret` (string, optional) The full signing secret. Returned only once at creation. Store it securely; future deliveries will be HMAC-signed with it. ```json { "id": "wh_550e8400-e29b-41d4-a716-446655440000", "name": "Production deliverability hook", "url": "https://hooks.example.com/anypost", "events": [ "email.sent" ], "status": "active", "signing_secret_prefix": "whsec_AbCdEf", "signing_secret_previous_prefix": "whsec_XyZwVu", "signing_secret_grace_expires_at": "string", "last_delivery_at": "string", "created_at": "string", "signing_secret": "whsec_AbCdEfGhIjKlMnOp..." } ``` #### Responses | Status | Description | |---|---| | `200` | Secret rotated. The full new signing secret is returned only in this response. | | `401` | Missing or invalid credentials. | | `404` | Resource not found. | | `409` | The request conflicts with the current state of the resource. | --- Source: /docs/reference/events # Events API Browse delivery, open, click, bounce, and complaint events captured for the team's sent mail. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## List events `GET /v1/events` Page through delivery, open, click, bounce, and complaint events for the authenticated team, newest-first. Operational events (admin bounces, expirations, …) are excluded. ## Time window The `start` / `end` query params accept ISO 8601 timestamps. The default window is the last 24 hours. The window is clamped to your plan's event retention — a request for 30 days of history on a 3-day-retention plan silently narrows to the last 3 days rather than erroring, so the same call works as customers upgrade. ## Filters All filters are exact-match on the indexed columns. A recipient or `email_id` that doesn't match any stored row returns an empty list; we do not support substring search because the events table holds billions of rows and wildcard scans would be prohibitively expensive. ## Pagination Cursor pagination on `(occurred_at, event_id)`. Echo the response's `next_cursor` back as `after` to fetch the next page. Cursors are opaque — do not parse them. #### Parameters `limit` (integer, in query, optional) Number of items to return. `after` (string, in query, optional) Opaque cursor from a previous response's `next_cursor`. Do not parse. `start` (string (date-time), in query, optional) ISO 8601 timestamp marking the start of the window (inclusive). Defaults to 24 hours before `end`. Will be moved forward if it falls outside your plan's retention window. `end` (string (date-time), in query, optional) ISO 8601 timestamp marking the end of the window (exclusive). Defaults to now. Future timestamps are clamped to now. `event_type` (string, in query, optional) Restrict to events of this customer-facing type. One of: `email.sent`, `email.delivered`, `email.delayed`, `email.bounced`, `email.complained`, `email.suppressed`, `email.unsubscribed`, `email.opened`, `email.clicked`. `recipient` (string, in query, optional) Restrict to events delivered to this exact recipient address (lowercased server-side). `email_id` (string, in query, optional) Restrict to events for the email message with this id. The canonical shape is `email_` in hyphenated form (e.g. `email_019e1972-e87e-7000-bf74-ba09e0ed0d62`) — minted identically by SMTP and HTTP submissions. A malformed value returns an empty list without hitting the data store. `message_id` (string, in query, optional) Restrict to events whose `Message-ID:` header matches this value exactly. `domain` (string, in query, optional) Restrict to events sent from this domain. Pass the domain hostname (not the internal `domain_` public id). Unknown domains return `400`. `topic` (string, in query, optional) Restrict to events whose originating send was tagged with this topic. Exact match against the lowercase `[a-z0-9_.-]{1,64}` shape enforced at send time. Values that don't match the shape are dropped from the filter set server-side (no 400) — the call returns the unfiltered slice rather than an error a customer can't act on. `campaign` (string, in query, optional) Restrict to events whose originating send set this `campaign` value. Exact match, case-sensitive — campaign values are stored verbatim. A value that doesn't match any stored row returns an empty list. `template_id` (string, in query, optional) Restrict to events whose originating send used this stored template. Exact match against the `template_id` passed to `POST /email`. A value that doesn't match any stored row returns an empty list. `tags` (array of string, in query, optional) Restrict to events whose originating send carried any of these tags (`hasAny` — an event matches if it has at least one of the listed values, not all of them). Pass a comma-separated list (`tags=onboarding,welcome`) or repeat the parameter (`tags[]=onboarding&tags[]=welcome`). Up to 10 tags; values outside `[A-Za-z0-9_-]{1,64}` are dropped from the filter rather than erroring. #### Example request ```bash curl https://api.anypost.com/v1/events \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `data` (array of Event, optional) `has_more` (boolean, optional) `next_cursor` (string, optional) May be null. ```json { "data": [ { "id": "evt_8f2c1b3e6a5d4f7c9a3e1d2b4c5e6f7a", "type": "email.sent", "occurred_at": "string", "email_id": "email_019e1972-e87e-7000-bf74-ba09e0ed0d62", "message_id": "string", "from": "string", "from_domain": "string", "recipient": "string", "subject": "string", "campaign": "string", "template_id": "template_019e2d74-df2a-72ed-a105-7ce7500ef2bc", "topic": "marketing", "tags": [ "onboarding", "welcome" ], "smtp_code": 0, "bounce_type": "string", "bounce_classification": "string", "attempt": 0, "tracking": { "bot": { "source": "google", "kind": "proxy" } } } ], "has_more": true, "next_cursor": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | Paginated list of events. | | `400` | Request validation failed. | | `401` | Missing or invalid credentials. | --- Source: /docs/reference/suppressions # Suppressions API Manage the per-team suppression list — addresses blocked from future sends. Every request needs a bearer token and goes to a path under `https://api.anypost.com/v1`. See [**API conventions**](/docs/api-conventions) for the shared request, error, and pagination rules. ## List suppressions `GET /v1/suppressions` Returns the authenticated team's suppression list, newest-first, with cursor pagination. Includes addresses suppressed automatically from permanent bounces and complaints as well as manual entries. Rows whose `expires_at` has passed are filtered out automatically — they aren't enforced on send and don't appear here. #### Parameters `limit` (integer, in query, optional) Number of items to return. `after` (string, in query, optional) Opaque cursor from a previous response's `next_cursor`. Do not parse. `email_contains` (string, in query, optional) Case-insensitive substring match against the suppressed address. `topic` (string, in query, optional) Restrict to entries scoped to this topic. Pass `*` to see only global (bounce/complaint/manual) suppressions, or a specific topic name (e.g. `marketing`) to see only topic-scoped unsubscribes. Omit to see every topic. `reason` (string, in query, optional) Restrict to entries with this reason. One of: `permanent_bounce`, `complaint`, `unsubscribed`, `manual`. `origin` (string, in query, optional) Restrict to entries added by this origin. One of: `auto`, `manual`. #### Example request ```bash curl https://api.anypost.com/v1/suppressions \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `data` (array of Suppression, optional) `has_more` (boolean, optional) `next_cursor` (string, optional) May be null. ```json { "data": [ { "id": "sup_550e8400-e29b-41d4-a716-446655440000", "email": "alice@example.com", "topic": "*", "reason": "permanent_bounce", "origin": "auto", "classification": "string", "smtp_code": 0, "note": "string", "suppressed_at": "string", "expires_at": "string", "created_at": "string" } ], "has_more": true, "next_cursor": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | Paginated list of suppressions. | | `401` | Missing or invalid credentials. | ## Add a manual suppression `POST /v1/suppressions` Suppresses a single recipient address for the authenticated team. The email is normalized (trimmed + lowercased) before storage and uniqueness check, so `Alice@Example.COM` and `alice@example.com` collide. Manual entries never expire; use `DELETE /suppressions/{email}/{topic}` to remove one. A `422 validation_error` is returned if an active suppression for the same normalized `(email, topic)` pair already exists for the team. To remove an entry, call `DELETE /suppressions/{email}/{topic}` with the customer-supplied address (and optional topic) as path parameters. #### Request body Send as JSON with `Content-Type: application/json`. `email` (string (email), required) `topic` (string, optional) Optional topic to scope the suppression. Omit (or send `*`) to block every topic for this address. Send a specific topic (e.g. `marketing`) to leave other topics — typically transactional traffic — unaffected. `note` (string, optional) Optional internal annotation (e.g. "customer requested removal"). Preserved if an automatic bounce later re-suppresses this address. May be null. #### Example request ```bash curl https://api.anypost.com/v1/suppressions \ -X POST \ -H "Authorization: Bearer $ANYPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "alice@example.com" }' ``` #### Response body On success (`201`), the response body is: `id` (string, optional) Stable identifier for log correlation. Lookups and deletes are keyed on `(email, topic)`, not on this id. `email` (string (email), optional) The suppressed recipient address, normalized to lowercase with surrounding whitespace trimmed. Together with `topic`, forms the natural key for `GET` / `DELETE /suppressions/{email}/{topic}`. `topic` (string, optional) The topic this suppression is scoped to. The wildcard `*` means "every topic" — bounces and complaints always write `*` so the address is suppressed for all sends. A specific topic (e.g. `marketing`) only blocks sends tagged with that same topic, leaving transactional traffic (`otp`, `password-reset`, …) unaffected. Topics are lowercased on write. `reason` (string, optional) Why the address is suppressed. * `permanent_bounce` — the receiving server reported a permanent delivery failure (e.g. `InvalidRecipient`, `BadDomain`). Always topic `*`. * `complaint` — an ARF feedback report (FBL) classified as abuse, fraud, virus, or opt-out. Always topic `*`. * `unsubscribed` — the recipient one-click unsubscribed (RFC 8058 POST to `/u/{token}`). Scoped to the topic from the unsubscribe token. * `manual` — added through the API/UI or via CSV import. Topic-scoped or `*`. One of: `permanent_bounce`, `complaint`, `unsubscribed`, `manual`. `origin` (string, optional) Provenance of the row. * `auto` — written automatically from a bounce or complaint event. * `manual` — created through this API or the dashboard. One of: `auto`, `manual`. `classification` (string, optional) For `permanent_bounce`, the bounce classification (e.g. `InvalidRecipient`). For `complaint`, the ARF `feedback-type` (`abuse`, `fraud`, …). Null for manual entries. May be null. `smtp_code` (integer, optional) The SMTP reply code on the bounce that produced this suppression. Null for complaints and manual entries. May be null. `note` (string, optional) Free-form note attached at creation. Preserved across automatic re-suppressions of the same address. May be null. `suppressed_at` (string (date-time), optional) When the suppression was first observed (bounce / complaint timestamp for `auto`, creation time for manual). `expires_at` (string (date-time), optional) When this suppression stops applying. Null means it never expires (complaints and manual entries by default). Permanent bounces expire on a rolling 90-day window. May be null. `created_at` (string (date-time), optional) ```json { "id": "sup_550e8400-e29b-41d4-a716-446655440000", "email": "alice@example.com", "topic": "*", "reason": "permanent_bounce", "origin": "auto", "classification": "string", "smtp_code": 0, "note": "string", "suppressed_at": "string", "expires_at": "string", "created_at": "string" } ``` #### Responses | Status | Description | |---|---| | `201` | Suppression created. | | `401` | Missing or invalid credentials. | | `422` | Request validation failed. | ## List every suppression for an email `GET /v1/suppressions/{email}` Returns every suppression record on file for the recipient address, across all topics. Since a single address can be suppressed globally (`*`) and/or scoped to specific topics (e.g. `marketing`), this endpoint returns the full set. The `{email}` path segment is URL-encoded and matched case-insensitively after trim + lowercase, so `Alice%40Example.com` and `alice%40example.com` resolve to the same rows. Returns `404` if no active suppressions exist for this address on the authenticated team. To target a single (email, topic) pair, use `GET /suppressions/{email}/{topic}` instead. #### Parameters `email` (string, in path, required) URL-encoded recipient email address. #### Example request ```bash curl https://api.anypost.com/v1/suppressions/alice%40example.com \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `data` (array of Suppression, optional) ```json { "data": [ { "id": "sup_550e8400-e29b-41d4-a716-446655440000", "email": "alice@example.com", "topic": "*", "reason": "permanent_bounce", "origin": "auto", "classification": "string", "smtp_code": 0, "note": "string", "suppressed_at": "string", "expires_at": "string", "created_at": "string" } ] } ``` #### Responses | Status | Description | |---|---| | `200` | All suppressions for this address. | | `404` | Resource not found. | ## Remove every suppression for an email `DELETE /v1/suppressions/{email}` Removes the address from the suppression list across every topic. After this call the address is immediately eligible to receive mail again on the next send (regardless of topic). An automatic bounce or complaint event afterwards will create a fresh global (`*`) row; a future one-click unsubscribe will create a fresh topic-scoped row. To remove only one topic's row, use `DELETE /suppressions/{email}/{topic}` instead. #### Parameters `email` (string, in path, required) URL-encoded recipient email address. #### Example request ```bash curl https://api.anypost.com/v1/suppressions/alice%40example.com \ -X DELETE \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Responses | Status | Description | |---|---| | `204` | Deleted. | | `404` | Resource not found. | ## Retrieve a suppression by email and topic `GET /v1/suppressions/{email}/{topic}` Returns the single suppression record for the `(email, topic)` pair, or `404` if it isn't suppressed for the authenticated team. Use `*` as the topic to fetch the global row written by bounces, complaints, and global manual entries. #### Parameters `email` (string, in path, required) URL-encoded recipient email address. `topic` (string, in path, required) Topic the suppression is scoped to. `*` resolves the global row; any other value resolves a topic-scoped unsubscribe / manual entry. URL-encode `*` as `%2A` if your HTTP client does not allow literal asterisks in path segments. #### Example request ```bash curl https://api.anypost.com/v1/suppressions/alice%40example.com/marketing \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Response body On success (`200`), the response body is: `id` (string, optional) Stable identifier for log correlation. Lookups and deletes are keyed on `(email, topic)`, not on this id. `email` (string (email), optional) The suppressed recipient address, normalized to lowercase with surrounding whitespace trimmed. Together with `topic`, forms the natural key for `GET` / `DELETE /suppressions/{email}/{topic}`. `topic` (string, optional) The topic this suppression is scoped to. The wildcard `*` means "every topic" — bounces and complaints always write `*` so the address is suppressed for all sends. A specific topic (e.g. `marketing`) only blocks sends tagged with that same topic, leaving transactional traffic (`otp`, `password-reset`, …) unaffected. Topics are lowercased on write. `reason` (string, optional) Why the address is suppressed. * `permanent_bounce` — the receiving server reported a permanent delivery failure (e.g. `InvalidRecipient`, `BadDomain`). Always topic `*`. * `complaint` — an ARF feedback report (FBL) classified as abuse, fraud, virus, or opt-out. Always topic `*`. * `unsubscribed` — the recipient one-click unsubscribed (RFC 8058 POST to `/u/{token}`). Scoped to the topic from the unsubscribe token. * `manual` — added through the API/UI or via CSV import. Topic-scoped or `*`. One of: `permanent_bounce`, `complaint`, `unsubscribed`, `manual`. `origin` (string, optional) Provenance of the row. * `auto` — written automatically from a bounce or complaint event. * `manual` — created through this API or the dashboard. One of: `auto`, `manual`. `classification` (string, optional) For `permanent_bounce`, the bounce classification (e.g. `InvalidRecipient`). For `complaint`, the ARF `feedback-type` (`abuse`, `fraud`, …). Null for manual entries. May be null. `smtp_code` (integer, optional) The SMTP reply code on the bounce that produced this suppression. Null for complaints and manual entries. May be null. `note` (string, optional) Free-form note attached at creation. Preserved across automatic re-suppressions of the same address. May be null. `suppressed_at` (string (date-time), optional) When the suppression was first observed (bounce / complaint timestamp for `auto`, creation time for manual). `expires_at` (string (date-time), optional) When this suppression stops applying. Null means it never expires (complaints and manual entries by default). Permanent bounces expire on a rolling 90-day window. May be null. `created_at` (string (date-time), optional) ```json { "id": "sup_550e8400-e29b-41d4-a716-446655440000", "email": "alice@example.com", "topic": "*", "reason": "permanent_bounce", "origin": "auto", "classification": "string", "smtp_code": 0, "note": "string", "suppressed_at": "string", "expires_at": "string", "created_at": "string" } ``` #### Responses | Status | Description | |---|---| | `200` | The suppression. | | `404` | Resource not found. | ## Remove a suppression by email and topic `DELETE /v1/suppressions/{email}/{topic}` Removes the single `(email, topic)` row from the suppression list. Other topics for the same address are untouched. After this call sends tagged with this topic are again eligible for delivery. #### Parameters `email` (string, in path, required) URL-encoded recipient email address. `topic` (string, in path, required) #### Example request ```bash curl https://api.anypost.com/v1/suppressions/alice%40example.com/marketing \ -X DELETE \ -H "Authorization: Bearer $ANYPOST_API_KEY" ``` #### Responses | Status | Description | |---|---| | `204` | Deleted. | | `404` | Resource not found. | --- Source: /docs/glossary # Glossary Short definitions of the terms used across these docs. Where a term has its own article, the definition links to it. ## API key A credential that authenticates everything you send. It has a public ID (`key_...`) that names it and a secret (`ap_...`) that authenticates requests. The same key works for the HTTP API and for SMTP. See [**Authentication**](/docs/authentication). ## Attachment A file sent alongside a message, such as a PDF receipt. A message may carry up to 20 attachments. See [**Attachments & inline images**](/docs/attachments). ## Batch send One `POST /v1/email/batch` request that submits many independent messages, each with its own recipients and content. See [**Batch sending**](/docs/batch-sending). ## Bounce A permanent delivery failure: the receiving server refused the message and will not accept a retry, usually because the address does not exist. A bounce emits an `email.bounced` event and adds the address to your suppression list. ## Campaign A correlation dimension: a customer-supplied label grouping messages from one send effort, so their events can be reported together. See [**Tags, topics & campaigns**](/docs/tags-topics-campaigns). ## Click tracking Rewriting links in an HTML message so Anypost records when a recipient clicks one, emitting an `email.clicked` event. See [**Open & click tracking**](/docs/tracking). ## CNAME A DNS record that aliases one name to another. Domain verification works by publishing CNAMEs that point your domain at records Anypost manages. See [**Domains & DNS setup**](/docs/domains). ## Complaint A recipient marking a message as spam. A complaint emits an `email.complained` event and suppresses the address. A rising complaint rate damages sending reputation. ## Correlation dimension One of four optional labels (`tags`, `topic`, `campaign`, `template_id`) that travel with a message so its events can be filtered and reported. They carry no delivery meaning. See [**Core concepts**](/docs/core-concepts). ## DKIM DomainKeys Identified Mail: a cryptographic signature proving a message was authorized by the sending domain. Anypost generates DKIM keys and signs your mail automatically once a domain is verified. ## DMARC A policy, published in DNS, telling receiving servers how to handle mail that fails SPF and DKIM checks. It builds on the authentication Anypost sets up. Anypost recommends a record when you add a domain. See [**Domains & DNS setup**](/docs/domains). ## Domain A domain you own that Anypost is authorized to send mail for, also called a sending domain. The domain in a `from` address must be verified. See [**Domains & DNS setup**](/docs/domains). ## Event A record of something that happened to a message: delivered, bounced, complained, opened, or clicked. Events are how Anypost reports delivery outcomes. See [**Core concepts**](/docs/core-concepts). ## Idempotency key A client-supplied `Idempotency-Key` header on a send request that makes a retry safe: the same key returns the original response instead of sending again. See [**API conventions**](/docs/api-conventions). ## Inline image An image embedded in the body of an HTML message and displayed in place, rather than offered as a downloadable attachment. See [**Attachments & inline images**](/docs/attachments). ## Message One email Anypost has accepted, identified by an `email_` ID. Acceptance is not delivery. See [**Send a single email**](/docs/send-email). ## Open tracking Embedding a tracking pixel in an HTML message so Anypost records when a recipient opens it, emitting an `email.opened` event. See [**Open & click tracking**](/docs/tracking). ## Permission level The scope of an API key, either `full` (every endpoint) or `send_only` (send mail and read its own identity only). See [**Authentication**](/docs/authentication). ## Reply-to An address, distinct from `from`, that recipient replies are directed to. A message may set up to 10. See [**Send a single email**](/docs/send-email). ## SMTP The standard mail-submission protocol. Anypost accepts mail over SMTP with near-parity to the HTTP API, so existing software can send through Anypost without code changes. See [**Sending over SMTP**](/docs/sending-over-smtp). ## SPF Sender Policy Framework: a DNS record listing the servers allowed to send mail for a domain. Anypost publishes and maintains it for verified domains. ## Suppression An address Anypost will not send to, added automatically by a hard bounce or a complaint. A send to a suppressed address is dropped. See [**Suppressions**](/docs/suppressions). ## Tag A correlation dimension: a short freeform label (`[A-Za-z0-9_-]`, up to 64 characters) attached to a message, up to 10 per message. See [**Tags, topics & campaigns**](/docs/tags-topics-campaigns). ## Team The owner of every resource: domains, keys, templates, webhooks, and messages. Resources never cross team boundaries. See [**Core concepts**](/docs/core-concepts). ## Template Stored message content (subject, HTML, text) with placeholders, referenced by ID and rendered with per-recipient variables. See [**Sending with templates**](/docs/templates). ## Topic A correlation dimension: a single customer-supplied label categorizing what a message is about. See [**Tags, topics & campaigns**](/docs/tags-topics-campaigns). ## Tracking domain A branded subdomain (such as `track.example.com`) under which open and click tracking links are served, verified separately from the sending domain. See [**Tracking domains**](/docs/tracking-domains). ## Variables Per-recipient values substituted into a template's placeholders when a message is rendered. See [**Variables & personalization**](/docs/variables). ## Verification The one-time process of proving you control a domain, by publishing the CNAME records Anypost provides. See [**Domains & DNS setup**](/docs/domains). ## Webhook An HTTPS endpoint you register so Anypost can `POST` each event to your application as it happens. See [**Webhooks**](/docs/webhooks). --- Source: /docs/changelog # Changelog ## 2026-05-17 The Anypost API is available at `v1`: transactional sending over the [**HTTP API**](/docs/send-email) and [**SMTP**](/docs/sending-over-smtp), [**sending domains**](/docs/domains), [**templates**](/docs/templates), [**open and click tracking**](/docs/tracking), [**webhooks**](/docs/webhooks), [**suppressions**](/docs/suppressions), and the [**events feed**](/docs/reference/events).