{"openapi":"3.1.0","info":{"title":"Geo SMS Hub API","description":"Developer-first transactional SMS for Georgia. See POST /v1/messages and GET /v1/messages/{id}.","version":"0.1.0"},"paths":{"/healthz":{"get":{"tags":["health"],"summary":"Liveness check","operationId":"healthz_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/readyz":{"get":{"tags":["health"],"summary":"Readiness check","operationId":"readyz_readyz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}}},"503":{"description":"The database is unreachable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}}}}}},"/v1/messages":{"post":{"tags":["messages"],"summary":"Send one transactional SMS","description":"Validates, records, and submits one message.\n\n`201` means the message resource was created — including when the provider rejected it, in which case the body carries `status: \"failed\"` and an `error`. Requires an `Idempotency-Key`: replaying it with the identical body returns the original message with `200`, while reusing it with a different body returns `409`.","operationId":"create_message_route_v1_messages_post","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Idempotency-Key"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMessageRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"}}}},"200":{"description":"Idempotent replay: the original message, unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"}}}},"400":{"description":"Invalid request body, recipient, template, or variables, or a missing Idempotency-Key. The error message names what to change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"401":{"description":"Missing, malformed, unknown, or revoked API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"403":{"description":"The project is suspended.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"402":{"description":"`insufficient_credits`: a live key's project has too few credits for this message. Nothing was sent. Test keys never spend credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"409":{"description":"The Idempotency-Key was reused with a different body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"429":{"description":"`rate_limited`: too many requests this minute; retry shortly. `daily_limit_reached`: the free tier's daily segment limit is used up; do not retry before the reset time named in the message (midnight UTC). Nothing was sent in either case.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"503":{"description":"The provider was unreachable and nothing was sent. Safe to retry, including with the same Idempotency-Key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}}}}},"/v1/messages/{message_id}":{"get":{"tags":["messages"],"summary":"Read one message","description":"Returns the current normalized state of a message owned by the authenticated project. Delivery confirmations arrive asynchronously, so poll this rather than assuming a fixed delay.","operationId":"get_message_route_v1_messages__message_id__get","parameters":[{"name":"message_id","in":"path","required":true,"schema":{"type":"string","title":"Message Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"}}}},"401":{"description":"Missing, malformed, unknown, or revoked API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"403":{"description":"The project is suspended.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}},"404":{"description":"No such message in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}}}}},"/v1/test-keys":{"post":{"tags":["keys"],"summary":"Get a test API key instantly, without an account","description":"Returns a `sk_test_` key immediately, with no sign-in. Test keys use the built-in fake provider: they never send a real SMS and never spend credits, so an AI agent can build and verify a complete integration unattended.\n\nTo send real messages, a human opens the returned `claim_url` in a browser, signs in with GitHub or Google, and confirms. The key you already have keeps working — claiming attaches it to their account and adds free credits.","operationId":"create_test_key_v1_test_keys_post","responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestKeyResponse"}}}},"429":{"description":"Too many keys requested. Retry shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiErrorResponse"}}}}}}}},"components":{"schemas":{"ApiErrorBody":{"properties":{"code":{"type":"string","title":"Code","description":"Stable machine-readable error code.","examples":["invalid_recipient"]},"message":{"type":"string","title":"Message","description":"Human-readable explanation, written to be actionable.","examples":["The recipient must be a Georgian mobile number in E.164 format: +9955XXXXXXXX."]},"request_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Request Id","description":"Echoes the `X-Request-Id` response header; quote it when reporting a problem.","examples":["req_01J8XW7K2N9P4Q6R8S0T2V4W6X"]}},"type":"object","required":["code","message"],"title":"ApiErrorBody"},"ApiErrorResponse":{"properties":{"error":{"$ref":"#/components/schemas/ApiErrorBody"}},"type":"object","required":["error"],"title":"ApiErrorResponse","description":"Every error response in this API has this shape."},"CreateMessageRequest":{"properties":{"to":{"type":"string","title":"To","description":"Recipient in E.164 format: `+9955` followed by eight digits. No other country or format is accepted, and numbers are never normalized on your behalf.","examples":["+995591234567"]},"template":{"type":"string","title":"Template","description":"Which message to send. Message text is never free-form: every message is rendered from one of these templates.\n\n- `verification_code` (`code`): Your verification code is {code}. Do not share it with anyone.\n- `login_code` (`code`): Your sign-in code is {code}. If you did not request it, ignore this message.\n- `password_reset_code` (`code`, `minutes`): Your password reset code is {code}. It expires in {minutes} minutes.","examples":["verification_code"]},"variables":{"additionalProperties":true,"type":"object","title":"Variables","description":"Values for the template's placeholders, all as JSON strings. A `code` is 4 to 8 digits; `minutes` is 1 to 999. Values that look like a link, a web address, or a phone number are refused.","examples":[{"code":"482913"}]},"locale":{"type":"string","enum":["ka","en"],"title":"Locale","description":"Language of the message: `ka` (Georgian, the default) or `en`.","default":"ka","examples":["ka"]}},"additionalProperties":false,"type":"object","required":["to","template","variables"],"title":"CreateMessageRequest","examples":[{"template":"verification_code","to":"+995591234567","variables":{"code":"482913"}}]},"HealthResponse":{"properties":{"status":{"type":"string","const":"alive","title":"Status","description":"The process is running."}},"type":"object","required":["status"],"title":"HealthResponse"},"MessageError":{"properties":{"code":{"type":"string","title":"Code","description":"Stable machine-readable code, safe to branch on.","examples":["provider_rejected"]},"message":{"type":"string","title":"Message","description":"Human-readable explanation. Wording may change; the code will not.","examples":["The delivery provider rejected the message."]}},"type":"object","required":["code","message"],"title":"MessageError","description":"A safe, provider-neutral explanation attached to a message."},"MessageResponse":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque message identifier. Store it to read the status later."},"object":{"type":"string","const":"message","title":"Object","description":"Always `message`."},"to":{"type":"string","title":"To","description":"Masked recipient: country code and first digit, then the last two digits.","examples":["+9955••••••67"]},"status":{"type":"string","enum":["queued","submitted","delivered","undeliverable","failed","unknown"],"title":"Status","description":"Normalized delivery state. `submitted` means the provider accepted the request, not that a handset received it."},"segments":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Segments","description":"SMS segments this message occupies — the unit the provider bills. `null` only for messages created before segment counting existed.","examples":[1]},"error":{"anyOf":[{"$ref":"#/components/schemas/MessageError"},{"type":"null"}],"description":"`null` unless the message failed or was undeliverable."},"created_at":{"type":"string","title":"Created At","description":"ISO 8601 UTC, second precision, `Z` suffix.","examples":["2026-09-14T09:15:30Z"]},"updated_at":{"type":"string","title":"Updated At","description":"ISO 8601 UTC time of the most recent change.","examples":["2026-09-14T09:15:31Z"]}},"type":"object","required":["id","object","to","status","segments","created_at","updated_at"],"title":"MessageResponse","description":"One message resource. Text, full recipient, and provider internals are never included.","examples":[{"created_at":"2026-09-14T09:15:30Z","id":"msg_01J8XW7K2N9P4Q6R8S0T2V4W6X","object":"message","segments":1,"status":"submitted","to":"+9955••••••67","updated_at":"2026-09-14T09:15:31Z"}]},"ReadinessResponse":{"properties":{"status":{"type":"string","enum":["ready","not_ready"],"title":"Status"},"database":{"type":"string","enum":["ok","unreachable"],"title":"Database","description":"Database reachability. No connection details are exposed."}},"type":"object","required":["status","database"],"title":"ReadinessResponse","description":"Readiness for traffic. Returned with `503` when the database is unreachable."},"TestKeyResponse":{"properties":{"api_key":{"type":"string","title":"Api Key","description":"The key itself, shown only in this response. Store it now.","examples":["sk_test_8PIL8xQvWtNOG1mFjJeBz29uo5ODR8Pc"]},"project_id":{"type":"string","title":"Project Id","description":"The project this key belongs to."},"mode":{"type":"string","const":"test","title":"Mode","description":"Always `test`: this key uses the fake provider and cannot deliver an SMS."},"claim_url":{"type":"string","title":"Claim Url","description":"For a human: open in a browser, sign in, and confirm to take ownership, add free credits, and enable live delivery. The key above keeps working afterwards."},"expires_at":{"type":"string","title":"Expires At","description":"When `claim_url` stops working if nobody has used it. The key above is not affected."},"next_steps":{"type":"string","title":"Next Steps","description":"What to do next, in one line, for a caller with no human attached."}},"type":"object","required":["api_key","project_id","mode","claim_url","expires_at","next_steps"],"title":"TestKeyResponse","description":"An instantly issued test key, plus the link that turns it into a real account."}}}}