ThreadCamp
Start free
Reference

API reference

Base URL https://www.threadcamp.com/v1. Authenticate with a Bearer key. See the quickstart to get started, or grab the OpenAPI spec.

Idempotency

Pass a client_id on inbox creation and email sends. A repeat request with the same client_id returns the original resource (HTTP 200) instead of creating a duplicate.

Request IDs

Every response carries an x-request-id header, echoed as request_id inside error bodies. Include it when contacting support.

Rate limits

Every response returns x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (Unix seconds). Exceed the ceiling and you get 429 rate_limit_error with a retry-after header — pace off remaining rather than waiting for the 429.

Plan limits

Your plan sets a monthly send allowance plus inbox and custom-domain ceilings. Hitting one returns 402 quota_exceeded with a message naming the actual limit. On Pro and Scale, sends past the monthly allowance are not blocked — they continue as billable overage and the response carries overage: true. Current allowances: Free 1,000/mo, 3 inboxes, 1 domain; Pro 20,000/mo, 50 inboxes, 10 domains; Scale 100,000/mo, unlimited inboxes, unlimited domains. Machine-readable at /pricing.json.

Account and API keys

Everything here works without a browser. POST /v1/account is unauthenticated and returns a usable key immediately, so an agent can go from nothing to sending without a human clicking anything.

POST/v1/account

Create an account and its first API key. The key is returned exactly ONCE and cannot be recovered. Omit BOTH email and password for a machine account, authenticated only by its key - useful when the domain that would host a contact address does not exist yet. A machine account cannot sign in to the dashboard and has no password reset until you attach a real address; supplying only one of the two is a 400.

Request
# A machine account: no email, no password.
curl https://www.threadcamp.com/v1/account \
  -H "Content-Type: application/json" -d '{}'

# Or a normal account you can also sign into.
curl https://www.threadcamp.com/v1/account \
  -H "Content-Type: application/json" \
  -d '{ "email": "you@acme.com", "password": "at-least-8-chars", "key_mode": "live" }'
Response
{
  "account": { "id": "user_...", "object": "account",
               "email": "user_...@machine.threadcamp.invalid", "plan": "free" },
  "api_key": { "id": "key_...", "mode": "test", "key": "sk_test_..." },
  "machine_account": true,
  "message": "Machine account: this API key is the only credential and cannot be recovered. Store it now."
}
GET/v1/me

Who this key belongs to, and what it can do. Worth calling at startup: it is how you prove you are holding the RIGHT product's key and that it is live rather than test, before sending anything a customer sees. domains and inboxes come back null (not empty) if the mail engine cannot be reached, because an empty list would read as a verification failure.

Request
curl https://www.threadcamp.com/v1/me \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "object": "identity",
  "account": { "id": "user_...", "email": "hello@acme.com", "plan": "pro" },
  "api_key": { "id": "key_...", "name": "Production", "mode": "live", "prefix": "sk_live_abc123" },
  "plan": { "id": "pro", "limits": { "emailsPerMonth": 50000 }, "overage_per_1k": 1.2 },
  "usage": { "emails_this_month": 812, "emails_included": 50000, "metered_overage": true },
  "relay_domain": "relay.threadcamp.com",
  "domains": [ { "name": "acme.com", "status": "verified", "sending_ready": true } ],
  "inboxes": { "count": 3, "default": { "address": "hello@acme.com" } }
}
PATCH/v1/account

Change the account's contact address. Use this (or set_as_account_email on POST /v1/inboxes) to move a machine account onto a real address once your domain is verified.

Request
curl -X PATCH https://www.threadcamp.com/v1/account \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "email": "hello@acme.com" }'
Response
{ "id": "user_...", "object": "account", "email": "hello@acme.com", "plan": "pro" }
POST/v1/api-keys

Create another key. Body: name?, mode? (live | test, default test). The raw key is returned exactly once. GET /v1/api-keys lists safe metadata; PATCH renames; DELETE revokes. Secrets and modes are immutable.

Request
curl https://www.threadcamp.com/v1/api-keys \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "CI", "mode": "test" }'
Response
{ "id": "key_...", "object": "api_key", "name": "CI",
  "mode": "test", "prefix": "sk_test_abc123", "key": "sk_test_..." }

