# OTP Sanly — Full API Reference (machine-readable) Base URL: https://otp.sanly.dev Content type: application/json (both request and response) Transport: HTTPS only. No custom auth header — the API key is a field inside the JSON body. Coverage: SMS delivery is Turkmenistan-only (+993 numbers, via a physical Android SIM-card gateway app). Email delivery works worldwide, any address. Get an API key: https://otp.sanly.dev/dashboard/api-keys (free account required; a key is created instantly, though it may need payment/plan confirmation before it can send real messages — sandbox keys work immediately with no payment). Official Node.js/TypeScript SDK: `npm install otp-sanly` — https://www.npmjs.com/package/otp-sanly (exports `OtpSanly` class with `sendOtp()` / `verifyOtp()` methods; typed, zero required dependencies beyond global `fetch`). No official PHP/Composer package yet — use raw HTTP calls per the examples below. --- ## 1. POST /api/send-otp Request body (JSON): | field | type | required | notes | |---------|--------|----------|-------| | apiKey | string | yes | starts with `otpsanly_` | | phone | string | one of phone/email | E.164-ish, Turkmenistan numbers only, e.g. `+99361234567` | | email | string | one of phone/email | any valid email address, any country | | project | string | no | free-text label shown in the dashboard/webhook, e.g. your site name | | lang | string | no | `"tk"` \| `"ru"` \| `"en"` — which language to send the OTP in (selects the 3-language custom message/project label configured on the API key's template). Defaults to `"tk"` if omitted. Set this explicitly — do not rely on the `Accept-Language` HTTP header, since send-otp is normally called server-to-server and that header is usually absent or meaningless in that context. | Send phone OR email, not both. If both are omitted, the request fails with a 400. Example request: ``` curl -X POST https://otp.sanly.dev/api/send-otp \ -H 'Content-Type: application/json' \ -d '{"apiKey":"otpsanly_xxxxxxxx","phone":"+99361234567","project":"My Site","lang":"ru"}' ``` Success response (200): ```json { "success": true, "otpId": 1234, "expiresIn": 120, "expiresAtTM": "2026-08-09 14:32:00", "attempt": 1, "maxAttempts": 3, "remainingOtp": 4820, "channel": "sms_gateway" } ``` Sandbox-key response (only sandbox keys get a `code` field — normal keys never return the code): ```json { "success": true, "sandbox": true, "otpId": 1234, "target": "+99361234567", "code": "482913", "channel": "sms_sandbox", "expiresIn": 120, "message": "SANDBOX MODE: no real SMS/email was sent." } ``` ### Response fields (send-otp) | field | type | description | |--------------|----------|-------------| | success | boolean | whether the request succeeded | | otpId | number | required for the following verify-otp call | | expiresIn | number | seconds until the code expires | | expiresAtTM | string | expiry time in Ashgabat (Turkmenistan) timezone | | attempt | number | which attempt this is (1st/2nd/3rd within the 10-minute window) | | maxAttempts | number | always 3 | | remainingOtp | number | OTPs left on the caller's plan | | channel | string | `email` \| `sms_gateway` \| `sms_sandbox` | | retryAfter | number? | present only when rate-limited — seconds to wait before retrying | | error | string? | present only when success is false | --- ## 2. POST /api/verify-otp Request body (JSON): | field | type | required | notes | |--------|--------|----------|-------| | apiKey | string | yes | same key used to send | | otpId | number | yes | from the send-otp response | | code | string | yes | the code the user typed in (or the sandbox `code` field during testing) | Example request: ``` curl -X POST https://otp.sanly.dev/api/verify-otp \ -H 'Content-Type: application/json' \ -d '{"apiKey":"otpsanly_xxxxxxxx","otpId":1234,"code":"482913"}' ``` Success response (200): ```json { "success": true, "otpId": 1234, "target": "+99361234567", "verifiedAt": "2026-08-09T14:30:12.000Z", "verifiedAtTM": "2026-08-09 18:30:12", "channel": "sms_gateway", "project": "My Site" } ``` ### Response fields (verify-otp) | field | type | description | |--------------|---------|-------------| | success | boolean | verification succeeded | | otpId | number | the verified OTP id | | target | string | phone or email that was verified | | verifiedAt | string | UTC ISO timestamp | | verifiedAtTM | string | Ashgabat (Turkmenistan) local time | | channel | string | delivery channel that was used | | project | string | the `project` label from the original send-otp call | | error | string? | present only when success is false | --- ## 3. Error codes (both endpoints) | HTTP | error (example) | meaning | |------|-------------------------------------------|---------| | 200 | success: true | OK | | 400 | "Kod ýalňyş" / wrong code | code entered doesn't match | | 400 | "OTP möhleti gutardy" / OTP expired | request a new OTP | | 400 | "OTP tapylmady" / OTP not found | wrong otpId, or otpId belongs to a different apiKey | | 401 | "API açar ýalňyş" / invalid API key | apiKey is wrong, disabled, or plan expired mid-request | | 403 | "IP rugsatsyz" / IP not allowed | caller's IP isn't in the key's IP allowlist (if one is set) | | 403 | "Bu domen üçin rugsatnama ýok" / domain not allowed | caller's Origin/Referer isn't in the key's domain allowlist (if one is set) — see note below | | 403 | "Plan möhleti gutardy" / plan expired | renew the plan in the dashboard | | 403 | "OTP limiti doldy" / OTP limit reached | plan's OTP quota exhausted | | 429 | "...sekuntdan soň synanyşyň" / rate limited | wait `retryAfter` seconds (see Retry Logic below) | | 500 | "Ulgam ýalňyşlygy" / server error | internal error, safe to retry after a short delay | **Important — domain vs. IP restriction:** Domain restriction checks the browser's `Origin`/`Referer` header. It only works when the request comes directly from a browser via client-side JavaScript. For server-to-server integrations (any backend calling the API — PHP, Python, Node backend, etc.), domain restriction does nothing useful because server-to-server requests typically don't send an `Origin` header at all, so a domain-restricted key can appear to work from anywhere unless IP restriction is also configured. **For backend integrations, use IP restriction instead of (or in addition to) domain restriction.** Both are optional and configured per-key in the dashboard (API Keys → key settings). If you get an unexpected HTTP 404 (not one of the codes above) calling either endpoint: the request never reached the app route — double-check the exact URL (`https://otp.sanly.dev/api/send-otp`, no trailing slash, POST method, Content-Type: application/json), and check whether the calling server's network/host blocks or proxies outbound HTTPS to otp.sanly.dev. --- ## 4. Sandbox mode Create a sandbox-flagged API key in the dashboard (API Keys → New key → check "Synag (sandbox) rejimi"). Sandbox keys: - Never send a real SMS or email. - Never consume the plan's OTP quota. - Return the actual OTP `code` directly in the send-otp JSON response (`channel: "sms_sandbox"` or similar), so you can complete a full send→verify integration test with zero cost and zero real messages. - Work exactly like normal keys with `/api/verify-otp` — just use the `code` from the response instead of a code from a real SMS/email. - Should never be used in production — swap to a normal key before going live. --- ## 5. Retry / rate-limit logic Per phone number or email address, at most 3 OTP requests are allowed within a rolling 10-minute window: | attempt | retryAfter | wait | |---------|------------|----------| | 1st | — | immediate | | 2nd | 60 | 1 minute | | 3rd | 300 | 5 minutes | | 4th+ | 600 | 10 minutes (blocked) | When rate-limited, `success` is `false` and `retryAfter` (seconds) is present — show a countdown to the user rather than retrying immediately. --- ## 6. Webhooks (optional) Configure a webhook URL and allowed domains per API key (API Keys → key settings → Webhook). Two event types are POSTed as JSON to your URL: `otp.created` — fired immediately when an OTP is generated: ```json { "event": "otp.created", "otpId": 42, "target": "+99361234567", "code": "348291", "project": "My Site", "channel": "sms_gateway", "expiresAt": "2026-08-09T14:32:00.000Z", "timestamp": "2026-08-09T14:30:00.000Z" } ``` `otp.status_updated` — fired once the SMS gateway confirms actual delivery or failure (SMS channel only; not applicable to email): ```json { "event": "otp.status_updated", "otpId": 42, "target": "+99361234567", "status": "delivered", "timestamp": "2026-08-09T14:30:12.000Z" } ``` `status` is `"delivered"` or `"failed"`. Webhooks are only accepted from/sent to domains explicitly allowed for that key. --- ## 7. Security best practices - Never expose the API key in client-side/browser code — always proxy the call through your own backend. - Store `otpId` server-side (httpOnly cookie or server session) between the send and verify calls, not in localStorage or a URL param. - Prefer IP restriction over domain restriction for server-to-server integrations (see section 3). - By default, a key with no restrictions works from any site/app/IP that has the key string. Set IP and/or domain restrictions in the dashboard if you want to lock a key to your own infrastructure. --- ## 8. Minimal integration (any language, pseudocode) ``` otpId = POST /api/send-otp { apiKey, phone or email, project } -> response.otpId # show a code-entry UI to the user, store otpId server-side result = POST /api/verify-otp { apiKey, otpId, code: user_input } -> response.success # if success == true, the user is verified — grant access / continue registration ``` Full copy-paste examples in Next.js, Python, JavaScript, HTML, PHP, Go, Ruby, Java, and cURL for both endpoints, plus small dependency-free wrapper classes for Node.js and PHP, are available at the interactive docs: https://otp.sanly.dev/developers An OpenAPI 3.0 spec (for tools that ingest OpenAPI directly) is available at: https://otp.sanly.dev/openapi.json