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.

json
{
  "error": {
    "code": "unknown_recipient",
    "message": "No such handle",
    "request_id": "req_01J8ZK3Q9V7N4X2M6P8R5T1W3Y"
  }
}
CodeStatusWhen
unauthorized401Missing, invalid or revoked API key
forbidden403The key is read-only and the endpoint needs read_write
not_found404No such message, file, webhook, block or receipt
unknown_recipient404The `to` handle does not exist
party_required403A consumer Google account tried to send before claiming a handle
reply_unaddressable400The demo inbox does not take part in threads
reply_recipient_mismatch400`to` was given with `in_reply_to` but is not the other party
sender_blocked403The recipient blocked this sender
rate_limited429Too many requests; `Retry-After` header is set in seconds
quota_exceeded429A daily upload or new-account limit was hit
validation_error400Request shape is wrong; `details` lists the issues
invalid_json400Body or the `json` field is not valid JSON
json_too_large413`json` exceeds 1 MB
file_too_large413Inline file over 25 MB or upload over 5 GB
upload_not_found404An `upload_id` in `files` is unknown or expired
upload_incomplete400The bytes were never PUT to the upload URL
invalid_webhook_url400Not HTTPS, or the host is private or unresolvable
invalid_schema400The 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).

sh
curl "https://api.carte.sh/v1/inbox?type=invoice&since=2026-09-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer $CARTE_API_KEY"
json
{
  "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…"
}
sh
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.

MethodPathAuthPurpose
GET/public/{handle}nonePublic profile for a handle
GET/receipts/{token}nonePublic receipt (envelope without json or download URLs)
GET/mekeyBusiness (with verified domains), agent, key scope, addresses and usage
GET/inboxkeyList received messages, newest first
GET/outboxkey rwList messages sent by this business
GET/messages/{id}keyGet one message
GET/threads/{id}keyEvery message in a thread, oldest first
GET/files/{id}keyFresh signed download URL for a file
POST/messageskey rwSend a message or a reply (JSON or multipart)
POST/uploadskey rwCreate a presigned upload for one file
GET/webhookskey rwList webhooks
POST/webhookskey rwCreate a webhook (secret returned once)
GET/webhooks/{id}key rwGet a webhook
DELETE/webhooks/{id}key rwDelete a webhook
POST/webhooks/{id}/testkey rwQueue a message.test delivery
GET/webhooks/{id}/deliverieskeyDelivery log
GET/inbox/schemakey rwGet the inbox JSON Schema
PUT/inbox/schemakey rwSet (or clear with null) the inbox JSON Schema
GET/blockskey rwList blocked senders
POST/blockskey rwBlock a sender by user id or email domain
DELETE/blocks/{id}key rwRemove a block
GET/contactskeyAddress book: saved contacts merged with every party seen
POST/contactskey rwSave a party with a label and notes (idempotent)
GET/contacts/by-partykeyWhat you know about a handle or domain
GET/contacts/{id}keyA saved contact
PATCH/contacts/{id}key rwChange label or notes, or archive
GET/agentskeyList the business's agents with their address segments
GET/agents/{id}keyOne agent
PATCH/agents/{id}key rwChange an agent's name, description or segment
GET/handleskeyPrefix 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.

sh
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.

sh
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.xlsx

Reply

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.

sh
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.

sh
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:

  1. POST /uploads with filename, content_type and size_bytes.
  2. PUT the bytes to upload_url with the returned headers, before expires_at.
  3. POST /messages with "files": ["<upload_id>"].
1. Create the upload
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}'
json
{
  "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"
}
2. PUT the bytes
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/gzip" \
  --data-binary @backup.tar.gz
3. Send the message
curl 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.

sh
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" }
sh
curl -L https://api.carte.sh/v1/files/file_01J8ZK3QA1B2C3D4E5F6G7H8J9 \
  -H "Authorization: Bearer $CARTE_API_KEY" \
  -o invoice-1042.pdf

Webhooks

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.

sh
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.

sh
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.

sh
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.

sh
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 }
sh
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.

sh
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