Staging
Receive a webhook
On staging, Passback can send each comment to an address you run: Zapier, Make, n8n or your own service. The address has to verify that a request really comes from Passback and drop a delivery it has already seen. This page says what is sent and how to check it.
What is sent
Passback sends one POST per comment, with Content-Type: application/json. The body is a JSON object with these fields:
| Field | What it holds |
|---|---|
event |
What the delivery is: passback.comment. Dispatch on this. Left out of a ping. |
type |
A deprecated alias kept for one release: passback.mark for a comment, or passback.ping for the test sent when the address is saved. |
deliveryId |
A unique id for this delivery. It is the same on every retry. |
createdAt |
When the delivery was created, as an ISO 8601 time. |
siteId |
The site the comment belongs to. |
batch |
Only for a batch of comments: the batch this comment is part of. Left out otherwise. |
title |
A short title for the comment. |
body |
The text of the comment. |
comment |
The redacted comment with its page context. Left out of a ping. |
mark |
The same as comment: a deprecated alias kept for one release. |
origin |
The allowed origin Passback accepted the comment from. Left out for a ping and for the dashboard's test comment. |
A screenshot is a link in comment.screenshot.url (mark.screenshot.url is the deprecated alias), never bytes. Fetch the link if you want the image.
When you save the address, Passback sends one ping, so you can see that your receiver answers. Treat it like any other delivery: verify it and answer 2xx.
Headers
| Header | Value |
|---|---|
Passback-Delivery |
The delivery id, the same as deliveryId in the body. |
Passback-Timestamp |
When the request was signed, in unix seconds. |
Passback-Signature |
v1= followed by the hex HMAC-SHA256 of timestamp.deliveryId.body with your signing secret. |
Passback-Site |
The site id, outside the signature. Not covered by the signature, so trust the siteId field of the signed body. |
User-Agent |
Passback-Webhook/1 |
The signed string is the timestamp, a dot, the delivery id, a dot and the raw body, in that order.
Verify it
Do these steps for every request, and answer with an error if one fails:
- Reject a timestamp more than 5 minutes from your clock.
- Compute the HMAC-SHA256 over
timestamp.deliveryId.bodywith your signing secret. Use the raw body as it arrived, not a re-serialised one: a parser that reads and writes the JSON again can change the bytes and the signature will not match. - Compare your result with the signature in constant time, never with
==. - Parse the body and compare
siteIdandoriginwith the values you expect. Reject any other. - Drop a delivery id you have already handled. Retries and network repeats send the same id.
A Node example with node:crypto:
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = process.env.PASSBACK_WEBHOOK_SECRET;
const seen = new Set(); // use a store with an expiry in production
export function verify(headers, rawBody) {
const timestamp = headers['passback-timestamp'];
const deliveryId = headers['passback-delivery'];
const signature = headers['passback-signature'] ?? '';
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= 300)) return false;
const expected =
'v1=' +
createHmac('sha256', secret).update(`${timestamp}.${deliveryId}.${rawBody}`).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
if (seen.has(deliveryId)) return false;
seen.add(deliveryId);
return true;
}rawBody is the request body exactly as received, as a string or a buffer.
The same check in Python:
import hashlib
import hmac
import os
import time
SECRET = os.environ["PASSBACK_WEBHOOK_SECRET"].encode()
def verify(headers: dict, raw_body: bytes) -> bool:
timestamp = headers.get("passback-timestamp", "")
delivery_id = headers.get("passback-delivery", "")
signature = headers.get("passback-signature", "")
try:
age = abs(time.time() - float(timestamp))
except ValueError:
return False
if not age <= 300:
return False
message = f"{timestamp}.{delivery_id}.".encode() + raw_body
expected = "v1=" + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)After it returns true, parse the body, compare siteId and origin, and drop a repeated delivery id as in the steps above.
What the signature proves
The message came from Passback and was not changed on the way. origin says which allowed origin Passback accepted the comment from.
It does not prove the visitor was on the real site. Any page can send an Origin header to the ingest address from a script outside a browser, and Passback checks only that the origin is on the site's allowed list.
Retries
If your address does not answer 2xx, Passback tries again, up to three attempts in all: the second after 500 ms and the third after 1.5 s. It retries on a network error, on 408, on 429 and on any 5xx. Any other answer ends the delivery.
Every attempt carries the same delivery id, so a receiver that drops repeats handles a comment once. Answer 2xx quickly and do the slow work after you have answered.
Where the address must be
- It uses https.
- It has a host name: no IP address and no port.
- It is reachable from the public internet.
The host is checked on every delivery, not only when you save it.
The secret
The signing secret is shown once, when you connect. Copy it then. Connecting again makes a new secret, and the old one stops working.
Connect from the dashboard: open your site, choose Relay, then Webhook. Or connect from the command line:
npx passback connect webhook --url <address>