Email Platform

Email API

Send transactional email, keep contacts and segments in sync from your own systems, track behaviour, and receive signed webhooks when something happens.

REST + Node SDK
Scoped API keys
Test mode
Signed webhooks

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:

http
Authorization: Bearer brn_live_XXXXXXXX.YOUR_SECRET

Keys 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:

bash
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
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

bash
npm install @brainito/sdk
typescript
import { 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.

typescript
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.

bash
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.

bash
# 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.

bash
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:

typescript
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.

MethodEndpointDescriptionScope
get/audiencesList audiencesaudiences.read
post/audiencesCreate an audienceaudiences.write
delete/audiences/{id}/contactsRemove contacts from an audienceaudiences.write
get/audiences/{id}/contactsList an audience's contactsaudiences.read
post/audiences/{id}/contactsAdd contacts to an audienceaudiences.write
get/contactsList contactscontacts.read
post/contactsCreate or update a contactcontacts.write
delete/contacts/{id}Delete a contactcontacts.write
get/contacts/{id}Retrieve a contactcontacts.read
patch/contacts/{id}Update a contactcontacts.write
post/contacts/{id}/subscribeRecord consent and subscribecontacts.write
post/contacts/{id}/unsubscribeUnsubscribecontacts.write
post/contacts/batchUpsert many contactscontacts.write
get/emailsList messagesemail.read
post/emailsSend an emailemail.send
get/emails/{id}Retrieve a messageemail.read
post/emails/{id}/cancelCancel a scheduled messageemail.send
post/emails/batchSend up to 100 messages in one requestemail.send
post/eventsTrack one event or a batchevents.write
get/report/{scan_uid}Retrieve an audit reportreports.read
get/reportsList audit reportsreports.read
post/scanStart a website auditwebsite.scan
get/segmentsList segmentssegments.read
get/segments/{id}/contactsList a segment's current memberssegments.read
get/sitesList connected websitessites.read
post/sitesConnect a websitesites.write
post/sites/{id}/verifyCheck domain ownershipsites.write
delete/suppressionsRemove a suppressionsuppressions.write
get/suppressionsList suppressed addressessuppressions.read
post/suppressionsSuppress an addresssuppressions.write
get/webhooksList webhook endpointswebhooks.read
post/webhooksRegister a webhook endpointwebhooks.write

Errors

Every error has the same shape:

json
{
  "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.

StatusMeaning
400Malformed request or failed validation. `param` names the field.
401Missing, malformed, revoked or expired key.
403Valid key, but it lacks the scope — or your IP is not allow-listed.
404No such object in this workspace.
409Conflicts with current state, e.g. cancelling a message already sent.
429Rate limited or allowance exhausted. Respect `Retry-After`.
503Temporarily unable to accept sends. Retry after the interval given.
500Our 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.