courier-owlIntegration guideGet API keys

Introduction

courier-owl is a white-label Gmail API. Connect your users' mailboxes, receive a signed webhook the moment new mail arrives, and read or send messages over a clean, Nylas-style REST API. We store every email so you read from us, never from Gmail directly. A connected mailbox is a grant, and your api_key alone identifies your organization.

Base URL https://tdgqo14n67.execute-api.us-east-1.amazonaws.com

Every JSON response is wrapped in an envelope { "request_id": "...", "data": ... }; list endpoints add a next_cursor when more results are available. Explore it live in the interactive API explorer, or download the OpenAPI 3.1 spec.

How it works

The whole lifecycle of a connected mailbox, end to end:

  1. Connect. You send a user to a branded consent link. On approval we store the connection and fire mailbox.connected.
  2. Fast first page, then full backfill. The newest messages are stored within seconds (Gmail returns newest-first); the rest of the mailbox backfills in the background — paced, retried, and reliable. When it finishes we fire mailbox.synced.
  3. Live mail. Every new email triggers a signed message.created webhook in real time.
  4. You read from us. Pull messages, threads, attachments, or the raw RFC-822 from the REST API — served from our store, so it's fast and always available even if Gmail is slow.
  5. Disconnect. On revoke or a permanent token failure we fire mailbox.disconnected.
Backfill is silent — historical mail does not fire a webhook per message (no flood on connect). Only new mail fires message.created; read the back-history via the API.

Quickstart

  1. Create an account and an organization — you get an api_key, an org_id, and a webhook signing secret.
  2. Set your webhook URL in the dashboard.
  3. Send a user through the connect link (below) to link their Gmail.
  4. Receive a signed message.created, then fetch the message:
cURL
# list connected mailboxes
curl https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/v1/mailboxes \
  -H "Authorization: Bearer cok_your_api_key"

# fetch a message (thread_id makes it a fast read)
curl "https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/v1/grants/USER_ID/messages/MESSAGE_ID?thread_id=THREAD_ID" \
  -H "Authorization: Bearer cok_your_api_key"

Authentication

Authenticate every API request with your organization's api_key as a Bearer token. The key identifies your org — there is no org_id in API paths. Keep it server-side; you can issue and revoke additional keys from the dashboard.

HTTP
Authorization: Bearer cok_31a6…

A missing or invalid key returns 401.

Connect a mailbox

Send the user to the authorize endpoint with your org_id and a user_id of your choosing — any stable code for that user in your system. That user_id becomes the grant id and is echoed back in every webhook and API path, so you always know which of your users a message belongs to.

GET/auth/gmail/authorize?org_id={org_id}&user_id={your_user_code}

302-redirects the browser into Google consent. Add &format=json to get the URL back as JSON instead (for SPAs that redirect themselves).

Connect link
https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/auth/gmail/authorize?org_id=org_abc123&user_id=user-123

On approval we store the connection, fire mailbox.connected, and start the backfill. Pass &redirect_uri=… to send the user back to your app afterwards (coming soon — ask if you need it).

Mailboxes

The quickest way to see who's connected — a bare array, no envelope.

GET/v1/mailboxes

All connected mailboxes for your org.

Response · GET /v1/mailboxes
[
  {
    "user_id": "user-123",
    "email": "alice@company.com",
    "status": "active",
    "connected_at": "2026-05-30T06:00:00Z",
    "mailbox_id": "mb_9f2c1ab34d5e6f70"
  }
]

Grants

A grant is a connected mailbox as a richer object, addressed by your user_id.

GET/v1/grants

List connected mailboxes (envelope form).

GET/v1/grants/{grant_id}

Retrieve one grant.

DELETE/v1/grants/{grant_id}

Revoke a grant — stops Gmail watching and fires mailbox.disconnected.