Inboxes

Create and manage addressable inboxes. Inboxes mint on the shared relay.threadcamp.com domain by default; pass a verified custom domain for your own. When someone replies to mail from an inbox, the reply is ALWAYS stored and readable via /v1/messages - forward_to also sends it to a human, and callback_url also sends it to your app.

POST/v1/inboxes

Create an inbox. Body: display_name?, username?, domain?, autonomy?, callback_url?, forward_to?, set_as_account_email?. Returns webhook_secret ONCE (signs email.received deliveries to callback_url). forward_to sends replies on to an off-platform address - it is stored as a Route, so SRS rewriting and loop guards apply and an inbox can never accumulate two conflicting forwards. set_as_account_email adopts the new address as the account contact in the same call, which is how a machine account moves off its placeholder address once your domain is verified.

Request
curl https://www.threadcamp.com/v1/inboxes \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "display_name": "Support Agent", "autonomy": "approve-first" }'
Response
{
  "id": "inbox_...",
  "object": "inbox",
  "address": "support-agent@relay.threadcamp.com",
  "display_name": "Support Agent",
  "autonomy": "approve-first",
  "callback_url": null,
  "is_default": false,
  "status": "active",
  "webhook_secret": "whsec_..."   // shown once
}
GET/v1/inboxes

List the caller's inboxes (your default inbox is created on first list).

Request
curl https://www.threadcamp.com/v1/inboxes \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "object": "list",
  "data": [
    { "id": "inbox_...", "object": "inbox",
      "address": "support-agent@relay.threadcamp.com",
      "autonomy": "approve-first", "status": "active" }
  ]
}
GET/v1/inboxes/:id

Fetch a single inbox by id.

Request
curl https://www.threadcamp.com/v1/inboxes/inbox_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "id": "inbox_123",
  "object": "inbox",
  "address": "support-agent@relay.threadcamp.com",
  "autonomy": "approve-first",
  "callback_url": null,
  "status": "active"
}
PATCH/v1/inboxes/:id

Update an inbox: status (active|paused), callback_url, display_name, autonomy.

Request
curl -X PATCH https://www.threadcamp.com/v1/inboxes/inbox_123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "callback_url": "https://acme.com/hooks/email" }'
Response
{
  "id": "inbox_123",
  "object": "inbox",
  "address": "support-agent@relay.threadcamp.com",
  "callback_url": "https://acme.com/hooks/email",
  "status": "active"
}
DELETE/v1/inboxes/:id

Delete an inbox. Your default inbox cannot be deleted (400).

Request
curl -X DELETE https://www.threadcamp.com/v1/inboxes/inbox_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{ "id": "inbox_123", "object": "inbox", "deleted": true, "address": "support-agent@relay.threadcamp.com" }
GET/v1/inboxes/:id/messages

List messages in an inbox. Optional ?direction=inbound|outbound.

Request
curl "https://www.threadcamp.com/v1/inboxes/inbox_123/messages?direction=inbound" \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "object": "list",
  "inbox_id": "inbox_123",
  "data": [
    { "id": "msg_...", "object": "message", "direction": "inbound",
      "from": "noreply@service.com", "subject": "Your code is 123456",
      "status": "received", "created_at": "..." }
  ]
}

Messages, threads and search

Read mail: list across every inbox, fetch one message, pull a whole conversation in a single call, or search subject and body full-text.

GET/v1/messages

List messages across all your inboxes, newest first. Filters: inbox_id, status, limit (max 200).

Request
curl "https://www.threadcamp.com/v1/messages?status=received&limit=20" \
  -H "Authorization: Bearer sk_live_..."
Response
{ "object": "list", "data": [ { "id": "msg_123", "object": "message", ... } ] }
GET/v1/threads

List conversations, most recent activity first. Filters: inbox_id, limit.

Request
curl https://www.threadcamp.com/v1/threads \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "object": "list",
  "data": [
    { "id": "thr_123", "object": "thread", "subject": "Invoice question",
      "message_count": 4, "state": "open", "last_message_at": "..." }
  ]
}
GET/v1/threads/:id

One conversation with every message embedded, oldest first - the read to make before replying.

