{"openapi":"3.1.0","info":{"title":"BlueOpen API","version":"1.0.0","description":"BlueOpen is a multi-tenant API for sending iMessage and SMS messages through PushCut-controlled iPhones. Authenticate with a Bearer API key from your BlueOpen admin settings, POST a message and recipient, and BlueOpen handles device selection, daily-cap enforcement, retries, and failover. Use GET /api/messages/{id} to poll status. Devices are pinned per-contact (sticky) by default; pass `from` to override and pin a send to a specific device.","contact":{"name":"BlueOpen","url":"https://www.theblueopen.com"}},"servers":[{"url":"https://www.theblueopen.com","description":"Production"},{"url":"http://localhost:3000","description":"Local dev"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Messages","description":"Send messages and poll their status"},{"name":"Campaigns","description":"Tag outbound messages with a campaign for reporting"},{"name":"Opt-outs","description":"Do-not-contact list management"},{"name":"Health","description":"Service health"}],"webhooks":{"message.sent":{"post":{"summary":"Outbound message delivered to PushCut","description":"Fired when a `/api/messages/send` request (or a successful retry from the background queue) successfully hands off to PushCut. Receivers should treat this as 'in flight' — actual iMessage delivery to the recipient happens on the iPhone after this fires. Includes HMAC-SHA256 signature header. Webhooks may be **global** (campaignId=null on the webhook → fire for every event) or **campaign-scoped** (campaignId set → fire only for events whose source message has that campaignId). When campaign-scoped webhooks exist alongside global ones, an event for that campaign delivers to both; an event with no campaign tag delivers only to global webhooks. The `data.campaignId` / `campaignSlug` / `campaignName` fields tell receivers which campaign triggered the event.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageWebhookPayload"}}}},"responses":{"2XX":{"description":"Receiver acknowledged"}}}},"message.failed":{"post":{"summary":"Outbound message failed terminally before queueing","description":"Fired when an inline send attempt failed and the message was NOT enqueued for retry — e.g., OPTED_OUT, all devices exhausted, or unrecoverable validation. Once fired, this messageId will not transition further.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageWebhookPayload"}}}},"responses":{"2XX":{"description":"Receiver acknowledged"}}}},"message.queued_for_retry":{"post":{"summary":"Outbound message enqueued for background retry","description":"Fired when inline retries were exhausted and the message has been enqueued. The background queue retries with backoff [5m, 15m, 1h, 4h, 24h]. Expect a `message.sent` or `message.abandoned` follow-up within 24 hours.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageWebhookPayload"}}}},"responses":{"2XX":{"description":"Receiver acknowledged"}}}},"message.abandoned":{"post":{"summary":"Outbound message abandoned after retry queue exhausted","description":"Fired when the background retry queue gave up after 5 attempts spread across ~30 hours. Final terminal state for the message.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageWebhookPayload"}}}},"responses":{"2XX":{"description":"Receiver acknowledged"}}}},"inbound.message.received":{"post":{"summary":"An inbound iMessage / SMS reply was received","description":"Fired when one of your iPhones receives a reply (via the `/api/messages/inbound` ingestion endpoint). Use this to push replies into your CRM or lead router. If the reply body matches a stop keyword (`STOP`, `UNSUBSCRIBE`, `QUIT`, `CANCEL`, `END`, `OPTOUT`, `OPT-OUT`, `OPT OUT`), BlueOpen also auto-creates an `optOut` row with `source='stop-keyword'` so future sends to that phone are blocked.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InboundWebhookPayload"}}}},"responses":{"2XX":{"description":"Receiver acknowledged"}}}}},"paths":{"/api/messages/send":{"post":{"tags":["Messages"],"summary":"Send an iMessage or SMS","description":"Routes a message through one of the user's PushCut-controlled iPhones. By default the picker selects sticky-then-weighted-random; pass `from` to pin a specific device by phone-number suffix or full number. Returns immediately; if PushCut fails for a non-sticky contact the request fails over to a different device. If all in-line attempts fail, the message is enqueued for background retry and the response status will be `queued_for_retry`.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"},"examples":{"minimal":{"summary":"Minimal — phone + message","value":{"phone":"7606399366","message":"Hi from BlueOpen"}},"sms":{"summary":"Force SMS channel","value":{"phone":"7606399366","message":"SMS test","channel":"sms"}},"pinned":{"summary":"Pin a specific device by phone-number suffix","value":{"phone":"7606399366","message":"Hi","from":"1234"}},"campaign":{"summary":"Tag with a campaign by slug","value":{"phone":"7606399366","message":"Q4 cashback offer","campaign":"q4-cashback"}},"full":{"summary":"Full payload with contact metadata","value":{"phone":"7606399366","message":"Hi Barry","channel":"imessage","firstName":"Barry","lastName":"Jones","email":"barry@example.com","case":"WeightLoss"}}}}}},"responses":{"200":{"description":"Sent or queued for retry","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"},"examples":{"sent":{"summary":"Delivered to PushCut","value":{"data":{"messageId":"11111111-1111-1111-1111-111111111111","deviceId":"22222222-2222-2222-2222-222222222222","contactId":"33333333-3333-3333-3333-333333333333","status":"sent"}}},"pinned":{"summary":"Pinned send","value":{"data":{"messageId":"11111111-1111-1111-1111-111111111111","deviceId":"22222222-2222-2222-2222-222222222222","contactId":"33333333-3333-3333-3333-333333333333","status":"sent","pinned":true}}},"queued":{"summary":"PushCut failed inline; queued for background retry","value":{"data":{"messageId":"11111111-1111-1111-1111-111111111111","deviceId":"22222222-2222-2222-2222-222222222222","contactId":"33333333-3333-3333-3333-333333333333","status":"queued_for_retry"}}}}}}},"400":{"description":"Validation error, unknown `from` device, or ambiguous `from` suffix","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"validation":{"summary":"VALIDATION_ERROR","value":{"error":{"code":"VALIDATION_ERROR","message":"phone must be 10-15 digits after normalization","details":{"phone":["phone must be 10-15 digits after normalization"]}}}},"deviceNotFound":{"summary":"DEVICE_NOT_FOUND","value":{"error":{"code":"DEVICE_NOT_FOUND","message":"No device matches phoneNumber suffix \"9999\"","details":{"suffix":"9999"}}}},"deviceAmbiguous":{"summary":"DEVICE_AMBIGUOUS","value":{"error":{"code":"DEVICE_AMBIGUOUS","message":"Multiple devices match `from` suffix","details":{"suffix":"1234","matches":[{"id":"22222222-2222-2222-2222-222222222222","name":"iPhone A","phoneNumber":"16195551234"},{"id":"44444444-4444-4444-4444-444444444444","name":"iPhone B","phoneNumber":"17605551234"}]}}}},"campaignNotFound":{"summary":"CAMPAIGN_NOT_FOUND","value":{"error":{"code":"CAMPAIGN_NOT_FOUND","message":"Campaign \"q4-cashback\" not found","details":{"campaign":"q4-cashback"}}}},"campaignInactive":{"summary":"CAMPAIGN_INACTIVE","value":{"error":{"code":"CAMPAIGN_INACTIVE","message":"Campaign \"q4-cashback\" is archived; only draft and active campaigns can be used for sends","details":{"campaign":"q4-cashback","status":"archived"}}}}}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"UNAUTHORIZED","message":"Invalid or missing API key"}}}}},"403":{"description":"Recipient is on this user's opt-out list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"OPTED_OUT","message":"Recipient has opted out (reply STOP)"}}}}},"503":{"description":"All devices exhausted (no devices, all paused, or all at cap)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"DEVICES_EXHAUSTED","message":"All active devices have reached daily iMessage cap","details":{"kind":"all-at-cap"}}}}}}}}},"/api/messages/{id}":{"get":{"tags":["Messages"],"summary":"Get message status by ID","description":"Returns the current state of a previously-sent message, scoped to the authenticated user. Use this for polling-based delivery tracking. Returns 404 if the message does not exist OR if it belongs to another user.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"messageLog UUID returned from POST /api/messages/send","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Message details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMessageResponse"},"example":{"data":{"id":"11111111-1111-1111-1111-111111111111","phone":"7606399366","channel":"imessage","body":"Hi from BlueOpen","status":"sent","deviceId":"22222222-2222-2222-2222-222222222222","deviceName":"iPhone Barry","devicePhone":"16195551234","contactId":"33333333-3333-3333-3333-333333333333","contactPhone":"7606399366","pushcutStatusCode":200,"errorMessage":null,"selectionReason":"sticky:22222222-2222-2222-2222-222222222222","createdAt":"2026-05-03T22:00:00.000Z","updatedAt":"2026-05-03T22:00:00.000Z","retry":{"queuedForRetry":false,"attemptCount":null,"nextAttemptAt":null,"lastError":null}}}}}},"400":{"description":"Invalid UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No message with this ID owned by the authed user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"NOT_FOUND","message":"Message not found"}}}}}}}},"/api/messages/inbound":{"post":{"tags":["Messages"],"summary":"Receive an inbound iMessage / SMS reply","description":"Webhook receiver for an iPhone Shortcut / PushCut automation that fires when a controlled iPhone receives a reply. Looks up the device by `from` (phone-number suffix), persists the message as `inboundMessage`, auto-creates a contact if one doesn't exist, and emits an `inbound.message.received` event to every active webhook subscribed to it. Pass `idempotencyKey` to dedupe retried Shortcut posts.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InboundMessageRequest"},"examples":{"minimal":{"summary":"Minimal — from + phone + body","value":{"from":"0101","phone":"+1 (314) 882-6305","body":"Yes I received your message"}},"full":{"summary":"With idempotency + receivedAt + channel override","value":{"from":"+13105550101","phone":"3148826305","body":"Reply text","channel":"sms","receivedAt":"2026-05-03T20:26:10.000Z","idempotencyKey":"shortcut-evt-7c8e"}}}}}},"responses":{"200":{"description":"Received and enqueued for webhook delivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InboundMessageResponse"},"examples":{"received":{"summary":"First time — new inbound row","value":{"data":{"messageId":"8b3c2c8e-1a64-4a9c-9b3a-1f2e3d4c5b6a","deviceId":"11111111-2222-3333-4444-555555555555","contactId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"received"}}},"duplicate":{"summary":"Idempotency hit — same idempotencyKey was seen before","value":{"data":{"messageId":"8b3c2c8e-1a64-4a9c-9b3a-1f2e3d4c5b6a","status":"received","duplicate":true}}}}}}},"400":{"description":"Validation error or device not found / ambiguous","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/opt-outs":{"post":{"tags":["Opt-outs"],"summary":"Register a phone number on the do-not-contact list","description":"Idempotent. Once registered, any subsequent POST to /api/messages/send for this phone (under the same user) returns 403 OPTED_OUT. Use this to honor explicit unsubscribe requests from your CRM, lead router, or compliance system. The opt-out is scoped to the API key's user — different tenants maintain separate DNC lists.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OptOutRequest"},"examples":{"minimal":{"summary":"Phone only","value":{"phone":"7606399366"}},"withNote":{"summary":"Phone + audit note","value":{"phone":"+1 (760) 639-9366","note":"Customer replied STOP via Twilio, 2026-05-04"}}}}}},"responses":{"200":{"description":"Registered (newly added or already on the list)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OptOutResponse"}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/campaigns":{"get":{"tags":["Campaigns"],"summary":"List campaigns","description":"Returns a paginated list of campaigns scoped to the authed user, plus aggregated counts (totalMessages, sentCount, failedCount, optOutCount, lastActivityAt). Session-authed (admin UI). Optional `?status=active` filter.","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":20}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"status","in":"query","schema":{"type":"string","enum":["draft","active","paused","archived"]}}],"responses":{"200":{"description":"Campaigns","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignListResponse"}}}},"401":{"description":"Not signed in"}}},"post":{"tags":["Campaigns"],"summary":"Create a campaign","description":"Slug auto-derived from name when omitted (kebab-case, lowercase, deduped via `-2`, `-3` suffix). When slug is provided explicitly, returns 409 DUPLICATE on collision instead of auto-suffixing. Session-authed.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCampaignRequest"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"400":{"description":"Validation error"},"401":{"description":"Not signed in"},"409":{"description":"Slug already taken (only when slug supplied explicitly)"}}}},"/api/campaigns/{id}":{"get":{"tags":["Campaigns"],"summary":"Get campaign with full stats","description":"Returns the campaign plus a `stats` block: totalMessages, sentCount, failedCount, sent7d, sent30d, contactCount, optOutCount, sentRate (%), optOutRate (%), lastActivityAt, topDevices (top 5 by sent count).","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Campaign with stats","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignDetailResponse"}}}},"401":{"description":"Not signed in"},"404":{"description":"Not found or not owned by authed user"}}},"put":{"tags":["Campaigns"],"summary":"Update campaign","description":"Slug is immutable; only name, description, and status can be updated.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignRequest"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"400":{"description":"Validation error"},"401":{"description":"Not signed in"},"404":{"description":"Not found"}}},"delete":{"tags":["Campaigns"],"summary":"Delete campaign (only when empty)","description":"Hard-deletes the campaign. Refuses with 409 DUPLICATE when any messageLog or bulkJob references it — archive instead by setting status='archived'.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted"},"401":{"description":"Not signed in"},"404":{"description":"Not found"},"409":{"description":"Campaign has associated messages or bulk jobs","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/health":{"get":{"tags":["Health"],"summary":"Service health check","description":"Public health probe. Returns 200 if database and hot-table queries succeed, 503 otherwise. Suitable for uptime monitors.","security":[],"responses":{"200":{"description":"Healthy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"},"example":{"status":"healthy","timestamp":"2026-05-03T22:00:00.000Z","version":"abc1234","latencyMs":12,"checks":{"database":{"status":"pass","latencyMs":4},"hot_tables":{"status":"pass","latencyMs":6}}}}}},"503":{"description":"Unhealthy — database or hot-table check failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"BlueOpen API key. Get one from /admin/settings after signing up. Send as `Authorization: Bearer <apiKey>`."}},"schemas":{"Channel":{"type":"string","enum":["imessage","sms"],"description":"Delivery channel"},"MessageStatus":{"type":"string","enum":["queued","sent","failed","queued_for_retry","abandoned"],"description":"queued = inserted; sent = delivered to PushCut; failed = terminal failure; queued_for_retry = inline attempts failed, background queue will retry; abandoned = retry queue exhausted"},"SendMessageRequest":{"type":"object","required":["phone","message"],"properties":{"phone":{"type":"string","description":"Recipient phone number. Non-digit characters are stripped; result must be 10-15 digits.","example":"7606399366"},"message":{"type":"string","minLength":1,"maxLength":1000,"description":"Message body to send","example":"Hi from BlueOpen"},"channel":{"$ref":"#/components/schemas/Channel","default":"imessage"},"firstName":{"type":"string","maxLength":100,"description":"Optional. Used for PushCut AddNewContact when this is a new contact."},"lastName":{"type":"string","maxLength":100},"email":{"type":"string","format":"email"},"case":{"type":"string","maxLength":100,"description":"Optional case/program label passed through to PushCut"},"from":{"type":"string","minLength":4,"maxLength":20,"description":"Pin a specific device by full phone number (digits only) or last 4-15 digit suffix. If omitted, BlueOpen auto-selects from the active device pool (sticky if contact has been messaged before, weighted-random otherwise). Pinned sends never failover.","example":"5551234"},"campaign":{"type":"string","minLength":1,"maxLength":100,"description":"Optional campaign tag. May be a campaign UUID or slug. The campaign must belong to the authed user and have status 'draft' or 'active' — paused/archived campaigns reject sends with 400 CAMPAIGN_INACTIVE. Inbound replies inherit the campaign of the most recent outbound to the same phone, so this field flows through reporting on both sides.","example":"q4-cashback"}}},"SendMessageResponse":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["messageId","deviceId","contactId","status"],"properties":{"messageId":{"type":"string","format":"uuid"},"deviceId":{"type":"string","format":"uuid"},"contactId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["sent","queued_for_retry"]},"pinned":{"type":"boolean","description":"Present and `true` when the send was pinned via `from`"},"failover":{"type":"boolean","description":"Present and `true` when the original device failed and a different device was selected (only for new, non-sticky contacts; never for pinned sends)"},"originalDeviceId":{"type":["string","null"],"format":"uuid","description":"Device that initially failed when failover was applied"}}}}},"GetMessageResponse":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","phone","channel","body","status","selectionReason","createdAt","updatedAt","retry"],"properties":{"id":{"type":"string","format":"uuid"},"phone":{"type":"string"},"channel":{"$ref":"#/components/schemas/Channel"},"body":{"type":"string"},"status":{"$ref":"#/components/schemas/MessageStatus"},"deviceId":{"type":["string","null"],"format":"uuid"},"deviceName":{"type":["string","null"]},"devicePhone":{"type":["string","null"]},"contactId":{"type":["string","null"],"format":"uuid"},"contactPhone":{"type":["string","null"]},"pushcutStatusCode":{"type":["integer","null"]},"errorMessage":{"type":["string","null"]},"selectionReason":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"retry":{"type":"object","required":["queuedForRetry","attemptCount","nextAttemptAt","lastError"],"properties":{"queuedForRetry":{"type":"boolean"},"attemptCount":{"type":["integer","null"]},"nextAttemptAt":{"type":["string","null"],"format":"date-time"},"lastError":{"type":["string","null"]}}}}}}},"InboundMessageRequest":{"type":"object","required":["from","phone","body"],"properties":{"from":{"type":"string","minLength":4,"maxLength":20,"description":"Device identifier — full phone number (digits only) or last 4-15 digit suffix matching device.phoneNumber. Same convention as the `from` field on /messages/send.","example":"0101"},"phone":{"type":"string","description":"Sender's phone (the customer who replied). Non-digit characters are stripped; result must be 10-15 digits. Leading `1` country code is stripped to 10 digits.","example":"+1 (314) 882-6305"},"body":{"type":"string","minLength":1,"maxLength":4000,"description":"The message text the customer sent"},"channel":{"$ref":"#/components/schemas/Channel","default":"imessage"},"receivedAt":{"type":"string","format":"date-time","description":"Optional ISO8601 timestamp of when the iPhone received the message. Defaults to server time if omitted."},"idempotencyKey":{"type":"string","minLength":1,"maxLength":128,"description":"Optional dedup key (unique per user). If set and seen before, the prior messageId is returned with `duplicate: true` and no new event is enqueued."},"campaign":{"type":"string","minLength":1,"maxLength":100,"description":"Optional campaign UUID or slug. When provided, the inbound row is tagged with that campaign and overrides auto-inheritance from the most recent outbound. Useful for tagging the very first inbound from a contact (no outbound history) or routing a reply to a different campaign than the originating outbound.","example":"q4-cashback"}}},"InboundMessageResponse":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["messageId","status"],"properties":{"messageId":{"type":"string","format":"uuid"},"deviceId":{"type":"string","format":"uuid"},"contactId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["received"]},"duplicate":{"type":"boolean","description":"Present and `true` when an idempotencyKey hit returned an existing row."}}}}},"OptOutRequest":{"type":"object","required":["phone"],"properties":{"phone":{"type":"string","description":"Phone number to add to the do-not-contact list. Non-digit characters are stripped; result must be 10-15 digits.","example":"7606399366"},"note":{"type":["string","null"],"maxLength":500,"description":"Optional audit context (max 500 chars). Stored alongside the row."}}},"OptOutResponse":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","phone","source","createdAt"],"properties":{"id":{"type":"string","format":"uuid"},"phone":{"type":"string","description":"Normalized digits-only"},"source":{"type":"string","enum":["manual","api","stop-keyword"],"description":"How this opt-out was registered. POST via Bearer auth = `api`; admin UI = `manual`; auto-detected STOP/UNSUBSCRIBE inbound = `stop-keyword`."},"note":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"}}}}},"WebhookSignature":{"type":"object","description":"Every webhook delivery includes these headers in addition to the JSON body. Verify the signature on your side by computing HMAC-SHA256 of the raw request body using the secret from /admin/settings → Webhooks.","properties":{"X-BlueOpen-Signature":{"type":"string","description":"Format: `sha256=<hex>`. HMAC-SHA256 of the raw body using the webhook secret.","example":"sha256=8f4d9c0e2b1a..."},"X-BlueOpen-Event":{"type":"string","description":"The event name (matches the event key in this webhooks block)","example":"message.sent"},"X-BlueOpen-Delivery":{"type":"string","format":"uuid","description":"Unique delivery ID (deduplicate retries by this if needed)"}}},"MessageEventData":{"type":"object","required":["messageId","phone","channel","status"],"properties":{"messageId":{"type":"string","format":"uuid"},"phone":{"type":"string","description":"Recipient phone (normalized digits-only)"},"channel":{"$ref":"#/components/schemas/Channel"},"status":{"$ref":"#/components/schemas/MessageStatus"},"deviceId":{"type":["string","null"],"format":"uuid"},"deviceName":{"type":["string","null"]},"devicePhone":{"type":["string","null"]},"contactId":{"type":["string","null"],"format":"uuid"},"errorMessage":{"type":["string","null"]},"attemptCount":{"type":["integer","null"],"description":"For retry-queue events, the number of attempts so far (1-5)"},"campaignId":{"type":["string","null"],"format":"uuid","description":"Campaign tag for the source message, or null if untagged"},"campaignSlug":{"type":["string","null"]},"campaignName":{"type":["string","null"]}}},"InboundEventData":{"type":"object","required":["messageId","phone","phoneE164","body","channel","deviceId","contactId","receivedAt"],"properties":{"messageId":{"type":"string","format":"uuid"},"phone":{"type":"string","description":"Sender phone (normalized digits-only)"},"phoneE164":{"type":"string","description":"Sender phone in E.164 format (e.g., +13148826305)"},"body":{"type":"string"},"channel":{"$ref":"#/components/schemas/Channel"},"deviceId":{"type":"string","format":"uuid","description":"Which iPhone received it"},"deviceName":{"type":["string","null"]},"contactId":{"type":"string","format":"uuid"},"receivedAt":{"type":"string","format":"date-time"},"idempotencyKey":{"type":["string","null"]},"campaignId":{"type":["string","null"],"format":"uuid","description":"Campaign resolved for this inbound (explicit `campaign` field, or inherited from the most recent outbound to this phone)"},"campaignSlug":{"type":["string","null"]},"campaignName":{"type":["string","null"]}}},"MessageWebhookPayload":{"type":"object","required":["event","data"],"properties":{"event":{"type":"string","enum":["message.sent","message.failed","message.queued_for_retry","message.abandoned"]},"data":{"$ref":"#/components/schemas/MessageEventData"}}},"InboundWebhookPayload":{"type":"object","required":["event","data"],"properties":{"event":{"type":"string","enum":["inbound.message.received"]},"data":{"$ref":"#/components/schemas/InboundEventData"}}},"HealthResponse":{"type":"object","required":["status","timestamp","version","checks"],"properties":{"status":{"type":"string","enum":["healthy","degraded","unhealthy"]},"timestamp":{"type":"string","format":"date-time"},"version":{"type":"string"},"latencyMs":{"type":"integer"},"checks":{"type":"object","additionalProperties":{"type":"object","required":["status","latencyMs"],"properties":{"status":{"type":"string","enum":["pass","fail","warn"]},"latencyMs":{"type":"integer"},"message":{"type":"string"}}}}}},"CampaignStatus":{"type":"string","enum":["draft","active","paused","archived"],"description":"draft and active campaigns can receive sends; paused and archived reject new sends with CAMPAIGN_INACTIVE"},"Campaign":{"type":"object","required":["id","name","slug","status","createdAt","updatedAt"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","maxLength":120},"slug":{"type":"string","maxLength":50,"description":"Lowercase alphanumeric + hyphens; immutable"},"description":{"type":["string","null"],"maxLength":1000},"status":{"$ref":"#/components/schemas/CampaignStatus"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"CampaignWithCounts":{"allOf":[{"$ref":"#/components/schemas/Campaign"},{"type":"object","properties":{"totalMessages":{"type":"integer"},"sentCount":{"type":"integer"},"failedCount":{"type":"integer"},"optOutCount":{"type":"integer"},"lastActivityAt":{"type":["string","null"],"format":"date-time"}}}]},"CampaignListResponse":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CampaignWithCounts"}},"pagination":{"type":"object","required":["total","limit","offset","hasMore"],"properties":{"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"hasMore":{"type":"boolean"}}}}},"CampaignResponse":{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/Campaign"}}},"CampaignDetailResponse":{"type":"object","required":["data"],"properties":{"data":{"allOf":[{"$ref":"#/components/schemas/Campaign"},{"type":"object","required":["stats"],"properties":{"stats":{"type":"object","required":["totalMessages","sentCount","failedCount","sent7d","sent30d","contactCount","optOutCount","sentRate","optOutRate","lastActivityAt","topDevices"],"properties":{"totalMessages":{"type":"integer"},"sentCount":{"type":"integer"},"failedCount":{"type":"integer"},"sent7d":{"type":"integer"},"sent30d":{"type":"integer"},"contactCount":{"type":"integer"},"optOutCount":{"type":"integer"},"sentRate":{"type":"number","description":"Percent (0-100, 1 decimal)"},"optOutRate":{"type":"number","description":"Percent (0-100, 1 decimal)"},"lastActivityAt":{"type":["string","null"],"format":"date-time"},"topDevices":{"type":"array","items":{"type":"object","required":["deviceId","sentCount"],"properties":{"deviceId":{"type":["string","null"],"format":"uuid"},"deviceName":{"type":["string","null"]},"sentCount":{"type":"integer"}}}}}}}}]}}},"CreateCampaignRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":120},"slug":{"type":"string","minLength":1,"maxLength":50,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","description":"Auto-derived from name when omitted"},"description":{"type":"string","maxLength":1000},"status":{"$ref":"#/components/schemas/CampaignStatus"}}},"UpdateCampaignRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":["string","null"],"maxLength":1000},"status":{"$ref":"#/components/schemas/CampaignStatus"}}},"ApiError":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["VALIDATION_ERROR","UNAUTHORIZED","FORBIDDEN","NOT_FOUND","DUPLICATE","RATE_LIMITED","DEVICES_EXHAUSTED","DEVICE_NOT_FOUND","DEVICE_AMBIGUOUS","PUSHCUT_FAILED","OPTED_OUT","CAMPAIGN_NOT_FOUND","CAMPAIGN_INACTIVE","INTERNAL_ERROR"]},"message":{"type":"string"},"details":{}}}}}}}}