Response · GET /v1/grants/{grant_id}
{
  "request_id": "req_82487d62…",
  "data": {
    "id": "user-123",
    "provider": "google",
    "grant_status": "valid",
    "email": "alice@company.com",
    "scope": ["https://www.googleapis.com/auth/gmail.readonly"],
    "created_at": "2026-05-30T06:00:00Z",
    "updated_at": "2026-05-30T06:00:00Z"
  }
}

Messages

GET/v1/grants/{grant_id}/messages

List messages, newest-first. Query: limit (≤100), cursor.

GET/v1/grants/{grant_id}/messages/{message_id}

Retrieve one message. Pass ?thread_id for a fast read; ?format=raw for the original RFC-822.

POST/v1/grants/{grant_id}/messages/send

Send (or reply) as the mailbox. Pass thread_id to reply within a thread.

cURL · send / reply
curl -X POST https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/v1/grants/USER_ID/messages/send \
  -H "Authorization: Bearer cok_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": [{ "email": "bob@company.com" }],
    "subject": "Hello from courier-owl",
    "body": "Hi Bob — sent via the API.",
    "thread_id": "OPTIONAL_to_reply"
  }'

Message object

We return the message parsed into both formats plus full metadata. body is HTML-preferred for convenience; body_html / body_text give you each explicitly. The RFC threading headers let you stitch threads and build correct replies.

Response · message object
{
  "request_id": "req_…",
  "data": {
    "id": "19e77cd732…",
    "grant_id": "user-123",
    "thread_id": "19e77cd732…",
    "subject": "Welcome",
    "from": [{ "email": "sender@x.com", "name": "Sender" }],
    "to":   [{ "email": "alice@company.com", "name": "" }],
    "cc":   [],
    "date": "Sat, 30 May 2026 03:32:56 -0400",
    "received_at": 1748583176,            // epoch seconds
    "snippet": "Hello…",
    "unread": true,
    "starred": false,
    "folders": ["INBOX", "UNREAD"],
    "has_attachments": false,
    "attachments": [],
    "body": "<html>…</html>",             // HTML-preferred
    "body_html": "<html>…</html>",
    "body_text": "Hello…",
    "message_id_header": "<abc@mail.gmail.com>",
    "reply_to_message_id": "",            // In-Reply-To
    "references": "",
    "headers": [{ "name": "List-Unsubscribe", "value": "<…>" }],
    "size": 25061
  }
}

headers (the full header list) is populated on mail ingested going forward; for any message you can always get every header from the raw export below.

Raw / formats

Need byte-exact fidelity — your own parser, DKIM verification, archival, or re-sending? Append ?format=raw to a message to get the original RFC-822.

GET/v1/grants/{grant_id}/messages/{message_id}?format=raw

The original RFC-822 bytes (Content-Type: message/rfc822), fetched live from Gmail.

cURL · raw export
curl "https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/v1/grants/USER_ID/messages/MSG_ID?thread_id=THREAD_ID&format=raw" \
  -H "Authorization: Bearer cok_your_api_key" \
  --output message.eml

Raw is proxied live, so it needs a connected (non-revoked) grant. The model is: parsed JSON by default, raw on demand, attachments downloaded individually.

Threads

GET/v1/grants/{grant_id}/threads/{thread_id}

Retrieve a thread and all of its messages.

Response · thread object
{
  "request_id": "req_…",
  "data": {
    "id": "19e77cd732…",
    "grant_id": "user-123",
    "subject": "Welcome",
    "message_ids": ["19e77…", "19e88…"],
    "message_count": 2,
    "has_attachments": false,
    "messages": [ /* full message objects */ ]
  }
}

Attachments

GET/v1/grants/{grant_id}/attachments/{attachment_id}

Download attachment bytes. Required query: message_id (thread_id recommended).

cURL
curl "https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/v1/grants/USER_ID/attachments/ATT_ID?message_id=MSG_ID&thread_id=THREAD_ID" \
  -H "Authorization: Bearer cok_your_api_key" \
  --output cv.pdf

Webhooks

