# Webhooks referansı

Payload şeması, etkinlik türleri, imzalama, yeniden denemeler ve REST abonelik uç noktaları.

## Webhooks referansı

Payload şeması, etkinlik türleri, imzalama, yeniden deneme davranışı ve Zapier ile Make için REST abonelik uç noktaları.

> ℹ️ **Kurulum kılavuzunu mu arıyorsunuz?**
> <p>
>     Bu sayfa, payload sözleşmesini ve REST abonelik API’sini belgeler. Formunuz için UI’de özel bir webhook kurmak için bkz.{' '}
>     <a href="/tr/integrations/webhooks">Özel webhook’lar</a>.
>   </p>

<h2 id="request">İstek</h2>
<p>
  <code>POST &lt;your-url&gt;</code> ile <code>Content-Type: application/json</code>.
</p>

<h3 id="headers">Başlıklar</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-Formstep-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — imzalama gizli anahtarı ayarlandığında eklenir
    (aşağıya bakın)
  </li>
  <li>
    <code>X-Formstep-Event-Id</code> ve <code>X-Formstep-Event-Type</code> — gövdedeki <code>id</code> ve <code>type</code> ile aynı
    değerler, böylece ayrıştırmadan önce yinelenenleri ayıklayıp yönlendirebilirsiniz. Bir{' '}
    <a href="/tr/requests/callbacks">istek geri çağırması</a> da aynı ikisini gönderir.
  </li>
  <li>
    Kurulum sırasında eklediğiniz özel başlıklar. Verildiği gibi birleştirilirler, üzerine yazılamayan <code>Content-Type</code> hariç.{' '}
    <code>webhooks.create</code> ile oluşturulan yerel aboneliklerin özel başlığı yoktur.
  </li>
</ul>

<h2 id="event-types">Etkinlik türleri</h2>

<h3 id="subscription-events">Abonelik etkinlikleri</h3>
<p>Bir webhook entegrasyonu kurduğunuzda, hangi etkinliğin teslimatları tetikleyeceğini seçersiniz:</p>

<p>
  Üç <code>request_*</code> etkinliği <code>webhooks.create</code> ile abone olunur ve Zapier, Make ile n8n için Formstep uygulamalarının
  dinlediği şey budur. Her biri bir <a href="/tr/requests/callbacks">istek geri çağırmasının</a> gönderdiği aynı zarfı, aboneliğin kendi
  sırrıyla imzalanmış olarak teslim eder. Test istekleri hiçbir zaman bir aboneliğe ulaşmaz ve <code>requests.replayCallback</code> yalnızca
  geri çağırmayı yeniden gönderir. Bir abonelik, bir etkinlik: <code>submission_created</code> genel bağlantı trafiğidir,{' '}
  <code>request_completed</code> ise istek trafiğidir; bu yüzden tamamlanan bir istek <code>submission_created</code>'ı hiçbir zaman
  tetiklemez ve her iki aboneliğe de sahip bir form, bir tamamlanma için tek teslimat alır. Bir düzenleme yalnızca bir{' '}
  <code>submission_updated</code> aboneliğine ulaşır, asla bir <code>submission_created</code> aboneliğine değil. Form ayarlarında
  yapılandırdığınız webhook'ta etkinlik seçimi yoktur: ilk gönderimleri ve düzenlemeleri aynı şekilde alır, <code>type</code> ile ayırt
  edilir.
</p>

<h3 id="payload-event-types">Payload etkinlik türleri</h3>
<p>
  JSON gövdesindeki <code>type</code> alanı ne olduğunu belirtir:
</p>
<ul>
  <li>
    <code>submission.completed</code> — yeni ve tamamlanmış bir submission
  </li>
  <li>
    <code>submission.updated</code> — mevcut bir submission düzenlendi
  </li>
  <li>
    <code>submission.abandoned</code> — bir submission taslağı yapılandırılmış boşta kalma süresinden sonra terk edildi
  </li>
</ul>
<p>
  Bir <a href="/tr/requests/callbacks">istek geri çağırması</a> ve bir <code>request_*</code> aboneliği, aynı zarfı{' '}
  <code>request.completed</code>, <code>request.expired</code> ve <code>request.canceled</code> ile kullanır, böylece tek bir ayrıştırıcı
  altısını da okur.
</p>

