PHANTOM NUMBERS

Sign in with NFT Number

Встройте вход через NFT‑номер в свой сервис за 5 минут. OTP-код летит в Telegram + Web Push + live в наш webapp, ответ — подписанный JWT id_token + refresh_token.

В проекте два разных API

Не путайте их — они решают разные задачи.

Public API
nftgen_live_*
Читаете/управляете своими номерами и NFT из внешнего приложения. Аналог: GitHub API для работы со своими репозиториями.
Список эндпоинтов ↓
Sign in with NFT Number
pk_live_*
Ваш сайт пускает пользователей по их NFT‑номеру. Аналог: «Sign in with Google». Эта страница.
Quick start ↓

Быстрый старт

1
Зарегистрируйтесь в webapp TON-кошельком.
2
Откройте Мои интеграции в сайдбаре. Если пункта нет — нажмите «Стать разработчиком» в разделе Мои интеграции (по умолчанию нужна модерация админа).
3
Нажмите + New Integration, заполните имя, slug, logo, категорию и scopes (минимум otp.request + otp.verify).
4
Скопируйте API-ключ. Он показывается один раз.
5
Вставьте код из примеров ниже в свой бэкенд. Готово.

⚡ Instant setup — одна команда на сервере

🚀 Не хочется писать код?
Прямо в модалке «My Integrations → New Integration» после создания откройте вкладку Quick Start — мы сгенерим готовый конфиг под вашу платформу. Три способа:

1. Copy-paste snippet в модалке

После создания интеграции открывается модальное окно с тремя табами: API key, Quick Start, Docs. В Quick Start выберите карточку вашей платформы — там уже подставлены ваши client_id, redirect_uri, discovery URL и (один раз) client_secret. Копируете в настройки — готово:

  • 🛡️ oauth2-proxy — docker-compose, защищает ЛЮБОЙ веб-сервис (Grafana, Prometheus, внутренние дашборды и т.п.)
  • 🦊 Gitea — одна команда CLI, без перезапуска
  • 📊 Grafana — готовый блок для grafana.ini
  • ☁️ Nextcloud — конфиг для OpenID Connect Login app
  • 🟢 Node.js / Express — 40 строк с openid-client + PKCE
  • 🐍 Python / FastAPI — authlib + starlette-session
  • 🌐 curl — для отладки или языков без OIDC-библиотеки
  • 🔌 Generic OIDC — все 4 URL для любой платформы (Keycloak, Auth0, AWS Cognito, Azure AD, Okta как identity broker и т.д.)

2. One-command installer

Максимально быстрый вариант. Одна команда на вашем сервере — ставит oauth2-proxy как systemd-сервис, пишет конфиг с нашими endpoint'ами:

bashexport NFT_CLIENT_SECRET='pk_live_...'
curl -fsSL https://phantomnumbers.org/integrate/<slug>.sh | bash

Нужно:

  • Ubuntu / Debian с systemd, root
  • Ваш NFT_CLIENT_SECRET в env (скрипт его не хранит — тянет из окружения)

Что делает:

  • Скачивает официальный oauth2-proxy v7.6.0 (amd64/arm64)
  • Создаёт системного юзера oauth2proxy
  • Пишет /etc/oauth2-proxy.cfg + systemd unit, запускает
  • Слушает на 0.0.0.0:4180, форвардит в UPSTREAM (по умолчанию localhost:8080, можно переопределить env)

После этого ставите nginx / Caddy → 127.0.0.1:4180 — и ваш сервис за логином NFT Number.

3. Docker-compose

Если у вас docker-окружение:

bashcurl -fsSL "https://phantomnumbers.org/integrate/<slug>/oauth2-proxy.yml" > docker-compose.yml
# отредактируйте OAUTH2_PROXY_UPSTREAMS под ваш сервис
export NFT_CLIENT_SECRET='pk_live_...'
export COOKIE_SECRET="$(openssl rand -base64 32)"
docker compose up -d
⚠️ Безопасность
  • Ваш client_secret никогда не попадает в URL — только в env переменных или в ссылках в браузере владельца интеграции (страница создания, ротации). После первого показа — только SHA-256 hash в нашей БД.
  • Redirect URIs из allowed_redirect_uris — обязательное full-match совпадение.
  • Installer-скрипт — плоский bash, ~90 строк — можно прочитать перед запуском: curl -fsSL https://phantomnumbers.org/integrate/<slug>.sh | less

