diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..5075ade
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,66 @@
+# .github/workflows/tests.yml
+
+name: Lint & Unit Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+jobs:
+ python:
+ name: Python – Lint & Test
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash -l {0}
+ working-directory: python
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v3
+ with:
+ version: "latest"
+
+ - name: Cache uv dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/uv
+ key: ${{ runner.os }}-uv-python-${{ hashFiles('python/pyproject.toml') }}
+
+ - name: Install dependencies
+ run: uv sync --extra dev
+
+ - name: Lint
+ run: uv run ruff check .
+
+ - name: Run unit tests
+ run: uv run pytest tests/ -v --cov=inkbox --cov-report=term-missing --cov-fail-under=70
+
+ typescript:
+ name: TypeScript – Lint & Build
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash -l {0}
+ working-directory: typescript
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "18"
+
+ - name: Install dependencies
+ run: npm install --ignore-scripts
+
+ - name: Type check
+ run: npx tsc --noEmit
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..df4f98c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Vectorly, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 79f1ed3..547c4f8 100644
--- a/README.md
+++ b/README.md
@@ -1,93 +1,561 @@
-# Inkbox Mail SDK
+# Inkbox SDK
-Official SDKs for the [Inkbox Mail API](https://inkbox.ai) — API-first email for AI agents.
+Official SDKs for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (identities, email, phone).
| Package | Language | Install |
|---|---|---|
-| [`inkbox-mail`](./python/) | Python ≥ 3.11 | `pip install inkbox-mail` |
-| [`@inkbox/mail`](./typescript/) | TypeScript / Node ≥ 18 | `npm install @inkbox/mail` |
+| [`inkbox`](./python/) | Python ≥ 3.11 | `pip install inkbox` |
+| [`@inkbox/sdk`](./typescript/) | TypeScript / Node ≥ 18 | `npm install @inkbox/sdk` |
---
-## Python
+## Authentication
-```python
-import asyncio
-from inkbox_mail import InkboxMail
+All SDK calls require an API key. You can obtain one from the [Inkbox Console](https://console.inkbox.ai/).
+
+---
-async def main():
- async with InkboxMail(api_key="sk-...") as client:
+## Identities
- # Create a mailbox
- mailbox = await client.mailboxes.create(display_name="Agent 01")
+Agent identities are the central concept — a named identity (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. Use `Inkbox` as the org-level entry point to create and retrieve identities.
+
+### Python
+
+```python
+from inkbox import Inkbox
- # Send an email
- await client.messages.send(
- mailbox.id,
- to=["user@example.com"],
- subject="Hello from Inkbox",
- body_text="Hi there!",
- )
+with Inkbox(api_key="ApiKey_...") as inkbox:
+ # Create an identity — returns an AgentIdentity object
+ identity = inkbox.create_identity("sales-agent")
- # Iterate over all messages (pagination handled automatically)
- async for msg in client.messages.list(mailbox.id):
- print(msg.subject, msg.from_address)
+ # Create and link new channels
+ mailbox = identity.create_mailbox(display_name="Sales Agent")
+ phone = identity.provision_phone_number(type="toll_free")
- # Reply to a message
- detail = await client.messages.get(mailbox.id, msg.id)
- await client.messages.send(
- mailbox.id,
- to=detail.to_addresses,
- subject=f"Re: {detail.subject}",
- body_text="Got it, thanks!",
- in_reply_to_message_id=detail.message_id,
- )
+ print(mailbox.email_address)
+ print(phone.number)
- # Search
- results = await client.mailboxes.search(mailbox.id, q="invoice")
+ # Link an existing mailbox or phone number instead of creating new ones
+ identity.assign_mailbox("mailbox-uuid-here")
+ identity.assign_phone_number("phone-number-uuid-here")
- # Webhooks (secret is one-time — save it immediately)
- hook = await client.webhooks.create(
- mailbox.id,
- url="https://yourapp.com/hooks/mail",
- event_types=["message.received"],
- )
- print(hook.secret) # save this
+ # Unlink channels without deleting them
+ identity.unlink_mailbox()
+ identity.unlink_phone_number()
-asyncio.run(main())
+ # List, get, update, delete, refresh
+ identities = inkbox.list_identities()
+ identity = inkbox.get_identity("sales-agent")
+ identity.update(status="paused") # or new_handle="new-name"
+ identity.refresh() # re-fetch from API, updates cached channels
+ identity.delete() # soft-delete; unlinks channels
```
-## TypeScript
+### TypeScript
```ts
-import { InkboxMail } from "@inkbox/mail";
+import { Inkbox } from "@inkbox/sdk";
+
+const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
+
+// Create an identity — returns an AgentIdentity object
+const identity = await inkbox.createIdentity("sales-agent");
+
+// Create and link new channels
+const mailbox = await identity.createMailbox({ displayName: "Sales Agent" });
+const phone = await identity.provisionPhoneNumber({ type: "toll_free" });
+
+console.log(mailbox.emailAddress);
+console.log(phone.number);
+
+// Link an existing mailbox or phone number instead of creating new ones
+await identity.assignMailbox("mailbox-uuid-here");
+await identity.assignPhoneNumber("phone-number-uuid-here");
+
+// Unlink channels without deleting them
+await identity.unlinkMailbox();
+await identity.unlinkPhoneNumber();
+
+// List, get, update, delete, refresh
+const identities = await inkbox.listIdentities();
+const i = await inkbox.getIdentity("sales-agent");
+await i.update({ status: "paused" }); // or newHandle: "new-name"
+await i.refresh(); // re-fetch from API, updates cached channels
+await i.delete(); // soft-delete; unlinks channels
+```
+
+---
+
+## Mail
+
+### Python
+
+```python
+from inkbox import Inkbox
+
+with Inkbox(api_key="ApiKey_...") as inkbox:
+ identity = inkbox.get_identity("sales-agent")
+
+ # 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="
Hi there!
",
+ 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,
+ )
-const client = new InkboxMail({ apiKey: "sk-..." });
+ # 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": "",
+ }],
+ )
-// Create a mailbox
-const mailbox = await client.mailboxes.create({ displayName: "Agent 01" });
+ # Iterate over all messages (pagination handled automatically)
+ for msg in identity.iter_emails():
+ print(msg.subject, msg.from_address, msg.is_read)
-// Send an email
-await client.messages.send(mailbox.id, {
+ # Filter by direction: "inbound" or "outbound"
+ for msg in identity.iter_emails(direction="inbound"):
+ print(msg.subject)
+
+ # Iterate only unread messages
+ for msg in identity.iter_unread_emails():
+ print(msg.subject)
+
+ # Mark messages as read
+ unread_ids = [msg.id for msg in identity.iter_unread_emails()]
+ identity.mark_emails_read(unread_ids)
+
+ # Get a full thread (all messages, oldest-first)
+ for msg in identity.iter_emails():
+ thread = identity.get_thread(msg.thread_id)
+ for m in thread.messages:
+ print(f"[{m.from_address}] {m.subject}")
+ break
+```
+
+### TypeScript
+
+```ts
+import { Inkbox } from "@inkbox/sdk";
+
+const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
+const identity = await inkbox.getIdentity("sales-agent");
+
+// Send an email (plain text and/or HTML)
+const sent = await identity.sendEmail({
to: ["user@example.com"],
subject: "Hello from Inkbox",
bodyText: "Hi there!",
+ bodyHtml: "Hi there!
",
+ cc: ["manager@example.com"],
+ bcc: ["archive@example.com"],
+});
+
+// Send a threaded reply
+await identity.sendEmail({
+ to: ["user@example.com"],
+ subject: `Re: ${sent.subject}`,
+ bodyText: "Following up!",
+ inReplyToMessageId: sent.id,
+});
+
+// Send with attachments
+await identity.sendEmail({
+ to: ["user@example.com"],
+ subject: "See attached",
+ bodyText: "Please find the file attached.",
+ attachments: [{
+ filename: "report.pdf",
+ contentType: "application/pdf",
+ contentBase64: "",
+ }],
});
// Iterate over all messages (pagination handled automatically)
-for await (const msg of client.messages.list(mailbox.id)) {
+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 messages
+for await (const msg of identity.iterUnreadEmails()) {
+ console.log(msg.subject);
+}
+
+// Mark messages as read
+const unreadIds: string[] = [];
+for await (const msg of identity.iterUnreadEmails()) unreadIds.push(msg.id);
+await identity.markEmailsRead(unreadIds);
+
+// Get a full thread (all messages, oldest-first)
+for await (const msg of identity.iterEmails()) {
+ const thread = await identity.getThread(msg.threadId);
+ for (const m of thread.messages) {
+ console.log(`[${m.fromAddress}] ${m.subject}`);
+ }
+ break;
+}
+```
+
+---
+
+## Phone
+
+### Python
+
+```python
+from inkbox import Inkbox
+
+with Inkbox(api_key="ApiKey_...") as inkbox:
+ identity = inkbox.get_identity("sales-agent")
+
+ # Place an outbound call — stream audio over WebSocket
+ call = identity.place_call(
+ to_number="+15167251294",
+ client_websocket_url="wss://your-agent.example.com/ws",
+ )
+ print(call.status)
+ print(call.rate_limit.calls_remaining)
+
+ # Or receive call events via webhook instead
+ call = identity.place_call(
+ to_number="+15167251294",
+ webhook_url="https://your-agent.example.com/call-events",
+ )
+
+ # 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"
+```
+
+### TypeScript
+
+```ts
+import { Inkbox } from "@inkbox/sdk";
+
+const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
+const identity = await inkbox.getIdentity("sales-agent");
+
+// Place an outbound call — stream audio over WebSocket
+const call = await identity.placeCall({
+ toNumber: "+15167251294",
+ clientWebsocketUrl: "wss://your-agent.example.com/ws",
+});
+console.log(call.status);
+console.log(call.rateLimit.callsRemaining);
+
+// Or receive call events via webhook instead
+const call2 = await identity.placeCall({
+ toNumber: "+15167251294",
+ webhookUrl: "https://your-agent.example.com/call-events",
+});
+
+// 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"
+}
+```
+
+---
+
+## Org-level mailboxes
+
+Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`.
+
+### Python
+
+```python
+from inkbox import Inkbox
+
+with Inkbox(api_key="ApiKey_...") as inkbox:
+ # List all mailboxes in the organisation
+ mailboxes = inkbox.mailboxes.list()
+
+ # Get a specific mailbox
+ mailbox = inkbox.mailboxes.get("abc-xyz@inkboxmail.com")
+
+ # Create a standalone mailbox
+ mailbox = inkbox.mailboxes.create(display_name="Support Inbox")
+ print(mailbox.email_address)
+
+ # Update display name or webhook URL
+ inkbox.mailboxes.update(mailbox.email_address, display_name="New Name")
+ inkbox.mailboxes.update(mailbox.email_address, webhook_url="https://example.com/hook")
+ inkbox.mailboxes.update(mailbox.email_address, webhook_url=None) # remove webhook
+
+ # 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)
+
+ # Delete a mailbox
+ inkbox.mailboxes.delete(mailbox.email_address)
+```
+
+### TypeScript
+
+```ts
+import { Inkbox } from "@inkbox/sdk";
+
+const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
+
+// List all mailboxes in the organisation
+const mailboxes = await inkbox.mailboxes.list();
+
+// Get a specific mailbox
+const mailbox = await inkbox.mailboxes.get("abc-xyz@inkboxmail.com");
+
+// Create a standalone mailbox
+const mb = await inkbox.mailboxes.create({ displayName: "Support Inbox" });
+console.log(mb.emailAddress);
+
+// Update display name or webhook URL
+await inkbox.mailboxes.update(mb.emailAddress, { displayName: "New Name" });
+await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: "https://example.com/hook" });
+await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: null }); // remove webhook
+
+// 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);
}
-// Search
-const results = await client.mailboxes.search(mailbox.id, { q: "invoice" });
+// Delete a mailbox
+await inkbox.mailboxes.delete(mb.emailAddress);
+```
+
+---
+
+## Org-level phone numbers
+
+Manage phone numbers directly without going through an identity. Access via `inkbox.phone_numbers` (Python) / `inkbox.phoneNumbers` (TypeScript).
+
+### Python
+
+```python
+from inkbox import Inkbox
+
+with Inkbox(api_key="ApiKey_...") as inkbox:
+ # 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(type="toll_free")
+ local = inkbox.phone_numbers.provision(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=number.number)
+```
+
+### TypeScript
+
+```ts
+import { Inkbox } from "@inkbox/sdk";
+
+const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
+
+// 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({ type: "toll_free" });
+const local = await inkbox.phoneNumbers.provision({ type: "local", 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({ number: num.number });
+```
+
+---
+
+## 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 `message.received` and `message.sent` events.
+
+```python
+# Python
+inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url="https://example.com/hook")
+# Remove:
+inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url=None)
+```
+
+```ts
+// TypeScript
+await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: "https://example.com/hook" });
+// Remove:
+await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: null });
+```
+
+### Phone webhooks
+
+Set an incoming call webhook URL and action on a phone number.
+
+```python
+# Python — route incoming calls to a webhook
+inkbox.phone_numbers.update(
+ number.id,
+ incoming_call_action="webhook",
+ incoming_call_webhook_url="https://example.com/calls",
+)
+```
+
+```ts
+// TypeScript — route incoming calls to a webhook
+await inkbox.phoneNumbers.update(number.id, {
+ incomingCallAction: "webhook",
+ incomingCallWebhookUrl: "https://example.com/calls",
+});
+```
+
+You can also supply a per-call webhook URL when placing a call:
+
+```python
+# Python
+identity.place_call(to_number="+15005550006", webhook_url="https://example.com/call-events")
+```
+
+```ts
+// TypeScript
+await identity.placeCall({ toNumber: "+15005550006", webhookUrl: "https://example.com/call-events" });
+```
+
+---
+
+## Signing Keys
+
+Org-level webhook signing keys are managed through the `Inkbox` client.
+
+### Python
+
+```python
+from inkbox import Inkbox
+
+with Inkbox(api_key="ApiKey_...") as inkbox:
+ # Create or rotate the org-level signing key (plaintext returned once)
+ key = inkbox.create_signing_key()
+ print(key.signing_key) # save this
+```
+
+### TypeScript
+
+```ts
+import { Inkbox } from "@inkbox/sdk";
+
+const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
+
+// Create or rotate the org-level signing key (plaintext returned once)
+const key = await inkbox.createSigningKey();
+console.log(key.signingKey); // save this
+```
+
+---
+
+## Verifying Webhook Signatures
+
+Use `verify_webhook` / `verifyWebhook` to confirm that an incoming request was sent by Inkbox. The function checks the HMAC-SHA256 signature over `{request_id}.{timestamp}.{body}`.
+
+### Python
+
+```python
+from inkbox import verify_webhook
+
+is_valid = verify_webhook(
+ payload=raw_body, # bytes
+ signature=request.headers["X-Inkbox-Signature"],
+ request_id=request.headers["X-Inkbox-Request-ID"],
+ timestamp=request.headers["X-Inkbox-Timestamp"],
+ secret="whsec_...", # from create_signing_key()
+)
+```
+
+### TypeScript
+
+```ts
+import { verifyWebhook } from "@inkbox/sdk";
-// Webhooks (secret is one-time — save it immediately)
-const hook = await client.webhooks.create(mailbox.id, {
- url: "https://yourapp.com/hooks/mail",
- eventTypes: ["message.received"],
+const valid = verifyWebhook({
+ payload: req.body, // Buffer or string
+ signature: req.headers["x-inkbox-signature"] as string,
+ requestId: req.headers["x-inkbox-request-id"] as string,
+ timestamp: req.headers["x-inkbox-timestamp"] as string,
+ secret: "whsec_...", // from createSigningKey()
});
-console.log(hook.secret); // save this
```
---
diff --git a/python/.coverage b/python/.coverage
new file mode 100644
index 0000000..6d5ccec
Binary files /dev/null and b/python/.coverage differ
diff --git a/python/README.md b/python/README.md
index e5cd836..7c79ceb 100644
--- a/python/README.md
+++ b/python/README.md
@@ -1,6 +1,6 @@
# inkbox
-Python SDK for the [Inkbox Mail API](https://inkbox.ai) — API-first email for AI agents.
+Python SDK for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (email, phone, identities).
## Install
@@ -8,53 +8,366 @@ Python SDK for the [Inkbox Mail API](https://inkbox.ai) — API-first email for
pip install inkbox
```
-## Usage
+Requires Python ≥ 3.11.
+
+## Authentication
+
+You'll need an API key to use this SDK. Get one at [console.inkbox.ai](https://console.inkbox.ai/).
+
+## Quick start
+
+```python
+import os
+from inkbox import Inkbox
+
+with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox:
+ # Create an agent identity
+ identity = inkbox.create_identity("support-bot")
+
+ # Create and link new channels
+ identity.create_mailbox(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()
+```
+
+## 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.
+
+---
+
+## 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.
```python
-from inkbox.mail import InkboxMail
+# Create and fully provision an identity
+identity = inkbox.create_identity("sales-bot")
+mailbox = identity.create_mailbox(display_name="Sales Bot") # creates + links
+phone = identity.provision_phone_number(type="toll_free") # provisions + links
+
+print(mailbox.email_address)
+print(phone.number)
+
+# Link an existing mailbox or phone number instead of creating new ones
+identity.assign_mailbox("mailbox-uuid-here")
+identity.assign_phone_number("phone-number-uuid-here")
+
+# 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 status or handle
+identity.update(status="paused")
+identity.update(new_handle="sales-bot-v2")
+
+# Unlink channels (without deleting them)
+identity.unlink_mailbox()
+identity.unlink_phone_number()
+
+# Delete
+identity.delete()
+```
-client = InkboxMail(api_key="sk-...")
+---
-# Create a mailbox
-mailbox = client.mailboxes.create(display_name="Agent 01")
+## Mail
-# Send an email
-client.messages.send(
- mailbox.id,
+```python
+# 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="Hi there!
",
+ 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": "",
+ }],
+)
+
+# 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
+
+```python
+# Place an outbound call — stream audio over WebSocket
+call = identity.place_call(
+ to_number="+15167251294",
+ client_websocket_url="wss://your-agent.example.com/ws",
+)
+print(call.status, call.rate_limit.calls_remaining)
+
+# Or receive call events via webhook instead
+call = identity.place_call(
+ to_number="+15167251294",
+ webhook_url="https://your-agent.example.com/call-events",
)
-# Iterate over all messages (pagination handled automatically)
-for msg in client.messages.list(mailbox.id):
+# 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}")
+```
+
+---
+
+## Org-level Mailboxes
+
+Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`.
+
+```python
+# List all mailboxes in the organisation
+mailboxes = inkbox.mailboxes.list()
+
+# Get a specific mailbox
+mailbox = inkbox.mailboxes.get("abc-xyz@inkboxmail.com")
+
+# Create a standalone mailbox
+mailbox = inkbox.mailboxes.create(display_name="Support Inbox")
+print(mailbox.email_address)
+
+# Update display name or webhook URL
+inkbox.mailboxes.update(mailbox.email_address, display_name="New Name")
+inkbox.mailboxes.update(mailbox.email_address, webhook_url="https://example.com/hook")
+inkbox.mailboxes.update(mailbox.email_address, webhook_url=None) # remove webhook
+
+# 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)
-# Reply to a message
-detail = client.messages.get(mailbox.id, msg.id)
-client.messages.send(
- mailbox.id,
- to=detail.to_addresses,
- subject=f"Re: {detail.subject}",
- body_text="Got it, thanks!",
- in_reply_to_message_id=detail.message_id,
+# Delete a mailbox
+inkbox.mailboxes.delete(mailbox.email_address)
+```
+
+---
+
+## Org-level Phone Numbers
+
+Manage phone numbers directly without going through an identity. Access via `inkbox.phone_numbers`.
+
+```python
+# 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(type="toll_free")
+local = inkbox.phone_numbers.provision(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}")
-# Search
-results = client.mailboxes.search(mailbox.id, q="invoice")
+# Release a number
+inkbox.phone_numbers.release(number=number.number)
+```
-# Webhooks (secret is one-time — save it immediately)
-hook = client.webhooks.create(
- mailbox.id,
- url="https://yourapp.com/hooks/mail",
- event_types=["message.received"],
+---
+
+## 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 `message.received` and `message.sent` events.
+
+```python
+# 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)
+```
+
+### Phone webhooks
+
+Set an incoming call webhook URL and action on a phone number.
+
+```python
+# Route incoming calls to a webhook
+inkbox.phone_numbers.update(
+ number.id,
+ incoming_call_action="webhook",
+ incoming_call_webhook_url="https://example.com/calls",
)
-print(hook.secret) # save this
```
-## Requirements
+You can also supply a per-call webhook URL when placing a call:
+
+```python
+identity.place_call(to_number="+15005550006", webhook_url="https://example.com/call-events")
+```
+
+---
+
+## Signing Keys
+
+```python
+# 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.
+
+```python
+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](https://github.com/vectorlyapp/inkbox/tree/main/inkbox/examples/python) directory:
-- Python ≥ 3.11
+| Script | What it demonstrates |
+|---|---|
+| `register_agent_identity.py` | Create an identity, assign mailbox + 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
diff --git a/python/inkbox/__init__.py b/python/inkbox/__init__.py
index b04400a..0d7b542 100644
--- a/python/inkbox/__init__.py
+++ b/python/inkbox/__init__.py
@@ -1 +1,65 @@
-# inkbox namespace package
+"""
+inkbox — Python SDK for the Inkbox APIs.
+"""
+
+from inkbox.client import Inkbox
+from inkbox.agent_identity import AgentIdentity
+
+# Exceptions (canonical source: mail; identical in all submodules)
+from inkbox.mail.exceptions import InkboxAPIError, InkboxError
+
+# Mail types
+from inkbox.mail.types import (
+ Mailbox,
+ Message,
+ MessageDetail,
+ Thread,
+ ThreadDetail,
+)
+
+# Phone types
+from inkbox.phone.types import (
+ PhoneCall,
+ PhoneCallWithRateLimit,
+ PhoneNumber,
+ PhoneTranscript,
+ RateLimitInfo,
+)
+
+# Identity types
+from inkbox.identities.types import (
+ AgentIdentitySummary,
+ IdentityMailbox,
+ IdentityPhoneNumber,
+)
+
+# Signing key + webhook verification
+from inkbox.signing_keys import SigningKey, verify_webhook
+
+__all__ = [
+ # Entry points
+ "Inkbox",
+ "AgentIdentity",
+ # Exceptions
+ "InkboxError",
+ "InkboxAPIError",
+ # Mail types
+ "Mailbox",
+ "Message",
+ "MessageDetail",
+ "Thread",
+ "ThreadDetail",
+ # Phone types
+ "PhoneCall",
+ "PhoneCallWithRateLimit",
+ "PhoneNumber",
+ "PhoneTranscript",
+ "RateLimitInfo",
+ # Identity types
+ "AgentIdentitySummary",
+ "IdentityMailbox",
+ "IdentityPhoneNumber",
+ # Signing key + webhook verification
+ "SigningKey",
+ "verify_webhook",
+]
diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py
new file mode 100644
index 0000000..52fcf93
--- /dev/null
+++ b/python/inkbox/agent_identity.py
@@ -0,0 +1,388 @@
+"""
+inkbox/agent_identity.py
+
+AgentIdentity — a domain object representing one agent identity.
+Returned by inkbox.create_identity() and inkbox.get_identity().
+
+Convenience methods (send_email, place_call, etc.) are scoped to this
+agent's assigned channels so callers never need to pass an email address
+or phone number ID explicitly.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Iterator
+
+from inkbox.identities.types import _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber
+from inkbox.mail.exceptions import InkboxError
+from inkbox.mail.types import Message, ThreadDetail
+from inkbox.phone.types import PhoneCall, PhoneCallWithRateLimit, PhoneTranscript
+
+if TYPE_CHECKING:
+ from inkbox.client import Inkbox
+
+
+class AgentIdentity:
+ """An agent identity with convenience methods for its assigned channels.
+
+ Obtain an instance via::
+
+ identity = inkbox.create_identity("support-bot")
+ # or
+ identity = inkbox.get_identity("support-bot")
+
+ After assigning channels you can communicate directly::
+
+ identity.create_mailbox(display_name="Support Bot")
+ identity.provision_phone_number(type="toll_free")
+
+ identity.send_email(to=["user@example.com"], subject="Hi", body_text="Hello")
+ identity.place_call(to_number="+15555550100", client_websocket_url="wss://my-app.com/ws")
+
+ for msg in identity.iter_emails():
+ print(msg.subject)
+ """
+
+ def __init__(self, data: _AgentIdentityData, inkbox: Inkbox) -> None:
+ self._data = data
+ self._inkbox = inkbox
+ self._mailbox: IdentityMailbox | None = data.mailbox
+ self._phone_number: IdentityPhoneNumber | None = data.phone_number
+
+ # ------------------------------------------------------------------
+ # Identity properties
+ # ------------------------------------------------------------------
+
+ @property
+ def agent_handle(self) -> str:
+ return self._data.agent_handle
+
+ @property
+ def id(self):
+ return self._data.id
+
+ @property
+ def status(self) -> str:
+ return self._data.status
+
+ @property
+ def mailbox(self) -> IdentityMailbox | None:
+ return self._mailbox
+
+ @property
+ def phone_number(self) -> IdentityPhoneNumber | None:
+ return self._phone_number
+
+ # ------------------------------------------------------------------
+ # Channel management
+ # ------------------------------------------------------------------
+
+ def create_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox:
+ """Create a new mailbox and link it to this identity.
+
+ Args:
+ display_name: Optional human-readable sender name.
+
+ Returns:
+ The newly created and linked mailbox.
+ """
+ mailbox = self._inkbox._mailboxes.create(display_name=display_name)
+ data = self._inkbox._ids_resource.assign_mailbox(
+ self.agent_handle, mailbox_id=mailbox.id
+ )
+ self._mailbox = data.mailbox
+ self._data = data
+ return self._mailbox # type: ignore[return-value]
+
+ def assign_mailbox(self, mailbox_id: str) -> IdentityMailbox:
+ """Link an existing mailbox to this identity.
+
+ Args:
+ mailbox_id: UUID of the mailbox to link. Obtain via
+ ``inkbox.mailboxes.list()`` or ``inkbox.mailboxes.get()``.
+
+ Returns:
+ The linked mailbox.
+ """
+ data = self._inkbox._ids_resource.assign_mailbox(
+ self.agent_handle, mailbox_id=mailbox_id
+ )
+ self._mailbox = data.mailbox
+ self._data = data
+ return self._mailbox # type: ignore[return-value]
+
+ def unlink_mailbox(self) -> None:
+ """Unlink this identity's mailbox (does not delete the mailbox)."""
+ self._require_mailbox()
+ self._inkbox._ids_resource.unlink_mailbox(self.agent_handle)
+ self._mailbox = None
+
+ def provision_phone_number(
+ self, *, type: str = "toll_free", state: str | None = None
+ ) -> IdentityPhoneNumber:
+ """Provision a new phone number and link it to this identity.
+
+ Args:
+ type: ``"toll_free"`` (default) or ``"local"``.
+ state: US state abbreviation (e.g. ``"NY"``), valid for local numbers only.
+
+ Returns:
+ The newly provisioned and linked phone number.
+ """
+ self._inkbox._numbers.provision(agent_handle=self.agent_handle, type=type, state=state)
+ data = self._inkbox._ids_resource.get(self.agent_handle)
+ self._phone_number = data.phone_number
+ self._data = data
+ return self._phone_number # type: ignore[return-value]
+
+ def assign_phone_number(self, phone_number_id: str) -> IdentityPhoneNumber:
+ """Link an existing phone number to this identity.
+
+ Args:
+ phone_number_id: UUID of the phone number to link. Obtain via
+ ``inkbox.phone_numbers.list()`` or ``inkbox.phone_numbers.get()``.
+
+ Returns:
+ The linked phone number.
+ """
+ data = self._inkbox._ids_resource.assign_phone_number(
+ self.agent_handle, phone_number_id=phone_number_id
+ )
+ self._phone_number = data.phone_number
+ self._data = data
+ return self._phone_number # type: ignore[return-value]
+
+ def unlink_phone_number(self) -> None:
+ """Unlink this identity's phone number (does not release the number)."""
+ self._require_phone()
+ self._inkbox._ids_resource.unlink_phone_number(self.agent_handle)
+ self._phone_number = None
+
+ # ------------------------------------------------------------------
+ # Mail helpers
+ # ------------------------------------------------------------------
+
+ def send_email(
+ self,
+ *,
+ to: list[str],
+ subject: str,
+ body_text: str | None = None,
+ body_html: str | None = None,
+ cc: list[str] | None = None,
+ bcc: list[str] | None = None,
+ in_reply_to_message_id: str | None = None,
+ attachments: list[dict] | None = None,
+ ) -> Message:
+ """Send an email from this identity's mailbox.
+
+ Args:
+ to: Primary recipient addresses (at least one required).
+ subject: Email subject line.
+ body_text: Plain-text body.
+ body_html: HTML body.
+ cc: Carbon-copy recipients.
+ bcc: Blind carbon-copy recipients.
+ in_reply_to_message_id: RFC 5322 Message-ID to thread a reply.
+ attachments: List of file attachment dicts with ``filename``,
+ ``content_type``, and ``content_base64`` keys.
+ """
+ self._require_mailbox()
+ return self._inkbox._messages.send(
+ self._mailbox.email_address, # type: ignore[union-attr]
+ to=to,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html,
+ cc=cc,
+ bcc=bcc,
+ in_reply_to_message_id=in_reply_to_message_id,
+ attachments=attachments,
+ )
+
+ def iter_emails(
+ self,
+ *,
+ page_size: int = 50,
+ direction: str | None = None,
+ ) -> Iterator[Message]:
+ """Iterate over emails in this identity's inbox, newest first.
+
+ Pagination is handled automatically.
+
+ Args:
+ page_size: Messages fetched per API call (1–100).
+ direction: Filter by ``"inbound"`` or ``"outbound"``.
+ """
+ self._require_mailbox()
+ return self._inkbox._messages.list(
+ self._mailbox.email_address, # type: ignore[union-attr]
+ page_size=page_size,
+ direction=direction,
+ )
+
+ def iter_unread_emails(
+ self,
+ *,
+ page_size: int = 50,
+ direction: str | None = None,
+ ) -> Iterator[Message]:
+ """Iterate over unread emails in this identity's inbox, newest first.
+
+ Fetches all messages and filters client-side. Pagination is handled
+ automatically.
+
+ Args:
+ page_size: Messages fetched per API call (1–100).
+ direction: Filter by ``"inbound"`` or ``"outbound"``.
+ """
+ return (msg for msg in self.iter_emails(page_size=page_size, direction=direction) if not msg.is_read)
+
+ def mark_emails_read(self, message_ids: list[str]) -> None:
+ """Mark a list of messages as read.
+
+ Args:
+ message_ids: IDs of the messages to mark as read.
+ """
+ self._require_mailbox()
+ for mid in message_ids:
+ self._inkbox._messages.mark_read(
+ self._mailbox.email_address, # type: ignore[union-attr]
+ mid,
+ )
+
+ def get_thread(self, thread_id: str) -> ThreadDetail:
+ """Get a thread with all its messages inlined (oldest-first).
+
+ Args:
+ thread_id: UUID of the thread to fetch. Obtain via ``msg.thread_id``
+ on any :class:`~inkbox.mail.types.Message`.
+ """
+ self._require_mailbox()
+ return self._inkbox._threads.get(
+ self._mailbox.email_address, # type: ignore[union-attr]
+ thread_id,
+ )
+
+ # ------------------------------------------------------------------
+ # Phone helpers
+ # ------------------------------------------------------------------
+
+ def place_call(
+ self,
+ *,
+ to_number: str,
+ client_websocket_url: str | None = None,
+ webhook_url: str | None = None,
+ ) -> PhoneCallWithRateLimit:
+ """Place an outbound call from this identity's phone number.
+
+ Args:
+ to_number: E.164 destination number.
+ client_websocket_url: WebSocket URL (wss://) for audio bridging.
+ webhook_url: Custom webhook URL for call lifecycle events.
+ """
+ self._require_phone()
+ return self._inkbox._calls.place(
+ from_number=self._phone_number.number, # type: ignore[union-attr]
+ to_number=to_number,
+ client_websocket_url=client_websocket_url,
+ webhook_url=webhook_url,
+ )
+
+ def list_calls(self, *, limit: int = 50, offset: int = 0) -> list[PhoneCall]:
+ """List calls made to/from this identity's phone number.
+
+ Args:
+ limit: Maximum number of results (default 50).
+ offset: Pagination offset (default 0).
+ """
+ self._require_phone()
+ return self._inkbox._calls.list(
+ self._phone_number.id, # type: ignore[union-attr]
+ limit=limit,
+ offset=offset,
+ )
+
+ def list_transcripts(self, call_id: str) -> list[PhoneTranscript]:
+ """List transcript segments for a specific call.
+
+ Args:
+ call_id: ID of the call to fetch transcripts for.
+ """
+ self._require_phone()
+ return self._inkbox._transcripts.list(
+ self._phone_number.id, # type: ignore[union-attr]
+ call_id,
+ )
+
+ # ------------------------------------------------------------------
+ # Identity management
+ # ------------------------------------------------------------------
+
+ def update(
+ self,
+ *,
+ new_handle: str | None = None,
+ status: str | None = None,
+ ) -> None:
+ """Update this identity's handle or status.
+
+ Args:
+ new_handle: New agent handle.
+ status: New lifecycle status: ``"active"`` or ``"paused"``.
+ """
+ result = self._inkbox._ids_resource.update(
+ self.agent_handle, new_handle=new_handle, status=status
+ )
+ self._data = _AgentIdentityData(
+ id=result.id,
+ organization_id=result.organization_id,
+ agent_handle=result.agent_handle,
+ status=result.status,
+ created_at=result.created_at,
+ updated_at=result.updated_at,
+ mailbox=self._mailbox,
+ phone_number=self._phone_number,
+ )
+
+ def refresh(self) -> AgentIdentity:
+ """Re-fetch this identity from the API and update cached channels.
+
+ Returns:
+ ``self`` for chaining.
+ """
+ data = self._inkbox._ids_resource.get(self.agent_handle)
+ self._data = data
+ self._mailbox = data.mailbox
+ self._phone_number = data.phone_number
+ return self
+
+ def delete(self) -> None:
+ """Soft-delete this identity (unlinks channels without deleting them)."""
+ self._inkbox._ids_resource.delete(self.agent_handle)
+
+ # ------------------------------------------------------------------
+ # Internal guards
+ # ------------------------------------------------------------------
+
+ def _require_mailbox(self) -> None:
+ if not self._mailbox:
+ raise InkboxError(
+ f"Identity '{self.agent_handle}' has no mailbox assigned. "
+ "Call identity.create_mailbox() or identity.assign_mailbox() first."
+ )
+
+ def _require_phone(self) -> None:
+ if not self._phone_number:
+ raise InkboxError(
+ f"Identity '{self.agent_handle}' has no phone number assigned. "
+ "Call identity.provision_phone_number() or identity.assign_phone_number() first."
+ )
+
+ def __repr__(self) -> str:
+ return (
+ f"AgentIdentity(agent_handle={self.agent_handle!r}, "
+ f"mailbox={self._mailbox.email_address if self._mailbox else None!r}, "
+ f"phone={self._phone_number.number if self._phone_number else None!r})"
+ )
diff --git a/python/inkbox/client.py b/python/inkbox/client.py
new file mode 100644
index 0000000..1da0655
--- /dev/null
+++ b/python/inkbox/client.py
@@ -0,0 +1,153 @@
+"""
+inkbox/client.py
+
+Inkbox — org-level entry point for all Inkbox APIs.
+"""
+
+from __future__ import annotations
+
+from inkbox.mail._http import HttpTransport as MailHttpTransport
+from inkbox.mail.resources.mailboxes import MailboxesResource
+from inkbox.mail.resources.messages import MessagesResource
+from inkbox.mail.resources.threads import ThreadsResource
+from inkbox.phone._http import HttpTransport as PhoneHttpTransport
+from inkbox.phone.resources.calls import CallsResource
+from inkbox.phone.resources.numbers import PhoneNumbersResource
+from inkbox.phone.resources.transcripts import TranscriptsResource
+from inkbox.identities._http import HttpTransport as IdsHttpTransport
+from inkbox.identities.resources.identities import IdentitiesResource
+from inkbox.agent_identity import AgentIdentity
+from inkbox.identities.types import AgentIdentitySummary
+from inkbox.signing_keys import SigningKey, SigningKeysResource
+
+_DEFAULT_BASE_URL = "https://api.inkbox.ai"
+
+
+class Inkbox:
+ """Org-level entry point for all Inkbox APIs.
+
+ Args:
+ api_key: Your Inkbox API key (``X-Service-Token``).
+ base_url: Override the API base URL (useful for self-hosting or testing).
+ timeout: Request timeout in seconds (default 30).
+
+ Example::
+
+ from inkbox import Inkbox
+
+ with Inkbox(api_key="ApiKey_...") as inkbox:
+ identity = inkbox.create_identity("support-bot")
+ identity.create_mailbox(display_name="Support Bot")
+ identity.send_email(
+ to=["customer@example.com"],
+ subject="Hello!",
+ body_text="Hi there",
+ )
+ """
+
+ def __init__(
+ self,
+ api_key: str,
+ *,
+ base_url: str = _DEFAULT_BASE_URL,
+ timeout: float = 30.0,
+ ) -> None:
+ _api_root = f"{base_url.rstrip('/')}/api/v1"
+
+ self._mail_http = MailHttpTransport(
+ api_key=api_key, base_url=f"{_api_root}/mail", timeout=timeout
+ )
+ self._phone_http = PhoneHttpTransport(
+ api_key=api_key, base_url=f"{_api_root}/phone", timeout=timeout
+ )
+ self._ids_http = IdsHttpTransport(
+ api_key=api_key, base_url=f"{_api_root}/identities", timeout=timeout
+ )
+ self._api_http = MailHttpTransport(
+ api_key=api_key, base_url=_api_root, timeout=timeout
+ )
+
+ self._mailboxes = MailboxesResource(self._mail_http)
+ self._messages = MessagesResource(self._mail_http)
+ self._threads = ThreadsResource(self._mail_http)
+
+ self._calls = CallsResource(self._phone_http)
+ self._numbers = PhoneNumbersResource(self._phone_http)
+ self._transcripts = TranscriptsResource(self._phone_http)
+
+ self._signing_keys = SigningKeysResource(self._api_http)
+ self._ids_resource = IdentitiesResource(self._ids_http)
+
+ # ------------------------------------------------------------------
+ # Public resource accessors
+ # ------------------------------------------------------------------
+
+ @property
+ def mailboxes(self) -> MailboxesResource:
+ """Access org-level mailbox operations (list, get, create, update, delete)."""
+ return self._mailboxes
+
+ @property
+ def phone_numbers(self) -> PhoneNumbersResource:
+ """Access org-level phone number operations (list, get, provision, release)."""
+ return self._numbers
+
+ # ------------------------------------------------------------------
+ # Org-level operations
+ # ------------------------------------------------------------------
+
+ def create_identity(self, agent_handle: str) -> AgentIdentity:
+ """Create a new agent identity.
+
+ Args:
+ agent_handle: Unique handle for this identity (e.g. ``"sales-bot"``).
+
+ Returns:
+ The created :class:`AgentIdentity`.
+ """
+ from inkbox.agent_identity import AgentIdentity
+
+ self._ids_resource.create(agent_handle=agent_handle)
+ data = self._ids_resource.get(agent_handle)
+ return AgentIdentity(data, self)
+
+ def get_identity(self, agent_handle: str) -> AgentIdentity:
+ """Get an agent identity by handle.
+
+ Args:
+ agent_handle: Handle of the identity to fetch.
+
+ Returns:
+ The :class:`AgentIdentity`.
+ """
+ from inkbox.agent_identity import AgentIdentity
+
+ return AgentIdentity(self._ids_resource.get(agent_handle), self)
+
+ def list_identities(self) -> list[AgentIdentitySummary]:
+ """List all agent identities for your organisation."""
+ return self._ids_resource.list()
+
+ def create_signing_key(self) -> SigningKey:
+ """Create or rotate the org-level webhook signing key.
+
+ The plaintext key is returned once — save it immediately.
+ """
+ return self._signing_keys.create_or_rotate()
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ def close(self) -> None:
+ """Close all underlying HTTP connection pools."""
+ self._mail_http.close()
+ self._phone_http.close()
+ self._ids_http.close()
+ self._api_http.close()
+
+ def __enter__(self) -> Inkbox:
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
diff --git a/python/inkbox/identities/__init__.py b/python/inkbox/identities/__init__.py
new file mode 100644
index 0000000..f2000e8
--- /dev/null
+++ b/python/inkbox/identities/__init__.py
@@ -0,0 +1,17 @@
+"""
+inkbox.identities — identity types.
+"""
+
+from inkbox.identities.types import (
+ AgentIdentitySummary,
+ _AgentIdentityData,
+ IdentityMailbox,
+ IdentityPhoneNumber,
+)
+
+__all__ = [
+ "AgentIdentitySummary",
+ "_AgentIdentityData",
+ "IdentityMailbox",
+ "IdentityPhoneNumber",
+]
diff --git a/python/inkbox/identities/_http.py b/python/inkbox/identities/_http.py
new file mode 100644
index 0000000..5629f63
--- /dev/null
+++ b/python/inkbox/identities/_http.py
@@ -0,0 +1,68 @@
+"""
+inkbox/identities/_http.py
+
+Sync HTTP transport (internal).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+
+from inkbox.identities.exceptions import InkboxAPIError
+
+_DEFAULT_TIMEOUT = 30.0
+
+
+class HttpTransport:
+ def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT) -> None:
+ self._client = httpx.Client(
+ base_url=base_url,
+ headers={
+ "X-Service-Token": api_key,
+ "Accept": "application/json",
+ },
+ timeout=timeout,
+ )
+
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
+ cleaned = {k: v for k, v in (params or {}).items() if v is not None}
+ resp = self._client.get(path, params=cleaned)
+ _raise_for_status(resp)
+ return resp.json()
+
+ def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
+ resp = self._client.post(path, json=json)
+ _raise_for_status(resp)
+ if resp.status_code == 204:
+ return None
+ return resp.json()
+
+ def patch(self, path: str, *, json: dict[str, Any]) -> Any:
+ resp = self._client.patch(path, json=json)
+ _raise_for_status(resp)
+ return resp.json()
+
+ def delete(self, path: str) -> None:
+ resp = self._client.delete(path)
+ _raise_for_status(resp)
+
+ def close(self) -> None:
+ self._client.close()
+
+ def __enter__(self) -> HttpTransport:
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
+
+
+def _raise_for_status(resp: httpx.Response) -> None:
+ if resp.status_code < 400:
+ return
+ try:
+ detail = resp.json().get("detail", resp.text)
+ except Exception:
+ detail = resp.text
+ raise InkboxAPIError(status_code=resp.status_code, detail=str(detail))
diff --git a/python/inkbox/identities/exceptions.py b/python/inkbox/identities/exceptions.py
new file mode 100644
index 0000000..2a90826
--- /dev/null
+++ b/python/inkbox/identities/exceptions.py
@@ -0,0 +1,25 @@
+"""
+inkbox/identities/exceptions.py
+
+Exception types raised by the SDK.
+"""
+
+from __future__ import annotations
+
+
+class InkboxError(Exception):
+ """Base exception for all Inkbox SDK errors."""
+
+
+class InkboxAPIError(InkboxError):
+ """Raised when the API returns a 4xx or 5xx response.
+
+ Attributes:
+ status_code: HTTP status code.
+ detail: Error detail from the response body.
+ """
+
+ def __init__(self, status_code: int, detail: str) -> None:
+ super().__init__(f"HTTP {status_code}: {detail}")
+ self.status_code = status_code
+ self.detail = detail
diff --git a/python/inkbox/identities/resources/__init__.py b/python/inkbox/identities/resources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/python/inkbox/identities/resources/identities.py b/python/inkbox/identities/resources/identities.py
new file mode 100644
index 0000000..06343a0
--- /dev/null
+++ b/python/inkbox/identities/resources/identities.py
@@ -0,0 +1,133 @@
+"""
+inkbox/identities/resources/identities.py
+
+Identity CRUD and channel assignment.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+from uuid import UUID
+
+from inkbox.identities.types import AgentIdentitySummary, _AgentIdentityData
+
+if TYPE_CHECKING:
+ from inkbox.identities._http import HttpTransport
+
+
+class IdentitiesResource:
+ def __init__(self, http: HttpTransport) -> None:
+ self._http = http
+
+ def create(self, *, agent_handle: str) -> AgentIdentitySummary:
+ """Create a new agent identity.
+
+ Args:
+ agent_handle: Unique handle for this identity within your organisation
+ (e.g. ``"sales-agent"`` or ``"@sales-agent"``).
+
+ Returns:
+ The created identity.
+ """
+ data = self._http.post("/", json={"agent_handle": agent_handle})
+ return AgentIdentitySummary._from_dict(data)
+
+ def list(self) -> list[AgentIdentitySummary]:
+ """List all identities for your organisation."""
+ data = self._http.get("/")
+ return [AgentIdentitySummary._from_dict(i) for i in data]
+
+ def get(self, agent_handle: str) -> _AgentIdentityData:
+ """Get an identity with its linked channels (mailbox, phone number).
+
+ Args:
+ agent_handle: Handle of the identity to fetch.
+ """
+ data = self._http.get(f"/{agent_handle}")
+ return _AgentIdentityData._from_dict(data)
+
+ def update(
+ self,
+ agent_handle: str,
+ *,
+ new_handle: str | None = None,
+ status: str | None = None,
+ ) -> AgentIdentitySummary:
+ """Update an identity's handle or status.
+
+ Only provided fields are applied; omitted fields are left unchanged.
+
+ Args:
+ agent_handle: Current handle of the identity to update.
+ new_handle: New handle value.
+ status: New lifecycle status: ``"active"`` or ``"paused"``.
+ """
+ body: dict[str, Any] = {}
+ if new_handle is not None:
+ body["agent_handle"] = new_handle
+ if status is not None:
+ body["status"] = status
+ data = self._http.patch(f"/{agent_handle}", json=body)
+ return AgentIdentitySummary._from_dict(data)
+
+ def delete(self, agent_handle: str) -> None:
+ """Soft-delete an identity.
+
+ Unlinks any assigned channels without deleting them.
+
+ Args:
+ agent_handle: Handle of the identity to delete.
+ """
+ self._http.delete(f"/{agent_handle}")
+
+ def assign_mailbox(
+ self,
+ agent_handle: str,
+ *,
+ mailbox_id: UUID | str,
+ ) -> _AgentIdentityData:
+ """Assign a mailbox to an identity.
+
+ Args:
+ agent_handle: Handle of the identity.
+ mailbox_id: UUID of the mailbox to assign.
+ """
+ data = self._http.post(
+ f"/{agent_handle}/mailbox",
+ json={"mailbox_id": str(mailbox_id)},
+ )
+ return _AgentIdentityData._from_dict(data)
+
+ def unlink_mailbox(self, agent_handle: str) -> None:
+ """Unlink the mailbox from an identity (does not delete the mailbox).
+
+ Args:
+ agent_handle: Handle of the identity.
+ """
+ self._http.delete(f"/{agent_handle}/mailbox")
+
+ def assign_phone_number(
+ self,
+ agent_handle: str,
+ *,
+ phone_number_id: UUID | str,
+ ) -> _AgentIdentityData:
+ """Assign a phone number to an identity.
+
+ Args:
+ agent_handle: Handle of the identity.
+ phone_number_id: UUID of the phone number to assign.
+ """
+ data = self._http.post(
+ f"/{agent_handle}/phone_number",
+ json={"phone_number_id": str(phone_number_id)},
+ )
+ return _AgentIdentityData._from_dict(data)
+
+ def unlink_phone_number(self, agent_handle: str) -> None:
+ """Unlink the phone number from an identity (does not delete the number).
+
+ Args:
+ agent_handle: Handle of the identity.
+ """
+ self._http.delete(f"/{agent_handle}/phone_number")
diff --git a/python/inkbox/identities/types.py b/python/inkbox/identities/types.py
new file mode 100644
index 0000000..6ebaa36
--- /dev/null
+++ b/python/inkbox/identities/types.py
@@ -0,0 +1,112 @@
+"""
+inkbox/identities/types.py
+
+Dataclasses mirroring the Inkbox Identities API response models.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+
+def _dt(value: str | None) -> datetime | None:
+ return datetime.fromisoformat(value) if value else None
+
+
+@dataclass
+class IdentityMailbox:
+ """Mailbox channel linked to an agent identity."""
+
+ id: UUID
+ email_address: str
+ display_name: str | None
+ status: str
+ created_at: datetime
+ updated_at: datetime
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> IdentityMailbox:
+ return cls(
+ id=UUID(d["id"]),
+ email_address=d["email_address"],
+ display_name=d.get("display_name"),
+ status=d["status"],
+ created_at=datetime.fromisoformat(d["created_at"]),
+ updated_at=datetime.fromisoformat(d["updated_at"]),
+ )
+
+
+@dataclass
+class IdentityPhoneNumber:
+ """Phone number channel linked to an agent identity."""
+
+ id: UUID
+ number: str
+ type: str
+ status: str
+ incoming_call_action: str
+ client_websocket_url: str | None
+ created_at: datetime
+ updated_at: datetime
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> IdentityPhoneNumber:
+ return cls(
+ id=UUID(d["id"]),
+ number=d["number"],
+ type=d["type"],
+ status=d["status"],
+ incoming_call_action=d["incoming_call_action"],
+ client_websocket_url=d.get("client_websocket_url"),
+ created_at=datetime.fromisoformat(d["created_at"]),
+ updated_at=datetime.fromisoformat(d["updated_at"]),
+ )
+
+
+@dataclass
+class AgentIdentitySummary:
+ """Lightweight agent identity returned by list and update endpoints."""
+
+ id: UUID
+ organization_id: str
+ agent_handle: str
+ status: str
+ created_at: datetime
+ updated_at: datetime
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> AgentIdentitySummary:
+ return cls(
+ id=UUID(d["id"]),
+ organization_id=d["organization_id"],
+ agent_handle=d["agent_handle"],
+ status=d["status"],
+ created_at=datetime.fromisoformat(d["created_at"]),
+ updated_at=datetime.fromisoformat(d["updated_at"]),
+ )
+
+
+@dataclass
+class _AgentIdentityData(AgentIdentitySummary):
+ """Agent identity with linked communication channels.
+
+ Returned by get, assign-mailbox, and assign-phone-number endpoints.
+ Internal — users interact with AgentIdentity (the domain class) instead.
+ """
+
+ mailbox: IdentityMailbox | None = field(default=None)
+ phone_number: IdentityPhoneNumber | None = field(default=None)
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> _AgentIdentityData: # type: ignore[override]
+ base = AgentIdentitySummary._from_dict(d)
+ mailbox_data = d.get("mailbox")
+ phone_data = d.get("phone_number")
+ return cls(
+ **base.__dict__,
+ mailbox=IdentityMailbox._from_dict(mailbox_data) if mailbox_data else None,
+ phone_number=IdentityPhoneNumber._from_dict(phone_data) if phone_data else None,
+ )
diff --git a/python/inkbox/mail/__init__.py b/python/inkbox/mail/__init__.py
index c20c2b6..da7a44f 100644
--- a/python/inkbox/mail/__init__.py
+++ b/python/inkbox/mail/__init__.py
@@ -1,8 +1,7 @@
"""
-inkbox.mail — Python SDK for the Inkbox Mail API.
+inkbox.mail — mail types and exceptions.
"""
-from inkbox.mail.client import InkboxMail
from inkbox.mail.exceptions import InkboxAPIError, InkboxError
from inkbox.mail.types import (
Mailbox,
@@ -10,19 +9,16 @@
MessageDetail,
Thread,
ThreadDetail,
- Webhook,
- WebhookCreateResult,
)
+from inkbox.signing_keys import SigningKey
__all__ = [
- "InkboxMail",
"InkboxError",
"InkboxAPIError",
"Mailbox",
"Message",
"MessageDetail",
+ "SigningKey",
"Thread",
"ThreadDetail",
- "Webhook",
- "WebhookCreateResult",
]
diff --git a/python/inkbox/mail/client.py b/python/inkbox/mail/client.py
deleted file mode 100644
index be77f1a..0000000
--- a/python/inkbox/mail/client.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""
-inkbox/mail/client.py
-
-Top-level InkboxMail client.
-"""
-
-from __future__ import annotations
-
-from inkbox.mail._http import HttpTransport
-from inkbox.mail.resources.mailboxes import MailboxesResource
-from inkbox.mail.resources.messages import MessagesResource
-from inkbox.mail.resources.threads import ThreadsResource
-from inkbox.mail.resources.webhooks import WebhooksResource
-
-_DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail"
-
-
-class InkboxMail:
- """Client for the Inkbox Mail API.
-
- Args:
- api_key: Your Inkbox API key (``X-Service-Token``).
- base_url: Override the API base URL (useful for self-hosting or testing).
- timeout: Request timeout in seconds (default 30).
-
- Example::
-
- from inkbox.mail import InkboxMail
-
- client = InkboxMail(api_key="sk-...")
-
- mailbox = client.mailboxes.create(display_name="Agent 01")
-
- client.messages.send(
- mailbox.id,
- to=["user@example.com"],
- subject="Hello from Inkbox",
- body_text="Hi there!",
- )
-
- for msg in client.messages.list(mailbox.id):
- print(msg.subject, msg.from_address)
-
- client.close()
-
- The client can also be used as a context manager::
-
- with InkboxMail(api_key="sk-...") as client:
- mailboxes = client.mailboxes.list()
- """
-
- def __init__(
- self,
- api_key: str,
- *,
- base_url: str = _DEFAULT_BASE_URL,
- timeout: float = 30.0,
- ) -> None:
- self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout)
- self.mailboxes = MailboxesResource(self._http)
- self.messages = MessagesResource(self._http)
- self.threads = ThreadsResource(self._http)
- self.webhooks = WebhooksResource(self._http)
-
- def close(self) -> None:
- """Close the underlying HTTP connection pool."""
- self._http.close()
-
- def __enter__(self) -> InkboxMail:
- return self
-
- def __exit__(self, *_: object) -> None:
- self.close()
diff --git a/python/inkbox/mail/resources/__init__.py b/python/inkbox/mail/resources/__init__.py
index a2cb00b..ae8610a 100644
--- a/python/inkbox/mail/resources/__init__.py
+++ b/python/inkbox/mail/resources/__init__.py
@@ -1,11 +1,9 @@
from inkbox.mail.resources.mailboxes import MailboxesResource
from inkbox.mail.resources.messages import MessagesResource
from inkbox.mail.resources.threads import ThreadsResource
-from inkbox.mail.resources.webhooks import WebhooksResource
__all__ = [
"MailboxesResource",
"MessagesResource",
"ThreadsResource",
- "WebhooksResource",
]
diff --git a/python/inkbox/mail/resources/mailboxes.py b/python/inkbox/mail/resources/mailboxes.py
index 2e9d682..559cbce 100644
--- a/python/inkbox/mail/resources/mailboxes.py
+++ b/python/inkbox/mail/resources/mailboxes.py
@@ -7,7 +7,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
-from uuid import UUID
from inkbox.mail.types import Mailbox, Message
@@ -15,6 +14,7 @@
from inkbox.mail._http import HttpTransport
_BASE = "/mailboxes"
+_UNSET = object()
class MailboxesResource:
@@ -47,18 +47,55 @@ def list(self) -> list[Mailbox]:
data = self._http.get(_BASE)
return [Mailbox._from_dict(m) for m in data]
- def get(self, mailbox_id: UUID | str) -> Mailbox:
- """Get a mailbox by ID."""
- data = self._http.get(f"{_BASE}/{mailbox_id}")
+ def get(self, email_address: str) -> Mailbox:
+ """Get a mailbox by its email address.
+
+ Args:
+ email_address: Full email address of the mailbox
+ (e.g. ``"abc-xyz@inkboxmail.com"``).
+ """
+ data = self._http.get(f"{_BASE}/{email_address}")
return Mailbox._from_dict(data)
- def delete(self, mailbox_id: UUID | str) -> None:
- """Delete a mailbox."""
- self._http.delete(f"{_BASE}/{mailbox_id}")
+ def update(
+ self,
+ email_address: str,
+ *,
+ display_name: str | None = _UNSET, # type: ignore[assignment]
+ webhook_url: str | None = _UNSET, # type: ignore[assignment]
+ ) -> Mailbox:
+ """Update mutable mailbox fields.
+
+ Only provided fields are applied; omitted fields are left unchanged.
+ Pass ``webhook_url=None`` to unsubscribe from webhooks.
+
+ Args:
+ email_address: Full email address of the mailbox to update.
+ display_name: New human-readable sender name.
+ webhook_url: HTTPS URL to receive webhook events, or ``None`` to unsubscribe.
+
+ Returns:
+ The updated mailbox.
+ """
+ body: dict[str, Any] = {}
+ if display_name is not _UNSET:
+ body["display_name"] = display_name
+ if webhook_url is not _UNSET:
+ body["webhook_url"] = webhook_url
+ data = self._http.patch(f"{_BASE}/{email_address}", json=body)
+ return Mailbox._from_dict(data)
+
+ def delete(self, email_address: str) -> None:
+ """Delete a mailbox.
+
+ Args:
+ email_address: Full email address of the mailbox to delete.
+ """
+ self._http.delete(f"{_BASE}/{email_address}")
def search(
self,
- mailbox_id: UUID | str,
+ email_address: str,
*,
q: str,
limit: int = 50,
@@ -66,7 +103,7 @@ def search(
"""Full-text search across messages in a mailbox.
Args:
- mailbox_id: UUID of the mailbox to search.
+ email_address: Full email address of the mailbox to search.
q: Search query string.
limit: Maximum number of results (1–100).
@@ -74,7 +111,7 @@ def search(
Matching messages ranked by relevance.
"""
data = self._http.get(
- f"{_BASE}/{mailbox_id}/search",
+ f"{_BASE}/{email_address}/search",
params={"q": q, "limit": limit},
)
return [Message._from_dict(m) for m in data["items"]]
diff --git a/python/inkbox/mail/resources/messages.py b/python/inkbox/mail/resources/messages.py
index 86fe94d..f720693 100644
--- a/python/inkbox/mail/resources/messages.py
+++ b/python/inkbox/mail/resources/messages.py
@@ -23,36 +23,42 @@ def __init__(self, http: HttpTransport) -> None:
def list(
self,
- mailbox_id: UUID | str,
+ email_address: str,
*,
page_size: int = _DEFAULT_PAGE_SIZE,
+ direction: str | None = None,
) -> Iterator[Message]:
"""Iterator over all messages in a mailbox, newest first.
Pagination is handled automatically — just iterate.
Args:
- mailbox_id: UUID of the mailbox.
+ email_address: Full email address of the mailbox.
page_size: Number of messages fetched per API call (1–100).
+ direction: Filter by direction: ``"inbound"`` or ``"outbound"``.
Example::
- for msg in client.messages.list(mailbox_id):
+ for msg in client.messages.list(email_address):
print(msg.subject, msg.from_address)
"""
- return self._paginate(mailbox_id, page_size=page_size)
+ return self._paginate(email_address, page_size=page_size, direction=direction)
def _paginate(
self,
- mailbox_id: UUID | str,
+ email_address: str,
*,
page_size: int,
+ direction: str | None = None,
) -> Iterator[Message]:
cursor: str | None = None
while True:
+ params: dict[str, Any] = {"limit": page_size, "cursor": cursor}
+ if direction is not None:
+ params["direction"] = direction
page = self._http.get(
- f"/mailboxes/{mailbox_id}/messages",
- params={"limit": page_size, "cursor": cursor},
+ f"/mailboxes/{email_address}/messages",
+ params=params,
)
for item in page["items"]:
yield Message._from_dict(item)
@@ -60,22 +66,22 @@ def _paginate(
break
cursor = page["next_cursor"]
- def get(self, mailbox_id: UUID | str, message_id: UUID | str) -> MessageDetail:
+ def get(self, email_address: str, message_id: UUID | str) -> MessageDetail:
"""Get a message with full body content.
Args:
- mailbox_id: UUID of the owning mailbox.
+ email_address: Full email address of the owning mailbox.
message_id: UUID of the message.
Returns:
Full message including ``body_text`` and ``body_html``.
"""
- data = self._http.get(f"/mailboxes/{mailbox_id}/messages/{message_id}")
+ data = self._http.get(f"/mailboxes/{email_address}/messages/{message_id}")
return MessageDetail._from_dict(data)
def send(
self,
- mailbox_id: UUID | str,
+ email_address: str,
*,
to: list[str],
subject: str,
@@ -89,7 +95,7 @@ def send(
"""Send an email from a mailbox.
Args:
- mailbox_id: UUID of the sending mailbox.
+ email_address: Full email address of the sending mailbox.
to: Primary recipient addresses (at least one required).
subject: Email subject line.
body_text: Plain-text body.
@@ -122,12 +128,12 @@ def send(
if attachments is not None:
body["attachments"] = attachments
- data = self._http.post(f"/mailboxes/{mailbox_id}/messages", json=body)
+ data = self._http.post(f"/mailboxes/{email_address}/messages", json=body)
return Message._from_dict(data)
def update_flags(
self,
- mailbox_id: UUID | str,
+ email_address: str,
message_id: UUID | str,
*,
is_read: bool | None = None,
@@ -143,26 +149,52 @@ def update_flags(
if is_starred is not None:
body["is_starred"] = is_starred
data = self._http.patch(
- f"/mailboxes/{mailbox_id}/messages/{message_id}", json=body
+ f"/mailboxes/{email_address}/messages/{message_id}", json=body
)
return Message._from_dict(data)
- def mark_read(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message:
+ def mark_read(self, email_address: str, message_id: UUID | str) -> Message:
"""Mark a message as read."""
- return self.update_flags(mailbox_id, message_id, is_read=True)
+ return self.update_flags(email_address, message_id, is_read=True)
- def mark_unread(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message:
+ def mark_unread(self, email_address: str, message_id: UUID | str) -> Message:
"""Mark a message as unread."""
- return self.update_flags(mailbox_id, message_id, is_read=False)
+ return self.update_flags(email_address, message_id, is_read=False)
- def star(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message:
+ def star(self, email_address: str, message_id: UUID | str) -> Message:
"""Star a message."""
- return self.update_flags(mailbox_id, message_id, is_starred=True)
+ return self.update_flags(email_address, message_id, is_starred=True)
- def unstar(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message:
+ def unstar(self, email_address: str, message_id: UUID | str) -> Message:
"""Unstar a message."""
- return self.update_flags(mailbox_id, message_id, is_starred=False)
+ return self.update_flags(email_address, message_id, is_starred=False)
- def delete(self, mailbox_id: UUID | str, message_id: UUID | str) -> None:
+ def delete(self, email_address: str, message_id: UUID | str) -> None:
"""Delete a message."""
- self._http.delete(f"/mailboxes/{mailbox_id}/messages/{message_id}")
+ self._http.delete(f"/mailboxes/{email_address}/messages/{message_id}")
+
+ def get_attachment(
+ self,
+ email_address: str,
+ message_id: UUID | str,
+ filename: str,
+ *,
+ redirect: bool = False,
+ ) -> dict[str, Any]:
+ """Get a presigned URL for a message attachment.
+
+ Args:
+ email_address: Full email address of the owning mailbox.
+ message_id: UUID of the message.
+ filename: Attachment filename.
+ redirect: If ``True``, follows the 302 redirect and returns the final
+ URL as ``{"url": str}``. If ``False`` (default), returns
+ ``{"url": str, "filename": str, "expires_in": int}``.
+
+ Returns:
+ Dict with ``url``, ``filename``, and ``expires_in`` (seconds).
+ """
+ return self._http.get(
+ f"/mailboxes/{email_address}/messages/{message_id}/attachments/{filename}",
+ params={"redirect": "true" if redirect else "false"},
+ )
diff --git a/python/inkbox/mail/resources/threads.py b/python/inkbox/mail/resources/threads.py
index 9e1ec67..2cb124f 100644
--- a/python/inkbox/mail/resources/threads.py
+++ b/python/inkbox/mail/resources/threads.py
@@ -23,7 +23,7 @@ def __init__(self, http: HttpTransport) -> None:
def list(
self,
- mailbox_id: UUID | str,
+ email_address: str,
*,
page_size: int = _DEFAULT_PAGE_SIZE,
) -> Iterator[Thread]:
@@ -32,26 +32,26 @@ def list(
Pagination is handled automatically — just iterate.
Args:
- mailbox_id: UUID of the mailbox.
+ email_address: Full email address of the mailbox.
page_size: Number of threads fetched per API call (1–100).
Example::
- for thread in client.threads.list(mailbox_id):
+ for thread in client.threads.list(email_address):
print(thread.subject, thread.message_count)
"""
- return self._paginate(mailbox_id, page_size=page_size)
+ return self._paginate(email_address, page_size=page_size)
def _paginate(
self,
- mailbox_id: UUID | str,
+ email_address: str,
*,
page_size: int,
) -> Iterator[Thread]:
cursor: str | None = None
while True:
page = self._http.get(
- f"/mailboxes/{mailbox_id}/threads",
+ f"/mailboxes/{email_address}/threads",
params={"limit": page_size, "cursor": cursor},
)
for item in page["items"]:
@@ -60,19 +60,19 @@ def _paginate(
break
cursor = page["next_cursor"]
- def get(self, mailbox_id: UUID | str, thread_id: UUID | str) -> ThreadDetail:
+ def get(self, email_address: str, thread_id: UUID | str) -> ThreadDetail:
"""Get a thread with all its messages inlined.
Args:
- mailbox_id: UUID of the owning mailbox.
+ email_address: Full email address of the owning mailbox.
thread_id: UUID of the thread.
Returns:
Thread detail with all messages (oldest-first).
"""
- data = self._http.get(f"/mailboxes/{mailbox_id}/threads/{thread_id}")
+ data = self._http.get(f"/mailboxes/{email_address}/threads/{thread_id}")
return ThreadDetail._from_dict(data)
- def delete(self, mailbox_id: UUID | str, thread_id: UUID | str) -> None:
+ def delete(self, email_address: str, thread_id: UUID | str) -> None:
"""Delete a thread."""
- self._http.delete(f"/mailboxes/{mailbox_id}/threads/{thread_id}")
+ self._http.delete(f"/mailboxes/{email_address}/threads/{thread_id}")
diff --git a/python/inkbox/mail/resources/webhooks.py b/python/inkbox/mail/resources/webhooks.py
deleted file mode 100644
index 5e645ef..0000000
--- a/python/inkbox/mail/resources/webhooks.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""
-inkbox/mail/resources/webhooks.py
-
-Webhook CRUD.
-"""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from uuid import UUID
-
-from inkbox.mail.types import Webhook, WebhookCreateResult
-
-if TYPE_CHECKING:
- from inkbox.mail._http import HttpTransport
-
-
-class WebhooksResource:
- def __init__(self, http: HttpTransport) -> None:
- self._http = http
-
- def create(
- self,
- mailbox_id: UUID | str,
- *,
- url: str,
- event_types: list[str],
- ) -> WebhookCreateResult:
- """Register a webhook subscription for a mailbox.
-
- Args:
- mailbox_id: UUID of the mailbox to subscribe to.
- url: HTTPS endpoint that will receive webhook POST requests.
- event_types: Events to subscribe to.
- Valid values: ``"message.received"``, ``"message.sent"``.
-
- Returns:
- The created webhook. ``secret`` is the HMAC-SHA256 signing key —
- save it immediately, as it will not be returned again.
- """
- data = self._http.post(
- f"/mailboxes/{mailbox_id}/webhooks",
- json={"url": url, "event_types": event_types},
- )
- return WebhookCreateResult._from_dict(data)
-
- def list(self, mailbox_id: UUID | str) -> list[Webhook]:
- """List all active webhooks for a mailbox."""
- data = self._http.get(f"/mailboxes/{mailbox_id}/webhooks")
- return [Webhook._from_dict(w) for w in data]
-
- def delete(self, mailbox_id: UUID | str, webhook_id: UUID | str) -> None:
- """Delete a webhook subscription."""
- self._http.delete(f"/mailboxes/{mailbox_id}/webhooks/{webhook_id}")
diff --git a/python/inkbox/mail/types.py b/python/inkbox/mail/types.py
index 7af8989..a32f471 100644
--- a/python/inkbox/mail/types.py
+++ b/python/inkbox/mail/types.py
@@ -23,6 +23,7 @@ class Mailbox:
id: UUID
email_address: str
display_name: str | None
+ webhook_url: str | None
status: str
created_at: datetime
updated_at: datetime
@@ -33,6 +34,7 @@ def _from_dict(cls, d: dict[str, Any]) -> Mailbox:
id=UUID(d["id"]),
email_address=d["email_address"],
display_name=d.get("display_name"),
+ webhook_url=d.get("webhook_url"),
status=d["status"],
created_at=datetime.fromisoformat(d["created_at"]),
updated_at=datetime.fromisoformat(d["updated_at"]),
@@ -153,36 +155,3 @@ def _from_dict(cls, d: dict[str, Any]) -> ThreadDetail: # type: ignore[override
)
-@dataclass
-class Webhook:
- """A webhook subscription for a mailbox."""
-
- id: UUID
- mailbox_id: UUID
- url: str
- event_types: list[str]
- status: str
- created_at: datetime
-
- @classmethod
- def _from_dict(cls, d: dict[str, Any]) -> Webhook:
- return cls(
- id=UUID(d["id"]),
- mailbox_id=UUID(d["mailbox_id"]),
- url=d["url"],
- event_types=d["event_types"],
- status=d["status"],
- created_at=datetime.fromisoformat(d["created_at"]),
- )
-
-
-@dataclass
-class WebhookCreateResult(Webhook):
- """Returned only on webhook creation. Includes the one-time HMAC signing secret."""
-
- secret: str = ""
-
- @classmethod
- def _from_dict(cls, d: dict[str, Any]) -> WebhookCreateResult: # type: ignore[override]
- base = Webhook._from_dict(d)
- return cls(**base.__dict__, secret=d["secret"])
diff --git a/python/inkbox/phone/__init__.py b/python/inkbox/phone/__init__.py
new file mode 100644
index 0000000..bd757ec
--- /dev/null
+++ b/python/inkbox/phone/__init__.py
@@ -0,0 +1,24 @@
+"""
+inkbox.phone — phone types and exceptions.
+"""
+
+from inkbox.phone.exceptions import InkboxAPIError, InkboxError
+from inkbox.phone.types import (
+ PhoneCall,
+ PhoneCallWithRateLimit,
+ PhoneNumber,
+ PhoneTranscript,
+ RateLimitInfo,
+)
+from inkbox.signing_keys import SigningKey
+
+__all__ = [
+ "InkboxError",
+ "InkboxAPIError",
+ "PhoneCall",
+ "PhoneCallWithRateLimit",
+ "PhoneNumber",
+ "PhoneTranscript",
+ "RateLimitInfo",
+ "SigningKey",
+]
diff --git a/python/inkbox/phone/_http.py b/python/inkbox/phone/_http.py
new file mode 100644
index 0000000..b03c1cd
--- /dev/null
+++ b/python/inkbox/phone/_http.py
@@ -0,0 +1,68 @@
+"""
+inkbox/phone/_http.py
+
+Sync HTTP transport (internal).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+
+from inkbox.phone.exceptions import InkboxAPIError
+
+_DEFAULT_TIMEOUT = 30.0
+
+
+class HttpTransport:
+ def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT) -> None:
+ self._client = httpx.Client(
+ base_url=base_url,
+ headers={
+ "X-Service-Token": api_key,
+ "Accept": "application/json",
+ },
+ timeout=timeout,
+ )
+
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
+ cleaned = {k: v for k, v in (params or {}).items() if v is not None}
+ resp = self._client.get(path, params=cleaned)
+ _raise_for_status(resp)
+ return resp.json()
+
+ def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
+ resp = self._client.post(path, json=json)
+ _raise_for_status(resp)
+ if resp.status_code == 204:
+ return None
+ return resp.json()
+
+ def patch(self, path: str, *, json: dict[str, Any]) -> Any:
+ resp = self._client.patch(path, json=json)
+ _raise_for_status(resp)
+ return resp.json()
+
+ def delete(self, path: str) -> None:
+ resp = self._client.delete(path)
+ _raise_for_status(resp)
+
+ def close(self) -> None:
+ self._client.close()
+
+ def __enter__(self) -> HttpTransport:
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
+
+
+def _raise_for_status(resp: httpx.Response) -> None:
+ if resp.status_code < 400:
+ return
+ try:
+ detail = resp.json().get("detail", resp.text)
+ except Exception:
+ detail = resp.text
+ raise InkboxAPIError(status_code=resp.status_code, detail=str(detail))
diff --git a/python/inkbox/phone/exceptions.py b/python/inkbox/phone/exceptions.py
new file mode 100644
index 0000000..6f05a5b
--- /dev/null
+++ b/python/inkbox/phone/exceptions.py
@@ -0,0 +1,25 @@
+"""
+inkbox/phone/exceptions.py
+
+Exception types raised by the SDK.
+"""
+
+from __future__ import annotations
+
+
+class InkboxError(Exception):
+ """Base exception for all Inkbox SDK errors."""
+
+
+class InkboxAPIError(InkboxError):
+ """Raised when the API returns a 4xx or 5xx response.
+
+ Attributes:
+ status_code: HTTP status code.
+ detail: Error detail from the response body.
+ """
+
+ def __init__(self, status_code: int, detail: str) -> None:
+ super().__init__(f"HTTP {status_code}: {detail}")
+ self.status_code = status_code
+ self.detail = detail
diff --git a/python/inkbox/phone/resources/__init__.py b/python/inkbox/phone/resources/__init__.py
new file mode 100644
index 0000000..99c4857
--- /dev/null
+++ b/python/inkbox/phone/resources/__init__.py
@@ -0,0 +1,9 @@
+from inkbox.phone.resources.numbers import PhoneNumbersResource
+from inkbox.phone.resources.calls import CallsResource
+from inkbox.phone.resources.transcripts import TranscriptsResource
+
+__all__ = [
+ "PhoneNumbersResource",
+ "CallsResource",
+ "TranscriptsResource",
+]
diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py
new file mode 100644
index 0000000..374f1b7
--- /dev/null
+++ b/python/inkbox/phone/resources/calls.py
@@ -0,0 +1,84 @@
+"""
+inkbox/phone/resources/calls.py
+
+Call operations: list, get, place.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+from uuid import UUID
+
+from inkbox.phone.types import PhoneCall, PhoneCallWithRateLimit
+
+if TYPE_CHECKING:
+ from inkbox.phone._http import HttpTransport
+
+
+class CallsResource:
+ def __init__(self, http: HttpTransport) -> None:
+ self._http = http
+
+ def list(
+ self,
+ phone_number_id: UUID | str,
+ *,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> list[PhoneCall]:
+ """List calls for a phone number, newest first.
+
+ Args:
+ phone_number_id: UUID of the phone number.
+ limit: Max results to return (1–200).
+ offset: Pagination offset.
+ """
+ data = self._http.get(
+ f"/numbers/{phone_number_id}/calls",
+ params={"limit": limit, "offset": offset},
+ )
+ return [PhoneCall._from_dict(c) for c in data]
+
+ def get(
+ self,
+ phone_number_id: UUID | str,
+ call_id: UUID | str,
+ ) -> PhoneCall:
+ """Get a single call by ID.
+
+ Args:
+ phone_number_id: UUID of the phone number.
+ call_id: UUID of the call.
+ """
+ data = self._http.get(f"/numbers/{phone_number_id}/calls/{call_id}")
+ return PhoneCall._from_dict(data)
+
+ def place(
+ self,
+ *,
+ from_number: str,
+ to_number: str,
+ client_websocket_url: str | None = None,
+ webhook_url: str | None = None,
+ ) -> PhoneCallWithRateLimit:
+ """Place an outbound call.
+
+ Args:
+ from_number: E.164 number to call from. Must belong to your org and be active.
+ to_number: E.164 number to call.
+ client_websocket_url: WebSocket URL (wss://) for audio bridging.
+ webhook_url: Custom webhook URL for call lifecycle events.
+
+ Returns:
+ The created call record with current rate limit info.
+ """
+ body: dict[str, Any] = {
+ "from_number": from_number,
+ "to_number": to_number,
+ }
+ if client_websocket_url is not None:
+ body["client_websocket_url"] = client_websocket_url
+ if webhook_url is not None:
+ body["webhook_url"] = webhook_url
+ data = self._http.post("/place-call", json=body)
+ return PhoneCallWithRateLimit._from_dict(data)
diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py
new file mode 100644
index 0000000..744f366
--- /dev/null
+++ b/python/inkbox/phone/resources/numbers.py
@@ -0,0 +1,115 @@
+"""
+inkbox/phone/resources/numbers.py
+
+Phone number CRUD, provisioning, release, and transcript search.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+from uuid import UUID
+
+from inkbox.phone.types import PhoneNumber, PhoneTranscript
+
+if TYPE_CHECKING:
+ from inkbox.phone._http import HttpTransport
+
+_BASE = "/numbers"
+_UNSET = object()
+
+
+class PhoneNumbersResource:
+ def __init__(self, http: HttpTransport) -> None:
+ self._http = http
+
+ def list(self) -> list[PhoneNumber]:
+ """List all phone numbers for your organisation."""
+ data = self._http.get(_BASE)
+ return [PhoneNumber._from_dict(n) for n in data]
+
+ def get(self, phone_number_id: UUID | str) -> PhoneNumber:
+ """Get a phone number by ID."""
+ data = self._http.get(f"{_BASE}/{phone_number_id}")
+ return PhoneNumber._from_dict(data)
+
+ def update(
+ self,
+ phone_number_id: UUID | str,
+ *,
+ incoming_call_action: str | None = _UNSET, # type: ignore[assignment]
+ client_websocket_url: str | None = _UNSET, # type: ignore[assignment]
+ incoming_call_webhook_url: str | None = _UNSET, # type: ignore[assignment]
+ ) -> PhoneNumber:
+ """Update phone number settings.
+
+ Pass only the fields you want to change; omitted fields are left as-is.
+ Pass a field as ``None`` to clear it.
+
+ Args:
+ phone_number_id: UUID of the phone number.
+ incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``.
+ client_websocket_url: WebSocket URL (wss://) for audio bridging.
+ incoming_call_webhook_url: Webhook URL called for incoming calls when action is ``"webhook"``.
+ """
+ body: dict[str, Any] = {}
+ if incoming_call_action is not _UNSET:
+ body["incoming_call_action"] = incoming_call_action
+ if client_websocket_url is not _UNSET:
+ body["client_websocket_url"] = client_websocket_url
+ if incoming_call_webhook_url is not _UNSET:
+ body["incoming_call_webhook_url"] = incoming_call_webhook_url
+ data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body)
+ return PhoneNumber._from_dict(data)
+
+ def provision(
+ self,
+ *,
+ agent_handle: str,
+ type: str = "toll_free",
+ state: str | None = None,
+ ) -> PhoneNumber:
+ """Provision a new phone number and link it to an agent identity.
+
+ Args:
+ agent_handle: Handle of the agent identity to assign this number to.
+ type: ``"toll_free"`` or ``"local"``. Defaults to ``"toll_free"``.
+ state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers.
+
+ Returns:
+ The provisioned phone number.
+ """
+ body: dict[str, Any] = {"agent_handle": agent_handle, "type": type}
+ if state is not None:
+ body["state"] = state
+ data = self._http.post(_BASE, json=body)
+ return PhoneNumber._from_dict(data)
+
+ def release(self, phone_number_id: UUID | str) -> None:
+ """Release a phone number.
+
+ Args:
+ phone_number_id: UUID of the phone number to release.
+ """
+ self._http.delete(f"{_BASE}/{phone_number_id}")
+
+ def search_transcripts(
+ self,
+ phone_number_id: UUID | str,
+ *,
+ q: str,
+ party: str | None = None,
+ limit: int = 50,
+ ) -> list[PhoneTranscript]:
+ """Full-text search across transcripts for a phone number.
+
+ Args:
+ phone_number_id: UUID of the phone number.
+ q: Search query string.
+ party: Filter by speaker: ``"local"`` or ``"remote"``.
+ limit: Maximum number of results (1–200).
+ """
+ data = self._http.get(
+ f"{_BASE}/{phone_number_id}/search",
+ params={"q": q, "party": party, "limit": limit},
+ )
+ return [PhoneTranscript._from_dict(t) for t in data]
diff --git a/python/inkbox/phone/resources/transcripts.py b/python/inkbox/phone/resources/transcripts.py
new file mode 100644
index 0000000..6ae0ad2
--- /dev/null
+++ b/python/inkbox/phone/resources/transcripts.py
@@ -0,0 +1,36 @@
+"""
+inkbox/phone/resources/transcripts.py
+
+Transcript retrieval.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from inkbox.phone.types import PhoneTranscript
+
+if TYPE_CHECKING:
+ from inkbox.phone._http import HttpTransport
+
+
+class TranscriptsResource:
+ def __init__(self, http: HttpTransport) -> None:
+ self._http = http
+
+ def list(
+ self,
+ phone_number_id: UUID | str,
+ call_id: UUID | str,
+ ) -> list[PhoneTranscript]:
+ """List all transcript segments for a call, ordered by sequence number.
+
+ Args:
+ phone_number_id: UUID of the phone number.
+ call_id: UUID of the call.
+ """
+ data = self._http.get(
+ f"/numbers/{phone_number_id}/calls/{call_id}/transcripts",
+ )
+ return [PhoneTranscript._from_dict(t) for t in data]
diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py
new file mode 100644
index 0000000..8cda2fd
--- /dev/null
+++ b/python/inkbox/phone/types.py
@@ -0,0 +1,150 @@
+"""
+inkbox/phone/types.py
+
+Dataclasses mirroring the Inkbox Phone API response models.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+
+def _dt(value: str | None) -> datetime | None:
+ return datetime.fromisoformat(value) if value else None
+
+
+@dataclass
+class PhoneNumber:
+ """A phone number owned by your organisation."""
+
+ id: UUID
+ number: str
+ type: str
+ status: str
+ incoming_call_action: str
+ client_websocket_url: str | None
+ incoming_call_webhook_url: str | None
+ created_at: datetime
+ updated_at: datetime
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber:
+ return cls(
+ id=UUID(d["id"]),
+ number=d["number"],
+ type=d["type"],
+ status=d["status"],
+ incoming_call_action=d["incoming_call_action"],
+ client_websocket_url=d.get("client_websocket_url"),
+ incoming_call_webhook_url=d.get("incoming_call_webhook_url"),
+ created_at=datetime.fromisoformat(d["created_at"]),
+ updated_at=datetime.fromisoformat(d["updated_at"]),
+ )
+
+
+@dataclass
+class PhoneCall:
+ """A phone call record."""
+
+ id: UUID
+ local_phone_number: str
+ remote_phone_number: str
+ direction: str
+ status: str
+ client_websocket_url: str | None
+ use_inkbox_tts: bool | None
+ use_inkbox_stt: bool | None
+ hangup_reason: str | None
+ started_at: datetime | None
+ ended_at: datetime | None
+ created_at: datetime
+ updated_at: datetime
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> PhoneCall:
+ return cls(
+ id=UUID(d["id"]),
+ local_phone_number=d["local_phone_number"],
+ remote_phone_number=d["remote_phone_number"],
+ direction=d["direction"],
+ status=d["status"],
+ client_websocket_url=d.get("client_websocket_url"),
+ use_inkbox_tts=d.get("use_inkbox_tts"),
+ use_inkbox_stt=d.get("use_inkbox_stt"),
+ hangup_reason=d.get("hangup_reason"),
+ started_at=_dt(d.get("started_at")),
+ ended_at=_dt(d.get("ended_at")),
+ created_at=datetime.fromisoformat(d["created_at"]),
+ updated_at=datetime.fromisoformat(d["updated_at"]),
+ )
+
+
+@dataclass
+class RateLimitInfo:
+ """Rolling 24-hour rate limit snapshot for an organisation."""
+
+ calls_used: int
+ calls_remaining: int
+ calls_limit: int
+ minutes_used: float
+ minutes_remaining: float
+ minutes_limit: int
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> RateLimitInfo:
+ return cls(
+ calls_used=d["calls_used"],
+ calls_remaining=d["calls_remaining"],
+ calls_limit=d["calls_limit"],
+ minutes_used=d["minutes_used"],
+ minutes_remaining=d["minutes_remaining"],
+ minutes_limit=d["minutes_limit"],
+ )
+
+
+@dataclass
+class PhoneCallWithRateLimit(PhoneCall):
+ """PhoneCall extended with the caller's current rate limit snapshot.
+
+ Returned by the place-call endpoint.
+ """
+
+ rate_limit: RateLimitInfo = None # type: ignore[assignment]
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> PhoneCallWithRateLimit: # type: ignore[override]
+ base = PhoneCall._from_dict(d)
+ return cls(
+ **base.__dict__,
+ rate_limit=RateLimitInfo._from_dict(d["rate_limit"]) if d.get("rate_limit") else None,
+ )
+
+
+@dataclass
+class PhoneTranscript:
+ """A transcript segment from a phone call."""
+
+ id: UUID
+ call_id: UUID
+ seq: int
+ ts_ms: int
+ party: str
+ text: str
+ created_at: datetime
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> PhoneTranscript:
+ return cls(
+ id=UUID(d["id"]),
+ call_id=UUID(d["call_id"]),
+ seq=d["seq"],
+ ts_ms=d["ts_ms"],
+ party=d["party"],
+ text=d["text"],
+ created_at=datetime.fromisoformat(d["created_at"]),
+ )
+
+
diff --git a/python/inkbox/signing_keys.py b/python/inkbox/signing_keys.py
new file mode 100644
index 0000000..df57927
--- /dev/null
+++ b/python/inkbox/signing_keys.py
@@ -0,0 +1,81 @@
+"""
+inkbox/signing_keys.py
+
+Org-level webhook signing key management — shared across all Inkbox clients.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Mapping
+
+
+@dataclass
+class SigningKey:
+ """Org-level webhook signing key.
+
+ Returned once on creation/rotation — store ``signing_key`` securely.
+ """
+
+ signing_key: str
+ created_at: datetime
+
+ @classmethod
+ def _from_dict(cls, d: dict[str, Any]) -> SigningKey:
+ return cls(
+ signing_key=d["signing_key"],
+ created_at=datetime.fromisoformat(d["created_at"]),
+ )
+
+
+def verify_webhook(
+ *,
+ payload: bytes,
+ headers: Mapping[str, str],
+ secret: str,
+) -> bool:
+ """Verify that an incoming webhook request was sent by Inkbox.
+
+ Args:
+ payload: Raw request body bytes (do not parse/re-serialize).
+ headers: Request headers mapping (keys are lowercased internally).
+ secret: Your signing key, with or without a ``whsec_`` prefix.
+
+ Returns:
+ True if the signature is valid, False otherwise.
+ """
+ h = {k.lower(): v for k, v in headers.items()}
+ signature = h.get("x-inkbox-signature", "")
+ request_id = h.get("x-inkbox-request-id", "")
+ timestamp = h.get("x-inkbox-timestamp", "")
+ if not signature.startswith("sha256="):
+ return False
+ key = secret.removeprefix("whsec_")
+ message = f"{request_id}.{timestamp}.".encode() + payload
+ expected = hmac.new(key.encode(), message, hashlib.sha256).hexdigest()
+ received = signature.removeprefix("sha256=")
+ return hmac.compare_digest(expected, received)
+
+
+class SigningKeysResource:
+ def __init__(self, http: Any) -> None:
+ self._http = http
+
+ def create_or_rotate(self) -> SigningKey:
+ """Create or rotate the webhook signing key for your organisation.
+
+ The first call creates a new key; subsequent calls rotate (replace) the
+ existing key. The plaintext ``signing_key`` is returned **once** —
+ store it securely as it cannot be retrieved again.
+
+ Use the returned key to verify ``X-Inkbox-Signature`` headers on
+ incoming webhook requests.
+
+ Returns:
+ The newly created/rotated signing key with its creation timestamp.
+ """
+ data = self._http.post("/signing-keys", json={})
+ return SigningKey._from_dict(data)
diff --git a/python/publish.sh b/python/publish.sh
index dd0a90b..8b7207b 100755
--- a/python/publish.sh
+++ b/python/publish.sh
@@ -16,14 +16,16 @@ fi
echo "==> Cleaning previous builds"
rm -rf dist/ build/ *.egg-info
-echo "==> Installing build tools"
-python -m pip install --quiet build twine
-
echo "==> Building"
-python -m build
+uv build
echo "==> Uploading to $REPO"
-twine upload --repository "$REPO" dist/*
+if [[ "$REPO" == "testpypi" ]]; then
+ REPO_URL="https://test.pypi.org/legacy/"
+else
+ REPO_URL="https://upload.pypi.org/legacy/"
+fi
+uv run --with twine twine upload --repository-url "$REPO_URL" dist/*
echo ""
if [[ "$REPO" == "testpypi" ]]; then
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 7b965b0..8ae0d64 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -4,13 +4,21 @@ build-backend = "hatchling.build"
[project]
name = "inkbox"
-version = "0.1.0"
+version = "0.1.0.2"
description = "Python SDK for the Inkbox API"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"httpx>=0.27",
+ "python-dotenv>=1.2.2",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0",
+ "pytest-cov>=5.0",
+ "ruff>=0.4",
]
[project.urls]
@@ -18,5 +26,9 @@ Homepage = "https://inkbox.ai"
Repository = "https://github.com/vectorlyapp/inkbox/tree/main/inkbox/python"
"Bug Tracker" = "https://github.com/vectorlyapp/inkbox/issues"
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+pythonpath = [".", "tests"]
+
[tool.hatch.build.targets.wheel]
packages = ["inkbox"]
diff --git a/python/tests/__init__.py b/python/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/python/tests/conftest.py b/python/tests/conftest.py
new file mode 100644
index 0000000..47d35ec
--- /dev/null
+++ b/python/tests/conftest.py
@@ -0,0 +1,37 @@
+"""Shared fixtures for Inkbox SDK tests."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from inkbox import Inkbox
+
+
+class FakeHttpTransport:
+ """Mock HTTP transport that returns pre-configured responses."""
+
+ def __init__(self) -> None:
+ self.get = MagicMock()
+ self.post = MagicMock()
+ self.patch = MagicMock()
+ self.delete = MagicMock()
+ self.close = MagicMock()
+
+
+@pytest.fixture
+def transport() -> FakeHttpTransport:
+ return FakeHttpTransport()
+
+
+@pytest.fixture
+def client(transport: FakeHttpTransport) -> Inkbox:
+ c = Inkbox(api_key="sk-test")
+ c._phone_http = transport # type: ignore[attr-defined]
+ c._api_http = transport # type: ignore[attr-defined]
+ c._numbers._http = transport
+ c._calls._http = transport
+ c._transcripts._http = transport
+ c._signing_keys._http = transport
+ return c
diff --git a/python/tests/sample_data.py b/python/tests/sample_data.py
new file mode 100644
index 0000000..79da17d
--- /dev/null
+++ b/python/tests/sample_data.py
@@ -0,0 +1,39 @@
+"""Sample API response dicts for tests."""
+
+PHONE_NUMBER_DICT = {
+ "id": "aaaa1111-0000-0000-0000-000000000001",
+ "number": "+18335794607",
+ "type": "toll_free",
+ "status": "active",
+ "incoming_call_action": "auto_reject",
+ "client_websocket_url": None,
+ "incoming_call_webhook_url": None,
+ "created_at": "2026-03-09T00:00:00Z",
+ "updated_at": "2026-03-09T00:00:00Z",
+}
+
+PHONE_CALL_DICT = {
+ "id": "bbbb2222-0000-0000-0000-000000000001",
+ "local_phone_number": "+18335794607",
+ "remote_phone_number": "+15167251294",
+ "direction": "outbound",
+ "status": "completed",
+ "client_websocket_url": "wss://agent.example.com/ws",
+ "use_inkbox_tts": None,
+ "use_inkbox_stt": None,
+ "hangup_reason": None,
+ "started_at": "2026-03-09T00:01:00Z",
+ "ended_at": "2026-03-09T00:05:00Z",
+ "created_at": "2026-03-09T00:00:00Z",
+ "updated_at": "2026-03-09T00:05:00Z",
+}
+
+PHONE_TRANSCRIPT_DICT = {
+ "id": "cccc3333-0000-0000-0000-000000000001",
+ "call_id": "bbbb2222-0000-0000-0000-000000000001",
+ "seq": 0,
+ "ts_ms": 1500,
+ "party": "local",
+ "text": "Hello, how can I help you?",
+ "created_at": "2026-03-09T00:01:01Z",
+}
diff --git a/python/tests/sample_data_identities.py b/python/tests/sample_data_identities.py
new file mode 100644
index 0000000..bb0590b
--- /dev/null
+++ b/python/tests/sample_data_identities.py
@@ -0,0 +1,36 @@
+"""Sample API response dicts for identities tests."""
+
+IDENTITY_DICT = {
+ "id": "eeee5555-0000-0000-0000-000000000001",
+ "organization_id": "org-abc123",
+ "agent_handle": "sales-agent",
+ "status": "active",
+ "created_at": "2026-03-09T00:00:00Z",
+ "updated_at": "2026-03-09T00:00:00Z",
+}
+
+IDENTITY_MAILBOX_DICT = {
+ "id": "aaaa1111-0000-0000-0000-000000000001",
+ "email_address": "sales-agent@inkbox.ai",
+ "display_name": "Sales Agent",
+ "status": "active",
+ "created_at": "2026-03-09T00:00:00Z",
+ "updated_at": "2026-03-09T00:00:00Z",
+}
+
+IDENTITY_PHONE_DICT = {
+ "id": "bbbb2222-0000-0000-0000-000000000001",
+ "number": "+18335794607",
+ "type": "toll_free",
+ "status": "active",
+ "incoming_call_action": "auto_reject",
+ "client_websocket_url": None,
+ "created_at": "2026-03-09T00:00:00Z",
+ "updated_at": "2026-03-09T00:00:00Z",
+}
+
+IDENTITY_DETAIL_DICT = {
+ **IDENTITY_DICT,
+ "mailbox": IDENTITY_MAILBOX_DICT,
+ "phone_number": IDENTITY_PHONE_DICT,
+}
diff --git a/python/tests/sample_data_mail.py b/python/tests/sample_data_mail.py
new file mode 100644
index 0000000..c4413ff
--- /dev/null
+++ b/python/tests/sample_data_mail.py
@@ -0,0 +1,79 @@
+"""Sample API response dicts for mail tests."""
+
+MAILBOX_DICT = {
+ "id": "aaaa1111-0000-0000-0000-000000000001",
+ "email_address": "agent01@inkbox.ai",
+ "display_name": "Agent 01",
+ "status": "active",
+ "created_at": "2026-03-09T00:00:00Z",
+ "updated_at": "2026-03-09T00:00:00Z",
+}
+
+MESSAGE_DICT = {
+ "id": "bbbb2222-0000-0000-0000-000000000001",
+ "mailbox_id": "aaaa1111-0000-0000-0000-000000000001",
+ "thread_id": "eeee5555-0000-0000-0000-000000000001",
+ "message_id": "",
+ "from_address": "user@example.com",
+ "to_addresses": ["agent01@inkbox.ai"],
+ "cc_addresses": None,
+ "subject": "Hello from test",
+ "snippet": "Hi there, this is a test message...",
+ "direction": "inbound",
+ "status": "delivered",
+ "is_read": False,
+ "is_starred": False,
+ "has_attachments": False,
+ "created_at": "2026-03-09T00:00:00Z",
+}
+
+MESSAGE_DETAIL_DICT = {
+ **MESSAGE_DICT,
+ "body_text": "Hi there, this is a test message body.",
+ "body_html": "Hi there, this is a test message body.
",
+ "bcc_addresses": None,
+ "in_reply_to": None,
+ "references": None,
+ "attachment_metadata": None,
+ "ses_message_id": "ses-abc123",
+ "updated_at": "2026-03-09T00:00:00Z",
+}
+
+THREAD_DICT = {
+ "id": "eeee5555-0000-0000-0000-000000000001",
+ "mailbox_id": "aaaa1111-0000-0000-0000-000000000001",
+ "subject": "Hello from test",
+ "status": "active",
+ "message_count": 2,
+ "last_message_at": "2026-03-09T00:05:00Z",
+ "created_at": "2026-03-09T00:00:00Z",
+}
+
+THREAD_DETAIL_DICT = {
+ **THREAD_DICT,
+ "messages": [MESSAGE_DICT],
+}
+
+CURSOR_PAGE_MESSAGES = {
+ "items": [MESSAGE_DICT],
+ "next_cursor": None,
+ "has_more": False,
+}
+
+CURSOR_PAGE_MESSAGES_MULTI = {
+ "items": [MESSAGE_DICT],
+ "next_cursor": "cursor-abc",
+ "has_more": True,
+}
+
+CURSOR_PAGE_THREADS = {
+ "items": [THREAD_DICT],
+ "next_cursor": None,
+ "has_more": False,
+}
+
+CURSOR_PAGE_SEARCH = {
+ "items": [MESSAGE_DICT],
+ "next_cursor": None,
+ "has_more": False,
+}
diff --git a/python/tests/test_agent_identity.py b/python/tests/test_agent_identity.py
new file mode 100644
index 0000000..5c0a3c5
--- /dev/null
+++ b/python/tests/test_agent_identity.py
@@ -0,0 +1,47 @@
+"""Tests for AgentIdentity convenience methods."""
+
+import pytest
+from unittest.mock import MagicMock
+
+from sample_data_identities import IDENTITY_DETAIL_DICT
+from sample_data_mail import THREAD_DETAIL_DICT
+
+from inkbox.agent_identity import AgentIdentity
+from inkbox.identities.types import _AgentIdentityData
+from inkbox.mail.exceptions import InkboxError
+from inkbox.mail.types import ThreadDetail
+
+
+def _identity_with_mailbox():
+ """Return an AgentIdentity backed by a mock Inkbox client."""
+ data = _AgentIdentityData._from_dict(IDENTITY_DETAIL_DICT)
+ inkbox = MagicMock()
+ return AgentIdentity(data, inkbox), inkbox
+
+
+def _identity_without_mailbox():
+ """Return an AgentIdentity with no mailbox assigned."""
+ detail = {**IDENTITY_DETAIL_DICT, "mailbox": None}
+ data = _AgentIdentityData._from_dict(detail)
+ inkbox = MagicMock()
+ return AgentIdentity(data, inkbox), inkbox
+
+
+class TestAgentIdentityGetThread:
+ def test_get_thread_returns_thread_detail(self):
+ identity, inkbox = _identity_with_mailbox()
+ thread_id = THREAD_DETAIL_DICT["id"]
+ inkbox._threads.get.return_value = ThreadDetail._from_dict(THREAD_DETAIL_DICT)
+
+ result = identity.get_thread(thread_id)
+
+ inkbox._threads.get.assert_called_once_with("sales-agent@inkbox.ai", thread_id)
+ assert isinstance(result, ThreadDetail)
+ assert str(result.id) == thread_id
+ assert len(result.messages) == 1
+
+ def test_get_thread_requires_mailbox(self):
+ identity, _ = _identity_without_mailbox()
+
+ with pytest.raises(InkboxError, match="no mailbox assigned"):
+ identity.get_thread("eeee5555-0000-0000-0000-000000000001")
diff --git a/python/tests/test_calls.py b/python/tests/test_calls.py
new file mode 100644
index 0000000..6afb5fb
--- /dev/null
+++ b/python/tests/test_calls.py
@@ -0,0 +1,110 @@
+"""Tests for CallsResource."""
+
+from uuid import UUID
+
+from sample_data import PHONE_CALL_DICT
+
+
+NUM_ID = "aaaa1111-0000-0000-0000-000000000001"
+CALL_ID = "bbbb2222-0000-0000-0000-000000000001"
+
+
+class TestCallsList:
+ def test_returns_calls(self, client, transport):
+ transport.get.return_value = [PHONE_CALL_DICT]
+
+ calls = client._calls.list(NUM_ID, limit=5)
+
+ transport.get.assert_called_once_with(
+ f"/numbers/{NUM_ID}/calls",
+ params={"limit": 5, "offset": 0},
+ )
+ assert len(calls) == 1
+ assert calls[0].direction == "outbound"
+ assert calls[0].remote_phone_number == "+15167251294"
+
+ def test_default_limit_and_offset(self, client, transport):
+ transport.get.return_value = []
+
+ client._calls.list(NUM_ID)
+
+ transport.get.assert_called_once_with(
+ f"/numbers/{NUM_ID}/calls",
+ params={"limit": 50, "offset": 0},
+ )
+
+ def test_custom_offset(self, client, transport):
+ transport.get.return_value = []
+
+ client._calls.list(NUM_ID, limit=10, offset=20)
+
+ transport.get.assert_called_once_with(
+ f"/numbers/{NUM_ID}/calls",
+ params={"limit": 10, "offset": 20},
+ )
+
+
+class TestCallsGet:
+ def test_returns_call(self, client, transport):
+ transport.get.return_value = PHONE_CALL_DICT
+
+ call = client._calls.get(NUM_ID, CALL_ID)
+
+ transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/calls/{CALL_ID}")
+ assert call.id == UUID(CALL_ID)
+ assert call.status == "completed"
+ assert call.client_websocket_url == "wss://agent.example.com/ws"
+ assert call.started_at is not None
+ assert call.ended_at is not None
+
+
+class TestCallsPlace:
+ def test_place_outbound_call(self, client, transport):
+ transport.post.return_value = {
+ **PHONE_CALL_DICT,
+ "status": "ringing",
+ "started_at": None,
+ "ended_at": None,
+ }
+
+ call = client._calls.place(
+ from_number="+18335794607",
+ to_number="+15167251294",
+ client_websocket_url="wss://agent.example.com/ws",
+ )
+
+ transport.post.assert_called_once_with(
+ "/place-call",
+ json={
+ "from_number": "+18335794607",
+ "to_number": "+15167251294",
+ "client_websocket_url": "wss://agent.example.com/ws",
+ },
+ )
+ assert call.status == "ringing"
+
+ def test_place_with_websocket_and_webhook(self, client, transport):
+ transport.post.return_value = PHONE_CALL_DICT
+
+ client._calls.place(
+ from_number="+18335794607",
+ to_number="+15167251294",
+ client_websocket_url="wss://agent.example.com/ws",
+ webhook_url="https://example.com/hook",
+ )
+
+ _, kwargs = transport.post.call_args
+ assert kwargs["json"]["client_websocket_url"] == "wss://agent.example.com/ws"
+ assert kwargs["json"]["webhook_url"] == "https://example.com/hook"
+
+ def test_optional_fields_omitted_when_none(self, client, transport):
+ transport.post.return_value = PHONE_CALL_DICT
+
+ client._calls.place(
+ from_number="+18335794607",
+ to_number="+15167251294",
+ )
+
+ _, kwargs = transport.post.call_args
+ assert "client_websocket_url" not in kwargs["json"]
+ assert "webhook_url" not in kwargs["json"]
diff --git a/python/tests/test_client.py b/python/tests/test_client.py
new file mode 100644
index 0000000..91a12a1
--- /dev/null
+++ b/python/tests/test_client.py
@@ -0,0 +1,24 @@
+"""Tests for Inkbox unified client — phone resources."""
+
+from inkbox import Inkbox
+from inkbox.phone.resources.numbers import PhoneNumbersResource
+from inkbox.phone.resources.calls import CallsResource
+from inkbox.phone.resources.transcripts import TranscriptsResource
+from inkbox.signing_keys import SigningKeysResource
+
+
+class TestInkboxPhoneResources:
+ def test_creates_phone_resource_instances(self):
+ client = Inkbox(api_key="sk-test")
+
+ assert isinstance(client._numbers, PhoneNumbersResource)
+ assert isinstance(client._calls, CallsResource)
+ assert isinstance(client._transcripts, TranscriptsResource)
+ assert isinstance(client._signing_keys, SigningKeysResource)
+
+ client.close()
+
+ def test_phone_http_base_url(self):
+ client = Inkbox(api_key="sk-test", base_url="http://localhost:8000")
+ assert str(client._phone_http._client.base_url) == "http://localhost:8000/api/v1/phone/"
+ client.close()
diff --git a/python/tests/test_exceptions.py b/python/tests/test_exceptions.py
new file mode 100644
index 0000000..991cc5f
--- /dev/null
+++ b/python/tests/test_exceptions.py
@@ -0,0 +1,32 @@
+"""Tests for SDK exception classes."""
+
+from inkbox.phone.exceptions import InkboxAPIError as PhoneAPIError
+from inkbox.mail.exceptions import InkboxAPIError as MailAPIError
+
+
+class TestPhoneInkboxAPIError:
+ def test_message_format(self):
+ err = PhoneAPIError(404, "not found")
+ assert str(err) == "HTTP 404: not found"
+
+ def test_attributes(self):
+ err = PhoneAPIError(422, "validation error")
+ assert err.status_code == 422
+ assert err.detail == "validation error"
+
+ def test_is_exception(self):
+ assert issubclass(PhoneAPIError, Exception)
+
+
+class TestMailInkboxAPIError:
+ def test_message_format(self):
+ err = MailAPIError(500, "server error")
+ assert str(err) == "HTTP 500: server error"
+
+ def test_attributes(self):
+ err = MailAPIError(403, "forbidden")
+ assert err.status_code == 403
+ assert err.detail == "forbidden"
+
+ def test_is_exception(self):
+ assert issubclass(MailAPIError, Exception)
diff --git a/python/tests/test_identities.py b/python/tests/test_identities.py
new file mode 100644
index 0000000..40a4f66
--- /dev/null
+++ b/python/tests/test_identities.py
@@ -0,0 +1,144 @@
+"""Tests for IdentitiesResource."""
+
+from unittest.mock import MagicMock
+
+from sample_data_identities import IDENTITY_DICT, IDENTITY_DETAIL_DICT
+from inkbox.identities.resources.identities import IdentitiesResource
+from inkbox.identities.types import AgentIdentitySummary, _AgentIdentityData
+
+
+def _resource():
+ http = MagicMock()
+ return IdentitiesResource(http), http
+
+
+HANDLE = "sales-agent"
+
+
+class TestIdentitiesCreate:
+ def test_creates_identity(self):
+ res, http = _resource()
+ http.post.return_value = IDENTITY_DICT
+
+ identity = res.create(agent_handle=HANDLE)
+
+ http.post.assert_called_once_with("/", json={"agent_handle": HANDLE})
+ assert isinstance(identity, AgentIdentitySummary)
+ assert identity.agent_handle == HANDLE
+
+
+class TestIdentitiesList:
+ def test_returns_list(self):
+ res, http = _resource()
+ http.get.return_value = [IDENTITY_DICT]
+
+ identities = res.list()
+
+ http.get.assert_called_once_with("/")
+ assert len(identities) == 1
+ assert identities[0].agent_handle == HANDLE
+
+ def test_empty_list(self):
+ res, http = _resource()
+ http.get.return_value = []
+
+ assert res.list() == []
+
+
+class TestIdentitiesGet:
+ def test_returns_detail(self):
+ res, http = _resource()
+ http.get.return_value = IDENTITY_DETAIL_DICT
+
+ detail = res.get(HANDLE)
+
+ http.get.assert_called_once_with(f"/{HANDLE}")
+ assert isinstance(detail, _AgentIdentityData)
+ assert detail.mailbox.email_address == "sales-agent@inkbox.ai"
+ assert detail.phone_number.number == "+18335794607"
+
+
+class TestIdentitiesUpdate:
+ def test_update_handle(self):
+ res, http = _resource()
+ http.patch.return_value = {**IDENTITY_DICT, "agent_handle": "new-handle"}
+
+ result = res.update(HANDLE, new_handle="new-handle")
+
+ http.patch.assert_called_once_with(
+ f"/{HANDLE}", json={"agent_handle": "new-handle"}
+ )
+ assert result.agent_handle == "new-handle"
+
+ def test_update_status(self):
+ res, http = _resource()
+ http.patch.return_value = {**IDENTITY_DICT, "status": "paused"}
+
+ result = res.update(HANDLE, status="paused")
+
+ http.patch.assert_called_once_with(f"/{HANDLE}", json={"status": "paused"})
+ assert result.status == "paused"
+
+ def test_omitted_fields_not_sent(self):
+ res, http = _resource()
+ http.patch.return_value = IDENTITY_DICT
+
+ res.update(HANDLE, status="active")
+
+ _, kwargs = http.patch.call_args
+ assert "agent_handle" not in kwargs["json"]
+
+
+class TestIdentitiesDelete:
+ def test_deletes_identity(self):
+ res, http = _resource()
+
+ res.delete(HANDLE)
+
+ http.delete.assert_called_once_with(f"/{HANDLE}")
+
+
+class TestIdentitiesAssignMailbox:
+ def test_assigns_mailbox(self):
+ res, http = _resource()
+ mailbox_id = "aaaa1111-0000-0000-0000-000000000001"
+ http.post.return_value = IDENTITY_DETAIL_DICT
+
+ detail = res.assign_mailbox(HANDLE, mailbox_id=mailbox_id)
+
+ http.post.assert_called_once_with(
+ f"/{HANDLE}/mailbox", json={"mailbox_id": mailbox_id}
+ )
+ assert isinstance(detail, _AgentIdentityData)
+
+
+class TestIdentitiesUnlinkMailbox:
+ def test_unlinks_mailbox(self):
+ res, http = _resource()
+
+ res.unlink_mailbox(HANDLE)
+
+ http.delete.assert_called_once_with(f"/{HANDLE}/mailbox")
+
+
+class TestIdentitiesAssignPhoneNumber:
+ def test_assigns_phone_number(self):
+ res, http = _resource()
+ phone_id = "bbbb2222-0000-0000-0000-000000000001"
+ http.post.return_value = IDENTITY_DETAIL_DICT
+
+ detail = res.assign_phone_number(HANDLE, phone_number_id=phone_id)
+
+ http.post.assert_called_once_with(
+ f"/{HANDLE}/phone_number", json={"phone_number_id": phone_id}
+ )
+ assert isinstance(detail, _AgentIdentityData)
+
+
+class TestIdentitiesUnlinkPhoneNumber:
+ def test_unlinks_phone_number(self):
+ res, http = _resource()
+
+ res.unlink_phone_number(HANDLE)
+
+ http.delete.assert_called_once_with(f"/{HANDLE}/phone_number")
diff --git a/python/tests/test_identities_client.py b/python/tests/test_identities_client.py
new file mode 100644
index 0000000..ad7052a
--- /dev/null
+++ b/python/tests/test_identities_client.py
@@ -0,0 +1,18 @@
+"""Tests for Inkbox unified client — identities."""
+
+from inkbox import Inkbox
+from inkbox.identities.resources.identities import IdentitiesResource
+
+
+class TestInkboxIdentitiesResources:
+ def test_creates_identities_resource(self):
+ client = Inkbox(api_key="sk-test")
+
+ assert isinstance(client._ids_resource, IdentitiesResource)
+
+ client.close()
+
+ def test_ids_http_base_url(self):
+ client = Inkbox(api_key="sk-test", base_url="http://localhost:8000")
+ assert str(client._ids_http._client.base_url) == "http://localhost:8000/api/v1/identities/"
+ client.close()
diff --git a/python/tests/test_identities_types.py b/python/tests/test_identities_types.py
new file mode 100644
index 0000000..a29b87d
--- /dev/null
+++ b/python/tests/test_identities_types.py
@@ -0,0 +1,71 @@
+"""Tests for identities type parsing."""
+
+from datetime import datetime
+from uuid import UUID
+
+from sample_data_identities import (
+ IDENTITY_DICT,
+ IDENTITY_DETAIL_DICT,
+ IDENTITY_MAILBOX_DICT,
+ IDENTITY_PHONE_DICT,
+)
+from inkbox.identities.types import (
+ AgentIdentitySummary,
+ _AgentIdentityData,
+ IdentityMailbox,
+ IdentityPhoneNumber,
+)
+
+
+class TestAgentIdentitySummaryParsing:
+ def test_from_dict(self):
+ i = AgentIdentitySummary._from_dict(IDENTITY_DICT)
+
+ assert isinstance(i.id, UUID)
+ assert i.organization_id == "org-abc123"
+ assert i.agent_handle == "sales-agent"
+ assert i.status == "active"
+ assert isinstance(i.created_at, datetime)
+ assert isinstance(i.updated_at, datetime)
+
+
+class TestAgentIdentityDataParsing:
+ def test_with_channels(self):
+ d = _AgentIdentityData._from_dict(IDENTITY_DETAIL_DICT)
+
+ assert isinstance(d.id, UUID)
+ assert d.agent_handle == "sales-agent"
+ assert isinstance(d.mailbox, IdentityMailbox)
+ assert d.mailbox.email_address == "sales-agent@inkbox.ai"
+ assert isinstance(d.phone_number, IdentityPhoneNumber)
+ assert d.phone_number.number == "+18335794607"
+
+ def test_no_channels(self):
+ d = _AgentIdentityData._from_dict(IDENTITY_DICT)
+
+ assert d.mailbox is None
+ assert d.phone_number is None
+
+
+class TestIdentityMailboxParsing:
+ def test_from_dict(self):
+ m = IdentityMailbox._from_dict(IDENTITY_MAILBOX_DICT)
+
+ assert isinstance(m.id, UUID)
+ assert m.email_address == "sales-agent@inkbox.ai"
+ assert m.display_name == "Sales Agent"
+ assert m.status == "active"
+ assert isinstance(m.created_at, datetime)
+ assert isinstance(m.updated_at, datetime)
+
+
+class TestIdentityPhoneNumberParsing:
+ def test_from_dict(self):
+ p = IdentityPhoneNumber._from_dict(IDENTITY_PHONE_DICT)
+
+ assert isinstance(p.id, UUID)
+ assert p.number == "+18335794607"
+ assert p.type == "toll_free"
+ assert p.status == "active"
+ assert p.incoming_call_action == "auto_reject"
+ assert p.client_websocket_url is None
diff --git a/python/tests/test_mail_client.py b/python/tests/test_mail_client.py
new file mode 100644
index 0000000..0a00d92
--- /dev/null
+++ b/python/tests/test_mail_client.py
@@ -0,0 +1,28 @@
+"""Tests for Inkbox unified client — mail resources."""
+
+from inkbox import Inkbox
+from inkbox.mail.resources.mailboxes import MailboxesResource
+from inkbox.mail.resources.messages import MessagesResource
+from inkbox.mail.resources.threads import ThreadsResource
+from inkbox.signing_keys import SigningKeysResource
+
+
+class TestInkboxMailResources:
+ def test_creates_mail_resource_instances(self):
+ client = Inkbox(api_key="sk-test")
+
+ assert isinstance(client._mailboxes, MailboxesResource)
+ assert isinstance(client._messages, MessagesResource)
+ assert isinstance(client._threads, ThreadsResource)
+ assert isinstance(client._signing_keys, SigningKeysResource)
+
+ client.close()
+
+ def test_context_manager(self):
+ with Inkbox(api_key="sk-test") as client:
+ assert isinstance(client, Inkbox)
+
+ def test_mail_http_base_url(self):
+ client = Inkbox(api_key="sk-test", base_url="http://localhost:8000")
+ assert str(client._mail_http._client.base_url) == "http://localhost:8000/api/v1/mail/"
+ client.close()
diff --git a/python/tests/test_mail_mailboxes.py b/python/tests/test_mail_mailboxes.py
new file mode 100644
index 0000000..5c9299e
--- /dev/null
+++ b/python/tests/test_mail_mailboxes.py
@@ -0,0 +1,128 @@
+"""Tests for MailboxesResource."""
+
+from unittest.mock import MagicMock
+from uuid import UUID
+
+from sample_data_mail import MAILBOX_DICT, CURSOR_PAGE_SEARCH
+from inkbox.mail.resources.mailboxes import MailboxesResource
+
+
+def _resource():
+ http = MagicMock()
+ return MailboxesResource(http), http
+
+
+class TestMailboxesList:
+ def test_returns_mailboxes(self):
+ res, http = _resource()
+ http.get.return_value = [MAILBOX_DICT]
+
+ mailboxes = res.list()
+
+ http.get.assert_called_once_with("/mailboxes")
+ assert len(mailboxes) == 1
+ assert mailboxes[0].email_address == "agent01@inkbox.ai"
+
+ def test_empty_list(self):
+ res, http = _resource()
+ http.get.return_value = []
+
+ assert res.list() == []
+
+
+class TestMailboxesGet:
+ def test_returns_mailbox(self):
+ res, http = _resource()
+ http.get.return_value = MAILBOX_DICT
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ mailbox = res.get(uid)
+
+ http.get.assert_called_once_with(f"/mailboxes/{uid}")
+ assert mailbox.id == UUID(uid)
+ assert mailbox.display_name == "Agent 01"
+
+
+class TestMailboxesCreate:
+ def test_create_with_display_name(self):
+ res, http = _resource()
+ http.post.return_value = MAILBOX_DICT
+
+ mailbox = res.create(display_name="Agent 01")
+
+ http.post.assert_called_once_with(
+ "/mailboxes", json={"display_name": "Agent 01"}
+ )
+ assert mailbox.display_name == "Agent 01"
+
+ def test_create_without_display_name(self):
+ res, http = _resource()
+ http.post.return_value = {**MAILBOX_DICT, "display_name": None}
+
+ mailbox = res.create()
+
+ http.post.assert_called_once_with("/mailboxes", json={})
+ assert mailbox.display_name is None
+
+
+class TestMailboxesUpdate:
+ def test_update_display_name(self):
+ res, http = _resource()
+ http.patch.return_value = {**MAILBOX_DICT, "display_name": "New Name"}
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ mailbox = res.update(uid, display_name="New Name")
+
+ http.patch.assert_called_once_with(
+ f"/mailboxes/{uid}", json={"display_name": "New Name"}
+ )
+ assert mailbox.display_name == "New Name"
+
+ def test_update_omits_none_fields(self):
+ res, http = _resource()
+ http.patch.return_value = MAILBOX_DICT
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ res.update(uid)
+
+ _, kwargs = http.patch.call_args
+ assert kwargs["json"] == {}
+
+
+class TestMailboxesDelete:
+ def test_deletes_mailbox(self):
+ res, http = _resource()
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ res.delete(uid)
+
+ http.delete.assert_called_once_with(f"/mailboxes/{uid}")
+
+
+class TestMailboxesSearch:
+ def test_search_returns_messages(self):
+ res, http = _resource()
+ http.get.return_value = CURSOR_PAGE_SEARCH
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ results = res.search(uid, q="invoice")
+
+ http.get.assert_called_once_with(
+ f"/mailboxes/{uid}/search",
+ params={"q": "invoice", "limit": 50},
+ )
+ assert len(results) == 1
+ assert results[0].subject == "Hello from test"
+
+ def test_search_with_custom_limit(self):
+ res, http = _resource()
+ http.get.return_value = {"items": [], "next_cursor": None, "has_more": False}
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ results = res.search(uid, q="test", limit=10)
+
+ http.get.assert_called_once_with(
+ f"/mailboxes/{uid}/search",
+ params={"q": "test", "limit": 10},
+ )
+ assert results == []
diff --git a/python/tests/test_mail_messages.py b/python/tests/test_mail_messages.py
new file mode 100644
index 0000000..b0a3008
--- /dev/null
+++ b/python/tests/test_mail_messages.py
@@ -0,0 +1,176 @@
+"""Tests for MessagesResource."""
+
+from unittest.mock import MagicMock
+
+from sample_data_mail import (
+ MESSAGE_DICT,
+ MESSAGE_DETAIL_DICT,
+ CURSOR_PAGE_MESSAGES,
+ CURSOR_PAGE_MESSAGES_MULTI,
+)
+from inkbox.mail.resources.messages import MessagesResource
+
+
+MBOX = "aaaa1111-0000-0000-0000-000000000001"
+MSG = "bbbb2222-0000-0000-0000-000000000001"
+
+
+def _resource():
+ http = MagicMock()
+ return MessagesResource(http), http
+
+
+class TestMessagesList:
+ def test_iterates_single_page(self):
+ res, http = _resource()
+ http.get.return_value = CURSOR_PAGE_MESSAGES
+
+ messages = list(res.list(MBOX))
+
+ assert len(messages) == 1
+ assert messages[0].subject == "Hello from test"
+
+ def test_iterates_multiple_pages(self):
+ res, http = _resource()
+ page2 = {"items": [MESSAGE_DICT], "next_cursor": None, "has_more": False}
+ http.get.side_effect = [CURSOR_PAGE_MESSAGES_MULTI, page2]
+
+ messages = list(res.list(MBOX))
+
+ assert len(messages) == 2
+ assert http.get.call_count == 2
+
+ def test_empty_page(self):
+ res, http = _resource()
+ http.get.return_value = {"items": [], "next_cursor": None, "has_more": False}
+
+ messages = list(res.list(MBOX))
+
+ assert messages == []
+
+
+class TestMessagesGet:
+ def test_returns_message_detail(self):
+ res, http = _resource()
+ http.get.return_value = MESSAGE_DETAIL_DICT
+
+ detail = res.get(MBOX, MSG)
+
+ http.get.assert_called_once_with(f"/mailboxes/{MBOX}/messages/{MSG}")
+ assert detail.body_text == "Hi there, this is a test message body."
+ assert detail.ses_message_id == "ses-abc123"
+
+
+class TestMessagesSend:
+ def test_send_basic(self):
+ res, http = _resource()
+ http.post.return_value = MESSAGE_DICT
+
+ msg = res.send(MBOX, to=["user@example.com"], subject="Test")
+
+ http.post.assert_called_once_with(
+ f"/mailboxes/{MBOX}/messages",
+ json={
+ "recipients": {"to": ["user@example.com"]},
+ "subject": "Test",
+ },
+ )
+ assert msg.subject == "Hello from test"
+
+ def test_send_with_all_options(self):
+ res, http = _resource()
+ http.post.return_value = MESSAGE_DICT
+
+ res.send(
+ MBOX,
+ to=["a@b.com"],
+ subject="Re: test",
+ body_text="reply text",
+ body_html="reply
",
+ cc=["cc@b.com"],
+ bcc=["bcc@b.com"],
+ in_reply_to_message_id="",
+ attachments=[{"filename": "f.txt", "content_type": "text/plain", "content_base64": "aGk="}],
+ )
+
+ _, kwargs = http.post.call_args
+ body = kwargs["json"]
+ assert body["recipients"] == {"to": ["a@b.com"], "cc": ["cc@b.com"], "bcc": ["bcc@b.com"]}
+ assert body["body_text"] == "reply text"
+ assert body["body_html"] == "reply
"
+ assert body["in_reply_to_message_id"] == ""
+ assert body["attachments"] == [{"filename": "f.txt", "content_type": "text/plain", "content_base64": "aGk="}]
+
+ def test_optional_fields_omitted(self):
+ res, http = _resource()
+ http.post.return_value = MESSAGE_DICT
+
+ res.send(MBOX, to=["a@b.com"], subject="Test")
+
+ _, kwargs = http.post.call_args
+ body = kwargs["json"]
+ assert "body_text" not in body
+ assert "body_html" not in body
+ assert "cc" not in body["recipients"]
+ assert "bcc" not in body["recipients"]
+ assert "in_reply_to_message_id" not in body
+ assert "attachments" not in body
+
+
+class TestMessagesUpdateFlags:
+ def test_mark_read(self):
+ res, http = _resource()
+ http.patch.return_value = {**MESSAGE_DICT, "is_read": True}
+
+ msg = res.mark_read(MBOX, MSG)
+
+ http.patch.assert_called_once_with(
+ f"/mailboxes/{MBOX}/messages/{MSG}",
+ json={"is_read": True},
+ )
+ assert msg.is_read is True
+
+ def test_mark_unread(self):
+ res, http = _resource()
+ http.patch.return_value = MESSAGE_DICT
+
+ res.mark_unread(MBOX, MSG)
+
+ _, kwargs = http.patch.call_args
+ assert kwargs["json"] == {"is_read": False}
+
+ def test_star(self):
+ res, http = _resource()
+ http.patch.return_value = {**MESSAGE_DICT, "is_starred": True}
+
+ res.star(MBOX, MSG)
+
+ _, kwargs = http.patch.call_args
+ assert kwargs["json"] == {"is_starred": True}
+
+ def test_unstar(self):
+ res, http = _resource()
+ http.patch.return_value = MESSAGE_DICT
+
+ res.unstar(MBOX, MSG)
+
+ _, kwargs = http.patch.call_args
+ assert kwargs["json"] == {"is_starred": False}
+
+ def test_update_both_flags(self):
+ res, http = _resource()
+ http.patch.return_value = MESSAGE_DICT
+
+ res.update_flags(MBOX, MSG, is_read=True, is_starred=True)
+
+ _, kwargs = http.patch.call_args
+ assert kwargs["json"] == {"is_read": True, "is_starred": True}
+
+
+class TestMessagesDelete:
+ def test_deletes_message(self):
+ res, http = _resource()
+
+ res.delete(MBOX, MSG)
+
+ http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/messages/{MSG}")
diff --git a/python/tests/test_mail_threads.py b/python/tests/test_mail_threads.py
new file mode 100644
index 0000000..ce3cb5b
--- /dev/null
+++ b/python/tests/test_mail_threads.py
@@ -0,0 +1,67 @@
+"""Tests for ThreadsResource."""
+
+from unittest.mock import MagicMock
+
+from sample_data_mail import THREAD_DICT, THREAD_DETAIL_DICT, CURSOR_PAGE_THREADS
+from inkbox.mail.resources.threads import ThreadsResource
+
+
+MBOX = "aaaa1111-0000-0000-0000-000000000001"
+THREAD_ID = "eeee5555-0000-0000-0000-000000000001"
+
+
+def _resource():
+ http = MagicMock()
+ return ThreadsResource(http), http
+
+
+class TestThreadsList:
+ def test_iterates_single_page(self):
+ res, http = _resource()
+ http.get.return_value = CURSOR_PAGE_THREADS
+
+ threads = list(res.list(MBOX))
+
+ assert len(threads) == 1
+ assert threads[0].subject == "Hello from test"
+ assert threads[0].message_count == 2
+
+ def test_empty_page(self):
+ res, http = _resource()
+ http.get.return_value = {"items": [], "next_cursor": None, "has_more": False}
+
+ threads = list(res.list(MBOX))
+
+ assert threads == []
+
+ def test_multi_page(self):
+ res, http = _resource()
+ page1 = {"items": [THREAD_DICT], "next_cursor": "cur1", "has_more": True}
+ page2 = {"items": [THREAD_DICT], "next_cursor": None, "has_more": False}
+ http.get.side_effect = [page1, page2]
+
+ threads = list(res.list(MBOX))
+
+ assert len(threads) == 2
+ assert http.get.call_count == 2
+
+
+class TestThreadsGet:
+ def test_returns_thread_detail(self):
+ res, http = _resource()
+ http.get.return_value = THREAD_DETAIL_DICT
+
+ detail = res.get(MBOX, THREAD_ID)
+
+ http.get.assert_called_once_with(f"/mailboxes/{MBOX}/threads/{THREAD_ID}")
+ assert detail.subject == "Hello from test"
+ assert len(detail.messages) == 1
+
+
+class TestThreadsDelete:
+ def test_deletes_thread(self):
+ res, http = _resource()
+
+ res.delete(MBOX, THREAD_ID)
+
+ http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/threads/{THREAD_ID}")
diff --git a/python/tests/test_mail_types.py b/python/tests/test_mail_types.py
new file mode 100644
index 0000000..4a66c29
--- /dev/null
+++ b/python/tests/test_mail_types.py
@@ -0,0 +1,102 @@
+"""Tests for mail type parsing."""
+
+from datetime import datetime
+from uuid import UUID
+
+from sample_data_mail import (
+ MAILBOX_DICT,
+ MESSAGE_DICT,
+ MESSAGE_DETAIL_DICT,
+ THREAD_DICT,
+ THREAD_DETAIL_DICT,
+)
+from inkbox.mail.types import (
+ Mailbox,
+ Message,
+ MessageDetail,
+ Thread,
+ ThreadDetail,
+)
+
+
+class TestMailboxParsing:
+ def test_from_dict(self):
+ m = Mailbox._from_dict(MAILBOX_DICT)
+
+ assert isinstance(m.id, UUID)
+ assert m.email_address == "agent01@inkbox.ai"
+ assert m.display_name == "Agent 01"
+ assert m.status == "active"
+ assert isinstance(m.created_at, datetime)
+ assert isinstance(m.updated_at, datetime)
+
+
+class TestMessageParsing:
+ def test_from_dict(self):
+ m = Message._from_dict(MESSAGE_DICT)
+
+ assert isinstance(m.id, UUID)
+ assert isinstance(m.mailbox_id, UUID)
+ assert m.thread_id == UUID("eeee5555-0000-0000-0000-000000000001")
+ assert m.message_id == ""
+ assert m.from_address == "user@example.com"
+ assert m.to_addresses == ["agent01@inkbox.ai"]
+ assert m.cc_addresses is None
+ assert m.subject == "Hello from test"
+ assert m.direction == "inbound"
+ assert m.is_read is False
+ assert m.is_starred is False
+ assert m.has_attachments is False
+ assert isinstance(m.created_at, datetime)
+
+ def test_null_thread_id(self):
+ d = {**MESSAGE_DICT, "thread_id": None}
+ m = Message._from_dict(d)
+ assert m.thread_id is None
+
+
+class TestMessageDetailParsing:
+ def test_from_dict(self):
+ m = MessageDetail._from_dict(MESSAGE_DETAIL_DICT)
+
+ assert m.body_text == "Hi there, this is a test message body."
+ assert m.body_html == "Hi there, this is a test message body.
"
+ assert m.bcc_addresses is None
+ assert m.in_reply_to is None
+ assert m.references is None
+ assert m.attachment_metadata is None
+ assert m.ses_message_id == "ses-abc123"
+ assert isinstance(m.updated_at, datetime)
+ # inherits base fields
+ assert m.from_address == "user@example.com"
+ assert m.subject == "Hello from test"
+
+
+class TestThreadParsing:
+ def test_from_dict(self):
+ t = Thread._from_dict(THREAD_DICT)
+
+ assert isinstance(t.id, UUID)
+ assert isinstance(t.mailbox_id, UUID)
+ assert t.subject == "Hello from test"
+ assert t.status == "active"
+ assert t.message_count == 2
+ assert isinstance(t.last_message_at, datetime)
+ assert isinstance(t.created_at, datetime)
+
+
+class TestThreadDetailParsing:
+ def test_from_dict(self):
+ t = ThreadDetail._from_dict(THREAD_DETAIL_DICT)
+
+ assert t.subject == "Hello from test"
+ assert len(t.messages) == 1
+ assert isinstance(t.messages[0], Message)
+ assert t.messages[0].from_address == "user@example.com"
+
+ def test_empty_messages(self):
+ d = {**THREAD_DICT, "messages": []}
+ t = ThreadDetail._from_dict(d)
+ assert t.messages == []
+
+
diff --git a/python/tests/test_numbers.py b/python/tests/test_numbers.py
new file mode 100644
index 0000000..504d443
--- /dev/null
+++ b/python/tests/test_numbers.py
@@ -0,0 +1,164 @@
+"""Tests for PhoneNumbersResource."""
+
+from uuid import UUID
+
+from sample_data import PHONE_NUMBER_DICT, PHONE_TRANSCRIPT_DICT
+
+
+class TestNumbersList:
+ def test_returns_list_of_phone_numbers(self, client, transport):
+ transport.get.return_value = [PHONE_NUMBER_DICT]
+
+ numbers = client._numbers.list()
+
+ transport.get.assert_called_once_with("/numbers")
+ assert len(numbers) == 1
+ assert numbers[0].number == "+18335794607"
+ assert numbers[0].type == "toll_free"
+ assert numbers[0].status == "active"
+ assert numbers[0].client_websocket_url is None
+
+ def test_empty_list(self, client, transport):
+ transport.get.return_value = []
+
+ numbers = client._numbers.list()
+
+ assert numbers == []
+
+
+class TestNumbersGet:
+ def test_returns_phone_number(self, client, transport):
+ transport.get.return_value = PHONE_NUMBER_DICT
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ number = client._numbers.get(uid)
+
+ transport.get.assert_called_once_with(f"/numbers/{uid}")
+ assert number.id == UUID(uid)
+ assert number.number == "+18335794607"
+ assert number.incoming_call_action == "auto_reject"
+
+
+class TestNumbersUpdate:
+ def test_update_incoming_call_action(self, client, transport):
+ updated = {**PHONE_NUMBER_DICT, "incoming_call_action": "webhook"}
+ transport.patch.return_value = updated
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ result = client._numbers.update(uid, incoming_call_action="webhook")
+
+ transport.patch.assert_called_once_with(
+ f"/numbers/{uid}",
+ json={"incoming_call_action": "webhook"},
+ )
+ assert result.incoming_call_action == "webhook"
+
+ def test_update_multiple_fields(self, client, transport):
+ updated = {
+ **PHONE_NUMBER_DICT,
+ "incoming_call_action": "webhook",
+ "client_websocket_url": "wss://agent.example.com/ws",
+ "incoming_call_webhook_url": "https://example.com/hook",
+ }
+ transport.patch.return_value = updated
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ result = client._numbers.update(
+ uid,
+ incoming_call_action="webhook",
+ client_websocket_url="wss://agent.example.com/ws",
+ incoming_call_webhook_url="https://example.com/hook",
+ )
+
+ transport.patch.assert_called_once_with(
+ f"/numbers/{uid}",
+ json={
+ "incoming_call_action": "webhook",
+ "client_websocket_url": "wss://agent.example.com/ws",
+ "incoming_call_webhook_url": "https://example.com/hook",
+ },
+ )
+ assert result.client_websocket_url == "wss://agent.example.com/ws"
+ assert result.incoming_call_webhook_url == "https://example.com/hook"
+
+ def test_omitted_fields_not_sent(self, client, transport):
+ transport.patch.return_value = PHONE_NUMBER_DICT
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ client._numbers.update(uid, incoming_call_action="auto_reject")
+
+ _, kwargs = transport.patch.call_args
+ assert "client_websocket_url" not in kwargs["json"]
+ assert "incoming_call_webhook_url" not in kwargs["json"]
+
+
+class TestNumbersProvision:
+ def test_provision_toll_free(self, client, transport):
+ transport.post.return_value = PHONE_NUMBER_DICT
+
+ number = client._numbers.provision(agent_handle="sales-bot", type="toll_free")
+
+ transport.post.assert_called_once_with(
+ "/numbers",
+ json={"agent_handle": "sales-bot", "type": "toll_free"},
+ )
+ assert number.type == "toll_free"
+
+ def test_provision_local_with_state(self, client, transport):
+ local = {**PHONE_NUMBER_DICT, "type": "local", "number": "+12125551234"}
+ transport.post.return_value = local
+
+ number = client._numbers.provision(agent_handle="sales-bot", type="local", state="NY")
+
+ transport.post.assert_called_once_with(
+ "/numbers",
+ json={"agent_handle": "sales-bot", "type": "local", "state": "NY"},
+ )
+ assert number.type == "local"
+
+ def test_provision_defaults_to_toll_free(self, client, transport):
+ transport.post.return_value = PHONE_NUMBER_DICT
+
+ client._numbers.provision(agent_handle="sales-bot")
+
+ _, kwargs = transport.post.call_args
+ assert kwargs["json"]["type"] == "toll_free"
+ assert kwargs["json"]["agent_handle"] == "sales-bot"
+
+
+class TestNumbersRelease:
+ def test_release_deletes_by_id(self, client, transport):
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ client._numbers.release(uid)
+
+ transport.delete.assert_called_once_with(f"/numbers/{uid}")
+
+
+class TestNumbersSearchTranscripts:
+ def test_search_with_query(self, client, transport):
+ transport.get.return_value = [PHONE_TRANSCRIPT_DICT]
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ results = client._numbers.search_transcripts(uid, q="hello")
+
+ transport.get.assert_called_once_with(
+ f"/numbers/{uid}/search",
+ params={"q": "hello", "party": None, "limit": 50},
+ )
+ assert len(results) == 1
+ assert results[0].text == "Hello, how can I help you?"
+
+ def test_search_with_party_and_limit(self, client, transport):
+ transport.get.return_value = []
+ uid = "aaaa1111-0000-0000-0000-000000000001"
+
+ results = client._numbers.search_transcripts(
+ uid, q="test", party="remote", limit=10
+ )
+
+ transport.get.assert_called_once_with(
+ f"/numbers/{uid}/search",
+ params={"q": "test", "party": "remote", "limit": 10},
+ )
+ assert results == []
diff --git a/python/tests/test_signing_keys.py b/python/tests/test_signing_keys.py
new file mode 100644
index 0000000..d8aba59
--- /dev/null
+++ b/python/tests/test_signing_keys.py
@@ -0,0 +1,102 @@
+"""Tests for SigningKeysResource and verify_webhook."""
+
+import hashlib
+import hmac
+from datetime import datetime, timezone
+from unittest.mock import MagicMock
+
+from inkbox.signing_keys import SigningKey, SigningKeysResource, verify_webhook
+
+
+SIGNING_KEY_DICT = {
+ "signing_key": "sk-test-hmac-secret-abc123",
+ "created_at": "2026-03-09T00:00:00Z",
+}
+
+
+def _resource():
+ http = MagicMock()
+ return SigningKeysResource(http), http
+
+
+class TestSigningKeysCreateOrRotate:
+ def test_calls_correct_endpoint(self):
+ res, http = _resource()
+ http.post.return_value = SIGNING_KEY_DICT
+
+ res.create_or_rotate()
+
+ http.post.assert_called_once_with("/signing-keys", json={})
+
+ def test_returns_signing_key(self):
+ res, http = _resource()
+ http.post.return_value = SIGNING_KEY_DICT
+
+ key = res.create_or_rotate()
+
+ assert isinstance(key, SigningKey)
+ assert key.signing_key == "sk-test-hmac-secret-abc123"
+ assert key.created_at == datetime(2026, 3, 9, 0, 0, 0, tzinfo=timezone.utc)
+
+
+def _make_signature(key: str, request_id: str, timestamp: str, body: bytes) -> str:
+ message = f"{request_id}.{timestamp}.".encode() + body
+ digest = hmac.new(key.encode(), message, hashlib.sha256).hexdigest()
+ return f"sha256={digest}"
+
+
+class TestVerifyWebhook:
+ KEY = "test-signing-key"
+ REQUEST_ID = "req-abc-123"
+ TIMESTAMP = "1741737600"
+ BODY = b'{"event":"message.received"}'
+
+ def _headers(self, sig: str) -> dict[str, str]:
+ return {
+ "X-Inkbox-Signature": sig,
+ "X-Inkbox-Request-ID": self.REQUEST_ID,
+ "X-Inkbox-Timestamp": self.TIMESTAMP,
+ }
+
+ def test_valid_signature(self):
+ sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ assert verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=self.KEY)
+
+ def test_valid_signature_with_whsec_prefix(self):
+ sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ assert verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=f"whsec_{self.KEY}")
+
+ def test_headers_are_case_insensitive(self):
+ sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ lowercase_headers = {
+ "x-inkbox-signature": sig,
+ "x-inkbox-request-id": self.REQUEST_ID,
+ "x-inkbox-timestamp": self.TIMESTAMP,
+ }
+ assert verify_webhook(payload=self.BODY, headers=lowercase_headers, secret=self.KEY)
+
+ def test_wrong_key_returns_false(self):
+ sig = _make_signature("wrong-key", self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ assert not verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=self.KEY)
+
+ def test_tampered_body_returns_false(self):
+ sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ assert not verify_webhook(payload=b'{"event":"message.sent"}', headers=self._headers(sig), secret=self.KEY)
+
+ def test_wrong_request_id_returns_false(self):
+ sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ headers = {**self._headers(sig), "X-Inkbox-Request-ID": "different-id"}
+ assert not verify_webhook(payload=self.BODY, headers=headers, secret=self.KEY)
+
+ def test_wrong_timestamp_returns_false(self):
+ sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ headers = {**self._headers(sig), "X-Inkbox-Timestamp": "9999999999"}
+ assert not verify_webhook(payload=self.BODY, headers=headers, secret=self.KEY)
+
+ def test_missing_sha256_prefix_returns_false(self):
+ sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY)
+ bare_hex = sig.removeprefix("sha256=")
+ assert not verify_webhook(payload=self.BODY, headers=self._headers(bare_hex), secret=self.KEY)
+
+ def test_missing_headers_returns_false(self):
+ assert not verify_webhook(payload=self.BODY, headers={}, secret=self.KEY)
diff --git a/python/tests/test_transcripts.py b/python/tests/test_transcripts.py
new file mode 100644
index 0000000..fcccc40
--- /dev/null
+++ b/python/tests/test_transcripts.py
@@ -0,0 +1,43 @@
+"""Tests for TranscriptsResource."""
+
+from uuid import UUID
+
+from sample_data import PHONE_TRANSCRIPT_DICT
+
+
+NUM_ID = "aaaa1111-0000-0000-0000-000000000001"
+CALL_ID = "bbbb2222-0000-0000-0000-000000000001"
+
+
+class TestTranscriptsList:
+ def test_returns_transcripts(self, client, transport):
+ second = {
+ **PHONE_TRANSCRIPT_DICT,
+ "id": "cccc3333-0000-0000-0000-000000000002",
+ "seq": 1,
+ "ts_ms": 3000,
+ "party": "remote",
+ "text": "I need help with my account.",
+ }
+ transport.get.return_value = [PHONE_TRANSCRIPT_DICT, second]
+
+ transcripts = client._transcripts.list(NUM_ID, CALL_ID)
+
+ transport.get.assert_called_once_with(
+ f"/numbers/{NUM_ID}/calls/{CALL_ID}/transcripts",
+ )
+ assert len(transcripts) == 2
+ assert transcripts[0].seq == 0
+ assert transcripts[0].party == "local"
+ assert transcripts[0].text == "Hello, how can I help you?"
+ assert transcripts[0].ts_ms == 1500
+ assert transcripts[0].call_id == UUID(CALL_ID)
+ assert transcripts[1].seq == 1
+ assert transcripts[1].party == "remote"
+
+ def test_empty_transcripts(self, client, transport):
+ transport.get.return_value = []
+
+ transcripts = client._transcripts.list(NUM_ID, CALL_ID)
+
+ assert transcripts == []
diff --git a/python/tests/test_types.py b/python/tests/test_types.py
new file mode 100644
index 0000000..699a016
--- /dev/null
+++ b/python/tests/test_types.py
@@ -0,0 +1,67 @@
+"""Tests for type parsing."""
+
+from datetime import datetime
+from uuid import UUID
+
+from sample_data import (
+ PHONE_NUMBER_DICT,
+ PHONE_CALL_DICT,
+ PHONE_TRANSCRIPT_DICT,
+)
+from inkbox.phone.types import (
+ PhoneNumber,
+ PhoneCall,
+ PhoneTranscript,
+)
+
+
+class TestPhoneNumberParsing:
+ def test_from_dict(self):
+ n = PhoneNumber._from_dict(PHONE_NUMBER_DICT)
+
+ assert isinstance(n.id, UUID)
+ assert n.number == "+18335794607"
+ assert n.type == "toll_free"
+ assert n.status == "active"
+ assert n.incoming_call_action == "auto_reject"
+ assert n.client_websocket_url is None
+ assert n.incoming_call_webhook_url is None
+ assert isinstance(n.created_at, datetime)
+ assert isinstance(n.updated_at, datetime)
+
+
+class TestPhoneCallParsing:
+ def test_from_dict(self):
+ c = PhoneCall._from_dict(PHONE_CALL_DICT)
+
+ assert isinstance(c.id, UUID)
+ assert c.local_phone_number == "+18335794607"
+ assert c.remote_phone_number == "+15167251294"
+ assert c.direction == "outbound"
+ assert c.status == "completed"
+ assert c.client_websocket_url == "wss://agent.example.com/ws"
+ assert c.use_inkbox_tts is None
+ assert c.use_inkbox_stt is None
+ assert isinstance(c.started_at, datetime)
+ assert isinstance(c.ended_at, datetime)
+
+ def test_nullable_timestamps(self):
+ d = {**PHONE_CALL_DICT, "started_at": None, "ended_at": None}
+
+ c = PhoneCall._from_dict(d)
+
+ assert c.started_at is None
+ assert c.ended_at is None
+
+
+class TestPhoneTranscriptParsing:
+ def test_from_dict(self):
+ t = PhoneTranscript._from_dict(PHONE_TRANSCRIPT_DICT)
+
+ assert isinstance(t.id, UUID)
+ assert isinstance(t.call_id, UUID)
+ assert t.seq == 0
+ assert t.ts_ms == 1500
+ assert t.party == "local"
+ assert t.text == "Hello, how can I help you?"
+ assert isinstance(t.created_at, datetime)
diff --git a/python/uv.lock b/python/uv.lock
new file mode 100644
index 0000000..616a310
--- /dev/null
+++ b/python/uv.lock
@@ -0,0 +1,373 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.13.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
+ { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
+ { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
+ { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
+ { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
+ { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
+ { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
+ { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
+ { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
+ { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
+ { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
+ { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
+ { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
+ { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
+ { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
+ { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
+ { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
+ { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
+ { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
+ { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
+ { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
+ { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
+ { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
+ { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
+ { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
+ { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
+ { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
+ { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
+ { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
+ { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
+ { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
+ { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
+ { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
+ { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
+ { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
+ { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "inkbox"
+version = "0.1.0.2"
+source = { editable = "." }
+dependencies = [
+ { name = "httpx" },
+ { name = "python-dotenv" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "httpx", specifier = ">=0.27" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
+ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
+ { name = "python-dotenv", specifier = ">=1.2.2" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
+]
+provides-extras = ["dev"]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
+ { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
+ { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
+ { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
+ { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
+ { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
+ { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
+ { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
+ { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
+ { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
+ { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
+ { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
+ { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
+ { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
+ { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
+ { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
+ { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
diff --git a/typescript/README.md b/typescript/README.md
new file mode 100644
index 0000000..fd929b3
--- /dev/null
+++ b/typescript/README.md
@@ -0,0 +1,369 @@
+# @inkbox/sdk
+
+TypeScript SDK for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (email, phone, identities).
+
+## Install
+
+```bash
+npm install @inkbox/sdk
+```
+
+Requires Node.js ≥ 18.
+
+## Authentication
+
+You'll need an API key to use this SDK. Get one at [console.inkbox.ai](https://console.inkbox.ai/).
+
+## Quick start
+
+```ts
+import { Inkbox } from "@inkbox/sdk";
+
+const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! });
+
+// Create an agent identity
+const identity = await inkbox.createIdentity("support-bot");
+
+// Create and link new channels
+const mailbox = await identity.createMailbox({ displayName: "Support Bot" });
+const phone = await identity.provisionPhoneNumber({ type: "toll_free" });
+
+// Send email directly from the identity
+await identity.sendEmail({
+ to: ["customer@example.com"],
+ 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();
+```
+
+## 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 |
+
+---
+
+## Identities
+
+`inkbox.createIdentity()` and `inkbox.getIdentity()` return an `AgentIdentity` object that holds the identity's channels and exposes convenience methods scoped to those channels.
+
+```ts
+// Create and fully provision an identity
+const identity = await inkbox.createIdentity("sales-bot");
+const mailbox = await identity.createMailbox({ displayName: "Sales Bot" }); // creates + links
+const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); // provisions + links
+
+console.log(mailbox.emailAddress);
+console.log(phone.number);
+
+// Link an existing mailbox or phone number instead of creating new ones
+await identity.assignMailbox("mailbox-uuid-here");
+await identity.assignPhoneNumber("phone-number-uuid-here");
+
+// Get an existing identity (returned with current channel state)
+const identity2 = await inkbox.getIdentity("sales-bot");
+await identity2.refresh(); // re-fetch channels from API
+
+// List all identities for your org
+const allIdentities = await inkbox.listIdentities();
+
+// Update status or handle
+await identity.update({ status: "paused" });
+await identity.update({ newHandle: "sales-bot-v2" });
+
+// Unlink channels (without deleting them)
+await identity.unlinkMailbox();
+await identity.unlinkPhoneNumber();
+
+// Delete
+await identity.delete();
+```
+
+---
+
+## Mail
+
+```ts
+// Send an email (plain text and/or HTML)
+const sent = await identity.sendEmail({
+ to: ["user@example.com"],
+ subject: "Hello from Inkbox",
+ bodyText: "Hi there!",
+ bodyHtml: "Hi there!
",
+ cc: ["manager@example.com"],
+ bcc: ["archive@example.com"],
+});
+
+// Send a threaded reply
+await identity.sendEmail({
+ to: ["user@example.com"],
+ subject: `Re: ${sent.subject}`,
+ bodyText: "Following up!",
+ inReplyToMessageId: sent.id,
+});
+
+// Send with attachments
+await identity.sendEmail({
+ to: ["user@example.com"],
+ subject: "See attached",
+ bodyText: "Please find the file attached.",
+ attachments: [{
+ filename: "report.pdf",
+ contentType: "application/pdf",
+ contentBase64: "",
+ }],
+});
+
+// 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
+const unread: string[] = [];
+for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id);
+await identity.markEmailsRead(unread);
+
+// 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);
+}
+```
+
+---
+
+## Phone
+
+```ts
+// Place an outbound call — stream audio over WebSocket
+const call = await identity.placeCall({
+ toNumber: "+15167251294",
+ clientWebsocketUrl: "wss://your-agent.example.com/ws",
+});
+console.log(call.status, call.rateLimit.callsRemaining);
+
+// Or receive call events via webhook instead
+const call2 = await identity.placeCall({
+ toNumber: "+15167251294",
+ webhookUrl: "https://your-agent.example.com/call-events",
+});
+
+// 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"
+}
+
+// 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}`);
+}
+```
+
+---
+
+## Org-level Mailboxes
+
+Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`.
+
+```ts
+// List all mailboxes in the organisation
+const mailboxes = await inkbox.mailboxes.list();
+
+// Get a specific mailbox
+const mailbox = await inkbox.mailboxes.get("abc-xyz@inkboxmail.com");
+
+// Create a standalone mailbox
+const mb = await inkbox.mailboxes.create({ displayName: "Support Inbox" });
+console.log(mb.emailAddress);
+
+// Update display name or webhook URL
+await inkbox.mailboxes.update(mb.emailAddress, { displayName: "New Name" });
+await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: "https://example.com/hook" });
+await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: null }); // remove webhook
+
+// 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);
+}
+
+// Delete a mailbox
+await inkbox.mailboxes.delete(mb.emailAddress);
+```
+
+---
+
+## Org-level Phone Numbers
+
+Manage phone numbers directly without going through an identity. Access via `inkbox.phoneNumbers`.
+
+```ts
+// 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({ type: "toll_free" });
+const local = await inkbox.phoneNumbers.provision({ type: "local", 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({ number: num.number });
+```
+
+---
+
+## 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 `message.received` and `message.sent` events.
+
+```ts
+// Set webhook
+await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: "https://example.com/hook" });
+
+// Remove webhook
+await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: null });
+```
+
+### Phone webhooks
+
+Set an incoming call webhook URL and action on a phone number.
+
+```ts
+// Route incoming calls to a webhook
+await inkbox.phoneNumbers.update(number.id, {
+ incomingCallAction: "webhook",
+ incomingCallWebhookUrl: "https://example.com/calls",
+});
+```
+
+You can also supply a per-call webhook URL when placing a call:
+
+```ts
+await identity.placeCall({ toNumber: "+15005550006", webhookUrl: "https://example.com/call-events" });
+```
+
+---
+
+## Signing Keys
+
+```ts
+// Create or rotate the org-level webhook signing key (plaintext returned once)
+const key = await inkbox.createSigningKey();
+console.log(key.signingKey); // save this immediately
+```
+
+---
+
+## Verifying Webhook Signatures
+
+Use `verifyWebhook` to confirm that an incoming request was sent by Inkbox.
+
+```typescript
+import { verifyWebhook } from "@inkbox/sdk";
+
+// Express — use express.raw() to get the raw body Buffer
+app.post("/hooks/mail", express.raw({ type: "*/*" }), (req, res) => {
+ const valid = verifyWebhook({
+ payload: req.body,
+ headers: req.headers,
+ secret: "whsec_...",
+ });
+ if (!valid) return res.status(403).end();
+ // handle event ...
+});
+```
+
+---
+
+## Examples
+
+Runnable example scripts are available in the [examples/typescript](https://github.com/vectorlyapp/inkbox/tree/main/inkbox/examples/typescript) directory:
+
+| Script | What it demonstrates |
+|---|---|
+| `register-agent-identity.ts` | Create an identity, assign mailbox + phone number |
+| `agent-send-email.ts` | Send an email and a threaded reply |
+| `read-agent-messages.ts` | List messages and threads |
+| `create-agent-mailbox.ts` | Create, update, search, and delete a mailbox |
+| `create-agent-phone-number.ts` | Provision, update, and release a number |
+| `list-agent-phone-numbers.ts` | List all phone numbers in the org |
+| `read-agent-calls.ts` | List calls and print transcripts |
+| `receive-agent-email-webhook.ts` | Register and delete a mailbox webhook |
+| `receive-agent-call-webhook.ts` | Register, update, and delete a phone webhook |
+
+## License
+
+MIT
diff --git a/typescript/package.json b/typescript/package.json
index 1f8e93c..eba371a 100644
--- a/typescript/package.json
+++ b/typescript/package.json
@@ -12,13 +12,20 @@
"types": "./dist/index.d.ts"
}
},
- "files": ["dist"],
+ "files": [
+ "dist"
+ ],
"scripts": {
"build": "tsc",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build"
},
"devDependencies": {
- "typescript": "^5.4.0"
+ "@types/node": "^25.5.0",
+ "@vitest/coverage-v8": "^2.0.0",
+ "typescript": "^5.4.0",
+ "vitest": "^2.0.0"
},
"repository": {
"type": "git",
diff --git a/typescript/src/agent_identity.ts b/typescript/src/agent_identity.ts
new file mode 100644
index 0000000..4635712
--- /dev/null
+++ b/typescript/src/agent_identity.ts
@@ -0,0 +1,321 @@
+/**
+ * inkbox/src/agent.ts
+ *
+ * AgentIdentity — a domain object representing one agent identity.
+ * Returned by inkbox.createIdentity() and inkbox.getIdentity().
+ *
+ * Convenience methods (sendEmail, placeCall, etc.) are scoped to this
+ * identity's assigned channels so callers never need to pass an email
+ * address or phone number ID explicitly.
+ */
+
+import { InkboxAPIError } from "./_http.js";
+import type { Message, ThreadDetail } from "./mail/types.js";
+import type { PhoneCall, PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js";
+import type {
+ AgentIdentitySummary,
+ _AgentIdentityData,
+ IdentityMailbox,
+ IdentityPhoneNumber,
+} from "./identities/types.js";
+import type { Inkbox } from "./inkbox.js";
+
+export class AgentIdentity {
+ private _data: _AgentIdentityData;
+ private readonly _inkbox: Inkbox;
+ private _mailbox: IdentityMailbox | null;
+ private _phoneNumber: IdentityPhoneNumber | null;
+
+ constructor(data: _AgentIdentityData, inkbox: Inkbox) {
+ this._data = data;
+ this._inkbox = inkbox;
+ this._mailbox = data.mailbox;
+ this._phoneNumber = data.phoneNumber;
+ }
+
+ // ------------------------------------------------------------------
+ // Identity properties
+ // ------------------------------------------------------------------
+
+ get agentHandle(): string { return this._data.agentHandle; }
+ get id(): string { return this._data.id; }
+ get status(): string { return this._data.status; }
+
+ /** The mailbox currently assigned to this identity, or `null` if none. */
+ get mailbox(): IdentityMailbox | null { return this._mailbox; }
+
+ /** The phone number currently assigned to this identity, or `null` if none. */
+ get phoneNumber(): IdentityPhoneNumber | null { return this._phoneNumber; }
+
+ // ------------------------------------------------------------------
+ // Channel management
+ // ------------------------------------------------------------------
+
+ /**
+ * Create a new mailbox and link it to this identity.
+ *
+ * @param options.displayName - Optional human-readable sender name.
+ * @returns The newly created and linked {@link IdentityMailbox}.
+ */
+ async createMailbox(options: { displayName?: string } = {}): Promise {
+ const mailbox = await this._inkbox._mailboxes.create(options);
+ const data = await this._inkbox._idsResource.assignMailbox(this.agentHandle, {
+ mailboxId: mailbox.id,
+ });
+ this._mailbox = data.mailbox;
+ this._data = data;
+ return this._mailbox!;
+ }
+
+ /**
+ * Link an existing mailbox to this identity.
+ *
+ * @param mailboxId - UUID of the mailbox to link. Obtain via
+ * `inkbox.mailboxes.list()` or `inkbox.mailboxes.get()`.
+ * @returns The linked {@link IdentityMailbox}.
+ */
+ async assignMailbox(mailboxId: string): Promise {
+ const data = await this._inkbox._idsResource.assignMailbox(this.agentHandle, {
+ mailboxId,
+ });
+ this._mailbox = data.mailbox;
+ this._data = data;
+ return this._mailbox!;
+ }
+
+ /**
+ * Unlink this identity's mailbox (does not delete the mailbox).
+ */
+ async unlinkMailbox(): Promise {
+ this._requireMailbox();
+ await this._inkbox._idsResource.unlinkMailbox(this.agentHandle);
+ this._mailbox = null;
+ }
+
+ /**
+ * Provision a new phone number and link it to this identity.
+ *
+ * @param options.type - `"toll_free"` (default) or `"local"`.
+ * @param options.state - US state abbreviation (e.g. `"NY"`), valid for local numbers only.
+ * @returns The newly provisioned and linked {@link IdentityPhoneNumber}.
+ */
+ async provisionPhoneNumber(
+ options: { type?: string; state?: string } = {},
+ ): Promise {
+ await this._inkbox._numbers.provision({ agentHandle: this.agentHandle, ...options });
+ const data = await this._inkbox._idsResource.get(this.agentHandle);
+ this._phoneNumber = data.phoneNumber;
+ this._data = data;
+ return this._phoneNumber!;
+ }
+
+ /**
+ * Link an existing phone number to this identity.
+ *
+ * @param phoneNumberId - UUID of the phone number to link. Obtain via
+ * `inkbox.phoneNumbers.list()` or `inkbox.phoneNumbers.get()`.
+ * @returns The linked {@link IdentityPhoneNumber}.
+ */
+ async assignPhoneNumber(phoneNumberId: string): Promise {
+ const data = await this._inkbox._idsResource.assignPhoneNumber(this.agentHandle, {
+ phoneNumberId,
+ });
+ this._phoneNumber = data.phoneNumber;
+ this._data = data;
+ return this._phoneNumber!;
+ }
+
+ /**
+ * Unlink this identity's phone number (does not release the number).
+ */
+ async unlinkPhoneNumber(): Promise {
+ this._requirePhone();
+ await this._inkbox._idsResource.unlinkPhoneNumber(this.agentHandle);
+ this._phoneNumber = null;
+ }
+
+ // ------------------------------------------------------------------
+ // Mail helpers
+ // ------------------------------------------------------------------
+
+ /**
+ * Send an email from this identity's mailbox.
+ *
+ * @param options.to - Primary recipient addresses (at least one required).
+ * @param options.subject - Email subject line.
+ * @param options.bodyText - Plain-text body.
+ * @param options.bodyHtml - HTML body.
+ * @param options.cc - Carbon-copy recipients.
+ * @param options.bcc - Blind carbon-copy recipients.
+ * @param options.inReplyToMessageId - RFC 5322 Message-ID to thread a reply.
+ * @param options.attachments - File attachments.
+ */
+ async sendEmail(options: {
+ to: string[];
+ subject: string;
+ bodyText?: string;
+ bodyHtml?: string;
+ cc?: string[];
+ bcc?: string[];
+ inReplyToMessageId?: string;
+ attachments?: Array<{ filename: string; contentType: string; contentBase64: string }>;
+ }): Promise {
+ this._requireMailbox();
+ return this._inkbox._messages.send(this._mailbox!.emailAddress, options);
+ }
+
+ /**
+ * Iterate over emails in this identity's inbox, newest first.
+ *
+ * Pagination is handled automatically.
+ *
+ * @param options.pageSize - Messages fetched per API call (1–100). Defaults to 50.
+ * @param options.direction - Filter by `"inbound"` or `"outbound"`.
+ */
+ iterEmails(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator {
+ this._requireMailbox();
+ return this._inkbox._messages.list(this._mailbox!.emailAddress, options);
+ }
+
+ /**
+ * Iterate over unread emails in this identity's inbox, newest first.
+ *
+ * Fetches all messages and filters client-side. Pagination is handled automatically.
+ *
+ * @param options.pageSize - Messages fetched per API call (1–100). Defaults to 50.
+ * @param options.direction - Filter by `"inbound"` or `"outbound"`.
+ */
+ async *iterUnreadEmails(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator {
+ for await (const msg of this.iterEmails(options)) {
+ if (!msg.isRead) yield msg;
+ }
+ }
+
+ /**
+ * Mark a list of messages as read.
+ *
+ * @param messageIds - IDs of the messages to mark as read.
+ */
+ async markEmailsRead(messageIds: string[]): Promise {
+ this._requireMailbox();
+ for (const id of messageIds) {
+ await this._inkbox._messages.markRead(this._mailbox!.emailAddress, id);
+ }
+ }
+
+ /**
+ * Get a thread with all its messages inlined (oldest-first).
+ *
+ * @param threadId - UUID of the thread to fetch. Obtain via `msg.threadId`
+ * on any {@link Message}.
+ */
+ async getThread(threadId: string): Promise {
+ this._requireMailbox();
+ return this._inkbox._threads.get(this._mailbox!.emailAddress, threadId);
+ }
+
+ // ------------------------------------------------------------------
+ // Phone helpers
+ // ------------------------------------------------------------------
+
+ /**
+ * Place an outbound call from this identity's phone number.
+ *
+ * @param options.toNumber - E.164 destination number.
+ * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging.
+ * @param options.webhookUrl - Custom webhook URL for call lifecycle events.
+ */
+ async placeCall(options: {
+ toNumber: string;
+ clientWebsocketUrl?: string;
+ webhookUrl?: string;
+ }): Promise {
+ this._requirePhone();
+ return this._inkbox._calls.place({
+ fromNumber: this._phoneNumber!.number,
+ toNumber: options.toNumber,
+ clientWebsocketUrl: options.clientWebsocketUrl,
+ webhookUrl: options.webhookUrl,
+ });
+ }
+
+ /**
+ * List calls made to/from this identity's phone number.
+ *
+ * @param options.limit - Maximum number of results. Defaults to 50.
+ * @param options.offset - Pagination offset. Defaults to 0.
+ */
+ async listCalls(options: { limit?: number; offset?: number } = {}): Promise {
+ this._requirePhone();
+ return this._inkbox._calls.list(this._phoneNumber!.id, options);
+ }
+
+ /**
+ * List transcript segments for a specific call.
+ *
+ * @param callId - ID of the call to fetch transcripts for.
+ */
+ async listTranscripts(callId: string): Promise {
+ this._requirePhone();
+ return this._inkbox._transcripts.list(this._phoneNumber!.id, callId);
+ }
+
+ // ------------------------------------------------------------------
+ // Identity management
+ // ------------------------------------------------------------------
+
+ /**
+ * Update this identity's handle or status.
+ *
+ * @param options.newHandle - New agent handle.
+ * @param options.status - New lifecycle status: `"active"` or `"paused"`.
+ */
+ async update(options: { newHandle?: string; status?: string }): Promise {
+ const result = await this._inkbox._idsResource.update(this.agentHandle, options);
+ this._data = {
+ ...result,
+ mailbox: this._mailbox,
+ phoneNumber: this._phoneNumber,
+ };
+ }
+
+ /**
+ * Re-fetch this identity from the API and update cached channels.
+ *
+ * @returns `this` for chaining.
+ */
+ async refresh(): Promise {
+ const data = await this._inkbox._idsResource.get(this.agentHandle);
+ this._data = data;
+ this._mailbox = data.mailbox;
+ this._phoneNumber = data.phoneNumber;
+ return this;
+ }
+
+ /** Soft-delete this identity (unlinks channels without deleting them). */
+ async delete(): Promise {
+ await this._inkbox._idsResource.delete(this.agentHandle);
+ }
+
+ // ------------------------------------------------------------------
+ // Internal guards
+ // ------------------------------------------------------------------
+
+ private _requireMailbox(): void {
+ if (!this._mailbox) {
+ throw new InkboxAPIError(
+ 0,
+ `Identity '${this.agentHandle}' has no mailbox assigned. Call identity.createMailbox() or identity.assignMailbox() first.`,
+ );
+ }
+ }
+
+ private _requirePhone(): void {
+ if (!this._phoneNumber) {
+ throw new InkboxAPIError(
+ 0,
+ `Identity '${this.agentHandle}' has no phone number assigned. Call identity.provisionPhoneNumber() or identity.assignPhoneNumber() first.`,
+ );
+ }
+ }
+}
diff --git a/typescript/src/client.ts b/typescript/src/client.ts
deleted file mode 100644
index 91bb805..0000000
--- a/typescript/src/client.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * inkbox-mail/client.ts
- *
- * Top-level InkboxMail client.
- */
-
-import { HttpTransport } from "./_http.js";
-import { MailboxesResource } from "./resources/mailboxes.js";
-import { MessagesResource } from "./resources/messages.js";
-import { ThreadsResource } from "./resources/threads.js";
-import { WebhooksResource } from "./resources/webhooks.js";
-
-const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail";
-
-export interface InkboxMailOptions {
- /** Your Inkbox API key (sent as `X-Service-Token`). */
- apiKey: string;
- /** Override the API base URL (useful for self-hosting or testing). */
- baseUrl?: string;
- /** Request timeout in milliseconds. Defaults to 30 000. */
- timeoutMs?: number;
-}
-
-/**
- * Async client for the Inkbox Mail API.
- *
- * @example
- * ```ts
- * import { InkboxMail } from "@inkbox/mail";
- *
- * const client = new InkboxMail({ apiKey: "sk-..." });
- *
- * const mailbox = await client.mailboxes.create({ displayName: "Agent 01" });
- *
- * await client.messages.send(mailbox.id, {
- * to: ["user@example.com"],
- * subject: "Hello from Inkbox",
- * bodyText: "Hi there!",
- * });
- *
- * for await (const msg of client.messages.list(mailbox.id)) {
- * console.log(msg.subject, msg.fromAddress);
- * }
- * ```
- */
-export class InkboxMail {
- readonly mailboxes: MailboxesResource;
- readonly messages: MessagesResource;
- readonly threads: ThreadsResource;
- readonly webhooks: WebhooksResource;
-
- private readonly http: HttpTransport;
-
- constructor(options: InkboxMailOptions) {
- this.http = new HttpTransport(
- options.apiKey,
- options.baseUrl ?? DEFAULT_BASE_URL,
- options.timeoutMs ?? 30_000,
- );
- this.mailboxes = new MailboxesResource(this.http);
- this.messages = new MessagesResource(this.http);
- this.threads = new ThreadsResource(this.http);
- this.webhooks = new WebhooksResource(this.http);
- }
-}
diff --git a/typescript/src/identities/resources/identities.ts b/typescript/src/identities/resources/identities.ts
new file mode 100644
index 0000000..3b38507
--- /dev/null
+++ b/typescript/src/identities/resources/identities.ts
@@ -0,0 +1,131 @@
+/**
+ * inkbox-identities/resources/identities.ts
+ *
+ * Identity CRUD and channel assignment.
+ */
+
+import { HttpTransport } from "../../_http.js";
+import {
+ AgentIdentitySummary,
+ _AgentIdentityData,
+ RawAgentIdentitySummary,
+ RawAgentIdentityData,
+ parseAgentIdentitySummary,
+ parseAgentIdentityData,
+} from "../types.js";
+
+export class IdentitiesResource {
+ constructor(private readonly http: HttpTransport) {}
+
+ /**
+ * Create a new agent identity.
+ *
+ * @param options.agentHandle - Unique handle for this identity within your organisation
+ * (e.g. `"sales-agent"` or `"@sales-agent"`).
+ */
+ async create(options: { agentHandle: string }): Promise {
+ const data = await this.http.post("/", {
+ agent_handle: options.agentHandle,
+ });
+ return parseAgentIdentitySummary(data);
+ }
+
+ /** List all identities for your organisation. */
+ async list(): Promise {
+ const data = await this.http.get("/");
+ return data.map(parseAgentIdentitySummary);
+ }
+
+ /**
+ * Get an identity with its linked channels (mailbox, phone number).
+ *
+ * @param agentHandle - Handle of the identity to fetch.
+ */
+ async get(agentHandle: string): Promise<_AgentIdentityData> {
+ const data = await this.http.get(`/${agentHandle}`);
+ return parseAgentIdentityData(data);
+ }
+
+ /**
+ * Update an identity's handle or status.
+ *
+ * Only provided fields are applied; omitted fields are left unchanged.
+ *
+ * @param agentHandle - Current handle of the identity to update.
+ * @param options.newHandle - New handle value.
+ * @param options.status - New lifecycle status: `"active"` or `"paused"`.
+ */
+ async update(
+ agentHandle: string,
+ options: { newHandle?: string; status?: string },
+ ): Promise {
+ const body: Record = {};
+ if (options.newHandle !== undefined) body["agent_handle"] = options.newHandle;
+ if (options.status !== undefined) body["status"] = options.status;
+ const data = await this.http.patch(`/${agentHandle}`, body);
+ return parseAgentIdentitySummary(data);
+ }
+
+ /**
+ * Soft-delete an identity.
+ *
+ * Unlinks any assigned channels without deleting them.
+ *
+ * @param agentHandle - Handle of the identity to delete.
+ */
+ async delete(agentHandle: string): Promise {
+ await this.http.delete(`/${agentHandle}`);
+ }
+
+ /**
+ * Assign a mailbox to an identity.
+ *
+ * @param agentHandle - Handle of the identity.
+ * @param options.mailboxId - UUID of the mailbox to assign.
+ */
+ async assignMailbox(
+ agentHandle: string,
+ options: { mailboxId: string },
+ ): Promise<_AgentIdentityData> {
+ const data = await this.http.post(
+ `/${agentHandle}/mailbox`,
+ { mailbox_id: options.mailboxId },
+ );
+ return parseAgentIdentityData(data);
+ }
+
+ /**
+ * Unlink the mailbox from an identity (does not delete the mailbox).
+ *
+ * @param agentHandle - Handle of the identity.
+ */
+ async unlinkMailbox(agentHandle: string): Promise {
+ await this.http.delete(`/${agentHandle}/mailbox`);
+ }
+
+ /**
+ * Assign a phone number to an identity.
+ *
+ * @param agentHandle - Handle of the identity.
+ * @param options.phoneNumberId - UUID of the phone number to assign.
+ */
+ async assignPhoneNumber(
+ agentHandle: string,
+ options: { phoneNumberId: string },
+ ): Promise<_AgentIdentityData> {
+ const data = await this.http.post(
+ `/${agentHandle}/phone_number`,
+ { phone_number_id: options.phoneNumberId },
+ );
+ return parseAgentIdentityData(data);
+ }
+
+ /**
+ * Unlink the phone number from an identity (does not delete the number).
+ *
+ * @param agentHandle - Handle of the identity.
+ */
+ async unlinkPhoneNumber(agentHandle: string): Promise {
+ await this.http.delete(`/${agentHandle}/phone_number`);
+ }
+}
diff --git a/typescript/src/identities/types.ts b/typescript/src/identities/types.ts
new file mode 100644
index 0000000..400e4b7
--- /dev/null
+++ b/typescript/src/identities/types.ts
@@ -0,0 +1,127 @@
+/**
+ * inkbox-identities TypeScript SDK — public types.
+ */
+
+export interface IdentityMailbox {
+ id: string;
+ emailAddress: string;
+ displayName: string | null;
+ /** "active" | "paused" | "deleted" */
+ status: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface IdentityPhoneNumber {
+ id: string;
+ number: string;
+ /** "toll_free" | "local" */
+ type: string;
+ /** "active" | "paused" | "released" */
+ status: string;
+ /** "auto_accept" | "auto_reject" | "webhook" */
+ incomingCallAction: string;
+ clientWebsocketUrl: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+/** Lightweight identity returned by list and update endpoints. */
+export interface AgentIdentitySummary {
+ id: string;
+ organizationId: string;
+ agentHandle: string;
+ /** "active" | "paused" | "deleted" */
+ status: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+/** @internal Full identity data with channels — users interact with AgentIdentity (the class) instead. */
+export interface _AgentIdentityData extends AgentIdentitySummary {
+ /** Mailbox assigned to this identity, or null if unlinked. */
+ mailbox: IdentityMailbox | null;
+ /** Phone number assigned to this identity, or null if unlinked. */
+ phoneNumber: IdentityPhoneNumber | null;
+}
+
+// ---- internal raw API shapes (snake_case from JSON) ----
+
+export interface RawIdentityMailbox {
+ id: string;
+ email_address: string;
+ display_name: string | null;
+ status: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface RawIdentityPhoneNumber {
+ id: string;
+ number: string;
+ type: string;
+ status: string;
+ incoming_call_action: string;
+ client_websocket_url: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface RawAgentIdentitySummary {
+ id: string;
+ organization_id: string;
+ agent_handle: string;
+ status: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface RawAgentIdentityData extends RawAgentIdentitySummary {
+ mailbox: RawIdentityMailbox | null;
+ phone_number: RawIdentityPhoneNumber | null;
+}
+
+// ---- parsers ----
+
+export function parseIdentityMailbox(r: RawIdentityMailbox): IdentityMailbox {
+ return {
+ id: r.id,
+ emailAddress: r.email_address,
+ displayName: r.display_name,
+ status: r.status,
+ createdAt: new Date(r.created_at),
+ updatedAt: new Date(r.updated_at),
+ };
+}
+
+export function parseIdentityPhoneNumber(r: RawIdentityPhoneNumber): IdentityPhoneNumber {
+ return {
+ id: r.id,
+ number: r.number,
+ type: r.type,
+ status: r.status,
+ incomingCallAction: r.incoming_call_action,
+ clientWebsocketUrl: r.client_websocket_url,
+ createdAt: new Date(r.created_at),
+ updatedAt: new Date(r.updated_at),
+ };
+}
+
+export function parseAgentIdentitySummary(r: RawAgentIdentitySummary): AgentIdentitySummary {
+ return {
+ id: r.id,
+ organizationId: r.organization_id,
+ agentHandle: r.agent_handle,
+ status: r.status,
+ createdAt: new Date(r.created_at),
+ updatedAt: new Date(r.updated_at),
+ };
+}
+
+export function parseAgentIdentityData(r: RawAgentIdentityData): _AgentIdentityData {
+ return {
+ ...parseAgentIdentitySummary(r),
+ mailbox: r.mailbox ? parseIdentityMailbox(r.mailbox) : null,
+ phoneNumber: r.phone_number ? parseIdentityPhoneNumber(r.phone_number) : null,
+ };
+}
diff --git a/typescript/src/index.ts b/typescript/src/index.ts
index 3f4443f..b1e927e 100644
--- a/typescript/src/index.ts
+++ b/typescript/src/index.ts
@@ -1,11 +1,25 @@
-export { InkboxMail } from "./client.js";
+export { Inkbox } from "./inkbox.js";
+export { AgentIdentity } from "./agent_identity.js";
+export type { InkboxOptions } from "./inkbox.js";
export { InkboxAPIError } from "./_http.js";
+export type { SigningKey } from "./signing_keys.js";
+export { verifyWebhook } from "./signing_keys.js";
export type {
Mailbox,
Message,
MessageDetail,
Thread,
ThreadDetail,
- Webhook,
- WebhookCreateResult,
-} from "./types.js";
+} from "./mail/types.js";
+export type {
+ PhoneNumber,
+ PhoneCall,
+ PhoneCallWithRateLimit,
+ RateLimitInfo,
+ PhoneTranscript,
+} from "./phone/types.js";
+export type {
+ AgentIdentitySummary,
+ IdentityMailbox,
+ IdentityPhoneNumber,
+} from "./identities/types.js";
diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts
new file mode 100644
index 0000000..069d337
--- /dev/null
+++ b/typescript/src/inkbox.ts
@@ -0,0 +1,143 @@
+/**
+ * inkbox/src/inkbox.ts
+ *
+ * Inkbox — org-level entry point for all Inkbox APIs.
+ */
+
+import { HttpTransport } from "./_http.js";
+import { MailboxesResource } from "./mail/resources/mailboxes.js";
+import { MessagesResource } from "./mail/resources/messages.js";
+import { ThreadsResource } from "./mail/resources/threads.js";
+import { SigningKeysResource } from "./signing_keys.js";
+import type { SigningKey } from "./signing_keys.js";
+import { PhoneNumbersResource } from "./phone/resources/numbers.js";
+import { CallsResource } from "./phone/resources/calls.js";
+import { TranscriptsResource } from "./phone/resources/transcripts.js";
+import { IdentitiesResource } from "./identities/resources/identities.js";
+import { AgentIdentity } from "./agent_identity.js";
+import type { AgentIdentitySummary } from "./identities/types.js";
+
+const DEFAULT_BASE_URL = "https://api.inkbox.ai";
+
+export interface InkboxOptions {
+ /** Your Inkbox API key (sent as `X-Service-Token`). */
+ apiKey: string;
+ /** Override the API base URL (useful for self-hosting or testing). */
+ baseUrl?: string;
+ /** Request timeout in milliseconds. Defaults to 30 000. */
+ timeoutMs?: number;
+}
+
+/**
+ * Org-level entry point for all Inkbox APIs.
+ *
+ * @example
+ * ```ts
+ * import { Inkbox } from "@inkbox/sdk";
+ *
+ * const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! });
+ *
+ * // Create an agent identity
+ * const identity = await inkbox.createIdentity("support-bot");
+ *
+ * // Create and link new channels
+ * const mailbox = await identity.createMailbox({ displayName: "Support Bot" });
+ * const phone = await identity.provisionPhoneNumber({ type: "toll_free" });
+ *
+ * // Send email directly from the identity
+ * await identity.sendEmail({
+ * to: ["customer@example.com"],
+ * subject: "Your order has shipped",
+ * bodyText: "Tracking number: 1Z999AA10123456784",
+ * });
+ * ```
+ */
+export class Inkbox {
+ readonly _mailboxes: MailboxesResource;
+ readonly _messages: MessagesResource;
+ readonly _threads: ThreadsResource;
+ readonly _signingKeys: SigningKeysResource;
+ readonly _numbers: PhoneNumbersResource;
+ readonly _calls: CallsResource;
+ readonly _transcripts: TranscriptsResource;
+ readonly _idsResource: IdentitiesResource;
+
+ constructor(options: InkboxOptions) {
+ const apiRoot = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/api/v1`;
+ const ms = options.timeoutMs ?? 30_000;
+
+ const mailHttp = new HttpTransport(options.apiKey, `${apiRoot}/mail`, ms);
+ const phoneHttp = new HttpTransport(options.apiKey, `${apiRoot}/phone`, ms);
+ const idsHttp = new HttpTransport(options.apiKey, `${apiRoot}/identities`, ms);
+ const apiHttp = new HttpTransport(options.apiKey, apiRoot, ms);
+
+ this._mailboxes = new MailboxesResource(mailHttp);
+ this._messages = new MessagesResource(mailHttp);
+ this._threads = new ThreadsResource(mailHttp);
+ this._signingKeys = new SigningKeysResource(apiHttp);
+
+ this._numbers = new PhoneNumbersResource(phoneHttp);
+ this._calls = new CallsResource(phoneHttp);
+ this._transcripts = new TranscriptsResource(phoneHttp);
+
+ this._idsResource = new IdentitiesResource(idsHttp);
+ }
+
+ // ------------------------------------------------------------------
+ // Public resource accessors
+ // ------------------------------------------------------------------
+
+ /** Org-level mailbox operations (list, get, create, update, delete). */
+ get mailboxes(): MailboxesResource { return this._mailboxes; }
+
+ /** Org-level phone number operations (list, get, provision, release). */
+ get phoneNumbers(): PhoneNumbersResource { return this._numbers; }
+
+ // ------------------------------------------------------------------
+ // Org-level operations
+ // ------------------------------------------------------------------
+
+ /**
+ * Create a new agent identity.
+ *
+ * @param agentHandle - Unique handle for this identity (e.g. `"sales-bot"`).
+ * @returns The created {@link AgentIdentity}.
+ */
+ async createIdentity(agentHandle: string): Promise {
+ await this._idsResource.create({ agentHandle });
+ // POST /identities returns summary (no channel fields); fetch detail so
+ // AgentIdentity has a fully-populated _AgentIdentityData.
+ const data = await this._idsResource.get(agentHandle);
+ return new AgentIdentity(data, this);
+ }
+
+ /**
+ * Get an existing agent identity by handle.
+ *
+ * @param agentHandle - Handle of the identity to fetch.
+ * @returns The {@link AgentIdentity}.
+ */
+ async getIdentity(agentHandle: string): Promise {
+ return new AgentIdentity(await this._idsResource.get(agentHandle), this);
+ }
+
+ /**
+ * List all agent identities for your organisation.
+ *
+ * @returns Array of {@link AgentIdentitySummary}.
+ */
+ async listIdentities(): Promise {
+ return this._idsResource.list();
+ }
+
+ /**
+ * Create or rotate the org-level webhook signing key.
+ *
+ * The plaintext key is returned once — save it immediately.
+ *
+ * @returns The new {@link SigningKey}.
+ */
+ async createSigningKey(): Promise {
+ return this._signingKeys.createOrRotate();
+ }
+}
diff --git a/typescript/src/mail/resources/mailboxes.ts b/typescript/src/mail/resources/mailboxes.ts
new file mode 100644
index 0000000..f7ea312
--- /dev/null
+++ b/typescript/src/mail/resources/mailboxes.ts
@@ -0,0 +1,106 @@
+/**
+ * inkbox-mail/resources/mailboxes.ts
+ *
+ * Mailbox CRUD and full-text search.
+ */
+
+import { HttpTransport } from "../../_http.js";
+import {
+ Mailbox,
+ Message,
+ RawCursorPage,
+ RawMailbox,
+ RawMessage,
+ parseMailbox,
+ parseMessage,
+} from "../types.js";
+
+const BASE = "/mailboxes";
+
+export class MailboxesResource {
+ constructor(private readonly http: HttpTransport) {}
+
+ /**
+ * Create a new mailbox.
+ *
+ * The email address is automatically generated by the server.
+ *
+ * @param options.displayName - Optional human-readable name shown as the sender.
+ */
+ async create(options: { displayName?: string } = {}): Promise {
+ const body: Record = {};
+ if (options.displayName !== undefined) {
+ body["display_name"] = options.displayName;
+ }
+ const data = await this.http.post(BASE, body);
+ return parseMailbox(data);
+ }
+
+ /** List all mailboxes for your organisation. */
+ async list(): Promise {
+ const data = await this.http.get(BASE);
+ return data.map(parseMailbox);
+ }
+
+ /**
+ * Get a mailbox by its email address.
+ *
+ * @param emailAddress - Full email address of the mailbox (e.g. `"abc-xyz@inkboxmail.com"`).
+ */
+ async get(emailAddress: string): Promise {
+ const data = await this.http.get(`${BASE}/${emailAddress}`);
+ return parseMailbox(data);
+ }
+
+ /**
+ * Update mutable mailbox fields.
+ *
+ * Only provided fields are applied; omitted fields are left unchanged.
+ * Pass `webhookUrl: null` to unsubscribe from webhooks.
+ *
+ * @param emailAddress - Full email address of the mailbox to update.
+ * @param options.displayName - New human-readable sender name.
+ * @param options.webhookUrl - HTTPS URL to receive webhook events, or `null` to unsubscribe.
+ */
+ async update(
+ emailAddress: string,
+ options: { displayName?: string; webhookUrl?: string | null },
+ ): Promise {
+ const body: Record = {};
+ if (options.displayName !== undefined) {
+ body["display_name"] = options.displayName;
+ }
+ if ("webhookUrl" in options) {
+ body["webhook_url"] = options.webhookUrl;
+ }
+ const data = await this.http.patch(`${BASE}/${emailAddress}`, body);
+ return parseMailbox(data);
+ }
+
+ /**
+ * Delete a mailbox.
+ *
+ * @param emailAddress - Full email address of the mailbox to delete.
+ */
+ async delete(emailAddress: string): Promise {
+ await this.http.delete(`${BASE}/${emailAddress}`);
+ }
+
+ /**
+ * Full-text search across messages in a mailbox.
+ *
+ * @param emailAddress - Full email address of the mailbox to search.
+ * @param options.q - Search query string.
+ * @param options.limit - Maximum number of results (1–100). Defaults to 50.
+ */
+ async search(
+ emailAddress: string,
+ options: { q: string; limit?: number },
+ ): Promise {
+ const data = await this.http.get>(
+ `${BASE}/${emailAddress}/search`,
+ { q: options.q, limit: options.limit ?? 50 },
+ );
+ return data.items.map(parseMessage);
+ }
+}
diff --git a/typescript/src/resources/messages.ts b/typescript/src/mail/resources/messages.ts
similarity index 62%
rename from typescript/src/resources/messages.ts
rename to typescript/src/mail/resources/messages.ts
index 3ec5da5..7173dc6 100644
--- a/typescript/src/resources/messages.ts
+++ b/typescript/src/mail/resources/messages.ts
@@ -4,7 +4,7 @@
* Message operations: list (auto-paginated), get, send, flag updates, delete.
*/
-import { HttpTransport } from "../_http.js";
+import { HttpTransport } from "../../_http.js";
import {
Message,
MessageDetail,
@@ -26,22 +26,24 @@ export class MessagesResource {
*
* @example
* ```ts
- * for await (const msg of client.messages.list(mailboxId)) {
+ * for await (const msg of client.messages.list(emailAddress)) {
* console.log(msg.subject, msg.fromAddress);
* }
* ```
*/
async *list(
- mailboxId: string,
- options?: { pageSize?: number },
+ emailAddress: string,
+ options?: { pageSize?: number; direction?: "inbound" | "outbound" },
): AsyncGenerator {
const limit = options?.pageSize ?? DEFAULT_PAGE_SIZE;
let cursor: string | undefined;
while (true) {
+ const params: Record = { limit, cursor };
+ if (options?.direction !== undefined) params["direction"] = options.direction;
const page = await this.http.get>(
- `/mailboxes/${mailboxId}/messages`,
- { limit, cursor },
+ `/mailboxes/${emailAddress}/messages`,
+ params,
);
for (const item of page.items) {
yield parseMessage(item);
@@ -54,12 +56,12 @@ export class MessagesResource {
/**
* Get a message with full body content.
*
- * @param mailboxId - UUID of the owning mailbox.
+ * @param emailAddress - Full email address of the owning mailbox.
* @param messageId - UUID of the message.
*/
- async get(mailboxId: string, messageId: string): Promise {
+ async get(emailAddress: string, messageId: string): Promise {
const data = await this.http.get(
- `/mailboxes/${mailboxId}/messages/${messageId}`,
+ `/mailboxes/${emailAddress}/messages/${messageId}`,
);
return parseMessageDetail(data);
}
@@ -67,7 +69,7 @@ export class MessagesResource {
/**
* Send an email from a mailbox.
*
- * @param mailboxId - UUID of the sending mailbox.
+ * @param emailAddress - Full email address of the sending mailbox.
* @param options.to - Primary recipient addresses (at least one required).
* @param options.subject - Email subject line.
* @param options.bodyText - Plain-text body.
@@ -81,7 +83,7 @@ export class MessagesResource {
* file content). Max total size: 25 MB. Blocked: `.exe`, `.bat`, `.scr`.
*/
async send(
- mailboxId: string,
+ emailAddress: string,
options: {
to: string[];
subject: string;
@@ -119,7 +121,7 @@ export class MessagesResource {
}
const data = await this.http.post(
- `/mailboxes/${mailboxId}/messages`,
+ `/mailboxes/${emailAddress}/messages`,
body,
);
return parseMessage(data);
@@ -131,7 +133,7 @@ export class MessagesResource {
* Pass only the flags you want to change; omitted flags are left as-is.
*/
async updateFlags(
- mailboxId: string,
+ emailAddress: string,
messageId: string,
flags: { isRead?: boolean; isStarred?: boolean },
): Promise {
@@ -140,34 +142,55 @@ export class MessagesResource {
if (flags.isStarred !== undefined) body["is_starred"] = flags.isStarred;
const data = await this.http.patch(
- `/mailboxes/${mailboxId}/messages/${messageId}`,
+ `/mailboxes/${emailAddress}/messages/${messageId}`,
body,
);
return parseMessage(data);
}
/** Mark a message as read. */
- async markRead(mailboxId: string, messageId: string): Promise {
- return this.updateFlags(mailboxId, messageId, { isRead: true });
+ async markRead(emailAddress: string, messageId: string): Promise {
+ return this.updateFlags(emailAddress, messageId, { isRead: true });
}
/** Mark a message as unread. */
- async markUnread(mailboxId: string, messageId: string): Promise {
- return this.updateFlags(mailboxId, messageId, { isRead: false });
+ async markUnread(emailAddress: string, messageId: string): Promise {
+ return this.updateFlags(emailAddress, messageId, { isRead: false });
}
/** Star a message. */
- async star(mailboxId: string, messageId: string): Promise {
- return this.updateFlags(mailboxId, messageId, { isStarred: true });
+ async star(emailAddress: string, messageId: string): Promise {
+ return this.updateFlags(emailAddress, messageId, { isStarred: true });
}
/** Unstar a message. */
- async unstar(mailboxId: string, messageId: string): Promise {
- return this.updateFlags(mailboxId, messageId, { isStarred: false });
+ async unstar(emailAddress: string, messageId: string): Promise {
+ return this.updateFlags(emailAddress, messageId, { isStarred: false });
}
/** Delete a message. */
- async delete(mailboxId: string, messageId: string): Promise {
- await this.http.delete(`/mailboxes/${mailboxId}/messages/${messageId}`);
+ async delete(emailAddress: string, messageId: string): Promise {
+ await this.http.delete(`/mailboxes/${emailAddress}/messages/${messageId}`);
+ }
+
+ /**
+ * Get a presigned URL for a message attachment.
+ *
+ * @param emailAddress - Full email address of the owning mailbox.
+ * @param messageId - UUID of the message.
+ * @param filename - Attachment filename.
+ * @param options.redirect - If `true`, follows the redirect. If `false` (default),
+ * returns `{ url, filename, expiresIn }`.
+ */
+ async getAttachment(
+ emailAddress: string,
+ messageId: string,
+ filename: string,
+ options?: { redirect?: boolean },
+ ): Promise<{ url: string; filename: string; expiresIn: number }> {
+ return this.http.get(
+ `/mailboxes/${emailAddress}/messages/${messageId}/attachments/${filename}`,
+ { redirect: options?.redirect ? "true" : "false" },
+ );
}
}
diff --git a/typescript/src/resources/threads.ts b/typescript/src/mail/resources/threads.ts
similarity index 71%
rename from typescript/src/resources/threads.ts
rename to typescript/src/mail/resources/threads.ts
index dc0cdc8..c2ba11b 100644
--- a/typescript/src/resources/threads.ts
+++ b/typescript/src/mail/resources/threads.ts
@@ -4,7 +4,7 @@
* Thread operations: list (auto-paginated), get with messages, delete.
*/
-import { HttpTransport } from "../_http.js";
+import { HttpTransport } from "../../_http.js";
import {
RawCursorPage,
RawThread,
@@ -26,13 +26,13 @@ export class ThreadsResource {
*
* @example
* ```ts
- * for await (const thread of client.threads.list(mailboxId)) {
+ * for await (const thread of client.threads.list(emailAddress)) {
* console.log(thread.subject, thread.messageCount);
* }
* ```
*/
async *list(
- mailboxId: string,
+ emailAddress: string,
options?: { pageSize?: number },
): AsyncGenerator {
const limit = options?.pageSize ?? DEFAULT_PAGE_SIZE;
@@ -40,7 +40,7 @@ export class ThreadsResource {
while (true) {
const page = await this.http.get>(
- `/mailboxes/${mailboxId}/threads`,
+ `/mailboxes/${emailAddress}/threads`,
{ limit, cursor },
);
for (const item of page.items) {
@@ -54,18 +54,18 @@ export class ThreadsResource {
/**
* Get a thread with all its messages inlined.
*
- * @param mailboxId - UUID of the owning mailbox.
+ * @param emailAddress - Full email address of the owning mailbox.
* @param threadId - UUID of the thread.
*/
- async get(mailboxId: string, threadId: string): Promise {
+ async get(emailAddress: string, threadId: string): Promise {
const data = await this.http.get(
- `/mailboxes/${mailboxId}/threads/${threadId}`,
+ `/mailboxes/${emailAddress}/threads/${threadId}`,
);
return parseThreadDetail(data);
}
/** Delete a thread. */
- async delete(mailboxId: string, threadId: string): Promise {
- await this.http.delete(`/mailboxes/${mailboxId}/threads/${threadId}`);
+ async delete(emailAddress: string, threadId: string): Promise {
+ await this.http.delete(`/mailboxes/${emailAddress}/threads/${threadId}`);
}
}
diff --git a/typescript/src/types.ts b/typescript/src/mail/types.ts
similarity index 80%
rename from typescript/src/types.ts
rename to typescript/src/mail/types.ts
index 1590931..3d36c09 100644
--- a/typescript/src/types.ts
+++ b/typescript/src/mail/types.ts
@@ -6,6 +6,7 @@ export interface Mailbox {
id: string;
emailAddress: string;
displayName: string | null;
+ webhookUrl: string | null;
/** "active" | "paused" | "deleted" */
status: string;
createdAt: Date;
@@ -61,27 +62,13 @@ export interface ThreadDetail extends Thread {
messages: Message[];
}
-export interface Webhook {
- id: string;
- mailboxId: string;
- url: string;
- eventTypes: string[];
- /** "active" | "paused" | "deleted" */
- status: string;
- createdAt: Date;
-}
-
-export interface WebhookCreateResult extends Webhook {
- /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */
- secret: string;
-}
-
// ---- internal raw API shapes (snake_case from JSON) ----
export interface RawMailbox {
id: string;
email_address: string;
display_name: string | null;
+ webhook_url: string | null;
status: string;
created_at: string;
updated_at: string;
@@ -125,20 +112,6 @@ export interface RawThread {
messages?: RawMessage[];
}
-export interface RawWebhook {
- id: string;
- mailbox_id: string;
- url: string;
- event_types: string[];
- status: string;
- created_at: string;
-}
-
-export interface RawWebhookCreateResult extends RawWebhook {
- /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */
- secret: string;
-}
-
export interface RawCursorPage {
items: T[];
next_cursor: string | null;
@@ -152,6 +125,7 @@ export function parseMailbox(r: RawMailbox): Mailbox {
id: r.id,
emailAddress: r.email_address,
displayName: r.display_name,
+ webhookUrl: r.webhook_url,
status: r.status,
createdAt: new Date(r.created_at),
updatedAt: new Date(r.updated_at),
@@ -211,17 +185,3 @@ export function parseThreadDetail(r: RawThread): ThreadDetail {
};
}
-export function parseWebhook(r: RawWebhook): Webhook {
- return {
- id: r.id,
- mailboxId: r.mailbox_id,
- url: r.url,
- eventTypes: r.event_types,
- status: r.status,
- createdAt: new Date(r.created_at),
- };
-}
-
-export function parseWebhookCreateResult(r: RawWebhookCreateResult): WebhookCreateResult {
- return { ...parseWebhook(r), secret: r.secret };
-}
diff --git a/typescript/src/phone/resources/calls.ts b/typescript/src/phone/resources/calls.ts
new file mode 100644
index 0000000..c39396f
--- /dev/null
+++ b/typescript/src/phone/resources/calls.ts
@@ -0,0 +1,79 @@
+/**
+ * inkbox-phone/resources/calls.ts
+ *
+ * Call operations: list, get, place.
+ */
+
+import { HttpTransport } from "../../_http.js";
+import {
+ PhoneCall,
+ PhoneCallWithRateLimit,
+ RawPhoneCall,
+ RawPhoneCallWithRateLimit,
+ parsePhoneCall,
+ parsePhoneCallWithRateLimit,
+} from "../types.js";
+
+export class CallsResource {
+ constructor(private readonly http: HttpTransport) {}
+
+ /**
+ * List calls for a phone number, newest first.
+ *
+ * @param phoneNumberId - UUID of the phone number.
+ * @param options.limit - Max results (1–200). Defaults to 50.
+ * @param options.offset - Pagination offset. Defaults to 0.
+ */
+ async list(
+ phoneNumberId: string,
+ options?: { limit?: number; offset?: number },
+ ): Promise {
+ const data = await this.http.get(
+ `/numbers/${phoneNumberId}/calls`,
+ { limit: options?.limit ?? 50, offset: options?.offset ?? 0 },
+ );
+ return data.map(parsePhoneCall);
+ }
+
+ /**
+ * Get a single call by ID.
+ *
+ * @param phoneNumberId - UUID of the phone number.
+ * @param callId - UUID of the call.
+ */
+ async get(phoneNumberId: string, callId: string): Promise {
+ const data = await this.http.get(
+ `/numbers/${phoneNumberId}/calls/${callId}`,
+ );
+ return parsePhoneCall(data);
+ }
+
+ /**
+ * Place an outbound call.
+ *
+ * @param options.fromNumber - E.164 number to call from. Must belong to your org and be active.
+ * @param options.toNumber - E.164 number to call.
+ * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging.
+ * @param options.webhookUrl - Custom webhook URL for call lifecycle events.
+ * @returns The created call record with current rate limit info.
+ */
+ async place(options: {
+ fromNumber: string;
+ toNumber: string;
+ clientWebsocketUrl?: string;
+ webhookUrl?: string;
+ }): Promise {
+ const body: Record = {
+ from_number: options.fromNumber,
+ to_number: options.toNumber,
+ };
+ if (options.clientWebsocketUrl !== undefined) {
+ body["client_websocket_url"] = options.clientWebsocketUrl;
+ }
+ if (options.webhookUrl !== undefined) {
+ body["webhook_url"] = options.webhookUrl;
+ }
+ const data = await this.http.post("/place-call", body);
+ return parsePhoneCallWithRateLimit(data);
+ }
+}
diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts
new file mode 100644
index 0000000..9792638
--- /dev/null
+++ b/typescript/src/phone/resources/numbers.ts
@@ -0,0 +1,120 @@
+/**
+ * inkbox-phone/resources/numbers.ts
+ *
+ * Phone number CRUD, provisioning, release, and transcript search.
+ */
+
+import { HttpTransport } from "../../_http.js";
+import {
+ PhoneNumber,
+ PhoneTranscript,
+ RawPhoneNumber,
+ RawPhoneTranscript,
+ parsePhoneNumber,
+ parsePhoneTranscript,
+} from "../types.js";
+
+const BASE = "/numbers";
+
+export class PhoneNumbersResource {
+ constructor(private readonly http: HttpTransport) {}
+
+ /** List all phone numbers for your organisation. */
+ async list(): Promise {
+ const data = await this.http.get(BASE);
+ return data.map(parsePhoneNumber);
+ }
+
+ /** Get a phone number by ID. */
+ async get(phoneNumberId: string): Promise {
+ const data = await this.http.get(
+ `${BASE}/${phoneNumberId}`,
+ );
+ return parsePhoneNumber(data);
+ }
+
+ /**
+ * Update phone number settings. Only provided fields are updated.
+ * Pass a field as `null` to clear it.
+ *
+ * @param phoneNumberId - UUID of the phone number.
+ * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`.
+ * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging.
+ * @param options.incomingCallWebhookUrl - Webhook URL called for incoming calls when action is `"webhook"`.
+ */
+ async update(
+ phoneNumberId: string,
+ options: {
+ incomingCallAction?: string;
+ clientWebsocketUrl?: string | null;
+ incomingCallWebhookUrl?: string | null;
+ },
+ ): Promise {
+ const body: Record = {};
+ if (options.incomingCallAction !== undefined) {
+ body["incoming_call_action"] = options.incomingCallAction;
+ }
+ if ("clientWebsocketUrl" in options) {
+ body["client_websocket_url"] = options.clientWebsocketUrl;
+ }
+ if ("incomingCallWebhookUrl" in options) {
+ body["incoming_call_webhook_url"] = options.incomingCallWebhookUrl;
+ }
+ const data = await this.http.patch(
+ `${BASE}/${phoneNumberId}`,
+ body,
+ );
+ return parsePhoneNumber(data);
+ }
+
+ /**
+ * Provision a new phone number and link it to an agent identity.
+ *
+ * @param options.agentHandle - Handle of the agent identity to assign this number to.
+ * @param options.type - `"toll_free"` or `"local"`. Defaults to `"toll_free"`.
+ * @param options.state - US state abbreviation (e.g. `"NY"`). Only valid for `local` numbers.
+ */
+ async provision(options: {
+ agentHandle: string;
+ type?: string;
+ state?: string;
+ }): Promise {
+ const body: Record = {
+ agent_handle: options.agentHandle,
+ type: options.type ?? "toll_free",
+ };
+ if (options.state !== undefined) {
+ body["state"] = options.state;
+ }
+ const data = await this.http.post(BASE, body);
+ return parsePhoneNumber(data);
+ }
+
+ /**
+ * Release a phone number.
+ *
+ * @param phoneNumberId - UUID of the phone number to release.
+ */
+ async release(phoneNumberId: string): Promise {
+ await this.http.delete(`${BASE}/${phoneNumberId}`);
+ }
+
+ /**
+ * Full-text search across transcripts for a phone number.
+ *
+ * @param phoneNumberId - UUID of the phone number.
+ * @param options.q - Search query string.
+ * @param options.party - Filter by speaker: `"local"` or `"remote"`.
+ * @param options.limit - Maximum number of results (1–200). Defaults to 50.
+ */
+ async searchTranscripts(
+ phoneNumberId: string,
+ options: { q: string; party?: string; limit?: number },
+ ): Promise {
+ const data = await this.http.get(
+ `${BASE}/${phoneNumberId}/search`,
+ { q: options.q, party: options.party, limit: options.limit ?? 50 },
+ );
+ return data.map(parsePhoneTranscript);
+ }
+}
diff --git a/typescript/src/phone/resources/transcripts.ts b/typescript/src/phone/resources/transcripts.ts
new file mode 100644
index 0000000..97f4f71
--- /dev/null
+++ b/typescript/src/phone/resources/transcripts.ts
@@ -0,0 +1,32 @@
+/**
+ * inkbox-phone/resources/transcripts.ts
+ *
+ * Transcript retrieval.
+ */
+
+import { HttpTransport } from "../../_http.js";
+import {
+ PhoneTranscript,
+ RawPhoneTranscript,
+ parsePhoneTranscript,
+} from "../types.js";
+
+export class TranscriptsResource {
+ constructor(private readonly http: HttpTransport) {}
+
+ /**
+ * List all transcript segments for a call, ordered by sequence number.
+ *
+ * @param phoneNumberId - UUID of the phone number.
+ * @param callId - UUID of the call.
+ */
+ async list(
+ phoneNumberId: string,
+ callId: string,
+ ): Promise {
+ const data = await this.http.get(
+ `/numbers/${phoneNumberId}/calls/${callId}/transcripts`,
+ );
+ return data.map(parsePhoneTranscript);
+ }
+}
diff --git a/typescript/src/phone/types.ts b/typescript/src/phone/types.ts
new file mode 100644
index 0000000..91dd336
--- /dev/null
+++ b/typescript/src/phone/types.ts
@@ -0,0 +1,181 @@
+/**
+ * inkbox-phone TypeScript SDK — public types.
+ */
+
+export interface PhoneNumber {
+ id: string;
+ number: string;
+ /** "toll_free" | "local" */
+ type: string;
+ /** "active" | "paused" | "released" */
+ status: string;
+ /** "auto_accept" | "auto_reject" | "webhook" */
+ incomingCallAction: string;
+ clientWebsocketUrl: string | null;
+ incomingCallWebhookUrl: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface PhoneCall {
+ id: string;
+ localPhoneNumber: string;
+ remotePhoneNumber: string;
+ /** "outbound" | "inbound" */
+ direction: string;
+ /** "initiated" | "ringing" | "answered" | "completed" | "failed" | "canceled" */
+ status: string;
+ clientWebsocketUrl: string | null;
+ useInkboxTts: boolean | null;
+ useInkboxStt: boolean | null;
+ /** "local" | "remote" | "max_duration" | "voicemail" | "rejected" */
+ hangupReason: string | null;
+ startedAt: Date | null;
+ endedAt: Date | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface RateLimitInfo {
+ callsUsed: number;
+ callsRemaining: number;
+ callsLimit: number;
+ minutesUsed: number;
+ minutesRemaining: number;
+ minutesLimit: number;
+}
+
+export interface PhoneCallWithRateLimit extends PhoneCall {
+ rateLimit: RateLimitInfo;
+}
+
+export interface PhoneTranscript {
+ id: string;
+ callId: string;
+ seq: number;
+ tsMs: number;
+ /** "local" | "remote" | "system" */
+ party: string;
+ text: string;
+ createdAt: Date;
+}
+
+// ---- internal raw API shapes (snake_case from JSON) ----
+
+export interface RawPhoneNumber {
+ id: string;
+ number: string;
+ type: string;
+ status: string;
+ incoming_call_action: string;
+ client_websocket_url: string | null;
+ incoming_call_webhook_url: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface RawPhoneCall {
+ id: string;
+ local_phone_number: string;
+ remote_phone_number: string;
+ direction: string;
+ status: string;
+ client_websocket_url: string | null;
+ use_inkbox_tts: boolean | null;
+ use_inkbox_stt: boolean | null;
+ hangup_reason: string | null;
+ started_at: string | null;
+ ended_at: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface RawRateLimitInfo {
+ calls_used: number;
+ calls_remaining: number;
+ calls_limit: number;
+ minutes_used: number;
+ minutes_remaining: number;
+ minutes_limit: number;
+}
+
+export interface RawPhoneCallWithRateLimit extends RawPhoneCall {
+ rate_limit: RawRateLimitInfo;
+}
+
+export interface RawPhoneTranscript {
+ id: string;
+ call_id: string;
+ seq: number;
+ ts_ms: number;
+ party: string;
+ text: string;
+ created_at: string;
+}
+
+// ---- parsers ----
+
+export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber {
+ return {
+ id: r.id,
+ number: r.number,
+ type: r.type,
+ status: r.status,
+ incomingCallAction: r.incoming_call_action,
+ clientWebsocketUrl: r.client_websocket_url,
+ incomingCallWebhookUrl: r.incoming_call_webhook_url,
+ createdAt: new Date(r.created_at),
+ updatedAt: new Date(r.updated_at),
+ };
+}
+
+export function parsePhoneCall(r: RawPhoneCall): PhoneCall {
+ return {
+ id: r.id,
+ localPhoneNumber: r.local_phone_number,
+ remotePhoneNumber: r.remote_phone_number,
+ direction: r.direction,
+ status: r.status,
+ clientWebsocketUrl: r.client_websocket_url,
+ useInkboxTts: r.use_inkbox_tts,
+ useInkboxStt: r.use_inkbox_stt,
+ hangupReason: r.hangup_reason,
+ startedAt: r.started_at ? new Date(r.started_at) : null,
+ endedAt: r.ended_at ? new Date(r.ended_at) : null,
+ createdAt: new Date(r.created_at),
+ updatedAt: new Date(r.updated_at),
+ };
+}
+
+export function parseRateLimitInfo(r: RawRateLimitInfo): RateLimitInfo {
+ return {
+ callsUsed: r.calls_used,
+ callsRemaining: r.calls_remaining,
+ callsLimit: r.calls_limit,
+ minutesUsed: r.minutes_used,
+ minutesRemaining: r.minutes_remaining,
+ minutesLimit: r.minutes_limit,
+ };
+}
+
+export function parsePhoneCallWithRateLimit(
+ r: RawPhoneCallWithRateLimit,
+): PhoneCallWithRateLimit {
+ return {
+ ...parsePhoneCall(r),
+ rateLimit: parseRateLimitInfo(r.rate_limit),
+ };
+}
+
+export function parsePhoneTranscript(r: RawPhoneTranscript): PhoneTranscript {
+ return {
+ id: r.id,
+ callId: r.call_id,
+ seq: r.seq,
+ tsMs: r.ts_ms,
+ party: r.party,
+ text: r.text,
+ createdAt: new Date(r.created_at),
+ };
+}
+
diff --git a/typescript/src/resources/mailboxes.ts b/typescript/src/resources/mailboxes.ts
deleted file mode 100644
index 30997a4..0000000
--- a/typescript/src/resources/mailboxes.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * inkbox-mail/resources/mailboxes.ts
- *
- * Mailbox CRUD and full-text search.
- */
-
-import { HttpTransport } from "../_http.js";
-import {
- Mailbox,
- Message,
- RawCursorPage,
- RawMailbox,
- RawMessage,
- parseMailbox,
- parseMessage,
-} from "../types.js";
-
-const BASE = "/mailboxes";
-
-export class MailboxesResource {
- constructor(private readonly http: HttpTransport) {}
-
- /**
- * Create a new mailbox.
- *
- * The email address is automatically generated by the server.
- *
- * @param displayName - Optional human-readable name shown as the sender.
- */
- async create(options?: { displayName?: string }): Promise {
- const body: Record = {};
- if (options?.displayName !== undefined) {
- body["display_name"] = options.displayName;
- }
- const data = await this.http.post(BASE, body);
- return parseMailbox(data);
- }
-
- /** List all mailboxes for your organisation. */
- async list(): Promise {
- const data = await this.http.get(BASE);
- return data.map(parseMailbox);
- }
-
- /** Get a mailbox by ID. */
- async get(mailboxId: string): Promise {
- const data = await this.http.get(`${BASE}/${mailboxId}`);
- return parseMailbox(data);
- }
-
- /** Delete a mailbox. */
- async delete(mailboxId: string): Promise {
- await this.http.delete(`${BASE}/${mailboxId}`);
- }
-
- /**
- * Full-text search across messages in a mailbox.
- *
- * @param mailboxId - UUID of the mailbox to search.
- * @param q - Search query string.
- * @param limit - Maximum number of results (1–100). Defaults to 50.
- */
- async search(
- mailboxId: string,
- options: { q: string; limit?: number },
- ): Promise {
- const data = await this.http.get>(
- `${BASE}/${mailboxId}/search`,
- { q: options.q, limit: options.limit ?? 50 },
- );
- return data.items.map(parseMessage);
- }
-}
diff --git a/typescript/src/resources/webhooks.ts b/typescript/src/resources/webhooks.ts
deleted file mode 100644
index 12d48db..0000000
--- a/typescript/src/resources/webhooks.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * inkbox-mail/resources/webhooks.ts
- *
- * Webhook CRUD.
- */
-
-import { HttpTransport } from "../_http.js";
-import {
- RawWebhook,
- RawWebhookCreateResult,
- Webhook,
- WebhookCreateResult,
- parseWebhook,
- parseWebhookCreateResult,
-} from "../types.js";
-
-export class WebhooksResource {
- constructor(private readonly http: HttpTransport) {}
-
- /**
- * Register a webhook subscription for a mailbox.
- *
- * @param mailboxId - UUID of the mailbox to subscribe to.
- * @param options.url - HTTPS endpoint that will receive webhook POST requests.
- * @param options.eventTypes - Events to subscribe to.
- * Valid values: `"message.received"`, `"message.sent"`.
- * @returns The created webhook. `secret` is the one-time HMAC-SHA256 signing
- * key — save it immediately, as it will not be returned again.
- */
- async create(
- mailboxId: string,
- options: { url: string; eventTypes: string[] },
- ): Promise {
- const data = await this.http.post(
- `/mailboxes/${mailboxId}/webhooks`,
- { url: options.url, event_types: options.eventTypes },
- );
- return parseWebhookCreateResult(data);
- }
-
- /** List all active webhooks for a mailbox. */
- async list(mailboxId: string): Promise {
- const data = await this.http.get(
- `/mailboxes/${mailboxId}/webhooks`,
- );
- return data.map(parseWebhook);
- }
-
- /** Delete a webhook subscription. */
- async delete(mailboxId: string, webhookId: string): Promise {
- await this.http.delete(`/mailboxes/${mailboxId}/webhooks/${webhookId}`);
- }
-}
diff --git a/typescript/src/signing_keys.ts b/typescript/src/signing_keys.ts
new file mode 100644
index 0000000..430c817
--- /dev/null
+++ b/typescript/src/signing_keys.ts
@@ -0,0 +1,80 @@
+/**
+ * Org-level webhook signing key management.
+ *
+ * Shared across all Inkbox clients (mail, phone, etc.).
+ */
+
+import { createHmac, timingSafeEqual } from "crypto";
+import { HttpTransport } from "./_http.js";
+
+const PATH = "/signing-keys";
+
+export interface SigningKey {
+ /** Plaintext signing key — returned once on creation/rotation. Store securely. */
+ signingKey: string;
+ createdAt: Date;
+}
+
+interface RawSigningKey {
+ signing_key: string;
+ created_at: string;
+}
+
+function parseSigningKey(r: RawSigningKey): SigningKey {
+ return {
+ signingKey: r.signing_key,
+ createdAt: new Date(r.created_at),
+ };
+}
+
+/**
+ * Verify that an incoming webhook request was sent by Inkbox.
+ *
+ * @param payload - Raw request body as a Buffer or string.
+ * @param headers - Request headers object (keys are lowercased internally).
+ * @param secret - Your signing key, with or without a `whsec_` prefix.
+ * @returns True if the signature is valid.
+ */
+export function verifyWebhook({
+ payload,
+ headers,
+ secret,
+}: {
+ payload: Buffer | string;
+ headers: Record;
+ secret: string;
+}): boolean {
+ const h: Record = {};
+ for (const [k, v] of Object.entries(headers)) {
+ h[k.toLowerCase()] = Array.isArray(v) ? v[0] : v;
+ }
+ const signature = h["x-inkbox-signature"] ?? "";
+ const requestId = h["x-inkbox-request-id"] ?? "";
+ const timestamp = h["x-inkbox-timestamp"] ?? "";
+ if (!signature.startsWith("sha256=")) return false;
+ const key = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
+ const body = typeof payload === "string" ? Buffer.from(payload) : payload;
+ const message = Buffer.concat([Buffer.from(`${requestId}.${timestamp}.`), body]);
+ const expected = createHmac("sha256", key).update(message).digest("hex");
+ const received = signature.slice("sha256=".length);
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
+}
+
+export class SigningKeysResource {
+ constructor(private readonly http: HttpTransport) {}
+
+ /**
+ * Create or rotate the webhook signing key for your organisation.
+ *
+ * The first call creates a new key; subsequent calls rotate (replace) the
+ * existing key. The plaintext `signingKey` is returned **once** —
+ * store it securely as it cannot be retrieved again.
+ *
+ * Use the returned key to verify `X-Inkbox-Signature` headers on
+ * incoming webhook requests.
+ */
+ async createOrRotate(): Promise {
+ const data = await this.http.post(PATH, {});
+ return parseSigningKey(data);
+ }
+}
diff --git a/typescript/tests/errors.test.ts b/typescript/tests/errors.test.ts
new file mode 100644
index 0000000..16159e0
--- /dev/null
+++ b/typescript/tests/errors.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from "vitest";
+import { InkboxAPIError } from "../src/_http.js";
+
+describe("InkboxAPIError", () => {
+ it("formats the message correctly", () => {
+ const err = new InkboxAPIError(404, "not found");
+ expect(err.message).toBe("HTTP 404: not found");
+ });
+
+ it("exposes statusCode and detail", () => {
+ const err = new InkboxAPIError(422, "validation error");
+ expect(err.statusCode).toBe(422);
+ expect(err.detail).toBe("validation error");
+ });
+
+ it("sets name to InkboxAPIError", () => {
+ const err = new InkboxAPIError(500, "server error");
+ expect(err.name).toBe("InkboxAPIError");
+ });
+
+ it("is an instance of Error", () => {
+ const err = new InkboxAPIError(403, "forbidden");
+ expect(err).toBeInstanceOf(Error);
+ });
+});
diff --git a/typescript/tests/identities/agent_identity.test.ts b/typescript/tests/identities/agent_identity.test.ts
new file mode 100644
index 0000000..078e5a6
--- /dev/null
+++ b/typescript/tests/identities/agent_identity.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect, vi } from "vitest";
+import { AgentIdentity } from "../../src/agent_identity.js";
+import { parseAgentIdentityData } from "../../src/identities/types.js";
+import { parseThreadDetail } from "../../src/mail/types.js";
+import type { Inkbox } from "../../src/inkbox.js";
+import { InkboxAPIError } from "../../src/_http.js";
+import { RAW_IDENTITY_DETAIL, RAW_THREAD_DETAIL } from "../sampleData.js";
+
+const THREAD_ID = RAW_THREAD_DETAIL.id;
+
+function mockInkbox() {
+ return {
+ _threads: { get: vi.fn() },
+ } as unknown as Inkbox;
+}
+
+function identityWithMailbox() {
+ const data = parseAgentIdentityData(RAW_IDENTITY_DETAIL);
+ const inkbox = mockInkbox();
+ return { identity: new AgentIdentity(data, inkbox), inkbox };
+}
+
+function identityWithoutMailbox() {
+ const data = parseAgentIdentityData({ ...RAW_IDENTITY_DETAIL, mailbox: null });
+ const inkbox = mockInkbox();
+ return { identity: new AgentIdentity(data, inkbox), inkbox };
+}
+
+describe("AgentIdentity.getThread", () => {
+ it("fetches thread detail from identity mailbox", async () => {
+ const { identity, inkbox } = identityWithMailbox();
+ const threadDetail = parseThreadDetail(RAW_THREAD_DETAIL);
+ vi.mocked(inkbox._threads.get).mockResolvedValue(threadDetail);
+
+ const result = await identity.getThread(THREAD_ID);
+
+ expect(inkbox._threads.get).toHaveBeenCalledWith("sales-agent@inkbox.ai", THREAD_ID);
+ expect(result.id).toBe(THREAD_ID);
+ expect(result.messages).toHaveLength(1);
+ });
+
+ it("throws when no mailbox is assigned", async () => {
+ const { identity } = identityWithoutMailbox();
+
+ await expect(identity.getThread(THREAD_ID)).rejects.toThrow(InkboxAPIError);
+ });
+});
diff --git a/typescript/tests/identities/identities.test.ts b/typescript/tests/identities/identities.test.ts
new file mode 100644
index 0000000..d1aeef7
--- /dev/null
+++ b/typescript/tests/identities/identities.test.ts
@@ -0,0 +1,163 @@
+import { describe, it, expect, vi } from "vitest";
+import { IdentitiesResource } from "../../src/identities/resources/identities.js";
+import type { HttpTransport } from "../../src/_http.js";
+import { RAW_IDENTITY, RAW_IDENTITY_DETAIL } from "../sampleData.js";
+
+function mockHttp() {
+ return {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ } as unknown as HttpTransport;
+}
+
+const HANDLE = "sales-agent";
+
+describe("IdentitiesResource.create", () => {
+ it("posts and returns AgentIdentity", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY);
+ const res = new IdentitiesResource(http);
+
+ const identity = await res.create({ agentHandle: HANDLE });
+
+ expect(http.post).toHaveBeenCalledWith("/", { agent_handle: HANDLE });
+ expect(identity.agentHandle).toBe(HANDLE);
+ });
+});
+
+describe("IdentitiesResource.list", () => {
+ it("returns list of identities", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([RAW_IDENTITY]);
+ const res = new IdentitiesResource(http);
+
+ const identities = await res.list();
+
+ expect(http.get).toHaveBeenCalledWith("/");
+ expect(identities).toHaveLength(1);
+ expect(identities[0].agentHandle).toBe(HANDLE);
+ });
+
+ it("returns empty list", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([]);
+ const res = new IdentitiesResource(http);
+ expect(await res.list()).toEqual([]);
+ });
+});
+
+describe("IdentitiesResource.get", () => {
+ it("returns AgentIdentityDetail", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(RAW_IDENTITY_DETAIL);
+ const res = new IdentitiesResource(http);
+
+ const detail = await res.get(HANDLE);
+
+ expect(http.get).toHaveBeenCalledWith(`/${HANDLE}`);
+ expect(detail.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai");
+ });
+});
+
+describe("IdentitiesResource.update", () => {
+ it("sends newHandle", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue({ ...RAW_IDENTITY, agent_handle: "new-handle" });
+ const res = new IdentitiesResource(http);
+
+ const result = await res.update(HANDLE, { newHandle: "new-handle" });
+
+ expect(http.patch).toHaveBeenCalledWith(`/${HANDLE}`, { agent_handle: "new-handle" });
+ expect(result.agentHandle).toBe("new-handle");
+ });
+
+ it("sends status", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue({ ...RAW_IDENTITY, status: "paused" });
+ const res = new IdentitiesResource(http);
+
+ const result = await res.update(HANDLE, { status: "paused" });
+
+ expect(http.patch).toHaveBeenCalledWith(`/${HANDLE}`, { status: "paused" });
+ expect(result.status).toBe("paused");
+ });
+
+ it("omits undefined fields", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_IDENTITY);
+ const res = new IdentitiesResource(http);
+
+ await res.update(HANDLE, { status: "active" });
+
+ const [, body] = vi.mocked(http.patch).mock.calls[0] as [string, Record];
+ expect(body["agent_handle"]).toBeUndefined();
+ });
+});
+
+describe("IdentitiesResource.delete", () => {
+ it("calls delete on the correct path", async () => {
+ const http = mockHttp();
+ vi.mocked(http.delete).mockResolvedValue(undefined);
+ const res = new IdentitiesResource(http);
+
+ await res.delete(HANDLE);
+
+ expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}`);
+ });
+});
+
+describe("IdentitiesResource.assignMailbox", () => {
+ it("posts mailbox_id and returns detail", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY_DETAIL);
+ const res = new IdentitiesResource(http);
+ const mailboxId = "aaaa1111-0000-0000-0000-000000000001";
+
+ const detail = await res.assignMailbox(HANDLE, { mailboxId });
+
+ expect(http.post).toHaveBeenCalledWith(`/${HANDLE}/mailbox`, { mailbox_id: mailboxId });
+ expect(detail.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai");
+ });
+});
+
+describe("IdentitiesResource.unlinkMailbox", () => {
+ it("deletes mailbox link", async () => {
+ const http = mockHttp();
+ vi.mocked(http.delete).mockResolvedValue(undefined);
+ const res = new IdentitiesResource(http);
+
+ await res.unlinkMailbox(HANDLE);
+
+ expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}/mailbox`);
+ });
+});
+
+describe("IdentitiesResource.assignPhoneNumber", () => {
+ it("posts phone_number_id and returns detail", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY_DETAIL);
+ const res = new IdentitiesResource(http);
+ const phoneNumberId = "bbbb2222-0000-0000-0000-000000000001";
+
+ const detail = await res.assignPhoneNumber(HANDLE, { phoneNumberId });
+
+ expect(http.post).toHaveBeenCalledWith(`/${HANDLE}/phone_number`, {
+ phone_number_id: phoneNumberId,
+ });
+ expect(detail.phoneNumber!.number).toBe("+18335794607");
+ });
+});
+
+describe("IdentitiesResource.unlinkPhoneNumber", () => {
+ it("deletes phone number link", async () => {
+ const http = mockHttp();
+ vi.mocked(http.delete).mockResolvedValue(undefined);
+ const res = new IdentitiesResource(http);
+
+ await res.unlinkPhoneNumber(HANDLE);
+
+ expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}/phone_number`);
+ });
+});
diff --git a/typescript/tests/identities/types.test.ts b/typescript/tests/identities/types.test.ts
new file mode 100644
index 0000000..2287b5f
--- /dev/null
+++ b/typescript/tests/identities/types.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseAgentIdentitySummary,
+ parseAgentIdentityData,
+ parseIdentityMailbox,
+ parseIdentityPhoneNumber,
+} from "../../src/identities/types.js";
+import {
+ RAW_IDENTITY,
+ RAW_IDENTITY_DETAIL,
+ RAW_IDENTITY_MAILBOX,
+ RAW_IDENTITY_PHONE,
+} from "../sampleData.js";
+
+describe("parseAgentIdentitySummary", () => {
+ it("converts all fields", () => {
+ const i = parseAgentIdentitySummary(RAW_IDENTITY);
+ expect(i.id).toBe(RAW_IDENTITY.id);
+ expect(i.organizationId).toBe("org-abc123");
+ expect(i.agentHandle).toBe("sales-agent");
+ expect(i.status).toBe("active");
+ expect(i.createdAt).toBeInstanceOf(Date);
+ expect(i.updatedAt).toBeInstanceOf(Date);
+ });
+});
+
+describe("parseAgentIdentityData", () => {
+ it("includes nested mailbox and phone number", () => {
+ const d = parseAgentIdentityData(RAW_IDENTITY_DETAIL);
+ expect(d.agentHandle).toBe("sales-agent");
+ expect(d.mailbox).not.toBeNull();
+ expect(d.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai");
+ expect(d.phoneNumber).not.toBeNull();
+ expect(d.phoneNumber!.number).toBe("+18335794607");
+ });
+
+ it("returns null for missing channels", () => {
+ const d = parseAgentIdentityData({ ...RAW_IDENTITY, mailbox: null, phone_number: null });
+ expect(d.mailbox).toBeNull();
+ expect(d.phoneNumber).toBeNull();
+ });
+});
+
+describe("parseIdentityMailbox", () => {
+ it("converts all fields", () => {
+ const m = parseIdentityMailbox(RAW_IDENTITY_MAILBOX);
+ expect(m.id).toBe(RAW_IDENTITY_MAILBOX.id);
+ expect(m.emailAddress).toBe("sales-agent@inkbox.ai");
+ expect(m.displayName).toBe("Sales Agent");
+ expect(m.status).toBe("active");
+ expect(m.createdAt).toBeInstanceOf(Date);
+ expect(m.updatedAt).toBeInstanceOf(Date);
+ });
+});
+
+describe("parseIdentityPhoneNumber", () => {
+ it("converts all fields", () => {
+ const p = parseIdentityPhoneNumber(RAW_IDENTITY_PHONE);
+ expect(p.id).toBe(RAW_IDENTITY_PHONE.id);
+ expect(p.number).toBe("+18335794607");
+ expect(p.type).toBe("toll_free");
+ expect(p.incomingCallAction).toBe("auto_reject");
+ expect(p.clientWebsocketUrl).toBeNull();
+ expect(p.createdAt).toBeInstanceOf(Date);
+ });
+});
diff --git a/typescript/tests/mail/mailboxes.test.ts b/typescript/tests/mail/mailboxes.test.ts
new file mode 100644
index 0000000..b659fcf
--- /dev/null
+++ b/typescript/tests/mail/mailboxes.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect, vi } from "vitest";
+import { MailboxesResource } from "../../src/mail/resources/mailboxes.js";
+import type { HttpTransport } from "../../src/_http.js";
+import { RAW_MAILBOX, RAW_MESSAGE, CURSOR_PAGE_MESSAGES } from "../sampleData.js";
+
+function mockHttp() {
+ return {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ } as unknown as HttpTransport;
+}
+
+const ADDR = "agent01@inkbox.ai";
+
+describe("MailboxesResource.create", () => {
+ it("creates with displayName", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_MAILBOX);
+ const res = new MailboxesResource(http);
+
+ const mailbox = await res.create({ agentHandle: "sales-agent", displayName: "Agent 01" });
+
+ expect(http.post).toHaveBeenCalledWith("/mailboxes", {
+ agent_handle: "sales-agent",
+ display_name: "Agent 01",
+ });
+ expect(mailbox.emailAddress).toBe("agent01@inkbox.ai");
+ });
+
+ it("creates without displayName omits the field", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_MAILBOX);
+ const res = new MailboxesResource(http);
+
+ await res.create({ agentHandle: "sales-agent" });
+
+ const [, body] = vi.mocked(http.post).mock.calls[0];
+ expect((body as Record)["display_name"]).toBeUndefined();
+ });
+});
+
+describe("MailboxesResource.list", () => {
+ it("returns array of mailboxes", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([RAW_MAILBOX]);
+ const res = new MailboxesResource(http);
+
+ const mailboxes = await res.list();
+
+ expect(http.get).toHaveBeenCalledWith("/mailboxes");
+ expect(mailboxes).toHaveLength(1);
+ expect(mailboxes[0].emailAddress).toBe("agent01@inkbox.ai");
+ });
+
+ it("returns empty array", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([]);
+ const res = new MailboxesResource(http);
+ expect(await res.list()).toEqual([]);
+ });
+});
+
+describe("MailboxesResource.get", () => {
+ it("fetches by email address", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(RAW_MAILBOX);
+ const res = new MailboxesResource(http);
+
+ const mailbox = await res.get(ADDR);
+
+ expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}`);
+ expect(mailbox.displayName).toBe("Agent 01");
+ });
+});
+
+describe("MailboxesResource.update", () => {
+ it("sends displayName", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue({ ...RAW_MAILBOX, display_name: "New Name" });
+ const res = new MailboxesResource(http);
+
+ const mailbox = await res.update(ADDR, { displayName: "New Name" });
+
+ expect(http.patch).toHaveBeenCalledWith(`/mailboxes/${ADDR}`, { display_name: "New Name" });
+ expect(mailbox.displayName).toBe("New Name");
+ });
+
+ it("sends empty body when no options provided", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_MAILBOX);
+ const res = new MailboxesResource(http);
+
+ await res.update(ADDR, {});
+
+ expect(http.patch).toHaveBeenCalledWith(`/mailboxes/${ADDR}`, {});
+ });
+});
+
+describe("MailboxesResource.delete", () => {
+ it("calls delete on the correct path", async () => {
+ const http = mockHttp();
+ vi.mocked(http.delete).mockResolvedValue(undefined);
+ const res = new MailboxesResource(http);
+
+ await res.delete(ADDR);
+
+ expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}`);
+ });
+});
+
+describe("MailboxesResource.search", () => {
+ it("passes q and default limit", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_MESSAGES);
+ const res = new MailboxesResource(http);
+
+ const results = await res.search(ADDR, { q: "invoice" });
+
+ expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/search`, { q: "invoice", limit: 50 });
+ expect(results).toHaveLength(1);
+ expect(results[0].subject).toBe("Hello from test");
+ });
+
+ it("passes custom limit", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false });
+ const res = new MailboxesResource(http);
+
+ await res.search(ADDR, { q: "test", limit: 10 });
+
+ expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/search`, { q: "test", limit: 10 });
+ });
+});
diff --git a/typescript/tests/mail/messages.test.ts b/typescript/tests/mail/messages.test.ts
new file mode 100644
index 0000000..d95ce4e
--- /dev/null
+++ b/typescript/tests/mail/messages.test.ts
@@ -0,0 +1,199 @@
+import { describe, it, expect, vi } from "vitest";
+import { MessagesResource } from "../../src/mail/resources/messages.js";
+import type { HttpTransport } from "../../src/_http.js";
+import {
+ RAW_MESSAGE,
+ RAW_MESSAGE_DETAIL,
+ CURSOR_PAGE_MESSAGES,
+ CURSOR_PAGE_MESSAGES_MULTI,
+} from "../sampleData.js";
+
+function mockHttp() {
+ return {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ } as unknown as HttpTransport;
+}
+
+const ADDR = "agent01@inkbox.ai";
+const MSG_ID = "bbbb2222-0000-0000-0000-000000000001";
+
+async function collect(gen: AsyncGenerator): Promise {
+ const items: T[] = [];
+ for await (const item of gen) items.push(item);
+ return items;
+}
+
+describe("MessagesResource.list", () => {
+ it("yields messages from a single page", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_MESSAGES);
+ const res = new MessagesResource(http);
+
+ const messages = await collect(res.list(ADDR));
+
+ expect(http.get).toHaveBeenCalledTimes(1);
+ expect(messages).toHaveLength(1);
+ expect(messages[0].id).toBe(RAW_MESSAGE.id);
+ });
+
+ it("paginates through multiple pages", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get)
+ .mockResolvedValueOnce(CURSOR_PAGE_MESSAGES_MULTI)
+ .mockResolvedValueOnce(CURSOR_PAGE_MESSAGES);
+ const res = new MessagesResource(http);
+
+ const messages = await collect(res.list(ADDR));
+
+ expect(http.get).toHaveBeenCalledTimes(2);
+ expect(messages).toHaveLength(2);
+ });
+
+ it("returns empty for empty page", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false });
+ const res = new MessagesResource(http);
+
+ const messages = await collect(res.list(ADDR));
+ expect(messages).toHaveLength(0);
+ });
+});
+
+describe("MessagesResource.get", () => {
+ it("returns MessageDetail", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(RAW_MESSAGE_DETAIL);
+ const res = new MessagesResource(http);
+
+ const detail = await res.get(ADDR, MSG_ID);
+
+ expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/messages/${MSG_ID}`);
+ expect(detail.bodyText).toBe("Hi there, this is a test message body.");
+ });
+});
+
+describe("MessagesResource.send", () => {
+ it("sends basic message", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+
+ const msg = await res.send(ADDR, { to: ["user@example.com"], subject: "Hi" });
+
+ const [path, body] = vi.mocked(http.post).mock.calls[0];
+ expect(path).toBe(`/mailboxes/${ADDR}/messages`);
+ expect((body as Record)["subject"]).toBe("Hi");
+ expect(msg.fromAddress).toBe("user@example.com");
+ });
+
+ it("includes all optional fields", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+
+ await res.send(ADDR, {
+ to: ["a@example.com"],
+ subject: "Test",
+ bodyText: "plain",
+ bodyHtml: "html
",
+ cc: ["b@example.com"],
+ bcc: ["c@example.com"],
+ inReplyToMessageId: "",
+ });
+
+ const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record];
+ expect(body["body_text"]).toBe("plain");
+ expect(body["body_html"]).toBe("html
");
+ expect(body["in_reply_to_message_id"]).toBe("");
+ expect((body["recipients"] as Record)["cc"]).toEqual(["b@example.com"]);
+ });
+
+ it("omits optional fields when not provided", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+
+ await res.send(ADDR, { to: ["a@example.com"], subject: "Test" });
+
+ const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record];
+ expect(body["body_text"]).toBeUndefined();
+ expect(body["in_reply_to_message_id"]).toBeUndefined();
+ });
+});
+
+describe("MessagesResource.updateFlags", () => {
+ it("sends is_read flag", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+
+ await res.updateFlags(ADDR, MSG_ID, { isRead: true });
+
+ expect(http.patch).toHaveBeenCalledWith(
+ `/mailboxes/${ADDR}/messages/${MSG_ID}`,
+ { is_read: true },
+ );
+ });
+
+ it("sends both flags", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+
+ await res.updateFlags(ADDR, MSG_ID, { isRead: false, isStarred: true });
+
+ expect(http.patch).toHaveBeenCalledWith(
+ `/mailboxes/${ADDR}/messages/${MSG_ID}`,
+ { is_read: false, is_starred: true },
+ );
+ });
+});
+
+describe("MessagesResource convenience methods", () => {
+ it("markRead delegates to updateFlags", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+ await res.markRead(ADDR, MSG_ID);
+ expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_read: true });
+ });
+
+ it("markUnread delegates to updateFlags", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+ await res.markUnread(ADDR, MSG_ID);
+ expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_read: false });
+ });
+
+ it("star delegates to updateFlags", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+ await res.star(ADDR, MSG_ID);
+ expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_starred: true });
+ });
+
+ it("unstar delegates to updateFlags", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE);
+ const res = new MessagesResource(http);
+ await res.unstar(ADDR, MSG_ID);
+ expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_starred: false });
+ });
+});
+
+describe("MessagesResource.delete", () => {
+ it("calls delete on the correct path", async () => {
+ const http = mockHttp();
+ vi.mocked(http.delete).mockResolvedValue(undefined);
+ const res = new MessagesResource(http);
+
+ await res.delete(ADDR, MSG_ID);
+
+ expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/messages/${MSG_ID}`);
+ });
+});
diff --git a/typescript/tests/mail/threads.test.ts b/typescript/tests/mail/threads.test.ts
new file mode 100644
index 0000000..f1bac61
--- /dev/null
+++ b/typescript/tests/mail/threads.test.ts
@@ -0,0 +1,82 @@
+import { describe, it, expect, vi } from "vitest";
+import { ThreadsResource } from "../../src/mail/resources/threads.js";
+import type { HttpTransport } from "../../src/_http.js";
+import { RAW_THREAD, RAW_THREAD_DETAIL, CURSOR_PAGE_THREADS } from "../sampleData.js";
+
+function mockHttp() {
+ return {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ } as unknown as HttpTransport;
+}
+
+const ADDR = "agent01@inkbox.ai";
+const THREAD_ID = "eeee5555-0000-0000-0000-000000000001";
+
+async function collect(gen: AsyncGenerator): Promise {
+ const items: T[] = [];
+ for await (const item of gen) items.push(item);
+ return items;
+}
+
+describe("ThreadsResource.list", () => {
+ it("yields threads from a single page", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_THREADS);
+ const res = new ThreadsResource(http);
+
+ const threads = await collect(res.list(ADDR));
+
+ expect(http.get).toHaveBeenCalledTimes(1);
+ expect(threads).toHaveLength(1);
+ expect(threads[0].subject).toBe("Hello from test");
+ });
+
+ it("paginates through multiple pages", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get)
+ .mockResolvedValueOnce({ items: [RAW_THREAD], next_cursor: "cur-1", has_more: true })
+ .mockResolvedValueOnce(CURSOR_PAGE_THREADS);
+ const res = new ThreadsResource(http);
+
+ const threads = await collect(res.list(ADDR));
+
+ expect(http.get).toHaveBeenCalledTimes(2);
+ expect(threads).toHaveLength(2);
+ });
+
+ it("returns empty for empty page", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false });
+ const res = new ThreadsResource(http);
+ expect(await collect(res.list(ADDR))).toHaveLength(0);
+ });
+});
+
+describe("ThreadsResource.get", () => {
+ it("returns ThreadDetail with nested messages", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(RAW_THREAD_DETAIL);
+ const res = new ThreadsResource(http);
+
+ const detail = await res.get(ADDR, THREAD_ID);
+
+ expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/threads/${THREAD_ID}`);
+ expect(detail.messageCount).toBe(2);
+ expect(detail.messages).toHaveLength(1);
+ });
+});
+
+describe("ThreadsResource.delete", () => {
+ it("calls delete on the correct path", async () => {
+ const http = mockHttp();
+ vi.mocked(http.delete).mockResolvedValue(undefined);
+ const res = new ThreadsResource(http);
+
+ await res.delete(ADDR, THREAD_ID);
+
+ expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/threads/${THREAD_ID}`);
+ });
+});
diff --git a/typescript/tests/mail/types.test.ts b/typescript/tests/mail/types.test.ts
new file mode 100644
index 0000000..53c4871
--- /dev/null
+++ b/typescript/tests/mail/types.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseMailbox,
+ parseMessage,
+ parseMessageDetail,
+ parseThread,
+ parseThreadDetail,
+} from "../../src/mail/types.js";
+import {
+ RAW_MAILBOX,
+ RAW_MESSAGE,
+ RAW_MESSAGE_DETAIL,
+ RAW_THREAD,
+ RAW_THREAD_DETAIL,
+} from "../sampleData.js";
+
+describe("parseMailbox", () => {
+ it("converts snake_case to camelCase with Date instances", () => {
+ const m = parseMailbox(RAW_MAILBOX);
+ expect(m.id).toBe(RAW_MAILBOX.id);
+ expect(m.emailAddress).toBe("agent01@inkbox.ai");
+ expect(m.displayName).toBe("Agent 01");
+ expect(m.status).toBe("active");
+ expect(m.createdAt).toBeInstanceOf(Date);
+ expect(m.updatedAt).toBeInstanceOf(Date);
+ });
+});
+
+describe("parseMessage", () => {
+ it("converts all fields", () => {
+ const msg = parseMessage(RAW_MESSAGE);
+ expect(msg.id).toBe(RAW_MESSAGE.id);
+ expect(msg.mailboxId).toBe(RAW_MESSAGE.mailbox_id);
+ expect(msg.threadId).toBe(RAW_MESSAGE.thread_id);
+ expect(msg.fromAddress).toBe("user@example.com");
+ expect(msg.toAddresses).toEqual(["agent01@inkbox.ai"]);
+ expect(msg.ccAddresses).toBeNull();
+ expect(msg.subject).toBe("Hello from test");
+ expect(msg.isRead).toBe(false);
+ expect(msg.isStarred).toBe(false);
+ expect(msg.hasAttachments).toBe(false);
+ expect(msg.createdAt).toBeInstanceOf(Date);
+ });
+
+ it("handles null threadId", () => {
+ const msg = parseMessage({ ...RAW_MESSAGE, thread_id: null });
+ expect(msg.threadId).toBeNull();
+ });
+});
+
+describe("parseMessageDetail", () => {
+ it("includes body and header fields", () => {
+ const detail = parseMessageDetail(RAW_MESSAGE_DETAIL);
+ expect(detail.bodyText).toBe("Hi there, this is a test message body.");
+ expect(detail.bodyHtml).toBe("Hi there, this is a test message body.
");
+ expect(detail.bccAddresses).toBeNull();
+ expect(detail.inReplyTo).toBeNull();
+ expect(detail.sesMessageId).toBe("ses-abc123");
+ expect(detail.updatedAt).toBeInstanceOf(Date);
+ });
+});
+
+describe("parseThread", () => {
+ it("converts all fields", () => {
+ const t = parseThread(RAW_THREAD);
+ expect(t.id).toBe(RAW_THREAD.id);
+ expect(t.mailboxId).toBe(RAW_THREAD.mailbox_id);
+ expect(t.subject).toBe("Hello from test");
+ expect(t.messageCount).toBe(2);
+ expect(t.lastMessageAt).toBeInstanceOf(Date);
+ expect(t.createdAt).toBeInstanceOf(Date);
+ });
+});
+
+describe("parseThreadDetail", () => {
+ it("includes nested messages", () => {
+ const td = parseThreadDetail(RAW_THREAD_DETAIL);
+ expect(td.messages).toHaveLength(1);
+ expect(td.messages[0].id).toBe(RAW_MESSAGE.id);
+ });
+
+ it("returns empty messages array when missing", () => {
+ const td = parseThreadDetail({ ...RAW_THREAD });
+ expect(td.messages).toEqual([]);
+ });
+});
+
diff --git a/typescript/tests/phone/calls.test.ts b/typescript/tests/phone/calls.test.ts
new file mode 100644
index 0000000..dc463f5
--- /dev/null
+++ b/typescript/tests/phone/calls.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, vi } from "vitest";
+import { CallsResource } from "../../src/phone/resources/calls.js";
+import type { HttpTransport } from "../../src/_http.js";
+import { RAW_PHONE_CALL, RAW_PHONE_CALL_WITH_RATE_LIMIT } from "../sampleData.js";
+
+function mockHttp() {
+ return {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ } as unknown as HttpTransport;
+}
+
+const NUM_ID = "aaaa1111-0000-0000-0000-000000000001";
+const CALL_ID = "bbbb2222-0000-0000-0000-000000000001";
+
+describe("CallsResource.list", () => {
+ it("uses default limit and offset", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([RAW_PHONE_CALL]);
+ const res = new CallsResource(http);
+
+ const calls = await res.list(NUM_ID);
+
+ expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls`, { limit: 50, offset: 0 });
+ expect(calls).toHaveLength(1);
+ expect(calls[0].direction).toBe("outbound");
+ });
+
+ it("passes custom limit and offset", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([]);
+ const res = new CallsResource(http);
+
+ await res.list(NUM_ID, { limit: 10, offset: 20 });
+
+ expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls`, { limit: 10, offset: 20 });
+ });
+});
+
+describe("CallsResource.get", () => {
+ it("fetches by ID", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(RAW_PHONE_CALL);
+ const res = new CallsResource(http);
+
+ const call = await res.get(NUM_ID, CALL_ID);
+
+ expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls/${CALL_ID}`);
+ expect(call.status).toBe("completed");
+ });
+});
+
+describe("CallsResource.place", () => {
+ it("places call with required fields", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT);
+ const res = new CallsResource(http);
+
+ const call = await res.place({
+ fromNumber: "+18335794607",
+ toNumber: "+15167251294",
+ });
+
+ expect(http.post).toHaveBeenCalledWith("/place-call", {
+ from_number: "+18335794607",
+ to_number: "+15167251294",
+ });
+ expect(call.rateLimit.callsUsed).toBe(5);
+ });
+
+ it("includes optional fields when provided", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT);
+ const res = new CallsResource(http);
+
+ await res.place({
+ fromNumber: "+18335794607",
+ toNumber: "+15167251294",
+ clientWebsocketUrl: "wss://agent.example.com/ws",
+ webhookUrl: "https://example.com/hook",
+ });
+
+ const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record];
+ expect(body["client_websocket_url"]).toBe("wss://agent.example.com/ws");
+ expect(body["webhook_url"]).toBe("https://example.com/hook");
+ });
+
+ it("omits optional fields when not provided", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT);
+ const res = new CallsResource(http);
+
+ await res.place({ fromNumber: "+18335794607", toNumber: "+15167251294" });
+
+ const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record];
+ expect(body["client_websocket_url"]).toBeUndefined();
+ expect(body["webhook_url"]).toBeUndefined();
+ });
+});
diff --git a/typescript/tests/phone/numbers.test.ts b/typescript/tests/phone/numbers.test.ts
new file mode 100644
index 0000000..e9f7349
--- /dev/null
+++ b/typescript/tests/phone/numbers.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi } from "vitest";
+import { PhoneNumbersResource } from "../../src/phone/resources/numbers.js";
+import type { HttpTransport } from "../../src/_http.js";
+import { RAW_PHONE_NUMBER, RAW_PHONE_TRANSCRIPT } from "../sampleData.js";
+
+function mockHttp() {
+ return {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ } as unknown as HttpTransport;
+}
+
+const NUM_ID = "aaaa1111-0000-0000-0000-000000000001";
+
+describe("PhoneNumbersResource.list", () => {
+ it("returns list of phone numbers", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([RAW_PHONE_NUMBER]);
+ const res = new PhoneNumbersResource(http);
+
+ const numbers = await res.list();
+
+ expect(http.get).toHaveBeenCalledWith("/numbers");
+ expect(numbers).toHaveLength(1);
+ expect(numbers[0].number).toBe("+18335794607");
+ });
+});
+
+describe("PhoneNumbersResource.get", () => {
+ it("fetches by ID", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue(RAW_PHONE_NUMBER);
+ const res = new PhoneNumbersResource(http);
+
+ const number = await res.get(NUM_ID);
+
+ expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}`);
+ expect(number.incomingCallAction).toBe("auto_reject");
+ });
+});
+
+describe("PhoneNumbersResource.update", () => {
+ it("sends incomingCallAction", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_NUMBER);
+ const res = new PhoneNumbersResource(http);
+
+ await res.update(NUM_ID, { incomingCallAction: "auto_accept" });
+
+ expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}`, {
+ incoming_call_action: "auto_accept",
+ });
+ });
+
+ it("omits undefined fields", async () => {
+ const http = mockHttp();
+ vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_NUMBER);
+ const res = new PhoneNumbersResource(http);
+
+ await res.update(NUM_ID, {});
+
+ expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}`, {});
+ });
+});
+
+describe("PhoneNumbersResource.provision", () => {
+ it("defaults to toll_free", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue(RAW_PHONE_NUMBER);
+ const res = new PhoneNumbersResource(http);
+
+ await res.provision({ agentHandle: "sales-agent" });
+
+ expect(http.post).toHaveBeenCalledWith("/numbers", {
+ agent_handle: "sales-agent",
+ type: "toll_free",
+ });
+ });
+
+ it("passes type and state for local numbers", async () => {
+ const http = mockHttp();
+ vi.mocked(http.post).mockResolvedValue({ ...RAW_PHONE_NUMBER, type: "local" });
+ const res = new PhoneNumbersResource(http);
+
+ const number = await res.provision({ agentHandle: "sales-agent", type: "local", state: "NY" });
+
+ const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record];
+ expect(body["state"]).toBe("NY");
+ expect(number.type).toBe("local");
+ });
+});
+
+describe("PhoneNumbersResource.release", () => {
+ it("deletes by ID", async () => {
+ const http = mockHttp();
+ vi.mocked(http.delete).mockResolvedValue(undefined);
+ const res = new PhoneNumbersResource(http);
+
+ await res.release(NUM_ID);
+
+ expect(http.delete).toHaveBeenCalledWith(`/numbers/${NUM_ID}`);
+ });
+});
+
+describe("PhoneNumbersResource.searchTranscripts", () => {
+ it("passes query and defaults", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([RAW_PHONE_TRANSCRIPT]);
+ const res = new PhoneNumbersResource(http);
+
+ const results = await res.searchTranscripts(NUM_ID, { q: "hello" });
+
+ expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/search`, {
+ q: "hello",
+ party: undefined,
+ limit: 50,
+ });
+ expect(results[0].text).toBe("Hello, how can I help you?");
+ });
+
+ it("passes party and custom limit", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([]);
+ const res = new PhoneNumbersResource(http);
+
+ await res.searchTranscripts(NUM_ID, { q: "test", party: "remote", limit: 10 });
+
+ expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/search`, {
+ q: "test",
+ party: "remote",
+ limit: 10,
+ });
+ });
+});
diff --git a/typescript/tests/phone/transcripts.test.ts b/typescript/tests/phone/transcripts.test.ts
new file mode 100644
index 0000000..d5e757b
--- /dev/null
+++ b/typescript/tests/phone/transcripts.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect, vi } from "vitest";
+import { TranscriptsResource } from "../../src/phone/resources/transcripts.js";
+import type { HttpTransport } from "../../src/_http.js";
+import { RAW_PHONE_TRANSCRIPT } from "../sampleData.js";
+
+function mockHttp() {
+ return {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ } as unknown as HttpTransport;
+}
+
+const NUM_ID = "aaaa1111-0000-0000-0000-000000000001";
+const CALL_ID = "bbbb2222-0000-0000-0000-000000000001";
+
+describe("TranscriptsResource.list", () => {
+ it("returns transcript segments", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([RAW_PHONE_TRANSCRIPT]);
+ const res = new TranscriptsResource(http);
+
+ const transcripts = await res.list(NUM_ID, CALL_ID);
+
+ expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls/${CALL_ID}/transcripts`);
+ expect(transcripts).toHaveLength(1);
+ expect(transcripts[0].text).toBe("Hello, how can I help you?");
+ expect(transcripts[0].seq).toBe(0);
+ });
+
+ it("returns empty array", async () => {
+ const http = mockHttp();
+ vi.mocked(http.get).mockResolvedValue([]);
+ const res = new TranscriptsResource(http);
+ expect(await res.list(NUM_ID, CALL_ID)).toEqual([]);
+ });
+});
diff --git a/typescript/tests/phone/types.test.ts b/typescript/tests/phone/types.test.ts
new file mode 100644
index 0000000..1db4053
--- /dev/null
+++ b/typescript/tests/phone/types.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from "vitest";
+import {
+ parsePhoneNumber,
+ parsePhoneCall,
+ parseRateLimitInfo,
+ parsePhoneCallWithRateLimit,
+ parsePhoneTranscript,
+} from "../../src/phone/types.js";
+import {
+ RAW_PHONE_NUMBER,
+ RAW_PHONE_CALL,
+ RAW_RATE_LIMIT,
+ RAW_PHONE_CALL_WITH_RATE_LIMIT,
+ RAW_PHONE_TRANSCRIPT,
+} from "../sampleData.js";
+
+describe("parsePhoneNumber", () => {
+ it("converts all fields", () => {
+ const n = parsePhoneNumber(RAW_PHONE_NUMBER);
+ expect(n.id).toBe(RAW_PHONE_NUMBER.id);
+ expect(n.number).toBe("+18335794607");
+ expect(n.type).toBe("toll_free");
+ expect(n.status).toBe("active");
+ expect(n.incomingCallAction).toBe("auto_reject");
+ expect(n.clientWebsocketUrl).toBeNull();
+ expect(n.createdAt).toBeInstanceOf(Date);
+ expect(n.updatedAt).toBeInstanceOf(Date);
+ });
+});
+
+describe("parsePhoneCall", () => {
+ it("converts all fields", () => {
+ const c = parsePhoneCall(RAW_PHONE_CALL);
+ expect(c.id).toBe(RAW_PHONE_CALL.id);
+ expect(c.localPhoneNumber).toBe("+18335794607");
+ expect(c.remotePhoneNumber).toBe("+15167251294");
+ expect(c.direction).toBe("outbound");
+ expect(c.status).toBe("completed");
+ expect(c.clientWebsocketUrl).toBe("wss://agent.example.com/ws");
+ expect(c.startedAt).toBeInstanceOf(Date);
+ expect(c.endedAt).toBeInstanceOf(Date);
+ });
+
+ it("handles null timestamps", () => {
+ const c = parsePhoneCall({ ...RAW_PHONE_CALL, started_at: null, ended_at: null });
+ expect(c.startedAt).toBeNull();
+ expect(c.endedAt).toBeNull();
+ });
+});
+
+describe("parseRateLimitInfo", () => {
+ it("converts all fields", () => {
+ const r = parseRateLimitInfo(RAW_RATE_LIMIT);
+ expect(r.callsUsed).toBe(5);
+ expect(r.callsRemaining).toBe(95);
+ expect(r.callsLimit).toBe(100);
+ expect(r.minutesUsed).toBe(12.5);
+ expect(r.minutesRemaining).toBe(987.5);
+ expect(r.minutesLimit).toBe(1000);
+ });
+});
+
+describe("parsePhoneCallWithRateLimit", () => {
+ it("includes rateLimit", () => {
+ const c = parsePhoneCallWithRateLimit(RAW_PHONE_CALL_WITH_RATE_LIMIT);
+ expect(c.rateLimit.callsUsed).toBe(5);
+ expect(c.status).toBe("completed");
+ });
+});
+
+describe("parsePhoneTranscript", () => {
+ it("converts all fields", () => {
+ const t = parsePhoneTranscript(RAW_PHONE_TRANSCRIPT);
+ expect(t.id).toBe(RAW_PHONE_TRANSCRIPT.id);
+ expect(t.callId).toBe(RAW_PHONE_TRANSCRIPT.call_id);
+ expect(t.seq).toBe(0);
+ expect(t.tsMs).toBe(1500);
+ expect(t.party).toBe("local");
+ expect(t.text).toBe("Hello, how can I help you?");
+ expect(t.createdAt).toBeInstanceOf(Date);
+ });
+});
+
diff --git a/typescript/tests/sampleData.ts b/typescript/tests/sampleData.ts
new file mode 100644
index 0000000..e469526
--- /dev/null
+++ b/typescript/tests/sampleData.ts
@@ -0,0 +1,172 @@
+/** Shared raw (snake_case) API response fixtures for tests. */
+
+// ---- Mail ----
+
+export const RAW_MAILBOX = {
+ id: "aaaa1111-0000-0000-0000-000000000001",
+ email_address: "agent01@inkbox.ai",
+ display_name: "Agent 01",
+ status: "active",
+ created_at: "2026-03-09T00:00:00Z",
+ updated_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_MESSAGE = {
+ id: "bbbb2222-0000-0000-0000-000000000001",
+ mailbox_id: "aaaa1111-0000-0000-0000-000000000001",
+ thread_id: "eeee5555-0000-0000-0000-000000000001",
+ message_id: "",
+ from_address: "user@example.com",
+ to_addresses: ["agent01@inkbox.ai"],
+ cc_addresses: null,
+ subject: "Hello from test",
+ snippet: "Hi there, this is a test message...",
+ direction: "inbound",
+ status: "delivered",
+ is_read: false,
+ is_starred: false,
+ has_attachments: false,
+ created_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_MESSAGE_DETAIL = {
+ ...RAW_MESSAGE,
+ body_text: "Hi there, this is a test message body.",
+ body_html: "Hi there, this is a test message body.
",
+ bcc_addresses: null,
+ in_reply_to: null,
+ references: null,
+ attachment_metadata: null,
+ ses_message_id: "ses-abc123",
+ updated_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_THREAD = {
+ id: "eeee5555-0000-0000-0000-000000000001",
+ mailbox_id: "aaaa1111-0000-0000-0000-000000000001",
+ subject: "Hello from test",
+ status: "active",
+ message_count: 2,
+ last_message_at: "2026-03-09T00:05:00Z",
+ created_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_THREAD_DETAIL = {
+ ...RAW_THREAD,
+ messages: [RAW_MESSAGE],
+};
+
+export const CURSOR_PAGE_MESSAGES = {
+ items: [RAW_MESSAGE],
+ next_cursor: null,
+ has_more: false,
+};
+
+export const CURSOR_PAGE_MESSAGES_MULTI = {
+ items: [RAW_MESSAGE],
+ next_cursor: "cursor-abc",
+ has_more: true,
+};
+
+export const CURSOR_PAGE_THREADS = {
+ items: [RAW_THREAD],
+ next_cursor: null,
+ has_more: false,
+};
+
+// ---- Phone ----
+
+export const RAW_PHONE_NUMBER = {
+ id: "aaaa1111-0000-0000-0000-000000000001",
+ number: "+18335794607",
+ type: "toll_free",
+ status: "active",
+ incoming_call_action: "auto_reject",
+ client_websocket_url: null,
+ created_at: "2026-03-09T00:00:00Z",
+ updated_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_PHONE_CALL = {
+ id: "bbbb2222-0000-0000-0000-000000000001",
+ local_phone_number: "+18335794607",
+ remote_phone_number: "+15167251294",
+ direction: "outbound",
+ status: "completed",
+ client_websocket_url: "wss://agent.example.com/ws",
+ use_inkbox_tts: null,
+ use_inkbox_stt: null,
+ hangup_reason: null,
+ started_at: "2026-03-09T00:01:00Z",
+ ended_at: "2026-03-09T00:05:00Z",
+ created_at: "2026-03-09T00:00:00Z",
+ updated_at: "2026-03-09T00:05:00Z",
+};
+
+export const RAW_RATE_LIMIT = {
+ calls_used: 5,
+ calls_remaining: 95,
+ calls_limit: 100,
+ minutes_used: 12.5,
+ minutes_remaining: 987.5,
+ minutes_limit: 1000,
+};
+
+export const RAW_PHONE_CALL_WITH_RATE_LIMIT = {
+ ...RAW_PHONE_CALL,
+ rate_limit: RAW_RATE_LIMIT,
+};
+
+export const RAW_PHONE_TRANSCRIPT = {
+ id: "cccc3333-0000-0000-0000-000000000001",
+ call_id: "bbbb2222-0000-0000-0000-000000000001",
+ seq: 0,
+ ts_ms: 1500,
+ party: "local",
+ text: "Hello, how can I help you?",
+ created_at: "2026-03-09T00:01:01Z",
+};
+
+// ---- Identities ----
+
+export const RAW_IDENTITY_MAILBOX = {
+ id: "aaaa1111-0000-0000-0000-000000000001",
+ email_address: "sales-agent@inkbox.ai",
+ display_name: "Sales Agent",
+ status: "active",
+ created_at: "2026-03-09T00:00:00Z",
+ updated_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_IDENTITY_PHONE = {
+ id: "bbbb2222-0000-0000-0000-000000000001",
+ number: "+18335794607",
+ type: "toll_free",
+ status: "active",
+ incoming_call_action: "auto_reject",
+ client_websocket_url: null,
+ created_at: "2026-03-09T00:00:00Z",
+ updated_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_IDENTITY = {
+ id: "eeee5555-0000-0000-0000-000000000001",
+ organization_id: "org-abc123",
+ agent_handle: "sales-agent",
+ status: "active",
+ created_at: "2026-03-09T00:00:00Z",
+ updated_at: "2026-03-09T00:00:00Z",
+};
+
+export const RAW_IDENTITY_DETAIL = {
+ ...RAW_IDENTITY,
+ mailbox: RAW_IDENTITY_MAILBOX,
+ phone_number: RAW_IDENTITY_PHONE,
+};
+
+// ---- Signing Keys ----
+
+export const RAW_SIGNING_KEY = {
+ signing_key: "sk-test-hmac-secret-abc123",
+ created_at: "2026-03-09T00:00:00Z",
+};
diff --git a/typescript/tests/signing-keys.test.ts b/typescript/tests/signing-keys.test.ts
new file mode 100644
index 0000000..863795f
--- /dev/null
+++ b/typescript/tests/signing-keys.test.ts
@@ -0,0 +1,111 @@
+import { createHmac } from "crypto";
+import { describe, it, expect, vi } from "vitest";
+import { SigningKeysResource, verifyWebhook } from "../src/signing_keys.js";
+import { HttpTransport } from "../src/_http.js";
+import { RAW_SIGNING_KEY } from "./sampleData.js";
+
+const TEST_KEY = "test-signing-key";
+const TEST_REQUEST_ID = "req-abc-123";
+const TEST_TIMESTAMP = "1741737600";
+const TEST_BODY = Buffer.from('{"event":"message.received"}');
+
+function makeSignature(key: string, requestId: string, timestamp: string, body: Buffer): string {
+ const message = Buffer.concat([Buffer.from(`${requestId}.${timestamp}.`), body]);
+ const digest = createHmac("sha256", key).update(message).digest("hex");
+ return `sha256=${digest}`;
+}
+
+function makeResource() {
+ const http = { post: vi.fn() } as unknown as HttpTransport;
+ const resource = new SigningKeysResource(http);
+ return { resource, http: http as { post: ReturnType } };
+}
+
+function makeHeaders(sig: string): Record {
+ return {
+ "x-inkbox-signature": sig,
+ "x-inkbox-request-id": TEST_REQUEST_ID,
+ "x-inkbox-timestamp": TEST_TIMESTAMP,
+ };
+}
+
+describe("verifyWebhook", () => {
+ it("returns true for a valid signature", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(true);
+ });
+
+ it("accepts whsec_ prefixed secret", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: `whsec_${TEST_KEY}` })).toBe(true);
+ });
+
+ it("accepts string payload", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ expect(verifyWebhook({ payload: TEST_BODY.toString(), headers: makeHeaders(sig), secret: TEST_KEY })).toBe(true);
+ });
+
+ it("normalizes header casing", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ const uppercaseHeaders = {
+ "X-Inkbox-Signature": sig,
+ "X-Inkbox-Request-ID": TEST_REQUEST_ID,
+ "X-Inkbox-Timestamp": TEST_TIMESTAMP,
+ };
+ expect(verifyWebhook({ payload: TEST_BODY, headers: uppercaseHeaders, secret: TEST_KEY })).toBe(true);
+ });
+
+ it("returns false for wrong key", () => {
+ const sig = makeSignature("wrong-key", TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false);
+ });
+
+ it("returns false for tampered body", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ expect(verifyWebhook({ payload: Buffer.from('{"event":"message.sent"}'), headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false);
+ });
+
+ it("returns false for wrong requestId", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ expect(verifyWebhook({ payload: TEST_BODY, headers: { ...makeHeaders(sig), "x-inkbox-request-id": "different-id" }, secret: TEST_KEY })).toBe(false);
+ });
+
+ it("returns false for wrong timestamp", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY);
+ expect(verifyWebhook({ payload: TEST_BODY, headers: { ...makeHeaders(sig), "x-inkbox-timestamp": "9999999999" }, secret: TEST_KEY })).toBe(false);
+ });
+
+ it("returns false when sha256= prefix is missing", () => {
+ const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY).slice("sha256=".length);
+ expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false);
+ });
+
+ it("returns false when headers are missing", () => {
+ expect(verifyWebhook({ payload: TEST_BODY, headers: {}, secret: TEST_KEY })).toBe(false);
+ });
+});
+
+describe("SigningKeysResource", () => {
+ describe("createOrRotate", () => {
+ it("calls POST /signing-keys with empty body", async () => {
+ const { resource, http } = makeResource();
+ http.post.mockResolvedValue(RAW_SIGNING_KEY);
+
+ await resource.createOrRotate();
+
+ expect(http.post).toHaveBeenCalledOnce();
+ expect(http.post).toHaveBeenCalledWith("/signing-keys", {});
+ });
+
+ it("parses signingKey and createdAt from response", async () => {
+ const { resource, http } = makeResource();
+ http.post.mockResolvedValue(RAW_SIGNING_KEY);
+
+ const key = await resource.createOrRotate();
+
+ expect(key.signingKey).toBe("sk-test-hmac-secret-abc123");
+ expect(key.createdAt).toBeInstanceOf(Date);
+ expect(key.createdAt.toISOString()).toBe("2026-03-09T00:00:00.000Z");
+ });
+ });
+});
diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json
index 09485ae..a1b642d 100644
--- a/typescript/tsconfig.json
+++ b/typescript/tsconfig.json
@@ -4,6 +4,7 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
+ "types": ["node"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
diff --git a/typescript/vitest.config.ts b/typescript/vitest.config.ts
new file mode 100644
index 0000000..f624398
--- /dev/null
+++ b/typescript/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ },
+});