# Bi-directional SMS API BlueHive HUM exposes a full REST + webhook surface for SMS. The same infrastructure powers: - Two-way conversations with contacts (manual or AI-driven) - Routing inbound messages to a Call Flow so the AI can reply - Outbound campaigns with throttling and quiet-hours awareness - TCPA-style opt-out handling (STOP / HELP / START) - MMS (inbound media is stored and mirrored to your object storage) - Third-party webhooks for inbound messages, delivery receipts, and conversation lifecycle events All endpoints are tenant-scoped via the standard HUM auth (session cookie or API key). They are mounted under `/api/sms/*`. --- ## 1. Concepts | Concept | Description | | ------------------ | ------------------------------------------------------------------------------------------ | | **Number** | A phone number you've provisioned in HUM. Each can have SMS enabled and a default flow. | | **Conversation** | A unique pairing of `(your number, contact E.164)`. Threads survive forever and reopen on inbound. | | **Thread message** | A single inbound or outbound SMS/MMS. Idempotent on the carrier message id. | | **Flow (channel='sms')** | A Call Flow whose `channel` is `sms`. Holds the AI prompt, model, tools, and config. | | **AI run** | A single AI turn (one model request + tool loop) that processed a batch of inbound messages. | | **Scheduled message** | A message held until a future time (manual schedule, AI follow-up, or quiet-hours auto-defer). | | **Campaign** | A throttled bulk send to a recipient list, optionally tied to a flow. | Inbound messages flow: ``` Carrier webhook → HUM inbound endpoint → upsert conversation → write thread message → fire `sms.received` webhook → enqueue (debounced) → AI turn ``` Outbound messages always flow through a single send pipeline, which enforces: 1. Conversation status (closed → reject) 2. Opt-out check (return `opted_out` unless message is a system reply) 3. Quiet-hours check (auto-defer to a scheduled send if enabled) 4. Carrier API call (with status callback) 5. Webhook fire (`sms.sent`, then `sms.delivered` / `sms.failed`) --- ## 2. Configuring a number for SMS Each number has these SMS settings: | Setting | Purpose | | ------------------------- | -------------------------------------------------------------------- | | `sms_enabled` | Allow inbound + outbound on this number | | `mms_enabled` | Allow MMS (media) inbound + outbound | | `sms_flow_id` | Default flow for inbound messages (`channel = 'sms'`) | | `sms_webhook_url` | Override the inbound webhook URL | | `sms_status_callback_url` | Override the delivery-receipt URL | Inbound and status-callback webhooks are configured automatically when you enable SMS on a number. --- ## 3. REST endpoints All require auth + an active org. All return JSON. Errors are `{ "error": "" }` with an HTTP status code. ### Conversations #### `GET /api/sms/conversations` List conversations for the org. | Query param | Description | | -------------------- | ------------------------------------------ | | `status` | `open`, `snoozed`, or `closed` | | `assigned_user_id` | Filter by assignee | | `twilio_number_id` | Filter by inbound number | | `q` | Search contact E.164 or last message text | | `limit` | 1–200, default 50 | | `cursor` | Opaque cursor from previous response | Returns: ```json { "conversations": [...], "next_cursor": "MjAyNS0wMS0wMVQxMjowMHwwMTk0..." } ``` #### `POST /api/sms/conversations` Create (or fetch) a conversation, optionally sending an initial message. ```json { "twilio_number_id": "01HV...", "contact_e164": "+13175551234", "flow_id": null, "initial_message": { "body": "Hi! Reminder about your appointment tomorrow.", "media": [{ "url": "https://...", "content_type": "image/png" }] } } ``` Response status is `201` for new, `200` for existing. #### `GET /api/sms/conversations/:id` Single conversation with denormalized counters and metadata. #### `PATCH /api/sms/conversations/:id` Update one or more fields. Only the keys you send are changed. ```json { "ai_paused": true, "assigned_user_id": "user_01HV...", "status": "snoozed", "snoozed_until": "2025-02-01T09:00:00Z", "flow_id": "flow_01HV..." } ``` Side-effects: setting `assigned_user_id` fires `conversation.assigned`; setting `status: "closed"` fires `conversation.closed`. #### `POST /api/sms/conversations/:id/messages` Send an outbound message (human takeover or API). Goes through the same opt-out + quiet-hours pipeline as the AI. ```json { "body": "On my way!", "media": [{ "url": "https://r2.example.com/img.jpg" }], "scheduled_at": "2025-02-01T09:00:00Z", "purpose": "appointment-reminder" } ``` If `scheduled_at` is in the future, the message is queued and the response is `202` with the row. Otherwise the response carries the send result: ```json { "message_id": "01HV...", "twilio_sid": "SM...", "status": "sent" // sent | queued | failed | opted_out | quiet_hours | invalid_number | ... } ``` `status` other than `sent` / `queued` / `scheduled` indicate operational failures — the request itself succeeded. #### `GET /api/sms/conversations/:id/messages` Paginated thread (most recent → oldest, then reversed in response). | Query | Description | | ----- | -------------------------------------------- | | `limit` | 1–200, default 50 | | `before` | ISO timestamp; returns messages older than this | #### `POST /api/sms/conversations/:id/read` Resets `unread_count` to 0. #### `POST /api/sms/conversations/:id/close` Convenience for `PATCH { status: "closed" }`. Fires `conversation.closed`. #### `POST /api/sms/conversations/:id/reopen` Sets status back to `open` and clears `snoozed_until`. ### Campaigns #### `GET /api/sms/campaigns` List the most recent 200 campaigns. #### `POST /api/sms/campaigns` Create a campaign. Recipients are optional inline; you can add more later through your own pipeline. ```json { "name": "January refill reminder", "twilio_number_id": "01HV...", "body_template": "Hi! Time to refill your prescription. Reply STOP to opt out.", "throttle_per_minute": 30, "scheduled_start_at": "2025-02-01T09:00:00Z", "recipients": [ { "contact_e164": "+13175551234" }, { "contact_e164": "+13175555678", "body": "Custom message override for this recipient" } ] } ``` #### `POST /api/sms/campaigns/:id/launch` Flips status from `draft` or `paused` → `queued`. The dispatcher picks it up on the next minute boundary. #### `POST /api/sms/campaigns/:id/cancel` Stops further sends. In-flight messages already handed to the carrier still deliver. ### Opt-outs The platform automatically processes carrier-level keywords (STOP, START, HELP). You can inspect or override the list: #### `GET /api/sms/opt-outs` #### `POST /api/sms/opt-outs` ```json { "e164": "+13175551234", "reason": "manual:do-not-contact" } ``` Fires `sms.opt_out`. #### `DELETE /api/sms/opt-outs/:e164` URL-encode the `+`. Fires `sms.opt_in`. ### Scheduled messages #### `GET /api/sms/scheduled?status=pending` Lists currently pending sends (manual schedules, AI follow-ups, and quiet-hours-deferred messages). #### `DELETE /api/sms/scheduled/:id` Cancels a pending message. --- ## 4. Routing inbound messages to AI To enable AI replies on a number: 1. Create a Call Flow with `channel = 'sms'`. The flow's `sms_config_json` controls AI behavior: ```json { "max_reply_chars": 1600, "debounce_ms": 1500, "max_turns_per_window": 30, "max_messages_per_run": 3, "fallback_message": "We'll get back to you shortly.", "system_prompt_addendum": "You are a helpful assistant for ACME Clinic." } ``` All keys are optional; missing keys fall back to runtime defaults. The base system prompt and the tool list both come from the flow's existing `system_prompt` / `tools_json` fields (the same fields the voice runtime consumes) — SMS does not maintain a separate tool list. 2. Set the flow on the number: `PATCH /api/numbers/:id` (existing endpoint) with `sms_flow_id`. 3. Inbound messages now flow into the platform, conversations are debounced briefly (or until a burst of messages arrives), and a single AI turn produces a reply. ### Built-in SMS tools The AI runtime ships with these function-call tools: | Tool | Effect | | ------------------------- | ------------------------------------------------------------------------------ | | `assign_to_human` | Pause AI on this conversation, set assignee; fires `conversation.assigned`. | | `close_conversation` | Mark conversation closed; fires `conversation.closed`. | | `transfer_sms_to_flow` | Hand off to another flow (must be same org and `channel='sms'`). | | `schedule_followup_sms` | Queue a future message (`source='ai'`). | | `lookup_knowledge` | (Stub) Hook up to your KB / RAG endpoint. | | `report_outcome` | Append a structured outcome to the conversation metadata. | You can wire EMR tool calls (e.g. "reschedule appointment") by adding custom tools in the Tool Registry and listing them on the flow — this uses the same registry the voice runtime consumes. ### Anti-loop guards - `max_turns_per_window`: cap on AI runs per 10-minute window per conversation. If exceeded the run is recorded as `tool_loop_exceeded` and AI is paused on that conversation. - `max_messages_per_run`: maximum *new inbound messages* a single AI run will see. Older messages are visible via the prompt history primer. - `parallel_tool_calls: false`: tools run sequentially, never as a thrashing fan-out. --- ## 5. Webhooks (third-party integrations) Configure webhook subscriptions via the existing webhook UI or API. SMS adds the following events: | Event | Payload includes | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `sms.received` | `conversation_id`, `message_id`, `twilio_number_id`, `twilio_number_e164`, `contact_e164`, `body`, `media[]`, `twilio_sid`, `first_in_conversation` | | `sms.sent` | `conversation_id`, `message_id`, `twilio_sid`, `to`, `from`, `messaging_service_sid`, `body`, `media[]`, `source`, `status` | | `sms.delivered` | `conversation_id`, `message_id`, `twilio_sid` | | `sms.failed` | `conversation_id`, `message_id`, `twilio_sid`, `error_code`, `error` | | `sms.opt_out` | `contact_e164`, `reason`, `source` | | `sms.opt_in` | `contact_e164`, `source` | | `conversation.started` | `conversation_id`, `twilio_number_id`, `contact_e164`, `flow_id`, `source` | | `conversation.assigned`| `conversation_id`, `assigned_user_id`, `previous_assignee`, `source` | | `conversation.closed` | `conversation_id`, `source` | `source` is one of `inbound`, `ai`, `human`, `api`, `campaign`, `system`. `media[]` items use the shape `{ "url": "...", "content_type": "image/jpeg" }`. Delivery webhooks fire only on the *first* transition into a terminal state, so duplicate carrier status callbacks (which are common) do not cause duplicate downstream notifications. --- ## 6. Quiet hours & compliance - STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT → records opt-out, fires `sms.opt_out`, sends one confirmation reply. - HELP / INFO → sends configured help message (no opt-out change). - START / YES / UNSTOP → clears opt-out, sends a confirmation, fires `sms.opt_in`. - Quiet-hours: when the org's business hours are configured, outbound messages that fall outside hours are auto-deferred to a scheduled send with `source='quiet_hours'` and `scheduled_at` set to the next open window. Set `honor_quiet_hours=false` per send to override (the REST API does this by default for human-initiated sends; the AI honors quiet hours). --- ## 7. Examples ### cURL: send a one-off text ```bash curl -X POST https://api.bluehive.com/api/sms/conversations \ -H 'Authorization: Bearer $API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "twilio_number_id": "01HVN...", "contact_e164": "+13175551234", "initial_message": { "body": "Hi! Just confirming your 10am appointment." } }' ``` ### cURL: enable inbound AI on a number ```bash # 1) Create a SMS flow curl -X POST https://api.bluehive.com/api/flows \ -H 'Authorization: Bearer $API_KEY' \ -d '{ "name": "SMS triage", "channel": "sms", "sms_config_json": { ... } }' # 2) Wire it to the number curl -X PATCH https://api.bluehive.com/api/numbers/01HVN... \ -H 'Authorization: Bearer $API_KEY' \ -d '{ "sms_flow_id": "flow_...", "sms_enabled": true }' ``` ### Webhook receiver (Node.js, Express) ```js app.post('/webhooks/bluehive', (req, res) => { const { event, data } = req.body; switch (event) { case 'sms.received': console.log(`New SMS from ${data.contact_e164}: ${data.body}`); break; case 'conversation.assigned': notifyAgent(data.assigned_user_id, data.conversation_id); break; } res.sendStatus(204); }); ``` --- ## 8. Operational notes - Inbound is idempotent on the carrier message id — carrier retries are safe. - Conversation rows live forever; closing one does *not* delete history. An inbound message on a closed conversation reopens it automatically. - AI runs are debounced per conversation, so two parallel inbound messages collapse into a single AI turn. - A scheduler runs every minute and drains both scheduled messages (per-row) and campaigns (per `throttle_per_minute`). - Stored MMS media is served through your existing object-storage CORS / bucket policy.