Webhook, bir API'nin kendi tarafında bir olay gerçekleştiğinde (yeni kullanıcı oluştu, sipariş ödendi gibi) bunu istemcinin önceden bildirdiği bir URL'ye HTTP POST isteğiyle haber vermesidir. İyi bir webhook tasarımı dört parçadan oluşur: abonelikleri yöneten bir kaynak (POST /webhooks), tutarlı olay adları (user.created), her olayda aynı yapıda bir gövde (event, data, timestamp) ve teslim edilemeyen olayları yeniden deneyen bir gönderim düzeni. Alıcı tarafta ise olayların birden fazla kez gelebileceği varsayılır ve her olay bir kez işlenir.

Bu yazı abonelik uç noktasını, olay gövdesini, yeniden deneme ve tekrar gelen olay sorununu, imza ile doğrulamayı ve webhook'ların OpenAPI 3.1'de nasıl belgelendiğini çalıştırılmış Python örnekleriyle anlatıyor. Örnek uygulama FastAPI ile yazıldı, fakat anlatılan kararların hiçbiri belirli bir dile ya da çatıya bağlı değil.

Webhook ile yoklama (polling) arasındaki fark

Webhook ile yoklama (polling) arasındaki fark: İsteği kim başlatır, Gecikme, Boş istek, İstemcinin ihtiyacı

Webhook olmayan bir API'de istemci "yeni bir şey var mı" sorusunu düzenli aralıklarla sorar: her dakika GET /v1/kullanicilar?created_after=... gibi bir istek gönderir. Buna yoklama (polling) denir. Webhook bu yönü tersine çevirir; soran taraf istemci değil, haber veren taraf sunucudur. İki yaklaşımın bedeli farklı yerlere düşer.

ÖlçütYoklamaWebhook
İsteği kim başlatırİstemciAPI sağlayıcısı
GecikmeYoklama aralığı kadarOlaydan hemen sonra (teslim başarılıysa)
Boş istekDeğişiklik yoksa da istek giderYalnızca olay olduğunda istek gider
İstemcinin ihtiyacıYalnızca HTTP istemcisiDışarıdan erişilebilen bir HTTPS uç noktası
Kaçan olayBir sonraki sorguda yakalanırYeniden deneme ve gerekirse yoklamayla tamamlanır

Tablonun son satırı önemli bir tasarım gerçeğine işaret eder: webhook bir teslim garantisi değildir. Alıcının sunucusu kapalı olabilir, ağ kopabilir. Bu yüzden iyi tasarlanmış bir API webhook'ları "hızlı haber" olarak sunar ve aynı veriyi sorgulanabilir bir liste uç noktasıyla da erişilebilir tutar; istemci kaçırdığı olayları filtreli bir liste isteğiyle tamamlayabilir.

Abonelik de bir kaynaktır

Webhook aboneliği, API'nizdeki diğer her şey gibi bir kaynak olarak tasarlanabilir. Bu sayede yeni bir kavram icat etmeden tanıdık uç noktalar kullanılır: POST /v1/webhooks abonelik oluşturur, GET /v1/webhooks abonelikleri listeler, GET /v1/webhooks/{id} tek bir aboneliği getirir, PATCH /v1/webhooks/{id} dinlenen olayları değiştirir, DELETE /v1/webhooks/{id} aboneliği kaldırır. Kaynak adının çoğul ve isim olması isimlendirme kurallarıyla uyumludur; yanıt kodları da CRUD tasarımındaki gibi seçilir. Bu serinin dayandığı API tasarımı rehberindeki abonelik gövdesi iki alandan oluşur: bildirimin gideceği url ve dinlenecek olayların listesi events.

http
POST /v1/webhooks HTTP/1.1
Host: api.ornek.com.tr
Content-Type: application/json

{"url": "https://ornek.com.tr/webhook", "events": ["user.created", "user.updated"]}

