# Inboxr — full documentation
Single-file dump for LLM context windows. Anchor headings (`#integrations`, `#api-keys`, …) match the slugs in /llms.txt.

Source: https://github.com/JasSra/inboxr-cloud/tree/main/docs
---

<a id="integrations"></a>

# Integrating with Inboxr — for humans, scripts, and AI agents

Inboxr is live at:

| Surface              | URL                                              |
|----------------------|--------------------------------------------------|
| Marketing & docs     | https://getinboxr.app                            |
| App / dashboard      | https://app.getinboxr.app                        |
| REST API             | https://api.getinboxr.app/v1                     |
| SMTP submission      | smtp.getinboxr.app:587 (STARTTLS)                |
| MCP server (npm)     | `@jassra25/inboxr-mcp`                                    |

Three things you can do, four ways to do them. Pick the one closest to
where your code already lives.

```
                         ┌───────────────┐
                         │   /v1/* REST  │ ← your CI, server-side scripts
              create     ├───────────────┤
   ────────► ─────────► │  SMTP submit  │ ← your existing MTA / mailer lib
   inbox     wait        ├───────────────┤
              receive    │  MCP tools    │ ← your Claude / OpenAI agent
              send out   ├───────────────┤
                         │  Webhooks     │ ← your test runner, real-time
                         └───────────────┘
```

## 1. REST API — the boring, reliable path