Request
curl https://www.threadcamp.com/v1/threads/thr_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "id": "thr_123",
  "object": "thread",
  "subject": "Invoice question",
  "message_count": 2,
  "messages": [
    { "id": "msg_1", "object": "message", "direction": "inbound", ... },
    { "id": "msg_2", "object": "message", "direction": "outbound", ... }
  ]
}
GET/v1/search

Full-text search across subject and body. `query` is required; filters: inbox_id, limit.

Request
curl "https://www.threadcamp.com/v1/search?query=invoice" \
  -H "Authorization: Bearer sk_live_..."
Response
{ "object": "list", "query": "invoice", "data": [ { "id": "msg_123", ... } ] }
GET/v1/attachments/:id

Signed download URL for an attachment on one of your messages. Valid 5 minutes; fetch it directly rather than storing it.

Request
curl https://www.threadcamp.com/v1/attachments/att_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "id": "att_123",
  "object": "attachment",
  "filename": "invoice.pdf",
  "url": "https://...signed...",
  "expires_in": 300
}
GET/v1/messages/:id

Fetch one message, including thread_id, body and status.

Request
curl https://www.threadcamp.com/v1/messages/msg_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "id": "msg_123",
  "object": "message",
  "thread_id": "thr_...",
  "inbox_id": "inbox_123",
  "direction": "inbound",
  "from": "noreply@service.com",
  "to": ["support-agent@relay.threadcamp.com"],
  "cc": [],
  "subject": "Your code is 123456",
  "text": "Your one-time code is 123456.",
  "html": null,
  "labels": [],
  "status": "received",
  "scheduled_at": null,
  "created_at": "..."
}

Emails (sending)

Resend-compatible send. Accepts the Resend payload plus scheduled_at (ISO), client_id (idempotency), thread_id and in_reply_to_message_id for real replies, force_approval for a stricter per-send hold, and attachments ([{ filename, content_base64, content_type }], 10MB total). `from` must be one of your inbox addresses - an unknown sender is refused, never rewritten. Sends from an approve-first inbox are held for human approval. Sending limits are enforced per-inbox daily; exceeding a cap returns the platform's 402.

POST/v1/emails

Send Markdown. `markdown` is rendered into BOTH an inline-styled HTML part and a matching plain-text part, so a link keeps its destination whichever part the recipient's client shows - passing the same markdown as `text` yourself is how "[Reset your password](https://...)" reaches a real inbox verbatim. `button` adds a real clickable call to action (it requires `markdown`; sent without it the request is refused rather than silently dropped). Explicit `html` or `text` wins over the derived value. Default styling is brand-neutral: a white card on light grey, system fonts, no webfont import. Pass `theme` to override.

Request
curl https://www.threadcamp.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reset-9f2c" \
  -d '{
    "from": "hello@acme.com",
    "to": ["someone@example.com"],
    "subject": "Reset your password",
    "markdown": "Hi there.\n\nTap the button below. The link expires in an hour.",
    "button": { "text": "Reset your password", "url": "https://acme.com/r/abc" }
  }'
Response
{
  "id": "msg_...",
  "object": "message",
  "status": "sent"
}

// With a sk_test_ key you get the RENDERED parts back instead of a delivery,
// so a rendering mistake is catchable before anyone receives it:
{
  "status": "test", "delivered": false,
  "html": "<div ...><a href=\"https://acme.com/r/abc\" ...>Reset your password</a>...",
  "text": "Hi there.\n\nTap the button below. The link expires in an hour.\n\nReset your password: https://acme.com/r/abc"
}
POST/v1/emails

Send or reply. Body: from (one of your inbox addresses), to, subject, text|html, cc?, bcc?, reply_to?, scheduled_at?, client_id?, thread_id?, in_reply_to_message_id?, force_approval?.

Request
curl https://www.threadcamp.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "support-agent@relay.threadcamp.com",
    "to": ["customer@example.com"],
    "subject": "Your receipt",
    "text": "Thanks for your order.",
    "scheduled_at": "2026-08-01T09:00:00Z",
    "client_id": "receipt-8891"
  }'
Response
// auto inbox, immediate -> 201
{ "id": "msg_...", "object": "message", "status": "sent",
  "thread_id": "thread_...", "created_at": "..." }

