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.
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:
- Connect. You send a user to a branded consent link. On approval we store the connection and fire
mailbox.connected. - 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. - Live mail. Every new email triggers a signed
message.createdwebhook in real time. - 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.
- Disconnect. On revoke or a permanent token failure we fire
mailbox.disconnected.
message.created; read the back-history via the API.Quickstart
- Create an account and an organization — you get an
api_key, anorg_id, and a webhook signing secret. - Set your webhook URL in the dashboard.
- Send a user through the connect link (below) to link their Gmail.
- Receive a signed
message.created, then fetch the message:
# 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.
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.
/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).
https://tdgqo14n67.execute-api.us-east-1.amazonaws.com/auth/gmail/authorize?org_id=org_abc123&user_id=user-123On 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.
/v1/mailboxesAll connected mailboxes for your org.
[
{
"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.
/v1/grantsList connected mailboxes (envelope form).
/v1/grants/{grant_id}Retrieve one grant.
/v1/grants/{grant_id}Revoke a grant — stops Gmail watching and fires mailbox.disconnected.
{
"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
/v1/grants/{grant_id}/messagesList messages, newest-first. Query: limit (≤100), cursor.
/v1/grants/{grant_id}/messages/{message_id}Retrieve one message. Pass ?thread_id for a fast read; ?format=raw for the original RFC-822.
/v1/grants/{grant_id}/messages/sendSend (or reply) as the mailbox. Pass thread_id to reply within a thread.
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.
{
"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.
/v1/grants/{grant_id}/messages/{message_id}?format=rawThe original RFC-822 bytes (Content-Type: message/rfc822), fetched live from Gmail.
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.emlRaw 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
/v1/grants/{grant_id}/threads/{thread_id}Retrieve a thread and all of its messages.
{
"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
/v1/grants/{grant_id}/attachments/{attachment_id}Download attachment bytes. Required query: message_id (thread_id recommended).
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.pdfWebhooks
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-Event | The event type (e.g. message.created) — route on this. |
| X-CourierOwl-Signature | Hex HMAC-SHA256 of the raw body, keyed by your signing secret. |
Event types
| message.created | A new email arrived. Carries metadata + a fetch_url. |
| mailbox.connected | A user finished connecting their Gmail. |
| mailbox.synced | The full-history backfill for a mailbox completed. |
| mailbox.disconnected | A grant was revoked or its token permanently failed. |
{
"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/…"
} }
}{
"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:
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
});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.
npm install @courier-owl/sdkimport { 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:
{ "request_id": "req_…", "error": { "type": "api_error", "message": "unauthorized" } }| 200 | Success |
| 400 | Bad request — a required parameter is missing |
| 401 | Missing or invalid api_key |
| 404 | Grant, message, thread, or attachment not found |
| 409 | Grant not active (e.g. raw export on a revoked mailbox) |
| 429 | Rate limited — back off and retry |
| 5xx | Server error — safe to retry |