← Baza wiedzy

Partner API — Webhooks i weryfikacja HMAC

Jak odbierać wyniki crawlów przez webhook. Weryfikacja podpisu HMAC w Node.js, Python i PHP, retry policy, idempotencja, testing lokalny.

Partner API — Webhooks i weryfikacja HMAC

Webhook = POST request od Selpio do Twojego URL w momencie gdy crawl się kończy. Zastępuje polling i dostajesz wyniki natychmiast.

Konfiguracja

  1. Dashboard → Partner API → Settings
  2. Wpisz Webhook URL (musi być HTTPS, publicznie dostępny, nie może wskazywać na private IP)
  3. Zapisz — system wygeneruje webhook secret
  4. Zapisz secret w menedżerze sekretów Twojej aplikacji (pokazujemy tylko raz)

Wymagania URL:

Payload

Selpio wysyła POST z headerami:

Content-Type: application/json
X-Selpio-Signature: sha256=abc123...
X-Selpio-Timestamp: 1713621234
X-Selpio-Event: crawl.completed
User-Agent: Selpio-Webhook/1.0

Body (JSON):

{
  "event": "crawl.completed",
  "crawl_id": "7f8a9b2c-3e4d-5f6a-7b8c-9d0e1f2a3b4c",
  "external_client_ref": "customer_abc",
  "status": "completed",
  "score": 78,
  "finished_at": "2026-04-20T14:30:00Z",
  "report_url": "https://api.selpio.com/api/v1/partner/crawls/7f8a9b2c-..."
}

Zdarzenia wysyłane: crawl.completed (status completed lub failed). Inne zdarzenia (np. crawl.started) nie są obecnie wspierane.

Weryfikacja podpisu HMAC

ZAWSZE weryfikuj podpis — bez tego ktoś może wysłać Ci fałszywy payload udając Selpio.

Jak podpis jest liczony

Po stronie Selpio:

signed_payload = timestamp + "." + raw_body
signature = HMAC-SHA256(webhook_secret, signed_payload)
header = "sha256=" + hex(signature)

Node.js / TypeScript

import crypto from 'node:crypto';
import express from 'express';

const app = express();

// WAŻNE: raw body, nie json()! Express.json() niszczy formatowanie.
app.post('/webhooks/selpio',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-Selpio-Signature');
    const timestamp = req.header('X-Selpio-Timestamp');
    const secret = process.env.SELPIO_WEBHOOK_SECRET!;

    if (!signature || !timestamp) {
      return res.status(401).send('Missing headers');
    }

    // Odrzuć requesty starsze niż 5 minut (replay protection)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - Number(timestamp)) > 300) {
      return res.status(401).send('Stale timestamp');
    }

    const signedPayload = `${timestamp}.${req.body.toString('utf8')}`;
    const expected = 'sha256=' + crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');

    // timingSafeEqual = stały czas porównania, nieodporne na timing attack
    const sigBuf = Buffer.from(signature);
    const expBuf = Buffer.from(expected);
    if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
      return res.status(401).send('Invalid signature');
    }

    const payload = JSON.parse(req.body.toString('utf8'));
    console.log(`Crawl ${payload.crawl_id} done: score=${payload.score}`);

    // Zwróć 2xx JAK NAJSZYBCIEJ — ciężką pracę wrzuć do kolejki
    res.status(200).send('OK');
  }
);

app.listen(3000);

Python / Flask

import hmac
import hashlib
import time
import os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post('/webhooks/selpio')
def selpio_webhook():
    signature = request.headers.get('X-Selpio-Signature', '')
    timestamp = request.headers.get('X-Selpio-Timestamp', '')
    secret = os.environ['SELPIO_WEBHOOK_SECRET']

    if not signature or not timestamp:
        abort(401)

    # Replay protection
    if abs(int(time.time()) - int(timestamp)) > 300:
        abort(401)

    raw_body = request.get_data()  # bytes, przed parsowaniem
    signed_payload = f'{timestamp}.{raw_body.decode()}'.encode()
    expected = 'sha256=' + hmac.new(
        secret.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401)

    data = request.get_json()
    print(f"Crawl {data['crawl_id']} done: score={data['score']}")
    return 'OK', 200

PHP

<?php
$signature = $_SERVER['HTTP_X_SELPIO_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_SELPIO_TIMESTAMP'] ?? '';
$secret = getenv('SELPIO_WEBHOOK_SECRET');