// approve-first inbox -> 202
{ "id": "msg_...", "status": "pending_approval", "approval_id": "msg_..." }

// future scheduled_at -> 202
{ "id": "msg_...", "status": "scheduled" }

// replayed client_id -> 200 with "idempotent": true
POST/v1/emails/batch

Send up to 100 emails in one call. Body: a JSON array of email objects (or { emails: [...] }). Each item runs the same pipeline as /v1/emails.

Request
curl https://www.threadcamp.com/v1/emails/batch \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '[
    { "from": "a@relay.threadcamp.com", "to": ["x@example.com"],
      "subject": "Hi", "text": "One" },
    { "from": "a@relay.threadcamp.com", "to": ["y@example.com"],
      "subject": "Hi", "text": "Two" }
  ]'
Response
{
  "object": "list", "sent": 2, "failed": 0,
  "data": [
    { "index": 0, "http_status": 201, "id": "msg_...", "status": "sent" },
    { "index": 1, "http_status": 201, "id": "msg_...", "status": "sent" }
  ]
}

Domains

Add a custom domain, publish the DNS records, verify. Verification is REAL and GRANULAR: DKIM is polled at the platform and every other record is resolved live, so the response names which record is missing, wrong or in conflict rather than only saying pending. Inbound mail lands on mail.yourdomain.com by default, so your normal email is untouched.

POST/v1/domains

Add a domain. Returns the DNS records to place at your DNS host. Optional inbound_host chooses which name receives replies (default mail.<name>); pass the root domain to opt into the root deliberately.

Request
curl https://www.threadcamp.com/v1/domains \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "acme-agents.email" }'
Response
{
  "id": "dom_...",
  "object": "domain",
  "name": "acme-agents.email",
  "status": "pending",
  "inbound_host": "mail.acme-agents.email",
  "sending_ready": null,       // null until you verify
  "receiving_ready": null,
  "dns_records": [
    // THREE DKIM CNAMEs, one per SES signing token
    { "type": "CNAME", "name": "xxxx._domainkey.acme-agents.email",
      "value": "xxxx.dkim.amazonses.com",
      "purpose": "dkim", "required": true },
    { "type": "TXT", "name": "acme-agents.email",
      "value": "v=spf1 include:amazonses.com ~all",
      "purpose": "spf", "required": true,
      "note": "If this domain already has a v=spf1 record, MERGE the include into it - a second v=spf1 record breaks SPF entirely." },
    { "type": "MX", "name": "mail.acme-agents.email",
      "value": "inbound-smtp.eu-west-2.amazonaws.com",
      "priority": 10, "purpose": "mx", "required": false,
      "note": "Receives replies, on a dedicated subdomain so your normal mail is untouched. Optional: without it, replies fall back to the shared relay." },
    { "type": "TXT", "name": "_dmarc.acme-agents.email",
      "value": "v=DMARC1; p=quarantine;",
      "purpose": "dmarc", "required": false }
  ],
  "created_at": "...",
  "verified_at": null
}
GET/v1/domains

List the caller's domains.

Request
curl https://www.threadcamp.com/v1/domains \
  -H "Authorization: Bearer sk_live_..."
Response
{ "object": "list", "data": [
  { "id": "dom_...", "object": "domain", "name": "acme-agents.email",
    "status": "verified", "sending_ready": true, "receiving_ready": true,
    "inbound_host": "mail.acme-agents.email", "verified_at": "..." }
] }
GET/v1/domains/:id

Read one domain and its records WITHOUT re-running verification. Use this when polling for readiness; /verify re-hits DNS on every call.

Request
curl https://www.threadcamp.com/v1/domains/dom_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{ "id": "dom_123", "object": "domain", "status": "verified",
  "sending_ready": true, "receiving_ready": true }
POST/v1/domains/:id/verify

Run a real verification check. Read each record's status and found - that is what tells you WHICH record is wrong. `conflict` means publishing ours would break something. `error` means the lookup failed, which is not the same as the record being missing.

