The URL must be https with a host name that resolves to public addresses only
Up to 50 webhooks per account
A webhook belongs to the key that made it, gets the events of the locations that key can see, and stops when the key is revoked
Remove one with DELETE /v1/webhooks/{id}
Events
Event
When
feedback.created
New private feedback from the review page
request.rated
A request's customer gave stars, the first time
request.public_click
A request's customer went on to a review site, the first time
request.sent
A request went out
request.failed
A request could not be sent or was not delivered
Each event happens once per request or feedback and type. data is the same object GET /v1/requests or GET /v1/feedback answers with, as it was when the event happened, contacts masked.
Every delivery is a POST with Content-Type: application/json and three headers. Citypicked-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> with the webhook's secret. Citypicked-Event-Id and Citypicked-Event-Type repeat the body's id and type.
1Read the raw body before parsing it
2Compute the HMAC of t, a dot and the raw body with your secret
3Compare it with v1 in constant time
4Refuse a t more than 300 seconds away from your clock
Node.js
import crypto from "node:crypto";
// rawBody is the request body exactly as it arrived, before JSON.parse.
export function verifyCitypicked(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Express
import express from "express";
const app = express();
app.post("/hooks/citypicked", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyCitypicked(req.get("Citypicked-Signature") ?? "", rawBody, process.env.CITYPICKED_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(rawBody);
// Dedupe by event.id: the same event can arrive twice.
res.sendStatus(200);
});
Python
import hashlib
import hmac
import time
def verify_citypicked(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Secrets start with cpwh_. The examples show a placeholder.
Your secret
secret = cpwh_xxxxxxxx
Retries
Answer with any 2xx within 10 seconds. Anything else, a timeout or a redirect counts as a failure and is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours. That makes 7 tries in about 18 hours.
The same event can arrive more than once and out of order, so dedupe by id
After 7 failed tries in a row the webhook is turned off with disabled_reasonfailing
An answer of 410 Gone turns it off at once with disabled_reasongone
The account owner sees a turned-off webhook in the app and can turn it on again, or your tool removes it and subscribes again
Polling with GET /v1/requests?since= and GET /v1/feedback?since= keeps working next to webhooks, as a fallback.