Scribase Mail docs

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" }
StatusnameMeaning
400validation_errorbody is not valid JSON
400invalid_idempotency_keyheader not 1-256 characters
401missing_api_keyno Authorization header
401validation_errorkey unknown or revoked ("API key is invalid")
401restricted_api_keysending key on a management endpoint
403validation_errordomain not verified, recipient suppressed, or key restricted to another domain
404not_foundunknown id or route
409invalid_idempotent_requestsame Idempotency-Key, different body
409validation_errorstate conflict, e.g. cancel of an already sent email
422missing_required_fieldfrom, to, subject, html or text, name, endpoint, scheduled_at missing
422validation_errora field is present but wrong
429rate_limit_exceededover 10 requests per second for this key
503dns_lookup_failedverification could not reach DNS
500application_errorour 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" }]
}
FieldTypeRules
fromstringrequired; Name <addr@domain> or bare address; domain must be verified and allowed for the key
tostring or string[]required; 1-50 addresses
subjectstringrequired; no line breaks
html, textstringat least one required
cc, bcc, reply_tostring or string[]optional
scheduled_atstringRFC 3339, at most 30 days ahead
headersobjectsingle-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[]objectfilename + base64 content required; optional content_type, cid; path is not supported; 40 MB total
tags[]objectname 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.

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

MethodPathPurpose
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}.gifopen-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.