Skip to main content

Python SDK for the Inkbox API

Reason this release was yanked:

Please use 0.4.6+

Project description

inkbox

Python 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

pip install inkbox

Requires Python ≥ 3.11.

Authentication

You'll need an API key to use this SDK. Get one at inkbox.ai/console.

Quick start

import os
from inkbox import Inkbox

with Inkbox(
    api_key=os.environ["INKBOX_API_KEY"],
    vault_key=os.environ.get("INKBOX_VAULT_KEY"),
) as inkbox:
    # Create an agent identity with a linked mailbox
    identity = inkbox.create_identity("support-bot", display_name="Support Bot")
    identity.provision_phone_number(type="toll_free")

    # Send email directly from the identity
    identity.send_email(
        to=["customer@example.com"],
        subject="Your order has shipped",
        body_text="Tracking number: 1Z999AA10123456784",
    )

    # Place an outbound call
    identity.place_call(
        to_number="+18005559999",
        client_websocket_url="wss://my-app.com/voice",
    )

    # Read inbox
    for message in identity.iter_emails():
        print(message.subject)

    # List calls
    calls = identity.list_calls()

    # Access credentials (vault unlocked at construction)
    for login in identity.credentials.list_logins():
        print(login.name, login.payload.username)

Authentication

Argument Type Default Description
api_key str required Your ApiKey_... token
base_url str API default Override for self-hosting or testing
timeout float 30.0 Request timeout in seconds

Use with Inkbox(...) as inkbox: (recommended) or call inkbox.close() manually to clean up HTTP connections.


Agent Signup

Agents can self-register without a pre-existing API key. All signup methods are class methods — no Inkbox instance required.

from inkbox import Inkbox

# Sign up (public — no API key needed)
result = Inkbox.signup(
    human_email="john@example.com",
    note_to_human="Hey John, this is your sales bot signing up!",  # required
    display_name="Sales Agent",          # optional
    agent_handle="sales-agent",          # optional
    email_local_part="sales.agent",      # optional
)
api_key = result.api_key          # save — shown only once
email = result.email_address      # e.g. "sales-agent-a1b2c3@inkboxmail.com"
handle = result.agent_handle      # e.g. "sales-agent-a1b2c3"

# Verify (after human shares the 6-digit code from the email)
Inkbox.verify_signup(api_key, verification_code="483921")

# Resend verification email (5-minute cooldown)
Inkbox.resend_signup_verification(api_key)

# Check status and restrictions
status = Inkbox.get_signup_status(api_key)
print(status.claim_status)                    # "agent_unclaimed" or "agent_claimed"
print(status.restrictions.max_sends_per_day)  # 10 (unclaimed) or 500 (claimed)
Method Auth Returns
Inkbox.signup(human_email, *, note_to_human, display_name=None, agent_handle=None, email_local_part=None) None AgentSignupResponse
Inkbox.verify_signup(api_key, verification_code) API key AgentSignupVerifyResponse
Inkbox.resend_signup_verification(api_key) API key AgentSignupResendResponse
Inkbox.get_signup_status(api_key) API key AgentSignupStatusResponse

signup() requires human_email and note_to_human. display_name, agent_handle, and email_local_part are optional. All methods accept optional base_url and timeout keyword arguments.

Note: Unclaimed agents have a limited send quota and can only email the human_email specified at signup. After verification or human approval in the console, full capabilities are unlocked.

Note: The organization_id returned at signup may change after verification or human approval. Always use the organization_id from the most recent response (verify_signup or resend_signup_verification) rather than caching the value from the initial signup() call.


Identities

inkbox.create_identity() and inkbox.get_identity() return an AgentIdentity object that holds the identity's channels and exposes convenience methods scoped to those channels.

# create_identity atomically provisions the mailbox AND the tunnel —
# both come back on the response. Phone numbers stay opt-in.
identity = inkbox.create_identity(
    "sales-bot",
    display_name="Sales Bot",
    description="Sales-outreach agent",
)
phone = identity.provision_phone_number(type="toll_free")

print(identity.email_address)            # sales-bot@inkboxmail.com
print(identity.tunnel.public_host)       # sales-bot.inkboxwire.com
print(phone.number)