Как это работает

Пользователь вводит свой NFT-номер (формат 111-22-101-18) на вашем сайте. Вы вызываете наш /api/request-code с его номером и вашим API-ключом. Мы:

  • находим владельца в нашей БД;
  • генерируем 6-значный OTP (TTL 10 мин);
  • доставляем по трём каналам: Telegram-бот, Web Push, live-уведомление в открытом webapp;
  • в сообщении всегда указано имя вашей интеграции.

Пользователь вводит код у вас, вы вызываете /api/verify-code — мы возвращаем id_token и опционально refresh_token.

API endpoints

Два способа встроить вход — выбирайте под себя:

GroupMethodPathScopeОписание
Простой
request/verify
POST/api/request-codeotp.requestСгенерировать OTP для номера
POST/api/verify-codeotp.verifyПроверить OTP, получить id_token + refresh_token
POST/api/refresh-tokenotp.verifyОбменять refresh_token на новый id_token
OAuth 2.0 / OIDC
стандартный flow
GET/api/authorizeAuthorization endpoint (redirect flow, consent-страница)
POST/api/tokenОбмен codeaccess_token+id_token+refresh_token
GET/api/userinfoClaims пользователя по Bearer access_token
GET/.well-known/openid-configurationOIDC discovery (auto-config для Gitea / Grafana / oauth2-proxy)
Identity
read-only
GET/api/identity/user/{phone}user.identityПрочитать данные пользователя (после недавнего verify)
GET/api/integrations/meanySelf-test: увидеть свою интеграцию по ключу
GET/.well-known/nft-numbers.jwksПубличный ключ для RS256

Заголовок аутентификации для простого API: X-API-Key: pk_live_.... Для OAuth2 — см. раздел ниже.

OAuth 2.0 / OpenID Connect

Стандартный authorization-code flow. Подходит когда вы не хотите принимать номер и OTP у себя на фронте — мы показываем consent-страницу сами, возвращаем code, вы обмениваете его на токены.

🔑 Идеально для готовых интеграций
Gitea, Grafana, Nextcloud, Outline, любые сервисы с поддержкой OIDC — подключаются указанием одного URL: https://phantomnumbers.org/.well-known/openid-configuration. Всё остальное (scopes, endpoints, signing keys) они возьмут сами.

Шаги flow

1
В настройках интеграции в админке / Developer Panel добавьте свой redirect_uri в allowed_redirect_uris (полное совпадение, включая схему + хост + порт + путь).
2
Сгенерируйте PKCE пару (обязательно для всех клиентов): code_verifier = случайная строка 43–128 символов, code_challenge = BASE64URL(SHA-256(code_verifier)) без padding. Сохраните code_verifier на сервере/клиенте, он понадобится на шаге 4.
3
Ваш сайт редиректит пользователя на:
https://phantomnumbers.org/api/authorize
  ?response_type=code
  &client_id=<ваш slug>
  &redirect_uri=<URL в allowed_redirect_uris>
  &scope=openid%20profile%20email
  &state=<случайная строка для CSRF>
  &nonce=<случайная строка для id_token>
  &code_challenge=<BASE64URL(SHA-256(verifier))>
  &code_challenge_method=S256
4
Пользователь видит нашу consent-страницу, вводит NFT-номер и OTP. Мы редиректим обратно на <redirect_uri>?code=ac_...&state=<ваш state>. Code живёт 60 секунд, используется один раз.
5
Ваш бэкенд обменивает code на токены. code_verifier обязателен — без него сервер вернёт invalid_grant:
POST /api/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=ac_...
&redirect_uri=<тот же URI>
&client_id=<slug>
&client_secret=<ваш API key pk_live_...>
&code_verifier=<сохранённый на шаге 2>
6
В ответе: {access_token, id_token, refresh_token, token_type: Bearer, expires_in, scope}. access_token — JWT, живёт 1 час. refresh_token — 30 дней.
7
Читайте userinfo по access_token:
GET /api/userinfo
Authorization: Bearer <access_token>