HTTP/1.1 201 Created
Location: /v1/webhooks/1
Content-Type: application/json

{"id": 1, "url": "https://ornek.com.tr/webhook", "events": ["user.created", "user.updated"]}

Abonelik oluşturulurken iki şeyi hemen doğrulamak, sonradan çıkacak sessiz hataları önler. Birincisi URL'dir: bildirim gövdesinde kullanıcı verisi taşınacağı için yalnızca https:// adreslerini kabul etmek mantıklıdır. İkincisi olay adlarıdır: user.creatd gibi bir yazım hatası doğrulanmazsa abonelik başarıyla oluşur ama hiçbir zaman bildirim almaz ve istemci bunun nedenini bulmakta zorlanır. Aşağıdaki örnek iki denetimi de yapıyor; abonelikler bellekteki bir sözlükte tutuluyor, üretimde bu bir veritabanı tablosu olur.

python
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()
OLAYLAR = {"user.created", "user.updated", "user.deleted"}
ABONELIKLER = {}   # üretimde veritabanı

class AbonelikIstegi(BaseModel):
    url: str
    events: list[str]

@app.post("/v1/webhooks", status_code=201)
def abone_ol(istek: AbonelikIstegi):
    if not istek.url.startswith("https://"):
        return JSONResponse(status_code=422, content={"detail": "url https ile başlamalı"})
    bilinmeyen = sorted(set(istek.events) - OLAYLAR)
    if bilinmeyen:
        return JSONResponse(status_code=422, content={"detail": "Bilinmeyen olay", "events": bilinmeyen})
    no = len(ABONELIKLER) + 1
    ABONELIKLER[no] = {"id": no, **istek.model_dump()}
    return JSONResponse(status_code=201, content=ABONELIKLER[no],
                        headers={"Location": f"/v1/webhooks/{no}"})

c = TestClient(app)
r = c.post("/v1/webhooks", json={"url": "https://ornek.com.tr/webhook",
                                 "events": ["user.created", "user.updated"]})
print(r.status_code, r.headers["location"], r.json())
r = c.post("/v1/webhooks", json={"url": "https://ornek.com.tr/webhook", "events": ["user.creatd"]})
print(r.status_code, r.json())
r = c.post("/v1/webhooks", json={"url": "http://ornek.com.tr/webhook", "events": ["user.created"]})
print(r.status_code, r.json())

# çıktı:
# 201 /v1/webhooks/1 {'id': 1, 'url': 'https://ornek.com.tr/webhook', 'events': ['user.created', 'user.updated']}
# 422 {'detail': 'Bilinmeyen olay', 'events': ['user.creatd']}
# 422 {'detail': 'url https ile başlamalı'}

Bilinmeyen olay hatasında yanıt hangi adın tanınmadığını da söylüyor. İstemcinin hatayı düzeltebilmesi için bu bilgi şart. Örnekte hata gövdesi kısa tutuldu; gerçek bir API'de bütün uç noktalarda kullandığınız hata biçimini burada da kullanın.

Olay adları nasıl seçilir

Başvuru rehberi user.created ve user.updated biçimini kullanır: önce kaynağın adı, sonra nokta, sonra geçmiş zamanda olan eylem. Bu biçimin iki pratik yararı var. Birincisi, olay adı API'deki kaynak modeliyle aynı dili konuşur; /users kaynağında olan şeyler user. ile başlar. İkincisi, alıcı tarafta yönlendirme kolaylaşır; olay adını noktadan bölüp kaynağa göre bir işleyiciye göndermek tek satırlık iştir. Geçmiş zaman kullanmak da anlamlıdır: webhook bir komut değil, olmuş bir şeyin haberidir. user.create gibi bir ad, alıcıya "kullanıcı oluştur" diyormuş gibi okunabilir.

