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
- Dashboard → Partner API → Settings
- Wpisz Webhook URL (musi być HTTPS, publicznie dostępny, nie może wskazywać na private IP)
- Zapisz — system wygeneruje webhook secret
- Zapisz secret w menedżerze sekretów Twojej aplikacji (pokazujemy tylko raz)
Wymagania URL:
- Protokół
https://(niehttp://) - Publicznie dostępny z internetu (nie localhost, nie 10.x.x.x, nie 192.168.x.x)
- Zwraca
2xxw ≤10 sekund na POST
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óba | Czas od pierwszej próby |
|---|---|
| 1 | 0s (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
- Otwórz https://webhook.site — dostaniesz unikalny URL
- Wklej URL w dashboard
- Odpal testowy crawl
- 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:
401 Invalid signature→ za każdym razem gdy modyfikujesz body PRZED weryfikacją (np. JSON parse → stringify) podpis przestaje pasować. Używaj raw body.Timeout→ Twój endpoint odpowiada wolniej niż 10s. Przenieś logikę do kolejki.ECONNREFUSED→ firewall / DNS / port blokuje nasze requesty. Wypuść54.*lub whitelist.
Rotacja sekretu
Jeśli podejrzewasz że secret wyciekł:
- Dashboard → Settings → “Regenerate webhook secret”
- Stary secret przestaje działać natychmiast
- Zaktualizuj secret w Twojej aplikacji przed następnym crawlem
- Crawle w locie użyją starego secreta → webhook failure → email
Rekomendacja: rotuj raz na kwartał jako proaktywny hygiene.
Następne kroki
- Partner API — Quickstart
- Dashboard: https://selpio.com/dashboard/partner
- Kontakt: support@selpio.com