→ {"sub":"123","email":"[email protected]",
   "nft_number":"1112210118","preferred_username":"1112210118",
   "name":"...","ton_wallet":"0:..."}

PKCE — обязателен для всех клиентов

С апреля 2026 PKCE (RFC 7636, method=S256) требуется на /api/authorize для всех приложений — и backend-интеграций с client_secret, и SPA без секрета. Это закрывает атаку «украденный auth-code → обмен на токен одним лишь client_id». Минимальный TypeScript-snippet:

const verifier = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
sessionStorage.setItem('pkce_verifier', verifier);
location.assign(`/api/authorize?...&code_challenge=${challenge}&code_challenge_method=S256`);

Что получит ваш сервис в userinfo

ClaimScopeПример
subвсегда"123" — наш user_id (стабильный)
nft_numberopenid / user.identity"1112748829"
preferred_usernameopenid"1112748829"
emailemail"[email protected]" — синтетический, стабильный
email_verifiedemailtrue
name, pictureprofiledisplay name пользователя
ton_walletuser.identity.wallet"0:abc..."
telegram_usernameuser.identity.telegram"ivan"

Готовые клиенты, протестированные с нами

  • Gitea — Site Administration → Authentication Sources → Add → OAuth2 provider = OpenID Connect, Discovery URL = наш openid-configuration. 3 минуты настройки.
  • oauth2-proxy — провайдер oidc, указать --oidc-issuer-url=https://phantomnumbers.org. Можно защитить любой non-OIDC сервис за этим прокси.
  • Grafana — Generic OAuth в [auth.generic_oauth], указать endpoints из discovery.

id_token: HS256 или RS256

Алгоритм подписи выбирается при создании интеграции:

  • HS256 — shared secret. Секрет = ваш API-ключ (он уже есть у обеих сторон). Самый простой вариант: одной функцией подписал, одной проверил. Ключ обязан оставаться на бэкенде.
  • RS256 — асимметричный. Мы подписываем приватным ключом, вы валидируете публичным из /.well-known/nft-numbers.jwks. Совместимо с любыми OIDC-библиотеками, подходит для раздачи токенов в мобильные приложения.

Payload: {iss, sub, aud, iat, exp, number, telegram_username?, wallet?, scope?}. TTL — 1 час (настраивается env ID_TOKEN_LIFETIME_SEC).

Refresh tokens

Вместе с id_token возвращается refresh_token (30 дней). Обменять на новую пару:

curlcurl -X POST https://phantomnumbers.org/api/refresh-token \
  -H "X-API-Key: pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"rt_..."}'

На каждом обмене старый refresh инвалидируется, выдаётся новый (rotation). Выдачу refresh можно отключить в настройках интеграции.

Webhooks

Укажите webhook_url в настройках интеграции — на него будут POST'иться события:

  • login.requested — OTP сгенерирован
  • login.verified — OTP подтверждён
  • login.denied — пользователь нажал «Это не я»
  • integration.suspended / integration.resumed

Подпись в заголовке X-NFT-Numbers-Signature: sha256=<hmac>. Всегда проверяйте подпись до обработки payload.

Пример на JavaScript

⚠️ API-ключ — только на бэкенде
Не вставляйте pk_live_* во frontend JS. Любой посетитель сможет его прочитать и минтить токены от вашего имени.

Frontend (вызывает ваш бэкенд)

html<button id="loginBtn">Sign in with NFT Number</button>
<script>
document.getElementById('loginBtn').addEventListener('click', async () => {
  const phone = prompt("Ваш NFT-номер (111-22-101-18):");
  await fetch("/auth/nft/request", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({ phone })
  });
  const code = prompt("Проверьте Telegram или webapp. Введите код:");
  const r = await fetch("/auth/nft/verify", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({ phone, code })
  });
  if (r.ok) location.reload();
  else alert("Неверный код");
});
</script>

Backend (Node.js / Express)

javascriptconst API = "https://phantomnumbers.org";
const KEY = process.env.NFT_NUMBERS_API_KEY;