<h2 id="payload">Payload yapısı</h2>

```
{
  "id": "evt_abc123",
  "type": "submission.completed",
  "createdAt": "2026-04-25T12:34:56.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "form": { "id": "frm_...", "name": "Contact", "snapshotId": "snp_..." },
    "submission": {
      "id": "sub_...",
      "respondentEmail": "alice@example.com",
      "submittedAt": "2026-04-25T12:34:56.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": {
      "email": "alice@example.com",
      "plan": "pro",
      "attendees": [{ "attendee_name": "Grace Hopper" }, { "attendee_name": "Alan Turing" }]
    },
    "display": {
      "email": "alice@example.com",
      "plan": "Pro",
      "attendees": "Grace Hopper, Alan Turing"
    }
  }
}
```

<h3 id="fields-vs-answers">answers ve display</h3>
<p>
  <code>answers</code>, yayınlama sırasında sabitlenen alan anahtarlarıyla anahtarlanan düz <code>{'{ field key: value }'}</code>{' '}
  nesnesidir. Bir workflow dallandığında veya bir değeri sakladığında bunu okuyun: <code>answers.email</code>, gezilecek bir dizi yok. Bir
  seçim yanıtı, seçilen seçeneğin <strong>anahtarıdır</strong> — <code>fields.list</code>’in o seçenek için listelediği <code>key</code> —
  bu yüzden katılımcı hangi dilde yanıtlarsa yanıtlasın aynıdır. Bir tarih ISO dizesi, bir sayı sayı, çoklu seçim ise seçenek
  anahtarlarından oluşan bir dizidir. Yanıtlanmamış alanlar dışarıda bırakılır, asla <code>null</code> olarak gönderilmez.
</p>
<p>
  <code>display</code>, aynı anahtarları insan tarafından okunabilir metinle taşır: anahtar yerine seçeneğin etiketi, biçimlendirilmiş bir
  tarih, birleştirilmiş bir liste. Değeri bir kişi göreceğinde bunu okuyun — bir Slack mesajı, bir e-tablo hücresi, bir e-posta.