if (!$signature || !$timestamp) {
    http_response_code(401); exit;
}

if (abs(time() - (int)$timestamp) > 300) {
    http_response_code(401); exit;
}

$rawBody = file_get_contents('php://input');
$signedPayload = $timestamp . '.' . $rawBody;
$expected = 'sha256=' . hash_hmac('sha256', $signedPayload, $secret);

if (!hash_equals($signature, $expected)) {
    http_response_code(401); exit;
}

$data = json_decode($rawBody, true);
error_log("Crawl {$data['crawl_id']} done: score={$data['score']}");
http_response_code(200);
echo 'OK';

Polityka retry

Jeśli Twój endpoint nie zwróci 2xx w ≤10 sekund, Selpio ponawia dostawę według exponential backoff:

PróbaCzas od pierwszej próby
10s (instant)
2+1s
3+10s
4+1min
5+10min
6+1h
7+6h

Po 7 nieudanych próbach webhook przechodzi w stan failed — wysyłamy Ci email webhook_failed z opisem błędu. Ten konkretny crawl nie zostanie ponowiony. Kolejne crawle będą próbowały dostarczyć na URL normalnie.

Co liczy się jako “sukces”: HTTP status 2xx (200, 201, 202, 204). Wszystko inne (3xx redirect, 4xx, 5xx, timeout) = retry.

Ważne: zwracaj 2xx szybko (≤1s jeśli możesz). Ciężką pracę (np. update w bazie, wysłanie maila do klienta) wrzuć do asynchronicznej kolejki, nie rób synchronicznie w handlerze webhooka.

Idempotencja

Ten sam webhook może zostać wysłany więcej niż raz (np. timeout po naszej stronie a request jednak dotarł). Twój handler musi być idempotentny.

Wzorzec: zapisz crawl_id w bazie z unique constraint, albo użyj Redis SET z TTL:

// Pseudocode
const key = `webhook:processed:${payload.crawl_id}`;
const wasSet = await redis.set(key, '1', 'EX', 86400, 'NX');
if (!wasSet) {
  console.log('Already processed, skipping');
  return res.status(200).send('OK');
}
// ... process ...

Testing lokalny

Opcja 1 — ngrok (najszybsza)

# Terminal 1
npm start  # Twoja aplikacja na porcie 3000

# Terminal 2
ngrok http 3000
# → Forwarding https://abc123.ngrok.io -> http://localhost:3000

Wklej https://abc123.ngrok.io/webhooks/selpio w dashboard. Odpal testowy crawl.

Opcja 2 — webhook.site

  1. Otwórz https://webhook.site — dostaniesz unikalny URL
  2. Wklej URL w dashboard
  3. Odpal testowy crawl
  4. Zobaczysz payload + headery na webhook.site

To nie weryfikuje HMAC, ale potwierdzi że POST w ogóle dochodzi.

Opcja 3 — manual test curl

Wygeneruj lokalnie podpis i POSTnij do własnego endpointa:

SECRET="twoj_secret"
TS=$(date +%s)
BODY='{"event":"crawl.completed","crawl_id":"test","score":80}'
SIG=$(echo -n "${TS}.${BODY}" | openssl dgst -sha256 -hmac "${SECRET}" | cut -d' ' -f2)

curl -X POST http://localhost:3000/webhooks/selpio \
  -H "Content-Type: application/json" \
  -H "X-Selpio-Signature: sha256=${SIG}" \
  -H "X-Selpio-Timestamp: ${TS}" \
  -d "${BODY}"

Debug — co gdy nie dochodzi?

Check 1: curl -I https://twoj-url/webhooks/selpio z zewnątrz (nie z localhost). Czy dostajesz jakąkolwiek odpowiedź?

Check 2: Dashboard → Partner API → sekcja “Webhook status” pokazuje ostatnie 10 prób z kodem odpowiedzi. Szukaj wzorca (wszystkie 401? wszystkie timeout?).

Check 3: Email webhook_failed — sprawdź skrzynkę + spam. Zawiera exact error message.

Typowe błędy:

Rotacja sekretu

Jeśli podejrzewasz że secret wyciekł:

  1. Dashboard → Settings → “Regenerate webhook secret”
  2. Stary secret przestaje działać natychmiast
  3. Zaktualizuj secret w Twojej aplikacji przed następnym crawlem
  4. Crawle w locie użyją starego secreta → webhook failure → email

Rekomendacja: rotuj raz na kwartał jako proaktywny hygiene.

Następne kroki