# TrivSend developer guide

Everything an application needs to send email through TrivSend, receive it, and react to what happens after. Written for people and for AI coding assistants: every request and response below is the real shape the API returns.

- **API base URL:** `https://api.trivsend.com`
- **Dashboard:** `https://app.trivsend.com`
- **SMTP relay:** `smtp.trivsend.com`, port 587 (STARTTLS) or 465 (TLS)
- **This guide as plain Markdown:** `https://trivsend.com/docs.md` (point an AI assistant at it)

## Quick start

Four steps from zero to a delivered email.

1. **Create an account** at `https://app.trivsend.com/signup` and confirm your email address. New accounts are reviewed before they can send to real recipients; you can set everything up, and send with a test key, in the meantime.
2. **Add your sending domain** under **Domains**. Use a subdomain such as `notify.example.com`, so transactional reputation stays separate from your people's mail. Publish the DNS records it shows, then click **Verify**. See [Domains and DNS](#domains-and-dns).
3. **Create an API key** under **API keys**. Choose **Sending access** for an app that only sends. The key (`ts_...`) is shown once; store it as a secret, for example `TRIVSEND_API_KEY`.
4. **Send:**

```bash
curl -X POST https://api.trivsend.com/emails \
  -H "Authorization: Bearer $TRIVSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <noreply@notify.example.com>",
    "to": "user@example.com",
    "subject": "Your sign-in code",
    "html": "<p>Your code is <strong>482913</strong></p>",
    "text": "Your code is 482913"
  }'
```

```json
{ "id": "3f2a1c9e-7b4d-4e8a-9c1f-2d6b8a0e5f71" }
```

