npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

colding

v0.5.0

Published

TypeScript SDK and webhook verification for colding: email tools for agents, from mailboxes you connect.

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 colding

First 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, alongside operate. A test from a transactional mailbox needs both send and transactional.
  • 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 402 PLAN_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 (with read) 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 need read; mailbox-batch reads need setup.

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.scope keyReads, and there is no live event stream for it. events.list shows it only the events of those mailboxes: an event with no data.mailboxId is 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 403 keyReads;
  • 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 to transactional starts 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 errorCode key_stream_scope, key_mailbox_scope or key_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_full or key_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.get says so: sendingWait is { blockedBy: "key_day", resetsAt }. resetsAt is 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 without setup or for a Microsoft or Google sign-in.
  • update_mailbox_credentials: an owner replaces its logins; include its mailboxId. 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 its mailboxId (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 without setup, ask the owner to turn a send-only mailbox's bounce and complaint reports on, replace their secret or turn them off; include mailboxId and say which change in note. 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 with colding.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 the campaignId and the lead's contactId, and say in note why 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 reads cancelled when they decline. Read the lead with colding.campaigns.getLead(...): its sendAnyway says 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 without setup. 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 without setup and read or when a person's webhook is already saved (409 webhookSavedByPerson).
  • manage_settings: an owner or admin saves the workspace's postal address in Settings from the link, for a key without setup. 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 either mailboxId (a connected mailbox's required SPF and DMARC, or DMARC alone for a send-only mailbox) or trackingDomainId (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 with colding.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 403 AUP_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 in note, then read the result with colding.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 in note. When that change creates an address, resultMailboxId holds its id for colding.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_message and send_campaign_step_test: save and pass your own intentKey. 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: intentKey is required. A retry must use the identical request, including expiresAt. 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 secret throws bad_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). Pass toleranceSeconds to change that.
  • Delivery is at least once. The body's id is the delivery's id (also the x-colding-delivery header): skip one you already handled.
  • The "Send a test event" button in the console (or colding.webhooks.test()) sends one more body: type is "webhook.test", seq is 0 (a real event's starts at 1), and data is { test: true, message }, a fixed English sentence that is never translated. verifyWebhook accepts it like any delivery. It is not in events.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 seq is the number events.list shows. If a callback never came, read events.list from 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 handled

A 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_key

An 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 question

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