# Hail documentation

Hail gives your AI agent a voice, a real phone number, and an inbox — place AI phone calls with ElevenLabs voices, run an AI call center, send SMS and agent mail, all behind one MCP endpoint, one API key, one invoice. Most people use [Hail Cloud](https://hail.so); Hail's services are also self-hostable under AGPLv3, with LiveKit Cloud and channel providers remaining external.

## Using Hail Cloud

- [MCP clients](./mcp.md) — connect Claude.ai, ChatGPT, Cursor, or any MCP client. Paste a URL, click Allow, done.
- [Webhooks](./webhooks.md) — signed JSON events for inbound mail, SMS, delivery reports, and call outcomes.
- [Bring your own LLM](./byo-llm.md) — point voice calls at your own OpenAI-compatible endpoint.
- [CLI reference](./cli.md) — the `hail` binary's email and webhooks surface.
- [API reference](https://hail.so/docs/api) — every REST endpoint, generated from the OpenAPI spec.

## Understanding Hail

- [Architecture](./architecture.md) — the three Python services, the Go CLI, and how LiveKit Cloud fits in.

## Running it yourself

- [Self-hosting](./self-host/README.md) — the umbrella: one-VM deploy, provider setup (LiveKit, Twilio, SES), SMTP inbound, and the operations runbook.
- [Contributing](./contributing.md) — dev environment, regenerating OpenAPI, PR flow.

Source lives at [github.com/hail-hq/hail](https://github.com/hail-hq/hail). Every page here is also plain markdown in `docs/public/` — anything in that folder is published, anything outside it is not.


---

# Architecture

Hail v1 is three Python services plus a Go CLI, built around LiveKit Cloud.

```
 AI agent                                     Hail                                LiveKit Cloud         PSTN
(caller)  ─────MCP URL──►  Hail MCP ─HTTP─►  Hail API  ◄────►  SIP+WebRTC  ◄────► Twilio ◄────► 📞
                          (HTTP :8081)     (FastAPI :8080)
                                                 │
                                                 └─dispatch──►  Hail voicebot  (LiveKit Agents worker)
                                                                     │
                                                                     ├─ VAD:   Silero
                                                                     ├─ STT:   Deepgram
                                                                     ├─ LLM:   fallback(OpenAI → Gemini → Anthropic)
                                                                     │        or caller-provided endpoint
                                                                     └─ TTS:   Cartesia (→ ElevenLabs fallback)
```

## Services

- **api** (`:8080`, FastAPI) — the REST surface; it accepts `POST /calls` and the other routes. It is the source of truth for OpenAPI.
- **mcp** (`:8081`, Streamable HTTP; legacy SSE during the transition) — the MCP server that wraps the API. Agent clients (Claude.ai, ChatGPT, Claude Code, Cursor) connect to it. Refer to [MCP setup](./mcp.md).
- **voicebot** (LiveKit Agents worker) — registers with LiveKit Cloud. Hail dispatches it into a room for each call.
- **postgres** — call records, phone numbers, API keys.
- **minio** (dev only) — S3-compatible local object storage. Use real S3 in production.

LiveKit Cloud is external. The `hail` Go CLI is a scriptable tool for humans, not a service.

## Outbound call flow

1. The caller (an agent via MCP, the CLI, or direct HTTP) sends `POST /calls` with `{to, from, first_message?, …llm}`.
2. The Hail API creates a LiveKit room and dispatches the voicebot into it.
3. The voicebot joins the room. LiveKit places an outbound SIP call through the Twilio trunk to `to`.
4. On pickup, the voicebot classifies who answered before it speaks (refer to the section below). Then it speaks the AI disclosure and the `first_message` (if set), and runs the STT → LLM → TTS loop. If the call set `ai_disclosure: false`, the voicebot skips the disclosure; Hail audit-logs the opt-out, and the opt-out is the caller's responsibility.
5. On hangup, the voicebot writes the call record to Postgres and uploads the recording to S3.

### Answering machine detection and DTMF

Every outbound call runs LiveKit's AMD ([`voicebot/hailhq/voicebot/amd.py`](https://github.com/hail-hq/hail/blob/main/voicebot/hailhq/voicebot/amd.py)) against the greeting. Classification runs on the session's own LLM and STT, not on LiveKit Inference. On `machine-vm` and `machine-unavailable`, the voicebot hangs up without a word — it never leaves a message. It records `status=no_answer` with `end_reason=voicemail_reached` / `machine_unavailable`. On `human` and `uncertain`, the call proceeds normally.

On `machine-ivr`, the voicebot does **not** speak the greeting. A menu cannot hear it, and `session.say` is TTS-only, so the LLM would get no turn in which to press a key. Instead, the agent takes a real LLM turn (`generate_reply`) with the captured menu text and the `send_dtmf` tool. Hail defers the disclosure until the first person speaks. Hail writes the verdict to `call_events` as an `amd_result` row on every call. A detection failure is non-fatal: the call proceeds as if a human answered.

Billing no longer requires a `completed` status. Hail bills a call when `answered_at` is set (the SIP leg went active) **or** when the call completed normally. The first clause bills a machine-answered call and a call that failed mid-conversation. The second clause keeps billing for a completed call whose answer signal never arrived.

The agent can press keypad digits at any point with the `send_dtmf` tool ([`core/hailhq/core/agent_tools/send_dtmf.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/agent_tools/send_dtmf.py)) — not only after an IVR verdict. Thus the agent can navigate phone trees that it reaches mid-call.

## LLM modes

**A — system prompt (default).** The caller supplies `system_prompt`. The voicebot uses LiveKit's `FallbackAdapter`, which chains `openai.LLM` → `google.LLM` → `anthropic.LLM` (a fast model for each). It falls through on error.

**B — BYO endpoint.** The caller supplies `llm: { base_url, api_key, model }`. The voicebot points `openai.LLM` at that endpoint. There is no fallback.

**C — standing BYO endpoint.** The organization saves an endpoint once on the console Providers page. Every call uses it, with opt-in fallback to Hail's models. A per-call mode B block overrides it.

Precedence is B, then C, then A. See [Bring your own LLM](./byo-llm.md) for the wire contract and a runnable endpoint.

## Data

- **Postgres** — call, SMS, and email records; phone numbers; email domains; contacts; webhook subscriptions; API keys.
- **S3** — call recordings.
- **LiveKit Cloud** — transient media (ephemeral).

## SMS

The `SmsProvider` adapter in [`core/hailhq/core/providers/sms/`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/providers/sms) sends SMS through Twilio.

**Outbound.** `POST /sms` sends from the org's dedicated SMS-capable number. There is no pool fallback — refer to `hail numbers` for number acquisition. Twilio posts delivery-status callbacks. These callbacks move `Sms.status` and fan out the `sms.delivered` / `sms.undelivered` / `sms.failed` webhook events.

**Inbound.** Twilio posts each incoming message to `POST /sms/inbound`. Hail verifies the `X-Twilio-Signature` header. It matches the destination number to an org, stores the message, and fires the `sms.received` webhook event. Messages to unknown or pool numbers are dropped. An opt-out reply (`STOP`) adds the sender to the org's suppression list; `START` removes it.

## Outbound email

The `EmailProvider` adapter in `core/hailhq/core/providers/email/` sends outbound mail through AWS SES. Hail stores two kinds of sender identity in `email_domains`:

- **`kind='custom'`** — tenant-controlled DNS (for example `acme.com`). `POST /email-domains` registers the identity with SES and auto-configures a custom MAIL FROM on `send.<domain>`. The response surfaces three DKIM CNAMEs **plus** the MAIL FROM MX/SPF records (each with an optional `priority`). The tenant publishes them, then calls `POST /email-domains/{id}/verify` to re-poll SES for the DKIM and MAIL FROM status. Verified custom domains can also **receive** — inbound matches by identity, with one row and one webhook per matched domain.
- **`kind='hail_mail'`** — a per-org address under an operator-managed parent domain. The full sender is `<user>+<org>@<HAIL_MAIL_BASE_DOMAIN>` (for example `alice+acme@mail.hail.so`). The operator pre-verifies the parent domain once, out of band. Thus Hail creates per-org rows as already verified, without a call to SES.

### Self-hosted vs managed

The two surfaces differ in the source of the prefixes and in the place where you edit them:

|                       | Self-hosted Hail                                                                                                         | Managed Hail (hail.so)                                                                              |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| Auth                  | Shared `HAIL_API_KEY`; one sentinel "Self-hosted" org                                                                    | Per-user `hl_live_*` keys via the website's auth backend                                            |
| Org concept           | None — single sentinel org                                                                                               | Real orgs with members                                                                              |
| Hail-mail base domain | `HAIL_MAIL_BASE_DOMAIN` (operator's `.env`)                                                                              | `HAIL_MAIL_BASE_DOMAIN` (operator's deploy env)                                                     |
| Hail-mail prefixes    | User prefix from `HAIL_MAIL_FROM` / `HAIL_MAIL_DEFAULT_USER_PREFIX` (`.env`); org prefix derived per-org from the org id | Same: user prefix from env, org prefix derived per-org; org admins override per-org via the console |
| Where edits land      | Restart with new `.env` values                                                                                           | `PATCH /email-domains/{id}` (console writes this)                                                   |
| SES production access | Operator's AWS account                                                                                                   | Operator's AWS account                                                                              |
| Billing               | Off — `usage_events` accumulates as raw analytics                                                                        | Cloud rater applies cents/unit, debits `account_credits`                                            |

Hail validates both prefixes against `^[a-z0-9]([a-z0-9-]{0,18}[a-z0-9])?$` (1–20 chars, lowercase alphanumeric + hyphen, no leading or trailing hyphen). Thus the full local part stays well under the RFC-5321 64-char budget.

### Send-time resolution

`POST /emails` picks a sender in this order:

1. An explicit `from` — it must match a `verified` row that the caller's org owns.
2. The org's single verified domain. With two or more, there is no default: the request returns `422` listing them, and the caller must pass `from`.
3. An auto-minted hail-mail row with the configured prefixes, if `HAIL_MAIL_BASE_DOMAIN` is set.

If none of those resolve, the request returns `503` with instructions on how to register a domain.

`GET /email-domains` answers the same question ahead of a send: its `default_from` field holds the address a `from`-less send would use, and is `null` when the caller must choose.

Refer to [AWS SES setup](./self-host/aws-ses.md) for the operator-side setup. Refer to [`docs/superpowers/plans/2026-05-17-hail-mail-addressing.md`](https://github.com/hail-hq/hail/blob/main/docs/superpowers/plans/2026-05-17-hail-mail-addressing.md) for the addressing/configurability plan.

## Inbound email

Operators on AWS enable inbound email when they apply `infra/terraform/`.
The Terraform provisions an S3 bucket, an SES Receipt Rule + Rule Set,
and a small Lambda. The Lambda signs SES events and forwards them to
Hail's `POST /internal/ses-events` endpoint. The API parses the raw MIME
from S3 and routes the message to the owning org by the hail-mail
local-part (`<user>+<org>@mail.hail.so`). It persists an `Email` row
with `direction='inbound'`. The background delivery worker fans out
events to per-domain webhooks and org-wide subscriptions.

```
inbound SMTP ──► SES Receipt Rule
                 ├─ Action: S3       → s3://hail-inbound/raw/<msgid>
                 └─ Action: Lambda   → POST /internal/ses-events (HMAC-signed)
                                          │
                                          ▼
                                    Hail API:
                                      • verify HMAC
                                      • fetch raw MIME from S3
                                      • parse MIME, route to org
                                      • write Email row (direction='inbound')
                                      • enqueue email_attachments to S3
                                      • fan out webhook deliveries
                                      • enqueue forwarding sends (header rewrite)
```

The cloud-agnostic SMTP path is a stub
([`SmtpInboundProvider`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/providers/email/inbound/smtp.py)).
[SMTP inbound](./self-host/smtp-inbound.md) tracks it.

### Per-domain routing — forward and/or webhook

Each `email_domains` row carries `inbound_enabled`, `forward_to`, and `webhook_url`. When `inbound_enabled` is true:

- `forward_to` (a list of addresses) triggers one outbound `Email` row per target through the existing send loop. The forward rewrites headers: envelope `From:` = `forwarder+<org>@mail.hail.so`, `Reply-To:` = the original sender, `References:` preserved for threading, and `X-Hail-Forward-Hops` plus `Auto-Submitted: auto-forwarded` for loop suppression.
- `webhook_url` triggers a signed POST through the webhook delivery worker.

A separate `inbound_routes` table, for per-mailbox routing in custom domains, is deferred to a future milestone (when tenants point their own MX at SES).

### Org-wide subscriptions

The `/webhooks` CRUD surface is the firehose pattern — one subscription covers multiple event types (`email.received`, `email.bounced`, `email.complained`). Signatures are Stripe-style: `X-Hail-Signature: t=<unix>,v1=<hex>`. Hail retries deliveries on a fixed `0/30s/2m/10m/1h/6h/24h` ladder. After the last retry, Hail marks the delivery `dead`. After 50 consecutive dead deliveries, the subscription auto-disables.

Refer to [AWS SES setup](./self-host/aws-ses.md) §10 for the operator runbook. Refer to [`docs/superpowers/specs/2026-06-06-inbound-email-design.md`](https://github.com/hail-hq/hail/blob/main/docs/superpowers/specs/2026-06-06-inbound-email-design.md) for the full spec.


---

# Bring your own LLM

Point a Hail voice call at your own OpenAI-compatible endpoint. Your agent
becomes the brain of the call: Hail handles telephony, speech-to-text,
text-to-speech, turn detection, and tools, and asks your endpoint what to
say on every turn.

Two ways to do it. Per call, by passing an `llm` block to `POST /calls` —
different brains for different calls, or a different tenant's endpoint each
time. Or standing, by saving an endpoint once in the console — every call
your organization places uses it.

## Run one in five minutes

This is a complete endpoint. It has no dependency on Hail or on any model
SDK, and it runs as written.

```python
# byo_endpoint.py
import json, os, time, uuid

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse

app = FastAPI()


def generate_reply(messages: list[dict]) -> str:
    """Replace this with your agent. Return plain text.

    `messages` is the OpenAI-format history for the call so far: Hail's
    composed instructions as the leading system message, then alternating
    user and assistant turns. This stub echoes the last user turn, which is
    enough to prove the wiring without involving a real model.
    """
    last_user = next(
        (m["content"] for m in reversed(messages) if m.get("role") == "user"), ""
    )
    return f"You said: {last_user}"


@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    # Hail sends the api_key you configured as a bearer token. Check it —
    # your endpoint is reachable from the public internet.
    expected = os.environ.get("BYO_LLM_SECRET", "")
    if not expected or request.headers.get("authorization") != f"Bearer {expected}":
        return JSONResponse(status_code=401, content={"error": "invalid api key"})

    body = await request.json()
    model = body.get("model", "byo-example")
    reply = generate_reply(body.get("messages", []))

    chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
    created = int(time.time())

    def sse(**fields) -> str:
        frame = {
            "id": chunk_id,
            "object": "chat.completion.chunk",
            "created": created,
            "model": model,
        }
        frame.update(fields)
        return f"data: {json.dumps(frame)}\n\n"

    async def stream():
        yield sse(
            choices=[
                {
                    "index": 0,
                    "delta": {"role": "assistant", "content": reply},
                    "finish_reason": None,
                }
            ]
        )
        yield sse(choices=[{"index": 0, "delta": {}, "finish_reason": "stop"}])
        yield "data: [DONE]\n\n"

    return StreamingResponse(stream(), media_type="text/event-stream")
```

Start it:

```bash
pip install fastapi uvicorn
export BYO_LLM_SECRET=demo-secret
uvicorn byo_endpoint:app --port 8000
```

Confirm it answers before you involve Hail:

```bash
curl -sN http://127.0.0.1:8000/v1/chat/completions \
  -H "Authorization: Bearer demo-secret" \
  -H "Content-Type: application/json" \
  -d '{"model":"demo","messages":[{"role":"user","content":"What is the weather today?"}]}'
```

```
data: {"id": "chatcmpl-f5cd09e477fc", "object": "chat.completion.chunk", "created": 1786038174, "model": "demo", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "You said: What is the weather today?"}, "finish_reason": null}]}

data: {"id": "chatcmpl-f5cd09e477fc", "object": "chat.completion.chunk", "created": 1786038174, "model": "demo", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}

data: [DONE]
```

Hail requires a public `https` URL, so expose the port through a tunnel —
`cloudflared tunnel --url http://localhost:8000` or `ngrok http 8000` — and
use the `https` address it prints. Then place the call:

```bash
hail call +15551234567 \
  --llm-url https://your-tunnel.example.com/v1 \
  --llm-key demo-secret \
  --llm-model demo \
  --recipient-consent
```

Answer the phone, say something, and the agent repeats it back. Your
endpoint is now driving a live phone call.

Add `--prompt "…"` alongside the `--llm-*` flags to send your task prompt
too: Hail composes its voice preamble plus your prompt into the leading
`system` message your endpoint receives. Prompt and endpoint are
independent choices — at least one is required, both together is fine.

## The contract

Hail calls your endpoint once per voice turn. The request below is a real
capture, taken by pointing Hail's own client at a recording server.

```
POST /v1/chat/completions HTTP/1.1
authorization: Bearer <api_key>
content-type: application/json
accept: application/json
user-agent: LiveKit Agents/1.6.6 (python 3.13.12)
```

```json
{
  "messages": [
    {
      "role": "system",
      "content": "<Hail's voice preamble, then your system_prompt>"
    },
    {
      "role": "user",
      "content": "What's the weather in Paris, and can you end the call after?"
    }
  ],
  "model": "my-model",
  "stream": true,
  "stream_options": { "include_usage": true },
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Get the current weather for a city.",
        "parameters": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "City name" }
          },
          "required": ["city"]
        }
      }
    }
  ]
}
```

Notes that follow from the capture, not from the spec:

- **The path is `{base_url}/chat/completions`.** A `base_url` of
  `https://you.example.com/v1` becomes
  `https://you.example.com/v1/chat/completions`. A trailing slash on
  `base_url` is safe — it does not produce a double slash.
- **`stream` is always `true`**, and `stream_options.include_usage` is
  always sent. Ignore the latter if you do not report usage.
- **`tools` carries Hail's agent tools** — `end_call`, `send_dtmf`,
  `send_sms`, and whatever else the call enabled — in OpenAI function
  format. Return a `tool_calls` delta to invoke one. Hail executes it and
  sends the result back on the very next request, as a `role: "tool"`
  message keyed by `tool_call_id`:

  ```json
  {
    "role": "tool",
    "tool_call_id": "call_1",
    "content": "The weather in Paris is sunny."
  }
  ```

  Restrict which tools a call may use with `--tools` / the `tools` field.

- **`tool_choice` is absent on the first turn**, and appears as
  `"auto"` on the request that follows a tool result. Treat it as optional
  and do not require it.
- Hail's client sends `x-stainless-*` headers. Do not reject unrecognized
  headers.

### What you must return

`text/event-stream`, one `data:` line per frame, each frame a JSON
`chat.completion.chunk`. The minimum that works is a single content chunk:

```
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1730000000,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
```

`data: [DONE]` and the `finish_reason: "stop"` frame are both **optional** —
closing the response body ends the turn cleanly. Send them anyway: they are
the OpenAI-compatible contract, and a future client may be stricter. Emit
many small content chunks rather than one large one; each chunk is spoken as
it arrives, so streaming token-by-token is what makes the agent sound
responsive instead of stalled.

## Rules and failure modes

**URLs must be `https` and publicly resolvable.** Hail checks the scheme
when you submit the call, then resolves the host and rejects private,
loopback, and link-local addresses — twice: once in the API, once again in
the voicebot at call time. A URL that resolves to a private address by the
time the call runs ends the call with `end_reason=provider_key_error`.

**There is no fallback.** You chose this brain deliberately, so Hail does
not silently substitute its own models when your endpoint fails. A standing
console endpoint can opt into fallback; a per-call endpoint cannot.

**Three consecutive failures end a per-call endpoint's call.** After three
non-recoverable errors in a row with no successful turn between them, Hail
speaks a short goodbye and hangs up with `end_reason=llm_endpoint_failed`,
rather than letting a dead endpoint burn the caller's minutes. A turn that
succeeds resets the count. An interruption is not a failure — barge-in
cancels the in-flight request and never counts against you. This give-up
is armed for per-call `llm` blocks only: a standing endpoint with fallback
off that keeps failing ends the call as `end_reason=agent_error` instead.

**A non-200 costs about four seconds.** Hail retries the turn three times
before giving up on it. The caller hears silence for that whole window, so
return a fast, valid reply on your own error paths rather than a 500.

**Your key is encrypted at rest.** The `api_key` is Fernet-encrypted before
it is written to call metadata, and decrypted only inside the voicebot.
Reads never return it.

## Per call

Every surface takes the same three fields.

```bash
curl -X POST https://api.hail.so/v1/calls \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+15551234567",
    "llm": {
      "base_url": "https://you.example.com/v1",
      "api_key": "demo-secret",
      "model": "demo"
    },
    "recipient_consent": true
  }'
```

```bash
hail call +15551234567 \
  --llm-url https://you.example.com/v1 \
  --llm-key demo-secret \
  --llm-model demo \
  --recipient-consent
```

```python
from hail import Client, LLMConfig

async with Client(api_key="sk-...") as client:
    call = await client.calls.create(
        to="+15551234567",
        llm=LLMConfig(
            base_url="https://you.example.com/v1",
            api_key="demo-secret",
            model="demo",
        ),
        recipient_consent=True,
    )
```

The MCP `place_call` tool takes the same `llm` object.

## Standing, for every call

Save an endpoint once in the console instead of sending it per call:
**Console → Calls → Providers → LLM → Configure**. Choose the
`OpenAI-compatible` provider, enter the base URL, model, and key, and use
**Test** to validate the key against the endpoint before you save it. Keys
are write-only — after saving, the console shows only the last four
characters and the date it was set.

The same page configures speech-to-text and text-to-speech. Transport stays
Hail's.

Standing config also offers **fallback**: when enabled, a failure of your
endpoint falls through to Hail's own models rather than ending the call.
Off by default, because silently billing Hail's models would defeat the
point of bringing your own.

### Configure it as code

The console is one client of `/providers`; the CLI and the SDK are the
others. The organization always comes from your API key — it is never a
path or body field, so a key can only reach its own config.

```bash
# Save the endpoint and activate it. '--key -' reads the key from stdin,
# so it never lands in your shell history.
printf '%s' "$MY_PROVIDER_KEY" | hail providers set llm \
  --provider openai-compatible \
  --base-url https://you.example.com/v1 \
  --model demo \
  --key -

hail providers list                     # all layers; keys show as …ABCD
hail providers test llm                 # probe the stored key, live
hail providers activate llm --provider anthropic
hail providers delete llm anthropic
```

```python
from hail import Client

async with Client(api_key="sk-...") as client:
    await client.providers.set(
        "llm",
        provider="openai-compatible",
        api_key="demo-secret",
        params={"base_url": "https://you.example.com/v1", "model": "demo"},
        fallback_enabled=False,
    )
    result = await client.providers.test("llm")
    print(result.status)  # "valid" | "invalid"
```

`tts` and `stt` take the same five verbs — `params` is what differs per
layer (`voice_id`/`model` for `tts`, `model` for `stt`). The canonical
per-layer schemas are `LLMParams` / `TTSParams` / `STTParams` in
[`core/hailhq/core/provider_config.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/provider_config.py);
the routes are `/providers` in
[`openapi/openapi.yaml`](https://github.com/hail-hq/hail/blob/main/openapi/openapi.yaml).

Keys are write-only on every one of these paths: `GET /providers` returns
`key_last4` and `key_set_at` and nothing else key-shaped. To rotate a key,
`set` the layer again with the new one; to edit the model or base URL
without resending the key, omit `--key` (SDK: omit `api_key`).

`set` is a partial write: it changes only the fields you send and leaves
every other saved field alone. So this swaps the model and nothing else —

```bash
hail providers set tts --provider cartesia --model sonic-3
```

— the row's `voice_id` and its fallback setting survive untouched. Send
`--voice-id` (SDK: `params={"voice_id": ...}`) when you do want to change
it, and `--fallback` / `--fallback=false` (SDK: `fallback_enabled=True` /
`False`) when you want to move the fallback flag; omit them to leave both
as they are. What the server validates is the merged result, so a partial
write can never leave an invalid config behind — it 422s instead.

Config is per provider: rows are keyed by `(organization, layer, provider)`,
so pointing a layer at a different `--provider` starts a fresh row rather
than inheriting the old provider's params. `fallback_enabled` is `false` on
a new row.

> The console writes the same rows through `/internal`, which keeps
> full-replace semantics — that is what lets a console user clear a field by
> emptying it. Merging is specific to the public `/providers` routes the CLI
> and SDK use.

## Which brain runs a call

| Mode                      | Source                           | Fallback                    |
| ------------------------- | -------------------------------- | --------------------------- |
| **B — per-call endpoint** | `llm` on `POST /calls`           | none                        |
| **C — standing endpoint** | `/providers` (console, CLI, SDK) | opt-in                      |
| **A — Hail's models**     | no `llm` block                   | OpenAI → Google → Anthropic |

Precedence is B, then C, then A. A per-call `llm` block overrides your
standing config for that one call.

The resolution logic is in
[`voicebot/hailhq/voicebot/pipeline.py`](https://github.com/hail-hq/hail/blob/main/voicebot/hailhq/voicebot/pipeline.py)
(`build_llm`), and the request schema is
[`LLMConfig`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/schemas.py).


---

# CLI reference

`hail` is the Go CLI. It codegens its client from [`openapi/openapi.yaml`](https://github.com/hail-hq/hail/blob/main/openapi/openapi.yaml) — that spec is the canonical contract. This page is a brief summary of each command group. Run `hail <cmd> --help` for the full, authoritative flag list.

## Install

Homebrew is the recommended install path on macOS and Linux:

```bash
brew install hail-hq/tap/hail
hail version
```

The fully qualified formula makes Homebrew add the `hail-hq/tap` tap
automatically. Upgrade or remove the CLI with:

```bash
brew upgrade hail-hq/tap/hail
brew uninstall hail
```

Alternatively, download the `darwin` or `linux` archive for your architecture
from [GitHub Releases](https://github.com/hail-hq/hail/releases), extract it,
and move the `hail` binary to a directory on your `PATH`. Release builds support
Intel (`amd64`) and ARM64.

After installation, choose the authentication mode:

```bash
# Hail Cloud: opens the device authorization flow and saves credentials.
hail login

# Self-host: point the CLI at your deployment; do not run hail login.
export HAIL_API_URL=http://localhost:8080
export HAIL_API_KEY='<the HAIL_API_KEY value from your server .env>'
```

Global flags (any command): `--api-url`, `--api-key`, `--json`. Auth resolves `--api-key > $HAIL_API_KEY > ~/.hail/credentials.json` (run `hail login`).

## Calls

Outbound phone calls only.

```bash
# Place an outbound call (consent attestation required)
hail call +15551234567 --prompt "You are a scheduling assistant." --recipient-consent

hail call status <id>     # one call (full UUID or 4+ char prefix)
hail call list
hail call tail <id>       # follow the event stream for one call
```

`hail call` flags: `--prompt` (mode A) and/or `--llm-url`/`--llm-key`/`--llm-model` (mode B) — at least one is required, and passing both runs your prompt on your own endpoint — `--from`, `--first-message`, `--ai-disclosure`, `--tools`, `--idempotency-key`, plus the consent flags.

To point a call at your own OpenAI-compatible endpoint, see [Bring your own LLM](./byo-llm.md) — it has the wire contract and an endpoint you can run in five minutes.

Language support:

- `--language` — one of 39 lowercase ISO 639-1 codes (e.g. `en`, `da`, `hi`). Hail auto-routes STT and turn detection per language (see [docs/languages.md](https://github.com/hail-hq/hail/blob/main/docs/languages.md) for the full table). STT provider selection is console-BYO-only — there is no per-call flag to pin one.

## SMS

```bash
# Send an outbound SMS (consent attestation required)
hail sms +15551234567 --body "Hello" --recipient-consent

hail sms status <id>
hail sms list --status delivered

hail sms suppressions list     # the opt-out list
hail sms sender-id get         # the org's custom sender ID
```

`hail sms` flags: `--body` (required), `--from`, `--idempotency-key`, plus the consent flags. `list` takes `--status` (`queued|sent|delivered|failed|undelivered|received`), `--to`, `--limit`, `--cursor`.

## Numbers

Dedicated phone numbers for voice and SMS.

```bash
hail numbers acquire --country US --type local
hail numbers list
hail numbers get <id>
hail numbers enable-sms <id>   # attach a Messaging Service so the number can send SMS
```

`acquire` flags: `--country`, `--type` (`local|mobile|toll_free|national`), `--idempotency-key`.

## Email

```bash
# List inbound mail (cursor-paginated)
hail email list --direction inbound

# List outbound mail, only failures
hail email list --direction outbound --status failed

# Fetch one email (full UUID or 4+ char prefix)
hail email get 1a2b

# Send (subject + at least one body flag required)
hail email send --to alice@example.com --subject "Hi" --body "Hello"
```

`hail email list` flags: `--direction` (`inbound|outbound`), `--status` (`queued|sent|failed|bounced|complained|received`), `--limit` (default 50), `--cursor`, `--all` (walk every page). Alias: `hail email ls`.

`hail email get <id>` prints headers, auth verdicts (SPF/DKIM/DMARC/spam/virus), the raw-MIME URL, and attachment metadata for inbound rows.

`hail email send` flags: `--to` (repeatable / comma-separated), `--cc`, `--bcc`, `--from`, `--from-name`, `--reply-to`, `--subject` (required), `--body`, `--body-html`, `--body-file`, `--body-html-file` (`-` reads stdin), `--idempotency-key`.

More email subcommands: `tail <id>`, `raw <id>`, `events <id>`, `stats`, `attachment`, `attachment-upload`. Run `hail email --help` for the list.

### Email domains

The identities that send and receive email. There are two kinds: `hail_mail` (operator-managed parent domain, verified immediately) and `custom` (your DNS). For `custom`, the register call returns DKIM CNAMEs. Publish them, then run `verify`.

```bash
# Register a hail-mail identity (uses server prefix defaults)
hail email domain register --kind hail_mail

# Register a custom domain (prints DKIM CNAMEs to publish)
hail email domain register --kind custom --domain acme.com

hail email domain list
hail email domain get <id>
hail email domain verify <id>   # re-poll the provider for DKIM status
hail email domain delete <id>   # also drops the SES identity for custom rows
```

`register` flags: `--kind` (`hail_mail|custom`, required), `--domain` (required for `custom`), `--local-prefix-user`, `--local-prefix-org` (for `hail_mail`), `--idempotency-key`. `list` takes `--limit` / `--cursor`. Aliases: `list`→`ls`, `delete`→`rm`.

`hail email domain list` closes with the address a send without `--from` goes out as. Own two or more verified identities and there is no default: the line reads `No default sender — a send without --from is rejected.`, and a `--from`-less `hail email send` fails with a 422 listing them.

> Renamed: `hail sender-domain ...` is now `hail email domain ...`. The old name no longer exists.

## Whoami

```bash
hail whoami          # human-readable
hail whoami --json   # for scripts
```

Prints the organization and the user the API key belongs to, plus how the
request authenticated (`apikey`, `jwt`, or `shared`). A shared operator key
carries no user, so the email and name come back empty. Use the email as
`hail email send --reply-to` so replies reach the person rather than the
sending domain.

## Contacts

The org's contact directory (members + manual contacts).

```bash
hail contacts list --q alice
hail contacts create "Alice" --phone +15551234567
hail contacts update <id> --email alice@example.com
hail contacts delete <id>
hail contacts set-phone me --phone +15551234567
hail contacts clear-phone me
```

`create` requires one of `--phone` / `--email`. `list` takes `--q`, `--limit`, `--cursor`, `--all`.

## Providers

Standing BYO provider config for the org's `llm`, `tts`, and `stt` layers — the same rows the console's Providers page writes. Applies to every call unless that call carries its own `--llm-*` block. See [byo-llm.md](./byo-llm.md).

```bash
# Save a provider and make it active ('--key -' reads the key from stdin,
# keeping it out of shell history)
printf '%s' "$MY_KEY" | hail providers set llm \
  --provider openai-compatible --base-url https://you.example.com/v1 \
  --model demo --key -

hail providers list                          # all layers; keys show as …ABCD
hail providers test llm                      # probe the stored key, live
hail providers activate llm --provider anthropic
hail providers delete llm anthropic
```

`set` requires only `--provider`. Params are per layer: `--model` (required by the `llm` layer, optional for `tts`/`stt`), `--base-url` (required by the `openai-compatible` LLM provider, rejected by the others), `--voice-id` (`tts` only). `--fallback` lets a failure of your provider fall through to Hail's own keys; `--fallback=false` turns it back off.

`set` is a partial write — it changes only the flags you pass and preserves everything else on the row, so `hail providers set tts --provider cartesia --model sonic-3` keeps the saved `voice_id` and the saved fallback setting. Omitting `--key` likewise keeps the stored key. Config is per provider, so a different `--provider` for the same layer starts a fresh row instead of inheriting the previous one's params. `test` takes `--provider` to probe a saved-but-inactive provider instead of the active one. Aliases: `list`→`ls`, `delete`→`rm`.

Keys are write-only: no command can print one back, only the last four characters and when it was set. The org comes from your API key — it is never an argument.

## Events

```bash
hail tail                      # stream events from across the org
hail tail --id call:1a2b       # narrow to one resource
```

`hail tail` flags: `--id`, `--kind`, `--interval`, `--from-start`, `--no-follow`.

## Webhooks

Org-wide outbound subscriptions. Each subscription fires an HMAC-signed POST for each matching event. Hail retries failed deliveries on a fixed ladder. Refer to [setup/webhooks.md](./webhooks.md) for the payload shape, the event-type list, and signature verification.

The CLI has no `webhooks` command group. Manage subscriptions through the HTTP API:

```bash
# Register a subscription (the response shows the signing secret ONCE — store it)
curl -X POST "$HAIL_API_URL/webhooks" \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -d '{"target_url":"https://example.com/hooks/hail","event_types":["email.received","sms.received"]}'

# List subscriptions
curl "$HAIL_API_URL/webhooks" -H "Authorization: Bearer $HAIL_API_KEY"

# Delivery attempts for one subscription
curl "$HAIL_API_URL/webhooks/<sub-id>/deliveries" -H "Authorization: Bearer $HAIL_API_KEY"

# Replay one delivery
curl -X POST "$HAIL_API_URL/webhooks/<sub-id>/deliveries/<delivery-id>/redeliver" \
  -H "Authorization: Bearer $HAIL_API_KEY"
```

Other endpoints: `PATCH /webhooks/{id}` (update URL, events, or status), `DELETE /webhooks/{id}`, and `POST /webhooks/{id}/rotate-secret`. Event types cover email, SMS, and call events — the canonical list is `WebhookEventType` in [`core/hailhq/core/schemas.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/schemas.py).

## Auth and utilities

```bash
hail login             # browser auth; saves an API key to ~/.hail/credentials.json
hail auth token        # print the bare API key (for scripting)
hail auth logout       # remove the local credentials file
hail mcp endpoint      # print the MCP server's Streamable HTTP URL
hail completion zsh    # shell completion script
hail version
```


---

# Contributing

For the full operational runbook (releases, deployment, DB switching, all the known problems), refer to [Operations](./self-host/operations.md). This page covers the contribution flow only.

## Setup

```bash
git clone <repo>
cd hail
cp .env.example .env.local
# fill in keys (see ./setup/)
pnpm install                      # installs husky + lint-staged + prettier
docker compose -f docker-compose.yml -f docker-compose.local.yml up postgres minio
                                  # just the data services for host-side dev
```

`pnpm install` installs the git pre-commit hook. The hook runs `ruff`/`black`/`gofmt`/`prettier` on staged files.

## Dev loops

- API: `cd api && uv run uvicorn hailhq.api.main:app --reload --port 8080`
- Voicebot: `cd voicebot && uv run python -m hailhq.voicebot.main start`
- MCP: `cd mcp && uv run uvicorn hailhq.mcp.server:app --reload --port 8081`
- CLI: `cd cli && go run . <args>`

Full stack in Docker:

- Bundled Postgres: `docker compose -f docker-compose.yml -f docker-compose.local.yml up`
- Managed Postgres (set `DATABASE_URL` to your hosted URL first): `docker compose up`

## Database migrations

The schema lives in [`api/migrations/versions/`](https://github.com/hail-hq/hail/tree/main/api/migrations/versions). The Alembic config is in [`api/alembic.ini`](https://github.com/hail-hq/hail/blob/main/api/alembic.ini); `DATABASE_URL` overrides the config default.

```bash
cd api
uv run alembic upgrade head       # apply all pending
uv run alembic revision -m "add foo"   # create a new revision (hand-edit the SQL)
uv run alembic downgrade -1       # revert the last revision
```

Migrations are hand-written raw SQL for v1 (no ORM models yet). When SQLAlchemy models are released, switch to `--autogenerate`.

## Regenerating openapi.yaml

After you change API routes, dump the spec:

```bash
curl -s http://localhost:8080/openapi.json \
  | python -c "import json, sys, yaml; yaml.safe_dump(json.load(sys.stdin), sys.stdout, sort_keys=False)" \
  > openapi/openapi.yaml
```

The Go CLI codegens its client from this file, so commit the update in the same PR as the route change.

Do not hand-edit `openapi/openapi.yaml`. CI regenerates it from the live app
and compares (refer to `.github/workflows/openapi-check.yml`). Any manual
change that the app does not also produce fails as "stale". A status raised
via `raise HTTPException(...)` does **not** appear in the spec unless the
route decorator declares it (for example `responses={429: {...}}`). Add it
there, then regenerate.

## Commit style

[Conventional Commits](https://www.conventionalcommits.org):

- `feat(api): add POST /calls`
- `fix(voicebot): handle SIP disconnect during greeting`
- `docs(setup): clarify Twilio trunk origination URI`

## Adding a provider

Put new adapters under `core/hailhq/core/providers/<channel>/<name>.py`. Each adapter implements that channel's adapter interface. Add config keys to `.env.example` in the same provider-grouped format.

## Model costs contributions

Public AI model costs live in [`costs/`](https://github.com/hail-hq/hail/tree/main/costs) under CC-BY-4.0. The JSON files at the top of that directory are the source of truth. CI validates them against the schemas in `costs/schema/` on every PR.

To update a price:

1. Edit `costs/<category>.json` (for example `costs/llm.json`).
2. Set `last_verified` to today (`YYYY-MM-DD`). Set `verified_by` to your GitHub handle.
3. Update `source_url` if it has changed.
4. Run `pnpm costs:validate` locally before you push.

A weekly cron opens a tracking issue that lists rows older than 30 days — refer to [`costs-stale.yml`](https://github.com/hail-hq/hail/blob/main/.github/workflows/costs-stale.yml).

## What we will not merge (v1)

- Code that hard-codes a provider in `api/` or `voicebot/` — route through `core/`.
- New env vars missing from `.env.example`.
- Features without a milestone in README.
- Web UI code (no dashboards in v1).
- Docs that paraphrase the OpenAPI spec or MCP tool schemas instead of a link to the canonical source.
- Non-GFM Markdown in docs.


---

# MCP clients

Hail exposes MCP as a **remote server**. Hail Cloud uses OAuth. Paste the URL into your client. Click Allow in the browser. Your agent then gets the call/sms/mail tools. There are no keys to manage and no installation.

> For the easy onboarding path, refer to the [client picker on hail.so/mcp](https://hail.so/mcp). It has copy-paste setup for the 8 most common clients (Claude.ai, ChatGPT, Cursor, Gemini, …). This page is the technical reference behind those snippets.

## URL

- **Hail Cloud**: `https://mcp.hail.so`
- **Self-hosted**: `http://<your-host>:8081` — refer to [Self-host](#self-host) below.

The Streamable HTTP transport serves the MCP root path. There is no `/mcp` suffix and no SSE.

> For web-based clients (Claude.ai, ChatGPT), the URL must be reachable from the client's servers — public DNS + TLS. If you want web clients to reach a self-hosted instance, tunnel it via cloudflared / tailscale funnel.

## Tools

The server exposes 20 tools. Schemas (args, validation, return shapes) are the source of truth — refer to [`mcp/hailhq/mcp/tools.py`](https://github.com/hail-hq/hail/blob/main/mcp/hailhq/mcp/tools.py).

| Tool                      | Does                                                    |
| ------------------------- | ------------------------------------------------------- |
| `place_call`              | Originate an outbound phone call.                       |
| `get_call`                | Fetch the current state of one call.                    |
| `list_calls`              | List recent calls (cursor-paginated).                   |
| `send_sms`                | Send an outbound SMS (recipient consent is required).   |
| `get_sms`                 | Fetch the current state of one SMS.                     |
| `list_sms`                | List recent SMS messages (cursor-paginated).            |
| `send_email`              | Send an outbound email (supports `attachment_ids`).     |
| `upload_email_attachment` | Upload a file, get back a reusable id.                  |
| `get_email`               | Fetch one email's full record (body + inbound headers). |
| `list_emails`             | List emails (`direction="inbound"` for replies).        |
| `get_email_raw`           | Presigned URL for an inbound email's raw MIME.          |
| `get_email_attachment`    | Presigned URL for one inbound attachment.               |
| `get_email_events`        | Page through one email's event history.                 |
| `get_email_stats`         | Aggregate email counts for a time window.               |
| `get_events`              | Page through the event stream.                          |
| `list_email_domains`      | List sending identities + the default `from` address.   |
| `whoami`                  | Identify the human behind the session (for `reply_to`). |
| `list_contacts`           | List the org's contacts (members + manual contacts).    |
| `lookup_contact`          | Find one contact by name, email, or phone fragment.     |
| `create_contact`          | Add a manual contact.                                   |

## Claude.ai (web)

1. **Settings → Connectors → Add custom connector**
2. Server URL: `https://mcp.hail.so`
3. Save. Claude prompts you to authorize on first use. Click **Allow** in the browser tab that opens.

That is all. There is no API key field.

## ChatGPT (web)

Custom MCP connectors are behind Developer Mode on ChatGPT today:

1. **Settings → Connectors → Advanced → Developer mode**
2. **Create** → paste `https://mcp.hail.so`
3. Save. ChatGPT guides you through the OAuth consent on the first call.

## Other clients

The [client picker](https://hail.so/mcp) has setup snippets for Cursor, Gemini, Windsurf, Copilot, Zed, Raycast, and Claude Desktop. All follow the same shape: paste the URL, then click Allow.

## Authorized apps

Each cloud client that you connect appears as a row at [`hail.so/console/apps`](https://hail.so/console/apps). Revoke deletes the consent and every active access and refresh token for that client. The client's next tool call returns 401 and runs OAuth again from the start.

Access tokens last 30 days, and refresh tokens last 180 days. In practice, you authorize each client again approximately every six months. The consent screen opens, you click **Allow**, and the client continues.

## Self-host

Self-hosted deployments do not use OAuth. Set `HAIL_API_KEY` and send it as a bearer token:

```sh
curl -H "Authorization: Bearer ${HAIL_API_KEY}" http://localhost:8081/
```

The MCP service selects its auth mode from env at boot. For the full env-var contract, refer to the [MCP modes table in the operations runbook](./self-host/operations.md#mcp-modes) (`HAIL_AUTH_URL` for cloud, `HAIL_API_KEY` for self-host, mutually exclusive).

## Why remote-only (no stdio / no PyPI install)

We release one MCP distribution: a remote HTTP endpoint. It is bundled with every Hail deploy. We deliberately do **not** publish a stdio MCP server on PyPI. The reasons:

1. **Web UIs cannot run stdio servers.** Claude.ai's MCP Connectors and ChatGPT's Custom Connectors accept only remote URLs. They cannot start local processes from a browser.
2. **Every terminal client also accepts a remote URL.** Claude Code, Claude Desktop, Cursor, and Zed all connect to a URL. The URL flow works for all clients. Stdio works only for a subset.
3. **Stdio fragments distribution.** Two artifacts (PyPI stdio wrapper + HTTP service) cause two versions to keep in sync, two install paths, and two failure modes.
4. **Installation friction.** Stdio requires Python + pip/uv on the user's dev machine. Remote HTTP requires nothing. You only paste a URL.

If a restricted client ever needs stdio, we will release a thin stdio-to-HTTP proxy on PyPI (approximately 50 LOC).


---

# API versioning

`/v1/<resource>` is the canonical, documented form of every customer-facing
Hail API route. It appears in the OpenAPI spec (`openapi/openapi.yaml`) and
is what the CLI and generated clients target.

## Legacy unprefixed paths

Routes without the `/v1` prefix (e.g. `/whoami` instead of `/v1/whoami`)
still work, for existing integrations built before versioning shipped. They
are not in the OpenAPI spec and should not be used for new integrations.

Every response from a legacy path carries:

- `Deprecation: true` — this path is deprecated (see the IETF Deprecation
  HTTP header field).
- `Link: </v1/...>; rel="successor-version"` — the canonical `/v1` path
  that replaces it, as a relative path (not an absolute URL).

## Sunset

No sunset date is set for the legacy paths yet. If one is scheduled, it
will be announced here and via a `Sunset` response header (RFC 8594) added
ahead of the change, giving integrators advance notice before the
unprefixed paths stop working.


---

# Webhooks

When a subscribed event occurs — inbound mail or SMS, a delivery report, a
call outcome — Hail `POST`s a signed JSON event to your URL. Verify the
`X-Hail-Signature` header against the once-shown secret. Return any `2xx`,
and you are done. Hail retries a non-2xx response (or a timeout) on a fixed
ladder.

## Verify the signature (Python)

The signature is HMAC-SHA256 over `f"{t}.{body}"` — the timestamp from the
header, a literal `.`, then the **raw request bytes**. This is a runnable
example. The `assert` passes (the real signer in
[`core/hailhq/core/webhooks.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/webhooks.py) produced
the fixture):

```python
import hashlib, hmac

def verify(secret: str, signature_header: str, raw_body: bytes) -> bool:
    # signature_header looks like "t=1700000000,v1=<hex>"
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t, v1 = parts["t"], parts["v1"]
    mac = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256)
    return hmac.compare_digest(mac.hexdigest(), v1)

# Worked example — prints True:
assert verify(
    "whsec_example",
    "t=1700000000,v1=72f7940d13ca8528cf655a04e82dabfe90a531e239241fda9bdff72980d33a4a",
    b'{"id":"evt_123","type":"email.received","data":{"id":"em_1"}}',
)
```

## Verify the signature (Node)

Hash the **raw request body bytes**, not a re-serialized object — JSON
key order and whitespace would differ and the HMAC would not match.

```js
import crypto from "node:crypto";

function verify(secret, signatureHeader, rawBody) {
  // rawBody is a Buffer of the exact bytes Hail sent.
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split(/=(.*)/s).slice(0, 2)),
  );
  const mac = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(parts.v1));
}
```

In Express, capture the raw bytes with `express.raw({ type: "application/json" })`.
In Next.js route handlers, use `await req.text()` / `req.arrayBuffer()` and pass
those bytes, not `await req.json()`.

## Headers

Every delivery carries these headers (refer to
[`core/hailhq/core/webhook_worker.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/webhook_worker.py)):

| Header                | Meaning                                                                                                                     |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `X-Hail-Signature`    | `t=<unix>,v1=<hex hmac_sha256>` — verify this.                                                                              |
| `X-Hail-Event`        | Event type, for example `email.received`.                                                                                   |
| `X-Hail-Delivery`     | Unique delivery id (stable across retries; use to dedupe).                                                                  |
| `X-Hail-Subscription` | Always present — identifies the subscription that produced this delivery.                                                   |
| `X-Hail-Email-Domain` | Informational: the source email domain for inbound events (when known). Branch on this to route per-domain in your handler. |

## Event types

The full set is the `WebhookEventType` enum in
[`core/hailhq/core/schemas.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/schemas.py):

- **`email.received`** — a message arrived, and Hail accepted it.
- **`email.received.suppressed`** — a message arrived, but Hail held back
  fan-out/forwarding. `data.reason` is one of `forward_loop`, `forward_rate_limit`,
  `inbound_rate_limit`, `insufficient_funds`. One event fires _per reason_
  (a single message can produce more than one suppressed event).
- **`email.delivered`** — SES accepted the message for delivery.
- **`email.delivery_delayed`** — SES reports a transient delay.
- **`email.bounced`** — the recipient mail server rejected the message (permanent or soft bounce).
- **`email.complained`** — the recipient marked the message as spam.
- **`email.opened`** — the recipient opened the message (image tracked, approximate).
  Only fires for emails sent with an HTML body; plain-text-only emails are never
  tracked for opens.
- **`email.clicked`** — the recipient clicked a tracked link. Only fires for
  emails sent with an HTML body; plain-text-only emails are never tracked for
  clicks.
- **`email.send_failed`** — an outbound email failed to send.
- **`sms.received`** — an inbound SMS arrived, and Hail accepted it. Hail delivers
  it through the same signed, retried webhook worker as the email events
  (`X-Hail-Signature`, `X-Hail-Event`, `X-Hail-Delivery`). Hail omits the
  `X-Hail-Email-Domain` header.
- **`sms.delivered`** — the carrier confirmed delivery (requires Twilio delivery receipts).
- **`sms.undelivered`** — the carrier reported the message was not delivered.
- **`sms.failed`** — the send failed (transport error or carrier rejection).

**Call lifecycle** — covers `answered`, `completed`, `failed`, `busy`, `no_answer`
only (no `ringing` or `canceled` events; no data source):

- **`call.answered`** — the callee picked up (call entered in-progress).
- **`call.completed`** — the call ended normally.
- **`call.failed`** — the call failed (setup error, trunk/media failure, or force-closed).
- **`call.busy`** — the callee was busy or rejected the call.
- **`call.no_answer`** — the callee did not answer.

## Payload

Hail wraps every event in this envelope (`build_event_payload` in
[`webhooks.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/webhooks.py) assembles it). The `data`
shape comes from the event type. Inbound events use [`build_event_data`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/webhook_fanout.py) for `email.received*`. Delivery events use [`build_delivery_event_data`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/email_delivery_events.py) for the lifecycle events.

**Inbound example** (`email.received`):

```json
{
  "id": "9f2c…",
  "type": "email.received",
  "api_version": "2026-06-06",
  "created_at": "2026-06-14T12:00:00+00:00",
  "organization_id": "org-uuid",
  "data": {
    "id": "em-uuid",
    "direction": "inbound",
    "from_address": "sender@example.com",
    "to_addresses": ["agent@yourorg.hail.so"],
    "subject": "Re: invoice",
    "message_id": "<abc@example.com>",
    "in_reply_to": "<def@yourorg.hail.so>",
    "spam_verdict": "PASS",
    "virus_verdict": "PASS",
    "spf_verdict": "PASS",
    "dkim_verdict": "PASS",
    "dmarc_verdict": "PASS",
    "raw_url": "https://api.hail.so/v1/emails/em-uuid/raw",
    "attachments": [
      {
        "id": "att-uuid",
        "filename": "invoice.pdf",
        "content_type": "application/pdf",
        "size_bytes": 12345,
        "url": "https://…"
      }
    ]
  }
}
```

**Inbound example** (`sms.received`):

```json
{
  "id": "7a1b…",
  "type": "sms.received",
  "api_version": "2026-06-06",
  "created_at": "2026-07-10T12:00:00+00:00",
  "organization_id": "org-uuid",
  "data": {
    "id": "sms-uuid",
    "from": "+14155551234",
    "to": "+14155559999",
    "body": "hello back"
  }
}
```

`raw_url` and each attachment `url` are Hail API endpoints that 302-redirect to a
presigned S3 URL on access. `email.received.suppressed` carries a trimmed `data`
(`id`, `direction`, `from_address`, `to_addresses`, `subject`, `message_id`,
`reason`) — no verdicts, `raw_url`, or attachments.

**Outbound delivery example** (`email.bounced`):

```json
{
  "id": "9f2c…",
  "type": "email.bounced",
  "api_version": "2026-06-06",
  "created_at": "2026-07-02T12:00:05+00:00",
  "organization_id": "org-uuid",
  "data": {
    "id": "em-uuid",
    "kind": "bounced",
    "occurred_at": "2026-07-01T12:00:05+00:00",
    "from_address": "noreply@acme.com",
    "to_addresses": ["bob@example.com"],
    "subject": "Welcome",
    "detail": {
      "hard": true,
      "bounce_type": "Permanent",
      "bounce_sub_type": "General",
      "recipients": ["bob@example.com"],
      "diagnostic_code": "smtp; 550 5.1.1 user unknown"
    }
  }
}
```

The `detail` field varies by event type. `bounced` and `complained` carry SES metadata. `delivered` and `delivery_delayed` carry SES status details. `opened` and `clicked` carry `ip_address` and `user_agent`; `clicked` adds `link` (the event time is the sibling `occurred_at` field). Both only occur for emails sent with an HTML body: the tracking pixel and link rewriting live in the HTML part, so plain-text-only emails never produce them. For `bounced`, the provider-neutral `hard` flag distinguishes hard bounces from soft bounces. Only hard bounces move the email to `status=bounced` and count toward `bounced_hard` in `GET /emails/stats`.

## Retries

Hail retries a delivery that does not get a `2xx` on this fixed ladder
(`RETRY_SCHEDULE_SECONDS` in [`webhooks.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/webhooks.py)):

```
0s → 30s → 2m → 10m → 1h → 6h → 24h
```

After the 7th attempt fails, Hail marks the delivery **dead**. When an org-wide
subscription accrues **50 consecutive dead** deliveries, it auto-disables. To
re-enable it, set its status back to `active`. Replay a single delivery from the
console or through the API:

```bash
curl -X POST "$HAIL_API_URL/v1/webhooks/<subscription-id>/deliveries/<delivery-id>/redeliver" \
  -H "Authorization: Bearer $HAIL_API_KEY"
```

## Subscribe

Create a subscription. The response returns the signing secret **once**, at create:

```bash
POST /webhooks   {"target_url": "https://example.com/hooks/hail",
                  "event_types": ["email.received", "email.received.suppressed"]}
```

The `event_types` enum and request/response schemas are in
[`openapi/openapi.yaml`](https://github.com/hail-hq/hail/blob/main/openapi/openapi.yaml) (`WebhookSubscriptionCreate`).
The CLI has no `webhooks` command group — for the full endpoint list, refer to
[the CLI reference](./cli.md#webhooks).


---

# Self-hosting

Run Hail's API, voicebot, MCP server, Postgres, and object storage with Docker
Compose. LiveKit Cloud and the providers for the channels you enable remain
external. The code is AGPLv3.

## Local evaluation

Prerequisites: Git, Docker Engine, and Docker Compose v2.

```bash
git clone https://github.com/hail-hq/hail
cd hail
cp .env.example .env

# Generate a key and put it in .env as HAIL_API_KEY.
printf 'hk_%s\n' "$(openssl rand -base64 32 | tr -d '/+=' | head -c 40)"

docker compose -f docker-compose.yml -f docker-compose.local.yml \
  run --rm api alembic upgrade head
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
curl --fail http://localhost:8080/healthz
```

The local overlay supplies Postgres. Plain `docker compose up` is only for a
managed database after `DATABASE_URL` has been changed from its bundled
`postgres` default. Compose reads `.env` for containers but does not export it
to your shell; set `HAIL_API_URL` and `HAIL_API_KEY` before using the CLI or SDK.

## Order

1. **[Operations](./operations.md#deployment-self-host)** — required credentials, migrations, authentication, phone-number binding, and troubleshooting.
2. **[LiveKit Cloud](./livekit-cloud.md)** — required media and SIP bridge for voice calls.
3. **[Twilio](./twilio.md)** — required phone numbers and SIP trunk for voice/SMS.
4. **[AWS SES](./aws-ses.md)** — optional outbound/inbound email. To receive without AWS, see [SMTP inbound](./smtp-inbound.md).
5. **[VM deployment](./vm-deploy.md)** — production deployment on Ubuntu with managed Postgres and HTTPS.

Local MCP clients use `http://localhost:8081`. Web-based clients need a public
HTTPS endpoint; see [MCP clients](../mcp.md#self-host).

## Day 2

- **[Operations runbook](./operations.md)** — develop, deploy, migrate, release. The single source of truth for operating a Hail deployment.


---

# AWS SES (email)

Outbound and inbound email go through [Amazon SES](https://aws.amazon.com/ses/) ([SESv2 API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_Operations.html)). You need an AWS account and the SES service enabled in one region. You also need IAM-role credentials (recommended for EC2/ECS/EKS deployments) or a long-lived access key.

## 1. Credentials

The Python SDK uses the standard [boto3 credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). If you run on AWS infrastructure with an attached IAM role, leave the keys empty in `.env`. Otherwise, set:

```bash
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA…
AWS_SECRET_ACCESS_KEY=…
```

Minimal IAM policy (one statement is sufficient for the v1 surface):

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ses:SendEmail",
        "ses:CreateEmailIdentity",
        "ses:GetEmailIdentity",
        "ses:DeleteEmailIdentity"
      ],
      "Resource": "*"
    }
  ]
}
```

## 2. Sandbox vs production

New SES accounts start in the [sandbox](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html): 200 messages/day, 1 message/second, and you can send only **to verified addresses**. When you are ready to send to arbitrary recipients, request production access from **SES → Account dashboard → Request production access**.

## 3. Why `mail.hail.so` (a subdomain), not `hail.so`

Always send transactional mail from a **dedicated subdomain**, not from your apex domain. There are two reasons:

- **Reputation isolation.** If a bounce spike or a spam-report cluster damages the sender reputation, only the subdomain takes the damage. Your apex domain (website, marketing email if any) stays clean.
- **Standard practice.** Postmark uses `mtasv.net`, and SendGrid uses `sendgrid.net`. Resend has tenants verify their own subdomains. Do not mix transactional volume with brand traffic on the apex domain — it is a known risk.

The recommended subdomain for the Hail public cloud is `mail.hail.so`. Self-hosters: select a subdomain that you own, for example `mail.<your-domain>`.

## 4. Set up the SES identity

This is operator setup, done once per deployment. Hail does not configure SES for the parent `HAIL_MAIL_BASE_DOMAIN`. That identity must exist, and be verified, before the first `POST /emails`. The operator also configures the MAIL FROM subdomain. Custom tenant domains follow a separate, fully automated flow: Hail calls `CreateEmailIdentity` **and** configures their MAIL FROM (refer to §7).

1. Open **AWS Console → SES → Verified identities → Create identity → Domain**. Enter the bare subdomain (`mail.hail.so`). Enable **DKIM** (the default; keep the bit-length at 2048).
2. SES returns three CNAMEs of the form `<token>._domainkey.mail.hail.so → <token>.dkim.amazonses.com`. **Publish all three** at your DNS provider. Wait until SES sets the status to **Verified** (usually less than 1 hour).
3. **Configure a custom MAIL FROM domain** — this step is **mandatory** for production deliverability (refer to §5 below). On the identity detail page, select **Edit MAIL FROM** and set the value to `bounces.mail.hail.so`. SES returns one MX record and one TXT record. Publish both at DNS:
   ```
   bounces.mail.hail.so  MX   10  feedback-smtp.us-east-1.amazonses.com
   bounces.mail.hail.so  TXT  "v=spf1 include:amazonses.com ~all"
   ```
4. Wait until the MAIL FROM domain status is **Success**.

> This MAIL FROM subdomain — `bounces.mail.hail.so` on the **operator parent** — is operator-managed: you configure it once, manually, in the steps above. **Custom tenant domains are different**: Hail configures their MAIL FROM automatically. `POST /email-domains` (kind=`custom`) calls `PutEmailIdentityMailFromAttributes` for `send.<domain>` and returns the MX + SPF records to publish together with the DKIM CNAMEs. `POST /email-domains/{id}/verify` then re-polls both the DKIM status and the MAIL FROM status. Refer to §7.

## 5. DMARC alignment (required for inbox delivery)

Without an aligned MAIL FROM, SES uses `<random>@amazonses.com` as the Return-Path. SPF then authenticates against `amazonses.com`, not against your domain. This breaks DMARC alignment, and the DMARC policies of recipients push your mail to spam.

With `bounces.mail.hail.so` as MAIL FROM:

- **SPF** authenticates against `bounces.mail.hail.so` → aligns with the From-domain `mail.hail.so`.
- **DKIM** signs with `mail.hail.so` → aligns.
- Both aligned → DMARC `pass` → inbox.

Publish a DMARC record. Start at `p=none` (monitor-only). Increase the policy after some weeks of clean reports:

```
_dmarc.mail.hail.so  TXT  "v=DMARC1; p=none; rua=mailto:dmarc-reports@hail.so; adkim=s; aspf=s"
```

Field summary: `p=none` reports but does not block (start here). `quarantine` moves unaligned mail to spam. `reject` drops it. `adkim=s` and `aspf=s` require strict alignment between the DKIM/SPF authentication domain and the From domain.

> **TODO(dmarc-ratchet):** Hail's public `mail.hail.so` is currently at `p=none`. After 30 or more days of clean DMARC aggregate reports (no unauthenticated mail in `rua=` feeds), step the policy to `p=quarantine`. Monitor for 30 more days, then move to `p=reject`. Self-hosters must follow the same staged rollout on their own subdomain.

## 6. Configure Hail

```bash
HAIL_MAIL_BASE_DOMAIN=mail.hail.so

# Single-variable form (self-hosters running a SINGLE org):
HAIL_MAIL_FROM=admin+selfhost@mail.hail.so

# Multi-tenant: leave HAIL_MAIL_FROM unset. The org prefix is derived
# per-org from the organization id; set only the default user prefix:
# HAIL_MAIL_DEFAULT_USER_PREFIX=admin

AWS_REGION=us-east-1
```

Hail-mail addresses always have the shape `<user>+<org>@<HAIL_MAIL_BASE_DOMAIN>` — for example `alice+acme@mail.hail.so`. Hail validates both `<user>` and `<org>` against `^[a-z0-9]([a-z0-9-]{0,18}[a-z0-9])?$` (1–20 characters, lowercase alphanumeric plus hyphens, no leading or trailing hyphen).

Precedence at send time (highest wins):

- **User prefix:** explicit `local_prefix_user` → `HAIL_MAIL_FROM` (user part) → `HAIL_MAIL_DEFAULT_USER_PREFIX`.
- **Org prefix:** explicit `local_prefix_org` → `HAIL_MAIL_FROM` (org part, single-tenant) → derived per-org from the organization id. The org prefix is never a deploy-wide constant — that constant would make every org collide on one address.

### Self-host vs managed

**Self-hosters**: there is no console, so the env vars _are_ the configuration. Set them in `.env` once, then restart. `POST /emails` then works without a prior `POST /email-domains` — the server auto-mints a hail-mail row from the env defaults on the first send.

**Managed cloud**: the website provisions the hail-mail row of each org at signup. It calls `POST /email-domains` with prefixes derived from the org slug and the user identity. Org admins then change the visible address through the console, which writes via `PATCH /email-domains/{id}`. The env vars provide deploy-time defaults but rarely surface to tenants directly.

## 7. Custom (tenant) domains

Tenants can register their own DNS-controlled domain:

```bash
curl -X POST $HAIL_API_URL/email-domains \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"custom","domain":"acme.com"}'
```

The response returns the full DNS record set to publish:

- **three DKIM `_domainkey` CNAMEs** — `<token>._domainkey.acme.com → <token>.dkim.amazonses.com`;
- **a MAIL FROM MX + SPF TXT on `send.acme.com`** — Hail configures the custom MAIL FROM automatically, so there is no AWS-console step for the tenant:
  ```
  send.acme.com  MX   10  feedback-smtp.<region>.amazonses.com
  send.acme.com  TXT      "v=spf1 include:amazonses.com ~all"
  ```

After the tenant publishes all records, the tenant calls `POST /email-domains/{id}/verify` to re-poll SES for **both** the DKIM status and the MAIL FROM status.

```bash
hail email domain register --kind custom --domain acme.com
# → prints the DKIM CNAMEs + the send.acme.com MAIL FROM records in a copy-pastable table
hail email domain verify <id>
# → re-polls SES; flips the row to verified once the records are live
```

> Inbound on a custom domain: after the row is `verified`, enable inbound (`forward_to` and/or a webhook) to receive mail. Matching is by identity, so each receiving domain yields its own inbound row + webhook. Receiving still relies on the operator's region-wide SES receipt rule (§10).

## 8. Send

```bash
hail email send --to alice@example.com --subject "hi" --body "hello"
```

Or via HTTP:

```bash
curl -X POST $HAIL_API_URL/emails \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["alice@example.com"],
    "subject": "hi from Hail",
    "body_text": "this just works"
  }'
```

The `from` field is optional. Resolution order:

1. Explicit `from` — must match a `verified` email_domain row owned by the caller's org.
2. The org's single verified domain. Own two or more and there is no default — the call returns `422` listing them, so `from` must be explicit.
3. The auto-minted hail-mail row, if `HAIL_MAIL_BASE_DOMAIN` and the prefixes are configured.

If none of these resolve, the call returns `503` with instructions to register a domain.

To see which address a `from`-less send would use, read `default_from` on `GET /email-domains` (`hail email domain list`). It is `null` when the org owns several verified identities.

The optional `from_name` field sets a display name on the `From:` header (`"Acme Billing" <billing@acme.com>`). Non-ASCII names are RFC-2047-encoded automatically; control characters are rejected with `422`.

## 8a. Attachments

Upload a file once and attach it to as many sends as you want. The limit is 25MB per upload and per send (body + all attachments combined, measured before base64 encoding). SES caps the encoded wire message at 40MB and bandwidth-throttles messages over 10MB.

### Upload a file

```bash
curl -s -X POST $HAIL_API_URL/email-attachments \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -F "file=@invoice.pdf" | jq -r .id
# → "3fa85f64-5717-4562-b3fc-2c963f66afa6"
```

The response is a JSON object with an `id` field (UUID). Store this id to reference the attachment in sends.

### Attach to a send

Pass `attachment_ids` (a list of UUIDs) in the `POST /emails` payload:

```bash
curl -X POST $HAIL_API_URL/emails \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["alice@example.com"],
    "subject": "Invoice",
    "body_text": "See attached.",
    "recipient_consent": true,
    "attachment_ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
  }'
```

You can attach the same uploaded file to multiple sends without a new upload. CLI shortcut — upload and attach in one step:

```bash
hail email send --to alice@example.com --subject "Invoice" --body "See attached." --attach invoice.pdf
```

### Lifecycle

Hail garbage-collects unused uploads (not attached to any send) 24 hours after upload. After you attach the file to a send, Hail retains it indefinitely. You can reuse it across as many messages as you want.

## 9. What v1 does not do

Skip these until later milestones. This list names them so that you do not use SES features that are not wired yet:

- **Templates** — the API takes raw `body_text` / `body_html`. SES templates are a v2 request.
- **Cloud-agnostic inbound** — the SMTP listener is described in [SMTP inbound](./smtp-inbound.md); inbound currently runs on AWS only.

## 10. Inbound email

To receive mail at `<user>+<org>@<HAIL_MAIL_BASE_DOMAIN>`, you need four things:

1. An MX record on `mail.hail.so` that points at SES inbound.
2. An S3 bucket that SES can write raw MIME into.
3. A SES Receipt Rule that writes the object and invokes a Lambda.
4. A small Lambda that signs the SES event and POSTs it to Hail.

A Terragrunt wrapper at `infra/terragrunt.hcl` automates provisioning
around the bare Terraform module in `infra/terraform/`. The wrapper
configures an S3-backed remote state with a DynamoDB lock table. It
pulls every input from the repo's `.env` — no parallel `tfvars` file.

### 10.1 Terragrunt apply

```bash
# .env must contain at minimum: AWS_PROFILE, AWS_REGION,
# HAIL_TERRAFORM_STATE_BUCKET, HAIL_TERRAFORM_LOCK_TABLE, HAIL_API_URL,
# HAIL_INBOUND_HMAC_SECRET (generate: openssl rand -hex 32),
# HAIL_MAIL_BASE_DOMAIN.

cd infra
terragrunt init                # Terragrunt sources .env automatically via
terragrunt plan                # `run_cmd`; no manual export step.
terragrunt apply
```

Do a one-time bootstrap per AWS account before the first `terragrunt init`:
the state bucket + lock table do not auto-create. Refer to the comment block
at the top of [`infra/terragrunt.hcl`](https://github.com/hail-hq/hail/blob/main/infra/terragrunt.hcl) for
the AWS CLI one-liners. Refer to [operations](./operations.md) →
"Inbound email rollout → Stage 4" for the full sequence.

Outputs:

- `inbound_mx_record` — publish at DNS for `HAIL_MAIL_BASE_DOMAIN`.
- `inbound_bucket` — this is `${HAIL_MAIL_NAME_PREFIX}-mail`. Set `HAIL_MAIL_NAME_PREFIX` in the API `.env` to match the Terraform `name_prefix` var. The bucket name is not settable directly — there is no `HAIL_MAIL_BUCKET` var.
- `activate_command` — the `aws sesv2 set-active-receipt-rule-set ...` to run once.

The bare Terraform module at `infra/terraform/` is provider-vanilla. If
you prefer to skip Terragrunt, it still works with
`terraform apply -var=...`.

### 10.2 Activate the receipt rule set (manual)

SES has **one active receipt rule set per region per AWS account.** The module
creates the rule set but does **not** activate it. If an account already has
another rule set active, activation is destructive.

- **Greenfield AWS account**: run the `activate_command` output verbatim.
- **Account with existing rules**: import the existing rule set into Terraform
  state and merge Hail's rule into it. As an alternative, skip the module's rule
  resource and add Hail's rule manually via the AWS console.

### 10.3 Publish the MX record

At your DNS provider, publish what the Terraform output prints, for example:

```
mail.hail.so  MX  10  inbound-smtp.us-east-1.amazonaws.com
```

### 10.4 Configure Hail

In the API service `.env`:

```bash
HAIL_INBOUND_ENABLED=true
HAIL_MAIL_NAME_PREFIX=hail-inbound-prod         # matches Terraform `name_prefix`; bucket = ${prefix}-mail
HAIL_INBOUND_HMAC_SECRET=<same as Terraform var>
```

Restart `api`. Send a test mail to a hail-mail address and confirm:

```bash
curl "$HAIL_API_URL/emails?direction=inbound" \
  -H "Authorization: Bearer $HAIL_API_KEY"
```

Or, with the Python SDK:

```python
emails = await client.emails.list(direction="inbound")
```

If the API is down beyond the Lambda's async retries, failed deliveries land in
the `<name_prefix>-ingest-dlq` SQS queue (`ingest_dlq_url` terraform output). To
replay, re-drive each message's body at `POST /internal/ses-events`. The raw
MIME is still in S3.

## Delivery & engagement events

Outbound sends carry the SES configuration set named by
`HAIL_SES_CONFIGURATION_SET` (Terraform default: `hail-events`). SES
publishes Delivery / Bounce / Complaint / Reject / DeliveryDelay / Open /
Click events to SNS. The ingest Lambda relays them to
`POST /internal/ses-events`, which records them in `email_events`,
advances `emails.status`, and fans out webhooks.

Check a single email's timeline:

```bash
hail email events <email-id>
```

Account-level stats:

```bash
hail email stats --from 2026-06-01T00:00:00Z --bucket day
```

Notes:

- Open/Click tracking rewrites links through the default SES tracking
  domain. Hail does not yet support a custom tracking domain.
- Open counts are approximate (mail clients that proxy images inflate them).
- Hail acknowledges and drops events for mail sent outside Hail from the
  same SES account (`status: unmatched` in the API log).
- Hail re-sends forwarded inbound mail (refer to §10.5) as normal outbound.
  It carries the config set and writes a synthetic `sent` event, so it
  counts in `/emails/stats` like any other send.

### 10.5 Forwarding and webhooks

Tenants configure routing per `email_domains` row:

```bash
# Forward every inbound on the org's hail-mail address to a real inbox
curl -X PATCH $HAIL_API_URL/email-domains/$DOMAIN_ID \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inbound_enabled":true,"forward_to":["team@acme.com"]}'

# Or POST inbound events to a webhook URL — the response carries the secret once
curl -X PATCH $HAIL_API_URL/email-domains/$DOMAIN_ID \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -d '{"inbound_enabled":true,"webhook_url":"https://hooks.acme.com/hail"}'
```

For org-wide multi-event delivery (firehose pattern):

```bash
curl -X POST $HAIL_API_URL/webhooks \
  -H "Authorization: Bearer $HAIL_API_KEY" \
  -d '{"target_url":"https://hooks.acme.com/all","event_types":["email.received","email.bounced","email.complained"]}'
```

### 10.6 Webhook secrets at rest

Hail encrypts webhook signing secrets **at rest** with a deployment-scoped
[Fernet](https://cryptography.io/en/latest/fernet/) key
([`core/hailhq/core/secret_cipher.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/secret_cipher.py)).
The worker decrypts on each delivery, so deliveries survive API restarts and
work across multi-process deployments. If `HAIL_WEBHOOK_SECRET_KEY` is unset,
webhook creation returns `500`. Generate and set the key before you enable
webhooks:

```bash
# Generate a key (run once; store in .env, never commit). Run inside the
# project venv so the `cryptography` package is on PYTHONPATH.
uv run --directory core python -c "from hailhq.core.secret_cipher import generate_key; print(generate_key())"

# In .env:
HAIL_WEBHOOK_SECRET_KEY=<output above>
```

## Reference

- [SES sending limits](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas.html)
- [SESv2 API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/Welcome.html)
- [DKIM in SES](https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim.html)
- [Custom MAIL FROM](https://docs.aws.amazon.com/ses/latest/dg/mail-from.html)
- [DMARC overview](https://dmarc.org/overview/)
- OpenAPI: [`openapi/openapi.yaml`](https://github.com/hail-hq/hail/blob/main/openapi/openapi.yaml) → `/emails`, `/email-domains`, `/webhooks` tags
- Code paths: [`api/hailhq/api/routes/emails.py`](https://github.com/hail-hq/hail/blob/main/api/hailhq/api/routes/emails.py), [`api/hailhq/api/routes/email_domains.py`](https://github.com/hail-hq/hail/blob/main/api/hailhq/api/routes/email_domains.py), [`api/hailhq/api/routes/webhooks.py`](https://github.com/hail-hq/hail/blob/main/api/hailhq/api/routes/webhooks.py), [`core/hailhq/core/providers/email/ses.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/providers/email/ses.py)
- Inbound infra: [`infra/terraform/`](https://github.com/hail-hq/hail/blob/main/infra/terraform), [`infra/ses-ingest-lambda/`](https://github.com/hail-hq/hail/blob/main/infra/ses-ingest-lambda)
- Design spec: [`docs/superpowers/specs/2026-06-06-inbound-email-design.md`](https://github.com/hail-hq/hail/blob/main/docs/superpowers/specs/2026-06-06-inbound-email-design.md)


---

# LiveKit Cloud

LiveKit Cloud supplies the media (SIP bridge + WebRTC) in v1. A self-hosted SFU is a later milestone.

## 1. Project + keys

1. Sign up at [cloud.livekit.io](https://cloud.livekit.io).
2. Create a project.
3. From **Settings → Keys**, copy these values into `.env`:
   - `LIVEKIT_URL` — `wss://<project>-<region>.livekit.cloud`
   - `LIVEKIT_API_KEY`
   - `LIVEKIT_API_SECRET`

## 2. SIP outbound trunk

First create the Twilio trunk and credentials in the [Twilio guide](./twilio.md).
Then, in LiveKit Cloud:

1. Open **Telephony → SIP trunks → Create new trunk**.
2. Select **Outbound** and use the Twilio termination domain
   (`<name>.pstn.twilio.com`) as the address.
3. Add the Twilio number in E.164 format and enter the same username/password
   configured on the Twilio trunk.
4. Create the trunk and copy its ID into `.env` as
   `LIVEKIT_SIP_OUTBOUND_TRUNK_ID`.

Hail passes this ID to LiveKit for every outbound call. It does not read a
Twilio trunk-domain environment variable. See LiveKit's
[outbound trunk reference](https://docs.livekit.io/telephony/making-calls/outbound-trunk/)
for the current UI and JSON forms.

`LIVEKIT_SIP_INBOUND_TRUNK_ID` is reserved for a future inbound-calling
release and can remain empty today.

## 3. Voicebot worker

With the local Compose overlay, run:

```bash
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d voicebot
```

At startup, the worker registers with LiveKit as a dispatchable agent. The
Hail API dispatches it into a room for each call.

For the full flow, refer to [Architecture](../architecture.md).


---

# Operations runbook

This document is the single source of truth for how to develop, deploy, migrate, and release Hail. If you are an AI agent that starts work on this codebase, **read this document first** together with `CLAUDE.md`.

## Quick reference

| task                           | command                                                                                                                                   |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Bring up stack (bundled DB)    | `docker compose -f docker-compose.yml -f docker-compose.local.yml up -d`                                                                  |
| Bring up stack (managed DB)    | `docker compose up -d`                                                                                                                    |
| Bring up stack (prod VM)       | `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` — pulls images from GHCR; refer to the [VM guide](./vm-deploy.md) |
| Tail one service               | `docker compose logs -f <api\|voicebot\|mcp\|postgres>`                                                                                   |
| Run all tests                  | `cd <core\|api\|voicebot\|mcp\|sdk> && uv run pytest` (per suite, **from each dir**)                                                      |
| Lint                           | `uvx ruff check .` then `uvx black --check .` (repo root)                                                                                 |
| Apply DB migrations            | `docker compose run --rm api alembic upgrade head`                                                                                        |
| Regenerate OpenAPI + Go client | refer to _Development → Regenerating OpenAPI_ below                                                                                       |
| Publish SDK                    | tag `sdk-v<X.Y.Z>` and push (fires `release-sdk.yml`)                                                                                     |
| Publish CLI                    | tag `cli-v<X.Y.Z>` and push (fires `release-cli.yml`)                                                                                     |

## Local development

### Bringing up the stack

```bash
cp .env.example .env                                       # then fill in keys
pnpm install                                               # husky pre-commit hooks
docker compose \
  -f docker-compose.yml -f docker-compose.local.yml \
  up -d                                                    # postgres + minio + api + voicebot + mcp
docker compose run --rm api alembic upgrade head           # apply schema
# bind a phone number to the self-host sentinel (see first-run setup below)
```

### Per-service dev loops (host-side, no Docker)

```bash
cd api      && uv run uvicorn hailhq.api.main:app --reload --port 8080
cd voicebot && uv run python -m hailhq.voicebot.main start
cd mcp      && uv run uvicorn hailhq.mcp.server:app --reload --port 8081
cd cli      && go run . <args>
```

Before host-side development, export `.env` into the shell: `set -a; source .env; set +a`. Pydantic Settings reads `.env` into Settings attributes, but plugin SDKs read `os.environ` directly.

### Tests

CI runs each suite from its own directory. Match that locally:

```bash
cd core     && uv run pytest -v
cd api      && uv run pytest -v
cd voicebot && uv run pytest -v
cd mcp      && uv run pytest -v
cd sdk      && uv run pytest -v
cd cli      && go test ./... && go vet ./...
```

Python tests use **testcontainers/postgres** locally (this starts a Postgres container automatically). When the `DATABASE_URL` env var is set, the tests use it instead (this is the CI path).

### Lint + format

Pre-commit runs `ruff check --fix`, `black`, `gofmt -w`, and `prettier --write` on staged files via husky + lint-staged. To run the checks manually:

```bash
uvx ruff check .            # at repo root
uvx black --check .         # at repo root
cd cli && gofmt -l . && go vet ./...
```

### Regenerating OpenAPI + Go CLI client

When API routes change:

```bash
# 1. Boot the API (or just import the app) and dump the spec
cd api && uv run python -c "from hailhq.api.main import app; import sys, yaml; yaml.safe_dump(app.openapi(), sys.stdout, sort_keys=False)" > ../openapi/openapi.yaml

# 2. Regenerate the Go client (consumes openapi.yaml via a build-tagged
#    preprocessor that downgrades to OpenAPI 3.0.3 — oapi-codegen v2
#    doesn't yet parse 3.1's anyOf:[type,null] nullable idiom).
cd cli && make codegen
```

Commit `openapi/openapi.yaml` and `cli/internal/client/client.gen.go` together with the route change.

### Adding a new env var

1. Add the field to `core/hailhq/core/config.py` `Settings` class.
2. Add the env line to `.env.example` under the right provider section (provider-grouped convention).
3. If code consumes the value, reference `settings.<field>`. If a LiveKit plugin consumes the value implicitly through `os.environ`, the Settings declaration is **documentation only**. In that case, the runtime path is `docker compose env_file: .env`, which exports the value to the container.

### Adding a new provider adapter

Put new adapters under `core/hailhq/core/providers/<channel>/<name>.py`. Each adapter implements the adapter interface of its channel (for example, `VoiceProvider` in `providers/voice/base.py`). `api/` and `voicebot/` must **not** import provider SDKs directly. Go through `core`.

## Deployment (self-host)

### Required external accounts

- **Twilio**: account SID + auth token + a phone number with voice capability + an Elastic SIP Trunk (Origination URI → LiveKit's inbound, Termination → Twilio's PSTN).
- **LiveKit Cloud**: project + URL + API key + secret + an outbound SIP trunk (`LIVEKIT_SIP_OUTBOUND_TRUNK_ID`) + an inbound trunk (`LIVEKIT_SIP_INBOUND_TRUNK_ID`, reserved for v1.1).
- **Deepgram** (STT): API key. Required; used for semantic turn detection and as the fallback when Speechmatics is unavailable.
- **Speechmatics** (STT, optional): API key. Enables language-specific STT routing and end-of-utterance detection for 22 languages. Deepgram-only self-hosts keep working; if absent, calls fall back to Deepgram with VAD turn detection.
- **Cartesia** (primary TTS): API key + a voice ID from the Cartesia voice library.
- **ElevenLabs** (fallback TTS, optional): API key + a voice ID. If `ELEVEN_API_KEY` is set, the system uses it automatically when Cartesia fails.
- **At least one LLM provider**: OpenAI / Gemini / Anthropic API key. The voicebot's mode-A FallbackAdapter chains all three. Mode-B uses a caller-provided OpenAI-compatible endpoint for each call.

For detailed setup walkthroughs, see [Twilio](./twilio.md),
[LiveKit Cloud](./livekit-cloud.md), and [MCP](../mcp.md). To run the stack on a
single Ubuntu VM with HTTPS and automatic deployment from `main`, see the
[VM deployment guide](./vm-deploy.md).

### Authentication

Self-host and managed cloud share the same FastAPI binary, but the contents of the env decide the auth mode implicitly:

| Mode                    | Trigger                                                                | What hail/api checks                                                                                                                                                                              |
| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Self-host** (default) | Operator sets `HAIL_API_KEY` in `.env`                                 | Constant-time compare against the env var. All shared-key requests resolve to the sentinel `organization_id = 00000000-0000-0000-0000-000000000000` (the nil UUID) — no DB row, no member lookup. |
| **Managed cloud**       | The auth backend's `apikey` table is migrated into the shared Postgres | Hashes the bearer with `base64url(sha256())` and looks it up; resolves the org via `members.user_id = api_keys.reference_id → members.organization_id`.                                           |

Both modes can be active at the same time. If `HAIL_API_KEY` is set in managed cloud, it operates as a master/admin override that always works.

A managed-cloud user with no `member` row gets a **403 "user not provisioned"**, not a fabricated org. Provisioning is the responsibility of the website (through its `user.create.after` hook).

#### Self-host: first-run setup

```bash
# 1) Generate a shared API key — used for BOTH directions:
#    inbound (API checks bearer) + outbound (CLI/MCP/voicebot send it).
HAIL_API_KEY="hk_$(openssl rand -base64 32 | tr -d '/+=' | head -c 40)"
printf '%s\n' "$HAIL_API_KEY"
# Replace the blank HAIL_API_KEY= line in .env with this value.

# 2) Add a phone number bound to the self-host sentinel org id.
export TWILIO_E164='+1XXXXXXXXXX' TWILIO_PN_SID='PNxxxxxxxxxxxxxxxx'
docker compose exec postgres psql -U hail -d hail -c "INSERT INTO phone_numbers (organization_id, e164, country_code, number_type, capabilities, provider, provider_resource_id, provisioning_state, acquired_at) VALUES ('00000000-0000-0000-0000-000000000000', '${TWILIO_E164}', 'US', 'local', ARRAY['voice','sms'], 'twilio', '${TWILIO_PN_SID}', 'active', now());"
```

There is no recovery path for a lost key, so save the value of `HAIL_API_KEY`. Then run `export HAIL_API_KEY=…` in the shell that runs `hail`, or pass the key with `--api-key`.

> **`hail login` is managed-cloud only.** It runs the auth backend's device flow against `hail-website` and writes the resulting `hl_live_*` key to `~/.hail/credentials.json`. In self-host, there is no website to authorize against. Set `HAIL_API_KEY` directly, and you are done.

#### `hail auth` subcommands

For interactive sessions on a managed Hail deployment:

- `hail login` — runs the device-authorization flow and persists `~/.hail/credentials.json`.
- `hail auth logout` — deletes the local credentials file (idempotent).
- `hail auth token` — prints the bare API key. Use it in scripts as
  `export HAIL_API_KEY=$(hail auth token)`.

Self-hosters usually skip the device flow and set `HAIL_API_KEY`
directly, as the bootstrap section above shows.

#### Phone number pool

Pool numbers are unowned `phone_numbers` rows (`is_pool=TRUE`, `organization_id IS NULL`). An org without its own active number falls back to them on outbound calls. The claim is atomic (`SELECT … FOR UPDATE SKIP LOCKED`, in randomized order to spread carrier wear). Each number binds to one call at a time through `reserved_call_id`; the system releases it when the call ends. Implementation: `core/hailhq/core/pool.py`. The sweeper backstop window is `HAIL_POOL_RELEASE_GRACE_SECONDS`.

Add a Twilio number to the pool with `organization_id` NULL and `is_pool=TRUE` (the CHECK constraint enforces the pairing):

```bash
psql "$DATABASE_URL" -c "INSERT INTO phone_numbers (organization_id, e164, country_code, number_type, capabilities, provider, provider_resource_id, provisioning_state, is_pool, acquired_at) VALUES (NULL, '+1XXXXXXXXXX', 'US', 'local', ARRAY['voice','sms'], 'twilio', 'PNxxxxxxxxxxxxxxxx', 'active', TRUE, now());"
```

Attach the number to the same Twilio SIP trunk that you wired in [Twilio setup](./twilio.md). There is no per-number trunk routing. To grow the pool, repeat the INSERT with a different `e164` / `PN_SID`. To quarantine a bad pool number without deletion, run `UPDATE phone_numbers SET provisioning_state='failed' WHERE e164=...`. The claim query skips non-`active` rows.

Callers cannot address a pool number explicitly with the `from` field of `POST /calls`. The number is shared, so a caller that names one would cross tenants. The fallback fires only when an org has zero active numbers of its own.

#### Managed cloud

Run `hail login`. The CLI opens `/device` on the website. You approve, and the CLI exchanges the device-flow session for a long-lived `hl_live_*` key, which the auth backend mints into the `apikey` table. `hail/api` reads the same table, so keys minted in the console work everywhere (CLI, MCP, direct API calls).

### MCP modes

The MCP service (`hail/mcp`) picks one of two modes at boot from env:

| Mode           | Env                                                            | Behaviour                                                                                                                                                                                  |
| -------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **oauth-rs**   | `HAIL_AUTH_URL` + `MCP_RESOURCE_URL` set, `HAIL_API_KEY` empty | FastMCP rejects unauth requests with `401 WWW-Authenticate: Bearer resource_metadata=…`; tools forward each request's JWT to the API; `.well-known/oauth-protected-resource` is published. |
| **static-key** | `HAIL_API_KEY` set, `HAIL_AUTH_URL` empty                      | No inbound auth; tools use the singleton `HailClient(api_key=HAIL_API_KEY)`; no protected-resource route.                                                                                  |

If both are set, boot fails with `ambiguous MCP auth config`. If neither is set, boot fails with `MCP auth not configured`. The service decides the mode once; a restart is necessary to change it.

The MCP service does not validate JWT signatures. `hail/api` is the single source of JWT-validation truth (`HAIL_AUTH_URL`, `HAIL_AUTH_AUDIENCES`). MCP forwards the bearer on the outbound call. The API validates the token and resolves the org.

## Database migrations

Schema lives in `api/migrations/versions/`. Alembic config: `api/alembic.ini`. The `DATABASE_URL` env var overrides `sqlalchemy.url`.

```bash
# Apply all pending
docker compose run --rm api alembic upgrade head
# OR host-side (needs DATABASE_URL exported):
cd api && uv run alembic upgrade head

# Author a new migration (hand-written raw SQL via op.execute)
cd api && uv run alembic revision -m "add foo column"

# Revert one revision
cd api && uv run alembic downgrade -1
```

For v1, write migrations as raw SQL by hand (`env.py` has no SQLAlchemy `target_metadata` wired in). Switch to `--autogenerate` when models become the source of truth.

### Migration discipline

**The deploy workflow runs `alembic upgrade head` against prod on every push to `main`.** There is no human review gate between the merge of a migration and its execution. Three rules keep this safe. The workflow enforces one; the other two depend on you:

1. **`api/migrations/env.py` caps statement runtime at 120s and lock-acquisition at 5s** via `SET LOCAL statement_timeout` / `SET LOCAL lock_timeout`. It issues them inside the migration transaction, so they apply to every statement that the migration runs. The choice of `SET LOCAL` (not `PGOPTIONS` or session-level `SET`) is deliberate. Neon's `-pooler` endpoint and other PgBouncer transaction-pooled connections reject the libpq startup option and reset session state between transactions. The per-transaction form is the only one that works on all of pooled / unpooled / direct.

   The deploy log also prints `alembic current` and `alembic upgrade head --sql` before it applies the migration. Thus a problem migration is visible in CI output before it touches data.

   If a legitimate migration needs more time (a backfill, `CREATE INDEX CONCURRENTLY`), bypass the guard: put its own `SET LOCAL statement_timeout = '<bigger>'` at the top of the migration body. Make the change explicit for that migration; do not raise the global cap. Bigger caps on every migration let a runaway migration use more wall-clock time before it aborts.

2. **Split all destructive shape changes into expand → backfill → contract across separate releases.** A column drop, a type narrowing, or a `NOT NULL` on existing data must not go in the same release as the code that depends on the new shape. The old containers still run when the migration starts. For example:

   | bad: one release                                | good: three releases                                                                                                            |
   | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
   | drop `phone_numbers.legacy_field` + remove code | release 1: stop writing `legacy_field` (still readable) → release 2: backfill / verify → release 3: `ALTER TABLE … DROP COLUMN` |
   | rename `calls.requested_at` → `calls.queued_at` | release 1: add new column, dual-write → release 2: backfill, switch reads → release 3: drop old column                          |

3. **Never edit, renumber, or remove a migration that prod has applied.** Author a new revision instead. Treat each revision at or below the previous deploy's `alembic current` as immutable. A renumber or history rewrite of a deployed migration is the same footgun as an edit. Prod's `alembic_version` still holds the old revision id. The next `alembic upgrade head` then cannot locate it (`Can't locate revision '<id>'`), or it silently skips the real DDL and collides downstream (`relation "…" already exists`).

   CI never catches this, because test fixtures build the schema via `Base.metadata.create_all`, not alembic. Recovery is manual. Apply the migrations that never ran on prod, then reconcile the pointer with `alembic stamp <head>` (or `UPDATE alembic_version`) once the schema matches. Refer to the Footguns table.

The expand/contract rule is the one that the workflow cannot enforce. If you break it, auto-deploy releases the footgun. The rollback is then "revert the code, write a new migration to restore the column, re-deploy" — hours of incident, not minutes.

### Cross-migration table ownership

Two services migrate the same Postgres database independently:

| Owner            | Migration tool                                                  | Tables                                                                                                                       |
| ---------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **hail-website** | `pnpm dlx @better-auth/cli migrate` (introspects `lib/auth.ts`) | `users`, `accounts`, `sessions`, `verifications`, `device_codes`, `api_keys`, `organizations`, `members`, `invitations`      |
| **hail/api**     | alembic                                                         | `account_credits`, `usage_events`, `phone_numbers`, `conversations`, `calls`, `call_events`, `idempotency_keys`, `audit_log` |

The schema source of truth for the website is `lib/auth.ts`. After each change to that file, run `pnpm dlx @better-auth/cli generate -y` to emit the resulting SQL into `better-auth_migrations/`, and commit it (audit trail). Then run `pnpm dlx @better-auth/cli migrate -y` to apply the diff against the target DB. Both subcommands introspect the auth config. `generate` writes the SQL file; `migrate` runs the same diff against the DB.

Columns in hail/api-owned tables that reference a website-owned table — `organization_id` everywhere, `audit_log.api_key_id` — carry **no foreign-key constraint**. This is deliberate.

**Why no FK:** the CLI regenerates the website schema from a TypeScript config on every relevant version bump. If alembic held a hard FK into `organizations(id)`, each rename or shape change on the website side would need a coordinated alembic migration in the same release, or it would break the next `alembic upgrade head`. Cross-tool referential integrity is a coordination tax we do not want to pay on every dependency bump.

**What we trade off:**

- No `ON DELETE CASCADE` from the auth side. If you delete an `organization` row in Postgres, orphaned rows stay in `account_credits`, `calls`, etc. v1 does not hard-delete orgs. If you start to do so, write a sweep query or a soft-delete.
- No DB-level guarantee that `organization_id` points at a real row. The auth flow in `api/hailhq/api/deps.py` validates that the org exists on every authenticated request, so application-layer integrity holds for the live path. Bulk inserts from migrations or fixtures must keep this integrity themselves.

**When to break the rule:** if you add a new hail/api-owned table that joins to another hail/api-owned table (for example, `call_events.call_id → calls.id`), keep the FK. Both ends are alembic-owned, so the constraint is safe.

### Shared-key sentinel

Shared-key (`HAIL_API_KEY`) requests resolve to `organization_id = 00000000-0000-0000-0000-000000000000` (the nil UUID) — a sentinel, not a real row. Nothing seeds it; nothing reads from `organizations` for that path. Self-host operators can attach `phone_numbers`, `account_credits`, etc. to the sentinel. To do so, pass the nil UUID as the org id directly (refer to _Self-host: first-run setup_ above for the phone-number example).

### Switching the database

Compose comes as two files. `docker-compose.yml` is the deployable base and assumes that `DATABASE_URL` reaches a Postgres you bring. `docker-compose.local.yml` is a thin overlay that adds a bundled `postgres` container and merges a `depends_on: postgres` into `api` and `voicebot`. Pick a mode:

**Bundled local Postgres** — layer both files:

```bash
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
```

`.env` must keep the default `DATABASE_URL=postgresql://hail:hail@postgres:5432/hail` (the in-network compose hostname).

**Reset the bundled DB** (this removes all data for a fresh start):

```bash
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v   # -v removes volumes
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d postgres
docker compose -f docker-compose.yml -f docker-compose.local.yml run --rm api alembic upgrade head
# re-seed (Phase above)
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
```

**Managed Postgres (Neon / Supabase / RDS / …)** — use only the base file:

```bash
# 1. Provision the DB; grab the postgres URL (sslmode=require for most hosted providers)
# 2. Edit .env:
#      DATABASE_URL=postgresql://USER:PASSWORD@HOST/DBNAME?sslmode=require
#    Comment out the bundled-local line.
# 3. Apply migrations
docker compose run --rm api alembic upgrade head
# 4. Re-seed (same SQL as above; replace `docker compose exec -T postgres
#    psql -U hail -d hail -c` with `psql "$DATABASE_URL" -c`)
# 5. Bring up the stack — no `-f docker-compose.local.yml`, so no postgres
#    container is started:
docker compose up -d
```

The migration `0001_initial.py` issues `CREATE EXTENSION IF NOT EXISTS pgcrypto;`. If a hosted provider gates extensions, allow `pgcrypto` on it.

## Releases

Tag conventions:

| tag prefix     | what fires                          | what it produces                                                                                                                                                |
| -------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sdk-v<X.Y.Z>` | `.github/workflows/release-sdk.yml` | `hail-sdk` on PyPI (trusted publishing — no token)                                                                                                              |
| `cli-v<X.Y.Z>` | `.github/workflows/release-cli.yml` | GoReleaser → multi-arch binaries on GitHub Releases + Homebrew formula push to `hail-hq/homebrew-tap`                                                           |
| `v<X.Y.Z>`     | nothing directly                    | **owned by the CLI release.** GoReleaser strips the `cli-` prefix, so `cli-v<X.Y.Z>` publishes its release page under `v<X.Y.Z>`. Never tag `v<X.Y.Z>` by hand. |

There is no umbrella release tag. `CHANGELOG.md` is the record of what each repo
version contains; the merge commit is the pointer. A hand-made `v<X.Y.Z>` tag
collides with the CLI's release namespace — it did once (2026-08-13), and the CLI
release then published its binaries onto a page whose tag pointed at older code.
`release-cli.yml` now fails loudly if that tag already exists somewhere else.

Hail does **not** release the service images (`hail-api`, `hail-voicebot`, `hail-mcp`) as versioned artifacts. The deploy workflow (`.github/workflows/deploy.yml`) pushes `latest` and `sha-<commit>` tags to GHCR on each push to `main` — the production VM pulls these. Self-hosters build from source via `docker compose up`. Versioned image releases are on the v1.x list.

### Releasing the SDK (Python)

```bash
# 1. Bump version in sdk/pyproject.toml (e.g., 0.1.0 → 0.2.0)
# 2. Commit
# 3. Tag and push
git tag sdk-v0.2.0
git push origin sdk-v0.2.0
# 4. Watch https://github.com/hail-hq/hail/actions
```

Pre-flight (one-time): configure a PyPI Trusted Publisher for `hail-sdk` that points at the `hail-hq/hail` repo + the `release-sdk.yml` workflow. After the project exists on PyPI, promote the _Pending_ publisher to a normal one (PyPI web UI).

### Releasing the CLI (Go)

```bash
# 1. Commit any CLI / config changes
# 2. Tag and push
git tag cli-v0.2.0
git push origin cli-v0.2.0
# 3. Watch the workflow; on green:
brew update && brew upgrade hail-hq/tap/hail
```

Required secret: `HOMEBREW_TAP_TOKEN` — a fine-grained PAT with **Contents: read+write** on `hail-hq/homebrew-tap`. Set it under repo Settings → Secrets → Actions. Without it, the binary build + GitHub Release succeed, but the formula push fails.

GoReleaser quirks to know:

- OSS GoReleaser does not have the Pro `monorepo:` block. The workflow strips the `cli-` prefix into the `GORELEASER_CURRENT_TAG` / `GORELEASER_PREVIOUS_TAG` env vars. It also passes `--skip=validate`, so GoReleaser does not reject the env-var-overridden tag. The manual `git rev-parse` and `git diff --exit-code HEAD` steps before GoReleaser keep the validate checks that _do_ matter.
- The snapshot version template is the literal `0.0.0-snapshot-{{ .ShortCommit }}` (not `incpatch`), because the repo carries non-semver tags like `sdk-v0.0.1` that confuse the parser.

### Cutting a repo release

A repo release is a CHANGELOG section, not a tag. Only components carry tags.

```bash
# 1. Move CHANGELOG.md's [Unreleased] block under the new version heading
# 2. Tick newly-shipped milestones in README.md
# 3. Bump sdk/pyproject.toml + sdk/hail/__init__.py if the SDK ships, then `uv lock`
# 4. Commit as `chore(release): <X.Y.Z> — <summary>`, merge to main
# 5. Tag the components that actually changed
git tag sdk-v0.15.0 cli-v0.20.0
git push origin sdk-v0.15.0 cli-v0.20.0
```

Do **not** tag a bare `v<X.Y.Z>`. That name belongs to the CLI release: GoReleaser
strips the `cli-` prefix, so `cli-v0.20.0` publishes its page under `v0.20.0`. A
hand-made `v<X.Y.Z>` makes the next CLI release upload its binaries onto whatever
commit that tag already points at. `release-cli.yml` fails the release when it
detects this.

The CLI release page carries the CHANGELOG prose for the matching repo version, so
edit its body after GoReleaser publishes.

## Conventions

- **Commits**: [Conventional Commits](https://www.conventionalcommits.org). `feat(scope): …`, `fix(scope): …`, `chore: …`, `docs(scope): …`, etc.
- **Markdown**: GitHub-flavored only. Use the binary task-list states `[ ]` / `[x]`; do not use the non-GFM `[~]` / `[-]`.
- **Python namespaces**:
  - Internal monorepo: `hailhq.*` (PEP 420 implicit namespace; no `hailhq/__init__.py` at the namespace root).
  - External SDK: `hail` — published as `hail-sdk` on PyPI, imports as `import hail`. Standalone — does **not** depend on any `hailhq.*` package.
- **Provider model IDs**: live only in `.env.example`; `Settings` fields default to empty strings. Do not set `Settings.<provider>_model = "literal"` — that is wrong.
- **Tag prefix grammar**: `<package>-v<semver>` for component releases (`sdk-v…`, `cli-v…`). Bare `v<semver>` is not a tag anyone creates by hand — GoReleaser mints it from `cli-v<semver>`.
- **No Opero references** in any committed file.

## Footguns (every one of these has bitten us)

| symptom                                                                                                     | root cause + fix                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Deploy dies mid-pull with `no space left on device`                                                         | Each deploy pulls `:latest` **and** `sha-<commit>` for three services; the `sha-` tags are tagged images, so a dangling-only prune never reclaims them. `deploy.yml` now runs `docker image prune -af --filter "until=336h"` after the rollout. To recover a full VM by hand, run that prune plus `docker builder prune -af`, then re-run the failed deploy.                                                                             |
| `voicebot` container exits showing the typer help menu                                                      | The CMD lacks the `start` subcommand. The Dockerfile fixes this; if you fork it, keep `["python", "-m", "hailhq.voicebot.main", "start"]`.                                                                                                                                                                                                                                                                                               |
| `ModuleNotFoundError: No module named 'hailhq'` after a Docker build                                        | Hatchling wheel config used `packages = ["hailhq/<service>"]` — strips the `hailhq/` prefix. Must be `packages = ["hailhq"]`.                                                                                                                                                                                                                                                                                                            |
| `exec /opt/venv/bin/uvicorn: no such file or directory`                                                     | Renaming `/app/.venv` → `/opt/venv` between builder and runtime breaks shebangs. Keep the same path in both stages (we use `/app/.venv`).                                                                                                                                                                                                                                                                                                |
| `RuntimeError: no running event loop` from `aiohttp.ClientSession()`                                        | A FastAPI dep that constructs LiveKit/aiohttp must be `async def`. Sync deps run in a threadpool worker thread with no loop.                                                                                                                                                                                                                                                                                                             |
| `google.auth.exceptions.DefaultCredentialsError: File  was not found.`                                      | `GOOGLE_GENAI_USE_VERTEXAI=true` with empty `GOOGLE_APPLICATION_CREDENTIALS`. Default is `false`; opt into Vertex by flipping it AND providing creds.                                                                                                                                                                                                                                                                                    |
| `failed to parse tag 'cli-v0.1.0' as semver` from GoReleaser                                                | OSS GoReleaser does not handle tag prefixes. The workflow's `Compute GoReleaser current/previous tags` step + `--skip=validate` flag handle it; do not remove either.                                                                                                                                                                                                                                                                    |
| `hailhq-core` references a workspace member, but is not one                                                 | Docker build context lacks the repo-root pyproject. The Dockerfile writes a minimal `/app/pyproject.toml` (workspace stub) inline before `uv sync`.                                                                                                                                                                                                                                                                                      |
| `pytest` from repo root fails with `ImportPathMismatchError`                                                | Run each suite from its own directory. CI does this; replicate locally.                                                                                                                                                                                                                                                                                                                                                                  |
| First `pip install hail-sdk` from a venv with `hailhq.*` already installed shadows imports                  | The SDK is standalone by design. If you mix it with internal packages in the same venv, the `hail` package wins for `import hail` (intended); do not co-install for production.                                                                                                                                                                                                                                                          |
| Deploy fails at `alembic upgrade head` with `Can't locate revision 'NNNN'` or `relation "…" already exists` | A branch reshuffle / history rewrite renumbered or removed a migration that prod already applied, so prod's `alembic_version` diverges from the code (refer to Migration discipline rule 3). Recover by hand: apply only the migrations that never ran on prod, then `UPDATE alembic_version SET version_num='<head>'` (or `alembic stamp <head>`) once the schema matches — do **not** re-run `upgrade head`, it re-hits the collision. |

## Inbound email rollout

This section records the end-to-end deployment of the inbound-email
milestone (umbrella `v0.5.0`, `sdk-v0.3.0`, `cli-v0.5.0`). It covers
five DB stages + infra + tag-driven component releases. Stage 1 needs
a brief maintenance window (the table rename is not online-safe).
Everything else is online.

### Full deployment order (copy-paste cheat-sheet)

```bash
# ── 0. Preflight on main ───────────────────────────────────────────────────
git checkout main && git pull --ff-only origin main
git log -1 --oneline                                  # confirm merge SHA

# ── 1. Stage-1 cutover: stop API, migrate, deploy new image ────────────────
docker compose stop api
docker compose run --rm api alembic upgrade 0006      # rename (DOWNTIME)
docker compose up -d api                              # new image w/ new code
hail email domain list                                # 200 smoke

# ── 2. Stage-2 + Stage-3 migrations (online) ───────────────────────────────
docker compose run --rm api alembic upgrade 0007      # inbound columns + email_attachments
docker compose run --rm api alembic upgrade 0008      # webhook tables
docker compose run --rm api alembic current           # → 0008 (head)

# ── 3. Provision AWS infra (Terragrunt reads .env directly) ───────────────
cd infra
terragrunt init
terragrunt plan
terragrunt apply
# Capture outputs: inbound_mx_record, inbound_bucket, activate_command, lambda_function_arn
#   (inbound_bucket confirms ${HAIL_MAIL_NAME_PREFIX}-mail — not independently settable)
#
# First time only: pre-create the state bucket + DynamoDB lock table once
# per AWS account — terragrunt can't bootstrap them. See the comment
# block at the top of infra/terragrunt.hcl for the AWS CLI one-liners.

# ── 4. Activate the SES Receipt Rule Set (manual; one-time per region) ─────
aws sesv2 set-active-receipt-rule-set --rule-set-name hail-inbound-prod-rules
#  ↑ ONLY safe on accounts with no other active rule set; see Stage 4 below.

# ── 5. Publish the MX record (manual at your DNS provider) ─────────────────
#  e.g. mail.hail.so  MX  10  inbound-smtp.us-east-1.amazonaws.com
dig MX mail.hail.so                                   # wait for propagation

# ── 6. Flip the inbound flag in API .env, restart ──────────────────────────
# Add: HAIL_INBOUND_ENABLED=true
#      HAIL_MAIL_NAME_PREFIX=<terraform var name_prefix — same value, bucket derives as ${prefix}-mail>
#      HAIL_INBOUND_HMAC_SECRET=<same as tfvars>
#      HAIL_WEBHOOK_SECRET_KEY=$(uv run --directory core python -c "from hailhq.core.secret_cipher import generate_key; print(generate_key())")
docker compose up -d api                              # picks up new env

# ── 7. Release SDK 0.3.0 (fires release-sdk.yml → PyPI) ────────────────────
git tag -a sdk-v0.3.0 -m "SDK 0.3.0 — inbound email + webhooks"
git push origin sdk-v0.3.0

# ── 8. Release CLI 0.5.0 (fires release-cli.yml → GitHub Releases + brew) ──
git tag -a cli-v0.5.0 -m "CLI 0.5.0 — hail email list/get; webhooks; domain rename"
git push origin cli-v0.5.0

# ── 9. Umbrella tag marker (no workflow fires) ─────────────────────────────
git tag -a v0.5.0 -m "Hail 0.5.0 — inbound email milestone"
git push origin v0.5.0
gh release create v0.5.0 --notes-file CHANGELOG.md    # optional release page

# ── 10. Smoke test (see "Smoke test sequence" subsection below) ────────────
hail email domain list
dig MX mail.hail.so
# … send a test mail, expect status=received within 30s
hail email list --direction inbound
```

**Decoupling note.** You can release steps 1–6 days before steps 7–9.
Until the flag in step 6 flips, the system is outbound-only, and the
new CLI and SDK are not yet public. If something looks wrong after
step 6, roll back: set `HAIL_INBOUND_ENABLED=false` and restart. No
migration revert is necessary; no tag re-spin is necessary.

Detailed nuance per stage follows.

### Stage 1 — schema rename + new code (coordinated cutover, ~30s downtime)

Migration `0006` renames `sender_domains` → `email_domains`. The new
app code references only the new name, so you must release the rename
and the deploy together. Each incremental path adds view-shim
complexity for marginal gain.

```bash
# Take the API offline.
docker compose stop api

# Apply 0006 only.
DATABASE_URL=$DATABASE_URL uv run --directory api alembic upgrade 0006

# Deploy the new image and start.
docker compose up -d api

# Smoke: should 200, list returns same rows under the new name.
hail email domain list
```

Rollback: `alembic downgrade 0005` reverses the rename. The new code
breaks against the old name, so this works only if you also roll back
the image.

### Stage 2 — additive inbound schema (online, anytime after stage 1)

Migration `0007` is purely additive: nullable columns on `emails`, a new
`email_attachments` table, and action columns on `email_domains`. Old and
new code both run against it cleanly.

```bash
DATABASE_URL=$DATABASE_URL uv run --directory api alembic upgrade 0007
```

**Footgun on large tables.** If `emails` has more than a few million
rows, edit the migration to `CREATE INDEX CONCURRENTLY` via
`op.execute` before you apply it. `emails_inbound_message_id_uq` is a
partial unique index whose `WHERE` is false for every existing row,
but Postgres still scans the whole table to build it. Alembic's
`create_index` runs in a transaction, which blocks the concurrent
flag.

### Stage 3 — webhook tables (online, anytime after stage 2)

Migration `0008` adds `webhook_subscriptions` + `webhook_deliveries`.
Pure new tables, no locks on existing tables.

```bash
DATABASE_URL=$DATABASE_URL uv run --directory api alembic upgrade 0008
```

At this point, the API is ready to receive inbound mail and fire
webhooks. But nothing is wired up yet on the AWS side, and the inbound
flag is off, so the system stays effectively outbound-only.

### Stage 4 — provision AWS infrastructure

The bare Terraform module under `infra/terraform/` provisions S3, the SES
Receipt Rule + Rule Set, the Lambda, and IAM. A Terragrunt wrapper at
`infra/terragrunt.hcl` adds an S3 remote backend with a DynamoDB lock
table, and pulls every variable from the repo's `.env`, so there is no
parallel `tfvars` file to keep in sync.

**One-time bootstrap** per AWS account. Terragrunt's state backend
cannot bootstrap itself. Create the bucket + lock table once with the
AWS CLI. The AWS CLI does not read `.env` like Terragrunt does, so
this one step needs the env exported into your shell. Note: the region
of the state backend is `HAIL_TERRAFORM_STATE_REGION` — often shared
across deployments and **separate from `AWS_REGION`** (where the SES +
Lambda + raw-MIME bucket get provisioned). It falls back to
`AWS_REGION` when unset.

```bash
set -a; source .env; set +a   # exports AWS_PROFILE, AWS_REGION, HAIL_TERRAFORM_*
STATE_REGION=${HAIL_TERRAFORM_STATE_REGION:-$AWS_REGION}

aws --profile $AWS_PROFILE s3api create-bucket \
    --bucket $HAIL_TERRAFORM_STATE_BUCKET \
    --region $STATE_REGION
aws --profile $AWS_PROFILE s3api put-bucket-versioning \
    --bucket $HAIL_TERRAFORM_STATE_BUCKET \
    --versioning-configuration Status=Enabled
aws --profile $AWS_PROFILE dynamodb create-table \
    --table-name $HAIL_TERRAFORM_LOCK_TABLE \
    --attribute-definitions AttributeName=LockID,AttributeType=S \
    --key-schema AttributeName=LockID,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST \
    --region $STATE_REGION
```

**Each deploy.** Generate (or rotate) the shared HMAC secret in `.env`
before the deploy. The API uses the same value to verify Lambda
POSTs:

```bash
# In .env:
#   HAIL_INBOUND_HMAC_SECRET=<openssl rand -hex 32 output>
#   HAIL_API_URL=https://api.hail.so          # the URL the Lambda calls

cd infra
terragrunt init                # Terragrunt reads .env via run_cmd internally;
terragrunt plan                # no manual `source .env` needed here.
terragrunt apply
```

Outputs to capture:

- `inbound_mx_record` — publish at DNS for `HAIL_MAIL_BASE_DOMAIN`.
- `inbound_bucket` — confirms `${HAIL_MAIL_NAME_PREFIX}-mail`; set `HAIL_MAIL_NAME_PREFIX` (not `inbound_bucket` itself) on the API.
- `activate_command` — the `aws sesv2 set-active-receipt-rule-set ...`
  to run after apply.

**Plain Terraform alternative.** The bare module under `infra/terraform/`
still works with `terraform apply -var ...` if you prefer to skip
Terragrunt. The Terragrunt wrapper is opinionated about remote state +
`.env`-driven inputs; the underlying module is provider-vanilla.

**Manual step: activate the receipt rule set.** Terraform creates the
rule set but does not activate it (SES allows one active rule set per
region per account, and activation is destructive against any
existing one):

```bash
# Greenfield AWS account:
aws sesv2 set-active-receipt-rule-set --rule-set-name hail-inbound-prod-rules

# Account with existing receipt rules: import the existing rule set
# into Terraform state, merge Hail's rule into it via the AWS console,
# or skip the module's aws_ses_receipt_rule resource entirely.
```

**Manual step: publish the MX record.** Publish the value that the
`inbound_mx_record` output prints, for example:

```
mail.hail.so  MX  10  inbound-smtp.us-east-1.amazonaws.com
```

DNS propagation usually takes minutes. SES does not deliver until the
record is live.

### Stage 5 — flip the inbound flag

Add to the API service `.env`:

```bash
HAIL_INBOUND_ENABLED=true
HAIL_MAIL_NAME_PREFIX=<terraform var name_prefix>
HAIL_INBOUND_HMAC_SECRET=<same value Terraform got>
HAIL_WEBHOOK_SECRET_KEY=<generate with `uv run --directory core python -c "from hailhq.core.secret_cipher import generate_key; print(generate_key())"`>
```

Restart the API. Until this step, `POST /internal/ses-events` returns
503 even if the Lambda is wired. Thus steps 1–4 can land days before
step 5, and you keep a free rollback window (no migration revert is
necessary to disable inbound).

### Smoke test sequence

After stage 5, in order:

1. **Routing exists.** Pick an org you control. From the console or
   the API, verify that the hail-mail row exists and `inbound_enabled=true`:

   ```bash
   hail email domain list
   ```

2. **MX is live.** From any host with `dig`:

   ```bash
   dig MX mail.hail.so
   # Should answer with inbound-smtp.<region>.amazonaws.com
   ```

3. **Round-trip a test mail.** Send to the org's hail-mail address
   from any external account. Within 30s:

   ```bash
   hail email list --direction inbound
   # Should show the new row with status=received.
   ```

4. **Raw + attachment access.** If the test mail had attachments,
   the listing must include their metadata. This fetch:

   ```bash
   hail email get <id> | jq .raw_url
   curl -L -H "Authorization: Bearer $HAIL_API_KEY" "$RAW_URL" > out.eml
   ```

   must download the raw MIME bytes.

5. **Forwarding** (only if `forward_to` is configured on the domain):
   the forward target must receive a copy from
   `forwarder+<org>@mail.hail.so` with the original sender in
   `Reply-To:`. Check `hail email list --direction outbound`. A
   row with `metadata.forwarded_from = <inbound id>` must show
   `status=sent`.

6. **Webhook delivery** (only if a webhook is configured): the
   target must receive a signed POST. Verify that the signature header
   parses, that the body shape matches the documented event, and that
   the delivery row reaches `status=succeeded`:

   ```bash
   curl "$HAIL_API_URL/webhooks/<subscription-id>/deliveries" \
     -H "Authorization: Bearer $HAIL_API_KEY"
   ```

7. **Bounce/complaint plumbing.** To send a test bounce, address a
   mail to `bounce@simulator.amazonses.com` from the verified sender.
   SES generates a bounce notification. The matching outbound row
   must change to `status=bounced` in a few seconds.

8. **Rate cap.** Set `email_domains.forward_rate_per_hour=1`, then
   send two inbounds with forwarding configured. The second one's
   `suppressed_reasons` must include `forward_rate_limit`.

### What can go wrong (failure modes we have seen first-hand or modeled)

- **DNS not propagated.** SES silently drops mail to a domain whose
  MX is wrong. `dig MX` is the first diagnostic.
- **Receipt rule set not activated.** SES accepts mail but does not
  route it; no S3 object appears. Check `aws sesv2
describe-active-receipt-rule-set` again.
- **SES sandbox.** New SES accounts accept mail only from verified
  addresses. During sandbox, the mail's `From:` must be on a verified
  SES identity.
- **Private webhook target rejected.** `httpx_post` blocks RFC-1918
  addresses by default. Self-hosters with internal webhook targets
  set `HAIL_WEBHOOK_ALLOW_PRIVATE_NETWORKS=true`.
- **Lambda → API connection refused.** Check the Lambda's
  CloudWatch logs (`/aws/lambda/hail-inbound-prod-ingest`). The
  POST URL must be reachable from AWS over the public internet.
- **Forward loops.** The system rejects a forward to a target on
  `HAIL_MAIL_BASE_DOMAIN`. If a target's auto-responder replies to
  the forwarder, the 3-hop counter catches the loop. If it does
  not, raise `HAIL_FORWARD_MAX_HOPS`, or blacklist the target:
  clear `forward_to`.

## SMS abuse monitor & channel suspensions

All orgs share one Hail-owned A2P 10DLC campaign, so one org's high opt-out
rate can cause carriers to throttle the whole platform. `AbuseMonitorWorker`
(hourly by default; `HAIL_ABUSE_MONITOR_POLL_SECONDS`) computes each org's
rolling opt-out rate. Past the threshold
(`HAIL_SMS_ABUSE_*` — window, min sends, max rate), it inserts a
`channel_suspensions` row. `check_sms_allowed` then blocks that org's
outbound SMS. The thresholds are unvalidated starting guesses. Expect to
tune them when real traffic arrives.

**You must lift a suspension manually for now — there is no CLI/route/expiry.**
An auto-suspended org stays blocked until an operator runs raw SQL. Inspect
and lift:

```bash
# See who is suspended and why
psql "$DATABASE_URL" -c \
  "SELECT organization_id, channel, reason, suspended_at FROM channel_suspensions;"

# Lift one org's SMS suspension (re-enables outbound immediately)
psql "$DATABASE_URL" -c \
  "DELETE FROM channel_suspensions WHERE organization_id = '<org-uuid>' AND channel = 'sms';"
```

Before you lift a suspension, confirm that the opt-out spike was benign (a
burst of legitimate STOPs, not abuse). Otherwise, the next hourly tick
suspends the org again. There is no cooldown/backoff yet, so a
persistently-abusive org trips again on the next run. If you see false
positives, tune `HAIL_SMS_ABUSE_MAX_OPT_OUT_RATE` up.

## Carry-forwards / open work

(Refer to `CHANGELOG.md` "Deferred to v1.x" for the canonical list.)

- LiveKit Egress recording → S3 (currently `recording.py` returns `None`)
- `idempotency_keys` GC sweeper
- Inbound calls (`LIVEKIT_SIP_INBOUND_TRUNK_ID` reserved)
- SMTP-listener inbound email provider (`SmtpInboundProvider` is a stub)
- `hail bootstrap` admin CLI (closes the manual DB-seed step above)
- CallEvent dedupe across voicebot redispatch
- **Un-suspend tooling for `channel_suspensions`** — auto-suspend has no
  reverse path (refer to "SMS abuse monitor" above); add a `hail sms suspensions
lift <org>` command / operator route and/or an automatic cooldown column
  so recovery is not raw SQL.


---

# SMTP inbound — not yet implemented

The `SmtpInboundProvider` interface exists in
[`core/hailhq/core/providers/email/inbound/smtp.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/providers/email/inbound/smtp.py)
but is not implemented. It is the cloud-agnostic / OSS-only path.
We defer it to a follow-up milestone.

When it is released, this page will describe:

- the `mailbot/` container (`aiosmtpd`-backed), parallel to `voicebot/`
- listen ports + TLS configuration
- the "front me with Maddy or Postfix" production recipe for SPF / DKIM /
  DMARC verification and flood resistance
- self-host quickstart

Until then, use the SES-backed inbound path documented in
[AWS SES](./aws-ses.md). If you must avoid AWS, file an
issue that tracks your need. Then we can give the SMTP listener priority.

## References

- Design spec: [`docs/superpowers/specs/2026-06-06-inbound-email-design.md`](https://github.com/hail-hq/hail/blob/main/docs/superpowers/specs/2026-06-06-inbound-email-design.md) §2
- Provider interface: [`core/hailhq/core/providers/email/inbound/base.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/providers/email/inbound/base.py)
- Placeholder stub: [`core/hailhq/core/providers/email/inbound/smtp.py`](https://github.com/hail-hq/hail/blob/main/core/hailhq/core/providers/email/inbound/smtp.py)


---

# Twilio

You need a Twilio account, a phone number, and a SIP trunk that bridges to LiveKit Cloud.

## 1. Account credentials

From [console.twilio.com](https://console.twilio.com), copy these values:

- `TWILIO_ACCOUNT_SID` — starts with `AC…`
- `TWILIO_AUTH_TOKEN` — click "Show" to see the value

Put them in `.env`.

## 2. Phone number

Go to **Phone Numbers → Buy a number**. Select a number with the Voice capability. For outbound SMS, select a number that also has the SMS capability. The carrier fixes these capabilities when you buy the number; you cannot add SMS to a voice-only number later. Note the E.164 format (`+1…`).

## 3. SIP trunk

1. **Elastic SIP Trunking → Trunks → Create new Trunk**.
2. Under **Termination**, choose a unique termination domain such as
   `your-trunk.pstn.twilio.com`.
3. Add a credential list with a SIP username and password. LiveKit's outbound
   trunk must use the same credentials.
4. Under **Numbers**, attach the phone number from step 2.
5. Continue with [LiveKit Cloud setup](./livekit-cloud.md), using the Twilio
   termination domain and credentials to create a LiveKit outbound trunk.

Do not add the termination domain to Hail's `.env`: Hail uses the resulting
LiveKit trunk ID (`LIVEKIT_SIP_OUTBOUND_TRUNK_ID`) at runtime. Twilio calls
traffic from LiveKit to the PSTN “termination.” Its “origination” settings are
for inbound PSTN calls, which Hail does not yet support.

Twilio requires outbound destinations and caller IDs in E.164 format. Trial
accounts can call only verified destination numbers. See Twilio's
[Elastic SIP Trunking reference](https://www.twilio.com/docs/sip-trunking).

## 4. Outbound SMS

Hail sends SMS from a dedicated number that you enable for messaging.

1. **Acquire an SMS-capable number** (step 2 above), or pick one you already
   hold. Only numbers with the SMS capability can send.
2. **Enable SMS on the number.** Call `POST /numbers/{id}/enable-sms`. Hail
   attaches the number to your organization's Twilio Messaging Service and
   creates that service the first time. There is one Messaging Service per
   organization; every enabled number joins the same shared sender pool. The
   call is idempotent — an already-enabled number returns its current state.
3. **Send.** Call `POST /sms` with the recipient and body. Hail sends from your
   organization's dedicated number.

**A2P 10DLC (United States).** US carriers deliver application-to-person SMS on
long-code numbers only after you register an A2P 10DLC brand and campaign. Do
this in the Twilio console (**Messaging → Regulatory Compliance → A2P 10DLC**)
before you send to US numbers. Registration is a Twilio-side requirement; Hail
does not manage it.

**Sender ID (optional, rest-of-world).** For destinations that allow it, set a
custom alphanumeric sender with `PATCH /sms/sender-id` (read the current value
with `GET /sms/sender-id`). Clear it by sending `null`. When you set none, Hail
falls back to the platform default sender. The United States and Canada do not
allow alphanumeric sender IDs — messages there always send from the dedicated
number, regardless of this setting.

**Rate limits.** Per-organization send velocity is capped by
`HAIL_VELOCITY_SMS_PER_HOUR` (default 100) and `HAIL_VELOCITY_SMS_PER_DAY`
(default 1000). An abuse monitor suspends an organization's SMS channel when its
opt-out rate is too high — see [operations](./operations.md) for the
`HAIL_SMS_ABUSE_*` variables and how to lift a suspension.

## 5. Inbound SMS & opt-out

Point the number's **A Message Comes In** webhook at
`https://<your-api-host>/sms/inbound` (HTTP POST). Hail verifies Twilio's
`X-Twilio-Signature` against `HAIL_API_URL`. Make sure that this value matches
the public URL that Twilio posts to.

**Recognized keywords** (Hail matches them on the message body, case-insensitive):

- Opt out (STOP): `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`
- Opt in (START): `START`, `YES`, `UNSTOP`
- Help: `HELP`, `INFO`

Hail records opt-outs in its own suppression list, regardless of the Twilio
configuration. Hail checks this list before every send.

**Opt-out replies:** By default, **Twilio** replies automatically to
STOP/HELP/START and carrier-blocks opted-out numbers. In that setup, keep
`HAIL_SMS_COMPLIANCE_REPLIES_ENABLED=false`. If you want **Hail** to own the
replies (for example, a non-Twilio provider, or a custom keyword experience),
do the two steps that follow. **Caution: if you disable Twilio's default
opt-out handling, the change is account-wide and requires a Twilio Support
request; there is no API for it.** First, disable Twilio's default opt-out
handling. Then set `HAIL_SMS_COMPLIANCE_REPLIES_ENABLED=true`.


---

# VM deployment

Self-host Hail on one Ubuntu VM behind HTTPS. GitHub Actions deploys every commit on `main`. The database is **managed** (Neon, Supabase, RDS, or your choice). The VM runs only the stateless services.

```
        ┌───────────────────────────────────────────────────────┐
        │  GitHub Actions  ──build──▶  ghcr.io/hail-hq/hail-*   │
        │       │                                               │
        │       └──ssh──▶  VM: docker compose pull && up -d     │
        └───────────────────────────────────────────────────────┘
                                  │
                                  ▼
   ┌──────────────────────────────────────────────────────────────┐
   │  Ubuntu VM                                                   │
   │                                                              │
   │   :80 / :443  ──▶  Caddy  ──▶  api  (api.<domain>)           │
   │                            └─▶  mcp  (mcp.<domain>)          │
   │                                                              │
   │                   voicebot ──outbound──▶ LiveKit Cloud       │
   └──────────────────────────────────────────────────────────────┘
                                  │
                                  └──▶ managed Postgres (Neon / RDS / …)
```

The voicebot is a worker. It dials out to LiveKit Cloud and needs no inbound port. Only `api.<domain>` and `mcp.<domain>` are public.

## Prerequisites

- An Ubuntu 22.04+ VM with a public IPv4 address. 2 vCPU / 4 GB RAM is sufficient to start.
- A managed Postgres that the VM can reach. Copy its connection string (typically `postgresql://USER:PASS@HOST/DB?sslmode=require`).
- A domain that you control. You point `api.<domain>` and `mcp.<domain>` at the VM's IP.

## 1. Prepare the VM

Connect with SSH as a sudo-capable user. Then run:

```bash
# Docker engine + compose plugin.
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
# Re-login or `newgrp docker` so the group change takes effect.

# Firewall: only 22, 80, 443 reach the host.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp   # HTTP/3
sudo ufw enable

# Working directory.
sudo mkdir -p /opt/hail
sudo chown "$USER:$USER" /opt/hail
git clone https://github.com/hail-hq/hail /opt/hail
cd /opt/hail
```

If your fork is private, clone via SSH (`git@github.com:<owner>/hail.git`) and authorize a deploy key on the VM. The CI deploy step runs `git fetch origin` on every push and gets a 403 error otherwise.

> **UFW + Docker caveat:** Docker writes its own iptables rules that _bypass_ UFW. The compose file binds the api/mcp/minio ports to `127.0.0.1`. Thus external traffic cannot reach them, even if UFW permits it locally. If you add a new published port, keep the `127.0.0.1:` prefix, unless you intend the port to be public.

## 2. DNS

Create two A records (or one wildcard) that point at the VM:

| record         | type | value     |
| -------------- | ---- | --------- |
| `api.<domain>` | A    | _VM IPv4_ |
| `mcp.<domain>` | A    | _VM IPv4_ |

If either name does not resolve to the VM yet, Caddy cannot get Let's Encrypt certs. Verify the records before you continue.

```bash
dig +short api.<domain>
dig +short mcp.<domain>
```

## 3. Configure `.env` on the VM

```bash
cd /opt/hail
cp .env.example .env
```

Edit `/opt/hail/.env` and fill in:

- All provider secrets (Twilio, LiveKit, Deepgram, Cartesia, optional ElevenLabs fallback, and at least one LLM key); see [Twilio](./twilio.md) and [LiveKit Cloud](./livekit-cloud.md).
- `HAIL_API_KEY` — generate with `openssl rand -base64 32 | tr -d '/+=' | head -c 40 | sed 's/^/hk_/'`.
- `DATABASE_URL` — the managed Postgres connection string. Most providers require `?sslmode=require`.
- `HAIL_DOMAIN` — your apex (for example, `hail.example.com`). Both `api.` and `mcp.` subdomains derive from it.

The `.env` file is already gitignored. Never commit it.

## 4. First boot

The compose invocations below are verbose because the prod overlay is not the default. If you use the VM often, add a shell alias to the deploy user's `~/.bashrc`:

```bash
echo "alias dcprod='docker compose -f /opt/hail/docker-compose.yml -f /opt/hail/docker-compose.prod.yml'" >> ~/.bashrc
```

```bash
cd /opt/hail
# Log in to GHCR so docker can pull (one-time; the CI deploy will re-login
# each run). Create a Personal Access Token with `read:packages`.
echo "$GHCR_PAT" | docker login ghcr.io -u "$GHCR_USER" --password-stdin

docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml run --rm api alembic upgrade head
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
```

To skip the manual GHCR login, trigger the GitHub Actions workflow (step 6) first. Its `docker login` step writes credentials that the VM can use again.

Bind your first phone number using [Self-host: first-run setup](./operations.md#self-host-first-run-setup). Because this deployment uses managed Postgres, replace `docker compose exec postgres …` with `psql "$DATABASE_URL" -c …`.

Send a request to `https://api.<domain>/healthz` from your laptop. You must see `{"status":"ok"}`. Caddy gets a Let's Encrypt cert on the first request. The first call after boot can take 10–20 seconds.

## 5. Configure GitHub Actions

Set these under **Settings → Secrets and variables → Actions**:

| name                     | type   | value                                                 |
| ------------------------ | ------ | ----------------------------------------------------- |
| `DEPLOY_SSH_HOST`        | secret | VM hostname or IP                                     |
| `DEPLOY_SSH_USER`        | secret | SSH user on the VM (in the `docker` group)            |
| `DEPLOY_SSH_PRIVATE_KEY` | secret | Private key matching an authorized public key         |
| `DEPLOY_SSH_PORT`        | secret | _(optional)_ SSH port; omit to default to 22          |
| `DEPLOY_PATH`            | _var_  | _(optional)_ repo checkout path; defaults `/opt/hail` |

Generate a deploy key dedicated to CI. Do not use a personal key again:

```bash
ssh-keygen -t ed25519 -f hail-deploy -C "github-actions@hail" -N ""
# On the VM: append hail-deploy.pub to ~/.ssh/authorized_keys
# In GitHub: paste hail-deploy (the private key) into DEPLOY_SSH_PRIVATE_KEY.
```

If every deploy must gate on a manual approval, create a `production` GitHub Environment (Settings → Environments → New → `production`) with required reviewers. The deploy job targets `environment: production` and inherits any protection rules automatically.

The GHCR push uses the workflow's `GITHUB_TOKEN` (already scoped `packages: write`). No extra PAT is necessary.

## 6. Trigger the first deploy

Push any commit to `main`, or run **Actions → Deploy → Run workflow** in the GitHub UI. The workflow:

1. Builds `api`, `voicebot`, `mcp` images in parallel and pushes both `sha-<commit>` and `latest` to ghcr.io/hail-hq/hail-\*.
2. Connects to the VM with SSH, fetches the new commit, and runs `alembic upgrade head`. Then `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` rolls the containers onto the newly pulled `:latest`.

`concurrency: deploy-prod` makes simultaneous merges queue. They do not race.

## Day-2

- **Tail logs**: `docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f api`
- **Restart one service**: `docker compose -f docker-compose.yml -f docker-compose.prod.yml restart api`
- **Roll back**: every build pushes a `sha-<commit>` tag. To pin an older build:

  ```bash
  docker pull ghcr.io/hail-hq/hail-api:sha-<previous-commit>
  docker tag  ghcr.io/hail-hq/hail-api:sha-<previous-commit> ghcr.io/hail-hq/hail-api:latest
  docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d api
  ```

  Repeat for each service. The cleanest rollback is to revert the commit on `main` and let CI deploy again.

- **Update Caddy config**: edit `/opt/hail/Caddyfile`, then `docker compose -f docker-compose.yml -f docker-compose.prod.yml exec caddy caddy reload --config /etc/caddy/Caddyfile`.
- **Reclaim disk**: each deploy leaves the previous `sha-<commit>` image behind. Prune monthly with `docker image prune -f`. This is safe; it removes only images with no container reference.

## Footguns

| symptom                                                      | fix                                                                                                                                                                                                |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Caddy logs `tls: no certificates configured` on first start  | DNS has not propagated yet, or :80/:443 is not open. Confirm that `dig api.<domain>` returns the VM IP and `sudo ufw status` shows both ports open.                                                |
| `docker pull` 401 / "denied" from GHCR                       | The package is private by default. Set its visibility to public under GitHub → Packages → hail-\_ → Package settings, or rely on the workflow's `docker login` step.                               |
| `permission denied while trying to connect to docker daemon` | The deploy user must be in the `docker` group. Run `sudo usermod -aG docker $USER` on the VM and re-establish the SSH session.                                                                     |
| Caddy 502s for api.<domain>                                  | Caddy resolves `api` and `mcp` via Compose's internal DNS — all services share the `hail` project network. Do not split the project name across files.                                             |
| api/mcp port reachable from the public internet              | A published port without the `127.0.0.1:` prefix bypasses UFW. Check `docker compose -f docker-compose.yml -f docker-compose.prod.yml port api 8080` returns `127.0.0.1:8080`, not `0.0.0.0:8080`. |


---

# Acquire Number

`POST /v1/numbers`

Buy a dedicated phone number for the caller's organization.

This purchases a real number at the carrier and starts a recurring
monthly fee immediately — it is not a reservation. The number is usable
for voice, SMS, or both depending on the requested capabilities and
what the carrier offers for the given country_code/number_type.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |
| `Idempotency-Key` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `country_code` | string | yes | ISO alpha-2 country code to acquire a number in (e.g. 'US'). Case-insensitive. |
| `number_type` | "local" \| "mobile" \| "toll_free" \| "national" | no | Kind of number to acquire: 'local', 'mobile', 'toll_free', or 'national'. |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this number. |
| `e164` | string | yes | The phone number, E.164 format. |
| `country_code` | string | yes | ISO alpha-2 country code this number belongs to. |
| `number_type` | string | yes | Kind of number: 'local', 'mobile', 'toll_free', or 'national'. |
| `capabilities` | string[] | yes | Channels this number supports, e.g. ['voice'], ['sms'], or both. |
| `provisioning_state` | string | yes | 'pending', 'active', 'failed', or 'released'. |
| `is_dedicated` | boolean | yes | True if this number is owned by the organization. False for shared-pool numbers. |
| `messaging_service_sid` | string \| null | no | Provider messaging-service identifier once SMS has been enabled on this number. Null until then. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Activate Provider

`POST /v1/providers/{layer}/activate`

Switch which saved provider is active for ``layer``. 404 when that
provider has no saved config.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `layer` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `provider` | string | yes | Previously saved provider to make active for this layer. |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `layer` | "llm" \| "tts" \| "stt" | yes | Voice-pipeline layer this config applies to. |
| `provider` | string | yes | Provider name (e.g. 'openai', 'cartesia'). |
| `key_last4` | string \| null | yes | Last 4 characters of the saved API key, for display. Null if no key is saved. |
| `key_set_at` | string \| null | yes | When the API key was last set, ISO 8601 timestamp. Null if no key is saved. |
| `params` | object | yes | Saved provider-specific config for this row. |
| `fallback_enabled` | boolean | yes | Whether Hail's default provider is used as a fallback if this one fails. |
| `is_active` | boolean | yes | True if this is the row currently used by calls on this layer. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Check Domain

`GET /v1/email-domains/check-domain`

Does this domain already receive mail? Drives apex-vs-prefix onboarding.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | string | yes | The apex domain that was checked, lowercased. |
| `in_use` | boolean | yes | True if the domain already has MX records — it receives mail elsewhere. |
| `existing_mx` | string[] | yes | MX hostnames currently published for the domain. Empty when in_use is false. |
| `suggested_domain` | string | yes | Domain to use for a custom sending identity: the apex domain if it is not already receiving mail, or an 'inbox.' subdomain if it is (so setup doesn't collide with existing mail). |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Create Call

`POST /v1/calls`

Place an outbound AI voice call.

The call is placed asynchronously — this returns as soon as the call is
queued, not when it completes. Poll GET /v1/calls/{call_id} or configure a
webhook to get the final status and transcript. Requires
recipient_consent=true on the request body; Hail does not verify lawful
basis to contact the recipient, the caller warrants it.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |
| `Idempotency-Key` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recipient_consent` | boolean | yes | Attestation that you have obtained the lawful consent required to contact this recipient. Hail does not verify consent itself — you are responsible for a lawful basis under TCPA/ePrivacy/PECR/CAN-SPAM/GDPR as applicable. Rejected (422) if not true. |
| `consent_source` | string \| null | no | Where/how consent was obtained (e.g. 'signup form', 'prior customer relationship'). Required (non-empty) when message_type is 'marketing'. |
| `consent_obtained_at` | string (date-time) \| null | no | When consent was obtained, if known. |
| `message_type` | "marketing" \| "informational" | no | 'marketing' additionally requires a non-empty consent_source. Use 'informational' for transactional/service communications. |
| `to` | string | yes | Recipient phone number, E.164 format (e.g. +14155551234). |
| `from` | string \| null | no | Caller-id phone number, E.164 format. Must be a number owned by the organization with the voice capability. Omitted: an active org-owned number is used if one exists, else a number is claimed from the shared pool. |
| `system_prompt` | string \| null | no | Task instructions for the agent, sent as its leading system message. At least one of system_prompt or llm is required; both together is also valid. |
| `llm` | object \| null | no | BYO LLM endpoint the call runs on instead of Hail's default model. At least one of system_prompt or llm is required; both together is also valid. |
| `first_message` | string \| null | no | Opening line the agent speaks first. Omitted: the agent waits for the callee to speak first. |
| `ai_disclosure` | boolean | no | Speak the AI self-disclosure line ('Hi, this is an AI assistant calling on behalf of ...') as the first thing on the call. Enabled by default. Disable only if you have verified the disclosure is not required for this call — 47 CFR 64.1200(b)(1) requires identifying the initiating business at the start of artificial-voice calls in the US, and several jurisdictions have AI bot-disclosure laws. Hail does not verify this for you. The agent still identifies itself as an AI if asked. |
| `voice_config` | object | no | TTS voice, VAD, turn-detection, and spoken-language settings for this call. |
| `voice_config.tts` | string | no | Text-to-speech provider. Currently only 'cartesia'. |
| `voice_config.vad` | string | no | Voice-activity-detection engine. Currently only 'silero'. |
| `voice_config.turn_detection` | string | no | Turn-detection engine. Currently only 'livekit'. |
| `voice_config.voice_id` | string \| null | no | Per-call TTS voice override, applied to whichever TTS provider serves the call. Omitted: the organization's or environment's default voice. |
| `voice_config.language` | "ar" \| "bg" \| "bn" \| "cs" \| "da" \| "de" \| "el" \| "en" \| "es" \| "fi" \| "fr" \| "gu" \| "he" \| "hi" \| "hr" \| "hu" \| "id" \| "it" \| "ja" \| "kn" \| "ko" \| "mr" \| "ms" \| "nl" \| "no" \| "pl" \| "pt" \| "ro" \| "ru" \| "sk" \| "sv" \| "ta" \| "te" \| "th" \| "tl" \| "tr" \| "uk" \| "vi" \| "zh" \| null | no | Spoken language for the call as a lowercase ISO 639-1 code (e.g. 'da'). One of the 39 supported codes — see docs/languages.md. Applied to STT, TTS, and turn detection. Omitted: the providers' defaults (English). |
| `conversation_id` | string (uuid) \| null | no | Groups this call with other calls/emails/SMS into one conversation thread. Omitted: the call is not linked to a conversation. |
| `metadata` | object | no | Free-form JSON object attached to the call and echoed back on reads. Not interpreted by Hail. |
| `tools` | string[] \| null | no | Agent tools to allow on this call. Omitted: every tool the organization's configured channels support (new channels appear automatically). Empty list: no tools. Tool names are validated against the server's registry. |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this call. |
| `organization_id` | string (uuid) | yes | Organization that placed or received this call. |
| `conversation_id` | string (uuid) \| null | yes | Conversation thread this call is grouped into, if any. Null when the call was not linked to a conversation. |
| `from_e164` | string | yes | Caller-id phone number used, E.164 format. |
| `to_e164` | string | yes | Recipient phone number, E.164 format. |
| `direction` | "outbound" \| "inbound" | yes | 'outbound' for calls Hail placed, 'inbound' for calls received. |
| `status` | "queued" \| "dialing" \| "ringing" \| "in_progress" \| "completed" \| "failed" \| "busy" \| "no_answer" \| "canceled" | yes | Current call-progress state: 'queued', 'dialing', 'ringing', 'in_progress', or one of the terminal states 'completed', 'failed', 'busy', 'no_answer', 'canceled'. |
| `end_reason` | string \| null | yes | Machine-readable reason the call reached a terminal status (e.g. 'normal_hangup', 'user_rejected', 'sip_trunk_failure'). Null while the call is still in progress. |
| `provider_call_sid` | string \| null | yes | The telephony provider's identifier for this call leg, if assigned. |
| `livekit_room` | string \| null | yes | Name of the LiveKit room hosting this call's media session, if one was created. |
| `initial_prompt` | string \| null | yes | The system_prompt this call was created with, if any. |
| `recording_s3_key` | string \| null | yes | Internal storage key for the call recording. Not a directly fetchable URL. |
| `requested_at` | string (date-time) | yes | When the call was requested, ISO 8601 timestamp. |
| `started_at` | string (date-time) \| null | yes | When dialing began, ISO 8601 timestamp. Null until the call starts. |
| `answered_at` | string (date-time) \| null | yes | When the callee answered, ISO 8601 timestamp. Null if never answered. |
| `ended_at` | string (date-time) \| null | yes | When the call ended, ISO 8601 timestamp. Null while still in progress. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. The agent-origin workspace exceeded a per-channel velocity cap, or the platform kill switch is on. Retry after the Retry-After header (seconds). Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Create Contact

`POST /v1/contacts`

Create a manual contact with a phone and/or an email.

Requires at least one of phone_e164 or email. Fails with 409 if a
contact with the same phone or email already exists in this
organization.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Display name for the contact. |
| `phone_e164` | string \| null | no | Phone number, E.164 format. At least one of phone_e164 or email is required. |
| `email` | string \| null | no | Email address, stored lowercased. At least one of phone_e164 or email is required. |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | 'member:<user_id>' for an org member, or the contact row's UUID (as a string) for a manual contact. |
| `kind` | "member" \| "manual" | yes | 'member' if this row is a member of the organization, 'manual' if it was added as a contact. |
| `name` | string | yes | Display name. |
| `phone_e164` | string \| null | no | Phone number, E.164 format. Null if none on file. |
| `email` | string \| null | no | Email address. Null if none on file. |
| `role` | string \| null | no | Organization role (e.g. 'owner', 'admin', 'member') for kind='member'. Always null for kind='manual'. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Create Email Domain

`POST /v1/email-domains`

Register a sender identity to send outbound email through.

kind="hail_mail" mints an address on the shared hail-mail domain and is
immediately verified — no DNS work needed. kind="custom" registers your
own domain with SES and returns DKIM records; the domain stays
unverified (POST /v1/email-domains/{domain_id}/verify) until you publish
those DNS records and Hail confirms them.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | "hail_mail" \| "custom" | yes | 'hail_mail' for a Hail-hosted address (domain omitted, composed from the prefix fields), or 'custom' to send from your own domain (domain required, prefix fields omitted). |
| `domain` | string \| null | no | DNS domain to send from (e.g. 'acme.com'). Required for kind='custom'; must be omitted for kind='hail_mail'. |
| `local_prefix_user` | string \| null | no | User-chosen local-part prefix for a hail_mail address. Only valid for kind='hail_mail'. Falls back to HAIL_MAIL_DEFAULT_USER_PREFIX if omitted. |
| `local_prefix_org` | string \| null | no | Org-chosen local-part prefix for a hail_mail address. Only valid for kind='hail_mail'. Falls back to HAIL_MAIL_DEFAULT_ORG_PREFIX if omitted. |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this email domain. |
| `organization_id` | string (uuid) | yes | Organization that owns this domain. |
| `kind` | "hail_mail" \| "custom" | yes | 'hail_mail' (Hail-hosted address) or 'custom' (your own domain). |
| `domain` | string | yes | The DNS domain mail is sent from. |
| `local_prefix_user` | string \| null | yes | User local-part prefix for a hail_mail address. Null for kind='custom'. |
| `local_prefix_org` | string \| null | yes | Org local-part prefix for a hail_mail address. Null for kind='custom'. |
| `verification_status` | "pending" \| "verified" \| "failed" | yes | 'pending' (not yet verified), 'verified' (ready to send), or 'failed'. |
| `dns_records` | object[] | yes | DNS records (DKIM, MAIL FROM MX, SPF) the tenant must publish to verify this domain. |
| `dns_records.name` | string | yes | DNS record name/host to publish (e.g. a CNAME's subdomain). |
| `dns_records.value` | string | yes | DNS record value to publish (e.g. a CNAME target or TXT content). |
| `dns_records.type` | "CNAME" \| "MX" \| "TXT" | no | DNS record type: 'CNAME' (DKIM), 'MX' (MAIL FROM), or 'TXT' (SPF). |
| `dns_records.priority` | integer \| null | no | MX priority. Only present for type='MX'; null otherwise. |
| `mail_from_domain` | string \| null | yes | Custom MAIL FROM domain, if configured. Null when using the provider default. |
| `mail_from_status` | string \| null | no | Verification status of the custom MAIL FROM domain, if one is configured. Secondary to verification_status. |
| `provider` | string | yes | Email sending provider for this domain (currently always 'ses'). |
| `verified_at` | string (date-time) \| null | yes | When the domain became verified, ISO 8601 timestamp. Null until it is. |
| `inbound_enabled` | boolean | no | Whether this domain accepts and forwards inbound mail. |
| `forward_to` | string[] \| null | no | Email addresses inbound mail is forwarded to, if inbound is enabled. |
| `forward_rate_per_hour` | integer \| null | no | Configured cap on forwarded messages per hour, if set. |
| `created_at` | string (date-time) | yes | When this domain was added, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this domain was last modified, ISO 8601 timestamp. |
| `receive_ready` | boolean \| null | no | True when the domain's published MX points at Hail's inbound host. Only populated by POST /{id}/verify on custom domains; null everywhere else. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Create Email

`POST /v1/emails`

Send an outbound email through SES.

Sends synchronously — the response reports the final status (sent or
failed), not a queued placeholder; no separate poll is needed for the
happy path, though a bounce or complaint can still arrive later as a
webhook or GET /v1/emails/{email_id}/events entry. Requires
recipient_consent=true on the request body; Hail does not verify lawful
basis to contact the recipient, the caller warrants it.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |
| `Idempotency-Key` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recipient_consent` | boolean | yes | Attestation that you have obtained the lawful consent required to contact this recipient. Hail does not verify consent itself — you are responsible for a lawful basis under TCPA/ePrivacy/PECR/CAN-SPAM/GDPR as applicable. Rejected (422) if not true. |
| `consent_source` | string \| null | no | Where/how consent was obtained (e.g. 'signup form', 'prior customer relationship'). Required (non-empty) when message_type is 'marketing'. |
| `consent_obtained_at` | string (date-time) \| null | no | When consent was obtained, if known. |
| `message_type` | "marketing" \| "informational" | no | 'marketing' additionally requires a non-empty consent_source. Use 'informational' for transactional/service communications. |
| `from` | string \| null | no | Sender email address. Must be a verified identity on the organization's email domains. Omitted: the org's resolved default sending address, if one exists. |
| `from_name` | string \| null | no | Display name for the From: header (e.g. 'Acme Billing'). Omitted: no display name. |
| `to` | string[] | yes | Recipient email addresses. At least one required. |
| `cc` | string[] \| null | no | CC recipient email addresses. |
| `bcc` | string[] \| null | no | BCC recipient email addresses. |
| `reply_to` | string \| null | no | Reply-To email address. Omitted: replies go to the From address. |
| `subject` | string | yes | Email subject line. |
| `body_text` | string \| null | no | Plain-text body. Either body_text or body_html (or both) is required. A plain-text-only email cannot be tracked for opens or clicks; include body_html to get those events. |
| `body_html` | string \| null | no | HTML body. Either body_text or body_html (or both) is required. Prefer including body_html: open and click tracking only works for emails with an HTML body. Plain-text-only emails still get sent, delivered, and bounce events, but opens and clicks are never tracked. |
| `conversation_id` | string (uuid) \| null | no | Groups this email with other calls/emails/SMS into one conversation thread. Omitted: the email is not linked to a conversation. |
| `metadata` | object | no | Free-form JSON object attached to the email and echoed back on reads. Not interpreted by Hail. |
| `attachment_ids` | string (uuid)[] \| null | no | Ids returned by POST /email-attachments to attach to this send. Omitted: no attachments. |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this email. |
| `organization_id` | string (uuid) | yes | Organization that sent or received this email. |
| `conversation_id` | string (uuid) \| null | yes | Conversation thread this email is grouped into, if any. Null when it was not linked to a conversation. |
| `email_domain_id` | string (uuid) \| null | yes | The sending domain used, if from_address belongs to one of the org's configured domains. |
| `direction` | "outbound" \| "inbound" | no | 'outbound' for emails Hail sent, 'inbound' for emails received. |
| `from_address` | string | yes | Sender email address. |
| `from_name` | string \| null | no | Display name used on the From: header, when the sender supplied one. Always null on inbound rows. |
| `to_addresses` | string[] | yes | Recipient email addresses. |
| `cc_addresses` | string[] \| null | yes | CC recipient email addresses, if any. |
| `bcc_addresses` | string[] \| null | yes | BCC recipient email addresses, if any. |
| `reply_to` | string \| null | yes | Reply-To email address, if set. |
| `subject` | string | yes | Email subject line. |
| `status` | "queued" \| "sent" \| "delivered" \| "failed" \| "bounced" \| "complained" \| "received" | yes | Delivery status: 'queued', 'sent', 'delivered', 'failed', 'bounced', 'complained', or 'received' (inbound emails). |
| `end_reason` | string \| null | yes | Reason delivery failed or bounced, if applicable. Null on success or while pending. |
| `provider_message_id` | string \| null | yes | The email provider's identifier for this message, if assigned. |
| `requested_at` | string (date-time) | yes | When the send was requested, ISO 8601 timestamp. |
| `sent_at` | string (date-time) \| null | yes | When the message was handed to the provider, ISO 8601 timestamp. Null until sent. |
| `failed_at` | string (date-time) \| null | yes | When the send failed, ISO 8601 timestamp. Null unless it failed. |
| `metadata` | object | no | Free-form JSON object attached to the email, as sent on create. Not interpreted by Hail. |
| `body_text` | string \| null | yes | Plain-text body, if any. |
| `body_html` | string \| null | yes | HTML body, if any. |
| `message_id` | string \| null | no | RFC 5322 Message-ID header. Inbound emails only; null on outbound rows. |
| `in_reply_to` | string \| null | no | RFC 5322 In-Reply-To header. Inbound emails only; null on outbound rows. |
| `references_ids` | string[] \| null | no | RFC 5322 References header, split into ids. Inbound emails only; null on outbound rows. |
| `spam_verdict` | string \| null | no | Provider spam-scan verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `virus_verdict` | string \| null | no | Provider virus-scan verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `dkim_verdict` | string \| null | no | Provider DKIM-authentication verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `spf_verdict` | string \| null | no | Provider SPF-authentication verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `dmarc_verdict` | string \| null | no | Provider DMARC-authentication verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `provider_received_at` | string (date-time) \| null | no | When the provider received this email, ISO 8601 timestamp. Inbound emails only. |
| `raw_url` | string \| null | no | API endpoint that redirects to the original MIME blob. Inbound emails only; null on outbound rows. |
| `attachments` | object[] | no | Inbound MIME attachments on this email. Empty on outbound rows. |
| `attachments.id` | string (uuid) | yes | Unique identifier for this attachment. |
| `attachments.filename` | string | yes | Original filename of the attachment. |
| `attachments.content_type` | string | yes | MIME type of the attachment. |
| `attachments.size_bytes` | integer | yes | Size of the attachment in bytes. |
| `attachments.content_id` | string \| null | no | MIME Content-ID, present when this attachment is referenced inline (cid:) from the HTML body. Null otherwise. |
| `attachments.url` | string | yes | API endpoint that 302-redirects to a presigned download URL for this attachment. |
| `last_event_at` | string (date-time) \| null | no | When the most recent delivery event for this email occurred, ISO 8601 timestamp. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. The agent-origin workspace exceeded a per-channel velocity cap, or the platform kill switch is on. Retry after the Retry-After header (seconds). Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Create Sms

`POST /v1/sms`

Send an outbound SMS.

Sends synchronously — the response reports the final status (sent or
failed), not a queued placeholder, though delivery confirmation from
the carrier can still arrive later as a webhook or GET /v1/sms/{sms_id}
update. An explicit from resolves a dedicated number; otherwise Hail
picks an alphanumeric sender ID where the destination corridor allows
it, or requires a dedicated SMS-capable number otherwise. Requires
recipient_consent=true on the request body; Hail does not verify
lawful basis to contact the recipient, the caller warrants it.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |
| `Idempotency-Key` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recipient_consent` | boolean | yes | Attestation that you have obtained the lawful consent required to contact this recipient. Hail does not verify consent itself — you are responsible for a lawful basis under TCPA/ePrivacy/PECR/CAN-SPAM/GDPR as applicable. Rejected (422) if not true. |
| `consent_source` | string \| null | no | Where/how consent was obtained (e.g. 'signup form', 'prior customer relationship'). Required (non-empty) when message_type is 'marketing'. |
| `consent_obtained_at` | string (date-time) \| null | no | When consent was obtained, if known. |
| `message_type` | "marketing" \| "informational" | no | 'marketing' additionally requires a non-empty consent_source. Use 'informational' for transactional/service communications. |
| `to` | string | yes | Recipient phone number, E.164 format (e.g. +14155551234). |
| `from` | string \| null | no | Sender phone number, E.164 format. Must be a number owned by the organization with the SMS capability. Omitted: an active org-owned number is used if one exists, else a number is claimed from the shared pool. |
| `body` | string | yes | Message text. Long bodies are split into multiple carrier segments. |
| `metadata` | object | no | Free-form JSON object attached to the message and echoed back on reads. Not interpreted by Hail. |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this message. |
| `organization_id` | string (uuid) | yes | Organization that sent or received this message. |
| `from_e164` | string | yes | Sender phone number, E.164 format. |
| `to_e164` | string | yes | Recipient phone number, E.164 format. |
| `direction` | "outbound" \| "inbound" | yes | 'outbound' for messages Hail sent, 'inbound' for messages received. |
| `status` | "queued" \| "sent" \| "delivered" \| "failed" \| "undelivered" \| "received" | yes | Delivery status: 'queued', 'sent', 'delivered', 'failed', 'undelivered', or 'received' (inbound messages). |
| `body` | string | yes | Message text. |
| `provider_message_sid` | string \| null | yes | The carrier/provider's identifier for this message, if assigned. |
| `segment_count` | integer | yes | Number of carrier SMS segments the body was split into. |
| `error_code` | string \| null | yes | Carrier error code if delivery failed. Null on success or while pending. |
| `requested_at` | string (date-time) | yes | When the send was requested, ISO 8601 timestamp. |
| `sent_at` | string (date-time) \| null | yes | When the message was handed to the carrier, ISO 8601 timestamp. Null until sent. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. The agent-origin workspace exceeded a per-channel velocity cap, or the platform kill switch is on. Retry after the Retry-After header (seconds). Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Create Subscription

`POST /v1/webhooks`

Create a webhook subscription for one or more event types.

The response includes the plaintext signing secret — this is the only
time it is ever returned; store it now. Every later GET omits it. Use
POST /v1/webhooks/{sub_id}/rotate-secret to get a new plaintext secret if
it is lost or compromised.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `target_url` | string | yes | HTTPS URL Hail POSTs event payloads to. |
| `event_types` | "email.received" \| "email.delivered" \| "email.delivery_delayed" \| "email.bounced" \| "email.complained" \| "email.opened" \| "email.clicked" \| "email.received.suppressed" \| "email.send_failed" \| "sms.received" \| "sms.delivered" \| "sms.undelivered" \| "sms.failed" \| "call.answered" \| "call.completed" \| "call.failed" \| "call.busy" \| "call.no_answer"[] | yes | Event types to subscribe to (e.g. 'call.completed', 'email.bounced'). At least one required. |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this subscription. |
| `organization_id` | string (uuid) | yes | Organization that owns this subscription. |
| `target_url` | string | yes | HTTPS URL event payloads are POSTed to. |
| `event_types` | string[] | yes | Event types this subscription receives. |
| `status` | "active" \| "disabled" | yes | 'active' (delivering) or 'disabled' (paused). |
| `consecutive_failures` | integer | yes | Consecutive failed delivery attempts since the last success. Resets to 0 on success. |
| `last_success_at` | string (date-time) \| null | no | When a delivery last succeeded, ISO 8601 timestamp. Null if never. |
| `last_failure_at` | string (date-time) \| null | no | When a delivery last failed, ISO 8601 timestamp. Null if never. |
| `created_at` | string (date-time) | yes | When this subscription was created, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this subscription was last modified, ISO 8601 timestamp. |
| `secret` | string \| null | no | Plaintext signing secret for verifying delivery payloads. Only present in the create and rotate-secret responses; every later read returns null. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Delete Contact

`DELETE /v1/contacts/{contact_id}`

Permanently remove a manual contact.

Manual contacts only — a member: id returns 422; org members are
removed via the membership APIs, not this route. Irreversible.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_id` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 204

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Delete Email Domain

`DELETE /v1/email-domains/{domain_id}`

Permanently remove a sender domain/identity.

Irreversible — for a custom domain, also deletes the SES identity, so
the domain can no longer send until re-registered and re-verified.
Fails with 409 if any Email rows still reference this domain; delete
or wait for those to age out first.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `domain_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 204

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Delete Member Phone

`DELETE /v1/members/{user_id}/phone`

Clear an org member's phone number.

Pass user_id="me" to clear your own, or a member's user id — clearing
another member's phone requires the caller to be an org owner or
admin. Returns 404 if the target is not a member of this organization.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 204

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Delete Provider

`DELETE /v1/providers/{layer}/{provider}`

Delete one provider row. Deleting the active row promotes the
most-recently-updated sibling. Idempotent: deleting a row that isn't
there is a 204 too. 404 on an unknown layer.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `layer` | string | yes | — |
| `provider` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 204

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Delete Sms Suppression

`DELETE /v1/sms/suppressions/{number}`

Remove one number from the SMS suppression list, re-allowing sends to it.

Does not itself constitute renewed consent — the caller is responsible
for having a lawful basis (e.g. a fresh opt-in) before sending again.
Returns 404 if the number was not suppressed.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `number` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 204

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Delete Subscription

`DELETE /v1/webhooks/{sub_id}`

Permanently remove a webhook subscription.

Irreversible — no further events are delivered to it, and its
delivery history is deleted with it.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sub_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 204

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Enable Sms

`POST /v1/numbers/{number_id}/enable-sms`

Attach a dedicated number to the org's shared SMS Messaging Service.

Required once per number before it can send/receive SMS; the number
must already have been acquired with sms capability. Idempotent —
calling this again on an already-enabled number just returns its
current state. Fails with 422 for a released number or one that lacks
sms capability.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `number_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this number. |
| `e164` | string | yes | The phone number, E.164 format. |
| `country_code` | string | yes | ISO alpha-2 country code this number belongs to. |
| `number_type` | string | yes | Kind of number: 'local', 'mobile', 'toll_free', or 'national'. |
| `capabilities` | string[] | yes | Channels this number supports, e.g. ['voice'], ['sms'], or both. |
| `provisioning_state` | string | yes | 'pending', 'active', 'failed', or 'released'. |
| `is_dedicated` | boolean | yes | True if this number is owned by the organization. False for shared-pool numbers. |
| `messaging_service_sid` | string \| null | no | Provider messaging-service identifier once SMS has been enabled on this number. Null until then. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Call

`GET /v1/calls/{call_id}`

Fetch one call by id, including its current status and end reason.

Org-scoped: returns 404 for a call belonging to a different organization
(not 403, to avoid confirming the id exists). Use this to poll for the
final outcome of a call placed with POST /v1/calls.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `call_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this call. |
| `organization_id` | string (uuid) | yes | Organization that placed or received this call. |
| `conversation_id` | string (uuid) \| null | yes | Conversation thread this call is grouped into, if any. Null when the call was not linked to a conversation. |
| `from_e164` | string | yes | Caller-id phone number used, E.164 format. |
| `to_e164` | string | yes | Recipient phone number, E.164 format. |
| `direction` | "outbound" \| "inbound" | yes | 'outbound' for calls Hail placed, 'inbound' for calls received. |
| `status` | "queued" \| "dialing" \| "ringing" \| "in_progress" \| "completed" \| "failed" \| "busy" \| "no_answer" \| "canceled" | yes | Current call-progress state: 'queued', 'dialing', 'ringing', 'in_progress', or one of the terminal states 'completed', 'failed', 'busy', 'no_answer', 'canceled'. |
| `end_reason` | string \| null | yes | Machine-readable reason the call reached a terminal status (e.g. 'normal_hangup', 'user_rejected', 'sip_trunk_failure'). Null while the call is still in progress. |
| `provider_call_sid` | string \| null | yes | The telephony provider's identifier for this call leg, if assigned. |
| `livekit_room` | string \| null | yes | Name of the LiveKit room hosting this call's media session, if one was created. |
| `initial_prompt` | string \| null | yes | The system_prompt this call was created with, if any. |
| `recording_s3_key` | string \| null | yes | Internal storage key for the call recording. Not a directly fetchable URL. |
| `requested_at` | string (date-time) | yes | When the call was requested, ISO 8601 timestamp. |
| `started_at` | string (date-time) \| null | yes | When dialing began, ISO 8601 timestamp. Null until the call starts. |
| `answered_at` | string (date-time) \| null | yes | When the callee answered, ISO 8601 timestamp. Null if never answered. |
| `ended_at` | string (date-time) \| null | yes | When the call ended, ISO 8601 timestamp. Null while still in progress. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Email Attachment

`GET /v1/emails/{email_id}/attachments/{attachment_id}`

302 → presigned S3 URL for one attachment.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email_id` | string (uuid) | yes | — |
| `attachment_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

_None._

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Email Domain

`GET /v1/email-domains/{domain_id}`

Fetch one sender domain/identity by id, including its DNS records.

Org-scoped: returns 404 for a domain belonging to a different
organization. For a pending custom domain, dns_records lists the DKIM
records still to publish.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `domain_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this email domain. |
| `organization_id` | string (uuid) | yes | Organization that owns this domain. |
| `kind` | "hail_mail" \| "custom" | yes | 'hail_mail' (Hail-hosted address) or 'custom' (your own domain). |
| `domain` | string | yes | The DNS domain mail is sent from. |
| `local_prefix_user` | string \| null | yes | User local-part prefix for a hail_mail address. Null for kind='custom'. |
| `local_prefix_org` | string \| null | yes | Org local-part prefix for a hail_mail address. Null for kind='custom'. |
| `verification_status` | "pending" \| "verified" \| "failed" | yes | 'pending' (not yet verified), 'verified' (ready to send), or 'failed'. |
| `dns_records` | object[] | yes | DNS records (DKIM, MAIL FROM MX, SPF) the tenant must publish to verify this domain. |
| `dns_records.name` | string | yes | DNS record name/host to publish (e.g. a CNAME's subdomain). |
| `dns_records.value` | string | yes | DNS record value to publish (e.g. a CNAME target or TXT content). |
| `dns_records.type` | "CNAME" \| "MX" \| "TXT" | no | DNS record type: 'CNAME' (DKIM), 'MX' (MAIL FROM), or 'TXT' (SPF). |
| `dns_records.priority` | integer \| null | no | MX priority. Only present for type='MX'; null otherwise. |
| `mail_from_domain` | string \| null | yes | Custom MAIL FROM domain, if configured. Null when using the provider default. |
| `mail_from_status` | string \| null | no | Verification status of the custom MAIL FROM domain, if one is configured. Secondary to verification_status. |
| `provider` | string | yes | Email sending provider for this domain (currently always 'ses'). |
| `verified_at` | string (date-time) \| null | yes | When the domain became verified, ISO 8601 timestamp. Null until it is. |
| `inbound_enabled` | boolean | no | Whether this domain accepts and forwards inbound mail. |
| `forward_to` | string[] \| null | no | Email addresses inbound mail is forwarded to, if inbound is enabled. |
| `forward_rate_per_hour` | integer \| null | no | Configured cap on forwarded messages per hour, if set. |
| `created_at` | string (date-time) | yes | When this domain was added, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this domain was last modified, ISO 8601 timestamp. |
| `receive_ready` | boolean \| null | no | True when the domain's published MX points at Hail's inbound host. Only populated by POST /{id}/verify on custom domains; null everywhere else. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Email Raw

`GET /v1/emails/{email_id}/raw`

302 → presigned S3 URL for the raw inbound MIME (404 for outbound).

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

_None._

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Email Stats

`GET /v1/emails/stats`

Aggregate send/delivery/open/click/bounce counts and rates over a range.

Defaults to the last 7 days, bucketed by day. bucket=hour is limited to
an 8-day range; any bucket size is limited to a 92-day range. Registered
above GET /v1/emails/{email_id} so "stats" is not swallowed by the id path param.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string (date-time) \| null | no | — |
| `to` | string (date-time) \| null | no | — |
| `bucket` | "hour" \| "day" | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string (date-time) | yes | Start of the queried window, ISO 8601 timestamp (inclusive). |
| `to` | string (date-time) | yes | End of the queried window, ISO 8601 timestamp (exclusive). |
| `bucket` | "hour" \| "day" | yes | Time-bucket size used for the series. |
| `totals` | object | yes | Event counts summed across the whole window. |
| `totals.sent` | integer | no | Emails sent in the window. |
| `totals.delivered` | integer | no | Emails confirmed delivered in the window. |
| `totals.delivery_delayed` | integer | no | Emails with a delivery-delayed event in the window. |
| `totals.bounced` | integer | no | Emails bounced (soft or hard) in the window. |
| `totals.bounced_hard` | integer | no | Emails hard-bounced in the window. Subset of bounced. |
| `totals.complained` | integer | no | Emails that received a spam complaint in the window. |
| `totals.rejected` | integer | no | Emails rejected by the provider before sending, in the window. |
| `totals.opened` | integer | no | Total open events in the window, including repeat opens by the same recipient. HTML emails only; plain-text-only emails are never tracked for opens. |
| `totals.clicked` | integer | no | Total click events in the window, including repeat clicks by the same recipient. HTML emails only; plain-text-only emails are never tracked for clicks. |
| `totals.unique_opened` | integer | no | Distinct emails opened at least once in the window (HTML emails only). |
| `totals.unique_clicked` | integer | no | Distinct emails clicked at least once in the window (HTML emails only). |
| `rates` | object | yes | All None when sent == 0 in the window. |
| `rates.delivery` | number \| null | no | delivered / sent for the window. Null when sent == 0. |
| `rates.bounce` | number \| null | no | bounced_hard / sent for the window. Null when sent == 0. |
| `rates.complaint` | number \| null | no | complained / sent for the window. Null when sent == 0. |
| `rates.open` | number \| null | no | unique_opened / sent for the window. Null when sent == 0. Only HTML emails can be opened-tracked, so plain-text sends lower this rate. |
| `rates.click` | number \| null | no | unique_clicked / sent for the window. Null when sent == 0. Only HTML emails can be click-tracked, so plain-text sends lower this rate. |
| `series` | object[] | yes | Per-bucket event counts across the window, in chronological order. |
| `series.sent` | integer | no | Emails sent in the window. |
| `series.delivered` | integer | no | Emails confirmed delivered in the window. |
| `series.delivery_delayed` | integer | no | Emails with a delivery-delayed event in the window. |
| `series.bounced` | integer | no | Emails bounced (soft or hard) in the window. |
| `series.bounced_hard` | integer | no | Emails hard-bounced in the window. Subset of bounced. |
| `series.complained` | integer | no | Emails that received a spam complaint in the window. |
| `series.rejected` | integer | no | Emails rejected by the provider before sending, in the window. |
| `series.opened` | integer | no | Total open events in the window, including repeat opens by the same recipient. HTML emails only; plain-text-only emails are never tracked for opens. |
| `series.clicked` | integer | no | Total click events in the window, including repeat clicks by the same recipient. HTML emails only; plain-text-only emails are never tracked for clicks. |
| `series.unique_opened` | integer | no | Distinct emails opened at least once in the window (HTML emails only). |
| `series.unique_clicked` | integer | no | Distinct emails clicked at least once in the window (HTML emails only). |
| `series.bucket_start` | string (date-time) | yes | Start of this bucket, ISO 8601 timestamp. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Email

`GET /v1/emails/{email_id}`

Fetch one email by id, including attachments and last event time.

Org-scoped: returns 404 for an email belonging to a different
organization. For an inbound email with a stored raw MIME, raw_url
points at GET /v1/emails/{email_id}/raw.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this email. |
| `organization_id` | string (uuid) | yes | Organization that sent or received this email. |
| `conversation_id` | string (uuid) \| null | yes | Conversation thread this email is grouped into, if any. Null when it was not linked to a conversation. |
| `email_domain_id` | string (uuid) \| null | yes | The sending domain used, if from_address belongs to one of the org's configured domains. |
| `direction` | "outbound" \| "inbound" | no | 'outbound' for emails Hail sent, 'inbound' for emails received. |
| `from_address` | string | yes | Sender email address. |
| `from_name` | string \| null | no | Display name used on the From: header, when the sender supplied one. Always null on inbound rows. |
| `to_addresses` | string[] | yes | Recipient email addresses. |
| `cc_addresses` | string[] \| null | yes | CC recipient email addresses, if any. |
| `bcc_addresses` | string[] \| null | yes | BCC recipient email addresses, if any. |
| `reply_to` | string \| null | yes | Reply-To email address, if set. |
| `subject` | string | yes | Email subject line. |
| `status` | "queued" \| "sent" \| "delivered" \| "failed" \| "bounced" \| "complained" \| "received" | yes | Delivery status: 'queued', 'sent', 'delivered', 'failed', 'bounced', 'complained', or 'received' (inbound emails). |
| `end_reason` | string \| null | yes | Reason delivery failed or bounced, if applicable. Null on success or while pending. |
| `provider_message_id` | string \| null | yes | The email provider's identifier for this message, if assigned. |
| `requested_at` | string (date-time) | yes | When the send was requested, ISO 8601 timestamp. |
| `sent_at` | string (date-time) \| null | yes | When the message was handed to the provider, ISO 8601 timestamp. Null until sent. |
| `failed_at` | string (date-time) \| null | yes | When the send failed, ISO 8601 timestamp. Null unless it failed. |
| `metadata` | object | no | Free-form JSON object attached to the email, as sent on create. Not interpreted by Hail. |
| `body_text` | string \| null | yes | Plain-text body, if any. |
| `body_html` | string \| null | yes | HTML body, if any. |
| `message_id` | string \| null | no | RFC 5322 Message-ID header. Inbound emails only; null on outbound rows. |
| `in_reply_to` | string \| null | no | RFC 5322 In-Reply-To header. Inbound emails only; null on outbound rows. |
| `references_ids` | string[] \| null | no | RFC 5322 References header, split into ids. Inbound emails only; null on outbound rows. |
| `spam_verdict` | string \| null | no | Provider spam-scan verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `virus_verdict` | string \| null | no | Provider virus-scan verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `dkim_verdict` | string \| null | no | Provider DKIM-authentication verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `spf_verdict` | string \| null | no | Provider SPF-authentication verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `dmarc_verdict` | string \| null | no | Provider DMARC-authentication verdict (e.g. 'PASS'/'FAIL'). Inbound emails only; null on outbound rows. |
| `provider_received_at` | string (date-time) \| null | no | When the provider received this email, ISO 8601 timestamp. Inbound emails only. |
| `raw_url` | string \| null | no | API endpoint that redirects to the original MIME blob. Inbound emails only; null on outbound rows. |
| `attachments` | object[] | no | Inbound MIME attachments on this email. Empty on outbound rows. |
| `attachments.id` | string (uuid) | yes | Unique identifier for this attachment. |
| `attachments.filename` | string | yes | Original filename of the attachment. |
| `attachments.content_type` | string | yes | MIME type of the attachment. |
| `attachments.size_bytes` | integer | yes | Size of the attachment in bytes. |
| `attachments.content_id` | string \| null | no | MIME Content-ID, present when this attachment is referenced inline (cid:) from the HTML body. Null otherwise. |
| `attachments.url` | string | yes | API endpoint that 302-redirects to a presigned download URL for this attachment. |
| `last_event_at` | string (date-time) \| null | no | When the most recent delivery event for this email occurred, ISO 8601 timestamp. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Number

`GET /v1/numbers/{number_id}`

Fetch one dedicated number by id, including its capabilities and state.

Org-scoped: returns 404 for a number belonging to a different
organization.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `number_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this number. |
| `e164` | string | yes | The phone number, E.164 format. |
| `country_code` | string | yes | ISO alpha-2 country code this number belongs to. |
| `number_type` | string | yes | Kind of number: 'local', 'mobile', 'toll_free', or 'national'. |
| `capabilities` | string[] | yes | Channels this number supports, e.g. ['voice'], ['sms'], or both. |
| `provisioning_state` | string | yes | 'pending', 'active', 'failed', or 'released'. |
| `is_dedicated` | boolean | yes | True if this number is owned by the organization. False for shared-pool numbers. |
| `messaging_service_sid` | string \| null | no | Provider messaging-service identifier once SMS has been enabled on this number. Null until then. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Sender Id

`GET /v1/sms/sender-id`

Get the org's custom alphanumeric SMS sender ID, if any is set.

effective_default is the platform sender id used for
alphanumeric-eligible corridors when custom_sender_id is null.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_sender_id` | string \| null | yes | The organization's configured alphanumeric sender id. Null if none is set. |
| `effective_default` | string | no | The platform's alphanumeric sender id, used on eligible corridors when custom_sender_id is null. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Sms

`GET /v1/sms/{sms_id}`

Fetch one SMS by id, including its current status.

Org-scoped: returns 404 for an SMS belonging to a different
organization.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sms_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this message. |
| `organization_id` | string (uuid) | yes | Organization that sent or received this message. |
| `from_e164` | string | yes | Sender phone number, E.164 format. |
| `to_e164` | string | yes | Recipient phone number, E.164 format. |
| `direction` | "outbound" \| "inbound" | yes | 'outbound' for messages Hail sent, 'inbound' for messages received. |
| `status` | "queued" \| "sent" \| "delivered" \| "failed" \| "undelivered" \| "received" | yes | Delivery status: 'queued', 'sent', 'delivered', 'failed', 'undelivered', or 'received' (inbound messages). |
| `body` | string | yes | Message text. |
| `provider_message_sid` | string \| null | yes | The carrier/provider's identifier for this message, if assigned. |
| `segment_count` | integer | yes | Number of carrier SMS segments the body was split into. |
| `error_code` | string \| null | yes | Carrier error code if delivery failed. Null on success or while pending. |
| `requested_at` | string (date-time) | yes | When the send was requested, ISO 8601 timestamp. |
| `sent_at` | string (date-time) \| null | yes | When the message was handed to the carrier, ISO 8601 timestamp. Null until sent. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Subscription

`GET /v1/webhooks/{sub_id}`

Fetch one webhook subscription by id. The signing secret is omitted.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sub_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this subscription. |
| `organization_id` | string (uuid) | yes | Organization that owns this subscription. |
| `target_url` | string | yes | HTTPS URL event payloads are POSTed to. |
| `event_types` | string[] | yes | Event types this subscription receives. |
| `status` | "active" \| "disabled" | yes | 'active' (delivering) or 'disabled' (paused). |
| `consecutive_failures` | integer | yes | Consecutive failed delivery attempts since the last success. Resets to 0 on success. |
| `last_success_at` | string (date-time) \| null | no | When a delivery last succeeded, ISO 8601 timestamp. Null if never. |
| `last_failure_at` | string (date-time) \| null | no | When a delivery last failed, ISO 8601 timestamp. Null if never. |
| `created_at` | string (date-time) | yes | When this subscription was created, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this subscription was last modified, ISO 8601 timestamp. |
| `secret` | string \| null | no | Plaintext signing secret for verifying delivery payloads. Only present in the create and rotate-secret responses; every later read returns null. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Get Whoami

`GET /v1/whoami`

Identify the caller: auth kind, organization, and (if resolvable) user.

Useful for an agent that needs the human's address to put in
Reply-To, since the bearer token itself only carries an organization.
user_id/email/name come back null for a shared-key
(HAIL_API_KEY) call, which has no individual user behind it.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `auth_kind` | "apikey" \| "jwt" \| "shared" | yes | How the caller authenticated: 'apikey' (org API key), 'jwt' (logged-in user session), or 'shared' (the shared HAIL_API_KEY, which carries no human identity). |
| `organization_id` | string (uuid) | yes | Organization the caller belongs to. |
| `user_id` | string (uuid) \| null | no | The authenticated user's id. Null for 'shared' callers. |
| `email` | string \| null | no | The authenticated user's email. Null for 'shared' callers. |
| `name` | string \| null | no | The authenticated user's display name. Null for 'shared' callers. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Healthz

`GET /healthz`

Liveness check. Returns {"status": "ok"} with no auth required.

## Responses

### 200

Successful Response

_None._


---

# List Calls

`GET /v1/calls`

List calls for the caller's organization, newest first.

Cursor-paginated: pass the returned next_cursor to fetch the next page;
a null next_cursor means there are no more results. Filter by status or
destination number (to) to narrow the list.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |
| `status` | "queued" \| "dialing" \| "ringing" \| "in_progress" \| "completed" \| "failed" \| "busy" \| "no_answer" \| "canceled" \| null | no | — |
| `to` | string \| null | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Calls in this page, newest first. |
| `items.id` | string (uuid) | yes | Unique identifier for this call. |
| `items.organization_id` | string (uuid) | yes | Organization that placed or received this call. |
| `items.conversation_id` | string (uuid) \| null | yes | Conversation thread this call is grouped into, if any. Null when the call was not linked to a conversation. |
| `items.from_e164` | string | yes | Caller-id phone number used, E.164 format. |
| `items.to_e164` | string | yes | Recipient phone number, E.164 format. |
| `items.direction` | "outbound" \| "inbound" | yes | 'outbound' for calls Hail placed, 'inbound' for calls received. |
| `items.status` | "queued" \| "dialing" \| "ringing" \| "in_progress" \| "completed" \| "failed" \| "busy" \| "no_answer" \| "canceled" | yes | Current call-progress state: 'queued', 'dialing', 'ringing', 'in_progress', or one of the terminal states 'completed', 'failed', 'busy', 'no_answer', 'canceled'. |
| `items.end_reason` | string \| null | yes | Machine-readable reason the call reached a terminal status (e.g. 'normal_hangup', 'user_rejected', 'sip_trunk_failure'). Null while the call is still in progress. |
| `items.provider_call_sid` | string \| null | yes | The telephony provider's identifier for this call leg, if assigned. |
| `items.livekit_room` | string \| null | yes | Name of the LiveKit room hosting this call's media session, if one was created. |
| `items.initial_prompt` | string \| null | yes | The system_prompt this call was created with, if any. |
| `items.recording_s3_key` | string \| null | yes | Internal storage key for the call recording. Not a directly fetchable URL. |
| `items.requested_at` | string (date-time) | yes | When the call was requested, ISO 8601 timestamp. |
| `items.started_at` | string (date-time) \| null | yes | When dialing began, ISO 8601 timestamp. Null until the call starts. |
| `items.answered_at` | string (date-time) \| null | yes | When the callee answered, ISO 8601 timestamp. Null if never answered. |
| `items.ended_at` | string (date-time) \| null | yes | When the call ended, ISO 8601 timestamp. Null while still in progress. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Contacts

`GET /v1/contacts`

List contacts for the caller's organization, newest first.

Merges two sources into one list: org members (kind="member", ids
prefixed "member:", managed via membership — not editable here) and
manually-created contacts (kind="manual", editable via PATCH/DELETE
/contacts/{contact_id}). Cursor-paginated; q does a substring search
over name/phone/email.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | string \| null | no | — |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Contacts in this page. |
| `items.id` | string | yes | 'member:<user_id>' for an org member, or the contact row's UUID (as a string) for a manual contact. |
| `items.kind` | "member" \| "manual" | yes | 'member' if this row is a member of the organization, 'manual' if it was added as a contact. |
| `items.name` | string | yes | Display name. |
| `items.phone_e164` | string \| null | no | Phone number, E.164 format. Null if none on file. |
| `items.email` | string \| null | no | Email address. Null if none on file. |
| `items.role` | string \| null | no | Organization role (e.g. 'owner', 'admin', 'member') for kind='member'. Always null for kind='manual'. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Deliveries

`GET /v1/webhooks/{sub_id}/deliveries`

List delivery attempts for one webhook subscription, newest first.

Cursor-paginated. Each entry shows the attempt count, response status,
and response body Hail recorded — use POST /v1/webhooks/{sub_id}/
deliveries/{delivery_id}/redeliver to retry a failed one.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sub_id` | string (uuid) | yes | — |

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Delivery attempts in this page. |
| `items.id` | string (uuid) | yes | Unique identifier for this delivery attempt. |
| `items.subscription_id` | string (uuid) \| null | yes | Subscription this delivery belongs to. |
| `items.email_domain_id` | string (uuid) \| null | yes | Email domain the triggering event relates to, if any. Informational only (surfaced as the X-Hail-Email-Domain header) — not a routing target. |
| `items.event_type` | string | yes | The event type being delivered (e.g. 'call.completed'). |
| `items.event_id` | string (uuid) | yes | Identifier of the underlying event that triggered this delivery. |
| `items.attempt` | integer | yes | Number of delivery attempts made so far for this event, starting at 0. |
| `items.status` | "pending" \| "succeeded" \| "failed" \| "dead" | yes | 'pending' (queued/retrying), 'succeeded', 'failed' (will retry), or 'dead' (retries exhausted). |
| `items.response_status` | integer \| null | no | HTTP status code returned by the target URL on the last attempt. Null before any attempt. |
| `items.response_body` | string \| null | no | Response body returned by the target URL on the last attempt, if any. Null before any attempt. |
| `items.next_attempt_at` | string (date-time) | yes | When the next delivery attempt is scheduled, ISO 8601 timestamp. |
| `items.succeeded_at` | string (date-time) \| null | no | When this delivery succeeded, ISO 8601 timestamp. Null until it does. |
| `items.created_at` | string (date-time) | yes | When this delivery was queued, ISO 8601 timestamp. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Email Domains

`GET /v1/email-domains`

List sender domains/identities for the caller's organization.

Cursor-paginated, newest first. The response also includes
default_from — the address a from-less POST /v1/emails would use right
now given the org's current verified senders.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Email domains in this page. |
| `items.id` | string (uuid) | yes | Unique identifier for this email domain. |
| `items.organization_id` | string (uuid) | yes | Organization that owns this domain. |
| `items.kind` | "hail_mail" \| "custom" | yes | 'hail_mail' (Hail-hosted address) or 'custom' (your own domain). |
| `items.domain` | string | yes | The DNS domain mail is sent from. |
| `items.local_prefix_user` | string \| null | yes | User local-part prefix for a hail_mail address. Null for kind='custom'. |
| `items.local_prefix_org` | string \| null | yes | Org local-part prefix for a hail_mail address. Null for kind='custom'. |
| `items.verification_status` | "pending" \| "verified" \| "failed" | yes | 'pending' (not yet verified), 'verified' (ready to send), or 'failed'. |
| `items.dns_records` | object[] | yes | DNS records (DKIM, MAIL FROM MX, SPF) the tenant must publish to verify this domain. |
| `items.mail_from_domain` | string \| null | yes | Custom MAIL FROM domain, if configured. Null when using the provider default. |
| `items.mail_from_status` | string \| null | no | Verification status of the custom MAIL FROM domain, if one is configured. Secondary to verification_status. |
| `items.provider` | string | yes | Email sending provider for this domain (currently always 'ses'). |
| `items.verified_at` | string (date-time) \| null | yes | When the domain became verified, ISO 8601 timestamp. Null until it is. |
| `items.inbound_enabled` | boolean | no | Whether this domain accepts and forwards inbound mail. |
| `items.forward_to` | string[] \| null | no | Email addresses inbound mail is forwarded to, if inbound is enabled. |
| `items.forward_rate_per_hour` | integer \| null | no | Configured cap on forwarded messages per hour, if set. |
| `items.created_at` | string (date-time) | yes | When this domain was added, ISO 8601 timestamp. |
| `items.updated_at` | string (date-time) | yes | When this domain was last modified, ISO 8601 timestamp. |
| `items.receive_ready` | boolean \| null | no | True when the domain's published MX points at Hail's inbound host. Only populated by POST /{id}/verify on custom domains; null everywhere else. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |
| `default_from` | string \| null | no | The From address used when a send omits 'from'. Null when no such default can be resolved (e.g. multiple verified identities, or none that can send yet). Computed across the whole organization, not just this page. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Email Events

`GET /v1/emails/{email_id}/events`

Chronological lifecycle events for one email (org-scoped).

Cursor-paginated with the same forward-walk shape as ``GET /events``:
strictly-greater on ``(occurred_at, id)``, ascending.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email_id` | string (uuid) | yes | — |

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Events for this email, oldest first. |
| `items.id` | string (uuid) | yes | Unique identifier for this event. |
| `items.email_id` | string (uuid) | yes | The email this event belongs to. |
| `items.kind` | "sent" \| "delivered" \| "delivery_delayed" \| "bounced" \| "complained" \| "rejected" \| "opened" \| "clicked" | yes | Event kind: 'sent', 'delivered', 'delivery_delayed', 'bounced', 'complained', 'rejected', 'opened', or 'clicked'. 'opened' and 'clicked' only occur for emails sent with an HTML body; plain-text-only emails never produce them. |
| `items.payload` | object | yes | Event-kind-specific detail, as a free-form JSON object. |
| `items.occurred_at` | string (date-time) | yes | When this event occurred, ISO 8601 timestamp. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Emails

`GET /v1/emails`

List emails for the caller's organization, newest first.

Cursor-paginated: pass the returned next_cursor to fetch the next page;
a null next_cursor means there are no more results. Filter by status or
direction (outbound/inbound). List entries omit body_text/body_html —
fetch GET /v1/emails/{email_id} for the full body.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |
| `status` | "queued" \| "sent" \| "delivered" \| "failed" \| "bounced" \| "complained" \| "received" \| null | no | — |
| `direction` | "outbound" \| "inbound" \| null | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Emails in this page, newest first. |
| `items.id` | string (uuid) | yes | Unique identifier for this email. |
| `items.organization_id` | string (uuid) | yes | Organization that sent or received this email. |
| `items.conversation_id` | string (uuid) \| null | yes | Conversation thread this email is grouped into, if any. Null when it was not linked to a conversation. |
| `items.email_domain_id` | string (uuid) \| null | yes | The sending domain used, if from_address belongs to one of the org's configured domains. |
| `items.direction` | "outbound" \| "inbound" | no | 'outbound' for emails Hail sent, 'inbound' for emails received. |
| `items.from_address` | string | yes | Sender email address. |
| `items.from_name` | string \| null | no | Display name used on the From: header, when the sender supplied one. Always null on inbound rows. |
| `items.to_addresses` | string[] | yes | Recipient email addresses. |
| `items.cc_addresses` | string[] \| null | yes | CC recipient email addresses, if any. |
| `items.bcc_addresses` | string[] \| null | yes | BCC recipient email addresses, if any. |
| `items.reply_to` | string \| null | yes | Reply-To email address, if set. |
| `items.subject` | string | yes | Email subject line. |
| `items.status` | "queued" \| "sent" \| "delivered" \| "failed" \| "bounced" \| "complained" \| "received" | yes | Delivery status: 'queued', 'sent', 'delivered', 'failed', 'bounced', 'complained', or 'received' (inbound emails). |
| `items.end_reason` | string \| null | yes | Reason delivery failed or bounced, if applicable. Null on success or while pending. |
| `items.provider_message_id` | string \| null | yes | The email provider's identifier for this message, if assigned. |
| `items.requested_at` | string (date-time) | yes | When the send was requested, ISO 8601 timestamp. |
| `items.sent_at` | string (date-time) \| null | yes | When the message was handed to the provider, ISO 8601 timestamp. Null until sent. |
| `items.failed_at` | string (date-time) \| null | yes | When the send failed, ISO 8601 timestamp. Null unless it failed. |
| `items.metadata` | object | no | Free-form JSON object attached to the email, as sent on create. Not interpreted by Hail. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Events

`GET /v1/events`

Cursor-paginated forward stream of call, email, and SMS events.

Scoped to the caller's organization. Pass id (typed "<type>:<uuid>",
e.g. "call:<uuid>") to narrow to one resource's events, or kind to
narrow to one event kind. Walks forward in time — pass the returned
next_cursor to continue tailing where you left off.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |
| `id` | string \| null | no | — |
| `kind` | string \| null | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Events in this page, oldest first. |
| `items.id` | string (uuid) | yes | Unique identifier for this event. |
| `items.source` | "call" \| "email" \| "sms" | yes | Which channel this event belongs to: 'call', 'email', or 'sms'. |
| `items.call_id` | string (uuid) \| null | no | The call this event belongs to. Set only when source='call'. |
| `items.email_id` | string (uuid) \| null | no | The email this event belongs to. Set only when source='email'. |
| `items.sms_id` | string (uuid) \| null | no | The message this event belongs to. Set only when source='sms'. |
| `items.kind` | string | yes | Event kind within the source (e.g. 'queued', 'delivered', 'bounced'). Vocabulary differs per source. |
| `items.payload` | object | yes | Event-kind-specific detail, as a free-form JSON object. |
| `items.occurred_at` | string (date-time) | yes | When this event occurred, ISO 8601 timestamp. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |
| `call_status` | "queued" \| "dialing" \| "ringing" \| "in_progress" \| "completed" \| "failed" \| "busy" \| "no_answer" \| "canceled" \| null | no | Current status of the call named by the id filter (e.g. id=call:<uuid>). Null for org-wide tails and non-call filters, since there is no single call to report a status for. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Numbers

`GET /v1/numbers`

List dedicated numbers owned by the caller's organization.

Cursor-paginated, newest first. Only org-owned numbers are listed —
shared pool numbers used for outbound calls never appear here.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Numbers in this page. |
| `items.id` | string (uuid) | yes | Unique identifier for this number. |
| `items.e164` | string | yes | The phone number, E.164 format. |
| `items.country_code` | string | yes | ISO alpha-2 country code this number belongs to. |
| `items.number_type` | string | yes | Kind of number: 'local', 'mobile', 'toll_free', or 'national'. |
| `items.capabilities` | string[] | yes | Channels this number supports, e.g. ['voice'], ['sms'], or both. |
| `items.provisioning_state` | string | yes | 'pending', 'active', 'failed', or 'released'. |
| `items.is_dedicated` | boolean | yes | True if this number is owned by the organization. False for shared-pool numbers. |
| `items.messaging_service_sid` | string \| null | no | Provider messaging-service identifier once SMS has been enabled on this number. Null until then. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Providers

`GET /v1/providers`

Every saved provider row for the caller's organization, all layers.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `providers` | object[] | yes | Every saved provider row for the organization, across all layers. |
| `providers.layer` | "llm" \| "tts" \| "stt" | yes | Voice-pipeline layer this config applies to. |
| `providers.provider` | string | yes | Provider name (e.g. 'openai', 'cartesia'). |
| `providers.key_last4` | string \| null | yes | Last 4 characters of the saved API key, for display. Null if no key is saved. |
| `providers.key_set_at` | string \| null | yes | When the API key was last set, ISO 8601 timestamp. Null if no key is saved. |
| `providers.params` | object | yes | Saved provider-specific config for this row. |
| `providers.fallback_enabled` | boolean | yes | Whether Hail's default provider is used as a fallback if this one fails. |
| `providers.is_active` | boolean | yes | True if this is the row currently used by calls on this layer. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Sms Suppressions

`GET /v1/sms/suppressions`

List SMS numbers suppressed from receiving messages, newest first.

Cursor-paginated. A suppressed number blocks POST /v1/sms sends to it
with a 403 until removed via DELETE /v1/sms/suppressions/{number}.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Suppressed recipients in this page. |
| `items.id` | string (uuid) | yes | Unique identifier for this suppression entry. |
| `items.recipient` | string | yes | The suppressed recipient — E.164 phone number for voice/sms, lowercased email address for email. |
| `items.channel` | string | yes | Channel this entry blocks sends on: 'voice', 'email', 'sms', or 'all' (every channel). |
| `items.reason` | string | yes | Why the recipient was suppressed (e.g. an unsubscribe or a bounce). |
| `items.source` | string | yes | How this entry was created: 'unsubscribe_link', 'manual' (an operator action), or 'bounce'. |
| `items.created_at` | string (date-time) | yes | When this entry was created, ISO 8601 timestamp. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Sms

`GET /v1/sms`

List SMS messages for the caller's organization, newest first.

Cursor-paginated: pass the returned next_cursor to fetch the next page;
a null next_cursor means there are no more results. Filter by status or
destination number (to) to narrow the list.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |
| `status` | "queued" \| "sent" \| "delivered" \| "failed" \| "undelivered" \| "received" \| null | no | — |
| `to` | string \| null | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Messages in this page, newest first. |
| `items.id` | string (uuid) | yes | Unique identifier for this message. |
| `items.organization_id` | string (uuid) | yes | Organization that sent or received this message. |
| `items.from_e164` | string | yes | Sender phone number, E.164 format. |
| `items.to_e164` | string | yes | Recipient phone number, E.164 format. |
| `items.direction` | "outbound" \| "inbound" | yes | 'outbound' for messages Hail sent, 'inbound' for messages received. |
| `items.status` | "queued" \| "sent" \| "delivered" \| "failed" \| "undelivered" \| "received" | yes | Delivery status: 'queued', 'sent', 'delivered', 'failed', 'undelivered', or 'received' (inbound messages). |
| `items.body` | string | yes | Message text. |
| `items.provider_message_sid` | string \| null | yes | The carrier/provider's identifier for this message, if assigned. |
| `items.segment_count` | integer | yes | Number of carrier SMS segments the body was split into. |
| `items.error_code` | string \| null | yes | Carrier error code if delivery failed. Null on success or while pending. |
| `items.requested_at` | string (date-time) | yes | When the send was requested, ISO 8601 timestamp. |
| `items.sent_at` | string (date-time) \| null | yes | When the message was handed to the carrier, ISO 8601 timestamp. Null until sent. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# List Subscriptions

`GET /v1/webhooks`

List webhook subscriptions for the caller's organization.

Cursor-paginated, newest first. The signing secret is never included —
only POST /v1/webhooks and POST /v1/webhooks/{sub_id}/rotate-secret return it.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string \| null | no | — |
| `limit` | integer | no | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | yes | Subscriptions in this page. |
| `items.id` | string (uuid) | yes | Unique identifier for this subscription. |
| `items.organization_id` | string (uuid) | yes | Organization that owns this subscription. |
| `items.target_url` | string | yes | HTTPS URL event payloads are POSTed to. |
| `items.event_types` | string[] | yes | Event types this subscription receives. |
| `items.status` | "active" \| "disabled" | yes | 'active' (delivering) or 'disabled' (paused). |
| `items.consecutive_failures` | integer | yes | Consecutive failed delivery attempts since the last success. Resets to 0 on success. |
| `items.last_success_at` | string (date-time) \| null | no | When a delivery last succeeded, ISO 8601 timestamp. Null if never. |
| `items.last_failure_at` | string (date-time) \| null | no | When a delivery last failed, ISO 8601 timestamp. Null if never. |
| `items.created_at` | string (date-time) | yes | When this subscription was created, ISO 8601 timestamp. |
| `items.updated_at` | string (date-time) | yes | When this subscription was last modified, ISO 8601 timestamp. |
| `items.secret` | string \| null | no | Plaintext signing secret for verifying delivery payloads. Only present in the create and rotate-secret responses; every later read returns null. |
| `next_cursor` | string \| null | no | Opaque cursor for the next page. Null when there are no more results. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Patch Contact

`PATCH /v1/contacts/{contact_id}`

Update a manual contact's fields. Only fields present in the body change.

Manual contacts only — a member: id (org members synced from
membership) returns 422; edit those via the membership APIs instead.
The contact must still have at least one of phone_e164 or email after
the update. Fails with 409 on a duplicate phone/email.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `contact_id` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string \| null | no | New display name. Omit to leave unchanged; cannot be set to null. |
| `phone_e164` | string \| null | no | New phone number, E.164 format. Omit to leave unchanged; explicit null clears it. The contact must keep at least one of phone_e164 or email. |
| `email` | string \| null | no | New email address, stored lowercased. Omit to leave unchanged; explicit null clears it. The contact must keep at least one of phone_e164 or email. |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | 'member:<user_id>' for an org member, or the contact row's UUID (as a string) for a manual contact. |
| `kind` | "member" \| "manual" | yes | 'member' if this row is a member of the organization, 'manual' if it was added as a contact. |
| `name` | string | yes | Display name. |
| `phone_e164` | string \| null | no | Phone number, E.164 format. Null if none on file. |
| `email` | string \| null | no | Email address. Null if none on file. |
| `role` | string \| null | no | Organization role (e.g. 'owner', 'admin', 'member') for kind='member'. Always null for kind='manual'. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Patch Email Domain

`PATCH /v1/email-domains/{domain_id}`

Edit hail-mail prefixes and/or inbound action settings.

Two modes, mutually compatible:

* **Prefix edit** (``local_prefix_user`` / ``local_prefix_org``):
  hail_mail rows only. The managed-cloud console writes here when
  an org admin changes the visible hail-mail address.
* **Inbound action edit** (``inbound_enabled`` / ``forward_to`` /
  ``forward_rate_per_hour``): any row kind.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `domain_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `local_prefix_user` | string \| null | no | New user local-part prefix. Only valid on kind='hail_mail' rows. Omit to leave unchanged. |
| `local_prefix_org` | string \| null | no | New org local-part prefix. Only valid on kind='hail_mail' rows. Omit to leave unchanged. |
| `inbound_enabled` | boolean \| null | no | Whether to accept inbound mail on this domain. Requires forward_to (or an existing one) when true. Omit to leave unchanged. |
| `forward_to` | string[] \| null | no | Email addresses to forward inbound mail to. Omit to leave unchanged. |
| `forward_rate_per_hour` | integer \| null | no | Cap on forwarded messages per hour. Omit to leave unchanged. |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this email domain. |
| `organization_id` | string (uuid) | yes | Organization that owns this domain. |
| `kind` | "hail_mail" \| "custom" | yes | 'hail_mail' (Hail-hosted address) or 'custom' (your own domain). |
| `domain` | string | yes | The DNS domain mail is sent from. |
| `local_prefix_user` | string \| null | yes | User local-part prefix for a hail_mail address. Null for kind='custom'. |
| `local_prefix_org` | string \| null | yes | Org local-part prefix for a hail_mail address. Null for kind='custom'. |
| `verification_status` | "pending" \| "verified" \| "failed" | yes | 'pending' (not yet verified), 'verified' (ready to send), or 'failed'. |
| `dns_records` | object[] | yes | DNS records (DKIM, MAIL FROM MX, SPF) the tenant must publish to verify this domain. |
| `dns_records.name` | string | yes | DNS record name/host to publish (e.g. a CNAME's subdomain). |
| `dns_records.value` | string | yes | DNS record value to publish (e.g. a CNAME target or TXT content). |
| `dns_records.type` | "CNAME" \| "MX" \| "TXT" | no | DNS record type: 'CNAME' (DKIM), 'MX' (MAIL FROM), or 'TXT' (SPF). |
| `dns_records.priority` | integer \| null | no | MX priority. Only present for type='MX'; null otherwise. |
| `mail_from_domain` | string \| null | yes | Custom MAIL FROM domain, if configured. Null when using the provider default. |
| `mail_from_status` | string \| null | no | Verification status of the custom MAIL FROM domain, if one is configured. Secondary to verification_status. |
| `provider` | string | yes | Email sending provider for this domain (currently always 'ses'). |
| `verified_at` | string (date-time) \| null | yes | When the domain became verified, ISO 8601 timestamp. Null until it is. |
| `inbound_enabled` | boolean | no | Whether this domain accepts and forwards inbound mail. |
| `forward_to` | string[] \| null | no | Email addresses inbound mail is forwarded to, if inbound is enabled. |
| `forward_rate_per_hour` | integer \| null | no | Configured cap on forwarded messages per hour, if set. |
| `created_at` | string (date-time) | yes | When this domain was added, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this domain was last modified, ISO 8601 timestamp. |
| `receive_ready` | boolean \| null | no | True when the domain's published MX points at Hail's inbound host. Only populated by POST /{id}/verify on custom domains; null everywhere else. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Patch Sender Id

`PATCH /v1/sms/sender-id`

Set or clear the org's custom alphanumeric SMS sender ID.

Pass custom_sender_id=null to clear it and fall back to the platform
default sender id. Only affects alphanumeric-eligible corridors — a
corridor requiring a dedicated number is unaffected.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_sender_id` | string \| null | no | Alphanumeric sender id (2-11 characters, letters/digits only) to use on alphanumeric-eligible corridors instead of a phone number. Explicit null clears it, reverting to the platform default. |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_sender_id` | string \| null | yes | The organization's configured alphanumeric sender id. Null if none is set. |
| `effective_default` | string | no | The platform's alphanumeric sender id, used on eligible corridors when custom_sender_id is null. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Patch Subscription

`PATCH /v1/webhooks/{sub_id}`

Update a webhook subscription's target_url, event_types, and/or status.

Only fields present in the request body are changed. Setting
status="active" resets the consecutive-failure counter, so a
subscription that was auto-disabled after 50 straight delivery
failures does not immediately re-disable itself.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sub_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `target_url` | string \| null | no | New delivery URL. Omit to leave unchanged. |
| `event_types` | "email.received" \| "email.delivered" \| "email.delivery_delayed" \| "email.bounced" \| "email.complained" \| "email.opened" \| "email.clicked" \| "email.received.suppressed" \| "email.send_failed" \| "sms.received" \| "sms.delivered" \| "sms.undelivered" \| "sms.failed" \| "call.answered" \| "call.completed" \| "call.failed" \| "call.busy" \| "call.no_answer"[] \| null | no | New set of subscribed event types. Omit to leave unchanged. |
| `status` | "active" \| "disabled" \| null | no | Set to 'disabled' to pause deliveries, or 'active' to resume. Omit to leave unchanged. |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this subscription. |
| `organization_id` | string (uuid) | yes | Organization that owns this subscription. |
| `target_url` | string | yes | HTTPS URL event payloads are POSTed to. |
| `event_types` | string[] | yes | Event types this subscription receives. |
| `status` | "active" \| "disabled" | yes | 'active' (delivering) or 'disabled' (paused). |
| `consecutive_failures` | integer | yes | Consecutive failed delivery attempts since the last success. Resets to 0 on success. |
| `last_success_at` | string (date-time) \| null | no | When a delivery last succeeded, ISO 8601 timestamp. Null if never. |
| `last_failure_at` | string (date-time) \| null | no | When a delivery last failed, ISO 8601 timestamp. Null if never. |
| `created_at` | string (date-time) | yes | When this subscription was created, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this subscription was last modified, ISO 8601 timestamp. |
| `secret` | string \| null | no | Plaintext signing secret for verifying delivery payloads. Only present in the create and rotate-secret responses; every later read returns null. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Put Member Phone

`PUT /v1/members/{user_id}/phone`

Set an org member's phone number.

Pass user_id="me" to set your own, or a member's user id — setting
another member's phone requires the caller to be an org owner or
admin. Returns 404 if the target is not a member of this organization.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone_e164` | string | yes | Phone number to save for the caller, E.164 format. |

## Responses

### 200

Successful Response

_None._

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Redeliver

`POST /v1/webhooks/{sub_id}/deliveries/{delivery_id}/redeliver`

Retry one webhook delivery attempt.

Resets it to pending with a fresh attempt counter — the delivery
worker picks it up and re-sends the same event payload to target_url
shortly after. Useful after fixing an endpoint that was returning
errors.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sub_id` | string (uuid) | yes | — |
| `delivery_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this delivery attempt. |
| `subscription_id` | string (uuid) \| null | yes | Subscription this delivery belongs to. |
| `email_domain_id` | string (uuid) \| null | yes | Email domain the triggering event relates to, if any. Informational only (surfaced as the X-Hail-Email-Domain header) — not a routing target. |
| `event_type` | string | yes | The event type being delivered (e.g. 'call.completed'). |
| `event_id` | string (uuid) | yes | Identifier of the underlying event that triggered this delivery. |
| `attempt` | integer | yes | Number of delivery attempts made so far for this event, starting at 0. |
| `status` | "pending" \| "succeeded" \| "failed" \| "dead" | yes | 'pending' (queued/retrying), 'succeeded', 'failed' (will retry), or 'dead' (retries exhausted). |
| `response_status` | integer \| null | no | HTTP status code returned by the target URL on the last attempt. Null before any attempt. |
| `response_body` | string \| null | no | Response body returned by the target URL on the last attempt, if any. Null before any attempt. |
| `next_attempt_at` | string (date-time) | yes | When the next delivery attempt is scheduled, ISO 8601 timestamp. |
| `succeeded_at` | string (date-time) \| null | no | When this delivery succeeded, ISO 8601 timestamp. Null until it does. |
| `created_at` | string (date-time) | yes | When this delivery was queued, ISO 8601 timestamp. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Release Number

`DELETE /v1/numbers/{number_id}`

Release a dedicated number. The monthly fee stops accruing after the
release month; months already accrued stay owed (the rater bills late,
never forgives).

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `number_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 204

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Rotate Secret

`POST /v1/webhooks/{sub_id}/rotate-secret`

Generate a new signing secret and immediately invalidate the old one.

The response includes the new plaintext secret — this is the only
time it is returned; update your verification code with it right away,
since the old secret stops validating new deliveries immediately.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sub_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this subscription. |
| `organization_id` | string (uuid) | yes | Organization that owns this subscription. |
| `target_url` | string | yes | HTTPS URL event payloads are POSTed to. |
| `event_types` | string[] | yes | Event types this subscription receives. |
| `status` | "active" \| "disabled" | yes | 'active' (delivering) or 'disabled' (paused). |
| `consecutive_failures` | integer | yes | Consecutive failed delivery attempts since the last success. Resets to 0 on success. |
| `last_success_at` | string (date-time) \| null | no | When a delivery last succeeded, ISO 8601 timestamp. Null if never. |
| `last_failure_at` | string (date-time) \| null | no | When a delivery last failed, ISO 8601 timestamp. Null if never. |
| `created_at` | string (date-time) | yes | When this subscription was created, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this subscription was last modified, ISO 8601 timestamp. |
| `secret` | string \| null | no | Plaintext signing secret for verifying delivery payloads. Only present in the create and rotate-secret responses; every later read returns null. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Unsubscribe

`GET /v1/unsubscribe`

One-click email opt-out (RFC 8058), reached by clicking a link in a sent email.

Public and unauthenticated — the signed token query param is the sole
credential, proving the caller holds a link Hail sent to that address.
On success, the address is added to the org's suppression list and all
future outbound email to it is blocked. Returns an HTML page, not JSON.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | string | yes | — |

## Responses

### 200

Successful Response

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |


---

# Create Email Attachment

`POST /v1/email-attachments`

Upload a file and get back a reusable attachment id.

The returned id can be referenced from attachment_ids on many later
POST /v1/emails calls until it is garbage-collected for being unused; it is
not deleted immediately after first use. Uploads are size-limited and
scoped to the caller's organization.

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 201

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Reusable attachment id — pass it in EmailCreate.attachment_ids to attach it to a send. |
| `filename` | string | yes | Original filename of the uploaded file. |
| `content_type` | string | yes | MIME type of the uploaded file. |
| `size_bytes` | integer | yes | Size of the uploaded file in bytes. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Upsert Provider

`PUT /v1/providers/{layer}`

Save a provider for ``layer`` and make it the active one.

A partial write: anything you omit keeps its saved value. Omitting
``api_key`` keeps the stored key, ``params`` keys you don't send are
preserved, and omitting ``fallback_enabled`` leaves the flag alone
(``false`` on a new row). The merged result — not the partial input —
is validated against the layer's schema (422 on a mismatch). 404 on an
unknown layer.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `layer` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `provider` | string | yes | Provider name to save/activate for this layer (e.g. 'openai', 'cartesia'). |
| `api_key` | string \| null | no | API key for this provider. Omit to edit params without resending the key. Write-only — never echoed back. |
| `params` | object | no | Provider-specific config, validated against the layer's schema (LLMParams/TTSParams/STTParams). Only the keys you send are changed; other saved keys are kept. |
| `fallback_enabled` | boolean \| null | no | Whether to fall back to Hail's default provider on failure. Omit to leave unchanged; defaults to false on a new row. |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `layer` | "llm" \| "tts" \| "stt" | yes | Voice-pipeline layer this config applies to. |
| `provider` | string | yes | Provider name (e.g. 'openai', 'cartesia'). |
| `key_last4` | string \| null | yes | Last 4 characters of the saved API key, for display. Null if no key is saved. |
| `key_set_at` | string \| null | yes | When the API key was last set, ISO 8601 timestamp. Null if no key is saved. |
| `params` | object | yes | Saved provider-specific config for this row. |
| `fallback_enabled` | boolean | yes | Whether Hail's default provider is used as a fallback if this one fails. |
| `is_active` | boolean | yes | True if this is the row currently used by calls on this layer. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Validate Provider

`POST /v1/providers/{layer}/validate`

Probe a provider key against the real provider.

Empty body tests the layer's active provider with its stored key; send
``provider`` to test a specific saved row, or ``api_key`` (plus
``provider``/``params``) to test a key before saving it.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `layer` | string | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `api_key` | string \| null | no | Key to test instead of the stored one. Not persisted. |
| `provider` | string \| null | no | Provider to test. Omitted: the layer's currently active provider. |
| `params` | object | no | Provider-specific config to test alongside the key. |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | Probe outcome: 'valid', 'invalid', or 'indeterminate' (the provider could not be reached). |
| `message` | string \| null | yes | Human-readable detail about the outcome. 'ok' on success, an error description otherwise. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).


---

# Verify Email Domain

`POST /v1/email-domains/{domain_id}/verify`

Re-poll the email provider for the current verification status.

On-demand only — there is no background poller in v1. Operators /
tenants hit this after publishing DNS to flip the row to ``verified``.
Hail-mail rows are no-ops (they're already verified by construction).

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `domain_id` | string (uuid) | yes | — |

## Header parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `authorization` | string \| null | no | — |

## Responses

### 200

Successful Response

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | Unique identifier for this email domain. |
| `organization_id` | string (uuid) | yes | Organization that owns this domain. |
| `kind` | "hail_mail" \| "custom" | yes | 'hail_mail' (Hail-hosted address) or 'custom' (your own domain). |
| `domain` | string | yes | The DNS domain mail is sent from. |
| `local_prefix_user` | string \| null | yes | User local-part prefix for a hail_mail address. Null for kind='custom'. |
| `local_prefix_org` | string \| null | yes | Org local-part prefix for a hail_mail address. Null for kind='custom'. |
| `verification_status` | "pending" \| "verified" \| "failed" | yes | 'pending' (not yet verified), 'verified' (ready to send), or 'failed'. |
| `dns_records` | object[] | yes | DNS records (DKIM, MAIL FROM MX, SPF) the tenant must publish to verify this domain. |
| `dns_records.name` | string | yes | DNS record name/host to publish (e.g. a CNAME's subdomain). |
| `dns_records.value` | string | yes | DNS record value to publish (e.g. a CNAME target or TXT content). |
| `dns_records.type` | "CNAME" \| "MX" \| "TXT" | no | DNS record type: 'CNAME' (DKIM), 'MX' (MAIL FROM), or 'TXT' (SPF). |
| `dns_records.priority` | integer \| null | no | MX priority. Only present for type='MX'; null otherwise. |
| `mail_from_domain` | string \| null | yes | Custom MAIL FROM domain, if configured. Null when using the provider default. |
| `mail_from_status` | string \| null | no | Verification status of the custom MAIL FROM domain, if one is configured. Secondary to verification_status. |
| `provider` | string | yes | Email sending provider for this domain (currently always 'ses'). |
| `verified_at` | string (date-time) \| null | yes | When the domain became verified, ISO 8601 timestamp. Null until it is. |
| `inbound_enabled` | boolean | no | Whether this domain accepts and forwards inbound mail. |
| `forward_to` | string[] \| null | no | Email addresses inbound mail is forwarded to, if inbound is enabled. |
| `forward_rate_per_hour` | integer \| null | no | Configured cap on forwarded messages per hour, if set. |
| `created_at` | string (date-time) | yes | When this domain was added, ISO 8601 timestamp. |
| `updated_at` | string (date-time) | yes | When this domain was last modified, ISO 8601 timestamp. |
| `receive_ready` | boolean \| null | no | True when the domain's published MX points at Hail's inbound host. Only populated by POST /{id}/verify on custom domains; null everywhere else. |

### 422

Validation Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detail` | object[] | no | List of validation errors: each entry gives the field location (loc), the problem (msg), and the error type (type). |
| `detail.loc` | string \| integer[] | yes | Path to the invalid field within the request, as a list of keys/indices (e.g. ['body', 'to']). |
| `detail.msg` | string | yes | Human-readable description of the validation failure. |
| `detail.type` | string | yes | Machine-readable error type code (e.g. 'missing', 'string_type'). |
| `detail.input` | any | no | The value that was actually provided and failed validation. |
| `detail.ctx` | object | no | Additional machine-readable context for the error, when the error type provides one. |

### 429

Rate limited. This caller exceeded the general request-rate ceiling. Retry after the Retry-After header (seconds).