Request
curl -X POST https://www.threadcamp.com/v1/domains/dom_123/verify \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "id": "dom_123",
  "object": "domain",
  "status": "pending",
  "sending_ready": false,
  "receiving_ready": false,
  "dns_records": [
    { "purpose": "dkim", "name": "xxxx._domainkey.acme-agents.email",
      "status": "verified", "found": ["xxxx.dkim.amazonses.com"] },
    { "purpose": "dkim", "name": "yyyy._domainkey.acme-agents.email",
      "status": "missing", "found": [] },
    { "purpose": "spf", "name": "acme-agents.email",
      "status": "conflict",
      "found": ["v=spf1 include:_spf.google.com ~all"],
      "detail": "This domain already sends mail through another service. Add \"include:amazonses.com\" to the EXISTING record - do not publish a second v=spf1 record, which would break SPF entirely." },
    { "purpose": "mx", "name": "mail.acme-agents.email",
      "status": "verified", "found": ["10 inbound-smtp.eu-west-2.amazonaws.com"] }
  ]
}
DELETE/v1/domains/:id

Stop using a domain. The shared SES identity is deliberately left in place, because deleting it could break another tenant that registered the same domain.

Request
curl -X DELETE https://www.threadcamp.com/v1/domains/dom_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{ "id": "dom_123", "object": "domain", "deleted": true }

Routes (forwarding)

Catch-all and alias forwarding rules to an OFF-platform address. Forwarding is real: SRS envelope rewriting and loop guards are applied automatically so SPF and DMARC survive.

POST/v1/routes

Create a Route. Body: match, destination, inbox_id?. destination must be an off-platform address.

Request
curl https://www.threadcamp.com/v1/routes \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "match": "*@acme-agents.email",
    "destination": "team@acme.com"
  }'
Response
{
  "id": "route_...",
  "object": "route",
  "match": "*@acme-agents.email",
  "destination": "team@acme.com",
  "enabled": true,
  "created_at": "..."
}
GET/v1/routes

List forwarding rules.

Request
curl https://www.threadcamp.com/v1/routes \
  -H "Authorization: Bearer sk_live_..."
Response
{ "object": "list", "data": [
  { "id": "route_...", "object": "route", "match": "*@acme-agents.email",
    "destination": "team@acme.com", "enabled": true }
] }
PATCH/v1/routes/:id

Enable or disable a Route. Body: enabled (boolean).

Request
curl -X PATCH https://www.threadcamp.com/v1/routes/route_123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'
Response
{ "id": "route_123", "object": "route", "enabled": false, ... }
DELETE/v1/routes/:id

Delete a Route.

Request
curl -X DELETE https://www.threadcamp.com/v1/routes/route_123 \
  -H "Authorization: Bearer sk_live_..."
Response
{ "id": "route_123", "object": "route", "deleted": true }

Webhooks (per-inbox)

Account-level webhooks are retired. Webhooks are per-inbox: set callback_url on an inbox and inbound mail POSTs an HMAC-signed payload there. The signing webhook_secret is returned ONCE at inbox creation. Events: email.received, email.received.platform_trigger, email.bounced, email.complained, mail.approval.resolved. Delivery is attempted 3 times with a 0s / 1s / 4s backoff and a 10s timeout; a 3xx counts as non-delivery.

PATCH/v1/inboxes/:id

Point an inbox's callback_url at your endpoint to start receiving email.received deliveries.

Request
curl -X PATCH https://www.threadcamp.com/v1/inboxes/inbox_123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "callback_url": "https://acme.com/hooks/email" }'
Response
{
  "id": "inbox_123",
  "object": "inbox",
  "callback_url": "https://acme.com/hooks/email",
  "status": "active"
}
VERIFYX-ThreadCamp-Signature

Every delivery is signed. Verify it against the RAW request body BEFORE parsing JSON: re-serialising a parsed object changes the bytes and the check will fail. The timestamp guards replay. @threadcamp/sdk exports verifyWebhook and parseWebhook, which implement all of this - prefer them to rolling your own.

Request
import { createHmac, timingSafeEqual } from "node:crypto";

