API entegrasyonunda kritik ayrım

Türkiye Cumhuriyeti Kimlik Numarası (TCKN) ve Vergi Kimlik Numarası (VKN) doğrulaması, yazılım ekiplerinin en sık yanlış kurguladıkları entegrasyonlardan biridir. Temel karışıklık şudur: "format doğrulaması" (algoritma) ile "kimlik doğrulaması" (NVİ/GİB servisi) aynı şey değildir. İlki yerel bir aritmetik işlemdir, saniyede milyonlarca yapılır, network istemez. İkincisi ise harici bir web servisine gider, ücretli veya kota kısıtlı olabilir, timeout ve retry gerektirir.

Bu yazıda beş dilde hem TC kimlik / vergi no yerel format doğrulaması hem de gerçek NVİ servisine istek atmak için production-safe kod örnekleri paylaşacağız. Ayrıca test sırasında sürekli geçerli numara bulmakla vakit kaybetmemek için tckn üret aracıyla mock data sağlayabilirsiniz. Odak: idempotency, hata sınıflandırması, rate limiting, retry with backoff. "Hello world" seviyesinde değil — üretimde kullandığınız kalıpları vereceğiz.

Başlamadan önce şunu netleştirelim: NVİ'nin TCKimlikNoDogrula servisi bir SOAP servisidir, saniyede kaç çağrı kabul ettiği resmi dokümanda sabit bir rakam olarak yayınlanmaz; kuruma ve anlaşmanıza göre değişir — doğrulayın. Detaylar için NVİ servisi rehberine bakın.

Mimari: önce yerel, sonra uzak

Herhangi bir üretim entegrasyonunda şu sıra takip edilmelidir:

  1. Giriş normalleştirme: trim, whitespace kaldırma, unicode normalizasyonu.
  2. Uzunluk branch: 11 → TC kimlik (TCKN), 10 → VKN (vergi no).
  3. Yerel format doğrulaması: algoritma çalıştırılır. Geçmezse daha ileri gidilmez.
  4. Uzak kimlik doğrulaması (gerekiyorsa): NVİ servisi veya GİB entegratörüne sorgu.
  5. Cache + idempotency: aynı TCKN+ad+doğum tarihi için kısa süreli cache.

Pek çok ekip 4. adıma hemen geçerek NVİ servisine gereksiz yere yük bindiriyor. Format doğrulaması, uzak çağrıların %30-60'ını daha en baştan eler.

Node.js (ES2022+)

Yerel doğrulama ve NVİ SOAP çağrısı tek bir modülde:

// tckn.mjs
import { fetch } from 'undici';

const TCKN_RE = /^[1-9][0-9]{10}$/;

export function isValidTcknFormat(input) {
  const tckn = String(input ?? '').trim();
  if (!TCKN_RE.test(tckn)) return false;
  const d = [...tckn].map(Number);
  const sumOdd = d[0] + d[2] + d[4] + d[6] + d[8];
  const sumEven = d[1] + d[3] + d[5] + d[7];
  const c10 = ((sumOdd * 7 - sumEven) % 10 + 10) % 10;
  const c11 = (sumOdd + sumEven + c10) % 10;
  return d[9] === c10 && d[10] === c11;
}

const SOAP_URL = 'https://tckimlik.nvi.gov.tr/Service/KPSPublic.asmx';

const envelope = ({ tckn, ad, soyad, dogum }) => `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <TCKimlikNoDogrula xmlns="http://tckimlik.nvi.gov.tr/WS">
      <TCKimlikNo>${tckn}</TCKimlikNo>
      <Ad>${ad}</Ad>
      <Soyad>${soyad}</Soyad>
      <DogumYili>${dogum}</DogumYili>
    </TCKimlikNoDogrula>
  </soap:Body>
</soap:Envelope>`;

export async function verifyWithNvi(payload, { timeoutMs = 5000, retries = 2 } = {}) {
  if (!isValidTcknFormat(payload.tckn)) {
    return { ok: false, reason: 'INVALID_FORMAT' };
  }
  for (let attempt = 0; attempt <= retries; attempt++) {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), timeoutMs);
    try {
      const res = await fetch(SOAP_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'text/xml; charset=utf-8',
          'SOAPAction': 'http://tckimlik.nvi.gov.tr/WS/TCKimlikNoDogrula',
        },
        body: envelope(payload),
        signal: ctrl.signal,
      });
      clearTimeout(timer);
      if (res.status >= 500) throw new Error(`Upstream ${res.status}`);
      const text = await res.text();
      const match = text.match(/<TCKimlikNoDogrulaResult>(true|false)<\/TCKimlikNoDogrulaResult>/i);
      if (!match) return { ok: false, reason: 'PARSE_ERROR', raw: text };
      return { ok: match[1].toLowerCase() === 'true' };
    } catch (err) {
      clearTimeout(timer);
      if (attempt === retries) return { ok: false, reason: 'UPSTREAM_ERROR', error: String(err) };
      await new Promise(r => setTimeout(r, 250 * Math.pow(2, attempt)));
    }
  }
}

