İçeriğe geç

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.

  1. Backend'inin tek bir isteği yeter; yanıtta hazır bir WhatsApp linki alırsın.
  2. Kullanıcı linke dokunur; WhatsApp önceden doldurulmuş mesajla açılır.
  3. "Gönder"e basar. Mesaj bize ulaşır.
  4. Kodu eşleştirir, doğrulamayı validated yapar ve callback_url adresine imzalı POST atarız.
  5. 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.

ModZorunluNe zaman
strictvarsayılanauthorized_numbersLogin, 2FA, işlem onayı. Numarayı zaten biliyorsun; WhatsApp sahipliğini kanıtlıyor.
subjectsubjectKayıt akışı. Numarayı henüz bilmiyorsun ama kimliği (e-posta, user id) biliyorsun. Numara o kimliğe bağlanır.
openAnonim 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_mismatch sinyali 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

AlanTipAçıklama
phone_new_for_subjectbilgiBu subject bu numarayla ilk kez doğrulanıyor.
subject_phone_mismatchyüksekSubject daha önce başka numarayla doğrulanmış. Kilit açıksa doğrulama zaten reddedilir.
phone_shared_across_subjectsortaAynı 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.

AlanTipAçıklama
expires_atintegerKodun geçerlilik süresi, dakika (1–1440). Varsayılan app ayarından gelir.
callback_urlstringSonucun POST edileceği HTTPS adres. Boşsa app varsayılanı kullanılır.
authorized_numbersstring[] | csvYalnızca bu numaralar doğrulayabilir. E.164 zorunlu (+905321234567). strict modda zorunlu alan.
subjectstringSenin tarafındaki benzersiz kimlik: e-posta, user id, sipariş no. Numara buna bağlanır. subject modda zorunlu.
contextstringKullanıcının WhatsApp mesajında göreceği "neyi onaylıyorum" metni. Relay saldırısına karşı asıl savunma.
link_messagestringWhatsApp mesajında kodun altına eklenen yönerge.
qr0 | 11 ise cevaba QR görseli (data URI) eklenir.
channelwhatsappŞu an yalnızca whatsapp. SMS kanalı henüz desteklenmiyor (422 döner).
metadataobject | jsonWebhook'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şlerSavunma
Numara sahtelemeSaldırgan kurbanın hesabı için başlattığı doğrulamayı kendi numarasından tamamlar.authorized_numbers veya subject kilidi. Tamamen engellenir.
RelaySaldı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

  1. context gö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.
  2. Kısa TTL. expires_at=2 saldı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.
  3. Kimlik başına rate limit. subject gönderirsen 5 dakikada 5 denemeyle sınırlanır. Tek hesaba yönelik ısrarlı deneme engellenir.
  4. 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.
  5. 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_numbers E.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)

  1. Meta for Developers'ta bir app aç, WhatsApp ürününü ekle.
  2. Numaranı bağla, phone_number_id değerini not al.
  3. Uzun ömürlü bir System User token üret → META_ACCESS_TOKEN.
  4. Webhook Callback URL: https://SENIN_DOMAIN/api/webhooks/meta, verify token: META_VERIFY_TOKEN.
  5. 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.
  6. Panel → Numaralar ekranından numarayı havuza ekle (PLATFORM_ADMIN_EMAILS yetkisi 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_URL ile doğrulama kodu içermeyen mesajlar ham haliyle iletilir. X-Hub-Signature-256 korunur, 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_SECRET set edip isteğe X-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ı

AlanTipAçıklama
unauthorized401API key eksik, hatalı veya iptal edilmiş.
insufficient_balance402Doğrulama bakiyesi bitti.
app_disabled403App kapalı.
not_found404Doğrulama bulunamadı.
invalid_request422Parametre doğrulaması başarısız (detaylar `details` içinde).
invalid_callback_url422callback_url https değil veya iç ağa işaret ediyor.
authorized_numbers_required422App strict modda; authorized_numbers verilmedi.
subject_required422App subject modda; subject verilmedi.
invalid_phone_number422authorized_numbers E.164 formatında değil (ülke kodu eksik veya 0 ile başlıyor).
channel_not_supported422channel=sms gönderildi; SMS kanalı henüz uygulanmadı.
rate_limited429Rate limit aşıldı (API key, IP veya subject bazında).
no_number_available503Havuzda aktif WhatsApp numarası yok.
internal_error500Beklenmeyen hata.

Hata gövdesi:

{ "error": { "code": "insufficient_balance", "message": "…" } }