Integration Guide · v1
Client Integration Guide
Everything you need to integrate squibble/email into your application — auth, sending, reading the inbox, delivery tracking, error handling.
01 Overview
Squibble/email exposes three primary API surfaces, each scoped by a separate JWT action so an agent only gets what it actually needs:
- Outbound sending
(
messages:send) — Queue email throughPOST /api/v1/messages/sendwith automatic sender binding, suppression checks, idempotent retries, open/click tracking, and optional file attachments (base64-encoded, up to 25 MiB total). - Markdown sending
(
messages:send) — Write email bodies in Markdown and letPOST /api/v1/messages/send-markdownconvert them to email-safe HTML via the emailmd sidecar — brand colours, plain-text fallback, and theme tokens included. - Inbox reading
(
messages:index+attachments:show) — Query received mail over JSON. SelectiveBODYSTRUCTUREfetch keeps attachments out of the agent's context window until explicitly requested. - Delivery tracking
(
messages:index) — Every send returns a UUID. Pull delivery status, bounce records, suppression reasons, and engagement events from/api/v1/messages/outbound/{id}/eventswithout polling or webhook plumbing.
Each token is bound to one mailbox and a precise set of those actions. Sharing a single SMTP credential across N agents — the failure mode this product exists to prevent — is not a configuration we expose.
02 Authentication
All API calls require a Bearer token in the
Authorization
header. Tokens are JWTs issued by Squibble per mailbox per agent.
After you join the waitlist we onboard your mailbox and provision your first token.
Authorization: Bearer <your-jwt-token> // export your token once, use it everywhere below
export TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
curl -s https://api.email.squibble.ch/api/v1/health_check \
-H "Authorization: Bearer $TOKEN"
Each token encodes the target mailbox and a set of permitted actions:
messages:send,
messages:index,
attachments:show.
The messages:index scope covers
both listing and fetching individual messages or their events.
Calling an endpoint outside your token's scope returns
403.
03 Sending Email
Requires the messages:send scope.
POST /api/v1/messages/send
Idempotency-Key: order-123-confirm # optional header, 24-hour dedup window
{
"to": ["recipient@example.com"], # required, max 100 recipients
"cc": [], # optional, max 100
"bcc": [], # optional, max 100
"subject": "Hello", # required, max 998 chars
"html_body": "<p>HTML body</p>", # required, max 1 MB
"text_body": "Plain text body", # optional plain-text fallback
"stream": "transactional", # "transactional" (default) | "marketing"
"attachments": [ # optional, max 20 files
{
"filename": "report.pdf",
"content_type": "application/pdf",
"content": "<base64-encoded file content>" # max 10 MiB decoded per file; 25 MiB total
}
]
} // send a transactional email
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-12345-confirm" \
-d '{
"to": ["recipient@example.com"],
"cc": [],
"bcc": [],
"subject": "Hello",
"html_body": "<p>HTML body</p>",
"text_body": "Plain text body",
"stream": "transactional"
}' Response codes
- 202 Accepted — message queued for delivery.
- 422 Validation error or recipient suppressed (bounce / unsubscribe).
- 429 Per-token send quota exceeded (per_hour or per_day).
202 response
The response body contains one UUID per recipient. Use these IDs to track delivery status and engagement events per message.
{
"message_ids": ["550e8400-e29b-41d4-a716-446655440000"]
} 04 Sending Markdown
Write your email body in Markdown and let the API render it to email-safe HTML.
Requires the messages:send scope.
The same suppression checks, idempotency, delivery tracking, and
attachment support
apply as for POST /api/v1/messages/send.
POST /api/v1/messages/send-markdown
Idempotency-Key: order-123-confirm # optional header, 24-hour dedup window
{
"to": ["recipient@example.com"], # required, max 100 recipients
"cc": [], # optional
"bcc": [], # optional
"subject": "Hello", # required, max 998 chars
"markdown_body": "# Hello\n\nYour **order** has shipped.", # required, max 1 MB
"stream": "transactional", # "transactional" (default) | "marketing"
"render_options": { # optional
"minify": true, # default true
"theme": {
"brand_color": "#4f46e5", # hex colour tokens
"button_color": "#4f46e5",
"font_family": "Inter, sans-serif"
}
}
} // send a markdown email with brand theming
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send-markdown \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": ["recipient@example.com"],
"subject": "Your order has shipped",
"markdown_body": "# Your order has shipped\n\nHi there,\n\nYour **order #1234** is on its way.\n\n[Track your package](https://example.com/track/1234)\n\nThanks for shopping with us.",
"stream": "transactional",
"render_options": {
"theme": { "brand_color": "#4f46e5", "button_color": "#4f46e5" }
}
}' Theme tokens
All theme fields are optional hex colour strings or CSS values.
Omitting a field leaves it at the emailmd default.
-
brand_colorPrimary accent used for links and borders -
heading_colorH1–H6 text colour -
body_colorBody copy text colour -
background_colorOuter page background -
content_colorContent card background -
card_colorInner card / blockquote background -
button_colorCTA button fill -
button_text_colorCTA button label -
secondary_colorSecondary button fill -
secondary_text_colorSecondary button label -
font_familyCSS font-family stack -
font_sizeBase font size (e.g. "16px") -
line_heightBody line height (e.g. "1.6") -
content_widthMax content width (e.g. "600px") -
border_radiusButton / card corner radius
Response codes
- 202 Accepted — message queued for delivery.
- 422 Validation error or recipient suppressed (bounce / unsubscribe).
- 429 Per-token send quota exceeded (per_hour or per_day).
202 response
{
"message_ids": ["550e8400-e29b-41d4-a716-446655440000"]
} 04.5 Attachments
Both POST /api/v1/messages/send and
POST /api/v1/messages/send-markdown
accept an optional attachments array.
Each file is base64-encoded by your client and declared with a filename and MIME type —
the API decodes, validates, and assembles the MIME multipart message before dispatch.
Requires the messages:send scope.
Limits
- Max 20 attachments per request.
- Max 10 MiB decoded per individual file.
- Max 25 MiB decoded total across all attachments in a single request.
- The
contentfield must be valid standard base64 (RFC 4648). Invalid encoding returns422. - The
filenamemust be 1–255 characters, no newlines or null bytes. - The
content_typemust be a valid MIME type string (e.g.application/pdf,image/png).
// send an email with a PDF attachment
# base64-encode the file first
CONTENT=$(base64 -i report.pdf)
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"to\": [\"customer@example.com\"],
\"subject\": \"Your invoice\",
\"html_body\": \"<p>Please find your invoice attached.</p>\",
\"stream\": \"transactional\",
\"attachments\": [
{
\"filename\": \"invoice-2026-001.pdf\",
\"content_type\": \"application/pdf\",
\"content\": \"$CONTENT\"
}
]
}" // multiple attachments in one send
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": ["customer@example.com"],
"subject": "Your order documents",
"html_body": "<p>Please find your documents attached.</p>",
"stream": "transactional",
"attachments": [
{
"filename": "invoice.pdf",
"content_type": "application/pdf",
"content": "JVBERi0xLjQK..."
},
{
"filename": "receipt.png",
"content_type": "image/png",
"content": "iVBORw0KGgoAAAA..."
}
]
}' Validation errors
Attachment validation runs before the message is queued. A single invalid attachment
rejects the entire request with 422:
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "The request contains invalid parameters.",
"instance": "/api/v1/messages/send",
"errors": [
{
"type": "value_error",
"loc": ["body"],
"msg": "attachments[0].content: invalid base64 encoding"
}
]
} 05 Reading the Inbox
Requires the
messages:index scope.
The {mailbox} path segment
is the mailbox identifier encoded in your JWT — use the value provided during onboarding.
GET /api/v1/email/account/{mailbox}/messages # list messages
GET /api/v1/email/account/{mailbox}/messages/{message_id} # get a message (integer ID)
GET /api/v1/email/account/{mailbox}/messages/{message_id}/attachments/{filename} # attachment // list the 10 most recent inbox messages
curl -s "https://api.email.squibble.ch/api/v1/email/account/{mailbox}/messages?limit=10" \
-H "Authorization: Bearer $TOKEN" // fetch a single message by integer ID
curl -s "https://api.email.squibble.ch/api/v1/email/account/{mailbox}/messages/42" \
-H "Authorization: Bearer $TOKEN"
The list endpoint supports optional query filters:
filter[from],
filter[to],
filter[subject], plus
limit (default 10, max 999) and
offset.
Attachment downloads require the
attachments:show scope.
06 Outbound Messages
Requires the messages:index scope.
Use these endpoints to inspect delivery status and per-message event timelines
for mail you have sent.
GET /api/v1/messages/outbound # list sent messages
GET /api/v1/messages/outbound/{message_id} # single message (UUID)
GET /api/v1/messages/outbound/{message_id}/events # delivery event timeline // list sent messages — filter by status
curl -s "https://api.email.squibble.ch/api/v1/messages/outbound?status=sent&limit=20" \
-H "Authorization: Bearer $TOKEN" // check delivery status for a specific message
curl -s https://api.email.squibble.ch/api/v1/messages/outbound/$MESSAGE_ID \
-H "Authorization: Bearer $TOKEN" // fetch the full delivery + engagement event timeline
curl -s https://api.email.squibble.ch/api/v1/messages/outbound/$MESSAGE_ID/events \
-H "Authorization: Bearer $TOKEN" List filters
The list endpoint accepts optional query parameters:
status (queued | processing | sent | failed | bounced),
stream (transactional | marketing),
recipient (email address string),
and limit (default 100, max 200).
Event types
The /events endpoint returns a chronological list of
delivery events for a message: queued,
sent, bounced,
opened, and clicked.
Open and click events are recorded automatically — see
Engagement Tracking.
07 Engagement Tracking
Open and click tracking is automatic — no integration work required. When you send
an html_body, the API injects a 1×1 tracking pixel
and rewrites all links through a redirect. Here is how each event is captured:
- Open — a hidden
<img>pixel is embedded in the HTML body. When the recipient's mail client renders the email it fetches the pixel URL, which records anopenedevent. - Click — every link in the HTML body is rewritten
to pass through a redirect. When the recipient clicks, the redirect records a
clickedevent and forwards them to the original URL.
Reading events
Use the message_id from the send response to query
the event timeline for that recipient.
// fetch open and click events for a message
curl -s https://api.email.squibble.ch/api/v1/messages/outbound/$MESSAGE_ID/events \
-H "Authorization: Bearer $TOKEN"
// example response
GET /api/v1/messages/outbound/550e8400-e29b-41d4-a716-446655440000/events
# example response
[
{
"id": "a1b2c3d4-...",
"event_type": "opened",
"occurred_at": "2025-05-07T10:42:00Z",
"payload": {}
},
{
"id": "b2c3d4e5-...",
"event_type": "clicked",
"occurred_at": "2025-05-07T10:43:15Z",
"payload": {}
}
]
Events are returned in chronological order. All event types —
queued, sent,
bounced, opened,
clicked — appear in the same timeline, giving you
the full delivery and engagement history for each recipient in one call.
08 Unsubscribe
All marketing stream emails include
a List-Unsubscribe header with
a signed token. Two public (no auth) endpoints handle unsubscribe requests:
GET /api/v1/unsubscribe?t={token} # browser-based unsubscribe page
POST /api/v1/unsubscribe?t={token} # RFC 8058 one-click (email client automation) // one-click unsubscribe (RFC 8058 — no auth required)
curl -s -X POST "https://api.email.squibble.ch/api/v1/unsubscribe?t={signed-token}"
// 200 response
{ "unsubscribed": true }
Both return { "unsubscribed": true } on success.
An invalid or expired token returns 400.
Successful unsubscribes are added to the suppression list immediately — subsequent
send attempts to that address return 422.
09 Error Handling
All errors follow
RFC 9457 Problem JSON with
Content-Type: application/problem+json.
The instance field always contains the request URL of the failing call.
// 422 — send to a suppressed address
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": ["bounced@example.com"], "subject": "Hello", "html_body": "<p>Hi</p>", "stream": "transactional" }'
{
"type": "https://email.squibble.ch/docs/errors/messages/send/suppressed",
"title": "Recipient Suppressed",
"status": 422,
"detail": "suppressed: [recipient@example.com]",
"instance": "/api/v1/messages/send"
} // 422 — validation failure (missing required fields)
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "html_body": "<p>Hi</p>" }'
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "The request contains invalid parameters.",
"instance": "/api/v1/messages/send",
"errors": [
{"type": "missing", "loc": ["body", "to"], "msg": "Field required"},
{"type": "missing", "loc": ["body", "subject"], "msg": "Field required"}
]
} // 429 — rate limit exceeded
{
"type": "https://email.squibble.ch/docs/errors/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded",
"instance": "/api/v1/messages/send"
} 10 Idempotency
Add an
Idempotency-Key
header (max 255 chars) to make sends safe to retry. The same key returns
the original response for 24 hours.
// first attempt
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-12345-confirm" \
-d '{ "to": ["recipient@example.com"], "subject": "Hello", "html_body": "<p>Hello</p>", "stream": "transactional" }'
// → 202 { "message_ids": ["550e8400-e29b-41d4-a716-446655440000"] } // retry with the same key — returns the original UUID, no duplicate send
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-12345-confirm" \
-d '{ "to": ["recipient@example.com"], "subject": "Hello", "html_body": "<p>Hello</p>", "stream": "transactional" }'
// → 202 { "message_ids": ["550e8400-e29b-41d4-a716-446655440000"] } ← same UUID 11 Suppressions & Bounces
Permanent bounces and unsubscribes are added to a per-mailbox suppression
list automatically. Any send attempt to a suppressed address returns
422 — the whole request is rejected, not
silently skipped. RFC 3463 bounce classification is applied to all delivery failures.
// attempt to send to a suppressed address
curl -s -X POST https://api.email.squibble.ch/api/v1/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": ["bounced@example.com"], "subject": "...", "html_body": "...", "stream": "transactional" }'
{
"type": "https://email.squibble.ch/docs/errors/messages/send/suppressed",
"title": "Recipient Suppressed",
"status": 422,
"detail": "suppressed: [bounced@example.com]",
"instance": "/api/v1/messages/send"
} How bounces are classified
Delivery failures are tagged with the RFC 3463 enhanced status code returned by the receiving MTA. The class digit determines whether the recipient is added to the suppression list:
- 5.x.x — hard bounce. Permanent failure: mailbox does not exist, domain has no MX, or the recipient is explicitly rejected. Added to the suppression list immediately.
- 4.x.x — soft bounce.
Temporary failure: greylisting, rate-limit, or transient mailbox-full. Marked as
failedon the message but not suppressed — the address remains sendable. - Policy / admin rejection. Suppressed only when the receiving MTA classifies the rejection as 5.x.x. A pure policy 4.x.x stays retryable.
Unsubscribes (RFC 8058 one-click or the
/api/v1/unsubscribe web form) are added immediately.
Suppression is per mailbox: the same address can be
hard-bounced for one of your mailboxes and reachable for another. Within a given
mailbox, a suppressed address is permanently blocked until the suppression record is
cleared by the operator.
Sending to a suppressed address is a bug, not a fallback. We surface it loudly.
12 Health Check
A public, unauthenticated endpoint useful for uptime monitoring and verifying connectivity to the API from your environment.
// verify connectivity — no auth required
curl -s https://api.email.squibble.ch/api/v1/health_check
// 200 response
{
"status": "ok",
"hostname": "api-1.squibble.ch",
"version": "2.0.13",
"timestamp": "2026-05-07T10:00:00Z"
}
No authentication required. Returns 200 when the
service is healthy. Use this to gate your integration startup sequence or confirm
your network path to the API is open.
Deep Dives
Integration Walkthrough
A step-by-step guide through the full transactional email lifecycle: send, check delivery status, read the event timeline, and handle bounces.
See the integration walkthroughArchitecture & Design Decisions
IMAP integration strategy, outbound delivery model, bounce processing, and the idempotency contract. Sourced from the repo.
Read the architecture overviewFeature Guides
Per-feature deep dives: outbound API + HTML parsing, async worker queue, tracking endpoints, IMAP bounce processing, and the database + config layer.
Browse the feature overviewWant early access?
Join the waitlist and we'll provision your mailbox and first JWT. Most teams ship their first email within a business day of onboarding.