On this page

Building with an AI coding agent? Point it at /api.md (this page as Markdown) or /llms.txt. To try the routes in your browser, use the interactive docs.

NOTIFY API reference

NOTIFY is a developer-first API for application-triggered SMS in Georgia. It is deliberately limited to transactional messages the recipient is expecting: today, verification, sign-in, and password-reset codes, each from a built-in template in Georgian or English.

Private beta. Get a test key instantly with POST /v1/test-keys — no account needed. For live keys, sign in with GitHub or Google at n0t1fy.dev/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.n0t1fy.dev 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.

How to read a send result

An HTTP 2xx means the message record exists, not that the SMS arrived. Always read status in the body:

HTTP status in the body What it means What to do
201 submitted The provider accepted it. It is not yet confirmed on the phone. Store the id. Poll if you need the final outcome.
201 failed The provider refused it. Nothing will be delivered. Read error; fix the cause before a new send.
201 unknown It may or may not have been sent. Do not retry automatically.
200 any A replay: this exact request was already processed. Use the original message; no second SMS was sent.
503 — (provider_unavailable) Nothing was sent. Safe to retry with the same Idempotency-Key.
other 4xx — Nothing was sent. Read error.message: it says what to change.

NOTIFY does not call your application when a status changes: there are no webhooks to customers yet. To learn the final outcome, poll GET /v1/messages/{id}: start after about 2 seconds and double the wait each time, up to 30 seconds between reads. Test messages settle within about 30 seconds; a live delivery confirmation usually arrives within seconds but can take minutes.

Authentication and key modes

Send every message request with:

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 or from your dashboard. The idempotency value shown here is an example; generate a new unique value for each logical message.

curl -X POST https://api.n0t1fy.dev/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:

{
  "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:

curl https://api.n0t1fy.dev/v1/messages/msg_01J… \
  -H 'Authorization: Bearer YOUR_TEST_API_KEY'

With a normal fake-provider recipient, the message becomes delivered within about 30 seconds: the simulated confirmation is ready after about five seconds, and the service applies it on its next status check, which runs every 20 seconds. Poll with a growing wait (see How to read a send result); 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.

curl -X POST https://api.n0t1fy.dev/v1/test-keys
{
  "api_key": "sk_test_8PIL8xQvWtNOG1mFjJeBz29uo5ODR8Pc",
  "project_id": "proj_01J…",
  "mode": "test",
  "claim_url": "https://n0t1fy.dev/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 in two ways. On 429 rate_limited (too many from your address this minute), wait a minute before trying again. On 429 daily_limit_reached (today's instant test keys are used up, either your address's daily share or everyone's daily allowance), do not retry before the reset time named in the message, midnight UTC; keep using a key you already have, and a human can still sign in and create a test key on the dashboard meanwhile.

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, and the messages you sent with it stay readable. The link works once, and stops working after 30 days (expires_at) if nobody uses it; the key itself is not affected. Messages of a project nobody claims are deleted 30 days after they were sent.

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:

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

{
  "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 NOTIFY (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:

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 NOTIFY 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

{
  "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.
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:

{
  "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

Authorization: Bearer <API_KEY>

200 OK returns the same 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 a key mode, and is compared with the exact to, template, variables, and locale request values. Test and live keys never share idempotency: a live send that reuses a key from a test rehearsal is a new live send, never a replay of the test message.

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:

{
  "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 A daily limit is used up: the free tier's segments (nothing was sent; paid credits remove the limit), or today's instant test keys from POST /v1/test-keys (your address's share, or everyone's). Do not retry before the reset time named in the message (midnight UTC).
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 within about 30 seconds.
Any other valid Georgian mobile number 201 message with submitted Becomes delivered within about 30 seconds.

Both later outcomes take the same path and the same time: about five seconds of simulated delay, then the service's next status check (every 20 seconds). A test message that is still submitted after 10 seconds is not stuck.

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:

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.