{"openapi":"3.1.0","info":{"title":"Retext API","version":"0.1.0","description":"Retext SMS transport API. Project apps authenticate with bearer API keys; admin-only operations live in the TanStack Start web app."},"tags":[{"name":"System","description":"Health, OpenAPI, and human-readable reference endpoints."},{"name":"Messages","description":"Project-facing outbound SMS operations."},{"name":"Webhooks","description":"Provider callbacks from the SMSGate private server."}],"paths":{"/health":{"get":{"tags":["System"],"summary":"Check API health","responses":{"200":{"description":"Gateway health","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}},"503":{"description":"Postgres did not answer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/docs":{"get":{"tags":["System"],"summary":"Open the Scalar API reference","responses":{"200":{"description":"Human-readable API reference HTML","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/openapi.json":{"get":{"tags":["System"],"summary":"Read the OpenAPI document","responses":{"200":{"description":"OpenAPI JSON"}}}},"/v1/messages":{"get":{"tags":["Messages"],"summary":"List messages visible to this project API key","security":[{"bearerAuth":[]}],"parameters":[{"name":"phone","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"key","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/SmsStatus"}},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"search","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"cursor","in":"query","description":"Opaque cursor returned by the previous page.","schema":{"type":"string"}}],"responses":{"200":{"description":"Project messages","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageListResponse"}}}},"400":{"description":"invalid_filter, invalid_cursor or invalid_date: a filter, cursor or date the API cannot use","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["Messages"],"summary":"Queue an SMS through the project's default phone, or fromNumberId","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"responses":{"202":{"description":"Message and dispatch outbox stored; provider submission is asynchronous","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"phone_unavailable: no enabled phone to send from; idempotency_conflict: the projectMessageId already exists with different content","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"payload_too_large: the request body is larger than the API accepts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"415":{"description":"unsupported_media_type: the body is not application/json","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"phone_rate_limit_exceeded: the phone reached its rolling 24-hour limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Server failure while accepting the message","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/messages/batch":{"post":{"tags":["Messages"],"summary":"Atomically queue up to 100 SMS messages","description":"The complete batch is accepted or rejected. Include a unique projectMessageId on every item when the caller needs safe retries.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageBatchRequest"}}}},"responses":{"202":{"description":"All messages and dispatch outbox rows were stored atomically","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageBatchResponse"}}}},"400":{"description":"Invalid batch or duplicate project message IDs","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A projectMessageId already exists with different message content","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"payload_too_large: the request body is larger than the API accepts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"415":{"description":"unsupported_media_type: the body is not application/json","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"The batch would exceed the phone rolling quota","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/messages/{id}":{"get":{"tags":["Messages"],"summary":"Read one message visible to this project API key","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Message details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Message not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/messages/export.csv":{"get":{"tags":["Messages"],"summary":"Export filtered project messages as CSV","description":"Exports at most 10,000 messages using the list filters.","security":[{"bearerAuth":[]}],"parameters":[{"name":"phone","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"key","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/SmsStatus"}},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"search","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"UTF-8 CSV export","content":{"text/csv":{"schema":{"type":"string"}}}},"400":{"description":"invalid_filter or invalid_date: a filter or date the API cannot use","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/messages/{id}/cancel":{"post":{"tags":["Messages"],"summary":"Cancel a queued message","description":"Cancellation is idempotent while the message is canceled. Provider-submitted and other terminal messages cannot be canceled.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Message canceled or already canceled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Message not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Message is already being delivered or is no longer queued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/api-keys":{"get":{"tags":["Messages"],"summary":"List this project's API keys without raw secrets","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Masked API keys","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyListResponse"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/phones":{"get":{"tags":["Messages"],"summary":"List phones this project can send through","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Project phones","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneListResponse"}}}},"401":{"description":"Missing or invalid project API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/usage":{"get":{"tags":["Messages"],"summary":"Read rolling phone quota usage","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Rolling 24-hour usage for assigned phones","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponse"}}}}}}},"/webhooks/smsgate":{"post":{"tags":["Webhooks"],"summary":"Receive SMSGate outbound delivery events","responses":{"200":{"description":"Webhook ignored"},"202":{"description":"Webhook accepted or already deduped"},"401":{"description":"Missing or invalid SMSGate webhook signature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer"}},"schemas":{"ErrorResponse":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{}},"additionalProperties":false}},"additionalProperties":false},"HealthResponse":{"type":"object","required":["ok","service","commit","database","dryRun","timestamp"],"properties":{"ok":{"type":"boolean"},"service":{"type":"string"},"commit":{"type":["string","null"]},"database":{"type":"string","enum":["ok","unreachable"]},"dryRun":{"type":"boolean"},"timestamp":{"type":"string","format":"date-time"}},"additionalProperties":false},"SendMessageRequest":{"type":"object","required":["to","body"],"properties":{"to":{"type":"string","pattern":"^\\+[1-9]\\d{7,14}$","examples":["+381641234567"]},"body":{"type":"string","minLength":1,"maxLength":1600},"projectMessageId":{"type":"string","maxLength":200},"callbackUrl":{"type":"string","format":"uri"},"fromNumberId":{"type":"string","format":"uuid","description":"Enabled sender number assigned to the authenticated project. The project default is used when omitted."},"sendAt":{"type":"string","format":"date-time","description":"Future delivery time, no more than 30 days ahead."},"metadata":{"type":"object","additionalProperties":true}},"additionalProperties":false},"SendMessageResponse":{"type":"object","required":["id","messageId","status","fromNumberId"],"properties":{"id":{"type":"string","format":"uuid"},"messageId":{"type":["string","null"]},"status":{"$ref":"#/components/schemas/SmsStatus"},"fromNumberId":{"type":["string","null"],"format":"uuid"}},"example":{"id":"2d821e56-6a0b-4b76-93f5-35910e4f45ad","messageId":null,"status":"queued","fromNumberId":"82e5e5a1-cb79-4e6c-af6c-5b782980aca0"},"additionalProperties":false},"SendMessageBatchRequest":{"type":"object","required":["messages"],"properties":{"messages":{"type":"array","minItems":1,"maxItems":100,"items":{"$ref":"#/components/schemas/SendMessageRequest"}}},"additionalProperties":false},"SendMessageBatchResponse":{"type":"object","required":["messages"],"properties":{"messages":{"type":"array","minItems":1,"maxItems":100,"items":{"$ref":"#/components/schemas/SendMessageResponse"}}},"additionalProperties":false},"Message":{"type":"object","required":["id","projectMessageId","to","body","callbackUrl","metadata","status","messageId","fromNumberId","apiKeyId","scheduledAt","canceledAt","submittedAt","sentAt","deliveredAt","failedAt","failureReason","createdAt","updatedAt"],"properties":{"id":{"type":"string","format":"uuid"},"projectMessageId":{"type":["string","null"]},"to":{"type":"string"},"body":{"type":"string"},"callbackUrl":{"type":["string","null"],"format":"uri"},"metadata":{"type":"object","additionalProperties":true},"status":{"$ref":"#/components/schemas/SmsStatus"},"messageId":{"type":["string","null"]},"fromNumberId":{"type":["string","null"],"format":"uuid"},"apiKeyId":{"type":["string","null"],"format":"uuid"},"scheduledAt":{"type":["string","null"],"format":"date-time"},"canceledAt":{"type":["string","null"],"format":"date-time"},"submittedAt":{"type":["string","null"],"format":"date-time"},"sentAt":{"type":["string","null"],"format":"date-time"},"deliveredAt":{"type":["string","null"],"format":"date-time"},"failedAt":{"type":["string","null"],"format":"date-time"},"failureReason":{"type":["string","null"]},"fromNumber":{"type":["string","null"]},"sendAttempts":{"type":"array","items":{"type":"object"}},"callbackAttempts":{"type":"array","items":{"type":"object"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"additionalProperties":false},"MessageListResponse":{"type":"object","required":["messages","nextCursor"],"properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"nextCursor":{"type":["string","null"]}},"additionalProperties":false},"ApiKeyListResponse":{"type":"object","required":["apiKeys"],"properties":{"apiKeys":{"type":"array","items":{"type":"object","required":["id","label","tokenPrefix","smsNumberId","phoneNumber","createdAt","lastUsedAt","revokedAt"],"properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string"},"tokenPrefix":{"type":"string"},"smsNumberId":{"type":["string","null"],"format":"uuid"},"phoneNumber":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"},"lastUsedAt":{"type":["string","null"],"format":"date-time"},"revokedAt":{"type":["string","null"],"format":"date-time"}},"additionalProperties":false}}},"additionalProperties":false},"PhoneListResponse":{"type":"object","required":["phones"],"properties":{"phones":{"type":"array","items":{"type":"object","required":["id","phoneNumber","label","enabled","setupStatus","health","lastSeenAt"],"properties":{"id":{"type":"string","format":"uuid"},"phoneNumber":{"type":"string"},"label":{"type":"string"},"enabled":{"type":"boolean"},"setupStatus":{"type":"string"},"health":{"type":"string","enum":["online","offline","unknown"],"description":"Health of the paired phone from the gateway's periodic SMSGate check; unknown when no paired phone is linked. Offline does not stop sends."},"lastSeenAt":{"type":["string","null"],"format":"date-time","description":"When the gateway last saw the phone: its latest heartbeat, or the time it was re-paired."}},"additionalProperties":false}}},"additionalProperties":false},"UsageResponse":{"type":"object","required":["phones"],"properties":{"phones":{"type":"array","items":{"type":"object","required":["fromNumberId","fromNumber","used","limit","remaining","windowStartedAt"],"properties":{"fromNumberId":{"type":"string","format":"uuid"},"fromNumber":{"type":"string"},"used":{"type":"integer"},"limit":{"type":"integer"},"remaining":{"type":"integer"},"windowStartedAt":{"type":"string","format":"date-time"}}}}}},"SmsStatus":{"enum":["queued","submitted","sent","delivered","failed","canceled"]},"SmsCallbackEvent":{"type":"object","description":"Sent to the message's callback URL when its status changes. sms.failed comes from the phone or from the gateway itself when it gives up on a message (then from is the assigned phone, or null when the phone is gone, and messageId is null when SMSGate never took the message). sms.failed is not always final: after an unanswered final send, or an operator retry, sms.sent or sms.delivered can follow. After an operator retry, the retried send reports its own outcome with a new occurredAt; a callback of the earlier send already being delivered can still arrive with the earlier send's occurredAt and look like the retried send's outcome. Delivery is at-least-once and unordered, and occurredAt cannot order these events: dedupe on (id, type, occurredAt), treat delivered as final, and check GET /v1/messages/{id} before acting on sms.failed.","required":["type","id","messageId","projectMessageId","to","from","body","occurredAt","error","metadata"],"properties":{"type":{"enum":["sms.sent","sms.delivered","sms.failed"]},"id":{"type":"string"},"messageId":{"type":["string","null"]},"projectMessageId":{"type":["string","null"]},"to":{"type":["string","null"]},"from":{"type":["string","null"],"description":"On events that came from the phone, the sending phone's number as the phone reports it (not normalized, so it can be in national format); otherwise the message's assigned phone; otherwise null. The gateway's own sms.failed uses the assigned phone's stored number."},"body":{"type":"string"},"occurredAt":{"type":"string","format":"date-time"},"error":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":true}},"additionalProperties":false},"PhoneHealthCallbackEvent":{"type":"object","description":"Sent to the project's default callback URL when a phone serving one of its enabled numbers goes offline or comes back. Delivery is at-least-once and unordered: dedupe on id and apply only events newer (occurredAt) than the last one applied for the phoneNumberId.","required":["type","id","phoneNumberId","phoneLabel","simSlot","occurredAt","lastSeenAt"],"properties":{"type":{"enum":["phone.offline","phone.online"]},"id":{"type":"string","format":"uuid"},"phoneNumberId":{"type":"string","format":"uuid"},"phoneLabel":{"type":"string"},"simSlot":{"type":["integer","null"]},"occurredAt":{"type":"string","format":"date-time","description":"When the gateway detected the change."},"lastSeenAt":{"type":["string","null"],"format":"date-time"}},"additionalProperties":false},"ProjectCallbackEvent":{"description":"Signed callback body. New event types may be added; return 2xx for types you do not handle. Each POST carries x-retext-timestamp (Unix seconds) and x-retext-signature: the lowercase hex HMAC-SHA256 of `<x-retext-timestamp>.<raw body>`, keyed with the project's whole signing secret (whsec_ included, as UTF-8 text) from the portal's Callbacks tab. Reject timestamps more than five minutes off. Projects created before per-project secrets may also get the deprecated x-shared-sms-signature and x-shared-sms-timestamp until an owner turns them off.","oneOf":[{"$ref":"#/components/schemas/SmsCallbackEvent"},{"$ref":"#/components/schemas/PhoneHealthCallbackEvent"}],"discriminator":{"propertyName":"type","mapping":{"sms.sent":"#/components/schemas/SmsCallbackEvent","sms.delivered":"#/components/schemas/SmsCallbackEvent","sms.failed":"#/components/schemas/SmsCallbackEvent","phone.offline":"#/components/schemas/PhoneHealthCallbackEvent","phone.online":"#/components/schemas/PhoneHealthCallbackEvent"}}}}}}