Not: XML escape'i üretim kodunda mutlaka ekleyin; burada örneğin kısalığı için atladık. &, <, >, ", ' karakterleri kullanıcı adında geçerse injection yaratır.

Python (3.10+)

Python için httpx (async) ve tenacity (retry) yaygın tercih. Yerel format kısmını önceki yazıda verdik; burada NVİ çağrısına odaklanıyoruz.

import httpx
import re
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

SOAP_URL = "https://tckimlik.nvi.gov.tr/Service/KPSPublic.asmx"
_RESULT_RE = re.compile(r"<TCKimlikNoDogrulaResult>(true|false)</TCKimlikNoDogrulaResult>", re.I)

def _envelope(tckn: str, ad: str, soyad: str, dogum: int) -> str:
    # XML escape şart — örnekte saf
    return f"""<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
 <soap:Body>
  <TCKimlikNoDogrula xmlns="http://tckimlik.nvi.gov.tr/WS">
   <TCKimlikNo>{tckn}</TCKimlikNo>
   <Ad>{ad}</Ad>
   <Soyad>{soyad}</Soyad>
   <DogumYili>{dogum}</DogumYili>
  </TCKimlikNoDogrula>
 </soap:Body>
</soap:Envelope>"""

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.25, max=2),
    retry=retry_if_exception_type(httpx.HTTPError),
    reraise=True,
)
async def verify_with_nvi(tckn: str, ad: str, soyad: str, dogum: int) -> bool:
    async with httpx.AsyncClient(timeout=5.0) as client:
        res = await client.post(
            SOAP_URL,
            headers={
                "Content-Type": "text/xml; charset=utf-8",
                "SOAPAction": "http://tckimlik.nvi.gov.tr/WS/TCKimlikNoDogrula",
            },
            content=_envelope(tckn, ad, soyad, dogum),
        )
        res.raise_for_status()
        m = _RESULT_RE.search(res.text)
        if not m:
            raise ValueError("unexpected SOAP response")
        return m.group(1).lower() == "true"

tenacity 5xx ve network hatalarını retry eder; 4xx'leri etmez — bu doğru davranış. Rate limit (429) durumunda Retry-After başlığını okumanız gerekir; minimal örnekte atladık.

Go (1.20+)

Go'da standart kütüphane yeter:

package tckn

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "regexp"
    "time"
)

const soapURL = "https://tckimlik.nvi.gov.tr/Service/KPSPublic.asmx"

var resultRe = regexp.MustCompile(`(?i)<TCKimlikNoDogrulaResult>(true|false)</TCKimlikNoDogrulaResult>`)

type VerifyReq struct {
    TCKN, Ad, Soyad string
    DogumYili       int
}

