Skip to content

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:

JSON
{
  "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-Signaturet=<unix seconds>,v1=<signature> (below)
Pricana-Event-Idthe event's id: the same on every attempt
Pricana-Event-Typechanges, or ping for a test
Pricana-Delivery-Attempt1 for the first attempt, then 2, 3 …
User-AgentPricanaWebhooks/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.

  1. Take t and every v1 from the header (after a secret was replaced, there can be two v1).
  2. Compute HMAC-SHA256(secret, t + "." + body) over the body exactly as received, before parsing it.
  3. Accept if it equals one of the v1 values (compare in constant time) and t is at most 5 minutes old.

Python

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 "", 204

Node.js

JavaScript
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
<?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

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 event id is 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.