app.post("/auth/nft/request", async (req, res) => {
  const r = await fetch(API + "/api/request-code", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ phone: req.body.phone })
  });
  res.status(r.status).json(await r.json());
});

app.post("/auth/nft/verify", async (req, res) => {
  const r = await fetch(API + "/api/verify-code", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ phone: req.body.phone, code: req.body.code })
  });
  const data = await r.json();
  if (!data.success) return res.status(401).json(data);
  req.session.userId = data.user.id;
  req.session.nftNumber = data.user.number;
  res.json({ ok: true });
});

Пример на Python

pythonimport os, httpx, jwt

API = "https://phantomnumbers.org"
KEY = os.environ["NFT_NUMBERS_API_KEY"]

async def request_code(phone: str) -> None:
    async with httpx.AsyncClient() as c:
        r = await c.post(f"{API}/api/request-code",
                         headers={"X-API-Key": KEY},
                         json={"phone": phone})
        r.raise_for_status()

async def verify(phone: str, code: str) -> dict | None:
    async with httpx.AsyncClient() as c:
        r = await c.post(f"{API}/api/verify-code",
                         headers={"X-API-Key": KEY},
                         json={"phone": phone, "code": code})
        if r.status_code != 200:
            return None
        j = r.json()
        if not j.get("success"):
            return None
        # HS256 (shared secret = API-ключ):
        claims = jwt.decode(
            j["id_token"], key=KEY,
            algorithms=["HS256"],
            audience="your-integration-slug",
        )
        return {"user": j["user"], "claims": claims,
                "refresh_token": j.get("refresh_token")}

curl

bash# Запросить OTP
curl -X POST https://phantomnumbers.org/api/request-code \
  -H "Content-Type: application/json" \
  -H "X-API-Key: pk_live_..." \
  -d '{"phone":"1112210118"}'

# Подтвердить OTP → получить id_token
curl -X POST https://phantomnumbers.org/api/verify-code \
  -H "Content-Type: application/json" \
  -H "X-API-Key: pk_live_..." \
  -d '{"phone":"1112210118","code":"482193"}'

Безопасность и rate-limits

  • Per-integration: по умолчанию 100 req/h (настраивается админом). Sandbox — 10/h.
  • Per-phone recipient: 3 OTP в час на номер (защита владельца от спама).
  • Per-IP: глобальный throttle.
  • Abuse score: каждый «Это не я» инкрементит счётчик; при достижении threshold (5 по умолчанию) — авто-suspend.
  • Scope escalation: user.identity.wallet / user.identity.telegram выдаются только после одобрения админа.
  • Identity window: GET /api/identity/user/{phone} требует успешный verify для этой пары (integration, phone) за последние 30 мин.
  • Redirect URI: если в настройках интеграции заполнен allowed_redirect_uris, передать можно только exact-match URI.

Billing

Платформа может быть бесплатной, по подписке, pay-per-verification или pay-per-RPM — режим выбирает админ в Platform Settings. В платном режиме в Developer Panel появляется кнопка Pay with TON: открывается deep-link ton://transfer/<wallet>, после on-chain подтверждения баланс пополняется автоматически.

Следующий шаг

Открыть Developer Panel+ New Integration — получите первый API-ключ за минуту.

Sign in with NFT Number

Ship NFT-Number auth in 5 minutes. OTP over Telegram + Web Push + in-webapp live panel; you get back a signed JWT id_token + refresh_token.

Two different APIs in this project

Don't mix them up — they solve different problems.

Public API
nftgen_live_*
Read / manage your own numbers & NFTs from an external app. Think: GitHub API for your repos.
Endpoint list ↓
Sign in with NFT Number
pk_live_*
Let users sign in to your app with their NFT number. Think: "Sign in with Google". This page.
Quick start ↓

Quick start

1
Sign up to the webapp with your TON wallet.
2
Open My Integrations in the sidebar. If the menu item is hidden — click «Become a developer»; default mode requires admin moderation.
3
Click + New Integration, fill in name, slug, logo, category, scopes (minimum otp.request + otp.verify).
4
Copy the API key. It is shown exactly once.
5
Paste the example code below into your backend. Done.

