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 theclaim_urlyour 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:
- Use only the three public routes documented below:
POST /v1/test-keys,POST /v1/messages, andGET /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. - Use an
sk_test_…key first. If you have none,POST /v1/test-keysreturns one instantly. Test keys always use the fake provider and never send a real SMS or spend credits. - 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. - Treat
submittedas provider acceptance, not handset delivery. PollGET /v1/messages/{id}until a terminal status when the workflow needs a final outcome. - 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. - 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:
- 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_reacheduntil 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 endsunknownstays 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
{
"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:
code: 4 to 8 digits (0–9only).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 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.