Dokümantasyon
Tek endpoint ve tek webhook ile başla. SDK kurmadan ilk doğrulamanı çalıştır.
Akış
Klasik OTP'nin tersi çalışır: kullanıcı kod almaz, kod gönderir.
- Backend'inin tek bir isteği yeter; yanıtta hazır bir WhatsApp linki alırsın.
- Kullanıcı linke dokunur; WhatsApp önceden doldurulmuş mesajla açılır.
- "Gönder"e basar. Mesaj bize ulaşır.
- Kodu eşleştirir, doğrulamayı
validatedyapar vecallback_urladresine imzalı POST atarız. - Frontend SSE veya polling ile sonucu anında görür.
Kimlik doğrulama
Her istek Authorization: Bearer <API_KEY> başlığı ister. API key app bazlıdır; panelden üretilir ve yalnızca bir kez gösterilir (veritabanında sadece hash'i durur).
API key aslatarayıcıya inmemeli. Frontend'in kod üretmesi gerekiyorsa kendi sunucunda bir proxy endpoint aç — örneği Frontend bölümünde.
Bağlama politikası
Hiçbir şeye bağlanmamış bir doğrulama yalnızca o numaradan bir mesaj gönderildiğini kanıtlar; hesabın kime ait olduğunu kanıtlamaz. Bu yüzden her app bir bağlama politikası seçmek zorunda.
| Mod | Zorunlu | Ne zaman |
|---|---|---|
strictvarsayılan | authorized_numbers | Login, 2FA, işlem onayı. Numarayı zaten biliyorsun; WhatsApp sahipliğini kanıtlıyor. |
subject | subject | Kayıt akışı. Numarayı henüz bilmiyorsun ama kimliği (e-posta, user id) biliyorsun. Numara o kimliğe bağlanır. |
open | — | Anonim numara toplama için. Korumayı bilerek kapatırsın; riski sen üstlenirsin. |
subject modu ne yakalar
Bir subject ilk kez doğrulandığında numara ona bağlanır. Sonraki doğrulamalarda aynı subject başka bir numarayla gelirse:
- Kimlik–numara kilidi açıksa (varsayılan) doğrulama reddedilir,
status: failedwebhook'u düşer. - Kapalıysa doğrulama geçer ama webhook'ta
subject_phone_mismatchsinyali gelir; kararı sen verirsin.
Bu, saldırganın kurbanın e-postasıyla giriş başlatıp kendi numarasından doğrulamasını engeller.
Risk sinyalleri
| Alan | Tip | Açıklama |
|---|---|---|
phone_new_for_subject | bilgi | Bu subject bu numarayla ilk kez doğrulanıyor. |
subject_phone_mismatch | yüksek | Subject daha önce başka numarayla doğrulanmış. Kilit açıksa doğrulama zaten reddedilir. |
phone_shared_across_subjects | orta | Aynı numara eşiği aşan sayıda farklı kimlikte kullanılıyor. Toplu hesap açma göstergesi. |
Kod üretme
GET /api/v1/verification_code — POST + JSON body da kabul edilir.
| Alan | Tip | Açıklama |
|---|---|---|
expires_at | integer | Kodun geçerlilik süresi, dakika (1–1440). Varsayılan app ayarından gelir. |
callback_url | string | Sonucun POST edileceği HTTPS adres. Boşsa app varsayılanı kullanılır. |
authorized_numbers | string[] | csv | Yalnızca bu numaralar doğrulayabilir. E.164 zorunlu (+905321234567). strict modda zorunlu alan. |
subject | string | Senin tarafındaki benzersiz kimlik: e-posta, user id, sipariş no. Numara buna bağlanır. subject modda zorunlu. |
context | string | Kullanıcının WhatsApp mesajında göreceği "neyi onaylıyorum" metni. Relay saldırısına karşı asıl savunma. |
link_message | string | WhatsApp mesajında kodun altına eklenen yönerge. |
qr | 0 | 1 | 1 ise cevaba QR görseli (data URI) eklenir. |
channel | Şu an yalnızca whatsapp. SMS kanalı henüz desteklenmiyor (422 döner). | |
metadata | object | json | Webhook'ta aynen geri döner. Session ID taşımak için ideal. |
Örnek istek:
curl -G 'https://replyauth.com/api/v1/verification_code' \
--data-urlencode 'authorized_numbers=+905321234567' \
--data-urlencode '[email protected]' \
--data-urlencode 'context=Chrome / Windows üzerinden giriş · 14:32' \
--data-urlencode 'expires_at=2' \
--data-urlencode 'qr=1' \
--header 'Authorization: Bearer ra_live_...'Cevap:
{
"id": "6f9a1c2e-…",
"code": "K7M2P9XT4B",
"link": "https://wa.me/905321234567?text=%60K7M2P9XT4B%60…",
"deep_link": "whatsapp://send?phone=905321234567&text=…",
"qr": "data:image/png;base64,…",
"poll_token": "s0m3-r4nd0m-t0k3n",
"expires_at": "2026-08-03T12:41:00.000Z",
"wa_number": "+905321234567",
"sandbox": true
}poll_token tarayıcıya verilebilir; yalnızca bu doğrulamanın durumunu okumaya yarar, başka hiçbir yetkisi yoktur.
Durum sorgulama
GET /api/v1/verifications/{id} — API key ile. Webhook kaçırıldıysa telafi yolu; tam payload döner.
GET /api/v1/verifications/{id}/status?token=… — poll_token ile, tarayıcıdan çağrılabilir. Telefon numarası bu cevapta dönmez; sadece doğrulanıp doğrulanmadığı bilgisi vardır.
GET /api/v1/verifications/{id}/events?token=… — Server-Sent Events. validated, closed, ping event'leri yayınlar.
Tarayıcıdan çağrılan iki endpoint için app ayarlarındaki izinli origin'ler listesi dolu olmalı; aksi halde CORS reddeder.
Webhook
Doğrulama terminal bir duruma ulaştığında belirttiğin adrese imzalı POST atılır. requested için webhook gönderilmez — doğrulama sürerken SSE veya polling kullan.
{
"id": "6f9a1c2e-…",
"status": "validated",
"verification_code": "K7M2P9XT4B",
"phone_number": "+905321234567",
"profile_name": "Muzaffer",
"channel": "whatsapp",
"authorized_numbers": ["+905321234567"],
"subject": "[email protected]",
"risk_signals": ["phone_new_for_subject"],
"metadata": { "session_id": "abc" },
"sandbox": false,
"requested_at": "2026-08-03T12:31:00.000Z",
"expires_at": "2026-08-03T12:41:00.000Z",
"validated_at": "2026-08-03T12:31:19.284Z",
"error": null
}status değerleri: validated, expired, failed.
Başlıklar: X-Replyauth-Signature (t=…,v1=…), X-Replyauth-Event, X-Replyauth-Delivery, X-Replyauth-Attempt.
Herhangi bir 2xx başarı sayılır. Aksi halde teslimat toplam altı denemeye kadar tekrarlanır (0s, 30s, 2dk, 10dk, 1sa, 6sa). Hepsi başarısız olursa teslimat abandoned işaretlenir ve hata kaydedilir.
İmza doğrulaması:
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text(); // raw body, not parsed JSON
const header = req.headers.get("x-replyauth-signature") ?? "";
const [tPart, vPart] = header.split(",");
const timestamp = tPart?.slice(2);
const received = vPart?.slice(3);
// Reject signatures older than 5 minutes — replay protection
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return new Response("stale", { status: 400 });
}
const expected = createHmac("sha256", process.env.REPLYAUTH_WEBHOOK_SECRET!)
.update(`${timestamp}.${raw}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(received ?? "");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw);
if (event.status === "validated") {
if (event.risk_signals.includes("phone_shared_across_subjects")) {
await flagForReview(event.subject, event.phone_number);
}
await markSessionVerified(event.metadata.session_id, event.phone_number);
}
return new Response(null, { status: 204 }); // 200 or 204
}Frontend
Önce kendi sunucunda bir proxy aç — API key orada kalır, tarayıcıya sadece görüntüleme için gereken alanlar iner:
// On your own server — the API key never reaches the browser.
export async function POST(req: Request) {
const session = await getSession();
const { phone } = await req.json();
const url = new URL("https://replyauth.com/api/v1/verification_code");
url.searchParams.set("expires_at", "2"); // short TTL narrows relay window
url.searchParams.set("qr", "1");
url.searchParams.set("authorized_numbers", phone); // strict: only this number
url.searchParams.set("subject", session.user.email); // identity <-> number binding
url.searchParams.set(
"context",
`Sign-in from ${session.device} · ${new Date().toLocaleString()}`,
);
url.searchParams.set("metadata", JSON.stringify({ session_id: session.id }));
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.REPLYAUTH_API_KEY}` },
});
const v = await res.json();
return Response.json({
id: v.id, link: v.link, deep_link: v.deep_link,
qr: v.qr, poll_token: v.poll_token, expires_at: v.expires_at,
});
}Sonra hazır widget'ı bağla:
<div id="verify"></div>
<script src="https://replyauth.com/widget.js"></script>
<script>
Replyauth.mount("#verify", {
create: "/api/replyauth/start",
baseUrl: "https://replyauth.com",
onValidated: function () { location.href = "/welcome"; },
});
</script>Widget mobilde deep link butonu, masaüstünde QR gösterir. SSE ve polling'i birlikte çalıştırır; proxy stream'i kesse bile sonuç kaçmaz. React kullanıyorsan demo sayfasındaki bileşeni kopyalayabilirsin.
Güvenlik
İki farklı saldırıyı karıştırmayın
| Saldırı | Nasıl işler | Savunma |
|---|---|---|
| Numara sahteleme | Saldırgan kurbanın hesabı için başlattığı doğrulamayı kendi numarasından tamamlar. | authorized_numbers veya subject kilidi. Tamamen engellenir. |
| Relay | Saldırgan doğrulamayı başlatır, wa.me linkini kurbana yollar. Kurban gönderir, saldırganın oturumu açılır. | Risk azalır, tamamen ortadan kalkmaz. Aşağıdaki önlemleri uygula. |
Önemli: authorized_numbersrelay'i tek başına durdurmaz. Saldırgan kurbanın numarasını zaten yazabilir; beklenen numara ile gelen numara aynı olur. Bu alan farklı bir saldırıyı — saldırganın kendi numarasıyla başkasının hesabını doğrulamasını — engeller. İkisi de gerçek, ikisi de kapatılmalı.
Relay'e karşı gerçekten işe yarayanlar
contextgönder. Kullanıcı gönder'e basmadan önce ne onayladığını okur: "Chrome / Windows üzerinden giriş · 14:32". Kendisi giriş yapmayan kurbanın durup düşünmesini sağlayan tek şey budur. Etkisi yüksek, ek maliyeti yok.- Kısa TTL.
expires_at=2saldırganın gerçek zamanlı relay yapmasını zorunlu kılar; hazırlıklı bir kampanyanın penceresini daraltır. Riski azaltır; relay'i tek başına engellemez. - Kimlik başına rate limit.
subjectgönderirsen 5 dakikada 5 denemeyle sınırlanır. Tek hesaba yönelik ısrarlı deneme engellenir. - Hassas işlemde ikinci faktör iste. Para transferi, şifre değiştirme, e-posta değiştirme gibi işlemlerde tek başına telefon doğrulaması yeterli olmamalı — bu her OTP yöntemi için geçerli, WhatsApp'a özgü değil.
- Oturum parmak izini taşı. Başlatan IP/device bilgisini
metadataile gönder, webhook'ta kendi kayıtlarınla karşılaştır. Uyuşmazlık ek adım tetikleyebilir.
Relay riski SMS OTP'de de vardır ("kodu bana söyle" dolandırıcılığı) ve orada çok daha kolaydır. Burada saldırgan kurbanı, ne onayladığını gösteren bir ekranda gönder'e basmaya ikna etmek zorunda. Daha iyi — ama sıfır değil.
Platform tarafında hazır olanlar
- Kodlar 50 bit entropiye sahip, tek kullanımlık ve TTL ile sınırlı.
- Bağlama politikası zorunlu — kazayla "açık" kalamaz.
- subject ↔ numara tutarlılık kontrolü ve risk sinyalleri.
- Webhook HMAC-SHA256 imzalı, timestamp'li.
callback_urlSSRF taraması: HTTPS zorunlu, özel IP'ye çözümlenen adresler reddedilir.- Tarayıcıdan erişim origin allowlist ile kısıtlı.
- Yetkisiz numaradan gelen deneme kodu yakmaz — numarayı bilen biri doğrulamayı DoS edemez.
authorized_numbersE.164 doğrulanır; hatalı format sessizce kilitlenme yerine 422 döner.- API key, session token ve poll_token veritabanında hash olarak saklanır.
Meta kurulumu (self-hosted)
- Meta for Developers'ta bir app aç, WhatsApp ürününü ekle.
- Numaranı bağla,
phone_number_iddeğerini not al. - Uzun ömürlü bir System User token üret →
META_ACCESS_TOKEN. - Webhook Callback URL:
https://SENIN_DOMAIN/api/webhooks/meta, verify token:META_VERIFY_TOKEN. - messagesalanına abone ol ve WABA'nın app'e abone olduğunu doğrula (
/{WABA_ID}/subscribed_apps). Yalnızca callback URL tanımlamak mesaj teslimi için yeterli değildir. - Panel → Numaralar ekranından numarayı havuza ekle (
PLATFORM_ADMIN_EMAILSyetkisi gerekir).
Numarayı başka bir sistemle paylaşma
Meta'da webhook callback URL'i app seviyesindedir — bir WABA'nın trafiği tek adrese gider. Numarayı n8n, destek aracı veya kendi bot'unla paylaşacaksan:
- Replyauth önde (önerilen):
INBOUND_FORWARD_URLile doğrulama kodu içermeyen mesajlar ham haliyle iletilir.X-Hub-Signature-256korunur, karşı tarafın entegrasyonu değişmez. - Proxy arkasında:Önünde n8n gibi bir sistem varsa Meta'nın HMAC imzası hayatta kalmaz — proxy JSON'ı yeniden serialize eder.
INBOUND_PROXY_SECRETset edip isteğeX-Replyauth-Proxy-Secretheader'ı eklersen imza yerine bu doğrulanır.
Proxy'de hangi uygulamaya ait olduğunu anlamak
Meta'nın inbound payload'ında tenant veya domain bilgisi yoktur— sadece numara, gönderen ve metin gelir. Kodu app'e bağlayan tek yer bizim veritabanımız. Proxy'nin dallandırma yapması gerekiyorsa:
curl -H "X-Replyauth-Proxy-Secret: $INBOUND_PROXY_SECRET" \
"https://replyauth.com/api/v1/resolve?body=%60K7M2P9XT4B%60"
{
"matched": true,
"status": "requested",
"subject": "[email protected]",
"app": { "id": "…", "name": "Web login" },
"org": { "id": "…", "name": "Acme" },
"domain": "acme.com",
"callback_host": "api.acme.com"
}code veya body alır — mesaj gövdesini olduğu gibi gönderebilirsin. Telefon numarası, profil adı, poll token ve webhook secret dönmez; routing için gerekmezler. Endpoint yalnızca INBOUND_PROXY_SECRET set edilmişse açıktır.
Proxy'nin "bu mesaj Replyauth'ye mi ait" sorusunu cevaplamak için lookup'a ihtiyacı yok; regex yeter: >>[0-9A-HJKMNP-TV-Z]{10}<< (kod alfabesinde I, L, O, U bulunmaz).
Paylaşılan numarada inbound_messagestablosu yalnızca doğrulama kodu içeren mesajların gövdesini saklar; diğerlerinden sadece idempotency için mesaj ID'si tutulur.
Hata kodları
| Alan | Tip | Açıklama |
|---|---|---|
unauthorized | 401 | API key eksik, hatalı veya iptal edilmiş. |
insufficient_balance | 402 | Doğrulama bakiyesi bitti. |
app_disabled | 403 | App kapalı. |
not_found | 404 | Doğrulama bulunamadı. |
invalid_request | 422 | Parametre doğrulaması başarısız (detaylar `details` içinde). |
invalid_callback_url | 422 | callback_url https değil veya iç ağa işaret ediyor. |
authorized_numbers_required | 422 | App strict modda; authorized_numbers verilmedi. |
subject_required | 422 | App subject modda; subject verilmedi. |
invalid_phone_number | 422 | authorized_numbers E.164 formatında değil (ülke kodu eksik veya 0 ile başlıyor). |
channel_not_supported | 422 | channel=sms gönderildi; SMS kanalı henüz uygulanmadı. |
rate_limited | 429 | Rate limit aşıldı (API key, IP veya subject bazında). |
no_number_available | 503 | Havuzda aktif WhatsApp numarası yok. |
internal_error | 500 | Beklenmeyen hata. |
Hata gövdesi:
{ "error": { "code": "insufficient_balance", "message": "…" } }