⚡ Instant setup — one command on your server

🚀 Don't want to write any code?
Right inside My Integrations → New Integration, after creation, open the Quick Start tab — we generate a ready-to-paste config for your platform. Three options:

1. Copy-paste snippet in the modal

After creating an integration, the modal has three tabs: API key, Quick Start, Docs. On Quick Start, pick the chip for your platform — the snippet is pre-filled with your client_id, redirect_uri, discovery URL and (once) client_secret. Copy, paste into your service config — done:

  • 🛡️ oauth2-proxy — docker-compose, protects ANY web service (Grafana, Prometheus, internal dashboards, etc.)
  • 🦊 Gitea — one CLI command, no restart
  • 📊 Grafana — ready grafana.ini block
  • ☁️ Nextcloud — config for the OpenID Connect Login app
  • 🟢 Node.js / Express — 40 lines with openid-client + PKCE
  • 🐍 Python / FastAPI — authlib + starlette-session
  • 🌐 curl — for debugging or languages without an OIDC library
  • 🔌 Generic OIDC — all 4 URLs for any platform (Keycloak, Auth0, AWS Cognito, Azure AD, Okta as identity broker, …)

2. One-command installer

Fastest option. A single command on your server — installs oauth2-proxy as a systemd service with our endpoints pre-configured:

bashexport NFT_CLIENT_SECRET='pk_live_...'
curl -fsSL https://phantomnumbers.org/integrate/<slug>.sh | bash

Requirements:

  • Ubuntu / Debian with systemd, root
  • NFT_CLIENT_SECRET in env (the script never carries it — pulls from your env)

What it does:

  • Downloads the official oauth2-proxy v7.6.0 (amd64/arm64)
  • Creates a system user oauth2proxy
  • Writes /etc/oauth2-proxy.cfg + a systemd unit, starts it
  • Listens on 0.0.0.0:4180, forwards to UPSTREAM (default localhost:8080, override via env)

Then point nginx / Caddy at 127.0.0.1:4180 — your service is now behind NFT Number login.

3. Docker Compose

For a container setup:

bashcurl -fsSL "https://phantomnumbers.org/integrate/<slug>/oauth2-proxy.yml" > docker-compose.yml
# Edit OAUTH2_PROXY_UPSTREAMS to your service
export NFT_CLIENT_SECRET='pk_live_...'
export COOKIE_SECRET="$(openssl rand -base64 32)"
docker compose up -d
⚠️ Security
  • Your client_secret never appears in a URL — only in env vars or as a one-time reveal inside the integration owner's browser (create / rotate page). After that we keep only the SHA-256 hash.
  • Redirect URIs from allowed_redirect_uris — mandatory full-match.
  • Installer script is plain bash, ~90 lines — feel free to read before running: curl -fsSL https://phantomnumbers.org/integrate/<slug>.sh | less

How it works

Your user enters their NFT number (format 111-22-101-18) on your page. You call our /api/request-code with the number and your API key. We:

  • look up the owner in our DB;
  • generate a 6-digit OTP (10 min TTL);
  • deliver it over three channels: Telegram bot, Web Push, live webapp notification;
  • the message always names your integration.

The user types the code on your page, you call /api/verify-code — we return a signed id_token and optionally a refresh_token.

API endpoints

Two ways to integrate — pick the one that fits:

GroupMethodPathScopeDescription
Simple
request/verify
POST/api/request-codeotp.requestGenerate OTP for a number
POST/api/verify-codeotp.verifyVerify OTP, receive id_token + refresh_token
POST/api/refresh-tokenotp.verifyExchange refresh for fresh id_token
OAuth 2.0 / OIDC
standard flow
GET/api/authorizeAuthorization endpoint (redirect flow with consent page)
POST/api/tokenExchange codeaccess_token+id_token+refresh_token
GET/api/userinfoUser claims via Bearer access_token
GET/.well-known/openid-configurationOIDC discovery (auto-config for Gitea / Grafana / oauth2-proxy)
Identity
read-only
GET/api/identity/user/{phone}user.identityRead user data (after recent verify)
GET/api/integrations/meanySelf-test: inspect your own integration
GET/.well-known/nft-numbers.jwksPublic key for RS256