</p>
<p>
  Yinelenen bir grup <code>answers</code> içinde bir kez, grubun kendi alan anahtarı altında, her üyenin kendi alan anahtarıyla
  anahtarlanmış satır nesnelerinden oluşan bir dizi olarak görünür — yukarıdaki <code>answers.attendees[0].attendee_name</code> — ve{' '}
  <code>display</code> içinde satırların birleştirildiği tek bir satır olarak. Bir üye asla üst düzeye taşınmaz.{' '}
  <a href="/tr/building-forms/calculated-fields">Hesaplanan alanlar</a> her iki eşlemede de, hesaplanan alanın adını anahtarı olarak
  kullanarak görünür (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Randevular ve ödemeler</h3>
<p>
  Bir Randevu planlama sorusu ve bir Ödeme alanı, <code>answers</code> içinde sorunun alan anahtarı altında birer nesne ve{' '}
  <code>display</code> içinde tek satırlık bir metin taşır. Zamanlar ISO anlarıdır; böylece bir e-tablo veya bir workflow, katılımcı hangi
  dilde yanıtlarsa yanıtlasın onları ayrıştırabilir:
</p>

```
{
  "book_a_call": {
    "status": "confirmed",
    "start": "2026-09-29T07:00:00.000Z",
    "end": "2026-09-29T07:30:00.000Z",
    "timeZone": "Europe/Oslo",
    "attendee": { "name": "Grace Hopper", "email": "grace@example.com" },
    "meetingUrl": "https://app.cal.com/video/...",
    "provider": "cal.com",
    "providerBookingId": "...",
    "eventTitle": "Intro call"
  },
  "pay_the_fee": {
    "status": "paid",
    "amount": 40,
    "currency": "USD",
    "amountRefunded": 0,
    "receiptUrl": "https://pay.stripe.com/receipts/...",
    "paidAt": "2026-09-24T10:12:00.000Z",
    "refundedAt": null,
    "disputedAt": null,
    "provider": "stripe",
    "providerPaymentIntentId": "pi_..."
  }
}
```

<p>
  Bir randevunun <code>status</code> değeri <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>, <code>rejected</code>{' '}
  veya <code>no_show</code> olur. Bir ödemeninki <code>paid</code>, <code>partially_refunded</code>, <code>refunded</code> veya{' '}
  <code>disputed</code> olur ve <code>amount</code> para biriminin ana birimindedir: <code>40</code>, 40,00 $ demektir. Cal.com gönderimden
  sonra bir randevuyu taşıdığında veya Stripe bir ödemeyi iade ettiğinde Formstep nesneyi günceller, böylece <code>submissions.list</code>{' '}
  ve sonraki olaylar güncel durumu gösterir; değişiklik için yeni bir olay gönderilmez.
</p>
<p>
  <code>apiVersion</code> <code>2026-09-24</code> öncesinde bir randevu <code>answers</code> içinde tek bir cümle olarak gönderiliyordu ve
  bir ödeme hiç gönderilmiyordu.
</p>

<p>
  Webhook’un alan eşlemesi her iki eşlemeye de aynı anda uygulanır: “seçili” alanları seçin, diğerleri dışarıda bırakılır; bir alanın
  sütununu yeniden adlandırın, yeni ad hem <code>answers</code> hem de <code>display</code> içinde onun anahtarı olur. Alan anahtarları var
  olmadan önce yayınlanmış bir alan, kendi eleman kimliği altında gider; okunabilir bir anahtar vermek için formu yeniden yayınlayın.
</p>

<h3 id="schema">Alan başlıkları ve türleri</h3>
<p>
  Etkinlik, her alanın başlığını ve türünü tekrarlamaz. Bunları, <code>data.form.snapshotId</code> başına sabit olan{' '}
  <code>fields.list</code>’ten okuyun; böylece alan listesini önbelleğe alıp yalnızca snapshot kimliği değiştiğinde yeniden çekebilirsiniz.
  İkinci bir çağrı yapamayan bir alıcı, webhook ayarlarında <strong>Alan listesini her etkinlikle gönder</strong> seçeneğini açabilir;
  etkinlik o zaman alan başına bir kayıt taşıyan <code>data.schema</code>’yı içerir. Bir seçim sorusu <code>options</code>’ını, bir matris{' '}
  <code>rows</code> ve <code>columns</code>’ını listeler, her biri <code>{'{ key, label }'}</code> olarak — böylece <code>answers</code>{' '}
  içindeki anahtarlar ikinci bir çağrı olmadan etiketlere çözümlenir:
</p>

```
[
  { "key": "email", "title": "Email", "type": "email", "group": null },
  { "key": "plan", "title": "Plan", "type": "radio", "group": null, "options": [{ "key": "free", "label": "Free" }, { "key": "pro", "label": "Pro" }] },
  { "key": "attendee_name", "title": "Attendee name", "type": "text", "group": "attendees" }
]
```

<h3 id="request-block">İstek bloğu</h3>
<p>
  Form ayarlarında yapılandırılan <a href="/tr/integrations/webhooks">özel webhook</a>'ta, bir <a href="/tr/requests/overview">isteği</a>{' '}
  yanıtlayan bir gönderi, <code>data</code> içinde bir ekstra nesne taşır: <code>request</code>. Her genel bağlantı gönderiminde bulunmaz,
  bu yüzden onun varlığı bu alıcının iki kanalı birbirinden ayırt etme yoludur. Bir Zapier, Make veya n8n aboneliği onu hiçbir zaman görmez:
  istek trafiği bir aboneliğe <code>request.completed</code> olarak ulaşır, bu da istek bloğunun tamamını taşır.
</p>

```
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
```

<ul>
  <li>
    <code>id</code> — bu gönderinin yanıtladığı istek. Tam görünüm için <code>requests.get</code>’e geçirin.
  </li>
  <li>
    <code>externalId</code> ve <code>metadata</code> — <code>requests.create</code> üzerinde tam olarak sağladığınız şekliyle kendi kayıt
    tutma bilgileriniz. Her biri yalnızca ayarlanmışsa bulunur.
  </li>
</ul>

> ℹ️ **Webhook’lar geri çağırma değildir**
> <p>
>     Bir gönderim webhook'u bir gönderide tetiklenir; istek bloğu yalnızca yanıtladığı isteği adlandırır. Bir{' '}
>     <a href="/tr/requests/callbacks">geri çağırma</a> bir istek sona erdiğinde — tamamlandığında, süresi dolduğunda veya iptal edildiğinde —
>     tetiklenir ve <code>context</code> ile <code>outcome</code> taşır. Süre dolumu ve iptalin gönderimi yoktur, bu yüzden onlar için hiçbir
>     zaman bir gönderim webhook'u tetiklenmez. Bir isteğin sona erdiğini bir geri çağırma URL'si olmadan duymak için,{' '}
>     <code>webhooks.create</code> üzerinden <code>request_completed</code>, <code>request_expired</code> veya <code>request_canceled</code>'a
>     abone olun.
>   </p>

<h3 id="submission-pdf">Gönderim PDF’i</h3>
<p>
  <code>data.submission.pdfUrl</code>, gönderim PDF’ine bir bağlantıdır. Özel webhook’u veya Zapier, Make ya da n8n aboneliği olan bir form,
  her gönderimin PDF’ini saklar; bu yüzden o formun olayları bağlantıyı taşır. Yalnızca gönderimin PDF’i yoksa <code>null</code> olur.
</p>

<h3 id="submission-language">Gönderim dili</h3>
<p>
  <code>data.submission.language</code> katılımcının gönderimi yaptığı andaki dilin BCP-47 kodudur (çevrilmiş formlar için). Tek dilli
  formlar için <code>null</code> değerini alır. Ayrı bir sorgu yapmadan katılımcının diline göre yönlendirme veya dallanma yapmak için bu
  değeri kullanın.
</p>

<h2 id="abandoned-submissions">Terk edilmiş submission payload’ları</h2>
<p>
  <code>submission_abandoned</code> etkinliğine abone olduğunuzda, Formstep her saat atıl taslakları kontrol eder. Bir taslak,
  yapılandırılan atıl süresini aştığında Formstep bir teslimat gönderir.
</p>
<p>
  Yerel API entegrasyonları <code>webhooks.create</code>’e <code>idleWindow</code> göndermelidir. Kabul edilen değerler <code>12h</code>,{' '}
  <code>1d</code>, <code>3d</code> ve <code>1w</code>’dir. Örtük bir varsayılan yoktur; değeri olmayan terk edilmiş bir abonelik reddedilir.
</p>

<p>Payload yapısı, tamamlanmış bir submission ile aynıdır. İki fark şunlardır:</p>
<ul>
  <li>
    <strong>Yanıtlar seyrek olabilir</strong> — yalnızca katılımcının yanıtladığı sorular <code>answers</code> ve <code>display</code>{' '}
    içinde görünür.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — katılımcı hiçbir zaman resmi olarak göndermediği için gönderim zaman damgasına geri düşer.
  </li>
</ul>
<p>
  Her entegrasyon, terk edilmiş bir taslak başına en fazla bir kez tetiklenir. Teslimat sonrası taslak, gelecekteki taramalardan hariç
  tutulur.
</p>

<h2 id="signing">İmzalama</h2>
<p>
  Her webhook’un kendi imzalama gizli anahtarı vardır — özel bir webhook yapılandırırken UI’de ayarlanır, ya da <code>webhooks.create</code>{' '}
  üzerinde isteğe bağlı <code>signingSecret</code> parametresiyle (32–255 karakter). Bu,{' '}
  <a href="/tr/requests/callbacks">istek geri çağırmaları</a> için kullanılan çalışma alanı istek imzalama sırrı değildir, ama başlık ve
  algoritma aynıdır, bu yüzden tek bir doğrulayıcı ikisini de karşılar.
</p>
<p>
  İmzalanmış her teslimat <code>X-Formstep-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code> taşır. Özet,
  <code>&#123;t&#125;.&#123;raw body&#125;</code> değerinin, gizli anahtarınızla anahtarlanmış bir HMAC-SHA256’sıdır. İki kural: herhangi
  bir ayrıştırma veya yeniden serileştirmeden önce <strong>ham</strong> gövdeyi özetleyin ve sabit zamanda karşılaştırın.
</p>

```
import crypto from 'node:crypto'

  if (!header) return false
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  if (!parts.t || !parts.sha256) return false

const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.sha256, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false

return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
}
```

```
import hashlib, hmac, time
def verify_formstep_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
  parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
  t, received = parts.get('t'), parts.get('sha256')
  if not t or not received:
    return False
  expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
  if not hmac.compare_digest(expected, received):
    return False
  return abs(time.time() - int(t)) <= tolerance_seconds
```

<p>
  Formstep bir tekrar oynatma (replay) penceresi zorunlu kılmaz, bu yüzden yukarıdaki tolerans sizin seçiminizdir. Gizli anahtarı
  değiştirmek, halen devam eden yeniden denemeler dahil, bir sonraki denemede geçerli olur — önce alıcınızı güncelleyin.
</p>

> ⚠️ **Üretimde her zaman doğrulayın**
> <p>Doğrulama olmadan, URL’nizi bulan herkes sahte submission gönderebilir.</p>

<h2 id="retries">Yeniden denemeler</h2>
<p>
  Formstep, teslimat başına en fazla 5 deneme yapar — 1 ilk deneme ve 4 yeniden deneme — en az 1, 2, 4 ve 8 dakika aralıklarla. Formstep,
  süresi gelen yeniden denemeleri her 30 dakikada bir arar, bu yüzden bir yeniden deneme kendi geri çekilme aralığı bittikten yarım saat
  sonrasına kadar gelebilir ve son deneme ilkinden yaklaşık iki saat sonra gelir. Yanıtınızdaki bir <code>Retry-After</code> başlığı, bir
  sonraki geri çekilme adımından daha uzun bir süre istediğinde dikkate alınır. Uç noktanız şu durumlarda teslimat başarısız sayılır:
</p>
<ul>
  <li>2xx dışında bir durum kodu döndürürse</li>
  <li>Zaman aşımına uğrarsa</li>
  <li>Bağlantıyı sıfırlarsa</li>
</ul>
<p>
  Bir durum asla yeniden denenmez: engellenmiş, çözümlenemeyen veya özel bir adrese çözümlenen bir hedef. URL, DNS dahil, her denemeden
  hemen önce yeniden doğrulanır; bu yüzden izin verilmekten çıkan bir sunucu, bütçeyi tüketmek yerine teslimatı anında başarısız kılar.
</p>
<p>
  5 ardışık başarısız teslimatın ardından entegrasyon duraklatılır. Uç noktayı düzeltip Form ayarları → Entegrasyonlar bölümünden yeniden
  etkinleştirin; başarılı bir teslimat sayacı sıfırlar.
</p>
<p>
  Aynı olayın yeniden denenen teslimatları aynı <code>id</code> değerini kullanır, bu yüzden işlenen id’leri saklayarak yinelenenleri
  ayıklayın. Gerçekten yeni bir olay — örneğin yanıtlayanın gönderimini düzenlemesi — yeni bir <code>id</code> ve{' '}
  <code>type: "submission.updated"</code> ile gelir: Form ayarları webhook'unda ya da bir <code>submission_updated</code> aboneliğinde.{' '}
  <code>createdAt</code>, denemenin yapıldığı an değil, olayın sıraya alındığı andır, bu yüzden yeniden denemeler arasında da aynı kalır.
  Düzenlemeleri birbirinden ayırt etmek için <code>data.submission.editCount</code>'u okuyun: her düzenlemeyle birlikte artar;{' '}
  <code>data.submission.updatedAt</code> ise sonuncusunun ne zaman gerçekleştiğini söyler.
</p>

<h2 id="testing">Test</h2>
<p>
  Entegrasyon kurulumu ve detay panelinde <strong>Test gönder</strong> düğmesi bulunur. Abone olunan etkinliğin bir örneğini URL’nize
  gönderir, böylece gerçek bir submission veya istek beklemeden bağlantıyı doğrulayabilirsiniz. Aynı örnekler, API üzerinden{' '}
  <code>submissions.sample</code> ve <code>requests.sample</code> olarak da kullanılabilir.
</p>
<p>Yerel geliştirme için, geliştirme sunucunuzu bir tünel ile dışarıya açın:</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

cloudflared tunnel --url http://localhost:3000
```

> 💡 **Yerel geliştirme**
> <p>Tünel URL’sini webhook uç noktanız olarak kullanın, ardından uçtan uca doğrulamak için Test gönder’e tıklayın.</p>

<h2 id="next-steps">Sonraki adımlar</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Webhooks kurulumu](/tr/integrations/webhooks) — Formunuz için webhook’ları yapılandırın
  - [Planlar ve fiyatlandırma](/tr/subscription-billing/plans-pricing) — Plan API özelliklerini ve limitlerini karşılaştırın
</div>
