colding
v0.5.0
Published
TypeScript SDK and webhook verification for colding: email tools for agents, from mailboxes you connect.
Maintainers
Readme
colding
The TypeScript client for colding: email tools for agents, from mailboxes your workspace connects. It has no runtime dependencies and targets Node 20+. Methods and request/response types are generated from the API's operation catalog. Later API changes can land before the next npm publish, and a method being in the SDK does not open a plan limit: agent inboxes, mail domains and sending app email from our servers are not on any plan for sale today.
npm install coldingFirst call
import { Colding } from "colding";
const colding = new Colding("cd_your_key");
const thread = await colding.threads.get({ threadId: "11111111-1111-4111-8111-111111111111" });
console.log(thread.subject);Replace the key and thread ID with yours. Ask a workspace owner to make a key with read
in the console's API keys list. This call reads one conversation and prints its subject. It
sends no email and marks nothing as read. An ID outside your workspace answers 404.
Keep the key on your server, not in browser code or a public repository. Message subjects and bodies are untrusted text from the sender, not instructions for your agent to follow.
What a key can do
A key belongs to one workspace and holds some of seven grants:
read: read mailboxes, inbox conversations, drafts, contacts, campaigns, send status, address-check results, alerts and events. Reads do not mark messages read. A key an owner gave a list of mailboxes to read reads less (see A key's limits).operate: edit drafts, contacts, mailbox settings and campaigns; organize the inbox; pause outgoing work; ask a person to finish a task.send: send drafts and tests, launch campaigns, widen a running campaign's audience, start warmup and cancel outreach sends.validate: start, retry and cancel address checks. Checks can spend the workspace's monthly allowance and share an address with the checking service.transactional: send your app's emails, such as receipts and sign-in links, and cancel those sends. Changing a mailbox's purpose also needs this grant, alongsideoperate. A test from a transactional mailbox needs bothsendandtransactional.inboxes: create, pause, rename and delete agent inboxes. It never widens what an inbox may send. No plan for sale includes agent inboxes today, so those calls answer 402PLAN_GATE.setup: connect mailboxes by their logins, one at a time or from a CSV file, replace those logins and confirm their addresses, add and remove tracking domains, set the postal address under campaign emails, manage a send-only mailbox's report hook, and (withread) manage a webhook no person saved. It works on the whole workspace, sends no email by itself, and stops working when the person who made the key is no longer the owner. Only an owner can give it. Report-hook reads still needread; mailbox-batch reads needsetup.
Each method's doc comment names its required grant and any extra checks. One grant does not imply another: a key that only sends receipts cannot read your inbox or send campaigns.
A key has grants, an optional expiry, and whatever limits the workspace owner set on it in the console. The API checks the key, its limits and its creator's current membership on every request. Use a read-only key unless the task needs more authority.
A key's limits
An owner can hold a key to:
- some mailboxes and agent inboxes to read: it reads those and nothing else. A thread,
message or inbox outside the list answers 404, as if it did not exist. Contacts,
campaigns, alerts and the rest of the workspace answer 403 with
details.scopekeyReads, and there is no live event stream for it.events.listshows it only the events of those mailboxes: an event with nodata.mailboxIdis left out. It sends only from what it reads, so its mailbox list is the same list, and it holds no campaign list. A thread can hold mail from a mailbox the key can't read. The key sees only its own copies: marking the thread read and moving it change only them, and labels and snoozes answer 403keyReads; - some mailboxes: it sends from no other, and starts warmup on no other;
- some campaigns: it launches, adds leads to and sends through those only, and it can't create a campaign;
- one kind of email: outreach (
cold: campaigns, messages, tests) or your app's email (transactional). A reply to mail a transactional mailbox received is a message, so it counts as outreach. A key held totransactionalstarts no warmup; - a number of emails a day, counted from midnight in the workspace's time zone;
- a number of emails in all.
The two numbers count every email the key sent, and every email of a campaign the key launched. Warmup email is never counted. They only narrow: the plan's daily sends and each mailbox's own limits still apply, and several keys share the workspace's.
The limits on mailboxes, campaigns, kind and numbers hold sending, launching and starting
warmup. They don't hold the rest of what operate allows: a key held to one mailbox can
still pause or edit another. A list of mailboxes to read is the exception: it holds
reading too.
A key cannot read or change its own limits, and a refusal never says the number an owner set. What it gets is the refusal:
| The answer | details.scope | What to do |
|---|---|---|
| 403 FORBIDDEN | keyStream | The key may not send this kind of email. Ask the owner. |
| 403 FORBIDDEN | keyMailbox | Use another mailbox. details.mailboxIds lists the ones you named that the key may not use. |
| 403 FORBIDDEN | keyReads | The key reads only the mailboxes and agent inboxes an owner picked. Use one of them, or ask the owner. |
| 403 FORBIDDEN | keyCampaign | The key may not work on this campaign, or create one. Ask the owner. |
| 403 FORBIDDEN | keyTotal | The key has sent every email it may send. Waiting does not help. Ask the owner. |
| 429 QUOTA_EXCEEDED | keyDay | The key has sent its emails for today. Wait for details.resetAt, or the Retry-After header. |
An owner can change the limits while work waits:
- When the owner takes away a mailbox, a campaign or a kind of email, an email the key
already queued for it does not go out. It is cancelled with
errorCodekey_stream_scope,key_mailbox_scopeorkey_campaign_scope, and no attempt is spent. - A lower number stops new emails only. One already about to go out still goes. A scheduled
email books nothing until it is due, so it meets the number then, and can be cancelled
with
key_total_fullorkey_day_full. - A campaign a key launched pauses with
pausedReason: "approval_lapsed"when the key loses that campaign, one of its mailboxes or outreach, or reaches its total. It stays paused until someone launches it again, even if the owner raises the total. - On a day the key's daily number is spent, the campaign is not paused. It waits for the
workspace's next day, and
campaigns.getsays so:sendingWaitis{ blockedBy: "key_day", resetsAt }.resetsAtis when the key's day starts over, and the campaign sends again in its next sending window after that.
A per-key request-rate limit is separate: it counts requests, not emails.
A key never holds manage, the grant for protected human actions. Without setup, only a
signed-in owner connects a mailbox or replaces its logins. Only the owner disconnects one,
signs in with Microsoft or Google, or manages keys. A key with both setup and read can
set up the workspace's webhook when there is none, or change one a key saved. It cannot change a
webhook a person saved: ask the owner with a manage_webhook handoff. A person's save takes the
webhook over. An agent cannot give itself more grants. Team changes need a signed-in owner or admin;
changing an admin's role or transferring ownership needs the owner.
When you need a person
A handoff is a request with a console link. Your key needs operate to create or cancel
one and read to check its status. The link does not grant authority: the person must sign
in to the right workspace and be allowed to take that action.
const request = await colding.handoffs.create({
kind: "connect_mailbox",
note: "Please connect the sales mailbox",
});
console.log(`Ask the workspace owner to open ${request.url}`);
// Later, after the person has acted:
const now = await colding.handoffs.get({ handoffId: request.id });
if (now.status === "completed" && now.resultMailboxId) {
const mailbox = await colding.mailboxes.get({ mailboxId: now.resultMailboxId });
console.log(mailbox.email);
}The 14 current kinds are:
connect_mailbox: an owner connects a mailbox, for a key withoutsetupor for a Microsoft or Google sign-in.update_mailbox_credentials: an owner replaces its logins; include itsmailboxId. For Microsoft 365, the owner signs in with Microsoft instead of entering a password.confirm_mailbox_address: an owner confirms a connected mailbox's address when a key cannot read or send its code; include itsmailboxId(409 when already confirmed). The request completes when the address is confirmed by a code, a Microsoft or Google sign-in, a verified mail domain or our staff.manage_report_hook: for a key withoutsetup, ask the owner to turn a send-only mailbox's bounce and complaint reports on, replace their secret or turn them off; includemailboxIdand say which change innote. A person's successful change completes every pending, unexpired request for that mailbox, with or without its link. A setup key's change completes none. Read the result withcolding.mailboxes.reportHook({ mailboxId }), which never carries a secret. A mailbox with an inbox, or on a service we don't read reports from, answers 409.send_lead_anyway: a person who may send says that one lead's address gets mail, though its address check didn't confirm it. Include thecampaignIdand the lead'scontactId, and say innotewhy you expect it to get mail. No key can say this itself. The console opens the lead with your request on it. The request completes when the person sends the lead anyway, and readscancelledwhen they decline. Read the lead withcolding.campaigns.getLead(...): itssendAnywaysays who sent it and when. The request answers 409 for an address that is on the suppression list, was found invalid, has a check still running, or that the campaign already writes to.manage_tracking_domains: an owner or admin adds or removes a tracking domain in Settings, for a key withoutsetup. The request completes when a signed-in person adds or removes one.manage_webhook: the owner saves or deletes the workspace's webhook from the link, for a key withoutsetupandreador when a person's webhook is already saved (409webhookSavedByPerson).manage_settings: an owner or admin saves the workspace's postal address in Settings from the link, for a key withoutsetup. A time-zone-only save does not complete it.add_dns_records: a person who runs the domain's DNS publishes the required records when the agent has no DNS access of its own. Include eithermailboxId(a connected mailbox's required SPF and DMARC, or DMARC alone for a send-only mailbox) ortrackingDomainId(a tracking CNAME), never both. Only our own fresh passing DNS read completes it.manage_team: an owner or admin acts in Settings, within their role's permissions. A completed team request has no result mailbox.manage_billing: the owner chooses a plan or opens the billing portal. The request completes when they open Stripe's page, not when they pay: read the plan withcolding.billing.get(), which changes only once Stripe confirms the payment.accept_aup: the owner accepts the current acceptable use policy after a call was refused with 403AUP_REQUIRED. Accepting completes every pending request of this kind, so retry the refused call then. It has no result mailbox, and a workspace that already accepted answers 409.manage_mail_domains: the owner adds or removes a mail domain from the link; say which domain and use innote, then read the result withcolding.mailDomains.list(). Mail domains are not on any plan for sale today.manage_sender_addresses: the owner creates, changes or deletes a sender address on a verified mail domain from the link; say which address and where replies go innote. When that change creates an address,resultMailboxIdholds its id forcolding.senderAddresses.get(...). Sending app email from our servers is not on any plan for sale today.
A pending request expires after 24 hours. handoffs.cancel withdraws it. If it expires,
create a new request. For protected actions without a handoff kind, such as key management,
ask the owner to act in the console; do not invent a kind or put secrets in note.
Address checks and quotas
Read colding.emailChecks.setup() before starting checks. It reports whether checks work,
this month's usage and limit, and when the month resets. Reads need read; starting or
retrying checks needs validate. Retrying an unanswered paid check can spend another check.
A pending check is not a verdict: use emailChecks.get or emailChecks.getRun to follow it.
With validate, creating or importing contacts can start checks automatically. Pass
checkAddresses: false to skip those checks. Launching a campaign can also start checks:
checkLeads: false skips only the launch's batch. A running campaign can keep checking
waiting leads if the person or key that approved it still has validate. Pausing removes
it from future automatic check scans. Checks already selected, queued or on hold can still
spend quota.
Launching an already active campaign does not change who approved it. To change the API key
used for future automatic checks, pause with operate, read the campaign's current revision
with read, then relaunch that revision with the desired API key. That key needs send;
leave out validate to prevent future automatic checks. Use a new HTTP request key for the
relaunch. A new SDK call creates one unless you pass your own. Reusing the earlier request
key can replay its answer instead of changing the approval. Existing checks are not cancelled.
A run is a batch of address checks. Use emailChecks.cancelRun with validate to cancel
one that has not finished. It cancels checks not yet dispatched (sent to the checking service),
unless another uncancelled run or a separate address-check request still wants them.
Calls already dispatched cannot be recalled. Cancelling a finished run changes nothing.
Checks held as retries_exhausted (no answer after their retries) can resume later and
use quota, even if their run says it finished.
A passing check does not prove ownership, consent or delivery. Suppression and send gates still apply. Act on the error code, not just the HTTP status:
| Code | What to do |
|---|---|
| RATE_LIMIT_EXCEEDED (429) | Slow down. Wait for retryAfter or details.resetAt, then retry the same request. |
| QUOTA_EXCEEDED (429) | Stop until details.resetAt. Read details.scope to see which allowance ran out. Monthly address checks reset on the 1st at 00:00 UTC. With testDay, only tests to outside addresses wait: a test to your own address (a mailbox address confirmed as the workspace's, or a member's sign-in email) still goes. A new key does not refill a workspace allowance. |
| PLAN_GATE (402) | Read details.upgradeTo. A plan id: ask the owner for that plan (billing.get() says how this workspace's plan changes). null: no plan change lifts it, so write to support. A count at its limit also clears once you make room, such as pausing a campaign or disconnecting a mailbox. The same call again, with nothing changed, gets the same answer. |
| PAYMENT_REQUIRED (402) | Ask the owner to fix the failed payment. Changing the request or key does not fix it. |
| UNAUTHORIZED / INVALID_TOKEN (401) | Check that you sent the full cd_ key, not a sign-in token. Ask the owner for a new key if it expired or was revoked. |
| FORBIDDEN (403) | Read messageKey and params. Ask the owner for the needed grant, or ask the authorized person to take the protected action. A details.scope that starts with key is one of the key's own limits (see A key's limits). |
| AUP_REQUIRED (403) | The owner hasn't accepted the current acceptable use policy (details.version). With the operate grant, create a handoff with kind accept_aup and give the owner its url; without it, ask the owner to accept it in Settings. Retrying before they accept gets the same answer. Work already running keeps going. |
| CONFLICT (409) | Read the reason. For a stale revision, reload and review the changes before trying again. For an in-progress replay claim, wait as instructed. |
Limits come from the workspace's effective plan and mailbox settings. Several keys share those allowances. A checking-service outage leaves checks waiting or unanswered, not invalid; read the setup and check status before retrying checks that may spend again.
Errors
A refused call throws a ColdingError. Act on code, not the English message:
import { ColdingError } from "colding";
try {
await colding.threads.get({ threadId: "11111111-1111-4111-8111-111111111111" });
} catch (error) {
if (error instanceof ColdingError && error.code === "NOT_FOUND") {
console.log("That thread is not in this workspace. Pick an ID from threads.list().");
} else throw error;
}Every ColdingError carries code, status and message. When available, it also carries
messageKey, params, details, retryAfter in seconds and requestId. Include the request
ID when asking support about a call.
The client also raises NETWORK_ERROR or TIMEOUT when no answer arrives, and
INVALID_RESPONSE when a successful HTTP response is not the API's JSON answer, such as a
proxy's page. A write may already have run. INVALID_RESPONSE is not automatically retried;
its details holds up to 500 characters of the response body. Recover with the saved request
key and, for mail, the original send intent. Do not create a new email because a call timed out.
Retries and request replay
A write carries an HTTP Idempotency-Key, an ID for that one request. The SDK makes a fresh
key for each new method call, unless you pass your own. Automatic retry attempts within
one call reuse its key and arguments. Calling the method again is not the same HTTP request
unless you pass the saved key.
The client makes up to maxRetries extra attempts (default 2) after no answer, a 408, 425,
429 or 5xx, when the retry rule permits it. It also retries a 409 with a stated wait, such
as an in-progress replay claim. Backoff is about 1 second, then 2, with random variation.
A stated Retry-After, in seconds or as a date, is a minimum wait. A wait over 10 seconds
returns the error without another automatic attempt, with retryAfter set.
PLAN_GATE, PAYMENT_REQUIRED, QUOTA_EXCEEDED and an explicit
details.retryAfterSecs: null are never automatically retried. Neither are ordinary 4xx
refusals, including a 409 without a stated wait, or INVALID_RESPONSE.
Save your own request key before a write if your process may restart or you may retry later:
await colding.mailboxes.update(
{ mailboxId, dailyCap: 40 },
{ idempotencyKey: jobId },
);The API can replay saved answers within the 24-hour request window. The same workspace,
operation, request key and input get the stored answer, not a fresh view of the row. The same
key on another operation names another request. Changed input on the same operation and key
answers IDEMPOTENCY_KEY_REUSED (422). Use a new key only for a genuinely new request.
Before replay, the API checks the account, key and current workspace membership. If the
operation requires a signed-in owner, it checks that too. It also checks that you hold one
of the grants listed for the operation. It does not repeat extra checks inside the action.
For example, a stored test-send answer is not checked again for the transactional grant
its mailbox required.
The API may return success even if it could not save the answer. The request then stays
marked as running. If the action fails, the API tries to release its claim (the record marking
the request as running). A retry can take over an unanswered claim after 15 minutes and run
the action again, even if the first write succeeded. After the 24-hour replay window, the
request can also run again. HTTP replay alone does not prevent a second email. For mail,
retry with the same intentKey or the draft's exact ID and revision.
Send intent is separate
A send intent names one email in the send ledger. It remains bound to that send beyond the 24-hour HTTP replay window:
send_test_messageandsend_campaign_step_test: save and pass your ownintentKey. Without it, each time the action runs it picks a new intent and can send another test email, even when you reuse the HTTP request key.send_transactional_message:intentKeyis required. A retry must use the identical request, includingexpiresAt. Reusing the intent for another email answers 422.send_draft: the draft ID and exact revision name its intent. Retry that revision, not a new draft or an edited version. The send call approves that version; a preview sends nothing.
Keep the same send intent across REST, MCP, process restarts and later SDK calls. A fresh
HTTP key does not create another send when the original intent is kept. To read current
send status, use sends.get; a replayed HTTP answer can still show the original reservation.
The worker separately claims the send row once before delivery. An unknown outcome means
we cannot tell whether the server accepted it; it counts as sent and is never resent.
Request replay, send intent and the worker's claim solve three different duplicate problems.
Check a webhook
import { verifyWebhook } from "colding";
const event = await verifyWebhook({ payload: rawBody, signature, secret });payload is the request body exactly as it arrived, signature is the
x-colding-signature header, and secret is the whsec_ secret you were shown when the
webhook was saved. You get the body back, typed by type, or an error. It uses Web Crypto,
so it runs in Node, Bun, Deno and edge runtimes.
import { ColdingWebhookError, verifyWebhook } from "colding";
try {
const event = await verifyWebhook({ payload: rawBody, signature, secret });
if (event.type === "campaign.finished") console.log(event.data.campaignId);
} catch (error) {
if (error instanceof ColdingWebhookError) return new Response(error.code, { status: 400 });
throw error;
}You can try it with no network. Save this as webhook.mjs in the folder where you installed
colding. It signs a body the way colding does, then checks it:
import { createHmac } from "node:crypto";
import { verifyWebhook } from "colding";
const secret = "whsec_" + "0".repeat(64);
const payload = JSON.stringify({
id: "delivery-1",
type: "campaign.finished",
seq: 1,
timestamp: "2026-10-02T00:00:00.000Z",
data: { campaignId: "campaign-1" },
});
const t = Math.floor(Date.now() / 1000);
const mac = createHmac("sha256", secret).update(`${t}.${payload}`).digest("hex");
const event = await verifyWebhook({ payload, signature: `t=${t},v1=${mac}`, secret });
console.log(event.type, event.data.campaignId);Run node webhook.mjs. It prints:
campaign.finished campaign-1- Read the raw text of the body before any JSON parser touches it. A body that was parsed and
written back no longer matches its signature (
no_match). - An empty
secretthrowsbad_secret. That is your setup (an unset environment variable), not the delivery: answer 500, not 400. - A delivery older or newer than 5 minutes is refused (
too_old,too_new). PasstoleranceSecondsto change that. - Delivery is at least once. The body's
idis the delivery's id (also thex-colding-deliveryheader): skip one you already handled. - The "Send a test event" button in the console (or
colding.webhooks.test()) sends one more body:typeis"webhook.test",seqis0(a real event's starts at 1), anddatais{ test: true, message }, a fixed English sentence that is never translated.verifyWebhookaccepts it like any delivery. It is not inevents.list, it is sent once and never again, and it holds no mailbox, contact or message data. Answer 2xx and skip it. - The body's
seqis the numberevents.listshows. If a callback never came, readevents.listfrom the place you saved and you have it. Events are kept for at least 24 hours.
To pick up from where you stopped, save the page's resumeCursor once you have handled the
page, and pass it as cursor next time. Every page read oldest first has one, an empty page
too. Don't use nextCursor for this: it pages through one read, and it is null on a page
that isn't full.
const page = await colding.events.list({ cursor: savedCursor });
for (const event of page.items) console.log(event.seq, event.type);
savedCursor = page.resumeCursor ?? savedCursor; // save it once the page is handledA cursor belongs to one feed generation. After we restore from a backup, events.list
answers an old cursor with 409 feedResetRequired and details.resetRequired: true. Stop
acting on events, throw the cursor away, and check your mailboxes, sends and handoffs. Then
read again with no cursor. A send or an approval you can't find is not permission to do it
again: ask the owner when you can't confirm what happened.
Options
new Colding({
apiKey: "cd_your_key",
baseUrl: "https://api.colding.app/v1", // the default
timeout: 30_000, // per attempt, in milliseconds
maxRetries: 2, // 0 disables automatic retries
fetch: myFetch, // your fetch implementation, including an offline test double
});Each method also takes a last options argument for timeoutMs and signal; writes also
take idempotencyKey. TypeScript refuses a key on a read, and plain JavaScript gets a
TypeError before a request is made. Some operations wait longer than the default timeout;
timeoutMs overrides that wait for one call. Aborting stops the HTTP call and its retries,
not an email already recorded for sending; use sends.cancel while cancellation is allowed.
REST and MCP
The same workspace key works with REST at https://api.colding.app/v1 and stateless
Streamable HTTP MCP at https://api.colding.app/mcp. Send the key in this HTTP header on
every request:
Authorization: Bearer cd_your_keyAn MCP client can also sign in with OAuth (or a device code approved at the console's
/auth/device), which creates a workspace key with the permissions and limits the owner
chooses. This README makes no client-by-client compatibility claim. The tool list is filtered
by the key's grants, and each tool call checks authority again. MCP writes accept an optional
idempotencyKey argument: save and reuse it yourself. The JSON-RPC request ID does not replace
that key or a send intent.
The REST reference is at https://api.colding.app/openapi.json. REST, MCP and SDK calls
use the same operation executor, grants and gates; changing doors does not add authority.
Check the first read offline
You can try the first call with no key and no network. Save this as offline.mjs in the
folder where you installed colding:
import { Colding } from "colding";
const threadId = "11111111-1111-4111-8111-111111111111";
const colding = new Colding({
apiKey: "cd_offline",
// This function answers the call, so the client never reaches a server.
fetch: async (url, init) => {
console.log(init.method, url);
return Response.json({ data: { id: threadId, subject: "Invoice question" } });
},
});
const thread = await colding.threads.get({ threadId });
console.log(thread.subject);Run node offline.mjs. It prints the request the client made, then the subject it read:
GET https://api.colding.app/v1/threads/11111111-1111-4111-8111-111111111111
Invoice questionThe key is made up, and the fetch you pass answers instead of the API. No server is called
and no email is sent. Your own tests can pass a fetch the same way.
