From inkbox
Use the Inkbox Python SDK for email, phone, SMS, iMessage, contacts, notes, vault, tunnels, and agent identity features in AI agent communication infrastructure.
How this skill is triggered — by the user, by Claude, or both
Slash command
/inkbox:inkbox-pythonThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
API-first communication infrastructure for AI agents — email, phone, encrypted vault, and identities.
API-first communication infrastructure for AI agents — email, phone, encrypted vault, and identities.
pip install inkbox
Always use the context manager — it manages the underlying HTTP session:
from inkbox import Inkbox
with Inkbox(api_key="ApiKey_...") as inkbox:
...
Constructor: Inkbox(api_key, base_url="https://inkbox.ai", timeout=30.0)
Inkbox (admin-only client)
├── .create_identity(handle) → AgentIdentity
├── .get_identity(handle) → AgentIdentity
├── .list_identities() → list[AgentIdentitySummary]
├── .mailboxes → MailboxesResource
├── .phone_numbers → PhoneNumbersResource
├── .texts → TextsResource
├── .imessages → IMessagesResource
├── .imessage_contact_rules → IMessageContactRulesResource
├── .mail_contact_rules → MailContactRulesResource
├── .phone_contact_rules → PhoneContactRulesResource
├── .sms_opt_ins → SmsOptInsResource
├── .contacts → ContactsResource (.access, .vcards)
├── .notes → NotesResource (.access)
├── .vault → VaultResource
├── .whoami() → WhoamiResponse
└── .create_signing_key() → SigningKey
AgentIdentity (identity-scoped helper)
├── .mailbox → IdentityMailbox | None
├── .phone_number → IdentityPhoneNumber | None
├── .credentials → Credentials (requires vault unlocked)
├── .list_access() → list[IdentityAccess]
├── .grant_access(viewer_id|None) → IdentityAccess
├── .revoke_access(viewer_id) → None
├── 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 raised with a clear message.
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
Python SDK methods: Inkbox.signup(...), Inkbox.verify_signup(api_key, ...), Inkbox.resend_signup_verification(api_key), Inkbox.get_signup_status(api_key).
identity = inkbox.create_identity("sales-agent")
identity = inkbox.get_identity("sales-agent")
identities = inkbox.list_identities() # → list[AgentIdentitySummary]
identity.update(new_handle="new-name") # rename
identity.update(status="paused") # or "active"
identity.refresh() # re-fetch from API, updates cached channels
identity.delete() # cascades: mailbox + tunnel + phone-number release
# Identity is created with a mailbox AND tunnel atomically — both come back on the response
print(identity.email_address) # e.g. "[email protected]"
print(identity.tunnel.public_host) # e.g. "sales-agent.inkboxwire.com"
# Phone numbers are still opt-in
phone = identity.provision_phone_number(type="toll_free") # or type="local", state="NY"
print(phone.number) # e.g. "+18005551234"
# Release the phone number (vendor + local)
identity.release_phone_number()
Mailboxes and tunnels are not separately linkable — they are 1:1 with their owning identity. Use inkbox.create_identity() to provision both; use identity.delete() to remove both (cascade).
Controls which other agent identities can see an identity in API responses. Humans and admins always see every identity.
rules = identity.list_access() # list[IdentityAccess]
# One wildcard row (viewer_identity_id is None → every active identity sees it),
# explicit per-viewer rows, or [] (no agent can see it).
identity.grant_access(viewer.id) # grant one viewer identity
identity.grant_access(None) # reset to org-wide wildcard
identity.revoke_access(viewer.id) # revoke one viewer (keyed by viewer UUID)
Granting a viewer against an already-wildcard target raises RedundantContactAccessGrantError (409); revoking a non-existent grant raises InkboxAPIError (404).
sent = identity.send_email(
to=["[email protected]"],
subject="Hello",
body_text="Hi there!", # plain text (optional)
body_html="<p>Hi there!</p>", # HTML (optional)
cc=["[email protected]"], # optional
bcc=["[email protected]"], # optional
in_reply_to_message_id=sent.id, # for threaded replies
attachments=[{ # optional
"filename": "report.pdf",
"content_type": "application/pdf",
"content_base64": "<base64>",
}],
)
# Iterate all messages — pagination handled automatically (Iterator[Message])
for msg in identity.iter_emails():
print(msg.subject, msg.from_address, msg.is_read)
# Filter by direction
for msg in identity.iter_emails(direction="inbound"): # or "outbound"
...
# Unread only (client-side filtered)
for msg in identity.iter_unread_emails():
...
# Mark as read
ids = [msg.id for msg in identity.iter_unread_emails()]
identity.mark_emails_read(ids)
# Get full thread (oldest-first)
thread = identity.get_thread(msg.thread_id)
for m in thread.messages:
print(f"[{m.from_address}] {m.subject}")
Threads carry a folder field: inbox, spam, archive, or blocked (server-assigned, never client-set).
from inkbox import ThreadFolder
# Thread.folder / ThreadDetail.folder is always one of the four values above.
Low-level folder listing / per-thread updates (list(folder=…), list_folders(email), update(..., folder=…)) live on ThreadsResource. Passing folder="blocked" to update raises ValueError before the HTTP call.
# Place outbound call — stream audio via WebSocket
call = identity.place_call(
to_number="+15551234567",
client_websocket_url="wss://your-agent.example.com/ws",
)
print(call.status)
print(call.rate_limit.calls_remaining)
# List calls (offset pagination)
calls = identity.list_calls(limit=10, offset=0)
for c in calls:
print(c.id, c.direction, c.remote_phone_number, c.status)
# Transcript segments (ordered by seq)
for t in identity.list_transcripts(calls[0].id):
print(f"[{t.party}] {t.text}") # party: "local" or "remote"
Outbound SMS limits and gates (current):
429 sender_rate_limited.identity.phone_number.sms_status is SmsStatus.PENDING until ready; sends in this window return 409 sender_sms_pending.START to any number in the org. Unknown → 403 recipient_not_opted_in. STOP → 403 recipient_opted_out. Inspect / override consent state via inkbox.sms_opt_ins (see below).Customer-managed 10DLC brands/campaigns lift the default per-number cap to the carrier-assigned tier. Toll-free SMS sending is still coming soon.
# Send SMS/MMS from this identity's phone number.
# Returns a queued TextMessage; final delivery state arrives via any
# webhook subscription on the sender's phone number whose event_types
# include the text.* lifecycle events.
sent = identity.send_text(to="+15551234567", text="Hello from Inkbox")
print(sent.id, sent.delivery_status) # SmsDeliveryStatus.QUEUED
# Group MMS beta: pass a list of recipients plus optional media URLs.
group = identity.send_text(
to=["+15551234567", "+15557654321"],
text="Hello group",
media_urls=["https://example.com/photo.jpg"],
)
print(group.conversation_id, group.recipients)
# Reply to an existing conversation by UUID. Do not pass "to" with this form.
reply = identity.send_text(
conversation_id=group.conversation_id,
text="Following up in the same conversation.",
)
# List text messages (offset pagination)
texts = identity.list_texts(limit=20, offset=0)
for t in texts:
print(t.id, t.direction, t.remote_phone_number, t.text, t.is_read)
# Filter by read state
unread = identity.list_texts(is_read=False)
# Get a single text message
text = identity.get_text("text-uuid")
print(text.type) # "sms" or "mms"
if text.media: # MMS media attachments (temporary signed URLs)
for m in text.media:
print(m.content_type, m.size, m.url)
# List one-to-one conversation summaries; opt into groups explicitly.
convos = identity.list_text_conversations(limit=20, include_groups=True)
for c in convos:
print(c.id, c.participants, c.latest_has_media, c.latest_text)
# Get messages in a specific conversation by remote number or conversation UUID.
msgs = identity.get_text_conversation("+15551234567", limit=50)
# Mark a text as read (identity convenience method)
identity.mark_text_read("text-uuid")
# Mark all messages in a conversation as read
result = identity.mark_text_conversation_read("+15551234567")
print(result["updated_count"])
# Admin-only: search, update, delete
results = inkbox.texts.search(phone.id, q="invoice", limit=20)
inkbox.texts.update(phone.id, "text-uuid", status="deleted")
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 conversation_id / remote_number; 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:
triage = inkbox.imessages.get_triage_number()
print(triage.number, triage.connect_command) # "+1646...", "connect @your-handle"
# Humans connect by texting that command to that number.
Reachability is opt-in per identity (imessage_enabled, default False):
identity = inkbox.create_identity("my-agent", imessage_enabled=True)
# or toggle later
identity.update(imessage_enabled=True)
# admin-only: flip contact-rule mode (default "blacklist")
identity.update(imessage_filter_mode="whitelist")
print(identity.imessage_enabled, identity.imessage_filter_mode)
Messaging (identity convenience methods; inkbox.imessages is the org-level resource with the same operations plus agent_identity_id / is_blocked filters):
# Send to a connected recipient, or reply into a conversation by UUID.
sent = identity.send_imessage(to="+15551234567", text="Hello over iMessage")
reply = identity.send_imessage(
conversation_id=sent.conversation_id,
text="With style",
send_style="slam", # IMessageSendStyle: confetti, lasers, slam, ...
)
print(sent.service, sent.status) # IMessageService.IMESSAGE, IMessageDeliveryStatus.QUEUED
# List messages / conversations
msgs = identity.list_imessages(limit=20, is_read=False)
convos = identity.list_imessage_conversations(limit=20)
convo = identity.get_imessage_conversation(sent.conversation_id)
# assignment_status tells you whether the recipient is still connected:
# anything other than "active" means sends/reactions will be refused
# until they reconnect through triage.
print(convo.assignment_status)
# Who is actively connected to this identity right now (paginated)?
connections = identity.list_imessage_assignments(limit=20)
for a in connections:
print(a.remote_number, a.status, a.created_at)
# Tapback reactions. Sends accept the classic six (love, like, dislike,
# laugh, emphasize, question); inbound can also be "custom" with the
# literal emoji in custom_emoji.
identity.send_imessage_reaction(message_id=msgs[0].id, reaction="like")
# Live tapbacks come back on message reads, oldest first.
for r in msgs[0].reactions or []:
print(r.direction, r.reaction, r.custom_emoji)
# Read receipts + typing indicator
identity.mark_imessage_conversation_read(sent.conversation_id)
identity.send_imessage_typing(sent.conversation_id)
# Media: upload bytes (max 10 MiB), then send the returned URL (one per message)
upload = identity.upload_imessage_media(
content=open("photo.jpg", "rb").read(),
filename="photo.jpg",
content_type="image/jpeg",
)
identity.send_imessage(to="+15551234567", media_urls=[upload.media_url])
Contact rules are scoped to the identity (not a phone number) because pool numbers are shared infrastructure:
from inkbox import IMessageRuleAction
rule = inkbox.imessage_contact_rules.create(
"my-agent", action=IMessageRuleAction.BLOCK, match_target="+15559999999",
)
rules = inkbox.imessage_contact_rules.list("my-agent")
inkbox.imessage_contact_rules.update("my-agent", rule.id, status="paused") # admin-only
inkbox.imessage_contact_rules.delete("my-agent", rule.id) # admin-only
all_rules = inkbox.imessage_contact_rules.list_all() # admin-only, org-wide
Inbound messages and reactions arrive via identity-owned webhook subscriptions — see Webhooks below.
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).
from inkbox import SmsOptInStatus
# List your org's consent rows, newest-updated first (server caps limit at 200)
rows = inkbox.sms_opt_ins.list(limit=50)
opted_out = inkbox.sms_opt_ins.list(status=SmsOptInStatus.OPTED_OUT)
# Look up one recipient — 404 → InkboxAPIError if no row exists
row = inkbox.sms_opt_ins.get("+15551234567")
print(row.status, row.source, row.opted_in_at, row.opted_out_at)
# Programmatic writes (customer-managed 10DLC campaign only)
inkbox.sms_opt_ins.opt_in("+15551234567")
inkbox.sms_opt_ins.opt_out("+15551234567")
Encrypted credential vault with client-side Argon2id key derivation and AES-256-GCM encryption. The server never sees plaintext secrets. Requires argon2-cffi and cryptography (included as dependencies).
# Initialize a new vault (org ID is fetched automatically from the API key)
result = inkbox.vault.initialize("my-Vault-key-01!")
print(result.vault_id, result.vault_key_id)
for code in result.recovery_codes:
print(code) # save these immediately — they cannot be retrieved again
from inkbox import LoginPayload, APIKeyPayload, SSHKeyPayload, OtherPayload
# Unlock with a vault key — derives key via Argon2id, decrypts all secrets
unlocked = inkbox.vault.unlock("my-Vault-key-01!")
# Optionally filter to secrets an agent identity has access to
unlocked = inkbox.vault.unlock("my-Vault-key-01!", identity_id="agent-uuid")
# All decrypted secrets from the unlock bundle
for secret in unlocked.secrets:
print(secret.name, secret.secret_type)
print(secret.payload) # LoginPayload, APIKeyPayload, SSHKeyPayload, or OtherPayload
# Fetch and decrypt a single secret by ID
secret = unlocked.get_secret("secret-uuid")
print(secret.payload.username, secret.payload.password) # for login type
# Create a login secret (secret_type inferred from payload type)
unlocked.create_secret(
"AWS Production",
LoginPayload(password="s3cret", username="admin", url="https://aws.amazon.com"),
description="Production IAM user",
)
# Create an API key secret
unlocked.create_secret(
"GitHub PAT",
APIKeyPayload(api_key="ghp_xxx"),
)
# Create an SSH key secret
unlocked.create_secret(
"Deploy Key",
SSHKeyPayload(private_key="-----BEGIN OPENSSH PRIVATE KEY-----..."),
)
# Create a freeform secret
unlocked.create_secret("Misc", OtherPayload(data="any freeform content"))
# Update name/description and/or re-encrypt payload
unlocked.update_secret("secret-uuid", name="New Name")
unlocked.update_secret("secret-uuid", payload=LoginPayload(password="new", username="new"))
# Delete
unlocked.delete_secret("secret-uuid")
info = inkbox.vault.info() # VaultInfo
keys = inkbox.vault.list_keys() # list[VaultKey]
keys = inkbox.vault.list_keys(key_type="recovery") # filter by type
secrets = inkbox.vault.list_secrets() # list[VaultSecret] (metadata only)
secrets = inkbox.vault.list_secrets(secret_type="login") # filter by type
inkbox.vault.delete_secret("secret-uuid") # delete without unlocking
| Type | Class | Fields |
|---|---|---|
login | LoginPayload | password, username?, email?, url?, notes? |
api_key | APIKeyPayload | api_key, endpoint?, notes? |
key_pair | KeyPairPayload | access_key, secret_key, endpoint?, notes? |
ssh_key | SSHKeyPayload | private_key, public_key?, fingerprint?, passphrase?, notes? |
other | OtherPayload | data |
secret_type is immutable after creation. To change it, delete and recreate.
Agent-facing credential access — typed, identity-scoped. The vault stays as the admin surface; identity.credentials is the agent runtime surface.
from inkbox import Credentials
# Unlock the vault first (stores state on the client)
inkbox.vault.unlock("my-Vault-key-01!")
identity = inkbox.get_identity("support-bot")
# Discovery — returns list[DecryptedVaultSecret] with name/metadata
all_creds = identity.credentials.list()
logins = identity.credentials.list_logins()
api_keys = identity.credentials.list_api_keys()
ssh_keys = identity.credentials.list_ssh_keys()
key_pairs = identity.credentials.list_key_pairs()
# Access by UUID — returns typed payload directly
login = identity.credentials.get_login("secret-uuid") # → LoginPayload
api_key = identity.credentials.get_api_key("secret-uuid") # → APIKeyPayload
ssh_key = identity.credentials.get_ssh_key("secret-uuid") # → SSHKeyPayload
key_pair = identity.credentials.get_key_pair("secret-uuid") # → KeyPairPayload
# Generic access — returns DecryptedVaultSecret
secret = identity.credentials.get("secret-uuid")
inkbox.vault.unlock() first — raises InkboxError if vault is not unlockedidentity.refresh() to clear the cacheget_* raises KeyError if not found, TypeError if wrong secret typeTOTP secrets are stored inside LoginPayload.totp in the encrypted vault. Codes are generated client-side — no server call needed.
from inkbox.vault.totp import parse_totp_uri
from inkbox.vault.types import LoginPayload
# Create a login with TOTP
secret = identity.create_secret(
name="GitHub",
payload=LoginPayload(
username="[email protected]",
password="s3cret",
totp=parse_totp_uri("otpauth://totp/GitHub:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"),
),
)
# Generate TOTP code
code = identity.get_totp_code(str(secret.id))
print(code.code) # e.g. "482901"
print(code.seconds_remaining) # e.g. 17
# Add/replace TOTP on existing login
identity.set_totp(secret_id, "otpauth://totp/...?secret=...")
# Remove TOTP
identity.remove_totp(secret_id)
unlocked = inkbox.vault.unlock("my-Vault-key-01!")
# Same methods available on UnlockedVault
unlocked.set_totp(secret_id, totp_config_or_uri)
unlocked.remove_totp(secret_id)
code = unlocked.get_totp_code(secret_id)
| Field | Type | Description |
|---|---|---|
code | str | The OTP code (e.g. "482901") |
period_start | int | Unix timestamp when the code became valid |
period_end | int | Unix timestamp when the code expires |
seconds_remaining | int | Seconds until expiry |
inkbox.mailboxes)mailboxes = inkbox.mailboxes.list()
mailbox = inkbox.mailboxes.get("[email protected]")
# To rename, use `identity.update(display_name="New Name")` — the
# mailbox PATCH endpoint hard-rejects `display_name` with a 422. To
# attach a webhook receiver, see "Webhooks" below.
# Switch contact-rule filter mode (admin-only — agent-scoped keys get 403)
updated = inkbox.mailboxes.update(mailbox.email_address, filter_mode="whitelist")
if updated.filter_mode_change_notice:
# Populated when filter_mode actually changed — tells you how many
# rules are now redundant under the new mode.
n = updated.filter_mode_change_notice
print(n.redundant_rule_count, n.redundant_rule_action, n.new_filter_mode)
# Mailbox responses now also carry mailbox.agent_identity_id when the
# mailbox is linked to an identity.
# `mailbox.sending_domain` is the bare domain the mailbox sends from
# (platform default or a verified custom domain — see "Custom email domains" below).
results = inkbox.mailboxes.search(mailbox.email_address, q="invoice", limit=20)
# Mailboxes are deleted via the owning identity's cascade — there is no standalone delete:
# identity.delete() # removes the mailbox + tunnel atomically (cascade)
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 sending_domain_id (standalone) or sending_domain
(identity).
from inkbox import SendingDomainStatus
verified = inkbox.domains.list(status=SendingDomainStatus.VERIFIED)
# Admin-scoped API key only — non-admin keys get 403.
# Returns the bare new default domain name (or None when reverted to platform).
new_default = inkbox.domains.set_default("mail.acme.com")
# Pass the platform domain (e.g. "inkboxmail.com" in prod) to clear the org default.
# Identity create: pick by bare domain name (not id).
inkbox.create_identity("sales-bot", sending_domain="mail.acme.com")
# Force the platform default:
inkbox.create_identity("sales-bot-2", sending_domain=None)
# Standalone mailbox creation is gone — provision via create_identity above.
inkbox.phone_numbers)numbers = inkbox.phone_numbers.list()
number = inkbox.phone_numbers.get("phone-number-uuid")
number = inkbox.phone_numbers.provision(agent_handle="my-agent", type="toll_free")
local = inkbox.phone_numbers.provision(agent_handle="my-agent", type="local", state="NY")
inkbox.phone_numbers.update(
number.id,
incoming_call_action="webhook", # "webhook", "auto_accept", or "auto_reject"
incoming_call_webhook_url="https://...",
)
inkbox.phone_numbers.update(
number.id,
incoming_call_action="auto_accept",
client_websocket_url="wss://...",
)
hits = inkbox.phone_numbers.search_transcripts(number.id, q="refund", party="remote", limit=50)
inkbox.phone_numbers.release(number.id)
Phone numbers carry the same filter_mode / agent_identity_id / filter_mode_change_notice fields as mailboxes; flipping filter_mode is admin-only and returns a change-notice when the value actually changed.
Per-mailbox or per-phone-number allow/block lists, enforced server-side. The active filter_mode on the owning resource controls whether the rules are interpreted as a whitelist or blacklist. Mail matches by exact email or domain; phone matches by exact E.164 number.
from inkbox import (
MailRuleAction, MailRuleMatchType, PhoneRuleAction, PhoneRuleMatchType,
DuplicateContactRuleError,
)
# Mail rules — scoped to a single mailbox. New rules always start active;
# call `update(..., status="paused")` afterwards to pause one.
rule = inkbox.mail_contact_rules.create(
mailbox.email_address,
action=MailRuleAction.ALLOW, # or BLOCK
match_type=MailRuleMatchType.DOMAIN, # or EXACT_EMAIL
match_target="example.com",
)
inkbox.mail_contact_rules.list(mailbox.email_address)
inkbox.mail_contact_rules.get(mailbox.email_address, rule.id)
inkbox.mail_contact_rules.update(mailbox.email_address, rule.id, status="paused") # admin-only
inkbox.mail_contact_rules.delete(mailbox.email_address, rule.id) # admin-only
# Admin-only list; optionally narrow to a single mailbox_id
all_rules = inkbox.mail_contact_rules.list_all(mailbox_id=str(mailbox.id))
# Duplicate (match_type, match_target) on the same mailbox raises 409:
try:
inkbox.mail_contact_rules.create(
mailbox.email_address,
action="allow", match_type="domain", match_target="example.com",
)
except DuplicateContactRuleError as e:
print(e.existing_rule_id) # UUID of the rule that already matched
# Phone rules — same shape, only match_type="exact_number" is supported.
inkbox.phone_contact_rules.create(
number.id,
action=PhoneRuleAction.BLOCK,
match_type=PhoneRuleMatchType.EXACT_NUMBER,
match_target="+15551234567",
)
inkbox.phone_contact_rules.list(number.id)
inkbox.phone_contact_rules.list_all(phone_number_id=str(number.id))
Admin-only address book with per-identity access grants and vCard import/export.
from inkbox import (
Contact, ContactEmail, ContactPhone, ContactAddress,
RedundantContactAccessGrantError,
)
# CRUD
contact = inkbox.contacts.create(
given_name="Ada",
family_name="Lovelace",
emails=[ContactEmail(label="work", value="[email protected]")],
phones=[ContactPhone(label="mobile", value="+15551234567")],
# access_identity_ids defaults to "wildcard" (every active identity);
# pass [] for admin-only, or a list of identity UUIDs for explicit grants.
)
inkbox.contacts.get(str(contact.id))
inkbox.contacts.list(q="ada", order="recent", limit=50, offset=0)
inkbox.contacts.update(str(contact.id), job_title="Analyst") # JSON-merge-patch via kwargs
inkbox.contacts.delete(str(contact.id))
# Reverse-lookup — exactly one filter required (else ValueError before HTTP)
inkbox.contacts.lookup(email="[email protected]")
inkbox.contacts.lookup(email_domain="example.com")
inkbox.contacts.lookup(phone="+15551234567")
inkbox.contacts.lookup(email_contains="ada")
inkbox.contacts.lookup(phone_contains="555")
# Access grants (admin + JWT only; agents can self-revoke)
inkbox.contacts.access.list(str(contact.id))
inkbox.contacts.access.grant(str(contact.id), identity_id="agent-uuid")
inkbox.contacts.access.grant(str(contact.id), wildcard=True) # every active identity
inkbox.contacts.access.revoke(str(contact.id), "agent-uuid")
# Redundant grants (e.g. per-identity on top of wildcard) raise 409
try:
inkbox.contacts.access.grant(str(contact.id), identity_id="agent-uuid")
except RedundantContactAccessGrantError as e:
print(e.error, e.detail_message)
# vCards
result = inkbox.contacts.vcards.import_vcards(vcf_text) # bulk, ≤5 MiB, ≤1000 cards
print(result.created_ids) # list[UUID]
for item in result.errors: # list[ContactImportResultItem]
print(item.index, item.error)
vcf = inkbox.contacts.vcards.export_vcard(str(contact.id)) # vCard 4.0 string
Admin-only free-form notes with per-identity access grants. Identities must be granted access explicitly — there is no wildcard for notes.
note = inkbox.notes.create(body="Customer prefers email follow-up.", title="Ada")
inkbox.notes.get(str(note.id))
inkbox.notes.list(q="email", identity_id="agent-uuid", order="recent", limit=50)
inkbox.notes.update(str(note.id), body="Updated body")
inkbox.notes.update(str(note.id), title=None) # clear title (body cannot be null)
inkbox.notes.delete(str(note.id))
# Access grants (admin + JWT only)
inkbox.notes.access.list(str(note.id))
inkbox.notes.access.grant(str(note.id), identity_id="agent-uuid")
inkbox.notes.access.revoke(str(note.id), "agent-uuid")
# Check the authenticated caller's identity
info = inkbox.whoami()
print(info.auth_type) # "api_key" or "jwt"
print(info.organization_id)
Returns WhoamiApiKeyResponse (with key_id, label, creator_type, auth_subtype, etc.) or WhoamiJwtResponse (with email, org_role, etc.) based on auth_type.
For branching on API-key scope, compare against the exported constants:
from inkbox import (
AUTH_SUBTYPE_API_KEY_ADMIN_SCOPED,
AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_CLAIMED,
AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_UNCLAIMED,
)
if info.auth_type == "api_key" and info.auth_subtype == AUTH_SUBTYPE_API_KEY_ADMIN_SCOPED:
... # admin-only operations (filter_mode flips, rule updates/deletes, etc.)
Bring a local process online at a public https://{name}.inkboxwire.com URL. Outbound HTTP/2 only — no inbound port to open. POSIX only.
# Forward to a local URL (edge mode — Inkbox terminates TLS at the edge)
listener = inkbox.tunnels.connect(
name="my-app",
forward_to="http://127.0.0.1:8080",
)
print(listener.public_url) # https://my-app.inkboxwire.com
listener.wait() # blocks until close()/Ctrl-C
# Forward to an in-process ASGI app (FastAPI / Starlette / your own)
listener = inkbox.tunnels.connect(name="my-app", forward_to=fastapi_app)
# Passthrough TLS (you terminate; SDK auto-signs a cert via the control plane)
listener = inkbox.tunnels.connect(
name="my-app",
tls_mode="passthrough",
forward_to="http://127.0.0.1:8080",
)
Async usage:
async with ...:
listener = inkbox.tunnels.connect(name="my-app", forward_to="http://127.0.0.1:8080")
try:
await listener.serve_forever()
finally:
await listener.aclose()
wait()/close() and serve_forever()/aclose() are mutually exclusive — pick one pair.
Tunnels are provisioned atomically by inkbox.create_identity(...); there is no standalone create / delete / restore / rotate_secret surface. For passthrough, opt in at create time: inkbox.create_identity("my-app", tunnel={"tls_mode": "passthrough"}) — tls_mode is fixed at create.
Reads + edit:
inkbox.tunnels.list() # list[Tunnel]
inkbox.tunnels.get("tunnel-uuid")
inkbox.tunnels.update( # metadata-only
"tunnel-uuid",
metadata={"team": "gtm"},
)
# Passthrough only:
inkbox.tunnels.sign_csr("tunnel-uuid", csr_pem=csr_bytes)
Data-plane auth uses the same api_key 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.api_keys.create(scoped_identity_id=...). Selected connect() kwargs: pool_size (1–32), state_dir (default ~/.inkbox/tunnels/{name}), on_status callback, allow_remote_forwarding=False (loopback-only allowlist), forward_to_verify_tls=True. 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 TS examples, see skills/inkbox-tunnels/SKILL.md.
Webhooks are configured directly on the mailbox or phone number — no separate registration.
import json
from typing import cast
from inkbox import (
verify_webhook,
MailWebhookPayload, TextWebhookPayload, PhoneIncomingCallWebhookPayload,
)
# Rotate signing key (plaintext returned once — save it)
key = inkbox.create_signing_key()
# Verify, then parse + discriminate
if not verify_webhook(payload=raw_body, headers=request.headers, secret="whsec_..."):
raise HTTPException(status_code=403)
payload = cast(TextWebhookPayload, json.loads(raw_body))
if payload["event_type"] == "text.delivery_failed":
msg = payload["data"]["text_message"]
logger.error("SMS failed: %s (%s)", msg["error_code"], msg["error_detail"])
Algorithm: HMAC-SHA256 over "{request_id}.{timestamp}.{body}".
Event taxonomy:
message.received, message.sent, message.forwarded, message.delivered, message.bounced, message.failed. Subscribe via inkbox.webhooks.subscriptions.create(mailbox_id=..., url=..., event_types=[...]).text.received, text.sent, text.delivered, text.delivery_failed, text.delivery_unconfirmed. Subscribe via inkbox.webhooks.subscriptions.create(phone_number_id=..., url=..., event_types=[...]). 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 None and per-recipient state lives in recipients[].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(agent_identity_id=..., url=..., event_types=[...]) — 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 imessage_enabled; contact-rule-blocked traffic is never delivered.PhoneIncomingCallWebhookPayload on a phone number's incoming_call_webhook_url. Not subscribable; the URL stays on the phone-number resource because the response (action: "answer" | "reject" + optional client_websocket_url) decides the call's fate. Non-200, invalid bodies, and timeouts are treated as "decline routing" by Inkbox.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, message. / text. / imessage. prefix matching the owner's channel) so most shape mistakes surface as ValueError 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.
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"] (None 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 None — 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, TextMessageRecipientWire, plus event-type Literal unions (MailWebhookEventType, TextWebhookEventType, IMessageWebhookEventType) and wire enums (MessageStatus, CallStatusWire, HangupReasonWire, SmsDeliveryStatusWire, etc.). All fields are snake_case TypedDicts to match the raw JSON body.
from inkbox import (
InkboxAPIError,
DuplicateContactRuleError,
RedundantContactAccessGrantError,
)
try:
identity = inkbox.get_identity("unknown")
except InkboxAPIError as e:
print(e.status_code) # HTTP status (e.g. 404)
print(e.detail) # str for legacy errors, dict for structured ones
InkboxAPIError.detail can now be a dict for structured responses (e.g. contact-rule / access conflicts). Catch the narrower subclasses when you need the parsed fields:
DuplicateContactRuleError — 409 when creating a contact rule with an already-taken (match_type, match_target) on the same resource. Exposes .existing_rule_id: UUID.RedundantContactAccessGrantError — 409 when a contact-access grant is redundant (e.g. per-identity grant on top of an active wildcard). Exposes .error and .detail_message.iter_emails() / iter_unread_emails() return Iterator[Message] — auto-paginated, lazylist_calls() returns list[PhoneCall] — offset pagination, not an iteratorfield=NoneInkbox client must be used as a context manager (with statement) or .close() called manuallyAgentIdentity raise InkboxError if the relevant channel isn't assignednpx claudepluginhub inkbox-ai/inkbox --plugin inkboxProvides API-first communication infrastructure for AI agents using the Inkbox TypeScript SDK, enabling email, phone, SMS, iMessage, contacts, notes, and encrypted vault features.
Send and receive SMS/MMS with Telnyx Python SDK, handle opt-outs and delivery webhooks. For notifications, 2FA, or messaging apps.
Sends transactional emails via the Resend API, manages templates, webhooks, domains, contacts, broadcasts, and automations. Use when working with Resend email infrastructure.