Olay adları bir kez yayınlandıktan sonra sözleşmenin parçası olur. Bir adı değiştirmek, bir JSON alanını yeniden adlandırmak gibi kırıcı bir değişikliktir ve alıcıların kodunu bozar; bu tür değişikliklerin nasıl yönetileceği geriye uyumluluk yazısında anlatılıyor. Yeni olay eklemek ise uyumludur, çünkü kimse abone olmadığı olayı almaz.

Olay gövdesinin yapısı

Olay gövdesinin yapısı

Başvuru rehberindeki gönderim kodu her olay için üç alan üretir: event (olay adı), data (olayla ilgili veri) ve timestamp (olayın zamanı). Bu çekirdeğe bir alan eklemek tasarımı belirgin biçimde sağlamlaştırır: olayın kendisine ait benzersiz bir id. Aşağıda göreceğiniz gibi aynı olay alıcıya birden fazla kez ulaşabilir ve alıcının "bunu daha önce işledim mi" sorusunu cevaplayabilmesi için olayın kimliği gerekir.

json
{"id": "3f6c1a9e-8b2d-4c71-9a55-2e7d0b4f1c88", "event": "user.created", "data": {"id": 101, "email": "zeynep@ornek.com.tr"}, "timestamp": "2026-11-07T09:30:00+00:00"}

Zaman damgasını RFC 3339 biçiminde ve saat dilimi bilgisiyle göndermek alıcının olayları doğru sıralamasına yardım eder. RFC 3339 hem Z hem +00:00 yazımına izin verir; Python'un isoformat() çıktısı ikincisini üretir. data alanına ne konacağı ise bir ödünleşimdir. Kaynağın tamamını koymak alıcıya ek istek yaptırmaz, ama gövde büyür ve kişisel veri her bildirimde ağda dolaşır. Yalnızca kimliği koymak gövdeyi küçük ve güvenli tutar; alıcı güncel veriyi GET /v1/kullanicilar/101 ile kendisi çeker ve bu sayede yeniden denemelerle gecikmiş eski bir kopyayla çalışma riski de ortadan kalkar. Hangi yolu seçerseniz seçin, her olay türü için gövdeyi belgeleyin ve yanıtlarınızda olduğu gibi gizli alanları asla göndermeyin.

Teslim, zaman aşımı ve yeniden deneme

Gönderen taraf için başarı ölçütünün net olması gerekir. Yaygın ve anlaşılır kural şudur: alıcı 2xx sınıfında bir durum koduyla yanıt verdiyse olay teslim edilmiştir; zaman aşımı, bağlantı hatası, 5xx ya da başka bir kod teslim edilemedi sayılır ve olay daha sonra yeniden gönderilir. Bekleme süreleri her denemede artırılır ki geçici olarak çökmüş bir alıcının üzerine aynı anda yüzlerce istek yığılmasın. Belirli sayıda denemeden sonra vazgeçilir; olay bir "başarısız teslimler" kaydına yazılır, gerekirse abonelik devre dışı bırakılıp sahibine haber verilir.

Bu düzenin kaçınılmaz bir sonucu vardır: aynı olay birden fazla kez teslim edilebilir. Alıcı olayı işleyip veritabanına yazmış, fakat 2xx yanıtı ağda kaybolmuş olabilir; gönderen bunu bir hata olarak görür ve olayı yeniden yollar. Gönderen taraf "en az bir kez" teslim edebilir, "tam olarak bir kez" garantisini tek başına veremez. Bu yüzden alıcı tarafın olay kimliğine bakarak tekrarları ayıklaması gerekir; düşünce olarak Idempotency-Key ile aynıdır, yalnızca roller yer değiştirmiştir. Benzer biçimde olayların sırası da garanti değildir: user.updated, gecikmiş bir user.created'dan önce gelebilir. Zaman damgası ve kaynağın güncel hâlini çekmek bu sorunu hafifletir.