# Pin the identity's mailbox to a verified custom sending domain
# (bare name; see "Custom Sending Domains" below).
inkbox.create_identity("sales-bot-2", sending_domain="mail.acme.com")

# Provision a passthrough tunnel (tls_mode is fixed at create time)
from inkbox import IdentityTunnelCreateOptions
inkbox.create_identity("sales-bot-pt", tunnel=IdentityTunnelCreateOptions(tls_mode="passthrough"))

# Get an existing identity
identity = inkbox.get_identity("sales-bot")
identity.refresh()  # re-fetch channels from API

# List all identities for your org
all_identities = inkbox.list_identities()

# Update handle, display name, description, status. For description,
# pass None to clear and omit the kwarg to leave untouched.
identity.update(status="paused")
identity.update(new_handle="sales-bot-v2")
identity.update(display_name="New Name", description="New blurb")
identity.update(description=None)  # clear

# Release the phone number (vendor + local).
identity.release_phone_number()

# Delete (cascades to mailbox + tunnel + phone-number release; revokes scoped API keys).
identity.delete()

Identity visibility

Control which other agent identities can see this identity in API responses. Humans and admins always see every identity regardless.

identity = inkbox.get_identity("sales-bot")

# List the current visibility rules. Either a single wildcard row
# (viewer_identity_id is None — every active identity sees it) or
# explicit per-viewer rows. An empty list means no agent can see it.
rules = identity.list_access()

# Grant one viewer identity visibility
viewer = inkbox.get_identity("support-bot")
identity.grant_access(viewer.id)

# Make it visible to every active identity in the org (wildcard)
identity.grant_access(None)

# Revoke one viewer (keyed by the viewer identity's UUID)
identity.revoke_access(viewer.id)

Mail

# Send an email (plain text and/or HTML)
sent = identity.send_email(
    to=["user@example.com"],
    subject="Hello from Inkbox",
    body_text="Hi there!",
    body_html="<p>Hi there!</p>",
    cc=["manager@example.com"],
    bcc=["archive@example.com"],
)

# Send a threaded reply
identity.send_email(
    to=["user@example.com"],
    subject=f"Re: {sent.subject}",
    body_text="Following up!",
    in_reply_to_message_id=sent.id,
)

# Send with attachments
identity.send_email(
    to=["user@example.com"],
    subject="See attached",
    body_text="Please find the file attached.",
    attachments=[{
        "filename": "report.pdf",
        "content_type": "application/pdf",
        "content_base64": "<base64-encoded-content>",
    }],
)

# Iterate inbox (paginated automatically)
for msg in identity.iter_emails():
    print(msg.subject, msg.from_address, msg.is_read)

# Filter by direction: "inbound" or "outbound"
for msg in identity.iter_emails(direction="inbound"):
    print(msg.subject)

# Iterate only unread emails
for msg in identity.iter_unread_emails():
    print(msg.subject)

# Mark messages as read
identity.mark_emails_read([msg.id for msg in identity.iter_unread_emails()])

# Get all emails in a thread (thread_id comes from msg.thread_id)
thread = identity.get_thread(msg.thread_id)
for m in thread.messages:
    print(m.subject, m.from_address)

Phone

# Place an outbound call — stream audio over WebSocket
call = identity.place_call(
    to_number="+15551234567",
    client_websocket_url="wss://your-agent.example.com/ws",
)
print(call.status, call.rate_limit.calls_remaining)

# List calls (paginated)
calls = identity.list_calls(limit=10, offset=0)
for call in calls:
    print(call.id, call.direction, call.remote_phone_number, call.status)

# Fetch transcript segments for a call
segments = identity.list_transcripts(calls[0].id)
for t in segments:
    print(f"[{t.party}] {t.text}")  # party: "local" or "remote"

# Read transcripts across all recent calls
for call in identity.list_calls(limit=10):
    segments = identity.list_transcripts(call.id)
    if not segments:
        continue
    print(f"\n--- Call {call.id} ({call.direction}) ---")
    for t in segments:
        print(f"  [{t.party:6}] {t.text}")

