Webhooks
Get the change feed pushed to your server as it happens, signed, in order, with retries. Includes code to check the signature.
A webhook sends the changes of your datasets to a URL on your server as they happen: the same changes, in the same order and with the same objects as the change feed, without polling. Webhooks are in the Pro plan and above (or as an add-on).
Set up an endpoint
In the portal, open API → Webhooks and add your endpoint's URL (https://…). You can limit it to some of your
datasets and to some kinds of change (say, only REMOVED). Pricana shows the endpoint's signing secret
(prc_whsec_…) once: store it next to your code, like an API key.
Send test posts a ping event at once and shows your server's answer. A new endpoint receives the changes from the
moment it was set up.
What your endpoint receives
A POST with a JSON body, up to 100 changes at a time:
{
"id": "evt_5f1c0b2a9d7e4c3b8a6f0e1d",
"type": "changes",
"createdAt": "2026-10-07T09:12:31Z",
"changes": [
{
"id": 812,
"kind": "CHANGED",
"observedAt": "2026-10-06T21:03:00Z",
"price": 1399.0,
"previousPrice": 1499.0,
"changes": { "price": [1499.0, 1399.0] },
"item": { "id": 77, "title": "Lenovo ThinkPad X1 Carbon Gen 12", "url": "https://…", "currency": "EUR" }
}
],
"cursor": 812
}and these headers:
| Header | |
|---|---|
Pricana-Signature | t=<unix seconds>,v1=<signature> (below) |
Pricana-Event-Id | the event's id: the same on every attempt |
Pricana-Event-Type | changes, or ping for a test |
Pricana-Delivery-Attempt | 1 for the first attempt, then 2, 3 … |
User-Agent | PricanaWebhooks/1.0 (+https://pricana.io/docs/webhooks) |
A ping has no changes and cursor is null. cursor is the id of the last change in the event: if you ever need to
catch up by polling, /changes?after=<cursor> continues from there.
Answer quickly
Answer with any 2xx status within 10 seconds. Do the work after answering (put the event in a queue, for example):
a slow answer counts as a failure. The body of your answer doesn't matter; Pricana keeps the first 300 characters in the
delivery log to help you debug.
Check the signature
Anyone can send a request to your URL. Before you use a body, check that it comes from Pricana: the header
Pricana-Signature holds a timestamp t and the signature v1, an HMAC-SHA256 of t, a dot and the raw body,
keyed with your endpoint's secret (the whole prc_whsec_… string), in hex.
- Take
tand everyv1from the header (after a secret was replaced, there can be twov1). - Compute
HMAC-SHA256(secret, t + "." + body)over the body exactly as received, before parsing it. - Accept if it equals one of the
v1values (compare in constant time) andtis at most 5 minutes old.
Python
import hashlib, hmac, os, time
def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
signatures = [v.encode() for k, v in parts if k == "v1"]
# isascii: "²".isdigit() is true, int("²") fails
if t is None or not (t.isascii() and t.isdigit()) or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest().encode()
# bytes, not str: compare_digest refuses str with other than ASCII characters
return any(hmac.compare_digest(expected, s) for s in signatures)
# Flask
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["PRICANA_WEBHOOK_SECRET"]
@app.post("/pricana")
def pricana():
if not verify(request.get_data(), request.headers.get("Pricana-Signature", ""), SECRET):
abort(400)
event = request.get_json()
for change in event["changes"]:
... # queue it; skip change ids you already have
return "", 204Node.js
import crypto from 'node:crypto'
import express from 'express'
export function verify(body, header, secret, tolerance = 300) {
const parts = header.split(',').map((p) => p.split('='))
const t = parts.find(([k]) => k === 't')?.[1]
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
if (!t || !/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(body).digest('hex')
const want = Buffer.from(expected)
// the same number of bytes, not just characters: timingSafeEqual throws otherwise
return signatures.some((s) => {
const got = Buffer.from(s)
return got.length === want.length && crypto.timingSafeEqual(got, want)
})
}
const app = express()
// the raw body: parsing JSON first would change the bytes that were signed
app.post('/pricana', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('Pricana-Signature') ?? '', process.env.PRICANA_WEBHOOK_SECRET)) {
return res.sendStatus(400)
}
const event = JSON.parse(req.body.toString('utf8'))
for (const change of event.changes) {
// queue it; skip change ids you already have
}
res.sendStatus(204)
})
app.listen(3000)PHP
<?php
function pricana_verify(string $body, string $header, string $secret, int $tolerance = 300): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') $t = $v;
if ($k === 'v1') $signatures[] = $v;
}
if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
foreach ($signatures as $s) {
if (hash_equals($expected, $s)) return true;
}
return false;
}
$body = file_get_contents('php://input');
if (!pricana_verify($body, $_SERVER['HTTP_PRICANA_SIGNATURE'] ?? '', getenv('PRICANA_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
foreach ($event['changes'] as $change) {
// queue it; skip change ids you already have
}
http_response_code(204);Java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class PricanaSignature {
/** body: the request body exactly as received, as UTF-8 text. */
public static boolean verify(String body, String header, String secret) throws Exception {
String t = null;
List<String> signatures = new ArrayList<>();
for (String part : header.split(",")) {
String[] kv = part.split("=", 2);
if (kv.length == 2 && kv[0].equals("t")) t = kv[1];
if (kv.length == 2 && kv[0].equals("v1")) signatures.add(kv[1]);
}
if (t == null || !t.matches("\\d+") || Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > 300) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = HexFormat.of()
.formatHex(mac.doFinal((t + "." + body).getBytes(StandardCharsets.UTF_8)))
.getBytes(StandardCharsets.UTF_8);
for (String s : signatures) {
if (MessageDigest.isEqual(expected, s.getBytes(StandardCharsets.UTF_8))) return true;
}
return false;
}
}Order, retries and repeats
- In order, one at a time: the next event is sent only after your endpoint accepted the one before. Within an event, changes are ordered by id.
- Retries: if your endpoint doesn't answer
2xx, Pricana tries the same event again after 1, 5, 15 and 30 minutes, then after 1, 2 and every 4 hours. Later changes wait behind it, so nothing arrives out of order. - Turned off after 24 hours: if an event still fails after 24 hours and at least 12 attempts, Pricana turns the endpoint off and emails your team. (Time while webhooks were paused or the endpoint was off doesn't count: the attempts go on after it.) Turn it on again in the portal once your server is fixed: delivery goes on with the event that failed (with a new event id), followed by everything since. You can also choose to skip what waited and start from now.
- Turned off by you: an event that waits stays as it is; turned on again, it is sent first, at once.
- What you may still see: every attempt sends the event's changes as your data shows them then. If a website objected or a dataset left your plan meanwhile, its changes are left out; an event with nothing left counts as delivered without being sent.
- Repeats: an event may arrive more than once (when your answer got lost, or when someone sends it again from the
log). Every change has its own
id: skip ids you already processed, and the eventidis the same on every attempt.
Redirects are not followed: answer at the URL you registered. Pricana sends webhooks only to public internet addresses.
The delivery log
API → Webhooks → Log lists every event of the last 30 days with its attempts, your server's status code and the start of its answer, or what went wrong (timeout, refused connection, TLS); its body is kept for 7 days. Send again repeats an event with the same id, with what your data shows now; for an event that is still waiting for its next attempt, Try now (also on the endpoint) tries at once, at most once a minute. Tests, repeats and tries: up to 30 an hour.
Replace the secret
Roll secret makes a new secret. For a while (24 hours by default, up to 7 days) Pricana signs with both the new and
the old one, so the header carries two v1 values: put the new secret into your server, then the old one stops
mattering. Choose no overlap if the old secret may have leaked.
When nothing arrives
Webhooks pause while your plan doesn't include them and while access is locked because of a payment overdue for 30 days; they go on where they were. The portal shows why an endpoint is paused. An endpoint limited to one dataset is turned off when that dataset is deleted (choose datasets, then turn it on). Changes go out about half a minute after Pricana found them.