API reference
Every Scribase Mail HTTP endpoint with request and response shapes: emails, batch, domains, API keys, webhooks, auth (magic link, OTP, JWKS), unsubscribe and tracking. Resend-compatible JSON over HTTPS.
Base URL: https://mail-api.scribase.com. All requests and responses are JSON. Authenticate with Authorization: Bearer sk_.... The auth endpoints (/auth/*, /.well-known/jwks.json) take no API key.
Conventions
Keys. full_access keys can call everything. sending_access keys can send and read individual emails only; on anything else they get 401 restricted_api_key. A sending key with a domain_id can only use from addresses on that domain (403 validation_error otherwise).
Errors.
{ "statusCode": 422, "name": "validation_error", "message": "`to` may contain at most 50 addresses" }
| Status | name | Meaning |
|---|---|---|
| 400 | validation_error | body is not valid JSON |
| 400 | invalid_idempotency_key | header not 1-256 characters |
| 401 | missing_api_key | no Authorization header |
| 401 | validation_error | key unknown or revoked ("API key is invalid") |
| 401 | restricted_api_key | sending key on a management endpoint |
| 403 | validation_error | domain not verified, recipient suppressed, or key restricted to another domain |
| 404 | not_found | unknown id or route |
| 409 | invalid_idempotent_request | same Idempotency-Key, different body |
| 409 | validation_error | state conflict, e.g. cancel of an already sent email |
| 422 | missing_required_field | from, to, subject, html or text, name, endpoint, scheduled_at missing |
| 422 | validation_error | a field is present but wrong |
| 429 | rate_limit_exceeded | over 10 requests per second for this key |
| 503 | dns_lookup_failed | verification could not reach DNS |
| 500 | application_error | our fault; the message is generic, details are in our logs |
Timestamps. Objects use 2026-11-12 21:15:12.548000+00 (UTC). Webhook payloads use RFC 3339 2026-11-12T21:15:12.548Z. Input scheduled_at must be RFC 3339 (2026-11-13T10:00:00Z).
Lists. { "object": "list", "has_more": false, "data": [ ... ] }.
Idempotency. Send Idempotency-Key: <1-256 chars> on POST /emails or POST /emails/batch. The same key with the same body returns the original response for 24 hours without sending again.
Emails
POST /emails
Send one email. Any key.
{
"from": "Acme <hello@acme.com>",
"to": ["jane@example.com"],
"subject": "Your receipt",
"html": "<p>Paid.</p>",
"text": "Paid.",
"cc": "ops@acme.com",
"bcc": ["archive@acme.com"],
"reply_to": "support@acme.com",
"scheduled_at": "2026-11-13T10:00:00Z",
"headers": { "X-Entity-Ref-ID": "inv_123" },
"attachments": [{ "filename": "receipt.pdf", "content": "JVBERi0x...", "content_type": "application/pdf", "cid": null }],
"tags": [{ "name": "category", "value": "receipt" }]
}
| Field | Type | Rules |
|---|---|---|
from | string | required; Name <addr@domain> or bare address; domain must be verified and allowed for the key |
to | string or string[] | required; 1-50 addresses |
subject | string | required; no line breaks |
html, text | string | at least one required |
cc, bcc, reply_to | string or string[] | optional |
scheduled_at | string | RFC 3339, at most 30 days ahead |
headers | object | single-line string values; reserved headers rejected (From, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature, Return-Path, List-Unsubscribe, List-Unsubscribe-Post) |
attachments[] | object | filename + base64 content required; optional content_type, cid; path is not supported; 40 MB total |
tags[] | object | name and value: ASCII letters, digits, _, -, max 256 characters |
Response 200:
{ "id": "7af8001f-54d6-4607-b951-ee1cc2ad60d5" }
Recipients on your suppression list are dropped silently from the send; if every recipient is suppressed the call returns 403 validation_error.
POST /emails/batch
Up to 100 emails in one call; the body is a JSON array of the objects above. Attachments are not allowed. Each item is validated before any is queued, so a bad item fails the whole batch. Any key.
{ "data": [ { "id": "85d351f5-..." }, { "id": "5539bf50-..." } ] }
GET /emails
List emails, newest first. Full access only. Query: limit (1-100, default 20), after or before (an email id, not both), test (true = only sk_test_ sends, false = only live sends; omitted = both).
{
"object": "list",
"has_more": false,
"data": [{
"id": "7af8001f-...", "message_id": "<7af8001f-...@acme.com>",
"to": ["jane@example.com"], "from": "Acme <hello@acme.com>",
"created_at": "2026-11-12 21:15:12.548000+00", "subject": "Your receipt",
"bcc": [], "cc": [], "reply_to": [], "last_event": "delivered", "scheduled_at": null
}]
}
GET /emails/{id}
Any key.
{
"object": "email",
"id": "7af8001f-54d6-4607-b951-ee1cc2ad60d5",
"message_id": "<7af8001f-54d6-4607-b951-ee1cc2ad60d5@acme.com>",
"to": ["jane@example.com"],
"from": "Acme <hello@acme.com>",
"created_at": "2026-11-12 21:15:12.548000+00",
"subject": "Your receipt",
"html": "<p>Paid.</p>",
"text": "Paid.",
"bcc": [],
"cc": [],
"reply_to": ["support@acme.com"],
"last_event": "sent",
"scheduled_at": null,
"tags": [{ "name": "category", "value": "receipt" }]
}
last_event is one of queued, scheduled, sent, delivered, delivery_delayed, bounced, complained, failed, canceled, opened, clicked.
PATCH /emails/{id}
Reschedule. Body { "scheduled_at": "2026-11-13T12:00:00Z" }. Only emails that have not been sent yet. Any key.
{ "object": "email", "id": "7af8001f-..." }
POST /emails/{id}/cancel
Cancel a queued or scheduled email. Returns { "object": "email", "id": "..." }; 409 if already sent. Any key.
GET /emails/{id}/events
The status history of one email, oldest first: every email.* event with its timestamp and a one-line detail (the SMTP reply on a bounce, the relay error on a failure, the recipients concerned). Full access only.
{
"object": "list",
"has_more": false,
"data": [
{ "object": "email_event", "id": "f1df091a-...", "email_id": "15e5cb57-...", "type": "email.sent", "message": "bob@customer.example", "created_at": "2026-11-12 22:39:27.135902+00" },
{ "object": "email_event", "id": "0c2d8f7e-...", "email_id": "15e5cb57-...", "type": "email.bounced", "message": "550 5.1.1 <bob@customer.example>: Recipient address rejected: User unknown", "created_at": "2026-11-12 22:39:29.314127+00" }
]
}
GET /emails/{id}/webhook-deliveries
Every webhook delivery attempted for this email's events, newest first (up to 200). Full access only. Items have the same shape as GET /webhooks/{id}/deliveries.
POST /emails/{id}/events
Scribase extension for teams that process bounces or complaints themselves. Full access only.
{ "type": "bounced", "recipient": "jane@example.com", "code": 550, "message": "user unknown" }
type: bounced, complained or delivered. Updates last_event, suppresses the recipient on bounce/complaint (all recipients when recipient is omitted), and fires the matching webhook event. Returns { "object": "email", "id": "..." }.
Domains
Full access only. See Domains and DNS for what the records mean.
POST /domains
{ "name": "acme.com", "region": "eu-frankfurt-1", "return_path": "send", "open_tracking": false, "click_tracking": false }
name required (lower-cased, trailing dot removed). return_path is one DNS label (default send). Response 201:
{
"object": "domain",
"id": "79e60a61-36e4-45bb-a88b-4633220a96d9",
"name": "acme.com",
"status": "not_started",
"created_at": "2026-11-12 21:15:12.548000+00",
"region": "eu-frankfurt-1",
"return_path": "send",
"open_tracking": false,
"click_tracking": false,
"dkim_selector": "scribase",
"capabilities": { "sending": "enabled", "receiving": "disabled" },
"records": [
{ "record": "DKIM", "name": "scribase._domainkey", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DKIM1; k=rsa; p=MIIBIjAN..." },
{ "record": "SPF", "name": "send", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=spf1 include:rp.oracleemaildelivery.com ~all" },
{ "record": "DMARC", "name": "_dmarc", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DMARC1; p=none;" }
]
}
An MX record with priority: 10 for the return-path sub-domain appears when inbound bounce handling is enabled for your region.
GET /domains
List. Each item has id, name, status, created_at, region, open_tracking, click_tracking, capabilities.
GET /domains/{id}
Full object as above, with each record's current status (not_started, verified, failed).
PATCH /domains/{id}
{ "open_tracking": true, "click_tracking": true }
Returns { "object": "domain", "id": "..." }.
DELETE /domains/{id}
Returns { "object": "domain", "id": "...", "deleted": true }.
POST /domains/{id}/verify
Runs real DNS lookups and stores the result. Returns { "object": "domain", "id": "..." }; read GET /domains/{id} for the outcome. Domain status is verified when DKIM TXT and SPF TXT both match, pending when one does, failed when neither does. 503 dns_lookup_failed if the resolver is unreachable.
API keys
Full access only.
POST /api-keys
{ "name": "app-server", "permission": "sending_access", "domain_id": "79e60a61-..." }
name required, max 50 characters. permission: full_access (default) or sending_access. domain_id is allowed only with sending_access. Response 201:
{ "id": "062d22bc-a5ee-4734-8226-78f42d881bfd", "object": "api_key", "token": "sk_y9ClOQs39PcacTDDMKfa8AOMOb30E6zObwpUV5ptooU" }
The token is shown once; only its SHA-256 is stored.
GET /api-keys
{ "object": "list", "has_more": false, "data": [{
"id": "062d22bc-...", "name": "app-server", "permission": "sending_access",
"domain_id": "79e60a61-...", "key_prefix": "sk_y9ClOQs", "created_at": "2026-11-12 21:15:12.558000+00", "last_used_at": null
}] }
DELETE /api-keys/{id}
Revokes immediately. Returns { "object": "api_key", "id": "...", "deleted": true }.
Webhooks
Full access only. Verification and payloads: Webhooks.
POST /webhooks
{ "endpoint": "https://acme.com/hooks/mail", "events": ["email.delivered", "email.bounced"] }
endpoint must be a public http(s) URL. events must be non-empty, from: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.failed, email.opened, email.clicked. Response 201:
{ "object": "webhook", "id": "dd5a4311-8925-43ff-a863-5c14d1551e22", "signing_secret": "whsec_3s6npId7AItCYHnmu98/wpzimCb7u/UH" }
GET /webhooks
Items: object, id, created_at, status (enabled / disabled), endpoint, events.
GET /webhooks/{id}
Same as a list item plus signing_secret.
PATCH /webhooks/{id}
Any of endpoint, events, status. Returns { "object": "webhook", "id": "..." }.
DELETE /webhooks/{id}
Returns { "object": "webhook", "id": "...", "deleted": true }.
GET /webhooks/{id}/deliveries
The delivery log of one endpoint, newest first. Query: limit (1-500, default 50). Full access only. status is pending (will be retried at next_attempt_at), delivered or failed; response_status is the last HTTP status your endpoint returned, error the last transport error.
{
"object": "list",
"has_more": false,
"data": [{
"object": "webhook_delivery",
"id": "6af82126-...",
"webhook_id": "79499cc6-...",
"endpoint": "https://api.acme.com/hooks/mail",
"event_id": "f1df091a-...",
"type": "email.sent",
"email_id": "90a4d96a-...",
"status": "delivered",
"response_status": 200,
"error": null,
"attempts": 1,
"next_attempt_at": null,
"delivered_at": "2026-11-13 09:54:44.603689+00",
"created_at": "2026-11-13 09:54:44.155746+00"
}]
}
Auth
No API key. Project-scoped passwordless sign-in; full guide on Magic links and OTP. Errors here use { "error": "<code>", "message": "..." }.
POST /auth/magic-link, POST /auth/email-verification, POST /auth/password-reset
{ "email": "jane@example.com", "redirect_to": "https://acme.com/welcome", "project": "acme" }
redirect_to must be on the project's allowed_redirect_origins. Response 200 (also when the address is unknown):
{ "ok": true, "message": "If that address can receive mail, a link is on its way.", "expires_in": 900 }
GET /auth/verify?token=
Consumes a link token (single use, 15 minutes). Browser: 303 to redirect_to#access_token=...&token_type=Bearer&expires_in=3600&purpose=magic_link. With Accept: application/json:
{
"access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLiJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"purpose": "magic_link",
"user": { "id": "7892cff8-...", "email": "jane@example.com", "email_verified_at": "2026-11-12T21:15:13Z" },
"redirect_to": "https://acme.com/welcome"
}
400 invalid_token when expired, unknown or already used.
POST /auth/otp
{ "email": "jane@example.com", "project": "acme" }
{ "ok": true, "message": "If that address can receive mail, a code is on its way.", "expires_in": 600, "max_attempts": 5 }
POST /auth/otp/verify
{ "email": "jane@example.com", "project": "acme", "code": "482913" }
Returns the session object above (purpose: "otp"). 400 invalid_code ("That code is not right. 4 attempts left.") or 400 too_many_attempts.
POST /auth/token/verify
{ "token": "eyJ...", "audience": "acme" }
{ "valid": true, "claims": { "iss": "scribase-mail-auth", "sub": "...", "aud": "acme", "email": "...", "email_verified": true, "project": "acme", "purpose": "otp", "iat": 1794518113, "exp": 1794521713, "jti": "..." }, "expires_in": 3599 }
401 unauthorized with the reason (token expired, invalid signature, audience mismatch).
GET /.well-known/jwks.json
{ "keys": [{ "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": "04mPK-p5_d0", "x": "FT7OX6Nt9pFa0se5QnE-ZDknb4fk8Ju5Up5mlVnKE7g" }] }
Cache for 5 minutes (Cache-Control: public, max-age=300).
Auth admin: /auth/admin/projects
Authorization: Bearer <admin token> (your account's auth admin token from the dashboard). 503 admin_disabled when not configured.
| Method | Path | Body |
|---|---|---|
| GET | /auth/admin/projects | |
| POST | /auth/admin/projects | { "slug", "app_name", "from_address", "jwt_audience"?, "allowed_redirect_origins"?: [], "subject_overrides"?: { "otp": "Your Acme code" } } |
| GET | /auth/admin/projects/{id} | |
| PATCH | /auth/admin/projects/{id} | any of app_name, from_address, jwt_audience, allowed_redirect_origins, subject_overrides |
Project object: id, slug, app_name, from_address, jwt_audience, allowed_redirect_origins, subject_overrides, created_at, updated_at. slug is 2-64 characters of a-z0-9-. Rate limits: 5 requests per hour per email per project, 30 per hour per IP; 429 rate_limited with Retry-After.
Public endpoints
No authentication; these are the URLs embedded in your emails.
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz | { "ok": true, "service": "scribase-mail" }, 503 when the database is unreachable |
| GET | /unsubscribe/{token} | hosted unsubscribe page |
| POST | /unsubscribe/{token} | one-click unsubscribe (RFC 8058) and the page's form; adds the recipient to your suppression list |
| GET | /track/open/{id}/{sig}.gif | open-tracking pixel |
| GET | /track/click/{id}?u=&s= | click tracking, 307 to the original link |
SMTP
smtp.scribase.com, ports 587 (STARTTLS) and 465 (TLS), AUTH PLAIN/LOGIN with username scribase and password = API key. AUTH is only accepted after TLS. The From address must be on one of your verified domains (or the key's domain restriction). On success the server answers 250 2.0.0 Ok <email_id>; the same id works with GET /emails/{id}. Messages are DKIM-signed and queued like an API send and appear in GET /emails tagged source=smtp; the same plan limits, suppression list and content checks apply (a blocked message gets 550 5.7.1). Setup for Supabase Auth is on Use as Supabase's mail server.