# Filter to only the remote party's speech
for t in identity.list_transcripts(calls[0].id):
    if t.party == "remote":
        print(t.text)

# Search transcripts across a phone number (org-level)
hits = inkbox.phone_numbers.search_transcripts(phone.id, q="refund", party="remote")
for t in hits:
    print(f"[{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):

  • Outbound SMS is currently allowed only from local numbers, not toll-free.
  • Each sender phone number is rate-limited to 15 outbound texts per rolling 24-hour window.
  • A new local number takes ~10-15 minutes for the 10DLC campaign to propagate at the carrier — phone_number.sms_status reads pending until then, and sends will return 409 sender_sms_pending.
  • The recipient must have texted START to any number within your organization to opt in. Unknown recipients will fail with 403 recipient_not_opted_in; recipients who later send STOP flip to 403 recipient_opted_out. You can inspect consent state directly via inkbox.sms_opt_ins — see SMS Opt-Ins.

Coming soon:

  • Toll-free SMS sending.
  • Customer-managed 10DLC brands and campaigns, which lift the per-number 24-hour limit dramatically.
# Send an SMS. Returns a queued TextMessage; final delivery state arrives
# via the incoming_text_webhook_url configured on the sender.
sent = identity.send_text(to="+15551234567", text="Hello from Inkbox")
print(sent.id, sent.delivery_status)   # SmsDeliveryStatus.QUEUED

# List text messages
texts = identity.list_texts(limit=20)
for t in texts:
    print(t.remote_phone_number, t.text, t.is_read)

# Filter to unread only
unread = identity.list_texts(is_read=False)

# Get a single text
text = identity.get_text("text-uuid")
print(text.type)  # "sms" or "mms"
if text.media:    # MMS attachments (temporary signed URLs)
    for m in text.media:
        print(m.content_type, m.size, m.url)

# List conversation summaries (one row per remote number)
convos = identity.list_text_conversations(limit=20)
for c in convos:
    print(c.remote_phone_number, c.latest_text, c.unread_count)

# Get messages in a specific conversation
msgs = identity.get_text_conversation("+15551234567", limit=50)

# Mark as read
identity.mark_text_read("text-uuid")
identity.mark_text_conversation_read("+15551234567")

# Org-level: search and delete
results = inkbox.texts.search(phone.id, q="invoice", limit=20)
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 Clerk JWT.

from inkbox import SmsOptInStatus

# List the 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)

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.)
inkbox.sms_opt_ins.opt_in("+15551234567")

# Honor an opt-out collected outside of inbound STOP
inkbox.sms_opt_ins.opt_out("+15551234567")

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)
inkbox.vault.unlock("my-Vault-key-01!")

identity = inkbox.get_identity("my-agent")

# Discovery — list credentials this identity has access to
for login in identity.credentials.list_logins():
    print(login.name, login.payload.username)

for key in identity.credentials.list_api_keys():
    print(key.name, key.payload.access_key)

# Access by UUID — returns the 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

# Generic access
secret = identity.credentials.get("secret-uuid")             # → DecryptedVaultSecret

Vault Management

Manage the encrypted vault at the org level. Access via inkbox.vault.

# Get vault metadata (key counts, secret counts)
info = inkbox.vault.info()
print(info.secret_count, info.key_count)

# Initialize a new vault (creates primary key + recovery keys)
result = inkbox.vault.initialize("my-Vault-key-01!")
for recovery_key in result.recovery_keys:
    print(recovery_key.recovery_code)  # save these immediately

# Rotate the vault password
inkbox.vault.update_key("new-Vault-key-02!", current_vault_key="my-Vault-key-01!")

# Rotate using a recovery code (if primary key is lost)
inkbox.vault.update_key("new-Vault-key-02!", recovery_code="recovery-code-here")

# List vault keys
keys = inkbox.vault.list_keys()                         # all keys
primary_keys = inkbox.vault.list_keys(key_type="PRIMARY")
recovery_keys = inkbox.vault.list_keys(key_type="RECOVERY")