Aşağıdaki örnek gönderen ve alıcı tarafı birlikte gösteriyor. Gerçek bir ağ yerine httpx'in MockTransport sınıfı kullanılıyor; sahte alıcı ilk istekte bağlantı zaman aşımı, ikincide 503 veriyor, üçüncüde imzayı doğrulayıp olayı işliyor. Örnek gerçekten beklemiyor; bekleme sürelerinin yeri yorumla gösterildi.

python
import hashlib
import hmac
import json
import uuid
from datetime import datetime, timezone

import httpx

GIZLI = b"abonelige-ozel-gizli-anahtar"
BEKLEMELER = [1, 5, 30]          # saniye; örnekte gerçekten beklemiyoruz

def olay_yap(tur: str, veri: dict) -> dict:
    return {
        "id": str(uuid.uuid4()),
        "event": tur,
        "data": veri,
        "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }

def imzala(govde: bytes) -> str:
    return hmac.new(GIZLI, govde, hashlib.sha256).hexdigest()

def teslim_et(istemci: httpx.Client, url: str, olay: dict) -> bool:
    govde = json.dumps(olay, ensure_ascii=False).encode("utf-8")
    basliklar = {"Content-Type": "application/json", "X-Webhook-Imza": imzala(govde)}
    for deneme, bekle in enumerate([0] + BEKLEMELER, start=1):
        try:
            r = istemci.post(url, content=govde, headers=basliklar, timeout=5.0)
            print(f"deneme {deneme}: {r.status_code}")
            if 200 <= r.status_code < 300:
                return True
        except httpx.TransportError as hata:
            print(f"deneme {deneme}: {type(hata).__name__}")
        # gerçek kodda burada `bekle` kadar beklenir ya da iş kuyruğa geri atılır
    return False

# Alıcıyı taklit eden sahte taşıyıcı: ilk iki istek başarısız
sayac = {"n": 0}
gorulen = set()

def alici(istek: httpx.Request) -> httpx.Response:
    sayac["n"] += 1
    if sayac["n"] == 1:
        raise httpx.ConnectTimeout("zaman aşımı", request=istek)
    if sayac["n"] == 2:
        return httpx.Response(503)
    beklenen = imzala(istek.content)
    if not hmac.compare_digest(beklenen, istek.headers["X-Webhook-Imza"]):
        return httpx.Response(401)
    olay = json.loads(istek.content)
    if olay["id"] in gorulen:
        return httpx.Response(200)       # tekrar gelen olay: yine 2xx, ama işleme yok
    gorulen.add(olay["id"])
    return httpx.Response(204)

istemci = httpx.Client(transport=httpx.MockTransport(alici))
olay = olay_yap("user.created", {"id": 101, "email": "zeynep@ornek.com.tr"})
print(sorted(olay))
print(teslim_et(istemci, "https://ornek.com.tr/webhook", olay))
print(teslim_et(istemci, "https://ornek.com.tr/webhook", olay))
print(len(gorulen))

# çıktı:
# ['data', 'event', 'id', 'timestamp']
# deneme 1: ConnectTimeout
# deneme 2: 503
# deneme 3: 204
# True
# deneme 1: 200
# True
# 1

Çıktıyı satır satır okuyalım. Olay dört alanla oluşturuldu. İlk teslim üç denemede başarılı oldu: zaman aşımı ve 503 başarısızlık sayıldı, 204 teslim demekti. Ardından aynı olay bilerek ikinci kez gönderildi; alıcı kimliği tanıdı, işi tekrarlamadı ama yine de 2xx döndürdü. Bu son nokta sık atlanır: tekrar gelen olaya hata kodu dönmek, gönderenin olayı tekrar tekrar denemesine yol açar. Son satır, iki teslime rağmen olayın bir kez işlendiğini gösteriyor. Örnekte görülen olaylar bellekte tutuluyor; birden çok sunucuda çalışan bir alıcı bunu paylaşılan bir depoda saklamalıdır.

Güvenlik: alıcı olayın sizden geldiğini nasıl bilir

Webhook uç noktası internete açık bir adrestir; adresi öğrenen herkes oraya sahte bir user.created gövdesi gönderebilir. Bunun yaygın çözümü paylaşılan bir sırla imzalamaktır. Abonelik oluşturulurken sunucu o aboneliğe özel rastgele bir sır üretir ve istemciye yalnızca bir kez gösterir. Her gönderimde gövdenin HMAC özeti bu sırla hesaplanır ve bir istek başlığında gönderilir; alıcı aynı hesabı yapıp sonuçları karşılaştırır. Örnekteki X-Webhook-Imza adı bizim uydurduğumuz bir addır: imza başlığı için bir RFC standardı yoktur, her API kendi adını belgeler. Karşılaştırmada hmac.compare_digest kullanılır, çünkü sıradan dize karşılaştırması ilk farklı karakterde durduğu için zamanlama farkı sızdırabilir.

İmzanın yanında birkaç tasarım kararı daha güvenliği artırır. Yalnızca HTTPS adreslerine teslim edin ve sertifika doğrulamasını kapatmayın. İmzayı hesaplarken zaman damgasını da gövdeye dahil ederseniz, alıcı çok eski bir zamana sahip olayları reddederek araya girilip kaydedilmiş bir isteğin tekrar oynatılmasını zorlaştırabilir. Abonelik URL'si istemciden geldiği için gönderim sunucunuzun bu adrese istek atması, iç ağınızdaki adreslere istek atılması için kötüye kullanılabilir; özel ağ adreslerine teslimi engellemek değerlendirilmesi gereken bir önlemdir. Sırrı hiçbir zaman olay gövdesine ya da abonelik listesi yanıtına koymayın.

Başvuru rehberindeki gönderim koduna eleştirel bir bakış

Başvuru rehberi webhook gönderimini birkaç satırlık bir işlevle gösterir: olayı dinleyen abonelikleri bulur, her birine requests.post ile gövdeyi yollar ve zaman damgası için datetime.utcnow() kullanır. Kavramı anlatmak için yeterli, fakat üretime olduğu gibi taşınırsa dört soruna yol açar. Zaman aşımı verilmediği için yanıt vermeyen tek bir alıcı işlemi uzun süre bekletebilir. Hata yakalanmadığı için bir alıcının hatası döngüyü keser ve sonraki abonelere hiç gönderim yapılmaz. Gönderim kullanıcı oluşturma isteğinin içinde yapıldığı için API'nizin yanıt süresi en yavaş alıcının hızına bağlanır. utcnow() ise Python 3.12'de kullanımdan kaldırılmış (deprecated) durumdadır ve saat dilimi bilgisi taşımayan bir zaman üretir; yerine datetime.now(timezone.utc) kullanılır.

Önerilen düzen şudur: olay oluştuğunda yalnızca bir kuyruğa ya da "gönderilecekler" tablosuna yazın, isteğe hemen yanıt verin; ayrı bir işçi süreci olayları kuyruktan alıp zaman aşımı, yeniden deneme ve kayıt ile teslim etsin. Yukarıdaki teslim_et işlevi bu işçinin çekirdeğidir.

Webhook'ları OpenAPI 3.1 ile belgelemek

OpenAPI 3.1, belgenin kök düzeyinde webhooks adlı bir alan tanımlar. Şartnameye göre bu alan, API'nin gönderebileceği ve istemcinin isterse uygulayabileceği gelen istekleri anlatır; her anahtar bir webhook'u adlandırır, değeri ise sağlayıcının başlatacağı isteği ve beklenen yanıtları tarif eden bir Path Item nesnesidir. Bir OpenAPI belgesinin geçerli olması için paths, components ya da webhooks alanlarından en az biri bulunmalıdır; yani yalnızca webhook anlatan bir belge de geçerlidir. Aşağıdaki belge bir doğrulayıcıdan hatasız geçti:

yaml
openapi: 3.1.1
info:
  title: Kullanıcı API
  version: 1.0.0
webhooks:
  userCreated:
    post:
      summary: Yeni kullanıcı oluşturulduğunda gönderilir
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Olay'
      responses:
        '2XX':
          description: Olay alındı; tekrar gönderilmez
components:
  schemas:
    Olay:
      type: object
      required: [id, event, data, timestamp]
      properties:
        id:
          type: string
        event:
          type: string
          enum: [user.created, user.updated, user.deleted]
        data:
          type: object
        timestamp:
          type: string
          format: date-time

Yanıt anahtarı olarak 2XX yazılabilmesi de şartnamenin bir özelliğidir: Responses nesnesi 1XX ile 5XX arasındaki aralık tanımlarını kabul eder. Bu, "her 2xx yanıt teslim sayılır" kuralını belgeye doğrudan geçirmenin kısa yoludur. Şartname ayrıca callbacks adlı ilişkili bir özellik tanımlar; aradaki fark, callback'in belirli bir API çağrısına bağlı olması (örneğin istekte gönderilen bir URL'ye ileride yapılacak çağrı), webhook'un ise bant dışı bir kayıtla, herhangi bir çağrıdan bağımsız gelmesidir. OpenAPI belgesini elle yazmanın geri kalanı OpenAPI 3.1 YAML yazma yazısında anlatılıyor.