Auth header for the simple API: X-API-Key: pk_live_.... For OAuth2 — see below.

OAuth 2.0 / OpenID Connect

Standard authorization-code flow. Pick this when you don't want to handle the NFT number + OTP on your own page — we render the consent page, you exchange the returned code for tokens.

🔑 Perfect for off-the-shelf integrations
Gitea, Grafana, Nextcloud, Outline, anything with OIDC support — plugs in via one URL: https://phantomnumbers.org/.well-known/openid-configuration. The client pulls endpoints, scopes, and signing keys automatically.

Flow steps

1
In your integration settings (admin or Developer Panel) add your redirect_uri to allowed_redirect_uris (full match on scheme + host + port + path).
2
Generate a PKCE pair (required for every client): code_verifier = 43–128 random chars, code_challenge = BASE64URL(SHA-256(code_verifier)) without padding. Keep code_verifier on your server/client — you'll need it in step 5.
3
Redirect the user to:
https://phantomnumbers.org/api/authorize
  ?response_type=code
  &client_id=<your slug>
  &redirect_uri=<URI from allowed_redirect_uris>
  &scope=openid%20profile%20email
  &state=<random CSRF string>
  &nonce=<random nonce for id_token>
  &code_challenge=<BASE64URL(SHA-256(verifier))>
  &code_challenge_method=S256
4
User sees our consent page, enters NFT number + OTP. We redirect back to <redirect_uri>?code=ac_...&state=<your state>. Code lives 60 seconds, single-use.
5
Your backend exchanges the code. code_verifier is required — missing it returns invalid_grant:
POST /api/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=ac_...
&redirect_uri=<same URI>
&client_id=<slug>
&client_secret=<your API key pk_live_...>
&code_verifier=<the one saved in step 2>
6
Response: {access_token, id_token, refresh_token, token_type: Bearer, expires_in, scope}. access_token is a JWT, 1h TTL. refresh_token — 30 days.
7
Fetch userinfo with the access_token:
GET /api/userinfo
Authorization: Bearer <access_token>

→ {"sub":"123","email":"[email protected]",
   "nft_number":"1112210118","preferred_username":"1112210118",
   "name":"...","ton_wallet":"0:..."}

PKCE — mandatory for every client

As of April 2026 PKCE (RFC 7636, method=S256) is required on /api/authorize for every integration — backends with a client_secret and browser-only SPAs alike. This closes the "stolen auth-code → token exchange with only the client_id" attack. Minimal TypeScript helper:

const verifier = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
sessionStorage.setItem('pkce_verifier', verifier);
location.assign(`/api/authorize?...&code_challenge=${challenge}&code_challenge_method=S256`);

Claims you receive in userinfo

ClaimScopeExample
subalways"123" — our user_id (stable)
nft_numberopenid / user.identity"1112748829"
preferred_usernameopenid"1112748829"
emailemail"[email protected]" — synthetic, stable
email_verifiedemailtrue
name, pictureprofileuser display name
ton_walletuser.identity.wallet"0:abc..."
telegram_usernameuser.identity.telegram"ivan"

Off-the-shelf clients tested with us

  • Gitea — Site Administration → Authentication Sources → Add → OAuth2 provider = OpenID Connect, Discovery URL = our openid-configuration. 3 minutes to set up.
  • oauth2-proxy — provider oidc, point --oidc-issuer-url=https://phantomnumbers.org. Protects any non-OIDC service behind this proxy.
  • Grafana — Generic OAuth in [auth.generic_oauth], endpoints from discovery.

id_token: HS256 or RS256

  • HS256 — shared secret. Secret = your API key (already on both ends). Simplest option: one function to sign, one to verify. The key must stay on the backend.
  • RS256 — asymmetric. We sign with a private key; you verify with the public key from /.well-known/nft-numbers.jwks. Compatible with any OIDC library, fine for mobile apps.

Payload: {iss, sub, aud, iat, exp, number, telegram_username?, wallet?, scope?}. TTL — 1 hour (env ID_TOKEN_LIFETIME_SEC).

