Overview
The API is REST over HTTPS, returns JSON, and is versioned in the path. Everything lives under https://brainito.com/api/v1.
Transactional email
Receipts, password resets, order updates. Accepted immediately and sent by a background dispatcher.
Contacts & segments
Keep your own systems as the source of truth. Segments re-evaluate as data arrives.
Events
Behavioural data that feeds segments and triggers automations.
Webhooks
Signed, retried delivery notifications so you never have to poll.
Authentication
Every request carries a bearer token:
Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRETKeys are scoped and belong to exactly one workspace. The workspace is taken from the key and never from the request body, so a key cannot reach another workspace's data no matter what it sends.
Test mode
A key beginning brn_test_ behaves identically but sends no real mail and consumes none of your allowance. Point your test suite at one — it exercises the same validation, the same suppression checks and the same error codes as production.
Quick start
Send your first message:
curl -X POST https://brainito.com/api/v1/emails \
-H "Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRET" \
-H "Idempotency-Key: order-confirmation-4821" \
-H "Content-Type: application/json" \
-d '{
"from": "Orders <orders@yourdomain.com>",
"to": "customer@example.com",
"subject": "Your order is on its way",
"html": "<p>Tracking: ABC123</p>"
}'HTTP/1.1 202 Accepted
X-Request-Id: req_9f2c1a4b7e3d
{
"ids": ["msg_01J8ZQ4K7T"],
"status": "queued"
}202 means accepted, not delivered. The message is persisted and a background dispatcher sends it, so a slow provider never becomes a slow request for you. Poll GET /v1/emails/{id} or subscribe to a webhook for the outcome.
Before you can send from a domain, it has to be verified — identity and DKIM both. Sending from an unverified domain returns 403 domain_not_verified.
Node SDK
npm install @brainito/sdkimport { Brainito } from "@brainito/sdk";
const brainito = new Brainito({ apiKey: process.env.BRAINITO_API_KEY! });
const { ids } = await brainito.sendEmail({
from: "Orders <orders@yourdomain.com>",
to: "customer@example.com",
subject: "Your order is on its way",
html: "<p>Tracking: ABC123</p>",
idempotencyKey: `order-confirmation-${order.id}`,
});The SDK retries 429s and 5xx responses with exponential backoff and jitter, honours Retry-After, and never retries a 4xx — a malformed request does not become valid on the second attempt.
import { BrainitoError } from "@brainito/sdk";
try {
await brainito.sendEmail({ /* ... */ });
} catch (err) {
if (err instanceof BrainitoError) {
console.error(err.code, err.message, err.requestId);
if (err.retryable) {
// 429 or 5xx — put it back on the queue
} else {
// 4xx — it will fail identically forever; dead-letter it
}
}
}Log requestId wherever you handle failures. It is the only thing that identifies the call afterwards, and it is the first thing support will ask for.
Idempotency
Send an Idempotency-Key header on any POST that creates something. Retrying with the same key and the same body returns the original response, marked with Idempotent-Replay: true. Keys are retained for 24 hours.
Reusing a key with a different body is a 409
That combination is a bug, not a retry — the same logical operation cannot have two different bodies. Returning the first response would silently discard the second request; accepting it would send twice. So it is rejected loudly instead, with idempotency_key_reuse.
Prefer a key derived from your own data — order-confirmation-4821 rather than a random one. A random key is lost when your process restarts mid-retry; a derived one survives it, which is exactly when the guarantee matters.
Contacts & consent
Creating a contact does not subscribe them
Consent is explicit, always. A contact created without a consent object is stored but not subscribed, and marketing will not go to them. This is deliberate: the API will not infer permission from the fact that you uploaded an address.
curl -X POST https://brainito.com/api/v1/contacts \
-H "Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"email": "customer@example.com",
"first_name": "Sam",
"consent": {
"subscribed": true,
"source": "form",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"evidence": { "form": "footer-signup", "page": "/pricing" }
}
}'Record ip_address and user_agent at the moment consent is given. They are what a data-protection enquiry actually asks for, and they cannot be reconstructed later. The full trail is returned as consent_history on GET /v1/contacts/{id}.
Attributes merge shallowly on update. An omitted key is left alone; an explicit null clears it, which is the only way to remove one.
Audiences
An audience is a static list you control. Every one has a short public_id — six characters, drawn from an alphabet with 0, 1, i, l and o removed so it survives being read off a screen or dictated over a call.
It is the same id the dashboard puts in the URL, so a link you were sent and a call your script makes refer to the same list in the same words.
# List your audiences — each carries a public_id
curl https://brainito.com/api/v1/audiences \
-H "Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRET"
# { "data": [ { "id": "aud_1837", "public_id": "rupzzr", "name": "Newsletter", ... } ] }
# Add contacts to it — by id, by email, or both
curl -X POST https://brainito.com/api/v1/audiences/rupzzr/contacts \
-H "Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{ "emails": ["customer@example.com"] }'
# Read who is in it
curl https://brainito.com/api/v1/audiences/rupzzr/contacts \
-H "Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRET"The numeric id keeps working
Every endpoint that takes an audience accepts either form — /v1/audiences/rupzzr and /v1/audiences/1837 are the same audience. The numeric id was the only identifier the API ever had, so anything you have already built keeps working. Use the public id for anything new.
Adding contacts accepts ids, email addresses, or a mix — integrations rarely have only one to hand. A dynamic (segment-backed) audience rejects manual membership, because the next recompute would overwrite it.
Events
Events feed segment membership and can trigger automations. The taxonomy is yours — there is no fixed vocabulary.
curl -X POST https://brainito.com/api/v1/events \
-H "Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"event": "placed_order",
"email": "customer@example.com",
"value": 89.99,
"properties": { "order_id": "4821", "items": 3 },
"dedup_key": "order-4821"
}'Always send a dedup_key
The natural place to call this is inside a webhook handler, and webhook senders retry. Without a dedup key one order becomes three placed_order events, three segment entries and three automation enrolments.
Post a single event, or up to 500 at once as { "events": [...] }.
Webhooks
Register an endpoint with POST /v1/webhooks. The signing secret is returned once, in that response — store it then, because it cannot be read back.
Every delivery carries Brainito-Signature: t=…,v1=… — an HMAC-SHA256 of {timestamp}.{raw body}. Verify it on every request:
import crypto from "node:crypto";
// Verify BEFORE parsing the body — and use the RAW body, not a re-serialised
// object. Re-serialising changes the bytes and the signature will never match.
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string]),
);
const timestamp = Number(parts.t);
// Reject anything old, or a captured delivery can be replayed forever.
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(parts.v1 ?? "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Deliveries are at-least-once
A delivery can arrive more than once — a timeout on our side does not mean your handler did not run. Make your handler idempotent on the event id rather than assuming each event arrives exactly once.
Return a 2xx quickly. Anything else is retried with backoff; repeated failures disable the endpoint and we will tell you.
All endpoints
Generated from the OpenAPI spec, so it always matches what is deployed.
| Method | Endpoint | Description | Scope |
|---|---|---|---|
| get | /audiences | List audiences | audiences.read |
| post | /audiences | Create an audience | audiences.write |
| delete | /audiences/{id}/contacts | Remove contacts from an audience | audiences.write |
| get | /audiences/{id}/contacts | List an audience's contacts | audiences.read |
| post | /audiences/{id}/contacts | Add contacts to an audience | audiences.write |
| get | /contacts | List contacts | contacts.read |
| post | /contacts | Create or update a contact | contacts.write |
| delete | /contacts/{id} | Delete a contact | contacts.write |
| get | /contacts/{id} | Retrieve a contact | contacts.read |
| patch | /contacts/{id} | Update a contact | contacts.write |
| post | /contacts/{id}/subscribe | Record consent and subscribe | contacts.write |
| post | /contacts/{id}/unsubscribe | Unsubscribe | contacts.write |
| post | /contacts/batch | Upsert many contacts | contacts.write |
| get | /emails | List messages | email.read |
| post | /emails | Send an email | email.send |
| get | /emails/{id} | Retrieve a message | email.read |
| post | /emails/{id}/cancel | Cancel a scheduled message | email.send |
| post | /emails/batch | Send up to 100 messages in one request | email.send |
| post | /events | Track one event or a batch | events.write |
| get | /report/{scan_uid} | Retrieve an audit report | reports.read |
| get | /reports | List audit reports | reports.read |
| post | /scan | Start a website audit | website.scan |
| get | /segments | List segments | segments.read |
| get | /segments/{id}/contacts | List a segment's current members | segments.read |
| get | /sites | List connected websites | sites.read |
| post | /sites | Connect a website | sites.write |
| post | /sites/{id}/verify | Check domain ownership | sites.write |
| delete | /suppressions | Remove a suppression | suppressions.write |
| get | /suppressions | List suppressed addresses | suppressions.read |
| post | /suppressions | Suppress an address | suppressions.write |
| get | /webhooks | List webhook endpoints | webhooks.read |
| post | /webhooks | Register a webhook endpoint | webhooks.write |
Errors
Every error has the same shape:
{
"error": {
"type": "permission_error",
"code": "domain_not_verified",
"message": "Verify yourdomain.com before sending from it.",
"param": "from",
"request_id": "req_9f2c1a4b7e3d"
}
}Branch on error.type for the class and error.code for the specific reason — code is stable across releases, wording is not. message is safe to show a person.
| Status | Meaning |
|---|---|
400 | Malformed request or failed validation. `param` names the field. |
401 | Missing, malformed, revoked or expired key. |
403 | Valid key, but it lacks the scope — or your IP is not allow-listed. |
404 | No such object in this workspace. |
409 | Conflicts with current state, e.g. cancelling a message already sent. |
429 | Rate limited or allowance exhausted. Respect `Retry-After`. |
503 | Temporarily unable to accept sends. Retry after the interval given. |
500 | Our fault. Quote the `request_id`. |
Sending to a suppressed address is not an error. It returns 200 with status: "suppressed", because honouring an unsubscribe is the correct outcome rather than a failure.
Rate limits
Limits are per workspace, not per key — issuing more keys does not raise them. A 429 carries Retry-After in seconds; respect it rather than retrying immediately, which only extends the window.
Your sending allowance is separate from the request rate limit and is set by your plan. Exceeding it returns 429 with a message explaining which limit was reached and how much of it is left.
Auditing websites instead?
The website-audit API — scans, scores and reports — is documented separately at /docs/api.