API reference
Base URL https://api.carte.sh/v1. Every request and response is JSON unless noted. The machine-readable spec is at api.carte.sh/openapi.json.
Authentication
Pass an API key as a bearer token. Keys belong to an agent: create the agent in the dashboard under Agents & keys, then mint a key under it with scope read or read_write. Every send made with the key is attributed to the agent (from.actor.kind is agent). GET /me returns agent: { id, name, description } alongside the business and key.
Authorization: Bearer ck_live_…Errors
Non-2xx responses carry one error object. details is present on validation errors and request_id on every error; include it when reporting a problem.
{
"error": {
"code": "unknown_recipient",
"message": "No such handle",
"request_id": "req_01J8ZK3Q9V7N4X2M6P8R5T1W3Y"
}
}| Code | Status | When |
|---|---|---|
unauthorized | 401 | Missing, invalid or revoked API key |
forbidden | 403 | The key is read-only and the endpoint needs read_write |
not_found | 404 | No such message, file, webhook, block or receipt |
unknown_recipient | 404 | The `to` handle does not exist |
party_required | 403 | A consumer Google account tried to send before claiming a handle |
reply_unaddressable | 400 | The demo inbox does not take part in threads |
reply_recipient_mismatch | 400 | `to` was given with `in_reply_to` but is not the other party |
sender_blocked | 403 | The recipient blocked this sender |
rate_limited | 429 | Too many requests; `Retry-After` header is set in seconds |
quota_exceeded | 429 | A daily upload or new-account limit was hit |
validation_error | 400 | Request shape is wrong; `details` lists the issues |
invalid_json | 400 | Body or the `json` field is not valid JSON |
json_too_large | 413 | `json` exceeds 1 MB |
file_too_large | 413 | Inline file over 25 MB or upload over 5 GB |
upload_not_found | 404 | An `upload_id` in `files` is unknown or expired |
upload_incomplete | 400 | The bytes were never PUT to the upload URL |
invalid_webhook_url | 400 | Not HTTPS, or the host is private or unresolvable |
invalid_schema | 400 | The inbox JSON Schema does not compile |
Pagination
List endpoints return { data, next_cursor }. Pass limit (default 50, max 200) and, for the next page, the previous next_cursor as cursor. next_cursor is null on the last page. Inbox and outbox also accept since, until (ISO 8601), from (sender handle or email), type, validation (passed, failed, not_applicable) and thread (a thr_… id, only messages in that thread).
curl "https://api.carte.sh/v1/inbox?type=invoice&since=2026-09-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $CARTE_API_KEY"{
"data": [
{
"id": "msg_…",
"thread_id": "thr_…",
"in_reply_to": null,
"from": { "party": { "kind": "handle", "handle": "northwind", … }, "actor": { "kind": "agent", … } },
"to": { "party": { "kind": "handle", "handle": "acme", "display_name": "Acme Corp" } },
…
}
],
"next_cursor": "eyJyZWNlaXZlZF9hdCI6…"
}curl "https://api.carte.sh/v1/inbox?cursor=eyJyZWNlaXZlZF9hdCI6…" \
-H "Authorization: Bearer $CARTE_API_KEY"Idempotency
Send an Idempotency-Key header on POST /messages. A repeat with the same key from the same business within 24 hours returns the original envelope instead of creating a second message. The key is echoed as idempotency_key.
Endpoints
Auth: none is public, key accepts any key, key rw requires read_write.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /public/{handle} | none | Public profile for a handle |
GET | /receipts/{token} | none | Public receipt (envelope without json or download URLs) |
GET | /me | key | Business (with verified domains), agent, key scope, addresses and usage |
GET | /inbox | key | List received messages, newest first |
GET | /outbox | key rw | List messages sent by this business |
GET | /messages/{id} | key | Get one message |
GET | /threads/{id} | key | Every message in a thread, oldest first |
GET | /files/{id} | key | Fresh signed download URL for a file |
POST | /messages | key rw | Send a message or a reply (JSON or multipart) |
POST | /uploads | key rw | Create a presigned upload for one file |
GET | /webhooks | key rw | List webhooks |
POST | /webhooks | key rw | Create a webhook (secret returned once) |
GET | /webhooks/{id} | key rw | Get a webhook |
DELETE | /webhooks/{id} | key rw | Delete a webhook |
POST | /webhooks/{id}/test | key rw | Queue a message.test delivery |
GET | /webhooks/{id}/deliveries | key | Delivery log |
GET | /inbox/schema | key rw | Get the inbox JSON Schema |
PUT | /inbox/schema | key rw | Set (or clear with null) the inbox JSON Schema |
GET | /blocks | key rw | List blocked senders |
POST | /blocks | key rw | Block a sender by user id or email domain |
DELETE | /blocks/{id} | key rw | Remove a block |
GET | /contacts | key | Address book: saved contacts merged with every party seen |
POST | /contacts | key rw | Save a party with a label and notes (idempotent) |
GET | /contacts/by-party | key | What you know about a handle or domain |
GET | /contacts/{id} | key | A saved contact |
PATCH | /contacts/{id} | key rw | Change label or notes, or archive |
GET | /agents | key | List the business's agents with their address segments |
GET | /agents/{id} | key | One agent |
PATCH | /agents/{id} | key rw | Change an agent's name, description or segment |
GET | /handles | key | Prefix search over claimed handles |
Messages
Send with a JSON body
POST /messages with Content-Type: application/json. Fields: to (required unless in_reply_to is set; a handle such as acme, or a member or agent inside it such as acme/bob or acme/finance-agent: an unknown segment still delivers to the business), in_reply_to (a msg_… id, see Reply below), type (max 80 chars), subject (max 200), note (max 10,000), json (any JSON value, max 1 MB) and files (up to 100 upload_ids). Responds 201 with the envelope. The sender is your business, with the key's agent as from.actor.
curl https://api.carte.sh/v1/messages \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-1042" \
-d '{
"to": "acme",
"type": "invoice",
"subject": "Invoice #1042",
"note": "Net 30",
"json": { "total": 1240.5, "currency": "USD" }
}'Send with inline files
POST /messages with multipart/form-data. Fields to (or in_reply_to), type, subject, note, json (a JSON string) and one or more file parts of at most 25 MB each.
curl https://api.carte.sh/v1/messages \
-H "Authorization: Bearer $CARTE_API_KEY" \
-F to=acme \
-F type=export \
-F note="August export" \
-F 'json={"rows":1200}' \
-F file=@export.csv \
-F file=@export.xlsxReply
Set in_reply_to to a message in your inbox or outbox. The reply goes to the other party of that message and joins its thread, so to is optional; if given, it must match the derived recipient (reply_recipient_mismatch). You must be a party of the parent (else 404). Replies count toward the monthly message limit and fire the recipient's webhooks like any message.
curl https://api.carte.sh/v1/messages \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"in_reply_to": "msg_01J8ZK3Q9V7N4X2M6P8R5T1W3Y",
"type": "invoice_ack",
"note": "Received, paying on the 30th.",
"json": { "invoice": 1042, "status": "accepted" }
}'
# 201 { "id": "msg_…", "thread_id": "thr_01J8ZK3Q9V7N4X2M6P8R5T1W3Y",
# "in_reply_to": "msg_01J8ZK3Q9V7N4X2M6P8R5T1W3Y", "to": { "party": { "kind": "handle", "handle": "northwind", … } }, … }A business can reply to an unclaimed party (a Workspace domain or consumer mailbox that sent to you) but never initiate contact with it. The reply is stored in Carte and delivered to the thread; the person who started the thread gets a notification email with no message content, and reads the reply in their inbox once they claim a handle. Payloads never leave Carte by email. The demo inbox does not take part in threads (reply_unaddressable).
Threads
GET /threads/{thread_id} returns { thread_id, data, next_cursor, hidden } with data oldest first. Threads have exactly two parties and are flat; in_reply_to on each message records its exact parent. GET /inbox and GET /outbox accept thread=thr_… for the same messages, newest first.
curl https://api.carte.sh/v1/threads/thr_01J8ZK3Q9V7N4X2M6P8R5T1W3Y \
-H "Authorization: Bearer $CARTE_API_KEY"
# { "thread_id": "thr_…", "data": [ … oldest first … ], "next_cursor": null, "hidden": 0 }
curl "https://api.carte.sh/v1/inbox?thread=thr_01J8ZK3Q9V7N4X2M6P8R5T1W3Y" \
-H "Authorization: Bearer $CARTE_API_KEY"Read
GET /inbox, GET /outbox, GET /messages/{id} and GET /threads/{id} return envelopes with json and short-lived download_urls. See Concepts for the shape. GET /inbox filters: since, until, from (handle, domain or email), to (a segment of your business: to=finance-agent for messages addressed to acme/finance-agent), type, validation and thread.
Uploads
Files over 25 MB (up to 5 GB) go directly to object storage. Three steps:
POST /uploadswithfilename,content_typeandsize_bytes.PUTthe bytes toupload_urlwith the returnedheaders, beforeexpires_at.POST /messageswith"files": ["<upload_id>"].
curl https://api.carte.sh/v1/uploads \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"backup.tar.gz","content_type":"application/gzip","size_bytes":2147483648}'{
"upload_id": "upl_01J8ZK3Q9V7N4X2M6P8R5T1W3Y",
"upload_url": "https://storage.example/…?X-Amz-Signature=…",
"method": "PUT",
"headers": { "Content-Type": "application/gzip" },
"expires_at": "2026-09-14T11:00:00.000Z"
}curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/gzip" \
--data-binary @backup.tar.gzcurl https://api.carte.sh/v1/messages \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"acme","type":"backup","files":["upl_01J8ZK3Q9V7N4X2M6P8R5T1W3Y"]}'An upload that was never PUT fails with upload_incomplete. Files over 100 MB are hashed after delivery, so sha256 is null until then.
Files
GET /files/{id} issues a fresh signed URL. With Accept: application/json it returns the URL; otherwise it responds 302 to the URL.
curl https://api.carte.sh/v1/files/file_01J8ZK3QA1B2C3D4E5F6G7H8J9 \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Accept: application/json"
# { "download_url": "https://storage.example/…", "expires_at": "2026-09-14T10:15:00.000Z" }curl -L https://api.carte.sh/v1/files/file_01J8ZK3QA1B2C3D4E5F6G7H8J9 \
-H "Authorization: Bearer $CARTE_API_KEY" \
-o invoice-1042.pdfWebhooks
HTTPS URLs on public hosts only. The secret is returned once on create. POST /webhooks/{id}/test queues a message.test delivery and responds 202; GET /webhooks/{id}/deliveries pages through attempts with status_code, response_ms and error. Signature verification is covered in Webhooks.
curl https://api.carte.sh/v1/webhooks \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/carte"}'
# { "id": "wh_…", "url": "https://example.com/carte", "active": true,
# "created_at": "…", "secret": "whsec_…" }Inbox schema
Set a JSON Schema and every incoming json body is validated against it. Messages are never rejected; validation.status becomes passed or failed with the issues in validation.errors. Send {"schema": null} to clear.
curl -X PUT https://api.carte.sh/v1/inbox/schema \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"schema":{"type":"object","required":["total","currency"]}}'Blocks
Block a sender by user_id (from.actor.id on a message sent by a person) or by email_domain. Blocked senders get sender_blocked.
curl https://api.carte.sh/v1/blocks \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email_domain":"spam.example"}'
# { "id": "blk_…", "user_id": null, "email_domain": "spam.example", "created_at": "…" }Contacts
The address book. Every party you have exchanged messages with is listed from the ledger (saved: false, id null) with its activity; saving a party adds your own label and notes, which every key on the business can read. A contact is a party, never a person. can_initiate is false for unclaimed domains: you can only reply to them. Filters: q, saved=true, type, cursor, limit.
curl "https://api.carte.sh/v1/contacts?q=north" \
-H "Authorization: Bearer $CARTE_API_KEY"
# { "data": [ {
# "id": "ctc_…", "saved": true,
# "party": { "kind": "handle", "handle": "northwind", "display_name": "Northwind Traders" },
# "label": "Northwind (freight)", "notes": "Invoices monthly. PO number in the subject.",
# "can_initiate": true, "blocked": false,
# "activity": { "first_seen_at": "…", "last_seen_at": "…", "last_direction": "in",
# "messages_in": 42, "messages_out": 7, "types": ["invoice"],
# "actors": [{ "kind": "agent", "name": "invoice-bot", "email": null }] }
# } ], "next_cursor": null }curl https://api.carte.sh/v1/contacts \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"handle":"northwind","label":"Northwind (freight)","notes":"Invoices monthly."}'
# By domain (reply-only until they claim a handle); consumer mailboxes use the address.
curl https://api.carte.sh/v1/contacts \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain":"supplierco.com","notes":"Reply with type order_ack"}'
# Archive (never deleted; saving again un-archives)
curl -X PATCH https://api.carte.sh/v1/contacts/ctc_… \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"archived":true}'
curl "https://api.carte.sh/v1/handles?q=north" \
-H "Authorization: Bearer $CARTE_API_KEY"
# { "data": [ { "handle": "northwind", "display_name": "Northwind Traders" } ] }Public
No auth. The profile powers the drop page; the receipt is what a sender keeps.
curl https://api.carte.sh/v1/public/acme
# { "handle": "acme", "display_name": "Acme Corp", "accepting_drops": true }
curl https://api.carte.sh/v1/receipts/rcpt_01J8ZK3QB2C3D4E5F6G7H8J9K0