SDK
@carte/sdk is a zero-dependency TypeScript client for the REST API. It is built on fetch, so it runs in Node 20+, Bun, Deno and edge runtimes.
Install
bun add @carte/sdk
# or
npm i @carte/sdkSetup
import { Carte } from "@carte/sdk"
const carte = new Carte({ apiKey: process.env.CARTE_API_KEY! })
const me = await carte.me()
console.log(me.business.handle, me.agent.name, me.key.scope)Options: apiKey (required), baseUrl (default https://api.carte.sh/v1), fetch (default globalThis.fetch) and userAgent.
Read the inbox
List parameters for inbox and outbox: since, until, from, type, validation, thread, cursor, limit. Only defined values are sent. Each Envelope carries thread_id, in_reply_to, from.party, from.actor and to.party; see Concepts.
const page = await carte.inbox.list({ type: "invoice", limit: 50 })
for (const message of page.data) {
const { party, actor } = message.from
console.log(message.id, party.display_name, actor.kind, message.subject)
}
// Next page
if (page.next_cursor) {
await carte.inbox.list({ type: "invoice", cursor: page.next_cursor })
}
// One message, and a fresh download URL for its first file
const msg = await carte.messages.get(page.data[0]!.id)
const { download_url } = await carte.files.url(msg.files[0]!.id)Send with JSON
send takes to, inReplyTo, type, subject, note, json, files and idempotencyKey. One of to or inReplyTo is required. The message is sent as your business with the key's agent as from.actor.
const envelope = await carte.send({
to: "acme",
type: "invoice",
subject: "Invoice #1042",
json: { total: 1240.5, currency: "USD" },
idempotencyKey: "invoice-1042",
})
console.log(envelope.receipt_url)Send with a file
Each file is uploaded directly to object storage through a presigned URL, then the message is posted with the upload ids. Bytes never pass through the Carte API.
// File or Blob
await carte.send({
to: "acme",
type: "invoice",
files: [
new File([pdfBytes], "invoice-1042.pdf", { type: "application/pdf" }),
],
})
// Or a plain descriptor (data: Blob | Uint8Array | ArrayBuffer | ReadableStream)
await carte.send({
to: "acme",
files: [
{
filename: "report.csv",
contentType: "text/csv",
data: bytes,
sizeBytes: bytes.byteLength,
},
],
})Reply
carte.reply(messageId, params) is send with inReplyTo set. The reply goes to the other party of the parent and joins its thread. If that party has not claimed a handle, the reply stays in Carte and they get a notification email; they read it once they claim one.
// Reply to a message in your inbox or outbox. The recipient is the other
// party of that message, so there is no `to`.
const reply = await carte.reply(msg.id, {
type: "invoice_ack",
note: "Received, paying on the 30th.",
json: { invoice: 1042, status: "accepted" },
})
console.log(reply.thread_id, reply.in_reply_to, reply.to.party)
// Same thing through send()
await carte.send({ inReplyTo: msg.id, note: "Received." })Threads
A thread has exactly two parties and is flat. threads.get returns { thread_id, data, next_cursor, hidden } with data oldest first.
// Every message in the thread, oldest first
const thread = await carte.threads.get(msg.thread_id, { limit: 100 })
for (const m of thread.data) {
console.log(m.received_at, m.from.party.display_name, m.in_reply_to)
}
if (thread.next_cursor) {
await carte.threads.get(msg.thread_id, { cursor: thread.next_cursor })
}
// Or only what you received in that thread, newest first
await carte.inbox.list({ thread: msg.thread_id })Webhooks
const hook = await carte.webhooks.create({ url: "https://example.com/carte" })
console.log(hook.secret) // whsec_…, shown once
await carte.webhooks.test(hook.id)
const log = await carte.webhooks.deliveries(hook.id, { limit: 20 })Verify incoming deliveries with the raw body. See Webhooks for the header format.
import { verifyWebhookSignature } from "@carte/sdk"
export async function POST(req: Request) {
const body = await req.text()
const ok = await verifyWebhookSignature({
header: req.headers.get("carte-signature") ?? "",
body,
secret: process.env.CARTE_WEBHOOK_SECRET!,
// toleranceSec: 300 (default)
})
if (!ok) return new Response("invalid signature", { status: 400 })
const envelope = JSON.parse(body)
// ...
return new Response(null, { status: 204 })
}Errors
Every non-2xx response throws a CarteError. code is the API's error code, or http_error when the body is not JSON. A failed direct upload throws with code: "upload_failed".
import { CarteError } from "@carte/sdk"
try {
await carte.messages.get("msg_missing")
} catch (err) {
if (err instanceof CarteError) {
console.error(err.status, err.code, err.message, err.requestId)
}
}Methods
| Method | Endpoint |
|---|---|
carte.me() | GET /me |
carte.inbox.list(params?) | GET /inbox |
carte.outbox.list(params?) | GET /outbox |
carte.messages.get(id) | GET /messages/{id} |
carte.threads.get(threadId, { cursor?, limit? }) | GET /threads/{id} |
carte.files.url(fileId) | GET /files/{id} |
carte.send(params) | POST /uploads + PUT per file, then POST /messages |
carte.reply(messageId, params?) | POST /messages with in_reply_to |
carte.uploads.create({ filename, contentType, sizeBytes }) | POST /uploads |
carte.webhooks.list() | GET /webhooks |
carte.webhooks.create({ url }) | POST /webhooks |
carte.webhooks.get(id) | GET /webhooks/{id} |
carte.webhooks.delete(id) | DELETE /webhooks/{id} |
carte.webhooks.test(id) | POST /webhooks/{id}/test |
carte.webhooks.deliveries(id, { cursor?, limit? }) | GET /webhooks/{id}/deliveries |
carte.blocks.list() | GET /blocks |
carte.blocks.add({ userId?, emailDomain? }) | POST /blocks |
carte.blocks.remove(id) | DELETE /blocks/{id} |
carte.inboxSchema.get() | GET /inbox/schema |
carte.inboxSchema.set(schema | null) | PUT /inbox/schema |
carte.contacts.list({ q?, saved?, type?, cursor?, limit? }) | GET /contacts |
carte.contacts.get(id) | GET /contacts/{id} |
carte.contacts.byParty({ handle | domain }) | GET /contacts/by-party |
carte.contacts.save({ handle | domain, label?, notes? }) | POST /contacts |
carte.contacts.update(id, { label?, notes?, archived? }) | PATCH /contacts/{id} |
carte.handles.search(q, limit?) | GET /handles |
Python
There is no Python SDK yet. The API is plain HTTP, so requests is enough.
import os
import requests
API = "https://api.carte.sh/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['CARTE_API_KEY']}"}
# Send a message with a JSON body
r = requests.post(
f"{API}/messages",
headers={**HEADERS, "Idempotency-Key": "invoice-1042"},
json={
"to": "acme",
"type": "invoice",
"subject": "Invoice #1042",
"json": {"total": 1240.5, "currency": "USD"},
},
)
r.raise_for_status()
print(r.json()["receipt_url"])
# Send a file inline (<= 25 MB)
with open("export.csv", "rb") as f:
r = requests.post(
f"{API}/messages",
headers=HEADERS,
data={"to": "acme", "note": "August export"},
files={"file": ("export.csv", f, "text/csv")},
)
r.raise_for_status()
# Read the inbox
page = requests.get(
f"{API}/inbox", headers=HEADERS, params={"type": "invoice", "limit": 50}
).json()
for m in page["data"]:
print(m["id"], m["from"]["party"]["display_name"], m["subject"])
# Reply to the first one; the recipient is derived from the parent
r = requests.post(
f"{API}/messages",
headers=HEADERS,
json={"in_reply_to": page["data"][0]["id"], "note": "Received."},
)
r.raise_for_status()
print(r.json()["thread_id"])