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

sh
bun add @carte/sdk
# or
npm i @carte/sdk

Setup

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

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

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

ts
// 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.

ts
// 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.

ts
// 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

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

ts
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".

ts
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

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

python
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"])