Integration guide
Wire your backend into the SMS-OTP reverse-verification login service
hosted at https://otp.mioe.com.tr.
What the service does
The end user’s phone number is verified by having the user send an SMS to your destination number containing a token your service displayed on screen. That’s the inverse of the usual OTP flow: instead of you sending an OTP and asking the user to type it, the user texts the OTP back from their phone, which proves they own the number.
┌──────────┐ 1. POST /auth/init ┌──────────────────────┐ │ your app │──────────────────────────────▶ otp.mioe.com.tr │ │ │ X-API-Key, phone │ │ │ │◀─────────────────────────────│ session, code, otp │ │ │ 2. response (code + otp) │ │ │ │ │ │ │ │ 3. tell user to SMS │ │ │ user │ "<code> <otp>" to <NUM> │ │ │ │ ╭──────────────╮ │ │ │ │ │ 📱 user │── SMS ──▶ │ GSM gateway │ │ │ │ <code> <otp> │ │ │ │ │ │ ╰──────────────╯ │ ▼ webhook │ │ │ │ /webhook/sms/<sec> │ │ │ │ │ │ │ │ │ ▼ match │ │ │ 4. signed callback │ │ │ │◀─────────────────────────────│ POST <callback_url> │ │ │ verified only │ │ └──────────┘ └──────────────────────┘
If the SMS arrives within 60 seconds and the <code> <otp>
matches, your configured callback_url receives a
verified event — that's the only thing we push, because
it's the only event only we can observe (an inbound SMS arriving).
There is no timeout callback: you already have
expiresAt from the /auth/init response, so your
own client can decide "the user didn't reply in time" on its own, on its
own clock, without waiting on us. If you want an authoritative read on a
session's final state anyway, poll
GET /auth/session/<sessionId> (see below).
Three integration modes
The operator picks one per webapp at creation time:
| Mode | Destination number / gateway | What the user texts |
|---|---|---|
| Shared (default) | The operator’s shared number | <code> <otp>, e.g. WXKZ 482910 |
| Dedicated inbound | Your own dedicated number | <otp> only, e.g. 482910 |
| Bring-your-own gateway | Your own SMS gateway / number | <otp> (or <code> <otp>) |
Shared mode is the cheap default. Dedicated mode requires a number bound exclusively to your webapp and gives end users a friendlier flow: just type six digits. BYO is for webapps running their own SMS infrastructure (different provider, self-hosted SMSC, sandbox short code, …).
What you get from the operator
| Field | Example | Purpose |
|---|---|---|
code | WXKZ | 4 letters the user types (shared mode) |
api_key | lvsr_… | Sent in X-API-Key on outbound calls |
hmac_secret | 64-char hex | Verifies HMAC on callbacks we send you |
callback_url | https://yourapp.example/sms-login/cb | We POST verified here (only event we push) |
inbound_number | +90850XXXXXXX or unset | Your dedicated number (dedicated mode) |
webhook_url | …/webhook/sms/webapp/lvsrwh_… | Your per-webapp inbound URL (BYO mode) |
api_key,
hmac_secret, and webhook_url as secrets —
not in source control. All three are shown only once (at creation and at
rotation). If any leaks, ask the operator to rotate it immediately.
Step 1 — Issue an OTP
POST https://otp.mioe.com.tr/auth/init HTTP/1.1
X-API-Key: lvsr_…
Content-Type: application/json
{ "phone": "+905321112233" }
phone accepts E.164 / national / local forms and is normalized server-side to +90….
Successful response:
{
"sessionId": "1cce6791-640e-4dd1-be9e-98b8b485ee14",
"code": "WXKZ",
"otp": "482910",
"inboundNumber": "+90850XXXXXXX",
"expiresAt": "2026-05-07T16:01:30.000Z",
"expiresIn": 60
}
inboundNumber is the dedicated number for your webapp, or null if you’re on the shared number.
Errors:
| HTTP | error | Cause |
|---|---|---|
| 400 | invalid_json | Body isn’t JSON |
| 400 | missing_phone | phone empty after normalization |
| 401 | missing_api_key | No X-API-Key header |
| 401 | invalid_api_key | Key doesn’t match an active webapp |
Re-issuing for the same (webapp, phone) while a previous session is pending invalidates the older one (status flips to failed, no callback fires for it).
Step 2 — Tell the user how to reply
Shared mode (inboundNumber is null):
WXKZ 482910 from your phone to <SHARED_GSM_NUMBER>. You have 60 seconds.
Dedicated mode:
482910 from your phone to <inboundNumber>. You have 60 seconds.
Code matching is case-insensitive. The body must be exactly
<4-letter-code><whitespace><6-digit-otp> or exactly
<6-digit-otp> (dedicated/BYO modes) — extra text is logged as unparseable and ignored.
Step 3 — Receive the callback
If the SMS matches within 60 seconds we POST exactly one
verified payload to your callback_url. That's
the only event we push — there is no timeout callback.
verified — the SMS matched
POST https://yourapp.example/sms-login/cb HTTP/1.1
Content-Type: application/json
X-Login-Signature: t=1746635012,v1=8c5d…
{
"type": "verified",
"sessionId": "1cce6791-640e-4dd1-be9e-98b8b485ee14",
"webappCode": "WXKZ",
"phone": "+905321112233",
"verifiedAt": "2026-05-07T16:00:42.000Z"
}
Your endpoint must respond 2xx quickly (within ~5s). Non-2xx is logged as a delivery failure but the session is still marked resolved; we do not retry.
No SMS within 60 seconds?
You already have expiresAt from the /auth/init
response — that's a fact about elapsed time, not something only we
observe, so we don't push anything for it. Start your own 60-second
timer when you show the code to the user; when it elapses without a
verified callback, treat the attempt as timed out and let
them retry with a fresh /auth/init call.
If you want an authoritative read on a specific session (e.g. to reconcile after your own timer fires, or to render a status page), poll:
GET /auth/session/<sessionId> HTTP/1.1
X-API-Key: <api_key>
which returns the current row — status is one of
pending / verified / timeout /
failed. If a session is still pending past its
expires_at, this call itself flips it to timeout
(and strips the phone) before responding — there's no separate job
racing to do that for you. A session you never poll is simply deleted
outright by the periodic KVKK sweep once it's expired, rather than
lingering as a timeout row.
{
"id": "1cce6791-640e-4dd1-be9e-98b8b485ee14",
"status": "timeout",
"created_at": 1746634952000,
"expires_at": 1746635012000,
"resolved_at": null,
"callback_status": null,
"callback_error": null
}
Verifying the signature
Always verify X-Login-Signature before trusting a callback.
The header has the form t=<unix-seconds>,v1=<hex>, where
v1 is HMAC-SHA256(hmac_secret, "<t>.<raw-body>").
t is more than ~5 minutes off your
server clock. Use a constant-time comparison
(crypto.timingSafeEqual / hmac.compare_digest / hash_equals).
Examples
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.LOGIN_HMAC_SECRET;
const app = express();
app.post(
"/sms-login/cb",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-Login-Signature") || "";
const m = sig.match(/^t=(\d+),v1=([a-f0-9]+)$/);
if (!m) return res.status(400).send("bad signature");
const t = Number(m[1]);
const v1 = m[2];
if (Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send("stale signature");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
if (
v1.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// event.type is always "verified" -- that's the only thing we push.
// mark session verified, issue your own session cookie/JWT, etc.
res.status(200).end();
},
);
app.listen(8080);
Python (Flask)
import hmac, hashlib, os, re, time, json
from flask import Flask, request, abort
SECRET = os.environ["LOGIN_HMAC_SECRET"].encode()
app = Flask(__name__)
@app.post("/sms-login/cb")
def cb():
sig = request.headers.get("X-Login-Signature", "")
m = re.match(r"^t=(\d+),v1=([a-f0-9]+)$", sig)
if not m:
abort(400, "bad signature")
t, v1 = int(m.group(1)), m.group(2)
if abs(time.time() - t) > 300:
abort(400, "stale signature")
raw = request.get_data() # bytes, untouched
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
abort(401, "invalid signature")
event = json.loads(raw)
# event["type"] is always "verified" -- that's the only thing we push.
... # log the user in
return "", 200
PHP
<?php
$secret = getenv('LOGIN_HMAC_SECRET');
$sig = $_SERVER['HTTP_X_LOGIN_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([a-f0-9]+)$/', $sig, $m)) {
http_response_code(400); exit('bad signature');
}
[$_, $t, $v1] = $m;
if (abs(time() - (int)$t) > 300) { http_response_code(400); exit('stale'); }
$raw = file_get_contents('php://input'); // raw bytes
$expected = hash_hmac('sha256', "$t." . $raw, $secret);
if (!hash_equals($expected, $v1)) {
http_response_code(401); exit('invalid signature');
}
$event = json_decode($raw, true);
// $event['type'] is always 'verified' -- that's the only thing we push.
// create a server-side session for $event['phone']
http_response_code(200);
Step 4 — Handle the callback
The only thing you have to build is the callback_url handler:
when the signed verified POST arrives, complete the login
server-side — record that this sessionId/phone
is now authenticated. That’s the whole contract with us.
The callback reaches your backend, not the user’s browser tab. How you then reflect that in your own UI is entirely up to you and outside the scope of this integration — we don’t require or prescribe anything on your front-end.
Identity binding — trust the phone in the callback, not the client
This is why the signed callback is the recommended integration,
not just a convenience. The verified callback carries the
sessionId and the verified phone
together, and both are covered by the HMAC signature. Log the user
in against the phone from that signed body, keyed by sessionId.
+90555 111 11 11), then call your own completion
endpoint with a different phone
(+90555 999 99 99) and be logged in as someone
else. The verified flag alone doesn’t say whose
identity was proven — only the (sessionId → phone)
pair inside the signed body does, and that pair comes from us. Bind the two
and the attack is structurally impossible.
Optional — poll the session status from us
GET https://otp.mioe.com.tr/auth/session/<sessionId> HTTP/1.1
X-API-Key: lvsr_…
{
"id": "1cce6791-640e-4dd1-be9e-98b8b485ee14",
"status": "verified",
"created_at": 1746634942000,
"expires_at": 1746635002000,
"resolved_at": 1746634971000,
"callback_status": 200,
"callback_error": null
}
Bound to your API key — you cannot read another webapp’s sessions. The callback is the source of truth for both the decision and the verified phone, because it carries the HMAC. This endpoint intentionally never returns the phone (it’s stripped the moment the session resolves) — the verified number is delivered only in the signed callback, exactly so identity can never be asserted by a client.
Bring-your-own SMS gateway
Don’t want our GSM channel? The operator gives you a per-webapp inbound webhook URL instead:
https://otp.mioe.com.tr/webhook/sms/webapp/lvsrwh_<32-hex>
Configure your SMS gateway (Twilio, Vonage, your self-hosted SMSC, …) to POST inbound SMS to that URL. We accept JSON, form-encoded, or query-string payloads on POST or GET. The path key alone identifies your webapp:
<otp>— six digits, e.g.482910<code> <otp>— e.g.WXKZ 482910(the code must match your webapp’scode)
Cross-webapp matching is impossible: a webhook hit on your URL only ever
matches sessions belonging to your webapp. If the URL key is wrong or
your webapp is disabled we respond 403 and log the attempt.
Rotating the URL
If the URL ever leaks, ask the operator to rotate the inbound webhook. This swaps the key in place — the old URL stops working immediately and you get a new one to configure in your gateway.
Edge cases & operational notes
- The user mistypes the OTP. No match, the session simply ages out — no callback fires. Your own 60-second timer (started from
expiresAt) is what tells you to give up; re-issue with/auth/initand try again. - The user sends extra text like
Hi WXKZ 482910 thanks— currently rejected as unparseable. Tell users to send only the two tokens. - Dedicated mode + wrong recipient. If the user sends just
482910to the shared number we can’t disambiguate which webapp it’s for. Make the destination number prominent in your UI. - The user re-issues mid-session. Calling
/auth/initagain invalidates the older one asfailed(no callback fires for it). - Multiple users from the same phone. A webapp can only have one pending OTP per phone at a time.
- Clock skew. All
*_atfields are UTC ISO 8601. Verify the HMACtagainst UTC time. - Webhook delivery failure. Non-2xx is logged on our side (visible in the admin UI) but not retried. Make your endpoint idempotent.
- Phone normalization. Turkish numbers are canonicalized to
+90+ 10 digits. - Rate limiting is not enforced today on
/auth/init. Gate it on your end (captcha, etc.) for high-volume integrations.
Endpoint reference
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /auth/init | X-API-Key | Issue OTP for a phone |
| GET | /auth/session/{sessionId} | X-API-Key | Read session status; also where a lapsed pending session actually becomes timeout |
| POST | your callback_url | HMAC verified | Receive verified (the only event pushed) |
| POST/GET | /webhook/sms/webapp/{key} | URL key in path | BYO inbound SMS hook |
Base URL: https://otp.mioe.com.tr.
Security model
- Proof of possession via carrier. The MSISDN of an inbound SMS is set by the carrier, not the user’s browser — impossible to spoof from a form submission.
- HMAC-SHA256 signed callbacks. Every outbound webhook is signed with your per-webapp secret over the raw body plus a Unix timestamp. Anyone hitting your endpoint without the correct signature is rejected.
- Identity binding. The
verifiedcallback carries thesessionIdand the provenphonetogether, both under the signature, so the “which number was verified” answer comes from us, not the client. A caller can never verify one number and claim another — the signed(session → phone)pair is the only authoritative source, which is why the callback is the recommended integration path. - Per-webapp isolation. Each tenant has its own API key, HMAC secret, 4-letter routing prefix and (in BYO mode) opaque inbound URL key. Cross-webapp SMS matching is structurally impossible.
- One-shot, time-boxed sessions. Each OTP is valid for a hard 60-second window enforced server-side: every match lookup requires
expires_at > now, so once the window passes the OTP simply stops matching and is swept. After a match or after expiry, the OTP is dead — replay attempts hit nothing. - Re-issue invalidates. A new
/auth/initfor the same phone marks the previous sessionfailedwith no callback — old OTPs are unusable the moment a new one is issued. - Signed admin sessions. The operator dashboard runs on cookie sessions signed with a worker-side secret; SPA origin is CORS-locked.
- Secrets are surfaced once. API keys, HMAC secrets and BYO webhook URLs are shown at creation/rotation only; the operator can rotate any of them in place.
- No retries on failed callbacks. If your endpoint rejects an event we log it but don’t resend — an attacker can’t induce duplicate verifications by stalling your server.