# List secrets (metadata only — no encrypted payloads)
secrets = inkbox.vault.list_secrets()
logins  = inkbox.vault.list_secrets(secret_type="login")

# Delete a secret
inkbox.vault.delete_secret("secret-uuid")

# Unlock the vault for decryption (returns an UnlockedVault)
unlocked = inkbox.vault.unlock("my-Vault-key-01!")
secret = unlocked.get_secret("secret-uuid")
print(secret.name, secret.payload)

Access control

Control which identities can access which secrets.

# List access rules for a secret
rules = inkbox.vault.list_access_rules("secret-uuid")
for rule in rules:
    print(rule.identity_id)

# Grant an identity access to a secret
inkbox.vault.grant_access("secret-uuid", "identity-uuid")

# Revoke access
inkbox.vault.revoke_access("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.

from inkbox.vault.models import LoginPayload, APIKeyPayload

identity = inkbox.get_identity("my-agent")

# Create a secret and auto-grant this identity access
secret = identity.create_secret(
    name="CRM Login",
    payload=LoginPayload(username="bot@crm.com", password="s3cret"),
    description="CRM service account",
)

# Fetch and decrypt a secret
decrypted = identity.get_secret(secret.id)
print(decrypted.payload.username)

# Delete a secret
identity.delete_secret(secret.id)

# Revoke this identity's access (without deleting the secret)
identity.revoke_credential_access(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)
identity.set_totp(secret.id, "otpauth://totp/Example:user?secret=JBSWY3DPEHPK3PXP&issuer=Example")

# Generate the current TOTP code
code = identity.get_totp_code(secret.id)
print(code.code, code.expires_in)

# Remove TOTP from a secret
identity.remove_totp(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 msg in inkbox.messages.list("abc@inkboxmail.com"):
    print(msg.subject)

# Get a single message with full body
detail = inkbox.messages.get("abc@inkboxmail.com", "message-uuid")
print(detail.body_text)

# Send a message from a mailbox
inkbox.messages.send(
    "abc@inkboxmail.com",
    to=["user@example.com"],
    subject="Hello",
    body_text="Hi there!",
)

# Update message flags
inkbox.messages.update_flags("abc@inkboxmail.com", "message-uuid", is_read=True)
inkbox.messages.mark_read("abc@inkboxmail.com", "message-uuid")
inkbox.messages.mark_unread("abc@inkboxmail.com", "message-uuid")
inkbox.messages.star("abc@inkboxmail.com", "message-uuid")
inkbox.messages.unstar("abc@inkboxmail.com", "message-uuid")

# Delete a message
inkbox.messages.delete("abc@inkboxmail.com", "message-uuid")

# Get a temporary signed URL for an attachment
attachment = inkbox.messages.get_attachment("abc@inkboxmail.com", "message-uuid", "report.pdf")
print(attachment["url"])

# List threads (paginated automatically)
for thread in inkbox.threads.list("abc@inkboxmail.com"):
    print(thread.subject, thread.message_count)

# Get a thread with all messages
thread = inkbox.threads.get("abc@inkboxmail.com", "thread-uuid")

# Delete a thread
inkbox.threads.delete("abc@inkboxmail.com", "thread-uuid")

Org-level Calls and Transcripts

Access calls and transcripts directly. Access via inkbox.calls and inkbox.transcripts.

# List calls for a phone number
calls = inkbox.calls.list("phone-number-uuid", limit=10)
for call in calls:
    print(call.id, call.direction, call.status)

# Get a single call
call = inkbox.calls.get("phone-number-uuid", "call-uuid")

# Place an outbound call
call = inkbox.calls.place(
    from_number="phone-number-uuid",
    to_number="+15551234567",
    client_websocket_url="wss://example.com/ws",
)

# List transcript segments for a call
segments = inkbox.transcripts.list("phone-number-uuid", "call-uuid")
for t in segments:
    print(f"[{t.party}] {t.text}")

Org-level Mailboxes

Mailboxes are provisioned atomically by inkbox.create_identity(...) and removed by identity.delete() (cascade). The inkbox.mailboxes surface is read + update + search only.

# List all mailboxes in the organisation
mailboxes = inkbox.mailboxes.list()

# Get a specific mailbox
mailbox = inkbox.mailboxes.get("abc-xyz@inkboxmail.com")
print(mailbox.email_address)
print(mailbox.sending_domain)        # bare domain the mailbox sends from
print(mailbox.agent_identity_id)     # non-null for live customer mailboxes (1:1 invariant)

# Update webhook URL or filter mode. display_name has moved to the
# identity — set it via identity.update(display_name=...). The mailbox
# PATCH endpoint hard-rejects display_name with a 422.
inkbox.mailboxes.update(mailbox.email_address, webhook_url="https://example.com/hook")
inkbox.mailboxes.update(mailbox.email_address, webhook_url=None)  # remove webhook
inkbox.mailboxes.update(mailbox.email_address, filter_mode="whitelist")  # admin-scoped key only

# Full-text search across messages in a mailbox
results = inkbox.mailboxes.search(mailbox.email_address, q="invoice", limit=20)
for msg in results:
    print(msg.subject, msg.from_address)

# To remove a mailbox, delete its owning identity (cascades to the
# linked mailbox AND tunnel; revokes scoped API keys):
inkbox.get_identity("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 sending_domain to create_identity. Domain registration, DNS records, verification, DKIM rotation, and deletion stay in the console.

from inkbox import SendingDomainStatus

# List custom sending domains for the org (optionally filter by status)
verified = inkbox.domains.list(status=SendingDomainStatus.VERIFIED)
for d in verified:
    print(d.id, d.domain, d.status, d.is_default)

# Set the org default — admin-scoped API key only.
# 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 revert.
inkbox.domains.set_default("inkboxmail.com")  # -> None

Org-level Phone Numbers

Read, search, and release phone numbers org-wide via inkbox.phone_numbers. Provisioning still goes through an identity — pass agent_handle so the new number is bound to it from the start.

# List all phone numbers in the organisation
numbers = inkbox.phone_numbers.list()

# Get a specific phone number by ID
number = inkbox.phone_numbers.get("phone-number-uuid")

# Provision a new number
number = inkbox.phone_numbers.provision(agent_handle="sales-bot", type="toll_free")
local  = inkbox.phone_numbers.provision(agent_handle="sales-bot", type="local", state="NY")

# Update incoming call behaviour
inkbox.phone_numbers.update(
    number.id,
    incoming_call_action="webhook",
    incoming_call_webhook_url="https://example.com/calls",
)
inkbox.phone_numbers.update(
    number.id,
    incoming_call_action="auto_accept",
    client_websocket_url="wss://example.com/ws",
)

# Full-text search across transcripts
hits = inkbox.phone_numbers.search_transcripts(number.id, q="refund", party="remote")
for t in hits:
    print(f"[{t.party}] {t.text}")

# Release a number
inkbox.phone_numbers.release(number.id)

Tunnels

Bring a local Python process online at a public https://{name}.inkboxwire.com URL via outbound HTTP/2. No inbound port to open, no static IP needed. POSIX only.

with Inkbox(api_key="ApiKey_...") as inkbox:
    # Forward to a local HTTP server (edge mode — Inkbox terminates TLS)
    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

    # Or forward to an in-process ASGI app (FastAPI / Starlette / yours)
    listener = inkbox.tunnels.connect(name="my-app", forward_to=fastapi_app)

    # Passthrough TLS — tls_mode is fixed at identity-create time:
    inkbox.create_identity("my-app-pt", tunnel={"tls_mode": "passthrough"})
    listener = inkbox.tunnels.connect(
        name="my-app-pt",
        forward_to="http://127.0.0.1:8080",
    )

Async variant (serve_forever() / aclose()) is available for callers already inside an event loop. Pick one pair; don't mix wait/close with the async APIs.

Tunnels are provisioned atomically by inkbox.create_identity(...); there is no standalone create / delete / restore / rotate_secret surface. Read + edit on the resource:

inkbox.tunnels.list()
inkbox.tunnels.get("tunnel-uuid")
inkbox.tunnels.update("tunnel-uuid", metadata={"team": "gtm"})
# Passthrough only:
inkbox.tunnels.sign_csr("tunnel-uuid", csr_pem=csr_bytes)

Data-plane authentication 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 scoped key via inkbox.api_keys.create(scoped_identity_id=...). There is no per-tunnel connect secret to rotate. State (passthrough cert/key, cached tunnel id) lives under ~/.inkbox/tunnels/{name}/; treat it like an SSH key dir. forward_to is loopback-only by default; pass allow_remote_forwarding=True after reviewing the SSRF tradeoff.


Webhooks

Webhooks are configured on the mailbox or phone number resource — no separate registration step.

Mailbox webhooks

Set a URL on a mailbox to receive every mail event for that mailbox.

# Set webhook
inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url="https://example.com/hook")

# Remove webhook
inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url=None)

The same URL receives all six mail event types:

event_type When
message.received A new inbound message was stored.
message.sent An outbound message was accepted for delivery.
message.forwarded A stored message was forwarded out.
message.delivered Downstream delivery succeeded.
message.bounced Downstream delivery bounced.
message.failed Delivery ultimately failed.

Every payload uses the standard {event_type, timestamp, data} envelope. data["contacts"] is a list of address-book matches (always present, possibly empty) — inbound events resolve the sender plus every CC, outbound events resolve every To + CC + BCC. Each entry is {"bucket": "from" | "to" | "cc" | "bcc", "address", "id", "name"}; receivers should pair to the source field by (bucket, address) since the same address may legally appear in multiple buckets on a single send. data["message"]["bcc_addresses"] is populated only on outbound events (the sending mailbox owner already knows the BCC list); inbound payloads carry None (BCC is not visible to recipients).

Phone webhooks

Phone numbers have two independent webhook URLs:

  • incoming_call_webhook_url — receives the flat, synchronous inbound-call payload. Your response (action: "answer" | "reject" plus optional client_websocket_url) decides what happens to the call. Non-200 responses, invalid bodies, and timeouts are treated as "decline routing" by Inkbox.
  • incoming_text_webhook_url — receives all five text lifecycle events: text.received (inbound), and the four outbound transitions text.sent, text.delivered, text.delivery_failed, text.delivery_unconfirmed. Fire-and-forget.
# Route incoming calls to a webhook
inkbox.phone_numbers.update(
    number.id,
    incoming_call_action="webhook",
    incoming_call_webhook_url="https://example.com/calls",
    incoming_text_webhook_url="https://example.com/texts",
)

The inbound-call payload is flat — no envelope — and carries a singular contact: {"id", "name"} | None at the top level. Text payloads use the standard envelope with data["contact"] (singular) and data["text_message"]. Each phone/text event has exactly one remote party, so the contact shape stays singular there; only mail uses the per-bucket list. The text-message body includes the full delivery-state block (delivery_status, error_code, error_detail, sent_at, delivered_at, failed_at) so receivers can act on outbound failures without a follow-up API call.

Receiving webhooks (typed)

The SDK exports TypedDict wire shapes for every payload. Pair verify_webhook with cast(TextWebhookPayload, json.loads(body)) and discriminate on event_type:

import json
from typing import cast

from inkbox import (
    MailWebhookPayload,
    PhoneIncomingCallWebhookPayload,
    TextWebhookPayload,
    verify_webhook,
)

# FastAPI
@app.post("/hooks/mail")
async def mail_hook(request: Request):
    raw_body = await request.body()
    if not verify_webhook(payload=raw_body, headers=request.headers, secret="whsec_..."):
        raise HTTPException(status_code=403)
    payload = cast(MailWebhookPayload, json.loads(raw_body))
    for match in payload["data"]["contacts"]:
        logger.info(
            "%s %s -> %s (%s)",
            match["bucket"], match["address"], match["name"], match["id"],
        )

@app.post("/hooks/text")
async def text_hook(request: Request):
    raw_body = await request.body()
    if not verify_webhook(payload=raw_body, headers=request.headers, secret="whsec_..."):
        raise HTTPException(status_code=403)
    payload = cast(TextWebhookPayload, json.loads(raw_body))
    match payload["event_type"]:
        case "text.delivery_failed":
            msg = payload["data"]["text_message"]
            logger.error(
                "SMS to %s failed: %s (%s)",
                msg["remote_phone_number"], msg["error_code"], msg["error_detail"],
            )
        case "text.delivered":
            # delivery_status, sent_at, delivered_at are all populated.
            ...
        case "text.received":
            contact = payload["data"]["contact"]
            if contact is not None:
                logger.info("inbound from known contact %s", contact["id"])

Wire shapes are intentionally snake_case (the raw JSON body, not the SDK's parsed dataclasses) so json.loads(body) round-trips into the TypedDict without a transformer. Enum-valued fields like direction, status, and delivery_status are Literal[...] string unions rather than the SDK's StrEnums — json.loads produces bare strings, and Literal unions narrow cleanly under mypy / pyright.


Whoami

# Check the authenticated caller's identity
info = inkbox.whoami()
print(info.auth_type)        # "api_key" or "jwt"
print(info.organization_id)

# Narrow by auth type
if isinstance(info, inkbox.WhoamiApiKeyResponse):
    print(info.key_id, info.label)
elif isinstance(info, inkbox.WhoamiJwtResponse):
    print(info.email, info.org_role)

Signing Keys

# Create or rotate the org-level webhook signing key (plaintext returned once)
key = inkbox.create_signing_key()
print(key.signing_key)  # save this immediately

Verifying Webhook Signatures

Use verify_webhook to confirm that an incoming request was sent by Inkbox.

from inkbox import verify_webhook

# FastAPI
@app.post("/hooks/mail")
async def mail_hook(request: Request):
    raw_body = await request.body()
    if not verify_webhook(
        payload=raw_body,
        headers=request.headers,
        secret="whsec_...",
    ):
        raise HTTPException(status_code=403)
    ...

# Flask
@app.post("/hooks/mail")
def mail_hook():
    raw_body = request.get_data()
    if not verify_webhook(
        payload=raw_body,
        headers=request.headers,
        secret="whsec_...",
    ):
        abort(403)
    ...

Examples

Runnable example scripts are available in the examples/python directory:

Script What it demonstrates
register_agent_identity.py Create an identity with a linked mailbox and phone number
agent_send_email.py Send an email and a threaded reply
read_agent_messages.py List messages and threads
create_agent_mailbox.py Create, update, search, and delete a mailbox
create_agent_phone_number.py Provision, update, and release a number
list_agent_phone_numbers.py List all phone numbers in the org
read_agent_calls.py List calls and print transcripts
receive_agent_email_webhook.py Register and delete a mailbox webhook
receive_agent_call_webhook.py Register, update, and delete a phone webhook

License

MIT

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

inkbox-0.4.4.tar.gz (221.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

inkbox-0.4.4-py3-none-any.whl (179.6 kB view details)

Uploaded Python 3

File details

Details for the file inkbox-0.4.4.tar.gz.

File metadata

  • Download URL: inkbox-0.4.4.tar.gz
  • Upload date:
  • Size: 221.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.10 {"installer":{"name":"uv","version":"0.10.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for inkbox-0.4.4.tar.gz
Algorithm Hash digest
SHA256 5b3db68670ea94d6a2318767afb77e6928590ce5ab0540fac5852f90fe5d5ddd
MD5 5260f936946862d134cadb87e8a7c498
BLAKE2b-256 9b1ad7cfbeffc6069614cee047e75015579c68932ae864ecb112b9065de6dc62

See more details on using hashes here.

File details

Details for the file inkbox-0.4.4-py3-none-any.whl.

File metadata

  • Download URL: inkbox-0.4.4-py3-none-any.whl
  • Upload date:
  • Size: 179.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.10 {"installer":{"name":"uv","version":"0.10.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for inkbox-0.4.4-py3-none-any.whl
Algorithm Hash digest
SHA256 4752fdb6b764bfadf4ce5ecad791ad0628dc5972bef9126fca32d44685e2d854
MD5 ff5bfaefbf2dd65c300baef04405fe7f
BLAKE2b-256 0d52c3020253a0b22c68834afe61783e4b76afc4127ac45bf7280bc6b368ad89

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page