func envelope(r VerifyReq) string {
    return fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><TCKimlikNoDogrula xmlns="http://tckimlik.nvi.gov.tr/WS">
<TCKimlikNo>%s</TCKimlikNo><Ad>%s</Ad><Soyad>%s</Soyad><DogumYili>%d</DogumYili>
</TCKimlikNoDogrula></soap:Body></soap:Envelope>`, r.TCKN, r.Ad, r.Soyad, r.DogumYili)
}

func VerifyNVI(ctx context.Context, client *http.Client, r VerifyReq) (bool, error) {
    if !IsValid(r.TCKN) {
        return false, fmt.Errorf("invalid format")
    }
    var lastErr error
    for attempt := 0; attempt < 3; attempt++ {
        if attempt > 0 {
            select {
            case <-ctx.Done():
                return false, ctx.Err()
            case <-time.After(time.Duration(200*(1<<attempt)) * time.Millisecond):
            }
        }
        req, err := http.NewRequestWithContext(ctx, "POST", soapURL, bytes.NewBufferString(envelope(r)))
        if err != nil {
            return false, err
        }
        req.Header.Set("Content-Type", "text/xml; charset=utf-8")
        req.Header.Set("SOAPAction", "http://tckimlik.nvi.gov.tr/WS/TCKimlikNoDogrula")
        resp, err := client.Do(req)
        if err != nil {
            lastErr = err
            continue
        }
        body, _ := io.ReadAll(resp.Body)
        resp.Body.Close()
        if resp.StatusCode >= 500 {
            lastErr = fmt.Errorf("upstream %d", resp.StatusCode)
            continue
        }
        m := resultRe.FindSubmatch(body)
        if m == nil {
            return false, fmt.Errorf("parse error")
        }
        return string(m[1]) == "true" || string(m[1]) == "True", nil
    }
    return false, lastErr
}

Go'nun http.Client'ını uygulama ömrü boyunca reuse edin (her çağrıda yeni client yaratmayın). Connection pooling ve TLS handshake maliyeti bu sayede amorti olur.

Java (17+)

Spring Boot ortamında WebClient kalıbı standart. Ama burada JDK'nın yeni HttpClient'ı ile:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class NviClient {
    private static final String URL = "https://tckimlik.nvi.gov.tr/Service/KPSPublic.asmx";
    private static final Pattern RESULT =
        Pattern.compile("<TCKimlikNoDogrulaResult>(true|false)</TCKimlikNoDogrulaResult>",
                         Pattern.CASE_INSENSITIVE);

    private final HttpClient http = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(3)).build();

    public boolean verify(String tckn, String ad, String soyad, int dogumYili) throws Exception {
        if (!Tckn.isValid(tckn)) return false;
        String body = """
            <?xml version="1.0" encoding="utf-8"?>
            <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
             <soap:Body><TCKimlikNoDogrula xmlns="http://tckimlik.nvi.gov.tr/WS">
             <TCKimlikNo>%s</TCKimlikNo><Ad>%s</Ad><Soyad>%s</Soyad><DogumYili>%d</DogumYili>
             </TCKimlikNoDogrula></soap:Body></soap:Envelope>
            """.formatted(tckn, escapeXml(ad), escapeXml(soyad), dogumYili);

        HttpRequest req = HttpRequest.newBuilder(URI.create(URL))
            .timeout(Duration.ofSeconds(5))
            .header("Content-Type", "text/xml; charset=utf-8")
            .header("SOAPAction", "http://tckimlik.nvi.gov.tr/WS/TCKimlikNoDogrula")
            .POST(HttpRequest.BodyPublishers.ofString(body)).build();

        for (int i = 0; i < 3; i++) {
            try {
                HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
                if (resp.statusCode() >= 500) throw new RuntimeException("upstream " + resp.statusCode());
                Matcher m = RESULT.matcher(resp.body());
                if (!m.find()) throw new RuntimeException("parse");
                return m.group(1).equalsIgnoreCase("true");
            } catch (Exception e) {
                if (i == 2) throw e;
                Thread.sleep(200L * (1L << i));
            }
        }
        return false;
    }

    private static String escapeXml(String s) {
        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }
}

C# (.NET 8+)

IHttpClientFactory ile DI entegrasyonu şiddetle tavsiye edilir; burada basitleştirilmiş versiyonu veriyoruz:

using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;

public class NviClient
{
    private static readonly Regex ResultRe = new(
        @"<TCKimlikNoDogrulaResult>(true|false)</TCKimlikNoDogrulaResult>",
        RegexOptions.IgnoreCase | RegexOptions.Compiled);

    private readonly HttpClient _http;
    public NviClient(HttpClient http) { _http = http; }

    public async Task<bool> VerifyAsync(string tckn, string ad, string soyad, int dogumYili,
                                        CancellationToken ct = default)
    {
        if (!Tckn.IsValid(tckn)) return false;

        var body = $"""
            <?xml version="1.0" encoding="utf-8"?>
            <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
             <soap:Body><TCKimlikNoDogrula xmlns="http://tckimlik.nvi.gov.tr/WS">
             <TCKimlikNo>{tckn}</TCKimlikNo><Ad>{Escape(ad)}</Ad>
             <Soyad>{Escape(soyad)}</Soyad><DogumYili>{dogumYili}</DogumYili>
             </TCKimlikNoDogrula></soap:Body></soap:Envelope>
            """;

        for (int i = 0; i < 3; i++)
        {
            try
            {
                using var req = new HttpRequestMessage(HttpMethod.Post,
                    "https://tckimlik.nvi.gov.tr/Service/KPSPublic.asmx");
                req.Content = new StringContent(body, Encoding.UTF8, "text/xml");
                req.Headers.Add("SOAPAction",
                    "http://tckimlik.nvi.gov.tr/WS/TCKimlikNoDogrula");

                using var resp = await _http.SendAsync(req, ct);
                if ((int)resp.StatusCode >= 500) throw new HttpRequestException("upstream");
                var text = await resp.Content.ReadAsStringAsync(ct);
                var m = ResultRe.Match(text);
                return m.Success && string.Equals(m.Groups[1].Value, "true", StringComparison.OrdinalIgnoreCase);
            }
            catch when (i < 2)
            {
                await Task.Delay(200 * (1 << i), ct);
            }
        }
        return false;
    }

    private static string Escape(string s) =>
        s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}

HttpClient'ı DI üzerinden AddHttpClient<NviClient>() ile register edin ki socket exhaustion yaşamayın. Bu klasik bir .NET tuzağıdır.

VKN varyantı

VKN tarafında NVİ muadili açık bir kamu SOAP servisi yoktur. Bunun yerine:

  • GİB e-Fatura portalı üzerinden manuel sorgulama
  • Özel entegratör API'leri (çoğu REST ve JSON-tabanlı)

Bu entegratörlerin imzası genelde şöyle görünür:

GET /v1/vkn/{vkn}
Authorization: Bearer <api-key>

Yanıtta unvan, vergi_dairesi, e_fatura_mukellef_mi gibi alanlar döner. Implementasyonunuz, yukarıdaki NVİ örneklerindeki retry + timeout kalıplarını olduğu gibi uygulayabilir; sadece URL ve auth başlığı değişir.

VKN (vergi no) format doğrulamasını sık çağırıyorsanız algoritmayı VKN algoritması yazısında göstermiştik.

Hata sınıflandırması

İyi bir doğrulama entegrasyonu dört hata sınıfını ayırt eder:

  1. INVALID_FORMAT: yerel algoritma fail. Retry yapma, kullanıcıya göster.
  2. NOT_MATCHED: NVİ "false" döndü. Yani format doğru ama isim/soyisim/yıl TCKN ile eşleşmiyor. Retry yapma.
  3. UPSTREAM_TEMP: 5xx, timeout, connection reset. Exponential backoff ile retry.
  4. UPSTREAM_CONTRACT: 4xx (429 hariç), şemalanmamış XML, beklenmeyen response. Alert'le, retry etme.

Bu ayrımı karıştıran ekipler retry sonsuzluk veya kullanıcıya boşuna yeniden deneme hataları yapar.

Cache ve idempotency

Aynı (tckn, ad, soyad, dogumYili) kombinasyonu için yapılan çağrı idempotenttir: sonuç değişmez (vatandaşın adı değişmedikçe). Bu nedenle kısa süreli (15 dk – 24 saat) bir in-memory veya Redis cache, NVİ'ye giden yükü ciddi oranda düşürür. Cache key olarak sha256(tckn + "|" + ad + "|" + soyad + "|" + yil) gibi bir hash kullanın — TCKN'yi cache key olarak çıplak tutmayın. Bu KVKK açısından önemli; detayı KVKK rehberinde.

Rate limiting ve fair use

NVİ servisinin resmi rate limit değeri kamuya açık dokümanda sabit bir rakamla verilmez; kuruma ve sözleşmeye göre değişir — doğrulayın. Pratikte abartılı çağrı yapan istemciler 1 dk ile 24 saat arasında geçici bloklarla karşılaşır. Mimarinizde şunu varsayın:

  • Tek bir client için dakikada 60 çağrı üzeri alarm kurun
  • Bursty çağrıları token bucket ile yumuşatın
  • Hata oranınız %10'u aştığında circuit breaker açın

Toplu doğrulama senaryolarında bu patternleri nasıl kuracağınızı toplu doğrulama rehberinde detaylandırdık.

Sonuç

Doğrulama entegrasyonlarında başarı, iki katmanı doğru ayırmaktan geçer: hızlı ve ücretsiz olan yerel format katmanı ve daha pahalı olan uzak kimlik katmanı. Yukarıdaki kod örnekleri her iki katmanı da production'a yakın bir seviyede veriyor; XML escape, retry, timeout ve hata sınıflandırması dahil.

Hızlı test için TC Doğrulayıcı, VKN Doğrulayıcı ve Kütüphaneler sayfalarını kullanın.