// Header: X-ThreadCamp-Signature: t=<unix seconds>,v1=<hex hmac-sha256>
// Signed payload: `${t}.${rawBody}`   Key: the inbox's webhook_secret
function verify(rawBody, header, secret) {
  const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? "");
  if (!m) return false;
  const t = Number(m[1]);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;   // replay window
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(m[2], "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Or, with the SDK:
import { parseWebhook } from "@threadcamp/sdk";
const payload = await parseWebhook(rawBody, req.headers["x-threadcamp-signature"], secret);
Response
{
  "event": "email.received",
  "inbox_id": "inbox_123",
  "thread_id": "thr_...",
  "correlation_token": "a1b2c3...",
  "from": "customer@example.com",
  "to": "hello@acme.com",
  "subject": "Re: Reset your password",
  "text": "I did not request this",
  "html": "...",
  "message_id": "<...>",
  "in_reply_to": "<...>",
  "received_at": "..."
}

Approvals (human-in-the-loop)

The queue behind approve-first inboxes. A held send waits here until a human approves (which executes the send) or rejects it.

GET/v1/approvals

List approvals. Optional ?status=pending.

Request
curl "https://www.threadcamp.com/v1/approvals?status=pending" \
  -H "Authorization: Bearer sk_live_..."
Response
{ "object": "list", "data": [
  { "object": "approval", "id": "...", "status": "pending", ... }
] }
POST/v1/approvals/:id

Resolve a pending approval. Body: action (approve|reject). Approving executes the held send.

Request
curl https://www.threadcamp.com/v1/approvals/apr_123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "action": "approve" }'
Response
{
  "object": "approval",
  "id": "apr_123",
  "status": "approved"
}

Suppressions

Addresses ThreadCamp will not send to. Hard bounces, complaints and unsubscribes land here automatically; you can add and remove entries yourself. Sending to a suppressed address is skipped rather than attempted, and the send response reports it.

GET/v1/suppressions

List suppressed addresses and why each one is suppressed.

Request
curl https://www.threadcamp.com/v1/suppressions \
  -H "Authorization: Bearer sk_live_..."
Response
{ "object": "list", "data": [
  { "address": "bounced@example.com", "reason": "bounce" },
  { "address": "left@example.com", "reason": "unsubscribe" }
] }
POST/v1/suppressions

Stop sending to an address.

Request
curl https://www.threadcamp.com/v1/suppressions \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "address": "someone@example.com" }'
Response
{ "address": "someone@example.com", "reason": "manual" }
DELETE/v1/suppressions/:address

Allow sending to a previously suppressed address again. Do this only when you know why it was suppressed: re-sending to a hard bounce or a complaint damages your sending reputation.

Request
curl -X DELETE https://www.threadcamp.com/v1/suppressions/someone%40example.com \
  -H "Authorization: Bearer sk_live_..."
Response
{ "deleted": true }

Events

The inspectable activity log, DERIVED from your message records: each message becomes one event (message.received / message.pending_approval / message.sent). There is no separate event store - this is exactly your message history, shaped as events.

GET/v1/events

List recent events (derived from messages). Optional ?limit=N (default 50, max 200).

Request
curl "https://www.threadcamp.com/v1/events?limit=20" \
  -H "Authorization: Bearer sk_live_..."
Response
{ "object": "list", "data": [
  { "id": "evt_msg_...", "object": "event", "type": "message.sent",
    "data": { "message_id": "msg_...", "inbox_id": "inbox_...",
      "subject": "...", "from": "...", "to": ["..."] },
    "created_at": "..." }
] }

Errors

Every non-2xx response has a stable, machine-parseable shape. Branch on error.type; show error.message to humans; log error.request_id. error.param names the offending field on validation errors.

{
  "error": {
    "type": "invalid_request_error",
    "message": "`from` is required.",
    "param": "from",
    "request_id": "req_...",
    "docs_url": "https://www.threadcamp.com/docs/api"
  }
}
error.typeHTTPWhen
authentication_error401Missing or invalid API key.
invalid_request_error400A required field is missing or malformed.
not_found404The referenced resource does not exist.
conflict409The resource already exists or is in a conflicting state.
quota_exceeded402A plan limit was reached.
rate_limit_error429Too many requests - back off and retry.
service_unavailable503A dependency is temporarily unavailable.

Ship your first call.

Try it with the public sandbox key, then sign up for a live key.

Start freeMCP server