Gmail integration (API v1)
Gmail Integration API (v1)
Guide for CLI tools and external integrators syncing Gmail with CustomDesigner / MioTees.
There are two related resources:
| Resource | Table | Purpose |
|---|---|---|
| Thread context | gmail_thread_contexts |
CRM cache: linked orders, clients, participant metadata for inbox UI |
| Email thread | email_threads |
Full thread payload: subject, labels, participants, and all messages from Gmail |
Use thread context when you need order/client matching. Use email threads when you are pushing mail content from the CLI into the database.
Prerequisites
- Developer API token — Settings → Developer → Generate (64 hex characters, shown once).
- Migration applied in Supabase:
migrations/20260604_gmail_thread_contexts.sql(thread context reads)migrations/20260605_email_threads.sql(email thread upserts)
- Base URL
- Production:
https://<your-site> - Local:
http://localhost:3000
- Production:
All paths below are under /api/v1.
Authentication
Every request:
Authorization: Bearer <64_character_hex_token>
Content-Type: application/json
- The token is scoped to one company (set when the token was created).
- Do not send
company_idin the body; the server assigns it from the token. - Errors use:
{
"error": {
"code": "unauthenticated",
"message": "…",
"details": null
}
}
Common codes: unauthenticated, forbidden, invalid_request, not_found, rate_limited, internal_error.
1. Gmail thread context (read CRM cache)
GET /api/v1/gmail/threads/{legacyThreadId}
Returns a row from gmail_thread_contexts for a Gmail legacy thread id (the id your CLI/extension already uses).
Request
curl -sS "https://YOUR_SITE/api/v1/gmail/threads/LEGACY_THREAD_ID" \
-H "Authorization: Bearer YOUR_TOKEN"
| Parameter | Location | Required | Description |
|---|---|---|---|
legacyThreadId |
path | yes | Gmail thread id |
No query parameters.
Response 200
{
"thread_id": "…",
"legacy_thread_id": "…",
"sender_email": "customer@example.com",
"client_id": "42",
"client_data": { },
"clients_data": [ ],
"orders_data": [ ],
"selected_order_id": "101",
"participant_emails": [ "a@example.com", "b@example.com" ],
"participants_data": [ ],
"fetched_at": "2026-06-04T12:00:00.000Z",
"updated_at": "2026-06-04T12:00:00.000Z"
}
Fields are stored as JSON in Supabase; shapes depend on what wrote the cache (extension, internal jobs, etc.).
Access control
Returns 404 if:
- No row exists for that
legacy_thread_id, or - The row is not tied to your company (via
client_id, order ids inorders_data, orcompany_idinside cached JSON).
This prevents cross-tenant reads when the table has no company_id column.
Typical CLI flow
- User opens a Gmail thread in the CLI.
- CLI calls this endpoint with the thread’s legacy id.
- CLI uses
orders_data/client_data/participants_datato show linked CRM records or suggest matches.
2. Email threads (upsert full thread + messages)
PUT /api/v1/gmail/email-threads/{legacyThreadId}
Creates or updates a row in email_threads for the authenticated company and mailbox.
Conflict key: (company_id, account_email, legacy_thread_id) — same legacyThreadId with a different account_email is a separate row.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
account_email |
string | yes | Gmail mailbox that was synced (normalized to lowercase) |
messages |
array | yes | Message objects from Gmail (use [] if empty) |
participants |
array | no | Default [] — people on the thread |
labels |
array | no | Default [] — Gmail label ids/names |
subject |
string | no | Thread subject |
snippet |
string | no | Short preview for lists |
gmail_thread_id |
string | no | Canonical Gmail thread id if different from legacy |
first_message_at |
ISO string | no | Earliest message time |
last_message_at |
ISO string | no | Latest message time |
gmail_history_id |
string | no | For incremental sync |
last_synced_at |
ISO string | no | Defaults to server now() |
Example: upsert from CLI
curl -sS -X PUT "https://YOUR_SITE/api/v1/gmail/email-threads/LEGACY_THREAD_ID" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"account_email": "shop@yourcompany.com",
"subject": "Re: Quote #4421",
"snippet": "Thanks, can we add 12 more shirts?",
"participants": [
{ "email": "customer@example.com", "name": "Jane Customer" },
{ "email": "shop@yourcompany.com", "name": "Your Shop" }
],
"labels": ["INBOX", "IMPORTANT"],
"messages": [
{
"id": "MSG_ID_1",
"internal_date": "2026-06-01T14:22:00.000Z",
"from": "customer@example.com",
"to": ["shop@yourcompany.com"],
"subject": "Quote #4421",
"snippet": "Hi, we need 48 tees…",
"body_plain": "Hi, we need 48 tees…",
"body_html": "<p>Hi, we need 48 tees…</p>"
},
{
"id": "MSG_ID_2",
"internal_date": "2026-06-04T09:15:00.000Z",
"from": "customer@example.com",
"to": ["shop@yourcompany.com"],
"subject": "Re: Quote #4421",
"snippet": "Thanks, can we add 12 more shirts?",
"body_plain": "Thanks, can we add 12 more shirts?",
"body_html": null
}
],
"first_message_at": "2026-06-01T14:22:00.000Z",
"last_message_at": "2026-06-04T09:15:00.000Z",
"gmail_history_id": "987654",
"gmail_thread_id": "LEGACY_THREAD_ID"
}'
Response 200
{
"email_thread": {
"id": "uuid",
"account_email": "shop@yourcompany.com",
"legacy_thread_id": "LEGACY_THREAD_ID",
"gmail_thread_id": "LEGACY_THREAD_ID",
"subject": "Re: Quote #4421",
"snippet": "Thanks, can we add 12 more shirts?",
"participants": [ ],
"labels": [ "INBOX", "IMPORTANT" ],
"messages": [ ],
"message_count": 2,
"first_message_at": "2026-06-01T14:22:00.000Z",
"last_message_at": "2026-06-04T09:15:00.000Z",
"gmail_history_id": "987654",
"last_synced_at": "2026-06-04T10:00:00.000Z",
"created_at": "2026-06-04T09:00:00.000Z",
"updated_at": "2026-06-04T10:00:00.000Z"
}
}
message_count is derived from messages.length in the database trigger.
Upsert semantics
- First call for
(company, account_email, legacy_thread_id)→ insert. - Repeat calls → update the same row (full replace of JSON arrays and scalar fields you send).
deletedis set back tofalseon upsert (restore after soft-delete, if you add that later in-app).- Send the complete
messagesarray each sync; omitted keys are not merged field-by-field.
Read back (optional)
GET /api/v1/gmail/email-threads/{legacyThreadId}?account_email=shop@yourcompany.com
curl -sS -G "https://YOUR_SITE/api/v1/gmail/email-threads/LEGACY_THREAD_ID" \
-H "Authorization: Bearer YOUR_TOKEN" \
--data-urlencode "account_email=shop@yourcompany.com"
Returns the same { "email_thread": { … } } wrapper. 404 if not found for that company + mailbox + thread id.
Suggested message object shape (CLI)
The API does not validate per-message schema; store consistent objects for your own tooling.
The Messages tab parser expects:
| Field | Type | Notes |
|---|---|---|
id |
string | Gmail message id (required) |
sent_at |
ISO string | Preferred timestamp (internal_date is accepted by tooling but not shown in UI) |
from |
{ name?, email? } |
Object form preferred for the Messages UI |
to |
{ name?, email? }[] |
Object form preferred |
subject |
string | Optional per message |
snippet |
string | Short preview |
body_plain / body_text |
string | Plain body |
is_from_us |
boolean | Aligns bubble to the right when true |
attachments |
array | See below |
Attachment objects
| Field | Type | Notes |
|---|---|---|
filename |
string | Required for the chip to render |
mime_type |
string | Optional |
size_bytes |
number | Optional |
storage_key |
string | R2 object key after upload (makes the chip downloadable) |
gmail_attachment_id |
string | Optional; use for idempotent matching when merging keys |
Example after R2 sync:
{
"filename": "Tiger.jpg",
"mime_type": "image/jpeg",
"size_bytes": 245760,
"storage_key": "orders/{companyId}/{orderId}/email/{legacyThreadId}/Tiger.jpg",
"gmail_attachment_id": "ANGjdJ8…"
}
3. Order file upload (Gmail attachments → R2)
Integrators (e.g. openclaw) upload attachment bytes into the same Cloudflare R2 store used by the order Files tab. Do not put R2 credentials on the CLI — use the Developer API token and presigned PUTs.
Resolve target order
GET /api/v1/gmail/threads/{legacyThreadId}- Require
orders_datawith at least one order (existing guardrail). - Target order id:
selected_order_idif set, else- the sole
orders_data[0].idwhenorders_data.length === 1, else - skip (multi-order thread without a selection).
Local attachment dirs that are not in gmail_thread_contexts are intentionally unlinked — skip them.
Presign
POST /api/v1/orders/{orderId}/files/presign
curl -sS -X POST "https://YOUR_SITE/api/v1/orders/1383/files/presign" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fileName": "Tiger.jpg",
"contentType": "image/jpeg",
"size": 245760,
"legacyThreadId": "19f38fde180ceb36"
}'
| Field | Required | Description |
|---|---|---|
fileName |
yes | Basename used in the R2 key |
contentType |
no | Defaults to application/octet-stream |
size |
yes | Bytes; max 150 MB |
legacyThreadId |
yes | Gmail legacy thread id (folder name under .attachments/) |
Response:
{
"uploadUrl": "https://…r2.cloudflarestorage.com/…",
"key": "orders/{companyId}/1383/email/19f38fde180ceb36/Tiger.jpg",
"contentType": "image/jpeg",
"expiresIn": 600
}
Then PUT the raw file bytes to uploadUrl with matching Content-Type (and Content-Length when required by the signer).
Object key (stable / idempotent overwrite):
orders/{companyId}/{orderId}/email/{legacyThreadId}/{safeBasename}
List (idempotency)
GET /api/v1/orders/{orderId}/files?legacyThreadId={legacyThreadId}
Returns { files: [{ path, objectName, length, lastChanged, source }], configured }.source is "email" when the key contains /email/, otherwise "manual".
Omit legacyThreadId to list the whole order prefix.
Merge keys into the email thread
After uploads, PUT /api/v1/gmail/email-threads/{legacyThreadId} with the full messages array, setting attachments[].storage_key to the returned key (match by gmail_attachment_id when present, else filename).
Suggested openclaw cadence
- Primary: after a successful email-thread write when
keith/.attachments/{legacyThreadId}/is non-empty. - Catch-up: daily cron over all attachment folders (cheap skip for unlinked / already listed in R2).
- Default dry-run; production writes only with
--apply. - Same eligibility rules as thread sync (client-com labels, linked order context required).
In-app result:
- Files tab lists the objects (with an Email badge).
- Messages chips become download links when
storage_keyis set.
End-to-end CLI workflow
- Pull CRM context —
GET …/gmail/threads/{legacyThreadId}to show linked orders/clients. - Push mail snapshot —
PUT …/gmail/email-threads/{legacyThreadId}after fetching thread/messages from Gmail API. - Upload attachments — for each local file under
.attachments/{legacyThreadId}/, presign → PUT → mergestorage_key. - Verify (optional) —
GET …/gmail/email-threads/…andGET …/orders/{orderId}/files?legacyThreadId=….
Rate limits and docs
- ~300 requests/minute per API key.
- Interactive OpenAPI: /docs/api/explorer
- OpenAPI YAML:
/openapi.yaml
Related files in this repo
| File | Role |
|---|---|
server/api/v1/gmail/threads/[legacyThreadId].get.ts |
Thread context read |
server/api/v1/gmail/email-threads/[legacyThreadId].put.ts |
Email thread upsert |
server/api/v1/gmail/email-threads/[legacyThreadId].get.ts |
Email thread read |
server/api/v1/orders/[id]/files/presign.post.ts |
Attachment upload presign |
server/api/v1/orders/[id]/files/index.get.ts |
List order / email R2 files |
migrations/20260604_gmail_thread_contexts.sql |
Context table |
migrations/20260605_email_threads.sql |
Email threads table |