@inkbox/sdk
v0.7.7
Published
TypeScript SDK for the Inkbox API
Readme
@inkbox/sdk
TypeScript SDK for the Inkbox API — API-first communication infrastructure for AI agents (email, phone, identities, encrypted vault — login credentials, API keys, key pairs, SSH keys, OTP, etc.).
Install
See Companion mode for complete group-conversation initialization.
npm install @inkbox/sdkRequires Node.js ≥ 22.
Note on Workers/Deno/browsers. The control-plane CRUD surface (
inkbox.tunnels.list/get/create/...etc.) is portable to Workers, Deno, and browsers — it only depends on the globalfetch. The data-plane runtime exposed viaimport { connect } from "@inkbox/sdk/tunnels/connect"requiresnode:http2,node:tls, andnode:net, so that subpath is Node-only. Use the Python SDK (inkbox.tunnels.connect()) if you need to run the data plane on a non-Node runtime.
Authentication
You'll need an API key to use this SDK. Get one at inkbox.ai/console.
new Inkbox(...) resolves apiKey / baseUrl / vaultKey from the explicit option, then the matching env var (INKBOX_API_KEY / INKBOX_BASE_URL / INKBOX_VAULT_KEY), then a ~/.inkbox/config file (key = value lines). The file fallback is handy for background/agent processes that don't inherit the shell's env, so new Inkbox() with no arguments works once the file is in place.
Behind a proxy? The SDK uses Node's fetch, which ignores HTTP_PROXY / HTTPS_PROXY / NO_PROXY by default — run with NODE_USE_ENV_PROXY=1 (Node 22.21+ / 24+) or, on older versions, configure a proxy-aware fetch dispatcher (e.g. undici's EnvHttpProxyAgent). A request that can't connect throws InkboxConnectionError naming the URL and underlying cause, with this hint attached when proxy variables are set but unused.
Directional communication permissions
Requires SDK 0.7.3 or later.
Mail and phone rules accept direction: "inbound" | "outbound" | "both"
(RuleDirection). Inbound means communication from the counterparty to the agent;
outbound means communication from the agent to the counterparty. Phone policy
also applies to iMessage.
import { Inkbox, MailRuleAction, MailRuleMatchType } from "@inkbox/sdk";
const inkbox = new Inkbox();
const identity = await inkbox.getIdentity("support-bot");
await identity.update({
mailInboundFilterMode: "blacklist",
mailOutboundFilterMode: "whitelist",
});
const rule = await identity.createMailContactRule({
action: MailRuleAction.ALLOW,
matchType: MailRuleMatchType.EXACT_EMAIL,
matchTarget: "[email protected]",
direction: "outbound",
});
await identity.updateMailContactRule(rule.id, {
action: MailRuleAction.BLOCK,
applyTo: "outbound",
});- Create omits direction by default, meaning Both. PATCH omission preserves coverage; action-only and direction-only updates are supported.
applyTorequiresaction, excludesdirection, and changes one covered side atomically while preserving the opposite side. The response remains one rule; refresh the list to see any retained opposite-side rule.- List and listAll accept
direction. Inbound/outbound include Both rules; Both selects only Both rules. A2A retains its separate exact-direction behavior. - Compatible coverage may consolidate under an existing ID. Trust the returned ID and direction; a successful create does not necessarily allocate a new ID.
- Identity effective fields are
mailInboundFilterMode,mailOutboundFilterMode,phoneInboundFilterMode, andphoneOutboundFilterMode. Shared mode writes set both directions. Shared reads report the common effective mode when equal, otherwise the stored shared baseline. Do not combine shared and directional mode writes for the same channel. - Legacy mailbox, number, and iMessage rule resources remain supported. Rule parsers default missing direction to Both; effective modes fall back to shared values on older responses. Directional operations require a supporting API.
Contact access groups expose optional inboundContactable and
outboundContactable lists. Legacy contactable means outbound on reads and both
directions on writes. Omit unchanged lists; an empty directional list blocks that
side's current addresses. Do not mix legacy and directional lists in one group or
supply null.
await inkbox.contacts.access.update("support-bot", "contact-id", {
email: { inboundContactable: ["[email protected]"], outboundContactable: [] },
});Address policy results also expose inboundAction, outboundAction,
allowedInbound, and allowedOutbound. Guarded address edits accept direction
and expectedInboundAction/expectedOutboundAction for pair-aware updates. A
one-way edit needs only its corresponding expected action; Both needs the pair
when expectedAction is omitted.
contacts.permissions.get/update expose inboundEmails, outboundEmails,
inboundPhones, and outboundPhones address-to-boolean maps. Legacy emails and
phones reads project outbound permissions; their writes still affect both
directions. Do not mix a shared map with a directional map for the same channel,
or supply null. Each map accepts up to 50 addresses; false and empty maps remain
explicit choices. Omitted maps preserve the existing choices.
These maps also work in contacts.create({ permissions: ... }). Alternatively,
initial permissions.addresses accepts up to 200 { kind, value, action,
direction? } decisions: 50 emails and 50 phone numbers, each with two directions.
This additive create overload accepts CreateContactWithAddressPermissionsOptions
with ContactCreateAddressPermissions, preserving existing boolean permission types.
Use one permission shape per creation request: maps, access groups, or address
decisions. The contact's identifier limits remain 50 per kind.
Visibility does not imply permission to send. Establishing a shared-line iMessage connection requires both effective permissions and never grants either one.
Response metadata
Existing resource methods keep their return types. To receive advisory notices
alongside a result, use withResponseMetadata and make calls through its scoped
client:
const response = await inkbox.withResponseMetadata(async (client) => {
const identity = await client.getIdentity("support-bot");
return identity.listMailContactRules({ direction: "outbound" });
});
console.log(response.data);
for (const notice of response.notices ?? []) {
console.log(notice.code, notice.level, notice.message);
}The result is APIResponse<T> with data and optional ResponseNotice[].
Void success becomes data: null. Notices are deduplicated by code, level, and
message across the callback's requests. Concurrent and nested scopes collect
independently while retaining authentication, cookies, timeout, and unlocked
vault state. No extra requests are made to collect metadata.
For every completed HTTP response, including errors, downloads, and empty
responses, an optional onResponse: ResponseObserver receives
ResponseMetadata. It is available on InkboxOptions, SignupOptions, and
A2AClient options. The SDK is silent by default; observer failures do not alter
API results or retry requests.
const observed = new Inkbox({
onResponse(metadata) {
for (const notice of metadata.notices ?? []) console.log(notice.message);
},
});
await observed.listIdentities();Notices use the Inkbox-Notices JSON response header, with top-level body fallback
only on declared identity, channel, and contact-permission response contracts,
including identity creation and avatar upload. Avatar downloads remain binary.
Unknown codes and levels remain strings. Missing, null, empty, or malformed
optional metadata never changes the primary result. Errors still throw their
ordinary exceptions, including agentSupport guidance; they are not returned as
successful metadata envelopes.
Quick start
import { Inkbox } from "@inkbox/sdk";
const inkbox = await new Inkbox({
apiKey: process.env.INKBOX_API_KEY!,
vaultKey: process.env.INKBOX_VAULT_KEY,
}).ready();
// Create an agent identity with a linked mailbox
const identity = await inkbox.createIdentity("support-bot", { displayName: "Support Bot" });
const phone = await identity.provisionPhoneNumber(); // provisions a local number
// Send email directly from the identity
await identity.sendEmail({
to: ["[email protected]"],
subject: "Your order has shipped",
bodyText: "Tracking number: 1Z999AA10123456784",
});
// Place an outbound call
await identity.placeCall({
toNumber: "+18005559999",
clientWebsocketUrl: "wss://my-app.com/voice",
});
// Read inbox
for await (const message of identity.iterEmails()) {
console.log(message.subject);
}
// List calls
const calls = await identity.listCalls();
// Access credentials (vault unlocked at construction)
const creds = await identity.getCredentials();
for (const login of creds.listLogins()) {
console.log(login.name);
}Authentication
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | required | Your ApiKey_... token |
| baseUrl | string | API default | Override for self-hosting or testing |
| timeoutMs | number | 30000 | Request timeout in milliseconds |
Agent Signup
Agents can self-register without a pre-existing API key. All signup methods are static — no Inkbox instance required.
import { Inkbox } from "@inkbox/sdk";
// Sign up (public — no API key needed)
const result = await Inkbox.signup({
humanEmail: "[email protected]",
noteToHuman: "Hey John, this is your sales bot signing up!",
displayName: "Sales Agent", // optional
agentHandle: "sales-agent", // optional
emailLocalPart: "sales.agent", // optional
harness: "claude-code", // optional — selects matching plugin guidance
invitationToken: process.env.INKBOX_A2A_INVITATION, // optional link or raw token
});
const apiKey = result.apiKey; // save — shown only once
const email = result.emailAddress; // e.g. "[email protected]"
const handle = result.agentHandle; // e.g. "sales-agent-a1b2c3"
console.log(result.message); // authoritative delivery/acceptance outcome
// A matching email-bound invitation can claim immediately without another email.
const alreadyClaimed = result.invitation?.status === "accepted"
|| result.claimStatus === "agent_claimed";
if (!alreadyClaimed) {
// If the email is missing, resend before submitting its 6-digit code.
// await Inkbox.resendSignupVerification(apiKey); // 5-minute cooldown
await Inkbox.verifySignup(apiKey, { verificationCode: "483921" });
}
// Check status and restrictions
const status = await Inkbox.getSignupStatus(apiKey);
console.log(status.claimStatus); // "agent_unclaimed" or "agent_claimed"
console.log(status.restrictions.maxSendsPerDay); // Effective 24-hour recipient-send limit| Method | Auth | Returns |
|---|---|---|
| Inkbox.signup(request, options?) | None | AgentSignupResponse |
| Inkbox.verifySignup(apiKey, request, options?) | API key | AgentSignupVerifyResponse |
| Inkbox.resendSignupVerification(apiKey, options?) | API key | AgentSignupResendResponse |
| Inkbox.getSignupStatus(apiKey, options?) | API key | AgentSignupStatusResponse |
request for signup() requires humanEmail and noteToHuman. displayName, agentHandle, emailLocalPart, harness, and invitationToken are optional. Omit invitationToken when signup is not part of an A2A connection invitation. Invitation-assisted signup and verification expose an optional invitation summary. A claimed response includes plugin guidance in message, tailored to harness when supplied.
Note: Unclaimed agents have a limited send quota and can only email the
humanEmailspecified at signup. After verification or human approval in the console, full capabilities are unlocked.
Note: The
organizationIdreturned at signup may change after verification or human approval. Always use theorganizationIdfrom the most recent response (verifySignuporresendSignupVerification) rather than caching the value from the initialsignup()call.
Identities
inkbox.createIdentity() and inkbox.getIdentity() return an AgentIdentity object that holds the identity's channels and exposes convenience methods scoped to those channels.
// Create and fully provision an identity
// createIdentity atomically provisions the mailbox AND the tunnel —
// both come back on the response. Phone numbers stay opt-in.
const identity = await inkbox.createIdentity("sales-bot", {
displayName: "Sales Bot",
description: "Sales-outreach agent",
});
const phone = await identity.provisionPhoneNumber(); // provisions a local number
console.log(identity.emailAddress); // [email protected]
console.log(identity.tunnel?.publicHost); // sales-bot.inkboxwire.com
console.log(phone.number);
// Pin the identity's mailbox to a verified custom sending domain
// (bare name; see "Custom Sending Domains" below).
await inkbox.createIdentity("sales-bot-2", { sendingDomain: "mail.acme.com" });
// Provision a passthrough tunnel (tls_mode is fixed at create time)
await inkbox.createIdentity("sales-bot-pt", { tunnel: { tlsMode: "passthrough" } });
// Get an existing identity (returned with current channel state)
const identity2 = await inkbox.getIdentity("sales-bot");
await identity2.refresh(); // re-fetch channels from API
// Admin credentials list organization identities; agent-scoped credentials
// return only their own identity.
const allIdentities = await inkbox.listIdentities();
// Agent-scoped credentials discover peers through the A2A directory.
const peers = await inkbox.a2a.organizationDirectory();
// Update identity metadata or handle
await identity.update({ newHandle: "sales-bot-v2" });
// Release the phone number (carrier release + local delete). Mailbox and
// tunnel are 1:1 with the identity and can only be removed by deleting it.
await identity.releasePhoneNumber();
// Delete (cascades to mailbox + tunnel + phone-number release; revokes scoped API keys).
await identity.delete();Mailbox imports
import { openAsBlob } from "node:fs";
import { MailImportFormat } from "@inkbox/sdk";
const file = await openAsBlob("./archive.zip");
const created = await inkbox.mailboxes.imports.create("[email protected]", {
sourceFormat: MailImportFormat.ZIP,
originalAddresses: ["[email protected]"],
});
await inkbox.mailboxes.imports.upload(created.upload, file);
await inkbox.mailboxes.imports.start("[email protected]", created.job.id);
const job = await inkbox.mailboxes.imports.wait("[email protected]", created.job.id, {
timeoutMs: 3_600_000,
pollIntervalMs: 5_000,
});Supported formats are auto, mbox, eml, and zip. A ZIP may hold .eml
and/or .mbox files (a Gmail Takeout ZIP imports as-is); entries that are not
mail, including nested archives, are ignored. wait fetches immediately, polls
every five seconds by default, and returns every terminal state, including
failed and cancelled. A local timeout does not cancel the job. Counters are
cumulative and never go backwards, but they can sit unchanged while a large
message is processed and never yield a percentage. Jobs run one at a time per
organization and share overall import capacity, so a long queued stretch is
normal. Unsafe imported content may be rejected and reported in
messagesRejectedUnsafe.
Upload targets expire after 5 minutes; call refreshUploadTarget and upload
again if one expires, or cancel the job so the mailbox is not held by an
upload that never landed. Other limits: 1 GiB per upload, 50 MiB per message,
100,000 messages and 20 originalAddresses per job, 65,000 entries per ZIP, 20
import jobs per organization per 24 hours (MailImportQuotaExceededError
carries retryAfterSeconds), and one in-flight import per mailbox.
// Send an email (plain text and/or HTML)
const sent = await identity.sendEmail({
to: ["[email protected]"],
subject: "Hello from Inkbox",
bodyText: "Hi there!",
bodyHtml: "<p>Hi there!</p>",
cc: ["[email protected]"],
bcc: ["[email protected]"],
});
// Send a threaded reply
await identity.sendEmail({
to: ["[email protected]"],
subject: `Re: ${sent.subject}`,
bodyText: "Following up!",
inReplyToMessageId: sent.id,
});
// Send with attachments
await identity.sendEmail({
to: ["[email protected]"],
subject: "See attached",
bodyText: "Please find the file attached.",
attachments: [{
filename: "report.pdf",
contentType: "application/pdf",
contentBase64: "<base64-encoded-content>",
}],
});
// Inline images: set contentId on an image attachment and reference it from
// bodyHtml as cid:<contentId>. Requires bodyHtml + an image/* contentType, a
// unique id per send, and is not supported on forwards.
await identity.sendEmail({
to: ["[email protected]"],
subject: "Weekly report",
bodyHtml: '<p>Revenue:</p><img src="cid:chart">',
attachments: [{
filename: "chart.png",
contentType: "image/png",
contentBase64: "<base64-encoded-content>",
contentId: "chart",
}],
});
// Track opens: embed a tracking pixel when an HTML body is present. Opens
// surface on the returned Message as firstOpenedAt / openCount.
const tracked = await identity.sendEmail({
to: ["[email protected]"],
subject: "Did you see this?",
bodyHtml: "<p>Please review.</p>",
trackOpens: true,
});
console.log(tracked.firstOpenedAt, tracked.openCount);
// Caveats: plain-text-only sends aren't tracked;
// openCount is approximate (proxy prefetch inflates it, the per-window
// debounce collapses repeats — so it can read above or below the true
// count); prefer firstOpenedAt. Pixels can also raise spam scores.
// Drafts accept incomplete content. Each draft response includes its generation.
const draft = await identity.createEmailDraft({
subject: "Review requested",
idempotencyKey: "draft-create-2026-08-19-1",
});
for await (const saved of identity.iterEmailDrafts()) {
console.log(saved.id, saved.generation);
}
let current = await identity.getEmailDraft(draft.id);
current = await identity.updateEmailDraft(current.id, {
generation: current.generation,
recipients: { to: ["[email protected]"] },
subject: null, // explicit null clears; omit the field to leave it unchanged
});
current = await inkbox.drafts.addAttachments(identity.emailAddress!, current.id,
current.generation, [{
filename: "report.txt",
contentType: "text/plain",
contentBase64: "cmVwb3J0",
}]);
const part = current.attachmentMetadata[0];
const downloaded = await inkbox.drafts.downloadAttachment(
identity.emailAddress!, current.id, part.partIndex, current.generation,
);
current = await inkbox.drafts.removeAttachment(
identity.emailAddress!, current.id, part.partIndex, current.generation,
);
const copy = await identity.duplicateEmailDraft(current.id, current.generation);
await identity.deleteEmailDraft(copy.id, copy.generation);
const sentDraft = await identity.sendEmailDraft(current.id, current.generation);
// Iterate inbox (paginated automatically)
for await (const msg of identity.iterEmails()) {
console.log(msg.subject, msg.fromAddress, msg.isRead);
}
// Filter by direction: "inbound" or "outbound"
for await (const msg of identity.iterEmails({ direction: "inbound" })) {
console.log(msg.subject);
}
// Iterate only unread emails
for await (const msg of identity.iterUnreadEmails()) {
console.log(msg.subject);
}
// Mark messages as read (or unread)
const unread: string[] = [];
for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id);
await identity.markEmailsRead(unread);
await identity.markEmailsUnread(["message-uuid"]);
// Get all emails in a thread (threadId comes from msg.threadId)
const thread = await identity.getThread(msg.threadId!);
for (const m of thread.messages) {
console.log(m.subject, m.fromAddress);
}Drafts use the same Drafts folder as a connected mail client, so edits are
visible in both directions. Always use the latest returned generation;
attachment partIndex values belong to the generation that returned them, so
refresh attachment metadata after an edit.
A successful send returns a Message and removes the draft. An exact-generation
retry may return the same sent message. Draft conflicts are InkboxAPIError
responses with statusCode === 409 and a structured detail.error. Refresh on
draft_generation_conflict and retry the same draft ID and generation on
draft_send_in_progress. Do not resend draft_delivery_uncertain; after
checking sent mail, duplicate or delete that draft instead.
Fetching a single inbound message by id (inkbox.messages.get, below)
with an API key marks it read server-side (isRead becomes true);
iterating via iterEmails / iterUnreadEmails does not, so
markEmailsRead stays the way to clear unread in list-only workflows.
This server-side isRead (the agent consumed the message via the API) is
distinct from firstOpenedAt (the recipient's mail client loaded the
tracking pixel).
Mailbox storage
Every mailbox has a plan storage cap. Sends, reply-alls, and forwards that
would push it over the cap are rejected with a 402 —
StorageLimitExceededError:
import { StorageLimitExceededError } from "@inkbox/sdk";
try {
await identity.sendEmail({ to: ["[email protected]"], subject: "Hi", bodyText: "…" });
} catch (err) {
if (err instanceof StorageLimitExceededError) {
console.log(err.message); // human-readable, includes the limit
console.log(err.limitBytes, err.upgradeUrl); // e.g. 2147483648, https://…?tab=billing
// Free space (reclaim is immediate) or upgrade the plan:
await inkbox.messages.delete(mailbox.emailAddress, "message-uuid");
await inkbox.threads.delete(mailbox.emailAddress, "thread-uuid");
}
}Current usage lives on the mailbox (inkbox.mailboxes.list() / .get()):
const mailbox = await inkbox.mailboxes.get("[email protected]");
console.log(mailbox.storageUsedBytes); // e.g. 1288490188
console.log(mailbox.storageLimitBytes); // e.g. 2147483648 (2 GiB), or null if unresolved
const usedGiB = mailbox.storageUsedBytes / 1024 ** 3; // caps are binary — GiB, not GBThe caps are binary: 2 GiB is 2 * 1024 ** 3 = 2,147,483,648 bytes. Divide
by 1024 and label the result GiB/MiB.
Free plan: a footer is appended to the stored body of outgoing mail, so what you read back with
inkbox.messages.get(...)is not byte-for-byte what you sent — asentBody === fetchedBodyround-trip assertion will fail on Free plans (a send with no body comes back with the footer as its body). Paid plans are unaffected.
Phone
import {
CallMode,
HostedAgentAuthorityMode,
OnVoicemail,
} from "@inkbox/sdk";
// Place an outbound call — stream audio over WebSocket
const call = await identity.placeCall({
toNumber: "+15551234567",
clientWebsocketUrl: "wss://your-agent.example.com/ws",
});
console.log(call.status, call.rateLimit.callsRemaining);
// Let Voice AI handle the call using this identity's saved authority.
// Hosted-agent calls leave a voicemail by default (onVoicemail=leave_message);
// pass voicemailMessage to control what is said, or hang_up / ignore to end
// the call at the beep or skip detection entirely.
const hostedCall = await identity.placeCall({
toNumber: "+15551234567",
mode: CallMode.HOSTED_AGENT,
reason: "Coordinate the appointment and send confirmations.",
onVoicemail: OnVoicemail.LEAVE_MESSAGE,
voicemailMessage: "Hi, this is Ava calling about your appointment. Please call us back.",
});
console.log(hostedCall.onVoicemail); // "leave_message"
// Set the saved default for future inbound and outbound Voice AI calls.
// Changing the saved default requires an admin API key.
await identity.setHostedAgentAuthorityMode({
authorityMode: HostedAgentAuthorityMode.YOLO,
});
// Per-call overrides are optional. CONTACT_SCOPED always downscopes. YOLO
// requires an admin credential unless the saved authority is already YOLO.
const scopedCall = await identity.placeCall({
toNumber: "+15551234567",
mode: CallMode.HOSTED_AGENT,
reason: "Confirm only this caller's appointment.",
hostedAgentAuthorityMode: HostedAgentAuthorityMode.CONTACT_SCOPED,
});
// Discover Voice AI voices for your organization; no identity ID is needed.
const catalog = await inkbox.hostedAgent.listVoices();
console.log(catalog.defaultVoice);
for (const voice of catalog.voices) {
console.log(voice.id, voice.name, voice.description, voice.available, voice.previewUrl);
}
// Keep unavailable entries for display, but select an available voice.
const selectedVoice = catalog.voices.find((voice) => voice.available);
if (selectedVoice) {
const config = await identity.getHostedAgentConfig();
// Full replacement: preserve instructions when changing only the voice.
await identity.setHostedAgentConfig({
voice: selectedVoice.id,
instructions: config.instructions ?? undefined,
});
}
// List calls (paginated)
const calls = await identity.listCalls({ limit: 10, offset: 0 });
for (const c of calls) {
console.log(c.id, c.direction, c.remotePhoneNumber, c.status);
}
// Fetch transcript segments for a call
const segments = await identity.listTranscripts(calls[0].id);
for (const t of segments) {
console.log(`[${t.party}] ${t.text}`); // party: "local" or "remote"
}
// Inspect tool activity for a Voice AI call
const activity = await identity.listToolInvocations(calls[0].id, {
limit: 50,
offset: 0,
});
for (const invocation of activity.items) {
console.log(invocation.toolName, invocation.status);
}
// Read transcripts across all recent calls
const recentCalls = await identity.listCalls({ limit: 10 });
for (const call of recentCalls) {
const segs = await identity.listTranscripts(call.id);
if (!segs.length) continue;
console.log(`\n--- Call ${call.id} (${call.direction}) ---`);
for (const t of segs) {
console.log(` [${t.party.padEnd(6)}] ${t.text}`);
}
}
// Filter to only the remote party's speech
const remoteOnly = segments.filter(t => t.party === "remote");
for (const t of remoteOnly) console.log(t.text);
// Search transcripts across a phone number (org-level)
const hits = await inkbox.phoneNumbers.searchTranscripts(phone.id, { q: "refund", party: "remote" });
for (const t of hits) {
console.log(`[${t.party}] ${t.text}`);
}Text Messages (SMS/MMS)
Send and receive SMS/MMS through the identity's assigned phone number.
Outbound SMS rules (read before sending):
- Each sender phone number is rate-limited to 100 recipient sends per rolling 24-hour window. 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. - A new local number takes ~10-15 minutes for the 10DLC campaign to propagate at the carrier —
phoneNumber.smsStatusreads"pending"until then, and sends will return409 sender_sms_pending. - The recipient must have texted
STARTto any number within your organization to opt in. Unknown recipients will fail with403 recipient_not_opted_in; recipients who later sendSTOPflip to403 recipient_opted_out. You can inspect consent state directly viainkbox.smsOptIns— see SMS Opt-Ins. - 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 and campaigns lift the default per-number cap to the carrier-assigned tier.
TypeScript users: group rows can legitimately have no single remote party, so text/conversation/webhook remotePhoneNumber / remote_phone_number fields are typed as string | null. One-to-one traffic still populates the remote number.
// Send SMS/MMS. Returns a queued TextMessage; final delivery state
// arrives via any webhook subscription on the sender's phone number
// whose eventTypes include the text.* lifecycle events.
const sent = await identity.sendText({
to: "+15551234567",
text: "Hello from Inkbox",
});
console.log(sent.id, sent.deliveryStatus); // "queued"
// Group MMS uses the same method with an array of recipients.
const group = await identity.sendText({
to: ["+15551234567", "+15557654321"],
text: "Hello group",
mediaUrls: ["https://example.com/photo.jpg"],
});
console.log(group.conversationId, group.recipients);
// Reply to an existing conversation by UUID. Do not pass `to` with this form.
const reply = await identity.sendText({
conversationId: group.conversationId,
text: "Following up in the same conversation.",
});
// List text messages
const texts = await identity.listTexts({ limit: 20 });
for (const t of texts) {
console.log(t.remotePhoneNumber, t.text, t.isRead);
}
// Filter to unread only
const unread = await identity.listTexts({ isRead: false });
// Get a single text
const text = await identity.getText("text-uuid");
console.log(text.type); // "sms" or "mms"
if (text.media) { // MMS attachments (temporary signed URLs)
for (const m of text.media) {
console.log(m.contentType, m.size, m.url);
}
}
// List one-to-one conversation summaries; opt into groups explicitly.
const convos = await identity.listTextConversations({ limit: 20, includeGroups: true });
for (const c of convos) {
console.log(c.id, c.participants, c.latestHasMedia, c.latestText);
}
// Get messages in a specific conversation by remote number or conversation UUID.
const msgs = await identity.getTextConversation("+15551234567", { limit: 50 });
// Mark as read
await identity.markTextRead("text-uuid");
await identity.markTextConversationRead("+15551234567");
// Org-level: search and delete
const results = await inkbox.texts.search(phone.id, { q: "invoice", limit: 20 });
await inkbox.texts.update(phone.id, "text-uuid", { status: "deleted" });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 — open to admin API keys and user session JWTs.
import { SmsOptInStatus } from "@inkbox/sdk";
// List the org's consent rows (newest-updated first; server caps limit at 200)
const rows = await inkbox.smsOptIns.list({ limit: 50 });
const optedOut = await inkbox.smsOptIns.list({ status: SmsOptInStatus.OPTED_OUT });
// Look up one recipient — 404 → InkboxAPIError if no row exists
const row = await inkbox.smsOptIns.get("+15551234567");
console.log(row.status, row.source, row.optedInAt, row.optedOutAt);Writes — admin-only, and only if your org runs its own active, customer-managed 10DLC
campaign. Orgs on the Inkbox-default campaign share consent state and get a
409 customer_campaign_required on write attempts. Writes record an audit
event with source: "api".
// Record consent captured outside of STOP/START (signup form, paper waiver, etc.)
await inkbox.smsOptIns.optIn("+15551234567");
// Honor an opt-out collected outside of inbound STOP
await inkbox.smsOptIns.optOut("+15551234567");iMessage
Chat with humans over the shared Inkbox router or a dedicated iMessage line.
iMessage is opt-in per identity (imessageEnabled). On shared service, the
human texts first. Dedicated lines may initiate conversations, subject to
consent and rate limits.
import {
DedicatedIMessageNumberInventoryPendingError,
DedicatedIMessageNumberQuotaExceededError,
IdempotencyKeyReusedError,
IMessageRuleAction,
IMessageSendStyle,
} from "@inkbox/sdk";
// Shared service: opt an identity in at create time or later.
const identity = await inkbox.createIdentity("my-agent", { imessageEnabled: true });
// Resolve the router number at runtime — never hardcode it.
const router = await inkbox.imessages.getTriageNumber();
console.log(router.number, router.connectCommand); // e.g. 'connect @my-agent'
// Once a human has connected and messaged, read and reply.
const convos = await identity.listIMessageConversations({ limit: 20 });
const msgs = await identity.listIMessages({ conversationId: convos[0].id });
await identity.sendIMessage({
conversationId: convos[0].id,
text: "On it — give me two minutes.",
});
// Who is currently connected? (Disconnected conversations stay readable
// with assignmentStatus === "released"; sends into them return 409.)
const connections = await identity.listIMessageAssignments();
await identity.releaseIMessageAssignment(connections[0].id); // admin key only; they can reconnect via triage
// Tapbacks target inbound one-to-one or group messages by messageId. The seven
// named reactions include "eyes" ("custom" is rejected locally on send), and a
// new tapback replaces your previous one on the same message part. Group read
// receipts and typing indicators remain unsupported and return 409.
const sentReaction = await identity.sendIMessageReaction({ messageId: msgs[0].id, reaction: "like" });
// Take your own tapback back. Only the sender can; a failed removal leaves it in
// place rather than clearing it locally, so the call can be retried.
await identity.removeIMessageReaction(sentReaction.id);
// Read receipts, typing indicator, media.
await identity.markIMessageConversationRead(convos[0].id);
await identity.sendIMessageTyping(convos[0].id);
const upload = await identity.uploadIMessageMedia({
content: fileBytes,
filename: "chart.png",
contentType: "image/png",
});
await identity.sendIMessage({ conversationId: convos[0].id, mediaUrls: [upload.mediaUrl] });
// Per-identity allow/block rules, interpreted via imessageFilterMode.
await inkbox.imessageContactRules.create("my-agent", {
action: IMessageRuleAction.BLOCK,
matchTarget: "+15555550999",
});
// List every dedicated line owned by the organization. Unattached lines
// have null agentIdentityId and agentHandle fields.
const numbers = await inkbox.imessages.listNumbers();
for (const number of numbers) {
console.log(number.number, number.agentHandle);
}
// Claim an unattached number for the organization. Generate the key once and
// reuse it if the request has an ambiguous outcome; a new key can claim again.
const claimKey = crypto.randomUUID();
try {
const claimed = await inkbox.imessages.claimNumber({
idempotencyKey: claimKey,
});
console.log(claimed.number);
} catch (err) {
if (err instanceof DedicatedIMessageNumberQuotaExceededError) {
console.error(err.message, err.upgradeUrl);
} else if (err instanceof DedicatedIMessageNumberInventoryPendingError) {
console.error(`Try again in ${err.retryAfterSeconds} seconds`);
} else if (err instanceof IdempotencyKeyReusedError) {
console.error(err.message);
} else {
throw err;
}
}
// Claim and attach atomically during identity creation.
const dedicatedIdentity = await inkbox.createIdentity("outreach-agent", {
imessageEnabled: true,
claimIMessageNumber: true,
});
console.log(dedicatedIdentity.imessageNumber?.number);
// A dedicated line can create or reuse an exact-participant group. Keep
// the returned conversationId for later replies. An ambiguous best-known match
// returns 409 instead of choosing a conversation.
const group = await dedicatedIdentity.sendIMessage({
to: ["+15551234567", "+15557654321"],
text: "Welcome to the group!",
mediaUrls: ["https://example.com/group-photo.jpg"],
sendStyle: IMessageSendStyle.CONFETTI,
});
await dedicatedIdentity.sendIMessage({
conversationId: group.conversationId,
text: "Following up in the same conversation.",
mediaUrls: ["https://example.com/follow-up.jpg"],
sendStyle: IMessageSendStyle.LASERS,
});
const groupConvos = await dedicatedIdentity.listIMessageConversations({ includeGroups: true });
const groupMessages = await dedicatedIdentity.listIMessages({ includeGroups: true });
console.log(group.isGroup, group.participants, group.recipients);
// groupCreationStatus is "creating", "not_created", or "ready". A rejected
// initial creation leaves this same local conversation at "not_created"; send
// again with its conversationId to retry. Success binds the remote thread and
// changes the status to "ready".
console.log(groupConvos[0].groupCreationStatus);
// Groups accept the same 13 IMessageSendStyle values as one-to-one sends on
// both creation and conversationId replies, with or without the media URL.
// Claim and atomically attach/swap during update. To attach an already-owned
// number, pass imessageNumberId instead. Pass imessageNumberId: null to move
// back to shared service. claimIMessageNumber and imessageNumberId cannot be
// combined in one update.
await identity.update({
claimIMessageNumber: true,
idempotencyKey: crypto.randomUUID(),
});Inbound messages, tapbacks, and outbound delivery status arrive via
identity-owned webhook subscriptions — see
Webhooks for the five imessage.* event types.
Agent-to-Agent (A2A)
With an admin-scoped API key, create and manage an invitation that connects an external agent to a fixed bundle of peers:
const invite = await inkbox.a2aInvitations.create({
peerAgentHandles: ["support", "billing"],
recipientEmail: "[email protected]",
});
const page = await inkbox.a2aInvitations.list({ status: "pending" });
await inkbox.a2aInvitations.revoke(invite.id);
// No API key is required to review an invitation before signup or acceptance:
const preview = await Inkbox.previewA2AInvitation(
process.env.INKBOX_A2A_INVITATION!,
);
// With a claimed agent-scoped key:
await inkbox.a2aInvitations.accept(process.env.INKBOX_A2A_INVITATION!);An unbound create returns invitationToken, invitationUrl, and
agentHandoffPrompt when available. accept() and signup accept either the
exact-origin share URL or a raw token; extractA2AInvitationToken() is exported
for local normalization. Only the raw token is sent to the API. A
recipient-email-bound create emails the recipient and omits capability fields.
Raw and extracted tokens must match a2ai_ followed by 43 URL-safe characters.
Share links require HTTPS, except for configured localhost/127.0.0.1 URLs.
const identity = await inkbox.getIdentity("coordinator");
const publicAgents = await inkbox.a2a.publicDirectory({ q: "research", limit: 25 });
const organizationAgents = await inkbox.a2a.organizationDirectory({ q: "support" });
for (const item of publicAgents.items) {
console.log(item.card.name, item.cardUrl, item.visibility);
}
await identity.a2aSetPubliclyDiscoverable(true); // admin API key required
await identity.a2aSetAllowPublicEgress(true);
// Omit direction for the receiver inbox. Use "outbound" for requested work
// or "both" for the complete identity-scoped history.
const page = await identity.a2aTasks({
direction: "both",
requesterHandle: "coordinator",
workerHandle: "researcher",
state: "working",
q: "quarterly report",
since: "2026-07-01T00:00:00Z",
limit: 25,
});
if (page.nextCursor) {
await identity.a2aTasks({
direction: "both",
requesterHandle: "coordinator",
workerHandle: "researcher",
state: "working",
q: "quarterly report",
since: "2026-07-01T00:00:00Z",
cursor: page.nextCursor,
limit: 25,
});
}
// Async iterators preserve every filter while following opaque cursors.
for await (const message of identity.iterA2AMessages({
direction: "outbound",
workerHandle: "researcher",
role: "agent",
q: "revenue",
})) {
console.log(message.taskId, message.taskState, message.parts);
}
// The outbound alias is convenient when only requested work is needed.
const sent = await identity.a2aSentTasks({ workerHandle: "researcher" });
// Context caller/target stays in original-open orientation. Each nested task
// carries its own caller and target, so both directions can run concurrently.
for (const context of (await identity.a2aContexts({ direction: "both" })).items) {
console.log(context.name, context.id);
}
const renamed = await identity.a2aUpdateContext("context-uuid", {
name: "Quarterly Research Review",
});Task keyword filtering returns tasks containing a matching message. Message
filtering returns the individual matching messages with task, context,
requester, and worker provenance. Search covers string and numeric content
values from text and data parts, excludes metadata, and is newest-first
rather than relevance-ranked. role is the message author (caller or
agent), independent of task direction. Task detail exposes message history
and current state.
Directory methods support q, cursor, and limit; async iterator variants
follow all pages. Receiver enablement, public egress, and advertised skills
accept the identity's agent-scoped key. Public discoverability, filter-mode,
and contact-rule create/update/delete operations require an admin API key. Use
a2aResetSkills() to restore default skills.
New contexts immediately expose the persisted name New A2A Session. That
exact default may be replaced asynchronously with a short name based on the
first task message. Either participant can rename the shared context at any
time; a non-default name is not replaced by automatic naming.
Applications should display the returned value as-is. Context-level caller
and target always identify the original opener and recipient. Nested task
participants are authoritative for each task's direction, and multiple tasks
can run concurrently in either direction.
The standard client reuses the existing contextId option. Supplying a context
without a task starts a sibling task; supplying taskId continues that
specific task:
const client = await identity.a2aClient();
const target = await client.fetchCard(
"https://example.test/a2a/researcher/card",
);
const result = await client.send(target, {
text: "Review the updated findings",
contextId: "context-uuid",
});Cross-endpoint context reuse is supported between Inkbox identities. External A2A services may define different context reuse behavior.
Rule directions are inbound, outbound, or both. Same-organization and
public discovery may imply admission; private cross-organization calls must
pass the requester's outbound policy and the worker's inbound policy. both
applies in either role, and explicit blocks always win.
The standard client authenticates Agent Card retrieval on the configured Inkbox origin. It never sends the Inkbox API key to external card or RPC origins. An explicit external credential is sent only to a same-origin RPC URL.
Credentials
Access credentials stored in the vault through the agent-facing credentials surface. The vault must be unlocked first.
// Unlock the vault (once per session)
await inkbox.vault.unlock("my-Vault-key-01!");
const identity = await inkbox.getIdentity("my-agent");
const creds = await identity.getCredentials();
// Discovery — list credentials this identity has access to
for (const login of creds.listLogins()) {
console.log(login.name, (login.payload as LoginPayload).username);
}
for (const key of creds.listApiKeys()) {
console.log(key.name, (key.payload as APIKeyPayload).accessKey);
}
// Access by UUID — returns the typed payload directly
const login = creds.getLogin("secret-uuid"); // → LoginPayload
const apiKey = creds.getApiKey("secret-uuid"); // → APIKeyPayload
const sshKey = creds.getSshKey("secret-uuid"); // → SSHKeyPayload
// Generic access
const secret = creds.get("secret-uuid"); // → DecryptedVaultSecretVault Management
Manage the encrypted vault at the org level. Access via inkbox.vault.
// Get vault metadata (key counts, secret counts)
const info = await inkbox.vault.info();
console.log(info.secretCount, info.keyCount);
// Initialize a new vault (creates primary key + recovery keys)
const result = await inkbox.vault.initialize("my-Vault-key-01!");
for (const key of result.recoveryKeys) {
console.log(key.recoveryCode); // save these immediately
}
// Rotate the vault password
await inkbox.vault.updateKey({
newVaultKey: "new-Vault-key-02!",
currentVaultKey: "my-Vault-key-01!",
});
// Rotate using a recovery code (if primary key is lost)
await inkbox.vault.updateKey({
newVaultKey: "new-Vault-key-02!",
recoveryCode: "recovery-code-here",
});
// List vault keys
const keys = await inkbox.vault.listKeys(); // all keys
const primaryKeys = await inkbox.vault.listKeys({ keyType: "PRIMARY" });
const recoveryKeys = await inkbox.vault.listKeys({ keyType: "RECOVERY" });
// List secrets (metadata only — no encrypted payloads)
const secrets = await inkbox.vault.listSecrets();
const logins = await inkbox.vault.listSecrets({ secretType: "login" });
// Delete a secret
await inkbox.vault.deleteSecret("secret-uuid");
// Unlock the vault for decryption (returns an UnlockedVault)
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!");
const secret = await unlocked.getSecret("secret-uuid");
console.log(secret.name, secret.payload);Access control
Control which identities can access which secrets.
// List access rules for a secret
const rules = await inkbox.vault.listAccessRules("secret-uuid");
for (const rule of rules) {
console.log(rule.identityId);
}
// Grant an identity access to a secret
await inkbox.vault.grantAccess("secret-uuid", "identity-uuid");
// Revoke access
await inkbox.vault.revokeAccess("secret-uuid", "identity-uuid");Identity Secret Management
Manage vault secrets scoped to a specific identity. These methods create secrets and automatically grant the identity access.
const identity = await inkbox.getIdentity("my-agent");
// Create a secret and auto-grant this identity access
const secret = await identity.createSecret({
name: "CRM Login",
payload: { type: "login", username: "[email protected]", password: "s3cret" },
description: "CRM service account",
});
// Fetch and decrypt a secret
const decrypted = await identity.getSecret(secret.id);
console.log(decrypted.payload);
// Delete a secret
await identity.deleteSecret(secret.id);
// Revoke this identity's access (without deleting the secret)
await identity.revokeCredentialAccess(secret.id);TOTP (one-time passwords)
Add, remove, and generate TOTP codes for login secrets.
// Add TOTP to a login secret (accepts otpauth:// URI or TOTPConfig)
await identity.setTotp(secret.id, "otpauth://totp/Example:user?secret=JBSWY3DPEHPK3PXP&issuer=Example");
// Generate the current TOTP code
const code = await identity.getTotpCode(secret.id);
console.log(code.code, code.expiresIn);
// Remove TOTP from a secret
await identity.removeTotp(secret.id);Org-level Messages and Threads
Access messages and threads directly without going through an identity. Useful for org-wide operations.
// List messages for a mailbox (paginated automatically)
for await (const msg of inkbox.messages.list("[email protected]")) {
console.log(msg.subject);
}
// Get a single message with full body. Fetching an *inbound* message with
// an API key marks it read server-side (isRead -> true); list, thread, and
// attachment routes do not. Use markRead for list-only workflows.
const detail = await inkbox.messages.get("[email protected]", "message-uuid");
console.log(detail.bodyText);
// Send a message from a mailbox
await inkbox.messages.send("[email protected]", {
to: ["[email protected]"],
subject: "Hello",
bodyText: "Hi there!",
});
// Update message flags
await inkbox.messages.updateFlags("[email protected]", "message-uuid", { isRead: true });
await inkbox.messages.markRead("[email protected]", "message-uuid");
await inkbox.messages.markUnread("[email protected]", "message-uuid");
await inkbox.messages.star("[email protected]", "message-uuid");
await inkbox.messages.unstar("[email protected]", "message-uuid");
// Delete a message
await inkbox.messages.delete("[email protected]", "message-uuid");
// Get a temporary signed URL for an attachment
const attachment = await inkbox.messages.getAttachment("[email protected]", "message-uuid", "report.pdf");
console.log(attachment.url);
// List threads (paginated automatically)
for await (const thread of inkbox.threads.list("[email protected]")) {
console.log(thread.subject, thread.messageCount);
}
// Get a thread with all messages
const thread = await inkbox.threads.get("[email protected]", "thread-uuid");
// Delete a thread
await inkbox.threads.delete("[email protected]", "thread-uuid");Org-level Calls
Calls are identity-scoped. Access them via inkbox.calls; transcripts
are folded onto the same resource as inkbox.calls.transcripts(callId).
// List calls (agent-scoped keys resolve their own identity; admin/JWT
// keys must pass agentIdentityId).
const calls = await inkbox.calls.list({ limit: 10 });
for (const call of calls) {
console.log(call.id, call.direction, call.status, call.origin);
}
// List calls for a specific identity (admin/JWT)
const scoped = await inkbox.calls.list({ agentIdentityId: "identity-uuid", limit: 10 });
// Get a single call
const call = await inkbox.calls.get("call-uuid");
// Place an outbound call from a dedicated number
const placed = await inkbox.calls.place({
fromNumber: "+18335794607",
toNumber: "+15551234567",
clientWebsocketUrl: "wss://example.com/ws",
});
// Place an outbound call over the shared iMessage-number pool
import { CallOrigin } from "@inkbox/sdk";
const shared = await inkbox.calls.place({
toNumber: "+15551234567",
origination: CallOrigin.SHARED_IMESSAGE_NUMBER,
agentIdentityId: "identity-uuid",
});
// List transcript segments for a call
const segments = await inkbox.calls.transcripts("call-uuid");
for (const t of segments) {
console.log(`[${t.party}] ${t.text}`);
}Incoming-call routing
import { ForwardingTargetType, IncomingCallAction } from "@inkbox/sdk";
// Read the current incoming-call config
const config = await inkbox.incomingCallAction.get();
// Route incoming calls to a webhook
await inkbox.incomingCallAction.set({
incomingCallAction: IncomingCallAction.WEBHOOK,
incomingCallWebhookUrl: "https://your-agent.example.com/incoming-call",
});
// Forward every incoming call for this identity to a complete E.164 number
await inkbox.incomingCallAction.set({
incomingCallAction: IncomingCallAction.FORWARD,
forwardingTargetType: ForwardingTargetType.PHONE,
forwardingPhoneNumber: "+14155550100",
});
// Forwarding attempts are chronological and separate from call.status
for (const forwarding of (await inkbox.calls.get("call-uuid")).forwardings) {
console.log(forwarding.status, forwarding.target);
}Org-level Mailboxes
Mailboxes are provisioned atomically by inkbox.createIdentity(...) and
removed by identity.delete() (cascade). The inkbox.mailboxes
surface is read + update + search only.
// List all mailboxes in the organisation
const mailboxes = await inkbox.mailboxes.list();
// Get a specific mailbox
const mb = await inkbox.mailboxes.get("[email protected]");
console.log(mb.emailAddress);
console.log(mb.sendingDomain); // bare domain the mailbox sends from
console.log(mb.agentIdentityId); // non-null for live customer mailboxes (1:1 invariant)
console.log(mb.storageUsedBytes); // bytes currently stored
console.log(mb.storageLimitBytes); // plan cap in bytes (binary GiB), or null
// Filter mode now lives on the agent identity — set it via
// identity.update({ mailFilterMode: ... }). display_name likewise moved
// to the identity; the mailbox PATCH endpoint hard-rejects display_name
// with a 422. To attach a webhook receiver, see "Webhooks" below.
const supportAgent = await inkbox.getIdentity("support-agent");
await supportAgent.update({ mailFilterMode: "whitelist" }); // admin-scoped key only
// (deprecated) await inkbox.mailboxes.update(mb.emailAddress, { filterMode: "whitelist" });
// Full-text search across messages in a mailbox
const results = await inkbox.mailboxes.search(mb.emailAddress, { q: "invoice", limit: 20 });
for (const msg of results) {
console.log(msg.subject, msg.fromAddress);
}
// To remove a mailbox, delete its owning identity (cascades to the
// linked mailbox AND tunnel; revokes scoped API keys):
await (await inkbox.getIdentity("support-agent")).delete();Custom Sending 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 sendingDomain to createIdentity. Domain registration, DNS records, verification, DKIM rotation, and deletion stay in the console.
import { SendingDomainStatus } from "@inkbox/sdk";
// List custom sending domains for the org (optionally filter by status)
const verified = await inkbox.domains.list({ status: SendingDomainStatus.VERIFIED });
for (const d of verified) {
console.log(d.id, d.domain, d.status, d.isDefault);
}
// Set the org default — admin-scoped API key only.
// Returns the bare new default domain name (or null when reverted to platform).
const newDefault = await inkbox.domains.setDefault("mail.acme.com");
// Pass the platform domain (e.g. "inkboxmail.com" in prod) to revert.
await inkbox.domains.setDefault("inkboxmail.com"); // -> nullMail clients (IMAP/SMTP)
An Inkbox inbox can also 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 directly.
| 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. [email protected]) |
| Password | an identity-scoped API key (ApiKey_...) |
The password is an agent-scoped API key — the same key an identity-scoped
Inkbox(...) client authenticates with. Mint one with
inkbox.apiKeys.create({ label, scopedIdentityId }). Admin-scoped keys are
rejected: one key maps to exactly one mailbox. Revoking the key revokes
mail-client access.
Two constraints that bite in practice:
Frommust be the authenticated inbox address, and exactly one address. Aliases and "send as" identities 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 the plan.
If your client saves its own copy of sent messages, leave that setting on: Inkbox recognizes the copy as the message it already stored, so you get one Sent entry, charged against your storage cap once.
Full setup walkthrough: https://inkbox.ai/docs/capabilities/email/mail-clients
Org-level Phone Numbers
Read, search, and release phone numbers org-wide via inkbox.phoneNumbers. Provisioning still goes through an identity — pass agentHandle so the new number is bound to it from the start.
// List all phone numbers in the organisation
const numbers = await inkbox.phoneNumbers.list();
// Get a specific phone number by ID
const number = await inkbox.phoneNumbers.get("phone-number-uuid");
// Provision a new number
const num = await inkbox.phoneNumbers.provision({ agentHandle: "sales-bot" }); // local by default
const inNy = await inkbox.phoneNumbers.provision({ agentHandle: "sales-bot", state: "NY" });
// Update incoming call behaviour
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "webhook",
incomingCallWebhookUrl: "https://example.com/calls",
});
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "auto_accept",
clientWebsocketUrl: "wss://example.com/ws",
});
// Full-text search across transcripts
const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote" });
for (const t of hits) {
console.log(`[${t.party}] ${t.text}`);
}
// Release a number
await inkbox.phoneNumbers.release(num.id);Tunnels
The Node-only data-plane runtime is exported separately so the main SDK remains 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);
console.log(listener.status); // idle, connecting, connected, ...
console.log(listener.isConnected); // local runtime liveness
console.log(listener.lastConnectedAt); // latest successful HELLO, or null
await listener.wait();Transient connect, HELLO, transport, and PING failures retry with bounded
establishment and exponential backoff. status is idle, connecting,
connected, reconnecting, closed, or superseded; the timestamp remains
available while reconnecting. These values describe the local listener, while
listener.tunnel remains the bootstrap resource snapshot. Authentication,
takeover, and unexpected fatal failures reject both serveForever() and
wait(). Attach a rejection handler when calling serveForever() without
awaiting it.
For in-process WebSocket handlers, planned drain throws WsServerDraining
(4500) and unplanned tunnel connection loss throws WsConnectionLost
(1011). Both extend WsClosed and advise the peer to reconnect. URL-forwarded
WebSockets receive the matching CLOSE code on their local upstream connection.
Webhooks
Webhook delivery uses a dedicated subscription resource. Each subscription names exactly one owner (a mailbox, a phone number, or an agent identity for iMessage), one HTTPS destination URL, and a non-empty subset of the catalog's event types. Multiple subscriptions on the same owner fan out independently.
The one exception is phone.incoming_call, which is a synchronous
control-plane callback (the response body decides whether Inkbox
answers). That URL still lives on the phone-number resource as
incomingCallWebhookUrl.
Subscribing to mail, text, or iMessage events
// Mail subscription: pick the message.* events you want.
await inkbox.webhooks.subscriptions.create({
mailboxId: mb.id,
url: "https://example.com/hook",
eventTypes: ["message.received", "message.bounced"],
});
// Text subscription: pick the text.* events you want.
await inkbox.webhooks.subscriptions.create({
phoneNumberId: number.id,
url: "https://example.com/texts",
eventTypes: [
"text.received",
"text.sent",
"text.delivered",
"text.delivery_failed",
"text.delivery_unconfirmed",
],
});
// iMessage subscription: owned by the agent identity (the shared
// pool lines aren't org resources).
await inkbox.webhooks.subscriptions.create({
agentIdentityId: identity.id,
url: "https://example.com/imessage",
eventTypes: [
"imessage.received",
"imessage.reaction_received",
"imessage.sent",
"imessage.delivered",
"imessage.delivery_failed",
],
});
// List, update, remove.
const subs = await inkbox.webhooks.subscriptions.list({ mailboxId: mb.id });
await inkbox.webhooks.subscriptions.update(subs[0].id, { url: "https://new/hook" });
await inkbox.webhooks.subscriptions.delete(subs[0].id);Available event types:
| Channel | event_type values |
|---|---|
| Mail | message.received, message.sent, message.forwarded, message.delivered, message.bounced, message.failed |
| Phone text | text.received, text.sent, text.delivered, text.delivery_failed, text.delivery_unconfirmed |
| iMessage | imessage.received, imessage.reaction_received, imessage.sent, imessage.delivered, imessage.delivery_failed |
Server-side validation: exactly one of mailboxId / phoneNumberId /
agentIdentityId must be set; eventTypes must be non-empty and
distinct; every event type must belong to the owner's channel (mailbox
→ message.*, phone number → text.*, agent identity → imessage.*).
On create the SDK mirrors the structural checks (XOR owner,
non-empty, distinct, no phone.incoming_call) plus the message. /
text. / imessage. prefix check, 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. On update the SDK also rejects mixed
event families. Owner compatibility remains server-validated because the
SDK doesn't know the owner FK from a subscription ID alone.
Conversation context
Opt a subscription into per-class conversation history on received
events (message.received, text.received, imessage.received) by
passing contextConfig. Each class (email, texts, calls) takes a
count mode (last N items, 1..50) or a window mode (last H hours,
1..168); omit a class to leave it unconfigured. Conversation context is
not supported for A2A subscriptions.
await inkbox.webhooks.subscriptions.create({
mailboxId: mb.id,
url: "https://example.com/hook",
eventTypes: ["message.received"],
contextConfig: {
email: { mode: "count", count: 10 },
texts: { mode: "window", hours: 24 },
},
});
// update() is tri-state: omit contextConfig to leave it unchanged, pass an
// object to replace it, or pass null to clear it.
await inkbox.webhooks.subscriptions.update(sub.id, { contextConfig: null });Received-event payloads then carry an optional payload.data.context keyed
by class. Optional fields are omitted when empty (never null) —
guard with ?., not === null. A
skipped class ships items: [] plus a skipped reason; call transcript
entries are either turns or an abridgment marker, discriminated on
"marker" in entry:
import type { WebhookContextCallItem, WebhookContextMailItem } from "@inkbox/sdk";
// payload is a MailWebhookPayload / TextWebhookPayload / ... (see below)
const context = payload.data.context;
if (context?.email) {
if (context.email.skipped) {
console.log("no email context:", context.email.skipped);
}
// Each class's items are that class's item type.
for (const item of context.email.items as WebhookContextMailItem[]) {
console.log(item.direction, item.subject);
}
}
for (const call of (context?.calls?.items ?? []) as WebhookContextCallItem[]) {
for (const entry of call.transcript) {
if ("marker" in entry) {
console.log(`… ${entry.omitted_turns} turns abridged`);
} else {
console.log(`${entry.party}: ${entry.text}`);
}
}
}The config types (WebhookContextConfig, WebhookContextClassConfig) and
the payload types (WebhookContext, WebhookContextBlock,
WebhookTranscriptEntry, …) are exported from @inkbox/sdk.
Delivery auth token
If your endpoint requires its own Authorization header, set an optional
bearer token on the subscription. Every delivery (and replay) then carries
Authorization: Bearer <token> in addition to the signature headers.
Reads return the stored token as authToken (null when unset) along
with the boolean hasAuthToken flag; both default to unset on servers
that predate the fields.
await inkbox.webhooks.subscriptions.create({
mailboxId: mb.id,
url: "https://example.com/hook",
eventTypes: ["message.received"],
authToken: "your-endpoint-token",
});
// update() is tri-state: omit authToken to leave it unchanged, pass a
// string to replace it, or pass null to clear it.
await inkbox.webhooks.subscriptions.update(sub.id, { authToken: null });Incoming-call webhooks (still per-number)
// Route incoming calls to a webhook. The response body controls call routing.
await inkbox.phoneNumbers.update(number.id, {
incomingCallAction: "webhook",
incomingCallWebhookUrl: "https://example.com/calls",
});Wire shapes
Every mail and text payload uses the standard { event_type,
timestamp, data } envelope. data.contacts (mail and text) and
data.agent_identities are always present, possibly empty.
agent_identities mirrors contacts but matches active agent
identities in the same org. On mail, each list entry carries a
bucket: "from" | "to" | "cc" | "bcc" plus address; receivers
should pair to the source field by (bucket, address).
data.message.bcc_addresses is populated only on outbound events.
Every resolved contact carries active memory text, newest first, in
memories; use match.memories ?? [] for replayed payloads that
predate contact memories. This is separate from the optional
conversation context.
name is null when the contact has no name on file — a contact
created automatically from an inbound message has an id and
memories before anyone gives it a name. It never falls back to the
phone number or email address, so guard it before addressing someone
by name.
Phone-text payloads carry several fields for group sends:
text_message.recipients—nullon inbound, a one-element list on outbound 1:1, multiple entries on group outbound.text_message.remote_phone_number—nullon group outbound (the per-recip