Auth is a bearer key from `/api-keys`. Every endpoint is documented and
runnable live in the [API playground](https://app.getinboxr.app/docs/api).

```bash
# Validate the key works against this deployment.
curl -fsS -H "Authorization: Bearer $INBOXR_KEY" \
  https://api.getinboxr.app/v1/inboxes >/dev/null && echo OK

# Create an inbox.
INBOX=$(curl -s -X POST https://api.getinboxr.app/v1/inboxes \
  -H "Authorization: Bearer $INBOXR_KEY" \
  -d '{"label":"signup-test"}')
ID=$(echo "$INBOX" | jq -r .id)
ADDR=$(echo "$INBOX" | jq -r .address)

# Trigger your signup flow with $ADDR, then long-poll for the verification email.
MSG=$(curl -s -H "Authorization: Bearer $INBOXR_KEY" \
  "https://api.getinboxr.app/v1/inboxes/$ID/wait?timeout=30")

# Send mail FROM that inbox.
curl -X POST https://api.getinboxr.app/v1/messages \
  -H "Authorization: Bearer $INBOXR_KEY" \
  -d "{\"from\":\"$ADDR\",\"to\":\"alice@example.com\",\"subject\":\"hi\",\"text\":\"...\"}"
```

Full per-endpoint docs + response shapes live in
[docs/api-keys.md](api-keys.md) and [docs/outbound-mail.md](outbound-mail.md).

## 2. SMTP submission — for libraries that already speak SMTP

If you have nodemailer / lettre / Mail::Sendmail / `msmtp` already wired
into your app, send through us by issuing a one-time SMTP credential per
inbox:

```bash
curl -X POST https://api.getinboxr.app/v1/inboxes/$ID/smtp-credentials \
  -H "Authorization: Bearer $INBOXR_KEY"
```

You get back:

```json
{
  "host": "smtp.getinboxr.app",
  "port": 587,
  "secure": "starttls",
  "username": "signup-test.platypus@getinboxr.app",
  "password": "<plaintext, shown once>",
  "url": "smtp://signup-test.platypus%40getinboxr.app:%3Cpass%3E@smtp.getinboxr.app:587"
}
```

Drop those into your mail config:

```ts
// nodemailer
import nodemailer from 'nodemailer';
const t = nodemailer.createTransport({
  host: 'smtp.getinboxr.app',
  port: 587,
  secure: false,            // STARTTLS — SMTP upgrades on connect
  auth: { user, pass },
});
await t.sendMail({ from: user, to: 'alice@example.com', subject: 'hi', text: '...' });
```

Calling `/smtp-credentials` again **rotates** the password. We never store
plaintext — save the response.

## 3. MCP server — for AI agents that need an inbox

`@jassra25/inboxr-mcp` is a stdio MCP server you run alongside Claude Desktop,
Cursor, the Claude Code CLI, or any other MCP-aware agent. Tools exposed:

| Tool             | What it does                                           |
|------------------|--------------------------------------------------------|
| **Email**        |                                                        |
| `create_inbox`   | Provision a fresh disposable address                   |
| `list_inboxes`   | Enumerate the agent's tenant's inboxes                 |
| `delete_inbox`   | Tear one down                                          |
| `wait_for_message` | Long-poll for the next inbound message               |
| `get_messages`   | List recent inbound mail                               |
| `get_message`    | Fetch one full message (text + sanitized HTML)         |
| `extract_otp`    | Pull a 4–8 digit code out of the latest message        |
| `extract_link`   | Pull the first link matching a regex                   |
| **SMS**          |                                                        |
| `sms_create_inbox`   | Claim a phone number from the shared pool (1 credit) |
| `sms_list_inboxes`   | List your SMS inboxes                              |
| `sms_delete_inbox`   | Release a phone number                             |
| `sms_send`           | Send outbound SMS (1 credit, 5/sec/tenant + 60/min/phone) |
| `sms_claim_sender`   | Stake a 30-min route for an expected inbound sender |
| `sms_wait_for_message` | Long-poll for the next inbound SMS               |
| `sms_get_messages`   | List recent SMS (inbound + outbound)               |
| `sms_extract_otp`    | Pull a 4–8 digit OTP from an SMS body              |

### Claude Desktop / Cursor / Claude Code

Add to your MCP config (`~/.claude/mcp.json` for Claude Desktop, similar
for other clients):

```jsonc
{
  "mcpServers": {
    "inboxr": {
      "command": "npx",
      "args": ["-y", "@jassra25/inboxr-mcp"],
      "env": {
        "INBOXR_API_KEY": "inb_live_...",
        "INBOXR_API_URL": "https://api.getinboxr.app"
      }
    }
  }
}
```

Restart the agent. The eight tools above appear under the `inboxr_*`
prefix and the agent can drive a full signup-and-confirm-email flow with
zero glue code.

### Sample agent prompt

> "Create an inbox on getinboxr.app. Use that address to sign up at
> https://example.com/signup. Wait for the verification email, click the
> first link in it, then return the dashboard URL."

The agent calls `create_inbox` → drives the browser → `wait_for_message`
→ `extract_link` → done. It needs zero knowledge of mail internals.

## 4. SMS — disposable phone numbers

Same auth, different verbs. Claim a phone number, stake a sender claim,
then send/receive. Full reference at [#sms](#sms).

```bash
# Claim a number
curl -X POST -H "Authorization: Bearer $KEY" \
  https://api.getinboxr.app/v1/sms/inboxes \
  -H "Content-Type: application/json" -d '{"label":"otp"}'
# → { "id": "...", "phoneNumber": "+61412345678" }

# Send outbound (1 credit; 5/sec/tenant + 60/min/phone)
curl -X POST -H "Authorization: Bearer $KEY" \
  https://api.getinboxr.app/v1/sms/send \
  -H "Content-Type: application/json" \
  -d '{"inboxId":"<id>","toNumber":"+14155550101","body":"hi"}'

# Stake a 30-min claim before triggering an OTP-sending form
curl -X POST -H "Authorization: Bearer $KEY" \
  https://api.getinboxr.app/v1/sms/inboxes/<id>/claim \
  -H "Content-Type: application/json" \
  -d '{"senderNumber":"+18551234567","ttlSeconds":1800}'

# Long-poll for the next inbound
curl -H "Authorization: Bearer $KEY" \
  "https://api.getinboxr.app/v1/sms/inboxes/<id>/messages/wait?timeout=30000"
```

## 5. Webhooks — for real-time event push

POST every event (email + SMS + voice + voicemail) to a URL of your choice.
Signed with HMAC-SHA256 in `X-Inboxr-Signature` (Stripe-style `t=…,v1=…`).
Configure under [/webhooks](https://app.getinboxr.app/webhooks). Six-attempt
retry on `1s, 10s, 1m, 10m, 1h, 6h` then DLQ. Response body captured every
attempt — surfaces in delivery history. Full reference at [#webhooks](#webhooks).

Subscribe to: `email.received`, `sms.received`, `sms.delivered`,
`voice.completed`, `voicemail.created`, `inbox.expired`.

## What about plan caps?

### Email plan (governs inbox + email volume)

| Plan       | Inboxes | Inbound /day | Outbound /mo | Retention | SMTP |
|------------|---------|--------------|--------------|-----------|------|
| Free       | 10      | 200          | 0            | 7 days    | ✗    |
| Dev        | 100     | 5,000        | 1,000        | 14 days   | ✓    |
| Team       | 1,000   | 50,000       | 10,000       | 30 days   | ✓    |
| Scale      | 10,000  | 500,000      | 100,000      | 90 days   | ✓    |
| Enterprise | unlimited | unlimited  | unlimited    | 365 days  | ✓    |

### SMS plan (governs SMS, voice, webhook endpoints — independent of email plan)

| Plan       | Monthly SMS  | Phone numbers | Voice min  | Webhooks |
|------------|--------------|---------------|------------|----------|
| Free       | 10 trial     | 1 shared      | 0          | 1        |
| Pro $29    | 1,000+rollover | 1 dedicated | 100        | 5        |
| Business $99 | 5,000+rollover | 3 dedicated | 500 + IVR | unlimited |
| Enterprise | unlimited    | unlimited     | unlimited  | unlimited |

A la carte SMS top-up (one-time, never expires): $5/100 credits, $20/500, $60/2000.

Hitting an email cap returns `429 plan_cap_reached`. Out of SMS credits returns
`402 no_credits`. SMS rate-limit (5/sec/tenant or 60/min/phone) returns `429
rate_limited` with `Retry-After`.

## Common 5-minute integrations

- **CI smoke test for password reset** — see the curl block at the top.
- **Receive-only relay for `+inbox` aliases** — provision one inbox per
  customer, configure your transactional-mail provider to send replies to
  it, route via webhook.
- **Anonymous reply-to in your SaaS** — create a one-shot inbox per
  conversation, expose its address, GC after retention window.
- **AI agent that reads its own MFA codes** — wire `@jassra25/inboxr-mcp`, ask the
  agent to `extract_otp` after triggering a login.
- **SMS OTP for tests that need a real phone number** — `sms_claim_sender`
  → submit form → `sms_wait_for_message` → `sms_extract_otp`.
- **Two-way SMS bot** — `sms_send` outbound; replies within 72h auto-route
  back via outbound-pair routing — no claim needed.

## When to reach for which

```
Need to send mail        → REST /v1/messages or SMTP submission
Need to receive once     → REST + wait_for_message
Need to send / receive SMS → REST /v1/sms/*
Need real-time delivery  → Webhook (email, SMS, voice — all on one endpoint)
Need an AI agent to drive it end-to-end → MCP (16 tools, email + SMS)
```

---

<a id="api-keys"></a>

# API keys

How keys work, how to validate them in the portal, and the rules to follow
in production.

## Anatomy

```
inb_live_aB3kQ9zX2v4P7t6n1mYcLs0eRhWuJfDgIo8K
└──┬──┘ └──────────────┬──────────────┘
prefix             32 base62 chars (cryptographic randomness)
```

- `inb_live_` — prefix used in production tenants. (`inb_test_` is reserved
  for future test-mode tenants; not currently issued.)
- 32 char body — `randomBytes(24)` rendered base62. ~143 bits of entropy.
- 12-char prefix is shown to humans; the **plaintext is shown exactly once**
  at creation. We store `sha256(plaintext)` and never the plaintext.

If you lose the plaintext, revoke the key and issue a new one. There is no
recovery path — by design.

## Scopes

Every key carries a scope set; the request matches the smallest required
scope. `admin` ⊃ `write` ⊃ `read`.

| Scope | Reads | Creates inboxes | Sends mail | Revokes keys |
|-------|-------|-----------------|------------|--------------|
| read  | ✓     | ✗               | ✗          | ✗            |
| write | ✓     | ✓               | ✓          | ✗            |
| admin | ✓     | ✓               | ✓          | ✓            |

Default scopes when generating from the dashboard: `read + write`. Promote
to `admin` only for keys used by ops automation.

## Validating a key from the portal

`/docs/api` (sidebar → **API playground**):

1. Paste your key in the left rail.
2. Click **Validate key** — runs `GET /v1/inboxes` against this deployment.
3. Green check + inbox count = authenticated successfully.
4. Red error = the key is wrong, revoked, or missing the `read` scope.

The playground also runs every other endpoint live so you can confirm
write-scope, plan-cap, and from-domain rules without leaving the browser.

## Validating a key from a script

```bash
# Smoke test — exits 0 if key is good, non-zero otherwise.
curl -fsS -o /dev/null \
  -H "Authorization: Bearer $INBOXR_KEY" \
  https://api.getinboxr.app/v1/inboxes
echo "$?"
```

For monitoring, prefer this over a synthetic create-then-delete inbox flow
— the read endpoint doesn't burn API-call quota in any meaningful way and
won't trigger plan-cap warnings.

## Operational rules

- **Never log the plaintext.** Our middleware redacts `Authorization`
  headers, but if you log request bodies of your own callers, scrub there.
- **One key per integration.** Don't share. Makes revocation surgical when
  one client is compromised.
- **Rotate quarterly** for keys that touch production user data. Generate
  the new key, deploy it, then revoke the old one — never the other way
  around (revoking first leaves a window of failed calls).
- **Watch `lastUsedAt`** on the API keys page. A key that hasn't fired in
  90 days is a candidate for revocation.

## Common 401 causes

| Symptom                                       | Cause                                                  |
|-----------------------------------------------|--------------------------------------------------------|
| Validate key returns 401 in playground         | Pasted with surrounding whitespace, or it's revoked.   |
| Works in playground, fails in your code        | Your HTTP client is mangling the Authorization header (Postman environment swap, etc.). |
| Random 401s                                    | You're hitting a deployment whose DB doesn't have your tenant — wrong host (`api.getinboxr.app` vs a self-hosted instance). |

---

<a id="outbound-mail"></a>

# Outbound mail — configuration & validation

How to send mail from an Inboxr inbox, what's required for it to actually
land in someone's inbox, and how to validate the path end-to-end before
wiring it into your CI / product.

## TL;DR

```bash
curl -X POST https://api.getinboxr.app/v1/messages \
  -H "Authorization: Bearer inb_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from":    "noreply@getinboxr.app",
    "to":      "alice@example.com",
    "subject": "Hi",
    "text":    "..."
  }'
```

Every successful (and failed) send is logged in your **outbound history** —
visible per-inbox at `/inboxes/{id}?tab=outbound`.

## What has to be true for a send to succeed

Outbound goes through the engine's SES relay. The chain is:

```
your code → POST /v1/messages → cloud (api-key + plan-cap check)
         → engine /api/engine/v1/send → AWS SES → recipient MX
```

Three independent things must all hold:

| Layer    | Requirement                                                  | How to verify                              |
|----------|--------------------------------------------------------------|--------------------------------------------|
| Cloud    | API key has `write` scope and tenant is under monthly cap    | `/api-keys` shows scopes; `/billing` shows quota |
| Engine   | `from` domain is enabled on the engine                        | `/system/domains` (super-admin) or the 403 response lists allowed domains |
| SES      | Domain is SES-verified and SPF/DKIM/DMARC are published       | `dig TXT <domain>`, `dig TXT mail2026._domainkey.<domain>` |

## Picking a `from` address

`from` does **not** need to be an inbox you've provisioned. Any local-part on
an enabled domain works:

- `noreply@getinboxr.app` ✓
- `support@servicestack.com.au` ✓ (if enabled for your tenant)
- `random-string@your-custom-domain.com` ✓ (if you've added the domain via
  `/domains` and DKIM is verified)

If you want replies to land in an Inboxr inbox, set `from` to an address you
*have* provisioned via `POST /v1/inboxes`. Replies get stored exactly like
inbound mail — visible in the inbox's **Inbound** tab.

The only constraint we enforce is **domain ownership**; the local-part is
free-form. Sends from `from` domains we don't control return:

```json
{ "error": "from_domain_not_allowed", "allowed": ["getinboxr.app", "..."] }
```

## Plan caps

| Plan     | Outbound /month |
|----------|-----------------|
| Free     | 0 (block — `outbound_not_on_plan`) |
| Dev      | 1,000           |
| Team     | 10,000          |
| Scale    | 100,000         |
| Enterprise | unlimited     |

Super-admin-owned tenants bypass all caps. Failed sends still consume the
quota only after a successful engine handoff — pure validation failures
(bad `from` domain, malformed JSON) don't.

## Outbound history

Every send is recorded in the cloud's `outbound_messages` table — even
ones that fail at the engine. The row carries:

- `status` — `queued` → `sent` or `failed`
- `engineMessageId` — populated when SES accepts the message
- `error` — populated when the engine refuses (e.g. SES throttling)
- `source` — `api` (programmatic) or `dashboard` (compose UI)

Surface in the UI: `/inboxes/{id}?tab=outbound`.

## Validating end-to-end without writing code

Use the **API playground** at `/docs/api`:

1. Paste a key in the left rail and hit **Validate key** — confirms the
   bearer authenticates against this deployment. Common 401 causes: copied a
   `inb_test_` key into a live deployment, or pasted with surrounding
   whitespace.
2. Pick **Send an outbound message**.
3. The form prefills a working example. Adjust `to:` and click **Run
   request**. The exact response body — including 4xx/5xx errors with
   `from_domain_not_allowed`, `plan_cap_reached`, etc. — appears live.
4. Check the inbox's **Outbound** tab; the row should show `status: sent`
   and an `engineMessageId`.

If the playground succeeds but the recipient never sees the mail, the
problem is downstream of SES — check your domain's DMARC report or the
[deliverability page](/deliverability) for SPF/DKIM alignment.

## Programmatic key validation

For monitors / CI smoke tests, hit a cheap read endpoint:

```bash
curl -fsS -H "Authorization: Bearer $INBOXR_KEY" \
  https://api.getinboxr.app/v1/inboxes >/dev/null \
  && echo "key OK" || echo "key FAILED"
```

`-f` makes curl exit non-zero on 4xx/5xx, so this works as-is in shell
pipelines.

## Common send failures and what they mean

| Status | `error`                  | Meaning                                                       |
|--------|--------------------------|---------------------------------------------------------------|
| 401    | `unauthorized`           | Bad / revoked bearer. Check `/api-keys`.                      |
| 403    | `forbidden`              | Key lacks the `write` scope.                                  |
| 403    | `from_domain_not_allowed`| `from` domain is not enabled on the engine for your tenant.   |
| 429    | `outbound_not_on_plan`   | Free plan; upgrade.                                           |
| 429    | `plan_cap_reached`       | Hit monthly quota; resets on Stripe billing-cycle reset.      |
| 502    | `engine_unreachable`     | Cloud can't reach the mail engine. Operator issue.            |
| 502    | `send_failed`            | Engine accepted and tried to relay but SES rejected. Check `error`. |

---

<a id="sms"></a>

# SMS API

Inboxr provides disposable SMS inboxes — real phone numbers your code can claim, send from, and receive on. Same auth, same shape as the email API; different verbs.

## Concepts

- **SMS inbox** — a tenant claim on a phone number from our shared pool. Created via `POST /v1/sms/inboxes`. One inbox = one phone number.
- **Credit** — 1 credit = 1 inbound or outbound SMS. Granted monthly on Pro/Business plans (with rollover) or bought as one-time top-up packs (`sms_100`, `sms_500`, `sms_2000`).
- **Sender claim** — a 30-minute reservation that says "the next inbound SMS from sender_number routes to this inbox." Stake one before triggering an OTP flow.
- **Outbound pair** — if you send to a number, replies within 72h auto-route back to your inbox. No claim needed.

## Plans (SMS axis, separate from email)

| Plan | $/mo | Monthly SMS | Phone numbers | Voice min | Webhook endpoints |
|------|------|-------------|----------------|-----------|--------------------|
| Free | 0    | 10 trial    | 1 shared       | 0         | 1                  |
| Pro  | 29   | 1,000 + rollover | 1 dedicated | 100       | 5                  |
| Business | 99 | 5,000 + rollover | 3 dedicated | 500 + IVR | unlimited |
| Enterprise | call | unlimited | unlimited      | unlimited | unlimited          |

A la carte top-up: $5 / 100 credits, $20 / 500, $60 / 2000. Top-up never expires; stacks on the plan grant.

## Endpoints

### Create an SMS inbox

```http
POST /v1/sms/inboxes
Authorization: Bearer inb_live_...
Content-Type: application/json

{ "label": "otp-flow" }
```

Returns:

```json
{ "id": "uuid", "phoneNumber": "+61412345678", "label": "otp-flow", "createdAt": "..." }
```

Required scope: `sms:send` (or legacy `write`). Requires non-zero credit balance. Returns `503 no_workers_online` if the phone fleet is offline.

### List inboxes

```http
GET /v1/sms/inboxes
```

Required scope: `sms:read` (or legacy `read`).

### Send SMS

```http
POST /v1/sms/send
{ "inboxId": "uuid", "toNumber": "+14155550101", "body": "Hello from Inboxr" }
```

Required scope: `sms:send`. Debits 1 credit on success. Returns 429 `rate_limited` if you exceed:

- **5 SMS / sec / tenant** (per-tenant burst control)
- **60 SMS / min / phone number** (carrier-friendly)

Both checks return a `Retry-After` header.

### Stake a sender claim

```http
POST /v1/sms/inboxes/{id}/claim
{ "senderNumber": "+1...", "ttlSeconds": 1800 }
```

Active claim wins over outbound-pair routing. Use this before triggering any flow that will send you a code (signup, password reset, MFA enrollment).

### Long-poll for the next inbound

```http
GET /v1/sms/inboxes/{id}/messages/wait?timeout=30000&from=%2B1...
```

Returns the next inbound message after the call started, or `{ message: null }` on timeout. The `from` filter is optional.

### Read recent messages

```http
GET /v1/sms/inboxes/{id}/messages?limit=20
```

Returns inbound + outbound for the inbox.

## Routing inbound — three priorities

1. **Active sender claim** — set via `/claim`, expires after `ttl_seconds`.
2. **Outbound pair** — you sent to that number within 72h.
3. **Unclaimed** — message is stored but unrouted (visible only to admins).

## Rate limits

In addition to the API-call rate limits (5/sec/tenant for sends), every endpoint enforces standard plan caps. Going over the SMS plan grant doesn't fail the send — it consumes pay-as-you-go credits at the cheapest pack rate ($0.05/SMS).

## Webhooks for SMS

Subscribe to `sms.received` and/or `sms.delivered` at `/webhooks`. The legacy `message.received` event also fires for SMS inbound (kept for back-compat). See [webhooks.md](#webhooks).

## MCP tools (for AI agents)

`@jassra25/inboxr-mcp` exposes 8 SMS tools alongside the email tools:

```
sms_create_inbox       label?                                    → { id, phoneNumber }
sms_list_inboxes
sms_delete_inbox       inbox_id
sms_send               inbox_id, to_number, body
sms_claim_sender       inbox_id, sender_number, ttl_seconds?=1800
sms_wait_for_message   inbox_id, timeout_s?=30, from?
sms_get_messages       inbox_id, limit?=20
sms_extract_otp        body                                      → "498212"
```

Typical agent OTP flow:

```
agent
  ├─ sms_create_inbox label="signup"  → { id, phoneNumber }
  ├─ sms_claim_sender sender_number="+1..." (the service that'll text you)
  ├─ playwright.fill(otp_request_form, phoneNumber)
  ├─ sms_wait_for_message inbox_id timeout=30 → msg
  └─ sms_extract_otp body=msg.body → "498212"
```

## Common errors

| Code | Meaning |
|------|---------|
| 402 `no_credits` | Out of credits. Upgrade plan or buy a top-up pack. |
| 402 `tenant_suspended` | Workspace suspended by support — see message for reason. |
| 429 `rate_limited` | 5/sec/tenant or 60/min/phone tripped. Honor `Retry-After`. |
| 503 `no_workers_online` | Phone fleet temporarily offline. Try again in a moment. |
| 503 `send_failed` | SMS gateway unavailable. Credit is auto-refunded. |

---

<a id="voice"></a>

# Voice + IVR

## TL;DR

Inboxr handles real PSTN voice calls on the same Pixel-with-real-SIM fleet that powers SMS. You can place outbound calls, receive inbound with auto-answer, drive an IVR you author in plain English (Claude generates the JSON), record + transcribe everything, and listen / inject DTMF / hang up live from the browser.

## Architecture

```
Caller / callee
       │ PSTN
   ┌───▼───┐
   │ Pixel │   ──InCallService auto-answers─▶ AudioRecord(VOICE_CALL)
   │  +SIM │
   └───┬───┘   ──AudioTrack───◀── audio frames over WS
       │  USB / WiFi
       ▼
  ws://api/ws/voice/{deviceId}/{callId}    ◀── 4-byte framed PCM16LE @ 8 kHz
       │
       ▼
  smssaas FastAPI
       ├── IVR engine (state machine: play / collect / transfer / voicemail / hangup / goto)
       ├── Whisper transcription (local, small model)
       └── Opus recording → /data/recordings/{call_id}.opus
       │
       ▼
  Browser dashboard
       ├── CallPanel: place call, live waveform, DTMF pad, hangup
       └── /sms/inboxes/{id}/voice: recordings playback + transcripts
```

## Place a call

```bash
curl -X POST https://api.getinboxr.app/v1/voice/calls \
  -H "Authorization: Bearer $INBOXR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inboxId":"sms_…","toNumber":"+61413253383","ivrFlowId":"flow_…"}'
# → { "callId":"call_…", "status":"ringing" }
```

`ivrFlowId` is optional. Without it the call rings, the recipient picks up, and the audio is bridged straight to your browser CallPanel for live two-way speech.

## IVR flows

A flow is a JSON tree of nodes. Six node types match the engine in `smssaas/api/ivr.py`:

| Type | Purpose |
|---|---|
| `play` | Play a TTS prompt or upload audio. `on_dtmf` map sends to next node. |
| `collect` | Like `play` but waits for input with a timeout fallback. |
| `transfer` | Bridge the call to another PSTN number. |
| `voicemail` | Record caller, transcribe via Whisper, store in voicemails table. |
| `hangup` | End the call. |
| `goto` | Loop back to a previous node, with `max_loops` + `on_exceeded` fallback. |

Example:

```json
{
  "name": "Acme support",
  "start_node": "greeting",
  "nodes": {
    "greeting": {
      "type": "play",
      "audio": "tts:Welcome to Acme. Press 1 for sales, 2 for support.",
      "voice": "alloy",
      "on_dtmf": { "1": "sales", "2": "support", "timeout": "greeting" }
    },
    "sales":   { "type": "transfer", "to": "+61412345678" },
    "support": { "type": "voicemail", "prompt": "tts:Leave a message after the tone." }
  }
}
```

Save with `POST /v1/voice/ivr-flows`, retrieve with `GET /v1/voice/ivr-flows/{id}`, attach to an inbox so inbound calls run it via `POST /v1/sms/inboxes/{id}/flow`.

## Generate from English

Don't want to write JSON? Describe what you want and Claude writes it:

```bash
curl -X POST https://api.getinboxr.app/v1/voice/ivr-flows/from-description \
  -H "Authorization: Bearer $INBOXR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Real estate inbound",
    "description": "Press 1 for sales, 2 for property management, 3 to leave a message. Friendly professional tone."
  }'
```

The MCP tool `voice_create_flow_from_description` does the same thing in one step from your agent — paired with `voice_assign_flow` and `voice_test_flow` you can build + assign + verify a phone tree without touching the UI.

## TTS voices

`play` and `collect` nodes synthesize through OpenAI `tts-1`. Six voices: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`. Audio is cached server-side by `hash(text + voice)` so identical scripts don't re-synthesize.

```json
{ "type": "play", "audio": "tts:Press 1 to confirm.", "voice": "nova" }
```

For a robotic open-source fallback, leave `voice` empty and the engine uses `pyttsx3`.

## Live monitor / DTMF / hangup

While a call is active, the browser dashboard's `CallPanel`:

- **Listens in** — opens `wss://api/ws/voice/{deviceId}/{callId}/listen`, decodes PCM16 into an `AudioWorklet`, renders the live waveform.
- **Injects DTMF** — buttons 0-9, *, # send a 4-byte frame `[0x02 0x02 seq digit]` to the API which forwards it to the phone.
- **Hangs up** — sends `[0x02 0x03 0 0]`. The phone disconnects, the call moves to `completed`, the recording finalizes.

## Recordings + transcripts

Every call is recorded to `/data/recordings/{call_id}.opus` (encoded after hangup). Whisper (small, CPU) transcribes async — usually within 30 s of hangup. Both surface on the call object:

```bash
curl https://api.getinboxr.app/v1/voice/calls/$CALL_ID \
  -H "Authorization: Bearer $INBOXR_KEY"
# → { "id":"call_…", "status":"completed", "duration_sec":47,
#     "transcript":"Hi, this is Sarah calling about...",
#     "recording_url":"/v1/voice/calls/call_…/recording" }

curl -o call.opus https://api.getinboxr.app/v1/voice/calls/$CALL_ID/recording \
  -H "Authorization: Bearer $INBOXR_KEY"
```

## Voicemails

Dropped automatically when an IVR `voicemail` node fires, or when an inbound call rings unanswered for 25 s. List + transcript + audio:

```bash
curl https://api.getinboxr.app/v1/voice/voicemails?inboxId=sms_… \
  -H "Authorization: Bearer $INBOXR_KEY"
```

## Pricing

| Item | Cost |
|---|---|
| Outbound call | 6 credits/min (round up) |
| Inbound call | 4 credits/min |
| Voicemail stored | 1 credit |
| TTS regeneration | included (cached) |
| Live browser listen / DTMF | free for the tenant who placed the call |
| Whisper transcription | included |

Each Pro/Business plan includes a monthly minute allowance — overage debits the credit balance.

## Caveats

- **Caller ID is the SIM's MSISDN.** Custom alphanumeric / branded caller ID needs a SIP trunk add-on (Twilio Elastic SIP, VoIP.ms). Ask if you need it.
- **Two-sides audio capture** requires `android.permission.CAPTURE_AUDIO_OUTPUT` granted via ADB at install: `adb shell pm grant com.smssaas.app android.permission.CAPTURE_AUDIO_OUTPUT`.
- **Carrier rate limits** — Telstra / Optus / Vodafone all limit short-burst calls. ~30 calls/hour per SIM is safe; beyond that you need a multi-phone pool or a SIP fallback.

---

<a id="webhooks"></a>

# Webhooks

Inboxr pushes events into your app the moment they happen. Configure endpoints at https://app.getinboxr.app/webhooks (or the alias `/settings/webhooks`).

## Event types

| Event | When |
|-------|------|
| `email.received` | New inbound email lands in any tenant inbox (also fires legacy `message.received`) |
| `inbox.expired`  | Email inbox has aged past retention and is being garbage-collected |
| `sms.received`   | Inbound SMS routed to a tenant SMS inbox (also fires legacy `message.received`) |
| `sms.delivered`  | Outbound SMS confirmed delivered by the carrier |
| `voice.completed` | Voice call ended (when voice ships) |
| `voicemail.created` | Voicemail recorded (when voice ships) |

## Signing

Each delivery includes:

```
X-Inboxr-Signature: t=<unix>,v1=<hex hmac sha256 of "t.body" using webhook.secret>
X-Inboxr-Event: sms.received
X-Inboxr-Delivery: <delivery uuid>
```

Verify by computing `HMAC-SHA256(secret, "${'$'}{t}.${'$'}{rawBody}")` and comparing to the `v1=` value. The secret is shown once at webhook creation — store it securely.

## Retry schedule

Failed deliveries (any non-2xx) retry on this schedule:

```
1s → 10s → 1m → 10m → 1h → 6h → DLQ
```

Six attempts then dead-letter. The dashboard shows `dlq_at` for any delivery that exhausted retries. The receiver gets the same payload + `X-Inboxr-Delivery` header on every retry — use that header as an idempotency key.

## Per-plan endpoint cap

The number of webhook endpoints you can register is governed by your **SMS plan** (regardless of email plan):

- Free: 1 endpoint
- Pro: 5 endpoints
- Business+: unlimited

Trying to create one over the cap returns `429 webhook_cap_reached`.

## Test endpoint

Click "Test" in the dashboard to fire a synthetic event at your URL and see the receiver's HTTP code + body. Doesn't touch your delivery history.

## Delivery history

Last 50 deliveries per webhook visible in the dashboard. Each row shows: status (delivered / retry / DLQ), event, attempt count, when, status code, and the response body (success or failure — captured every attempt).

## Why your endpoint should return 202 with no body

Receivers that return `200 + HTML` (typically misconfigured proxies serving an error page) look identical to a healthy receiver from our side. Always return `202 Accepted` with an empty body to make health unambiguous.

---

<a id="self-host"></a>

# Self-host

Run the whole Inboxr stack on your own infrastructure. The cloud control plane is open-source-distributed (public Docker image, source on GitHub); the SMTP mail engine `inboxr-next` is open-source under MIT; the phone backend `smssaas` is source-available with a license required for commercial use.

## What you can self-host

| Component | Purpose | Self-host |
|---|---|---|
| `inboxr-next` | SMTP receive / relay engine. Postfix + a thin REST shim. | ✅ Open-source. github.com/JasSra/inboxr-next |
| `inboxr-cloud` | This app — auth, multi-tenant, billing, dashboards. | ✅ Public image: `docker pull jassra/inboxr-cloud:latest`. Source: github.com/JasSra/inboxr-cloud |
| `smssaas` | Phone backend (FastAPI + ADB + APK). Required for SMS / voice. | ✅ Public images: `docker pull jassra/smssaas-api:latest` + `jassra/smssaas-adb-bridge:latest`. Source: github.com/JasSra/smssaas. License for commercial use. |

You can run them independently:
- Email-only: just `inboxr-next` + `inboxr-cloud`. Two containers + Postgres.
- Add SMS + voice: also stand up `smssaas` + a Pixel.

## Hardware

| Use case | Minimum | Recommended |
|---|---|---|
| Email only, ~10 inboxes | 1 vCPU, 1 GB RAM, 20 GB disk | 2 vCPU, 4 GB RAM, 100 GB SSD |
| + SMS, 1 phone | + 1 Pixel 6a, 1 active SIM | + Optus / Telstra unlimited prepaid |
| + Voice / IVR | Same as SMS | + Whisper-small needs ~2 GB RAM headroom |
| Production (10+ tenants) | Use the full Proxmox stack — see below | |

A Pixel 6a (~AUD 350 used) + Boost / Optus SIM (AUD 30/mo unlimited) handles ~50 tenants worth of SMS/voice traffic. Plan one phone per ~50 tenants.

## Quick start — email-only

```bash
git clone https://github.com/JasSra/inboxr-cloud
cd inboxr-cloud
cp .env.example .env

# Edit .env:
#   AUTH_SECRET=$(openssl rand -hex 32)
#   GOOGLE_CLIENT_ID=...   (optional, for OAuth)
#   GITHUB_CLIENT_ID=...   (optional)
#   STRIPE_SECRET_KEY=     (optional, leave blank for free tier only)

docker compose up --build
open http://localhost:4000
```

The first user who hits `/setup` becomes the superadmin and bootstraps the tenant. After that, signups go through normal flow. Email reception requires `inboxr-next` running with port 25 reachable on whatever domain you own — see its README for MX setup.

## Quick start — full stack with SMS / voice

```bash
# 1. Stand up the cloud control plane (as above)
docker compose up -d --build

# 2. Stand up smssaas alongside — pulls the public images, no local build
mkdir smssaas && cd smssaas
wget https://raw.githubusercontent.com/JasSra/smssaas/main/docker-compose.public.yml -O docker-compose.yml
wget https://raw.githubusercontent.com/JasSra/smssaas/main/.env.example -O .env
# Edit .env — at minimum:
#   ADMIN_KEY=$(openssl rand -hex 32)
#   DEVICE_SECRET=$(openssl rand -hex 16)
#   CLOUD_WEBHOOK_URL=http://inboxr-cloud:4000/api/internal/sms/inbound
#   CLOUD_WEBHOOK_KEY=$ADMIN_KEY
#   OPENAI_API_KEY=sk-…    # optional, for IVR TTS
#   ANTHROPIC_API_KEY=sk-… # optional, for AI flow generation
#   REDIS_URL=redis://…    # optional, for live diagnostics
docker compose up -d

# 3. Plug a Pixel into the host with USB debugging enabled, accept the prompt
docker compose exec adb-bridge adb devices    # should show your phone

# 4. Build + install the APK (one-time per phone)
#    Build pipeline + signed APK in github.com/JasSra/smssaas/android-app
```

Now in `inboxr-cloud`'s `.env` add:

```
SMS_API_URL=http://smssaas-api:8300
SMS_ADMIN_KEY=<same as smssaas's ADMIN_KEY>
```

Restart the inboxr-cloud container — the SMS pages light up and the phone shows up under `/sms/workers` (superadmin).

## Production (Proxmox / k8s)

A working blueprint matching how getinboxr.app runs:

```
┌───────────────────────────────────────────────────────────────┐
│  Internet  ──TLS──▶  UniFi router (NAT 443)                   │
│                            │                                   │
│                            ▼                                   │
│  Gateway VM (Debian 12, Docker)                                │
│   ├─ nginx (Let's Encrypt, vhost per app)                      │
│   ├─ smssaas-api  (port 8300, internal only)                   │
│   ├─ smssaas-adb-bridge (USB → Pixel via host passthrough)     │
│   └─ /opt/smssaas/data  (SQLite + recordings + voicemail)      │
│                                                                │
│  Docker host VM                                                │
│   ├─ inboxr-cloud-app    (port 4000, behind nginx)             │
│   ├─ inboxr-cloud-worker (background jobs)                     │
│   └─ Postgres 16 (shared with other apps OK)                   │
│                                                                │
│  Redis VM (optional, for live diagnostics + rate limits)       │
└───────────────────────────────────────────────────────────────┘
```

USB passthrough on Proxmox:
```bash
qm set 100 -usb0 host=18d1:4ee8 -hotplug usb   # Pixel vendor:product
```
The phone shows up as a USB device inside the gateway VM, the smssaas adb-bridge picks it up automatically.

For multiple phones, allocate one USB slot per device or use a powered USB hub.

## License

| Use | License |
|---|---|
| Personal projects, learning, evaluation | Free, no license needed |
| Internal-only company use (≤ 25 employees, no external customers using it) | Free under fair-use, no license needed |
| External commercial use (selling Inboxr-as-a-service to your customers, embedding in a paid product) | Per-instance license required |

Request a license at https://getinboxr.app/license/request — provide email + company + use case + your `instance_id` from `/setup`. We sign back an HMAC JWT with caps:

```
{
  "iss": "inboxr",
  "aud": "<your instance_id>",
  "exp": 1771977600,
  "max_tenants": 50,
  "max_users": 500,
  "max_inboxes_per_tenant": 100,
  "max_messages_per_day": 100000,
  "features": ["sms", "voice", "ivr", "webhooks", "sso"],
  "customer": "Acme Corp"
}
```

Drop the token into `INBOXR_LICENSE` env, restart, done. Caps are enforced at write-time (you can read past a cap, but new tenants / users / messages will be rejected with `429 license_cap`).

> Today: licensing plumbing is built but **not enforced**. Self-host on the honour system. We'll flip enforcement on with at least 30 days notice in the changelog before any cap kicks in.

## Migrations

`docker compose run --rm app npm run migrate` runs all SQL migrations in `src/db/migrations/`. Drizzle tracks state in `drizzle.__drizzle_migrations`. New migrations ship in every image; running `up` against the latest image picks them up.

## Backups

- **Postgres**: standard `pg_dump`. The schema is small (~16 tables); the volume is messages + attachments. Hourly logical backups are sufficient.
- **smssaas SQLite**: `data/smssaas.db` — copy the file. Recordings + voicemails live in `data/recordings/` and `data/voicemail/` — rsync to S3 / B2 nightly.
- **APK config**: stored on the phone in `/data/data/com.smssaas.app/shared_prefs/smssaas.xml` — survives APK reinstall.

## Updating

```bash
docker compose pull
docker compose up -d
```

`watchtower` is supported — label your containers `com.centurylinklabs.watchtower.enable=true` and it auto-pulls + restarts when a new tag is published.

## Support

Self-host is BYO support unless you hold a Business or Enterprise license. Issues: github.com/JasSra/inboxr-cloud/issues. Discussions for architecture / capacity questions: github.com/JasSra/inboxr-cloud/discussions.

For a managed SaaS instead — same code, no ops — use [getinboxr.app](https://getinboxr.app).

---

<a id="ai-agents"></a>

# Inboxr for AI agents

This is an opinionated guide for anyone building an LLM-driven workflow
that needs an email address **or a phone number**. Inboxr ships both axes
behind one MCP server. You don't need to glue together SES + Twilio +
SQS + Lambda — one API key, 16 tools.

## Five patterns we see

### 1. "Sign up for me and confirm the email"

```
agent
  ├─ inboxr.create_inbox       → addr
  ├─ playwright.fill(form, addr)
  ├─ inboxr.wait_for_message timeout=60 → msg
  ├─ inboxr.extract_link msgId=… → url
  └─ playwright.goto(url)
```

End to end: one tool config, no `imap.connect`, no inbox parsing.

### 2. "Read my email MFA codes"

```
agent
  ├─ inboxr.wait_for_message after=NOW timeout=30 → msg
  ├─ inboxr.extract_otp msgId=… → "498212"
  └─ playwright.fill(otp_input, "498212")
```

### 3. "Sign up with an SMS-required service"

The agent claims a phone number, stakes a sender claim, drives the form,
catches the OTP, and types it in:

```
agent
  ├─ inboxr.sms_create_inbox label="signup"  → { id, phoneNumber }
  ├─ inboxr.sms_claim_sender sender_number="+1855..."  ← stake the route
  ├─ playwright.fill(otp_request_form, phoneNumber)
  ├─ inboxr.sms_wait_for_message inbox_id timeout=30 → msg
  ├─ inboxr.sms_extract_otp body=msg.body → "498212"
  └─ playwright.fill(otp_input, "498212")
```

### 4. "Two-way SMS conversation"

The agent sends an outbound SMS and replies within 72h auto-route back —
no claim needed:

```
agent
  ├─ inboxr.sms_send inbox_id=… to_number="+1..." body="Hi! ..."
  ├─ inboxr.sms_wait_for_message inbox_id timeout=300 → reply
  └─ classify(reply) → ok | escalate
```

### 5. "Receive replies from a human-in-the-loop"

Agent sends an email, then waits for the human to reply, then continues:

```
agent
  ├─ inboxr.create_inbox label="approval-flow" → addr
  ├─ inboxr.send_message from=addr to="alice@..." subject="Approve?" text="..."
  ├─ inboxr.wait_for_message after=NOW timeout=900 → reply
  └─ classify(reply) → ok | reject
```

## Why MCP and not REST?

You can absolutely use REST from an agent — every model can write a
fetch call. But the MCP server gives you:

- **Pre-typed tool schemas** the model already knows. No prompt drift.
- **Built-in convenience tools** (`extract_otp`, `extract_link`) that the
  model would otherwise re-implement (poorly) every call.
- **Long-poll without prompting around it** — `wait_for_message` blocks
  inside the tool call so you don't burn tokens on busy-loops.
- **One-line install** in Claude Desktop / Cursor / Claude Code.

Pick MCP for interactive agents. Pick REST when you're in a non-MCP
runtime (LangGraph, plain OpenAI tool-use, Bedrock).

## Setup — Claude Code (CLI)

```bash
claude mcp add inboxr -- npx -y @jassra25/inboxr-mcp \
  --env INBOXR_API_KEY=inb_live_... \
  --env INBOXR_API_URL=https://api.getinboxr.app
```

Then in a chat: *"Create an Inboxr inbox and tell me the address."*

## Setup — Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`
(macOS) or the equivalent on your platform:

```json
{
  "mcpServers": {
    "inboxr": {
      "command": "npx",
      "args": ["-y", "@jassra25/inboxr-mcp"],
      "env": {
        "INBOXR_API_KEY": "inb_live_...",
        "INBOXR_API_URL": "https://api.getinboxr.app"
      }
    }
  }
}
```

Restart Claude Desktop.

## Setup — Cursor

Cursor reads MCP servers from `.cursor/mcp.json` per-project, or globally
from `~/.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "inboxr": {
      "command": "npx",
      "args": ["-y", "@jassra25/inboxr-mcp"],
      "env": {
        "INBOXR_API_KEY": "inb_live_..."
      }
    }
  }
}
```

## REST from agents that aren't MCP-aware

OpenAI tool-use, LangGraph, Bedrock, and similar runtimes can hit `/v1`
directly. Ship these tool definitions to your agent and you're done:

```jsonc
[
  { "name": "inboxr_create_inbox",
    "description": "Provision a disposable email inbox. Returns its address.",
    "input_schema": { "type": "object", "properties": { "label": { "type": "string" } } } },
  { "name": "inboxr_wait_for_message",
    "description": "Block up to N seconds for the next inbound message in the given inbox.",
    "input_schema": { "type": "object", "required": ["inboxId"],
                      "properties": { "inboxId": { "type": "string" }, "timeoutSec": { "type": "integer" } } } },
  { "name": "inboxr_send",
    "description": "Send mail from an Inboxr inbox to one or more recipients.",
    "input_schema": { "type": "object", "required": ["from","to","subject"],
                      "properties": { "from": { "type": "string" }, "to": { "type": ["string","array"] },
                                       "subject": { "type": "string" }, "text": { "type": "string" }, "html": { "type": "string" } } } }
]
```

Wire each handler to the corresponding `/v1` endpoint with the API key as
a Bearer header.

## Operational guidance for agents

- **Email rate limits** are per-tenant (monthly), not per-key. Don't issue 100
  keys hoping to dodge them.
- **SMS rate limits** apply per-tenant AND per-phone:
  - **5 SMS / sec / tenant** — burst control
  - **60 SMS / min / phone** — carrier-friendly
  Both return `429 rate_limited` with a `Retry-After` header. Honor it.
- **SMS credits** debit per send AND per receive. Out-of-credits returns
  `402 no_credits` — handle by buying a top-up pack or upgrading the SMS
  plan, not by retrying.
- **Stake claims early.** Always `sms_claim_sender` BEFORE triggering the
  form that will SMS you. If the SMS arrives in the 50ms window between
  form submit and `claim`, it routes to whoever the previous claimant
  was (or unclaimed pool).
- **Don't log message bodies.** They may contain MFA codes and reset
  links. Log the `uid` and the extracted artifact only.
- **Use one inbox per concurrent flow.** If the agent runs 50 signup
  tests in parallel, give it 50 inboxes — `wait_for_message` /
  `sms_wait_for_message` are per-inbox and you'll otherwise race.
- **Clean up.** Delete inboxes you don't need; `delete_inbox` /
  `sms_delete_inbox` are the cheapest calls in the API.
- **Validate the key once on startup** (`GET /v1/inboxes`). 401s in the
  middle of a long agent run are catastrophic; fail fast.

## Debugging from inside an agent session

If the agent reports a 401 / 429 / 502 you can diagnose it without
dropping out of the agent: just ask it to `inboxr_validate_key` (the MCP
ships this as a no-op call to `/v1/inboxes`). The model can then read the
HTTP status from the tool's error path and adjust.

For deeper debugging — outbound delivery, plan caps, webhook delivery
— send the agent a link to https://app.getinboxr.app/docs/api with the
relevant key already pasted; the live response there is canonical.

---

## SMTP submission
Anchor: `#smtp` (covered inline in /llms-full.txt#integrations and /llms-full.txt#outbound-mail).
Endpoint to issue creds: `POST /v1/inboxes/{id}/smtp-credentials`.
Host: smtp.getinboxr.app:587, STARTTLS.