A `200` with an `id` means TrivSend accepted the email and queued it. Delivery happens in the background; follow it with [`GET /emails/:id`](#check-an-email) or [webhooks](#webhooks).

## Authentication

Every request carries an API key as a bearer token:

```
Authorization: Bearer ts_xxxxxxxxxxxxxxxxxxxxxxxx
```

| Key type | Prefix | Can do |
| --- | --- | --- |
| Full access | `ts_` | Everything in this guide |
| Sending access | `ts_` | `POST /emails`, `POST /emails/batch`, `POST /validations`, `GET /usage`. Can be limited to one domain with `domain_id` |
| Test | `ts_test_` | Sending access, but nothing is delivered: every email is accepted, logged and simulated. See [Testing](#testing-without-sending) |

Rules for keys:

- Keep keys on the server. Never put one in browser or mobile app code.
- Give each app and environment its own key, so you can revoke one without touching the others.
- Use a sending-access key unless the app really manages domains, webhooks or contacts.

## Sending email

### POST /emails

Send one email.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `from` | string | yes* | `"Name <addr@domain>"` or `"addr@domain"`. The domain must be verified in your account |
| `to` | string or string[] | yes | Up to 50 recipients across `to`, `cc` and `bcc` |
| `cc`, `bcc` | string or string[] | no | |
| `reply_to` | string or string[] | no | |
| `subject` | string | yes* | Up to 998 characters |
| `html` | string | one of* | HTML body |
| `text` | string | one of* | Plain-text body. Send both for the best inbox placement |
| `headers` | object | no | Extra headers, e.g. `{"X-Entity-Ref-ID": "123"}`. `From`, `To`, `Subject`, `Message-ID`, `Date` and similar are set by TrivSend |
| `tags` | `{name, value}[]` | no | Up to 50. Letters, numbers, `_` and `-` only. Searchable and included in webhooks |
| `attachments` | object[] | no | Up to 20. See [Attachments](#attachments) |
| `scheduled_at` | string | no | Send later. See [Scheduling](#scheduling) |
| `template` | `{id, variables}` | no | Send a hosted template instead of `html`/`text`. See [Templates](#templates) |

\* With `template`, `from` and `subject` may come from the template instead, and `html`/`text` must not be sent.

Optional request header: `Idempotency-Key`. See [Idempotency](#idempotency).

Response `200`:

```json
{ "id": "3f2a1c9e-7b4d-4e8a-9c1f-2d6b8a0e5f71" }
```

### POST /emails/batch

Send up to 100 emails in one request. The body is a JSON array of `POST /emails` objects. Either all of them are accepted or none are: a validation error in any one rejects the whole batch.

```bash
curl -X POST https://api.trivsend.com/emails/batch \
  -H "Authorization: Bearer $TRIVSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {"from": "Acme <noreply@notify.example.com>", "to": "a@example.com", "subject": "Hi A", "text": "Hello"},
    {"from": "Acme <noreply@notify.example.com>", "to": "b@example.com", "subject": "Hi B", "text": "Hello"}
  ]'
```

```json
{ "data": [ { "id": "..." }, { "id": "..." } ] }
```

### Code examples

Node.js (18 or later, no packages needed):

```js
const res = await fetch("https://api.trivsend.com/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRIVSEND_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `welcome-${user.id}`,
  },
  body: JSON.stringify({
    from: "Acme <noreply@notify.example.com>",
    to: user.email,
    subject: "Welcome to Acme",
    html: "<h1>Welcome!</h1>",
    text: "Welcome!",
    tags: [{ name: "category", value: "welcome" }],
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`TrivSend ${res.status} ${body.name}: ${body.message}`);
console.log("queued", body.id);
```

Python (`requests`):

```python
import os, requests

res = requests.post(
    "https://api.trivsend.com/emails",
    headers={
        "Authorization": f"Bearer {os.environ['TRIVSEND_API_KEY']}",
        "Idempotency-Key": f"welcome-{user_id}",
    },
    json={
        "from": "Acme <noreply@notify.example.com>",
        "to": user_email,
        "subject": "Welcome to Acme",
        "html": "<h1>Welcome!</h1>",
        "text": "Welcome!",
    },
    timeout=15,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"TrivSend {res.status_code} {body['name']}: {body['message']}")
print("queued", body["id"])
```

PHP:

```php
$ch = curl_init("https://api.trivsend.com/emails");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRIVSEND_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "from" => "Acme <noreply@notify.example.com>",
        "to" => $email,
        "subject" => "Welcome to Acme",
        "html" => "<h1>Welcome!</h1>",
    ]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status !== 200) throw new Exception("TrivSend $status {$body['name']}: {$body['message']}");
```

Oracle APEX / PL/SQL (`APEX_WEB_SERVICE`; the database needs an ACL and a wallet that trusts Let's Encrypt for `api.trivsend.com`):

```sql
declare
  l_body     clob;
  l_response clob;
begin
  select json_object(
           'from'    value 'Acme <noreply@notify.example.com>',
           'to'      value :P1_EMAIL,
           'subject' value 'Your report is ready',
           'html'    value '<p>Hello</p>'
           returning clob)
    into l_body
    from dual;

  apex_web_service.g_request_headers.delete;
  apex_web_service.g_request_headers(1).name  := 'Authorization';
  apex_web_service.g_request_headers(1).value := 'Bearer ' || :TRIVSEND_API_KEY;
  apex_web_service.g_request_headers(2).name  := 'Content-Type';
  apex_web_service.g_request_headers(2).value := 'application/json';

  l_response := apex_web_service.make_rest_request(
    p_url         => 'https://api.trivsend.com/emails',
    p_http_method => 'POST',
    p_body        => l_body);

  if apex_web_service.g_status_code <> 200 then
    raise_application_error(-20001, 'TrivSend: ' || dbms_lob.substr(l_response, 2000, 1));
  end if;
end;
```

### Attachments

```json
"attachments": [
  {
    "filename": "invoice-1042.pdf",
    "content": "JVBERi0xLjQKJ...",
    "content_type": "application/pdf"
  },
  {
    "filename": "logo.png",
    "content": "iVBORw0KGgo...",
    "content_id": "logo"
  }
]
```

- `content` is the file encoded as **base64**.
- `content_id` makes an inline image: reference it in the HTML as `<img src="cid:logo">`.
- The whole request may be up to 15 MB, so keep attachments under about 10 MB in total (base64 adds a third).
- Attachments by URL (`path`) are not supported; send `content`.

### Idempotency

Networks fail after the server has acted. To retry safely, send an `Idempotency-Key` header (up to 256 characters) that is unique per logical email, for example `otp-<request id>` or `invoice-<invoice id>`. A repeat request with the same key returns the original email's `id` and sends nothing new. Keys are scoped to your account.

### Scheduling

Set `scheduled_at` to send later, up to 30 days ahead:

- an ISO 8601 timestamp **with** a timezone: `"2026-11-01T09:00:00+05:30"` or `"2026-11-01T03:30:00Z"`;
- or a phrase: `"in 10 minutes"`, `"in 2 hours"`, `"in 3 days"`.

A scheduled email has status `scheduled` until it goes out. Change or cancel it:

```bash
# Reschedule
curl -X PATCH https://api.trivsend.com/emails/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"scheduled_at": "in 1 hour"}'

# Cancel
curl -X POST https://api.trivsend.com/emails/$ID/cancel -H "Authorization: Bearer $KEY"
```

Both return `{ "object": "email", "id": "..." }` and need a full-access key. Only emails that are still `scheduled` can be changed.

## Check an email

`GET /emails/:id` (full access):

```json
{
  "object": "email",
  "id": "3f2a1c9e-7b4d-4e8a-9c1f-2d6b8a0e5f71",
  "from": "Acme <noreply@notify.example.com>",
  "to": ["user@example.com"],
  "cc": [],
  "bcc": [],
  "reply_to": [],
  "subject": "Your sign-in code",
  "html": "<p>Your code is <strong>482913</strong></p>",
  "text": "Your code is 482913",
  "tags": [{ "name": "category", "value": "otp" }],
  "status": "sent",
  "last_event": "sent",
  "message_id": "<3f2a1c9e-...@notify.example.com>",
  "template_id": null,
  "test": false,
  "scheduled_at": null,
  "created_at": "2026-10-22T09:14:03.120Z",
  "sent_at": "2026-10-22T09:14:04.501Z"
}
```

| `status` | Meaning |
| --- | --- |
| `scheduled` | Waiting for `scheduled_at` |
| `queued`, `sending` | Accepted, being delivered |
| `sent` | The recipient's mail server accepted it |
| `bounced` | The recipient's server rejected it (after it was sent) |
| `failed` | Could not be delivered after all retries |
| `suppressed` | Not sent: every recipient is on your suppression list |
| `canceled` | A scheduled email you canceled |

`last_event` is the latest thing that happened, which can be newer than the status: `delivery_delayed`, `bounced`, `complained`, `opened` or `clicked`.

`sent` means the receiving server accepted the message. Whether it lands in the inbox or in spam is decided by the receiver; good DNS, consistent sending and clean lists are what move that.

### List and search

`GET /emails` (full access) returns the newest first and takes these filters, which combine:

| Query | Example |
| --- | --- |
| `recipient` | `user@example.com` (matches to, cc and bcc) |
| `status` | `sent,bounced` |
| `tag` | `category=otp` |
| `subject`, `from` | partial, case-insensitive |
| `stream` | `transactional` or `marketing` |
| `test` | `true` or `false` |
| `created_after`, `created_before` | `2026-10-01T00:00:00Z` |

```bash
curl -H "Authorization: Bearer $KEY" \
  "https://api.trivsend.com/emails?recipient=user@example.com&status=bounced&limit=20"
```

## Pagination

Every list endpoint pages the same way:

- `limit`: 1 to 100, default 20;
- `after=<id>`: the next page (older items), using the last `id` you received;
- `before=<id>`: the previous page.

```json
{ "object": "list", "data": [ ... ], "has_more": true }
```

Keep calling with `after` set to the last item's `id` while `has_more` is `true`.

## Errors

Errors always have this shape:

```json
{ "statusCode": 403, "name": "invalid_from_address", "message": "The example.com domain is not verified. Verify its DNS records first." }
```

Branch on `name`; show `message` to developers, not end users.

| Status | `name` | What to do |
| --- | --- | --- |
| 401 | `missing_api_key` | Send the `Authorization: Bearer` header |
| 403 | `invalid_api_key` | The key is wrong or revoked |
| 401 | `restricted_api_key` | A sending-access key called a full-access endpoint, or used another domain |
| 403 | `invalid_from_address` | Add and verify the `from` domain |
| 403 | `account_pending_review` | Your account is in review; use a test key until it's approved |
| 403 | `tenant_paused`, `tenant_suspended` | Sending is stopped for the account; contact support |
| 404 | `not_found` | Wrong id, or it belongs to another account |
| 422 | `validation_error` | The `message` names the field and the problem |
| 429 | `rate_limit_exceeded` | Too many requests per second; retry after a short pause |
| 429 | `daily_quota_exceeded`, `monthly_quota_exceeded` | Plan limit reached; resets at midnight UTC or on the 1st |
| 500 | `application_error` | Retry with the same `Idempotency-Key` |

### Retrying

Retry on network errors, `429 rate_limit_exceeded` and `5xx`, with exponential backoff (for example 1 s, 2 s, 4 s, 8 s) and the **same** `Idempotency-Key`, so a retry never sends twice. Don't retry other `4xx` errors; fix the request.

## Rate limits and quotas

Each account has a requests-per-second limit and daily and monthly email quotas, set by its plan. Quotas count emails that can reach a real mailbox; test keys and [simulator addresses](#testing-without-sending) are free. Check them at any time:

```bash
curl -H "Authorization: Bearer $KEY" https://api.trivsend.com/usage
```

```json
{
  "object": "usage",
  "plan": { "slug": "starter", "name": "Starter" },
  "status": "active",
  "emails": {
    "daily":   { "used": 132,  "limit": 1000,  "resets_at": "2026-10-23T00:00:00.000Z" },
    "monthly": { "used": 4210, "limit": 30000, "resets_at": "2026-11-01T00:00:00.000Z" }
  }
}
```

(Shortened; the response also reports contacts, domains and bounce/complaint counts.)

## Domains and DNS

You can only send from domains you have added and verified. Add one in the dashboard or with `POST /domains {"name": "notify.example.com"}`; the response lists the DNS records to publish:

| Record | Type | Name | Value |
| --- | --- | --- | --- |
| DKIM | TXT | `trivsend._domainkey.notify.example.com` | `v=DKIM1; k=rsa; p=...` (unique per domain) |
| Return path | MX | `bounces.notify.example.com` | `mail.trivsend.com`, priority 10 |
| SPF | TXT | `bounces.notify.example.com` | `v=spf1 include:_spf.trivsend.com ~all` |
| DMARC (recommended) | TXT | `_dmarc.notify.example.com` | `v=DMARC1; p=none;` |

Then verify with **Verify** in the dashboard or `POST /domains/:id/verify`. The domain's `status` becomes `verified` once DKIM, the return-path MX and SPF are found. DNS changes can take a few minutes to an hour to appear.

Notes:

- SPF is checked on the `bounces.` return-path subdomain, so your domain's existing SPF record does not change.
- With Cloudflare, records must be **DNS only** (grey cloud). TXT and MX records always are.
- Start DMARC at `p=none`. Add `rua=mailto:you@yourdomain` to get reports at an address you read, and tighten to `p=quarantine` once they look clean.

Domain settings, with `PATCH /domains/:id`:

| Field | Effect |
| --- | --- |
| `open_tracking`, `click_tracking` | `true`/`false`. Off by default |
| `tracking_subdomain` | e.g. `"links"`: tracked links use `links.notify.example.com` instead of a TrivSend host. Add the CNAME it shows (to `track.trivsend.com`) and verify. `null` removes it |
| `inbound` | `true` to receive mail. See [Receiving email](#receiving-email) |

## Templates

Store an email once, send it by id or alias with different variables. Create templates in the dashboard (**Templates**) or with `POST /templates`:

```json
{
  "name": "Sign-in code",
  "alias": "otp",
  "from": "Acme <noreply@notify.example.com>",
  "subject": "Your code is {{{CODE}}}",
  "html": "<p>Hi {{{NAME|there}}}, your code is <strong>{{{CODE}}}</strong>.</p>",
  "variables": [{ "key": "CODE", "type": "string" }, { "key": "NAME", "type": "string", "fallback_value": "there" }]
}
```

Publish it (`POST /templates/otp/publish`, or **Publish** in the dashboard), then send:

```json
{
  "to": "user@example.com",
  "template": { "id": "otp", "variables": { "CODE": "482913", "NAME": "Asha" } }
}
```

- Placeholders are `{{{NAME}}}`, or `{{{NAME|fallback}}}` with a default.
- Values are HTML-escaped in the HTML body, so user input can't inject markup.
- Sends always use the **published** version; edits are drafts until you publish again.
- `from`, `subject` and `reply_to` in the request override the template's.

## Webhooks

Webhooks tell your app what happened after a send, without polling. Create one in the dashboard (**Webhooks**) or with the API:

```bash
curl -X POST https://api.trivsend.com/webhooks \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"endpoint": "https://example.com/hooks/trivsend", "events": ["email.sent", "email.bounced", "email.complained"]}'
```

```json
{ "object": "webhook", "id": "...", "signing_secret": "whsec_..." }
```

Store `signing_secret`; it is shown once (rotate it with `POST /webhooks/:id/signing-secret/rotate`).

### Events

| Event | When |
| --- | --- |
| `email.scheduled` | An email was scheduled |
| `email.sent` | The recipient's server accepted it |
| `email.delivery_delayed` | A temporary failure; TrivSend keeps retrying |
| `email.bounced` | The address doesn't exist or rejected it; the address is now suppressed |
| `email.complained` | The recipient marked it as spam; the address is now suppressed |
| `email.failed` | Gave up after all retries |
| `email.suppressed` | Not sent because the recipient is suppressed |
| `email.opened`, `email.clicked` | With open/click tracking on |
| `email.received` | Inbound mail arrived. See [Receiving email](#receiving-email) |
| `contact.created`, `contact.updated`, `contact.deleted`, `contact.topics.updated` | Marketing contacts changed |
| `domain.created`, `domain.updated`, `domain.deleted` | Domains changed |
| `suppression.added`, `suppression.removed` | The suppression list changed |
| `topic.created`, `topic.updated`, `topic.deleted` | Topics changed |

Payload:

```json
{
  "type": "email.bounced",
  "created_at": "2026-10-22T09:15:10.000Z",
  "data": {
    "email_id": "3f2a1c9e-7b4d-4e8a-9c1f-2d6b8a0e5f71",
    "created_at": "2026-10-22T09:14:03.120Z",
    "message_id": "<3f2a1c9e-...@notify.example.com>",
    "from": "Acme <noreply@notify.example.com>",
    "to": ["user@example.com"],
    "subject": "Your sign-in code",
    "tags": { "category": "otp" },
    "bounce": {
      "type": "Permanent",
      "subType": "hard",
      "message": "550 5.1.1 user unknown",
      "status": "5.1.1",
      "recipient": "user@example.com"
    }
  }
}
```

### Verify the signature

Every request is signed with the [Standard Webhooks](https://www.standardwebhooks.com/) scheme. The headers are `webhook-id`, `webhook-timestamp` and `webhook-signature` (also sent as `svix-id`, `svix-timestamp`, `svix-signature`). Any Standard Webhooks or Svix library verifies them. Without a library:

1. Take the **raw** request body, exactly as received, before any JSON parsing.
2. Compute HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<raw body>`, keyed with the base64-decoded part of the secret after `whsec_`.
3. Base64 the result and compare it with each `v1,<signature>` entry in `webhook-signature` (space-separated), in constant time.
4. Reject timestamps more than 5 minutes from now, to stop replays.

Node.js (Express):

```js
import crypto from "node:crypto";
import express from "express";

const secret = Buffer.from(process.env.TRIVSEND_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

app.post("/hooks/trivsend", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.header("webhook-id");
  const timestamp = req.header("webhook-timestamp");
  const signatures = (req.header("webhook-signature") ?? "").split(" ");
  if (!id || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac("sha256", secret).update(`${id}.${timestamp}.${req.body}`).digest();
  const valid = signatures.some((s) => {
    const given = Buffer.from(s.replace(/^v1,/, ""), "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  // Deduplicate on webhook-id: a retried delivery has the same id.
  handle(event).then(() => res.sendStatus(200), () => res.sendStatus(500));
});
```

Python (Flask):

```python
import base64, hashlib, hmac, os, time
from flask import Flask, request, abort

secret = base64.b64decode(os.environ["TRIVSEND_WEBHOOK_SECRET"].removeprefix("whsec_"))

@app.post("/hooks/trivsend")
def trivsend_hook():
    msg_id = request.headers.get("webhook-id", "")
    ts = request.headers.get("webhook-timestamp", "0")
    if abs(time.time() - int(ts)) > 300:
        abort(400)
    body = request.get_data()  # raw bytes
    expected = base64.b64encode(hmac.new(secret, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
    given = [s.split(",", 1)[-1] for s in request.headers.get("webhook-signature", "").split()]
    if not any(hmac.compare_digest(expected, g) for g in given):
        abort(401)
    event = request.get_json()
    # ... handle event["type"], deduplicate on msg_id
    return "", 200
```

### Delivery and retries

- Reply with any `2xx` within 15 seconds. Do slow work after replying, or in a queue.
- Failed deliveries are retried after 5 seconds, 5 minutes, 30 minutes, 2, 5, 10 and 10 hours, then dropped.
- The same event keeps the same `webhook-id` across retries and replays; use it to ignore duplicates.
- Events can arrive out of order. Use `created_at` when order matters.
- Each delivery is logged: `GET /webhooks/:id/events`, and `POST /webhooks/:id/events/:eventId/replay` sends one again.

## Receiving email

Turn on inbound for a domain (`PATCH /domains/:id {"inbound": true}`, or the switch on the domain's page), then add the MX record it shows:

| Type | Name | Value |
| --- | --- | --- |
| MX | `inbound.notify.example.com` | `mail.trivsend.com`, priority 10 |

After verification, mail to **any address** at `inbound.notify.example.com` (for example `support@inbound.notify.example.com` or `reply+ticket-42@inbound.notify.example.com`) is stored and sent to your `email.received` webhooks. Messages can be up to 25 MB.

The webhook carries the metadata; fetch the body and attachments with the API:

```bash
curl -H "Authorization: Bearer $KEY" https://api.trivsend.com/emails/receiving/$EMAIL_ID
```

```json
{
  "object": "email",
  "id": "...",
  "from": "Asha <asha@example.org>",
  "to": ["support@inbound.notify.example.com"],
  "cc": [],
  "reply_to": [],
  "subject": "Re: Ticket 42",
  "message_id": "<CAF...@mail.gmail.com>",
  "created_at": "2026-10-22T10:01:00.000Z",
  "rcpt_to": ["support@inbound.notify.example.com"],
  "html": "<div>Thanks, that fixed it.</div>",
  "text": "Thanks, that fixed it.",
  "headers": { "in-reply-to": "<...>" },
  "attachments": [
    { "id": "...", "filename": "screenshot.png", "content_type": "image/png", "content_disposition": "attachment", "content_id": null, "size": 48213 }
  ]
}
```

Download an attachment with `GET /emails/receiving/:id/attachments/:attachmentId`; `content` is base64. List everything received with `GET /emails/receiving`.

Tip: to route replies to the right record, send with `reply_to: "reply+ticket-42@inbound.notify.example.com"` and read the part after `+` from `rcpt_to` when the reply arrives.

## Validating addresses

Check an address before you store it or send to it, for example on a sign-up form:

```bash
curl -X POST https://api.trivsend.com/validations \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"email": "asha@gmial.com"}'
```

```json
{
  "object": "validation",
  "email": "asha@gmial.com",
  "result": "valid",
  "reason": "accepted",
  "did_you_mean": "asha@gmail.com",
  "checks": { "syntax": true, "mail_server": true, "disposable": false, "role": false, "free": false, "suppressed": false }
}
```

| `result` | `reason` | Suggested handling |
| --- | --- | --- |
| `valid` | `accepted` | Accept. If `did_you_mean` is set, ask "Did you mean ...?" |
| `risky` | `disposable`, `role_address` | Accept or warn, your choice |
| `invalid` | `invalid_syntax`, `no_mail_server`, `suppressed` | Reject or ask for another address |
| `unknown` | `dns_error` | Accept; try again later |

`POST /validations/batch` takes `{"emails": [...]}` (up to 100) and returns `{ "object": "list", "data": [...] }`. Sending-access keys may call both. Validation checks syntax, DNS and lists; it never connects to the mailbox, so `valid` means "this domain takes mail and nothing marks it risky", not "this inbox exists".

## Suppressions

TrivSend never sends to an address on your suppression list. Hard bounces, spam complaints and unsubscribes are added automatically; an email to only suppressed recipients gets status `suppressed`.

| Request | Purpose |
| --- | --- |
| `GET /suppressions?origin=bounce` | List (origins: `bounce`, `complaint`, `unsubscribe`, `manual`) |
| `POST /suppressions {"email": "..."}` | Block an address yourself |
| `GET /suppressions/:idOrEmail` | Is this address suppressed, and why |
| `DELETE /suppressions/:idOrEmail` | Remove one, for example after a user fixed their mailbox |
| `POST /suppressions/batch/add`, `/batch/remove` | Up to 100 at once |

Remove a complaint suppression only if the person asked for your mail again.

## Testing without sending

Two tools, usable together:

**Simulator addresses** work with any key and never leave TrivSend:

| Send to | Result |
| --- | --- |
| `delivered@simulator.trivsend.com` | `sent` |
| `bounced@simulator.trivsend.com` | `sent`, then a hard bounce and an `email.bounced` webhook |
| `complained@simulator.trivsend.com` | `sent`, then a complaint and an `email.complained` webhook |

Add a label to tell tests apart: `bounced+signup-test@simulator.trivsend.com`. Simulated events never add suppressions and don't count toward quotas.

**Test keys** (`ts_test_...`, created with **Test** under API keys or `POST /api-keys {"name": "ci", "test": true}`) accept, log and simulate every email, even to real addresses, and nothing is delivered. They work before your domain is verified and while your account is in review, which makes them right for CI and staging.

## SMTP relay

For software that can only send over SMTP (WordPress, Supabase Auth, Keycloak, Grafana, Oracle APEX instance settings, ...):

| Setting | Value |
| --- | --- |
| Host | `smtp.trivsend.com` |
| Port | `587` with STARTTLS, or `465` with TLS |
| Username | `trivsend` |
| Password | a TrivSend API key (sending access is enough) |

The `From` domain must be verified, as with the API. Optional headers: `X-TrivSend-Tags: category=otp, app=crm` and `X-TrivSend-Idempotency-Key: otp-1234`. The server replies `250 OK queued as <email id>`, and the email appears in the log like any API send.

## Using the Resend SDKs

TrivSend's sending API matches Resend's request and response shapes, so code written for Resend works by changing the base URL and key. With the official Node SDK, set two environment variables and change nothing else:

```bash
RESEND_API_KEY=ts_...
RESEND_BASE_URL=https://api.trivsend.com
```

```js
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({ from: "Acme <noreply@notify.example.com>", to: "user@example.com", subject: "Hi", text: "Hello" });
```

`resend.webhooks.verify()` also accepts TrivSend signatures. TrivSend-only fields (`test`, the extra search filters, validation, inbound) are plain HTTP and are documented above.

## Marketing: contacts and broadcasts

For newsletters and announcements (full-access key):

- **Contacts:** `POST /contacts {"email", "first_name", "last_name", "properties": {...}}` creates or updates by email. `GET`/`PATCH`/`DELETE /contacts/:idOrEmail`.
- **Segments:** groups of contacts. `POST /segments {"name"}`, then `POST /contacts/:idOrEmail/segments/:segmentId`.
- **Topics:** what people can subscribe to, opt-in or opt-out by default. `PATCH /contacts/:idOrEmail/topics` updates a contact's choices.
- **Broadcasts:** `POST /broadcasts {"segment_id", "topic_id", "from", "subject", "html"}`, then `POST /broadcasts/:id/send` (optionally with `scheduled_at`).

Every broadcast gets one-click `List-Unsubscribe` headers and an unsubscribe link, unless your HTML places `{{{RESEND_UNSUBSCRIBE_URL}}}` itself. Contact properties can be used as `{{{key}}}`. Keep marketing on a different sending subdomain (for example `news.example.com`) from your transactional mail.

## Best practices

- **Send from a subdomain** (`notify.`, `mail.`) and keep transactional and marketing on different ones.
- **Always send `text` with `html`.** Spam filters score HTML-only mail worse.
- **Use an `Idempotency-Key`** on anything a retry could duplicate: OTPs, receipts, invoices.
- **Handle `email.bounced` and `email.complained`**: mark the address in your own database and stop asking the user to use it.
- **Don't send to addresses you didn't collect yourself,** and honour unsubscribes. Complaint rates above 0.3% get mail blocked at Gmail and Outlook.
- **Keep keys in secrets,** one per app and environment, with sending access where possible.
- **Watch** `GET /usage` and the **Stats** page for bounce and complaint rates.

## Endpoint reference

All paths are relative to `https://api.trivsend.com`.

| Method and path | Key | Purpose |
| --- | --- | --- |
| `POST /emails` | sending | Send one email |
| `POST /emails/batch` | sending | Send up to 100 emails |
| `GET /emails` | full | List and search sent email |
| `GET /emails/:id` | full | One email and its last event |
| `PATCH /emails/:id` | full | Reschedule a scheduled email |
| `POST /emails/:id/cancel` | full | Cancel a scheduled email |
| `GET /emails/receiving` | full | List received email |
| `GET /emails/receiving/:id` | full | One received email with its body |
| `GET /emails/receiving/:id/attachments/:attachmentId` | full | Download an attachment (base64) |
| `POST /validations` | sending | Validate one address |
| `POST /validations/batch` | sending | Validate up to 100 addresses |
| `GET /usage` | any | Plan, quotas and usage |
| `GET /stats?start=&end=&stream=` | full | Daily counts and rates (dates `YYYY-MM-DD`, up to 92 days) |
| `POST /domains`, `GET /domains` | full | Add, list domains |
| `GET`, `PATCH`, `DELETE /domains/:id` | full | Inspect (with DNS records), change settings, remove |
| `POST /domains/:id/verify` | full | Check DNS now |
| `POST /api-keys`, `GET /api-keys` | full | Create (token shown once), list |
| `PATCH`, `DELETE /api-keys/:id` | full | Rename, revoke |
| `POST /templates`, `GET /templates` | full | Create, list templates |
| `GET`, `PATCH`, `DELETE /templates/:idOrAlias` | full | Read, edit draft, delete |
| `POST /templates/:idOrAlias/publish`, `/duplicate` | full | Publish, copy |
| `POST /webhooks`, `GET /webhooks` | full | Create, list webhooks |
| `GET`, `PATCH`, `DELETE /webhooks/:id` | full | Inspect, change (`endpoint`, `events`, `status`), remove |
| `POST /webhooks/:id/signing-secret/rotate` | full | New signing secret |
| `GET /webhooks/:id/events[/:eventId]` | full | Delivery log |
| `POST /webhooks/:id/events/:eventId/replay` | full | Send an event again |
| `POST /suppressions`, `GET /suppressions` | full | Add, list |
| `GET`, `DELETE /suppressions/:idOrEmail` | full | Check, remove |
| `POST /suppressions/batch/add`, `/batch/remove` | full | Up to 100 at once |
| `POST /contacts`, `GET /contacts` | full | Create or update, list contacts |
| `GET`, `PATCH`, `DELETE /contacts/:idOrEmail` | full | Read, change, delete |
| `GET /contacts/:idOrEmail/segments`, `POST`/`DELETE .../segments/:segmentId` | full | A contact's segments |
| `GET`, `PATCH /contacts/:idOrEmail/topics` | full | A contact's topic subscriptions |
| `POST /segments`, `GET /segments`, `GET`/`PATCH`/`DELETE /segments/:id`, `GET /segments/:id/contacts` | full | Segments |
| `POST /topics`, `GET /topics`, `GET`/`PATCH`/`DELETE /topics/:id` | full | Topics |
| `POST /broadcasts`, `GET /broadcasts`, `GET`/`PATCH`/`DELETE /broadcasts/:id` | full | Broadcasts |
| `POST /broadcasts/:id/send`, `/cancel`, `/duplicate` | full | Send (now or `scheduled_at`), cancel, copy |
| `GET /health` | none | Service health |

## Instructions for AI coding assistants

If you are an AI assistant adding TrivSend to a codebase, follow these rules:

1. Read the key from an environment variable (`TRIVSEND_API_KEY`). Never hard-code it, never log it, never send it to a browser.
2. Call `POST https://api.trivsend.com/emails` from server-side code only, with `Authorization: Bearer <key>` and `Content-Type: application/json`.
3. Put the `from` address on a domain the user has verified in TrivSend. If you don't know it, read it from configuration (for example `TRIVSEND_FROM`) and ask the user for the value.
4. Send both `html` and `text`.
5. Set an `Idempotency-Key` derived from the business event (for example `password-reset-<token id>`), and reuse it on retries.
6. Treat `200` with an `id` as "queued", not "delivered". Store the `id` if the app needs to track delivery.
7. On errors, read `name` from the JSON body. Retry only network errors, `429 rate_limit_exceeded` and `5xx`, with backoff.
8. For delivery outcomes, add a webhook endpoint that verifies the signature over the **raw** body (see [Verify the signature](#verify-the-signature)) and returns `2xx` quickly.
9. In tests, use a `ts_test_` key or `delivered@simulator.trivsend.com`; never send real email from CI.
10. Don't invent endpoints or fields. Everything TrivSend supports is in this guide.

## Support

- Dashboard: `https://app.trivsend.com`
- Email: `trivsend@gmail.com`
- Service health: `https://api.trivsend.com/health`
