| name | inkbox-ts |
| description | Use when writing TypeScript or JavaScript code that imports from `@inkbox/sdk`, uses `npm install @inkbox/sdk`, or when adding email, phone, text/SMS, iMessage, contacts, notes, contact rules, vault, tunnels, mailbox storage, mail clients (IMAP/SMTP), or agent identity features using the Inkbox TypeScript SDK. |
| user-invocable | false |
Inkbox TypeScript SDK
API-first communication infrastructure for AI agents — email, phone, encrypted vault, and identities.
Install & Init
npm install @inkbox/sdk
Requires Node.js ≥ 22. ESM module — no context manager needed:
import { Inkbox } from "@inkbox/sdk";
const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
Constructor options: { apiKey: string, baseUrl?: string, timeoutMs?: number }
Core Model
Inkbox (admin-only client)
├── .createIdentity(handle) → Promise<AgentIdentity>
├── .getIdentity(handle) → Promise<AgentIdentity>
├── .listIdentities() → Promise<AgentIdentitySummary[]>
├── .mailboxes → MailboxesResource
├── .phoneNumbers → PhoneNumbersResource
├── .texts → TextsResource
├── .imessages → IMessagesResource
├── .imessageContactRules → IMessageContactRulesResource
├── .mailIdentityContactRules → MailIdentityContactRulesResource (keyed by agentHandle)
├── .phoneIdentityContactRules → PhoneIdentityContactRulesResource (keyed by agentHandle)
├── .signingKeys → SigningKeysResource (per-identity: createOrRotate/getStatus)
├── .mailContactRules → MailContactRulesResource (DEPRECATED — per-mailbox)
├── .phoneContactRules → PhoneContactRulesResource (DEPRECATED — per-number)
├── .smsOptIns → SmsOptInsResource
├── .contacts → ContactsResource (.access, .vcards)
├── .notes → NotesResource (.access)
├── .vault → VaultResource
├── .whoami() → Promise<WhoamiResponse>
└── .createSigningKey() → Promise<SigningKey> (DEPRECATED — org-level; use .signingKeys)
AgentIdentity (identity-scoped helper)
├── .mailbox → IdentityMailbox | null
├── .phoneNumber → IdentityPhoneNumber | null
├── .mailFilterMode / .phoneFilterMode → FilterMode
├── .getCredentials() → Promise<Credentials> (requires vault unlocked)
├── .listAccess() → Promise<IdentityAccess[]>
├── .grantAccess(viewerId|null) → Promise<IdentityAccess>
├── .revokeAccess(viewerId) → Promise<void>
├── .listMailContactRules() / .createMailContactRule(...) / .get/.update/.delete
├── .listPhoneContactRules() / .createPhoneContactRule(...) / ... (requires phone number)
├── .getSigningKeyStatus() / .createSigningKey()
├── mail methods (requires assigned mailbox)
├── phone methods (requires assigned phone number)
└── text methods (requires assigned phone number)
An identity must have a channel assigned before you can use mail/phone methods. If not assigned, an InkboxError is thrown.
Agent Signup
For the full agent self-signup flow (register, verify, check status, restrictions, and direct API examples), read the shared reference:
See: skills/inkbox-agent-self-signup/SKILL.md
TypeScript SDK methods: Inkbox.signup({...}), Inkbox.verifySignup(apiKey, {...}), Inkbox.resendSignupVerification(apiKey), Inkbox.getSignupStatus(apiKey).
Identities
const identity = await inkbox.createIdentity("sales-agent");
const identity = await inkbox.getIdentity("sales-agent");
const identities = await inkbox.listIdentities();
await identity.update({ newHandle: "new-name" });
await identity.update({ status: "paused" });
await identity.refresh();
await identity.delete();
Channel Management
console.log(identity.emailAddress);
console.log(identity.tunnel?.publicHost);
const phone = await identity.provisionPhoneNumber({ type: "local", state: "NY" });
console.log(phone.number);
await identity.releasePhoneNumber();
Mailboxes and tunnels are not separately linkable — they are 1:1 with their owning identity. Use inkbox.createIdentity() to provision both; use identity.delete() to remove both (cascade).
Identity Visibility
Controls which other agent identities can see an identity in API responses. Humans and admins always see every identity.
const rules = await identity.listAccess();
await identity.grantAccess(viewer.id);
await identity.grantAccess(null);
await identity.revokeAccess(viewer.id);
Granting a viewer against an already-wildcard target raises RedundantContactAccessGrantError (409); revoking a non-existent grant raises InkboxAPIError (404).
Mail
Send
const sent = await identity.sendEmail({
to: ["user@example.com"],
subject: "Hello",
bodyText: "Hi there!",
bodyHtml: "<p>Hi there!</p>",
cc: ["cc@example.com"],
bcc: ["bcc@example.com"],
inReplyToMessageId: sent.id,
attachments: [{
filename: "report.pdf",
contentType: "application/pdf",
contentBase64: "<base64>",
}, {
filename: "chart.png",
contentType: "image/png",
contentBase64: "<base64>",
contentId: "chart",
}],
trackOpens: true,
});
Read
for await (const msg of identity.iterEmails()) {
console.log(msg.subject, msg.fromAddress, msg.isRead);
}
for await (const msg of identity.iterEmails({ direction: "inbound" })) {
...
}
for await (const msg of identity.iterUnreadEmails()) {
...
}
const ids: string[] = [];
for await (const msg of identity.iterUnreadEmails()) ids.push(msg.id);
await identity.markEmailsRead(ids);
await identity.markEmailsUnread(ids);
const thread = await identity.getThread(msg.threadId);
for (const m of thread.messages) {
console.log(`[${m.fromAddress}] ${m.subject}`);
}
Thread Folders
Threads carry a folder field: inbox, spam, archive, or blocked (server-assigned, never client-set).
import { ThreadFolder } from "@inkbox/sdk";
Low-level folder listing / per-thread updates (list({ folder }), listFolders(email), update(..., { folder })) live on ThreadsResource. Passing folder: "blocked" to update throws before the HTTP call.
Storage cap (402)
Every mailbox has a plan storage cap. All three send paths — sendEmail, replyAllEmail, and forwardEmail (and the inkbox.messages.* equivalents) — throw StorageLimitExceededError (HTTP 402) when the send would push the mailbox over it.
import { StorageLimitExceededError } from "@inkbox/sdk";
try {
await identity.sendEmail({ to: ["user@example.com"], subject: "Hi", bodyText: "…" });
} catch (e) {
if (e instanceof StorageLimitExceededError) {
console.log(e.message);
console.log(e.limitBytes);
console.log(e.upgradeUrl);
await inkbox.messages.delete(identity.emailAddress!, "<message-uuid>");
await inkbox.threads.delete(identity.emailAddress!, "<thread-uuid>");
}
}
Read usage off the mailbox (inkbox.mailboxes.get(...)): storageUsedBytes and storageLimitBytes (null = the server resolved no cap). The caps are binary — 2 GiB is 2 * 1024 ** 3 = 2,147,483,648 bytes, so divide by 1024 and label GiB/MiB, never GB.
Free plan: a footer is appended to the stored body of outgoing mail, so inkbox.messages.get(...) does not return byte-for-byte what you sent (a body-less send comes back with the footer as its body). Don't assert sentBody === fetchedBody on a Free plan.
Mail Clients (IMAP/SMTP)
An inbox can be attached to a regular mail client (Thunderbird, Apple Mail, mutt, …) with the API key you already have — there is no separate credential to create and no SDK call involved; the gateway speaks IMAP and SMTP, not HTTP.
| Setting | Value |
|---|
| IMAP host | imap.inkboxmail.com |
| IMAP port | 993 (IMAPS / implicit TLS) |
| SMTP host | smtp.inkboxmail.com |
| SMTP port | 465 (SMTPS / implicit TLS) or 587 (STARTTLS) |
| Username | the inbox address (e.g. sales-agent@inkboxmail.com) |
| Password | an identity-scoped API key (ApiKey_...) |
The password is the same agent-scoped key an identity-scoped Inkbox({...}) client authenticates with; mint one with inkbox.apiKeys.create({ scopedIdentityId }). Admin-scoped keys are rejected — one key maps to exactly one mailbox. Revoking the key revokes mail-client access.
Constraints that bite:
From must be the authenticated inbox address, and exactly one address — aliases / "send as" are rejected.
- On the Free plan, signed/encrypted mail (S/MIME, PGP) cannot be sent over SMTP — the required footer can't be injected without breaking the signature, so the send is refused. Send unsigned, or upgrade.
- Leave "save a copy of sent messages" on — Inkbox recognizes the client's copy as the message it already stored, so you get one Sent entry, charged against the storage cap once.
Full walkthrough: https://inkbox.ai/docs/capabilities/email/mail-clients
Phone
import { CallMode, IncomingCallAction } from "@inkbox/sdk";
const call = await identity.placeCall({
toNumber: "+15551234567",
clientWebsocketUrl: "wss://your-agent.example.com/ws",
});
console.log(call.status);
console.log(call.rateLimit.callsRemaining);
const hosted = await identity.placeCall({
toNumber: "+15551234567",
mode: CallMode.HOSTED_AGENT,
reason: "Confirm tomorrow's 3pm appointment; reschedule if needed.",
});
console.log(hosted.mode, hosted.reason);
const calls = await identity.listCalls({ limit: 10, offset: 0 });
for (const c of calls) {
console.log(c.id, c.direction, c.remotePhoneNumber, c.status, c.mode);
for (const item of c.postCallActionItems) {
console.log(` [${item.seq}] ${item.action}: ${item.details}`);
}
}
const segments = await identity.listTranscripts(calls[0].id);
for (const t of segments) {
console.log(`[${t.party}] ${t.text}`);
}
const hungUp = await identity.hangupCall(calls[0].id);
const cfg = await identity.getHostedAgentConfig();
await identity.setHostedAgentConfig({ instructions: "Be brief and friendly." });
await identity.setIncomingCallAction({
incomingCallAction: IncomingCallAction.HOSTED_AGENT,
});
console.log((await identity.getIncomingCallAction()).incomingCallAction);
Text Messages (SMS/MMS)
Outbound SMS limits and gates (current):
- Allowed only from local numbers, not toll-free.
- 100 recipient sends per phone number per rolling 24h. A 3-recipient group message counts as 3 recipient sends. A single accepted send may push usage past the cap; the next capped send returns
429 sender_rate_limited.
- New local numbers need ~10-15 min for 10DLC carrier propagation.
identity.phoneNumber.smsStatus is SmsStatus.PENDING until ready; sends in this window return 409 sender_sms_pending.
- Recipient must have texted
START to any number in the org. Unknown → 403 recipient_not_opted_in. STOP → 403 recipient_opted_out. Inspect / override consent state via inkbox.smsOptIns (see below).
- Beta: Group MMS and conversation sends are beta. Some carriers may reject group chats or MMS from 10DLC numbers even when the sender is ready and recipients have opted in.
Customer-managed 10DLC brands/campaigns lift the default per-number cap to the carrier-assigned tier. Toll-free SMS sending is still coming soon.
const sent = await identity.sendText({
to: "+15551234567",
text: "Hello from Inkbox",
});
console.log(sent.id, sent.deliveryStatus);
const group = await identity.sendText({
to: ["+15551234567", "+15557654321"],
text: "Hello group",
mediaUrls: ["https://example.com/photo.jpg"],
});
console.log(group.conversationId, group.recipients);
const reply = await identity.sendText({
conversationId: group.conversationId,
text: "Following up in the same conversation.",
});
const texts = await identity.listTexts({ limit: 20, offset: 0 });
for (const t of texts) {
console.log(t.id, t.direction, t.remotePhoneNumber, t.text, t.isRead);
}
const unread = await identity.listTexts({ isRead: false });
const text = await identity.getText("text-uuid");
console.log(text.type);
if (text.media) {
for (const m of text.media) {
console.log(m.contentType, m.size, m.url);
}
}
const convos = await identity.listTextConversations({ limit: 20, includeGroups: true });
for (const c of convos) {
console.log(c.id, c.participants, c.latestHasMedia, c.latestText);
}
const msgs = await identity.getTextConversation("+15551234567", { limit: 50 });
await identity.markTextRead("text-uuid");
const readResult = await identity.markTextConversationRead("+15551234567");
console.log(readResult.updatedCount);
const results = await inkbox.texts.search(phone.id, { q: "invoice", limit: 20 });
await inkbox.texts.update(phone.id, "text-uuid", { status: "deleted" });
iMessage
iMessage works differently from SMS: there is no per-identity iMessage number. Recipients connect to an agent identity through a small shared pool of numbers — they ask the triage line to connect them to @agent_handle, and that creates an assignment between that one recipient and the identity. Everything agent-facing is keyed by conversationId / remoteNumber; the shared local number is never exposed, and there is no cold outreach — you can only message recipients who connected first.
Discover the router (triage) line at runtime — it can change, so never hardcode it:
const triage = await inkbox.imessages.getTriageNumber();
console.log(triage.number, triage.connectCommand);
Reachability is opt-in per identity (imessageEnabled, default false):
const identity = await inkbox.createIdentity("my-agent", { imessageEnabled: true });
await identity.update({ imessageEnabled: true });
await identity.update({ imessageFilterMode: "whitelist" });
console.log(identity.imessageEnabled, identity.imessageFilterMode);
Messaging (identity convenience methods; inkbox.imessages is the org-level resource with the same operations plus agentIdentityId / isBlocked filters):
const sent = await identity.sendIMessage({ to: "+15551234567", text: "Hello over iMessage" });
const reply = await identity.sendIMessage({
conversationId: sent.conversationId,
text: "With style",
sendStyle: "slam",
});
console.log(sent.service, sent.status);
const msgs = await identity.listIMessages({ limit: 20, isRead: false });
const convos = await identity.listIMessageConversations({ limit: 20 });
const convo = await identity.getIMessageConversation(sent.conversationId);
console.log(convo.assignmentStatus);
const connections = await identity.listIMessageAssignments({ limit: 20 });
for (const a of connections) {
console.log(a.remoteNumber, a.status, a.createdAt);
}
await identity.sendIMessageReaction({ messageId: msgs[0].id, reaction: "like" });
for (const r of msgs[0].reactions ?? []) {
console.log(r.direction, r.reaction, r.customEmoji);
}
await identity.markIMessageConversationRead(sent.conversationId);
await identity.sendIMessageTyping(sent.conversationId);
const upload = await identity.uploadIMessageMedia({
content: await readFile("photo.jpg"),
filename: "photo.jpg",
contentType: "image/jpeg",
});
await identity.sendIMessage({ to: "+15551234567", mediaUrls: [upload.mediaUrl] });
Contact rules are scoped to the identity (not a phone number) because pool numbers are shared infrastructure:
import { IMessageRuleAction } from "@inkbox/sdk";
const rule = await inkbox.imessageContactRules.create("my-agent", {
action: IMessageRuleAction.BLOCK,
matchTarget: "+15559999999",
});
const rules = await inkbox.imessageContactRules.list("my-agent");
await inkbox.imessageContactRules.update("my-agent", rule.id, { status: "paused" });
await inkbox.imessageContactRules.delete("my-agent", rule.id);
const allRules = await inkbox.imessageContactRules.listAll();
Inbound messages and reactions arrive via identity-owned webhook subscriptions — see Webhooks below.
SMS Opt-Ins
Per-recipient SMS consent state, keyed by (your org, recipient number). The registry is updated automatically when recipients text START / STOP to any of your numbers (source: "sms"). Reads are admin-only; writes are admin-only and require your org to be on its own active, customer-managed 10DLC campaign (Inkbox-default-campaign orgs share consent state and get 409 customer_campaign_required on writes — source: "api" writes record an audit event).
import { SmsOptInStatus } from "@inkbox/sdk";
const rows = await inkbox.smsOptIns.list({ limit: 50 });
const optedOut = await inkbox.smsOptIns.list({ status: SmsOptInStatus.OPTED_OUT });
const row = await inkbox.smsOptIns.get("+15551234567");
console.log(row.status, row.source, row.optedInAt, row.optedOutAt);
await inkbox.smsOptIns.optIn("+15551234567");
await inkbox.smsOptIns.optOut("+15551234567");
Vault
Encrypted credential vault with client-side Argon2id key derivation and AES-256-GCM encryption. The server never sees plaintext secrets. Requires hash-wasm (included as a dependency).
Initialize
const result = await inkbox.vault.initialize("my-Vault-key-01!");
console.log(result.vaultId, result.vaultKeyId);
for (const code of result.recoveryCodes) {
console.log(code);
}
Unlock & Read
import type { LoginPayload, APIKeyPayload, SSHKeyPayload, OtherPayload } from "@inkbox/sdk";
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!");
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!", { identityId: "agent-uuid" });
for (const secret of unlocked.secrets) {
console.log(secret.name, secret.secretType);
console.log(secret.payload);
}
const secret = await unlocked.getSecret("secret-uuid");
const login = secret.payload as LoginPayload;
console.log(login.username, login.password);
Create & Update
await unlocked.createSecret({
name: "AWS Production",
description: "Production IAM user",
payload: { password: "s3cret", username: "admin", url: "https://aws.amazon.com" },
});
await unlocked.createSecret({
name: "GitHub PAT",
payload: { apiKey: "ghp_xxx" },
});
await unlocked.createSecret({
name: "Deploy Key",
payload: { privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----..." },
});
await unlocked.createSecret({
name: "Misc",
payload: { data: "any freeform content" },
});
await unlocked.updateSecret("secret-uuid", { name: "New Name" });
await unlocked.updateSecret("secret-uuid", {
payload: { password: "new", username: "new" },
});
await unlocked.deleteSecret("secret-uuid");
Metadata (no unlock needed)
const info = await inkbox.vault.info();
const keys = await inkbox.vault.listKeys();
const keys = await inkbox.vault.listKeys({ keyType: "recovery" });
const secrets = await inkbox.vault.listSecrets();
const secrets = await inkbox.vault.listSecrets({ secretType: "login" });
await inkbox.vault.deleteSecret("secret-uuid");
Payload Types
| Type | Interface | Fields |
|---|
login | LoginPayload | password, username?, email?, url?, notes? |
api_key | APIKeyPayload | apiKey, endpoint?, notes? |
key_pair | KeyPairPayload | accessKey, secretKey, endpoint?, notes? |
ssh_key | SSHKeyPayload | privateKey, publicKey?, fingerprint?, passphrase?, notes? |
other | OtherPayload | data |
secretType is immutable after creation. To change it, delete and recreate.
Agent Credentials (identity-scoped)
Agent-facing credential access — typed, identity-scoped. The vault stays as the admin surface; identity.getCredentials() is the agent runtime surface.
import type { Credentials } from "@inkbox/sdk";
await inkbox.vault.unlock("my-Vault-key-01!");
const identity = await inkbox.getIdentity("support-bot");
const creds = await identity.getCredentials();
const allCreds = creds.list();
const logins = creds.listLogins();
const apiKeys = creds.listApiKeys();
const sshKeys = creds.listSshKeys();
const keyPairs = creds.listKeyPairs();
const login = creds.getLogin("secret-uuid");
const apiKey = creds.getApiKey("secret-uuid");
const sshKey = creds.getSshKey("secret-uuid");
const keyPair = creds.getKeyPair("secret-uuid");
const secret = creds.get("secret-uuid");
- Requires
inkbox.vault.unlock() first — throws InkboxError if vault is not unlocked
- Results are filtered to secrets the identity has access to (via access rules)
- Cached after first call; call
identity.refresh() to clear the cache
get* throws Error if not found, TypeError if wrong secret type
One-Time Passwords (TOTP)
TOTP secrets are stored inside LoginPayload.totp in the encrypted vault. Codes are generated client-side — no server call needed.
From an agent identity (recommended)
import { parseTotpUri } from "@inkbox/sdk";
import type { LoginPayload } from "@inkbox/sdk";
const secret = await identity.createSecret({
name: "GitHub",
payload: {
username: "user@example.com",
password: "s3cret",
totp: parseTotpUri("otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"),
} satisfies LoginPayload,
});
const code = await identity.getTotpCode(secret.id);
console.log(code.code);
console.log(code.secondsRemaining);
await identity.setTotp(secretId, "otpauth://totp/...?secret=...");
await identity.removeTotp(secretId);
From the unlocked vault (admin-only)
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!");
await unlocked.setTotp(secretId, totpConfigOrUri);
await unlocked.removeTotp(secretId);
const code = await unlocked.getTotpCode(secretId);
TOTPCode fields
| Field | Type | Description |
|---|
code | string | The OTP code (e.g. "482901") |
periodStart | number | Unix timestamp when the code became valid |
periodEnd | number | Unix timestamp when the code expires |
secondsRemaining | number | Seconds until expiry |
Admin-only Resources
Mailboxes (inkbox.mailboxes)
const mailboxes = await inkbox.mailboxes.list();
const mailbox = await inkbox.mailboxes.get("abc@inkboxmail.com");
const updated = await inkbox.mailboxes.update(mailbox.emailAddress, {
filterMode: "whitelist",
});
if (updated.filterModeChangeNotice) {
const n = updated.filterModeChangeNotice;
console.log(n.redundantRuleCount, n.redundantRuleAction, n.newFilterMode);
}
console.log(mailbox.storageUsedBytes);
console.log(mailbox.storageLimitBytes);
const usedGib = mailbox.storageUsedBytes / 1024 ** 3;
const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "invoice", limit: 20 });
Custom email domains (inkbox.domains)
If your org has registered custom sending domains in the console, list them
and (admin-only) set the org default. New mailboxes inherit the org default
unless you pass sendingDomainId (standalone) or sendingDomain (identity).
import { SendingDomainStatus } from "@inkbox/sdk";
const verified = await inkbox.domains.list({ status: SendingDomainStatus.VERIFIED });
const newDefault = await inkbox.domains.setDefault("mail.acme.com");
await inkbox.createIdentity("sales-bot", { sendingDomain: "mail.acme.com" });
await inkbox.createIdentity("sales-bot-2", { sendingDomain: null });
Phone Numbers (inkbox.phoneNumbers)
const numbers = await inkbox.phoneNumbers.list();
const number = await inkbox.phoneNumbers.get("phone-number-uuid");
const num = await inkbox.phoneNumbers.provision({ agentHandle: "my-agent", type: "local", state: "NY" });
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "webhook",
incomingCallWebhookUrl: "https://...",
});
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "auto_accept",
clientWebsocketUrl: "wss://...",
});
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "hosted_agent",
});
const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote", limit: 50 });
await inkbox.phoneNumbers.release(num.id);
Phone numbers carry the same filterMode / agentIdentityId / filterModeChangeNotice fields as mailboxes; flipping filterMode here is the deprecated channel path (admin-only; returns a change-notice when the value actually changed). Prefer identity.update({ phoneFilterMode: "whitelist" }), which sets the mode on the identity and does not return a change notice.
Contact Rules
Allow/block lists are scoped to the agent identity (mirroring iMessage), addressed by agentHandle. The identity's mailFilterMode / phoneFilterMode decides whether each channel's rules act as a whitelist or blacklist. Mail matches by exact email or domain; phone matches by exact E.164 number. Returned rows are MailIdentityContactRule / PhoneIdentityContactRule, keyed by rule.agentIdentityId (not a mailbox/phone-number id).
import {
MailRuleAction, MailRuleMatchType, PhoneRuleAction, PhoneRuleMatchType,
DuplicateContactRuleError,
} from "@inkbox/sdk";
const identity = await inkbox.getIdentity("sales-agent");
const rule = await identity.createMailContactRule({
action: MailRuleAction.ALLOW,
matchType: MailRuleMatchType.DOMAIN,
matchTarget: "example.com",
});
await identity.listMailContactRules();
await identity.getMailContactRule(rule.id);
await identity.updateMailContactRule(rule.id, { status: "paused" });
await identity.deleteMailContactRule(rule.id);
await identity.createPhoneContactRule({
action: PhoneRuleAction.BLOCK,
matchTarget: "+15551234567",
matchType: PhoneRuleMatchType.EXACT_NUMBER,
});
await identity.listPhoneContactRules();
await inkbox.mailIdentityContactRules.create("sales-agent", {
action: "allow", matchType: "domain", matchTarget: "example.com",
});
await inkbox.mailIdentityContactRules.list("sales-agent");
await inkbox.mailIdentityContactRules.listAll({ agentIdentityId: identity.id });
await inkbox.phoneIdentityContactRules.listAll();
try {
await identity.createMailContactRule({
action: "allow", matchType: "domain", matchTarget: "example.com",
});
} catch (e) {
if (e instanceof DuplicateContactRuleError) {
console.log(e.existingRuleId);
}
}
Filter mode
The whitelist/blacklist mode lives on the identity. Flip it with identity.update
(admin-only). Unlike the deprecated channel update, this does not return a
FilterModeChangeNotice. phoneFilterMode requires the identity to have a phone
number (else a 422).
await identity.update({ mailFilterMode: "whitelist", phoneFilterMode: "blacklist" });
console.log(identity.mailFilterMode, identity.phoneFilterMode);
Deprecated: per-mailbox / per-number rules
The legacy per-mailbox inkbox.mailContactRules and per-number
inkbox.phoneContactRules resources still work but hit deprecated server routes
(Sunset 2026-08-31). Prefer the identity-keyed surface above.
await inkbox.mailContactRules.create(mailbox.emailAddress, {
action: "allow", matchType: "domain", matchTarget: "example.com",
});
await inkbox.mailContactRules.listAll({ mailboxId: mailbox.id });
await inkbox.phoneContactRules.create(num.id, {
action: "block", matchType: "exact_number", matchTarget: "+15551234567",
});
Contacts
Admin-only address book with per-identity access grants and vCard import/export.
import type { CreateContactOptions, ContactEmail, ContactPhone } from "@inkbox/sdk";
import { RedundantContactAccessGrantError } from "@inkbox/sdk";
const contact = await inkbox.contacts.create({
givenName: "Ada",
familyName: "Lovelace",
emails: [{ label: "work", value: "ada@example.com" }],
phones: [{ label: "mobile", value: "+15551234567" }],
});
await inkbox.contacts.get(contact.id);
await inkbox.contacts.list({ q: "ada", order: "recent", limit: 50, offset: 0 });
await inkbox.contacts.update(contact.id, { jobTitle: "Analyst" });
await inkbox.contacts.delete(contact.id);
await inkbox.contacts.lookup({ email: "ada@example.com" });
await inkbox.contacts.lookup({ emailDomain: "example.com" });
await inkbox.contacts.lookup({ phone: "+15551234567" });
await inkbox.contacts.lookup({ emailContains: "ada" });
await inkbox.contacts.lookup({ phoneContains: "555" });
await inkbox.contacts.access.list(contact.id);
await inkbox.contacts.access.grant(contact.id, { identityId: "agent-uuid" });
await inkbox.contacts.access.grant(contact.id, { wildcard: true });
await inkbox.contacts.access.revoke(contact.id, "agent-uuid");
try {
await inkbox.contacts.access.grant(contact.id, { identityId: "agent-uuid" });
} catch (e) {
if (e instanceof RedundantContactAccessGrantError) {
console.log(e.error, e.detailMessage);
}
}
const result = await inkbox.contacts.vcards.import(vcfText);
console.log(result.createdIds);
for (const item of result.errors) {
console.log(item.index, item.error);
}
const vcf = await inkbox.contacts.vcards.export(contact.id);
Notes
Admin-only free-form notes with per-identity access grants. There is no wildcard for notes — grant identities explicitly.
const note = await inkbox.notes.create({ body: "Customer prefers email follow-up.", title: "Ada" });
await inkbox.notes.get(note.id);
await inkbox.notes.list({ q: "email", identityId: "agent-uuid", order: "recent", limit: 50 });
await inkbox.notes.update(note.id, { body: "Updated body" });
await inkbox.notes.update(note.id, { title: null });
await inkbox.notes.delete(note.id);
await inkbox.notes.access.list(note.id);
await inkbox.notes.access.grant(note.id, "agent-uuid");
await inkbox.notes.access.revoke(note.id, "agent-uuid");
Whoami
const info = await inkbox.whoami();
console.log(info.authType);
console.log(info.organizationId);
if (info.authType === "api_key") {
console.log(info.keyId, info.label);
}
Returns WhoamiApiKeyResponse (with keyId, label, creatorType, authSubtype, etc.) or WhoamiJwtResponse (with email, orgRole, etc.) — discriminated on authType.
For branching on API-key scope, compare against the exported constants:
import {
AUTH_SUBTYPE_API_KEY_ADMIN_SCOPED,
AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_CLAIMED,
AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_UNCLAIMED,
} from "@inkbox/sdk";
if (info.authType === "api_key" && info.authSubtype === AUTH_SUBTYPE_API_KEY_ADMIN_SCOPED) {
}
Tunnels
Bring a local Node process online at a public https://{name}.inkboxwire.com URL. Outbound HTTP/2 only — no inbound port to open. POSIX only; the data-plane runtime lives on a separate package subpath so the main @inkbox/sdk entry stays browser-safe.
import { connect } from "@inkbox/sdk/tunnels/connect";
const listener = await connect(inkbox, {
name: "my-app",
forwardTo: "http://127.0.0.1:8080",
});
console.log(listener.publicUrl);
await listener.wait();
import type { InkboxHandler } from "@inkbox/sdk/tunnels/connect";
const handler: InkboxHandler = async (req, ctx) => {
return new Response("hi", { headers: { "content-type": "text/plain" } });
};
await connect(inkbox, { name: "my-app", handler });
import type { InkboxWsHandler } from "@inkbox/sdk/tunnels/connect";
const wsHandler: InkboxWsHandler = async (ws) => {
await ws.accept();
for await (const msg of ws) {
await ws.send(typeof msg === "string" ? `echo: ${msg}` : msg);
}
};
await connect(inkbox, { name: "my-app", handler, wsHandler });
await inkbox.createIdentity("my-app", { tunnel: { tlsMode: "passthrough" } });
await connect(inkbox, {
name: "my-app",
forwardTo: "http://127.0.0.1:8080",
});
Tunnels are provisioned atomically by inkbox.createIdentity(...); there is no standalone create / delete / restore / rotateSecret surface.
Reads + edit:
await inkbox.tunnels.list();
await inkbox.tunnels.get("tunnel-uuid");
await inkbox.tunnels.update("tunnel-uuid", {
metadata: { team: "gtm" },
});
await inkbox.tunnels.signCsr("tunnel-uuid", { csrPem });
Data-plane auth uses the same apiKey the Inkbox client was constructed with — admin-scoped or identity-scoped (matching the tunnel's identity). Mint a per-agent identity-scoped key via inkbox.apiKeys.create({ scopedIdentityId }). Selected connect() options: poolSize (1–32), stateDir (default ~/.inkbox/tunnels/{name}), onStatus callback, allowRemoteForwarding: false (loopback-only allowlist), forwardToVerifyTls: true, forwardToCaBundle. In passthrough mode the state dir holds the per-tunnel private key — treat it like an SSH key dir.
For full options, lifecycle notes, and Python examples, see skills/inkbox-tunnels/SKILL.md.
Webhooks & Signature Verification
Webhooks are configured directly on the mailbox or phone number — no separate registration.
import {
verifyWebhook,
MailWebhookPayload, TextWebhookPayload, PhoneIncomingCallWebhookPayload,
} from "@inkbox/sdk";
const key = await identity.createSigningKey();
const status = await identity.getSigningKeyStatus();
const key2 = await inkbox.signingKeys.createOrRotate("sales-agent");
const status2 = await inkbox.signingKeys.getStatus("sales-agent");
const valid = verifyWebhook({
payload: req.body,
headers: req.headers as Record<string, string>,
secret: "whsec_...",
});
if (!valid) return res.status(403).end();
const payload = JSON.parse(req.body.toString()) as TextWebhookPayload;
if (payload.event_type === "text.delivery_failed") {
console.error(payload.data.text_message.error_code, payload.data.text_message.error_detail);
}
Headers checked: x-inkbox-signature, x-inkbox-request-id, x-inkbox-timestamp.
Algorithm: HMAC-SHA256 over "{requestId}.{timestamp}.{body}".
Event taxonomy:
- Mail (envelope, fire-and-forget) —
message.received, message.sent, message.forwarded, message.delivered, message.bounced, message.failed. Subscribe via inkbox.webhooks.subscriptions.create({ mailboxId, url, eventTypes }). On message.received, data.message includes the plain-text body (whole under a size cap, else a prefix with body_truncated: true / body_state: "truncated"); when truncated, hydrate with inkbox.messages.get(message.email_address, message.id) — use id (row id), not message_id (RFC 5322 header). Present-with-null on the other events, absent on pre-feature payloads.
- Text (envelope, fire-and-forget) —
text.received, text.sent, text.delivered, text.delivery_failed, text.delivery_unconfirmed. Subscribe via inkbox.webhooks.subscriptions.create({ phoneNumberId, url, eventTypes }). The text-message body carries delivery_status as an outbound message-level rollup; 1:1 traffic also hoists error_code, error_detail, sent_at, delivered_at, and failed_at. On group outbound those legacy detail fields are null and per-recipient state lives in recipients[].
- iMessage (envelope, fire-and-forget) —
imessage.received, imessage.reaction_received, plus the outbound delivery lifecycle imessage.sent, imessage.delivered, imessage.delivery_failed (declined/error; details on the message object). Subscribe via inkbox.webhooks.subscriptions.create({ agentIdentityId, url, eventTypes }) — owned by the agent identity, since shared iMessage pool numbers are not org resources. data.message is populated on imessage.received and the three delivery-lifecycle events; data.reaction on imessage.reaction_received. Fan-out only happens while the identity is active and imessageEnabled; contact-rule-blocked traffic is never delivered.
- Call lifecycle (envelope, fire-and-forget + replayable) —
call.ended, owned by the agent identity (like iMessage). Subscribe via inkbox.webhooks.subscriptions.create({ agentIdentityId, url, eventTypes: ["call.ended"] }). CallEndedWebhookPayload.data carries the call (WebhookPhoneCall, with derived duration_seconds), resolved contacts / agent_identities, an always-present transcript_url (authoritative verbatim, fetch with an admin API key), and an inline transcript block (WebhookCallTranscript, middle-cut/abridged) present when the platform captured a transcript for the call, otherwise null — discriminate a turn from the abridgment marker on "marker" in entry. Voice AI call fields (all optional so pre-Voice AI payloads parse): data.call carries mode / reason; data carries outcome ("completed" | "no_answer" | "declined" | "failed", null iff mode is client_websocket) and post_call_action_items (open items only, seq-ascending, mirroring PhoneCall.postCallActionItems). Voice AI calls fire call.ended on every terminal state (including never-connected ones like no_answer), not just connected calls. An identity may hold a call.ended sub and an imessage.* sub independently, but one subscription carries a single channel.
- Inbound call (flat, synchronous) —
PhoneIncomingCallWebhookPayload on a phone number's incomingCallWebhookUrl. Not subscribable; the URL stays on the phone-number resource because the response (action: "answer" | "reject" + optional clientWebsocketUrl) decides the call's fate. Non-200, invalid bodies, and timeouts are treated as "decline routing" by Inkbox. (Contrast call.ended above, which is the replayable post-call fan-out.)
Subscription resource: inkbox.webhooks.subscriptions.{list,get,create,update,delete}. Each subscription names exactly one owner (mailbox, phone number, or agent identity), one HTTPS destination URL, and a non-empty subset of the catalog's event types. Multiple subscriptions on the same owner fan out independently (cap: 20 active per owner). The SDK runs structural + prefix validation client-side (exactly-one-FK, non-empty distinct events, no phone.incoming_call, and one channel per subscription — message. / text. / imessage. / call. prefix matching the owner's channel, where an agent identity owns both imessage.* and call.ended) so most shape mistakes surface as Error before the request leaves the client. The server remains authoritative for the exact event-name enum, so a typo with a valid prefix (e.g. message.received_typo) passes the SDK's check and is rejected as 422 by the server.
create(...) returns a WebhookSubscriptionCreateResponse. The first subscription created for an identity that has no signing key yet carries that identity's signingKey once (otherwise null) — capture it then, it cannot be retrieved again. Every subscription (read or created) also carries ownerIdentityId, the resolved owning agent identity (mail/phone/iMessage).
const created = await inkbox.webhooks.subscriptions.create({
mailboxId: mailbox.id, url: "https://example.com/hook", eventTypes: ["message.received"],
});
console.log(created.ownerIdentityId);
if (created.signingKey) saveSecret(created.signingKey);
Conversation context: opt a subscription into per-class history on received events (message.received, text.received, imessage.received) with contextConfig — email / texts / calls, each { mode: "count", count: N } (1..50) or { mode: "window", hours: H } (1..168). On update it is tri-state: omit = unchanged, null = clear, object = replace. Received-event payloads then carry an optional payload.data.context keyed by class; optional fields are absent, not null, so guard with ?.. A skipped class ships items: [] plus a skipped reason; call transcript entries are turns or an abridgment marker, discriminated on "marker" in entry. Config types WebhookContextConfig / WebhookContextClassConfig and payload types WebhookContext / WebhookContextBlock / WebhookTranscriptEntry (and the item types) are exported from @inkbox/sdk.
await inkbox.webhooks.subscriptions.create({
mailboxId: mailbox.id, url: "https://example.com/hook",
eventTypes: ["message.received"],
contextConfig: { email: { mode: "count", count: 10 } },
});
await inkbox.webhooks.subscriptions.update(created.id, { contextConfig: null });
Mail contact / identity resolution: data.contacts and data.agent_identities are lists of { bucket, address, id, ... } entries (always present, possibly empty). Inbound events resolve from + every cc; outbound events resolve every to + cc + bcc. Pair entries to the source field by (bucket, address). Outbound payloads also carry data.message.bcc_addresses (null on inbound, since BCC is not visible to recipients).
Phone/text contact / identity resolution: data.contacts (text) and top-level contacts (inbound call) are lists of { id, name } matches; data.agent_identities mirrors that for matched agent identities. Scoped to the identity that owns the receiving phone number; both default to [] when nothing matches. Group text events carry per-recipient delivery rows in data.text_message.recipients; outbound group lifecycle events name the event target in data.recipient_phone_number (one webhook per recipient leg). Inbound and outbound 1:1 events leave data.recipient_phone_number as null — the singular peer is already in data.text_message.remote_phone_number (inbound) or data.text_message.recipients[0] (outbound 1:1).
Exported wire types: MailWebhookPayload, TextWebhookPayload, IMessageWebhookPayload, PhoneIncomingCallWebhookPayload, WebhookContact, WebhookAgentIdentity, WebhookMailContact, WebhookMailAgentIdentity, RawTextMessageRecipient, the conversation-context shapes (WebhookContext, WebhookContextBlock, WebhookTranscriptEntry, and item types), plus event-type string unions (MailWebhookEventType, TextWebhookEventType, IMessageWebhookEventType) and wire enums (MessageStatus, CallStatusWire, HangupReasonWire, SmsDeliveryStatusWire, etc.). All fields are snake_case to match the raw JSON body.
Error Handling
import {
InkboxAPIError,
DuplicateContactRuleError,
RedundantContactAccessGrantError,
StorageLimitExceededError,
} from "@inkbox/sdk";
try {
const identity = await inkbox.getIdentity("unknown");
} catch (e) {
if (e instanceof InkboxAPIError) {
console.log(e.statusCode);
console.log(e.detail);
}
}
InkboxAPIError.detail is typed as InkboxAPIErrorDetail — either a string or a structured object. Catch the narrower subclasses when you need the parsed fields:
DuplicateContactRuleError — 409 when creating a contact rule with an already-taken (matchType, matchTarget) on the same resource. Exposes .existingRuleId: string.
RedundantContactAccessGrantError — 409 when a contact-access grant is redundant (e.g. per-identity grant on top of an active wildcard). Exposes .error and .detailMessage.
StorageLimitExceededError — 402 when a send / reply-all / forward would push the mailbox past its plan storage cap. Exposes .message (also as .detailMessage), .upgradeUrl, and .limitBytes. Delete messages or threads to free space (immediate), or upgrade. A 402 whose detail is a plain string stays a plain InkboxAPIError.
Key Conventions
- All method and property names are camelCase
iterEmails() / iterUnreadEmails() return AsyncGenerator<Message> — use for await...of
listCalls() returns Promise<PhoneCall[]> — offset pagination, not a generator
- To clear a nullable field (e.g. webhook URL), pass
field: null
- No context manager needed —
new Inkbox({...}) is all that's required
- All methods are
async and return Promises — always await them