Sık yapılan hatalar

Sık yapılan hatalar: Olay kimliği göndermemek, Gönderimi istek içinde yapmak, Zaman aşımı koymamak, Tekrar gelen olaya hata dönmek
  • Olay kimliği göndermemek: Alıcı tekrar gelen olayı ayırt edemez ve aynı işi iki kez yapar.
  • Gönderimi istek içinde yapmak: API yanıt süresi en yavaş alıcıya bağlanır, bir alıcının hatası diğer abonelere gönderimi keser.
  • Zaman aşımı koymamak: Yanıt vermeyen bir alıcı gönderim işçisini kilitler.
  • Tekrar gelen olaya hata dönmek: Alıcı 409 ya da 500 döndürürse gönderen olayı yeniden dener; tekrar olsa da 2xx dönülmelidir.
  • Olay adlarını doğrulamamak: Yazım hatalı bir abonelik sessizce hiçbir şey almaz.
  • İmzasız gönderim: Alıcının gövdeye güvenmesi için bir doğrulama yolu yoktur.
  • Webhook'u tek veri kaynağı yapmak: Kaçan olayları tamamlamak için sorgulanabilir bir liste uç noktası da sunulmalıdır.

Sık Sorulan Sorular

Webhook isteği hangi HTTP metoduyla gönderilir?

Başvuru rehberindeki örnek ve OpenAPI belgelerindeki yaygın kullanım POST'tur. Olay bir gövde taşır ve alıcıda bir işlem başlatır; bu, POST'un amacına uygundur.

Alıcı hangi durum kodunu döndürmeli?

Olayı aldığını bildirmek için 2xx sınıfından bir kod yeterlidir; gövde gerekmiyorsa 204 No Content uygundur. Hangi kodların teslim sayıldığını API belgenizde açıkça yazın.

Kaç kez yeniden denemek gerekir?

Bunun bir standardı yok; bir tasarım kararıdır. Önemli olan sayıyı ve bekleme sürelerini belgelemek, artan aralıklar kullanmak ve vazgeçilen olayları kayıt altına alıp istemcinin görebileceği bir yerde tutmaktır.

Olaylar sırayla gelir mi?

Yeniden denemeler ve paralel gönderimler nedeniyle sıra garanti edilemez. Alıcı zaman damgasına bakmalı ya da kaynağın güncel hâlini API'den çekmelidir.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: REST API tasarımı öğrenme rehberi · Idempotency-Key · OpenAPI YAML yazma · alan adları ve tarih biçimi · CRUD endpoint tasarımı · REST API güvenliği