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:

ModeDestination number / gatewayWhat the user texts
Shared (default)The operator’s shared number<code> <otp>, e.g. WXKZ 482910
Dedicated inboundYour own dedicated number<otp> only, e.g. 482910
Bring-your-own gatewayYour 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

FieldExamplePurpose
codeWXKZ4 letters the user types (shared mode)
api_keylvsr_…Sent in X-API-Key on outbound calls
hmac_secret64-char hexVerifies HMAC on callbacks we send you
callback_urlhttps://yourapp.example/sms-login/cbWe POST verified here (only event we push)
inbound_number+90850XXXXXXX or unsetYour dedicated number (dedicated mode)
webhook_url…/webhook/sms/webapp/lvsrwh_…Your per-webapp inbound URL (BYO mode)
Treat these like passwords. Store 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:

HTTPerrorCause
400invalid_jsonBody isn’t JSON
400missing_phonephone empty after normalization
401missing_api_keyNo X-API-Key header
401invalid_api_keyKey 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):

To finish signing in, text WXKZ 482910 from your phone to <SHARED_GSM_NUMBER>. You have 60 seconds.

Dedicated mode:

To finish signing in, text 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>").

Do not parse-and-re-stringify the body before verifying. JSON re-serialization changes byte order and breaks the signature. Use the raw bytes you received.
Reject callbacks where 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.

You must not trust the front-end to tell you the session was verified. The proof is the signed callback hitting your backend.

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.

Never let the client tell you which number was verified. If you read the phone from a browser field or a client-posted value, a malicious client can verify a number it genuinely controls (+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:

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

Endpoint reference

MethodPathAuthPurpose
POST/auth/initX-API-KeyIssue OTP for a phone
GET/auth/session/{sessionId}X-API-KeyRead session status; also where a lapsed pending session actually becomes timeout
POSTyour callback_urlHMAC verifiedReceive verified (the only event pushed)
POST/GET/webhook/sms/webapp/{key}URL key in pathBYO inbound SMS hook

Base URL: https://otp.mioe.com.tr.

Security model

Open admin dashboard   Back to overview