Refresh tokens

A refresh_token (30-day TTL) comes back alongside the id_token. Exchange it for a fresh pair:

curlcurl -X POST https://phantomnumbers.org/api/refresh-token \
  -H "X-API-Key: pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"rt_..."}'

Each exchange rotates the token — the old one is revoked, a new one is issued. You can disable refresh emission per integration.

Webhooks

Set webhook_url on your integration — we POST events to you:

  • login.requested
  • login.verified
  • login.denied
  • integration.suspended / integration.resumed

Signature: X-NFT-Numbers-Signature: sha256=<hmac>. Always verify before processing the payload.

JavaScript example

⚠️ API key — backend only
Don't embed pk_live_* in frontend JS. Any visitor can read it and mint tokens in your name.

Frontend (calls your backend)

html<button id="loginBtn">Sign in with NFT Number</button>
<script>
document.getElementById('loginBtn').addEventListener('click', async () => {
  const phone = prompt("Your NFT number (111-22-101-18):");
  await fetch("/auth/nft/request", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({ phone })
  });
  const code = prompt("Check Telegram or the webapp. Enter the code:");
  const r = await fetch("/auth/nft/verify", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({ phone, code })
  });
  if (r.ok) location.reload();
  else alert("Invalid code");
});
</script>

Backend (Node.js / Express)

javascriptconst API = "https://phantomnumbers.org";
const KEY = process.env.NFT_NUMBERS_API_KEY;

app.post("/auth/nft/request", async (req, res) => {
  const r = await fetch(API + "/api/request-code", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ phone: req.body.phone })
  });
  res.status(r.status).json(await r.json());
});

app.post("/auth/nft/verify", async (req, res) => {
  const r = await fetch(API + "/api/verify-code", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ phone: req.body.phone, code: req.body.code })
  });
  const data = await r.json();
  if (!data.success) return res.status(401).json(data);
  req.session.userId = data.user.id;
  req.session.nftNumber = data.user.number;
  res.json({ ok: true });
});

Python example

pythonimport os, httpx, jwt

API = "https://phantomnumbers.org"
KEY = os.environ["NFT_NUMBERS_API_KEY"]

async def verify(phone, code):
    async with httpx.AsyncClient() as c:
        r = await c.post(f"{API}/api/verify-code",
            headers={"X-API-Key": KEY},
            json={"phone": phone, "code": code})
        j = r.json()
        if not j.get("success"): return None
        claims = jwt.decode(j["id_token"], key=KEY,
            algorithms=["HS256"], audience="your-slug")
        return {"user": j["user"], "claims": claims}

curl

bash# Request OTP
curl -X POST https://phantomnumbers.org/api/request-code \
  -H "Content-Type: application/json" -H "X-API-Key: pk_live_..." \
  -d '{"phone":"1112210118"}'

# Verify OTP → get id_token
curl -X POST https://phantomnumbers.org/api/verify-code \
  -H "Content-Type: application/json" -H "X-API-Key: pk_live_..." \
  -d '{"phone":"1112210118","code":"482193"}'

Security & rate-limits

  • Per-integration: default 100 req/h (admin-configurable). Sandbox — 10/h.
  • Per-phone recipient: 3 OTPs per hour per number (owner flood protection).
  • Per-IP: global throttle.
  • Abuse score: each «It wasn't me» bumps a counter; at the threshold (default 5) — auto-suspend.
  • Scope escalation: user.identity.wallet / user.identity.telegram require admin approval.
  • Identity window: GET /api/identity/user/{phone} requires a successful verify for the (integration, phone) pair in the last 30 minutes.
  • Redirect URI: if allowed_redirect_uris is populated, the caller must pass an exact match.

Billing

The platform can be free, flat-subscription, pay-per-verification, or pay-per-RPM — the admin picks a mode in Platform Settings. In a paid mode the Developer Panel shows a Pay with TON button opening a ton://transfer/<wallet> deep-link; the balance is credited automatically once the transfer is confirmed on-chain.

Next step

Open the Developer Panel+ New Integration and get your first API key in a minute.