We POST thin, HMAC-signed events to your webhook URL. Respond 2xx quickly, dedupe on the event id, then do any heavy work async. Two headers ride on every delivery:

X-CourierOwl-EventThe event type (e.g. message.created) — route on this.
X-CourierOwl-SignatureHex HMAC-SHA256 of the raw body, keyed by your signing secret.

Event types

message.createdA new email arrived. Carries metadata + a fetch_url.
mailbox.connectedA user finished connecting their Gmail.
mailbox.syncedThe full-history backfill for a mailbox completed.
mailbox.disconnectedA grant was revoked or its token permanently failed.
message.created
{
  "type": "message.created",
  "id": "evt_…",                  // unique — dedupe on this
  "org_id": "org_…",
  "data": { "object": {
    "object": "message",
    "id": "19e77cd732…",
    "thread_id": "19e77cd732…",
    "user_id": "user-123",        // your code, for routing
    "account_email": "alice@company.com",
    "from": { "email": "sender@x.com", "name": "Sender" },
    "subject": "hi",
    "snippet": "Hello…",
    "received_at": "2026-05-30T03:32:56Z",
    "fetch_url": "https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/v1/orgs/…/messages/…"
  } }
}
mailbox.connected / mailbox.synced / mailbox.disconnected
{
  "type": "mailbox.synced",
  "id": "evt_…",
  "org_id": "org_…",
  "data": { "object": {
    "object": "mailbox",
    "user_id": "user-123",
    "email": "alice@company.com",
    "mailbox_id": "mb_9f2c1ab34d5e6f70",
    "status": "active",            // connected/synced: active · disconnected: revoked|invalid
    "synced_at": "2026-05-30T06:04:00Z",   // mailbox.synced only
    "messages_synced": 1766                // mailbox.synced only
  } }
}

Verify the signature over the raw request bytes before trusting a payload:

Node (Express)
import crypto from "crypto";

// mount with the RAW body: app.post(path, express.raw({ type: "application/json" }), handler)
function verify(rawBody, signature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

app.post("/webhooks/courier-owl", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.get("X-CourierOwl-Signature") || "";
  if (!verify(req.body, sig, process.env.COURIER_OWL_WEBHOOK_SECRET)) return res.status(401).end();
  const event = JSON.parse(req.body.toString("utf8"));
  res.status(200).end();              // ack fast
  enqueue(event);                     // dedupe on event.id, then process async
});
Python (Flask)
import hashlib, hmac, os
secret = os.environ["COURIER_OWL_WEBHOOK_SECRET"].encode()
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()   # raw bytes, before json.loads
if not hmac.compare_digest(expected, request.headers.get("X-CourierOwl-Signature", "")):
    abort(401)

Delivery is at-least-once with retries and a dead-letter queue — always dedupe on the event id. Send a test delivery any time from your dashboard.

SDK

The official TypeScript/JS SDK is dependency-free and isomorphic — Node 18+, browsers, Edge, and serverless. It includes verifyWebhook.

Install
npm install @courier-owl/sdk
Node
import { CourierOwl } from "@courier-owl/sdk";

const co = new CourierOwl({ apiKey: process.env.COURIER_OWL_API_KEY! });

const mailboxes = await co.grants.list();
const { data, nextCursor } = await co.messages.list("user-123", { limit: 20 });

await co.messages.send("user-123", {
  to: "bob@company.com",
  subject: "Hello from courier-owl",
  body: "Sent via the API.",
});

Errors

Errors share the envelope and carry a typed message:

Error
{ "request_id": "req_…", "error": { "type": "api_error", "message": "unauthorized" } }
200Success
400Bad request — a required parameter is missing
401Missing or invalid api_key
404Grant, message, thread, or attachment not found
409Grant not active (e.g. raw export on a revoked mailbox)
429Rate limited — back off and retry
5xxServer error — safe to retry
Need help? Create an account to get your keys and a test panel, or open the interactive explorer.