# Webhooks

When something changes in your categories we do not just say "something changed": we send **the notice itself** — company, category, notice date, type, a short summary and the gazette PDF link — so your system can act without a second request.

- Up to 5 addresses, each with its own categories (empty = all).
- Sent on the day a new notice is published for a company you follow; the same notice is never sent twice.
- The rows of one company from the same scan arrive in one request.
- Manage via the [API endpoints](/en/gelistirici/api/webhook-ekle/) or Dashboard › API keys.

## Request

```http
POST https://sisteminiz.ornek.com/ulawk
Content-Type: application/json; charset=utf-8
User-Agent: Ulawk-Webhook/2.0
X-Ulawk-Olay: sirket.degisiklik
X-Ulawk-Teslim: msg_3f9c1a0b7d2e4c5f6a7b8c9d
X-Ulawk-Imza: t=1760572800,v1=5e2b…
```

:::sema WebhookYuku

## Verify the signature {#imza}

`X-Ulawk-Imza: t=<unix seconds>,v1=<hex>` — `v1 = HMAC-SHA256(secret, t + "." + raw body)`. Compute it on the raw bytes **before parsing**, compare in constant time, and reject if `t` is more than 300 seconds from now (replay protection). During secret rotation the header may carry several `v1` values; accept if any matches.

```python Python
import hashlib
import hmac
import time


def ulawk_imzasi_gecerli(gizli, ham_govde, baslik, tolerans=300):
    """gizli: whsec_… · ham_govde: istek gövdesinin HAM baytları · baslik: X-Ulawk-Imza"""
    try:
        parcalar = [p.strip().split("=", 1) for p in baslik.split(",") if "=" in p]
        t = int(next(v for k, v in parcalar if k == "t"))
    except (StopIteration, ValueError):
        return False
    if abs(time.time() - t) > tolerans:
        return False
    beklenen = hmac.new(gizli.encode(), f"{t}.".encode() + ham_govde, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(beklenen, v) for k, v in parcalar if k == "v1")
```

```javascript JavaScript (Node)
const crypto = require("node:crypto");

// gizli: whsec_… · hamGovde: istek gövdesinin HAM hali (Buffer) · baslik: X-Ulawk-Imza
function ulawkImzasiGecerli(gizli, hamGovde, baslik, tolerans = 300) {
  const parcalar = baslik.split(",").map((p) => p.trim().split("=", 2)).filter((p) => p.length === 2);
  const t = Number((parcalar.find(([k]) => k === "t") || [])[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerans) return false;
  const beklenen = crypto.createHmac("sha256", gizli).update(`${t}.`).update(hamGovde).digest("hex");
  return parcalar.some(([k, v]) => k === "v1" && v.length === beklenen.length &&
    crypto.timingSafeEqual(Buffer.from(v), Buffer.from(beklenen)));
}

module.exports = { ulawkImzasiGecerli };
```

```php PHP
<?php
// $gizli: whsec_… · $hamGovde: file_get_contents('php://input') · $baslik: $_SERVER['HTTP_X_ULAWK_IMZA']
function ulawk_imzasi_gecerli(string $gizli, string $hamGovde, string $baslik, int $tolerans = 300): bool {
    $t = null;
    $imzalar = [];
    foreach (explode(',', $baslik) as $parca) {
        [$k, $v] = array_pad(explode('=', trim($parca), 2), 2, '');
        if ($k === 't') { $t = (int) $v; }
        if ($k === 'v1') { $imzalar[] = $v; }
    }
    if ($t === null || abs(time() - $t) > $tolerans) { return false; }
    $beklenen = hash_hmac('sha256', $t . '.' . $hamGovde, $gizli);
    foreach ($imzalar as $v) {
        if (hash_equals($beklenen, $v)) { return true; }
    }
    return false;
}
```

## Retries and duplicates

- Any non-2xx answer or timeout is retried with growing waits (from 30 seconds up to 6 hours, 7 attempts in total).
- Retries carry the **same** `teslim_id` and body: drop duplicates by `X-Ulawk-Teslim`.
- After 20 consecutive failures the webhook is paused; re-enable with `PATCH {"aktif": true}` or in the dashboard.
- We wait at most 5 seconds: answer 2xx quickly and do the work afterwards.

## Security

`https` only. Addresses resolving to private, loopback, link-local or reserved networks are refused; the connection is pinned to the verified IP; redirects are not followed.
