# Geo SMS Hub API reference

Geo SMS Hub is a developer-first API for application-triggered SMS in Georgia. It is deliberately limited to transactional messages such as verification codes, security alerts, booking confirmations, receipts, and service notifications.

> **Private beta.** Get a test key instantly with [`POST /v1/test-keys`](#get-a-test-key) — no account needed. For live keys, sign in with GitHub or Google at `/login`, or open the `claim_url` your test key came with. There is no billing API, campaign API, or sender-management API.

## Start here

| Environment | Base URL | Use |
| --- | --- | --- |
| Production beta | `https://api-production-d007.up.railway.app` | Use with a key from `POST /v1/test-keys` or from your dashboard. |
| Local development | `http://127.0.0.1:8000` | Start the service with the built-in fake provider. |

Interactive documentation is available at `<base URL>/docs`, and the machine-readable OpenAPI document is at `<base URL>/openapi.json`.

All public message routes use JSON and are versioned under `/v1`. Health endpoints are documented separately below.

## Integration contract for developers and coding agents

Before writing an integration, follow these rules:

1. Use only the three public routes documented below: `POST /v1/test-keys`, `POST /v1/messages`, and `GET /v1/messages/{id}`. Live keys and credits are managed by a human on the dashboard; sender configuration, provider webhooks, and balance management are operator functions. None of them is an API route.
2. Use an `sk_test_…` key first. If you have none, `POST /v1/test-keys` returns one instantly. Test keys always use the fake provider and never send a real SMS or spend credits.
3. For each logical send, generate and persist one `Idempotency-Key`. Reuse that exact key only when retrying that same request: the same recipient, template, variables, and locale.
4. Treat `submitted` as provider acceptance, **not** handset delivery. Poll `GET /v1/messages/{id}` until a terminal status when the workflow needs a final outcome.
5. If a create call returns a message with `unknown`, do not automatically send it again: the provider may have received the first request. Preserve the message ID and reconcile before any manual retry.
6. Never expect a message response to echo its text or full recipient number. Store the information your application needs before calling the API.

## Authentication and key modes

Send every message request with:

```http
Authorization: Bearer sk_test_…
```

Keys are project-scoped. A key can access only the messages created by its own project.

| Key prefix | Delivery behavior |
| --- | --- |
| `sk_test_` | Uses the built-in fake provider. No SMS is sent. |
| `sk_live_` | Uses the configured live delivery provider. Only use for consented, transactional recipient-expected messages. |

Treat a key as a password. Do not put it in browser code, source control, logs, issue trackers, or agent prompts that may be retained. A key is shown only once, when it is created; if one is exposed, revoke it on your dashboard and create another.

## Quickstart: create and retrieve a test message

Replace `YOUR_TEST_API_KEY` with an `sk_test_…` key, from [`POST /v1/test-keys`](#get-a-test-key) or from your dashboard. The idempotency value shown here is an example; generate a new unique value for each logical message.

```bash
curl -X POST https://api-production-d007.up.railway.app/v1/messages \
  -H 'Authorization: Bearer YOUR_TEST_API_KEY' \
  -H 'Idempotency-Key: 2a4370b5-26a4-4485-b9bf-8113a053b8bc' \
  -H 'Content-Type: application/json' \
  --data '{
    "to": "+995591234567",
    "template": "verification_code",
    "variables": { "code": "482913" }
  }'
```

A newly created message returns `201 Created`:

```json
{
  "id": "msg_01J…",
  "object": "message",
  "to": "+9955••••••67",
  "status": "submitted",
  "segments": 1,
  "error": null,
  "created_at": "2026-09-04T09:15:30Z",
  "updated_at": "2026-09-04T09:15:31Z"
}
```

Use the returned ID to retrieve the same project’s message:

```bash
curl https://api-production-d007.up.railway.app/v1/messages/msg_01J… \
  -H 'Authorization: Bearer YOUR_TEST_API_KEY'
```

With a normal fake-provider recipient, the message becomes eligible for a simulated delivery confirmation after about five seconds. The API reflects that confirmation on the next status-poller pass; do not code against a fixed number of seconds.

## Get a test key

`POST /v1/test-keys`

Issues a test API key immediately. No authentication, no account, and no browser
step — this exists so an AI coding agent can start on its own.

```bash
curl -X POST https://api-production-d007.up.railway.app/v1/test-keys
```

```json
{
  "api_key": "sk_test_8PIL8xQvWtNOG1mFjJeBz29uo5ODR8Pc",
  "project_id": "proj_01J…",
  "mode": "test",
  "claim_url": "https://api-production-d007.up.railway.app/claim/…",
  "expires_at": "2026-10-16T09:15:30Z",
  "next_steps": "Send with: POST /v1/messages, header 'Authorization: Bearer <api_key>' …"
}
```

The key is shown once and uses the built-in fake provider: it never delivers a
real SMS and never spends credits, so the magic recipients below are the whole
behaviour. Requests are limited per caller and by a daily total across all
callers; on `429 rate_limited`, wait a minute before trying again.

To send real messages, a human opens `claim_url` in a browser, signs in with
GitHub or Google, and confirms. That attaches the project to their account and
adds free credits — **the key you already have keeps working**, so nothing needs
rewiring. The link works once, and stops working after 30 days (`expires_at`) if
nobody uses it; the key itself is not affected.

## Credits and limits

Live sends spend credits; test sends never do. One credit is one SMS segment —
the `segments` field of the message — and a live send reserves its credits
before it reaches the provider:

- Too few credits: `402 insufficient_credits`, and nothing is sent. The project
  owner adds credits; the dashboard explains how.
- A free-tier project may send 20 segments a day. Past that: `429
  daily_limit_reached` until midnight UTC, and nothing is sent. Paid credits
  remove the limit.
- A definitive provider rejection, or a `503 provider_unavailable`, is refunded.
  A message that ends `unknown` stays charged, because it may have been sent.

## Create a message

`POST /v1/messages`

Creates, records, and attempts to submit one transactional message.

### Required headers

| Header | Value |
| --- | --- |
| `Authorization` | `Bearer <API_KEY>` |
| `Idempotency-Key` | A non-empty value of 200 characters or fewer, unique per logical send. |
| `Content-Type` | `application/json` |

### Request body

```json
{
  "to": "+995591234567",
  "template": "verification_code",
  "variables": { "code": "482913" },
  "locale": "ka"
}
```

| Field | Required | Rules |
| --- | --- | --- |
| `to` | Yes | A Georgian mobile number in the exact E.164 form `+9955` followed by eight digits. Do not omit `+`, add spaces, or send another country’s number. |
| `template` | Yes | One of the template ids below. Message text is never free-form. |
| `variables` | Yes | An object with exactly the template's variables, every value a JSON **string** (`"0451"`, not `451`, so leading zeros survive). |
| `locale` | No | `ka` (Georgian, the default) or `en`. |

No other fields are accepted — in particular there is no `text` field.

### Templates

Every message is rendered from a built-in template, and messages are sent under the shared sender name `GeoAuth` (unless the team has approved a sender of its own for your project). This is what keeps a shared sender trustworthy: nobody can send arbitrary text under it.

| `template` | Variables | `en` | `ka` | Segments (`en` / `ka`) |
| --- | --- | --- | --- | --- |
| `verification_code` | `code` | Your verification code is {code}. Do not share it with anyone. | თქვენი დამადასტურებელი კოდია {code}. არავის გაუზიაროთ. | 1 / 1 |
| `login_code` | `code` | Your sign-in code is {code}. If you did not request it, ignore this message. | შესვლის კოდი: {code}. თუ არ მოგითხოვიათ, გამოტოვეთ ეს შეტყობინება. | 1 / 1 |
| `password_reset_code` | `code`, `minutes` | Your password reset code is {code}. It expires in {minutes} minutes. | პაროლის აღდგენის კოდი: {code}. მოქმედებს {minutes} წუთი. | 1 / 1 |

Variable rules:

- `code`: 4 to 8 digits (`0`–`9` only).
- `minutes`: a whole number from 1 to 999, without leading zeros.
- Any value that looks like a link (`http`, `www.`, `://`), a web address (`example.ge`), or a phone number (nine or more digits) is refused, whatever the variable.

A refused template or variable returns `400` with `invalid_template` or `invalid_variables` and a message naming exactly what to change. Credits are charged per segment of the rendered text, and the response reports `segments`.

No other fields are accepted. In particular, `from` is not a public request field: the sender is `GeoAuth` unless the team has approved another for your project, and a caller can never choose it.

### Success responses

| HTTP status | Meaning |
| --- | --- |
| `201 Created` | This is a new message submission attempt. The returned object can have `submitted`, `failed`, or `unknown` status depending on the provider outcome. |
| `200 OK` | The same idempotency key and identical request were already processed. The response is the original message, not a second send. |

### Message object

```json
{
  "id": "msg_01J…",
  "object": "message",
  "to": "+9955••••••67",
  "status": "submitted",
  "segments": 1,
  "error": null,
  "created_at": "2026-09-04T09:15:30Z",
  "updated_at": "2026-09-04T09:15:31Z"
}
```

| Field | Description |
| --- | --- |
| `id` | Opaque message identifier. Store this to retrieve the status later. |
| `object` | Always `message`. |
| `to` | Masked recipient: country code and first digit plus the final two digits. The full number is never returned. |
| `status` | One of the normalized statuses in [Message status](#message-status). |
| `segments` | How many SMS segments the message occupies — the unit the provider bills. `null` only for messages created before segment counting existed. |
| `error` | `null` or a safe `{ "code", "message" }` object. Provider internals are not exposed. |
| `created_at` | ISO 8601 UTC time when the record was created. |
| `updated_at` | ISO 8601 UTC time of the latest recorded update. |

The API never returns message text, an unmasked recipient, a provider message ID, or raw provider status.

### A definitive provider rejection

A provider can reject a message after the request has passed API validation. That is still a successfully created API resource, so the response is `201 Created` with an honest message state—not an HTTP transport failure:

```json
{
  "id": "msg_01J…",
  "object": "message",
  "to": "+9955••••••01",
  "status": "failed",
  "segments": 1,
  "error": {
    "code": "provider_rejected",
    "message": "The delivery provider rejected the message."
  },
  "created_at": "2026-09-04T09:15:30Z",
  "updated_at": "2026-09-04T09:15:31Z"
}
```

## Retrieve a message

`GET /v1/messages/{message_id}`

Returns the current normalized representation of a message when it belongs to the authenticated project.

### Required header

```http
Authorization: Bearer <API_KEY>
```

`200 OK` returns the same [Message object](#message-object) shape used by creation. A message that does not exist **or** belongs to another project returns `404 message_not_found`; this prevents cross-project message enumeration.

## Message status

| Status | Meaning | Recommended application behavior |
| --- | --- | --- |
| `queued` | The message record exists and is waiting for a submission attempt. | Read again shortly. This is normally brief. |
| `submitted` | The delivery provider accepted the submission. Handset delivery is not yet confirmed. | Record the ID and poll when a final result matters. |
| `delivered` | A reliable provider confirmation says the message reached delivery. | Terminal success. |
| `undeliverable` | A reliable provider terminal result says the SMS could not be delivered. | Terminal non-delivery; inspect `error`. |
| `failed` | The service or provider definitively rejected or failed the submission. | Inspect `error` and correct the cause before a new logical send. |
| `unknown` | The provider did not return a definitive submission result; the SMS may or may not have been sent. | Do not automatically retry. Avoiding duplicate SMS takes priority. |

Only `delivered` and `undeliverable` claim a reliable final delivery outcome. `submitted` is intentionally not a delivery claim.

## Idempotency and retry safety

The idempotency key belongs to a project and is compared with the exact `to`, `template`, `variables`, and `locale` request values.

| Situation | HTTP result | What to do |
| --- | --- | --- |
| A network failure leaves your client unsure whether it received the response | Unknown to the client | Retry the same request with the same key. If the service processed it, it returns the original message with `200`. |
| Same key, same recipient, template, and variables | `200` | Use the original message; do not create another send. |
| Same key, different recipient, template, or variables | `409 idempotency_key_reused` | Generate a new key for the new logical message. |
| `503 provider_unavailable` | `503` | The service verified that the provider was not reached. It is safe to retry the same request. |
| A `201` message whose status is `unknown` | `201` | Do **not** retry automatically: submission may have occurred. |

Idempotency records are currently retained for 24 hours. Treat that as a safety window, not permission to reuse one key for a later business event.

## Error format

Every API error uses this envelope and includes a request ID:

```json
{
  "error": {
    "code": "invalid_recipient",
    "message": "The recipient must be a Georgian mobile number in E.164 format: +9955XXXXXXXX.",
    "request_id": "req_01J…"
  }
}
```

The same ID is returned in the `X-Request-Id` response header. Provide it when asking for support; it is safe to share, unlike an API key, message body, or full phone number.

| HTTP | Code | Meaning and next step |
| --- | --- | --- |
| `400` | `invalid_request` | The JSON body or a request value is invalid, unexpected, or malformed. Correct the supplied request. |
| `400` | `invalid_recipient` | Send only the documented `+9955XXXXXXXX` Georgian mobile format. |
| `400` | `invalid_template` | The `template` id is unknown. The message lists the valid ids. |
| `400` | `invalid_variables` | A variable is missing, unexpected, not a string, the wrong shape, or looks like a link or phone number. The message names the variable and gives a valid example. |
| `400` | `idempotency_key_required` | Add a unique `Idempotency-Key` header. |
| `401` | `unauthorized` | Supply `Authorization: Bearer <API_KEY>`. |
| `401` | `invalid_api_key` | The key is malformed, unknown, or revoked. Obtain a valid current key. |
| `402` | `insufficient_credits` | A live key's project has too few credits for this message; nothing was sent. The project owner adds credits (the dashboard explains how). Test keys keep working. |
| `403` | `project_suspended` | The project cannot send at present. Contact the operator. |
| `404` | `message_not_found` | The ID is absent or is owned by a different project. Do not retry with another project’s key. |
| `409` | `idempotency_key_reused` | Use a new key for a changed recipient, template, or variables. |
| `429` | `rate_limited` | Slow down and retry shortly with the same logical-send idempotency key. |
| `429` | `daily_limit_reached` | The free tier's daily segment limit is used up; nothing was sent. Do not retry before the reset time named in the message (midnight UTC). Paid credits remove the limit. |
| `503` | `provider_unavailable` | The provider was not reached; retrying is safe. |
| `500` | `internal_error` | An unexpected service error occurred. Retry cautiously with the same key and provide the request ID if it continues. |

Some provider outcomes appear inside a `201` Message object’s `error` field rather than this HTTP error envelope. Possible safe codes include `provider_rejected`, `sender_not_allowed`, `invalid_message`, `invalid_recipient`, and `internal_error`.

## Test-mode scenarios

Use these recipient values with an `sk_test_…` key to verify how your integration handles outcomes. They are simulator controls only; no SMS is sent.

| Recipient | Initial API result | Later result / safe action |
| --- | --- | --- |
| `+995500000001` | `201` message with `failed` / `provider_rejected` | Definitive simulated provider rejection. |
| `+995500000002` | `503 provider_unavailable` | The request is retry-safe. |
| `+995500000003` | `201` message with `unknown` / `provider_unavailable` | Simulated ambiguous result; do not automatically retry. |
| `+995500000004` | `201` message with `submitted` | Becomes `undeliverable` after the simulated status delay and a poller pass. |
| Any other valid Georgian mobile number | `201` message with `submitted` | Becomes `delivered` after the simulated status delay and a poller pass. |

## Health endpoints

These endpoints do not require an API key and return no secret configuration.

| Route | `200` response | Failure behavior |
| --- | --- | --- |
| `GET /healthz` | `{ "status": "alive" }` | Process liveness only. |
| `GET /readyz` | `{ "status": "ready", "database": "ok" }` | Returns `503` with `{ "status": "not_ready", "database": "unreachable" }` when the database cannot be reached. |

## Local development

The repository can bootstrap a complete fake-provider environment, including a new test key:

```bash
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python scripts/bootstrap_dev.py
.venv/bin/uvicorn app.main:create_app --factory --reload
```

The bootstrap script creates a local `.env`, runs migrations, creates a demo project, and prints a test key. It defaults to the fake provider. Never enable the live provider or the opt-in end-to-end test unless you are authorized to send a controlled message to a consenting recipient.

## Scope and data handling

This API does not support marketing campaigns, bulk sends, contact lists, scheduled messaging, inbound SMS, two-way conversations, customer-facing webhooks, arbitrary sender selection, billing, or multi-provider routing.

The service stores message text encrypted at rest and normally purges it after its configured retention period. Public API responses and normal logs redact the sensitive parts of phone numbers and never expose message text or API secrets. Applications should apply the same standard: avoid logging the full request body or authorization header.
