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.
- Create an account at
https://app.trivsend.com/signupand 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. - 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. - 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 exampleTRIVSEND_API_KEY. - Send:
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"
}'{ "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 or 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 |
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 |
scheduled_at | string | no | Send later. See Scheduling |
template | {id, variables} | no | Send a hosted template instead of html/text. See 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.
Response 200:
{ "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.
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"}
]'{ "data": [ { "id": "..." }, { "id": "..." } ] }Code examples
Node.js (18 or later, no packages needed):
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):
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:
$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):
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
"attachments": [
{
"filename": "invoice-1042.pdf",
"content": "JVBERi0xLjQKJ...",
"content_type": "application/pdf"
},
{
"filename": "logo.png",
"content": "iVBORw0KGgo...",
"content_id": "logo"
}
]contentis the file encoded as base64.content_idmakes 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; sendcontent.
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:
# 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):
{
"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 |
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 lastidyou received;before=<id>: the previous page.
{ "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:
{ "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 are free. Check them at any time:
curl -H "Authorization: Bearer $KEY" https://api.trivsend.com/usage{
"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. Addrua=mailto:you@yourdomainto get reports at an address you read, and tighten top=quarantineonce 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 |
Templates
Store an email once, send it by id or alias with different variables. Create templates in the dashboard (Templates) or with POST /templates:
{
"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:
{
"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,subjectandreply_toin 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:
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"]}'{ "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 |
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:
{
"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 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:
- Take the raw request body, exactly as received, before any JSON parsing.
- Compute HMAC-SHA256 over
<webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of the secret afterwhsec_. - Base64 the result and compare it with each
v1,<signature>entry inwebhook-signature(space-separated), in constant time. - Reject timestamps more than 5 minutes from now, to stop replays.
Node.js (Express):
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):
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 "", 200Delivery and retries
- Reply with any
2xxwithin 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-idacross retries and replays; use it to ignore duplicates. - Events can arrive out of order. Use
created_atwhen order matters. - Each delivery is logged:
GET /webhooks/:id/events, andPOST /webhooks/:id/events/:eventId/replaysends 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:
curl -H "Authorization: Bearer $KEY" https://api.trivsend.com/emails/receiving/$EMAIL_ID{
"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:
curl -X POST https://api.trivsend.com/validations \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"email": "asha@gmial.com"}'{
"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:
RESEND_API_KEY=ts_...
RESEND_BASE_URL=https://api.trivsend.comimport { 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"}, thenPOST /contacts/:idOrEmail/segments/:segmentId. - Topics: what people can subscribe to, opt-in or opt-out by default.
PATCH /contacts/:idOrEmail/topicsupdates a contact's choices. - Broadcasts:
POST /broadcasts {"segment_id", "topic_id", "from", "subject", "html"}, thenPOST /broadcasts/:id/send(optionally withscheduled_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
textwithhtml. Spam filters score HTML-only mail worse. - Use an
Idempotency-Keyon anything a retry could duplicate: OTPs, receipts, invoices. - Handle
email.bouncedandemail.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 /usageand 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:
- Read the key from an environment variable (
TRIVSEND_API_KEY). Never hard-code it, never log it, never send it to a browser. - Call
POST https://api.trivsend.com/emailsfrom server-side code only, withAuthorization: Bearer <key>andContent-Type: application/json. - Put the
fromaddress on a domain the user has verified in TrivSend. If you don't know it, read it from configuration (for exampleTRIVSEND_FROM) and ask the user for the value. - Send both
htmlandtext. - Set an
Idempotency-Keyderived from the business event (for examplepassword-reset-<token id>), and reuse it on retries. - Treat
200with anidas "queued", not "delivered". Store theidif the app needs to track delivery. - On errors, read
namefrom the JSON body. Retry only network errors,429 rate_limit_exceededand5xx, with backoff. - For delivery outcomes, add a webhook endpoint that verifies the signature over the raw body (see Verify the signature) and returns
2xxquickly. - In tests, use a
ts_test_key ordelivered@simulator.trivsend.com; never send real email from CI. - 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