# Webhooks

Get each rating, click and piece of private feedback at your own URL within seconds, signed so you know it came from Citypicked.

Source: https://citypicked.com/en/developers/webhooks

## Subscribe

A key with the `send` scope subscribes a URL with `POST /v1/webhooks` ([reference](https://citypicked.com/en/developers/api#createWebhook)). The answer carries the signing secret, once. Keep it next to your key.

curl:

```bash
curl -X POST "https://api.citypicked.com/v1/webhooks" \
  -H "Authorization: Bearer cp_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://example.com/hooks/citypicked",
  "events": [
    "feedback.created",
    "request.rated"
  ]
}'
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "url": "https://example.com/hooks/citypicked",
    "events": [
      "feedback.created",
      "request.rated"
    ]
  }),
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.post(
    "https://api.citypicked.com/v1/webhooks",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
    json={
        "url": "https://example.com/hooks/citypicked",
        "events": [
            "feedback.created",
            "request.rated",
        ],
    },
)
print(res.status_code, res.json())
```

- 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.

### feedback.created

```json
{
  "id": "9e2a4c6b-1d3f-4a5e-8b7c-2e4f6a8c0d10",
  "type": "feedback.created",
  "created_at": "2026-11-08T10:13:05Z",
  "data": {
    "id": "9e2a4c6b-1d3f-4a5e-8b7c-2e4f6a8c0d10",
    "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
    "request_id": "c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20",
    "rating": 2,
    "text": "We waited 20 minutes past our booking time.",
    "contact": {
      "name": "Emma C."
    },
    "status": "new",
    "created_at": "2026-11-08T10:13:05Z"
  }
}
```

### request.rated

```json
{
  "id": "2d4f6a8c-0e1b-4c3d-a5e7-9b1d3f5a7c60",
  "type": "request.rated",
  "created_at": "2026-11-08T10:13:05Z",
  "data": {
    "id": "c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20",
    "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
    "channel": "email",
    "source": "api",
    "status": "rated",
    "contact": {
      "name": "Emma C.",
      "email": "em***@example.com",
      "phone": null
    },
    "rating": 5,
    "created_at": "2026-11-08T10:13:05Z",
    "sent_at": "2026-11-08T09:30:04Z",
    "clicked_at": "2026-11-08T10:12:40Z",
    "rated_at": "2026-11-08T10:13:05Z"
  }
}
```

### request.public_click

```json
{
  "id": "2d4f6a8c-0e1b-4c3d-a5e7-9b1d3f5a7c60",
  "type": "request.public_click",
  "created_at": "2026-11-08T10:13:05Z",
  "data": {
    "id": "c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20",
    "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
    "channel": "email",
    "source": "api",
    "status": "public_click",
    "contact": {
      "name": "Emma C.",
      "email": "em***@example.com",
      "phone": null
    },
    "rating": 5,
    "created_at": "2026-11-08T10:13:05Z",
    "sent_at": "2026-11-08T09:30:04Z",
    "clicked_at": "2026-11-08T10:12:40Z",
    "rated_at": "2026-11-08T10:13:05Z"
  }
}
```

### request.sent

```json
{
  "id": "2d4f6a8c-0e1b-4c3d-a5e7-9b1d3f5a7c60",
  "type": "request.sent",
  "created_at": "2026-11-08T10:13:05Z",
  "data": {
    "id": "c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20",
    "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
    "channel": "email",
    "source": "api",
    "status": "sent",
    "contact": {
      "name": "Emma C.",
      "email": "em***@example.com",
      "phone": null
    },
    "rating": null,
    "created_at": "2026-11-08T10:13:05Z",
    "sent_at": "2026-11-08T09:30:04Z",
    "clicked_at": null,
    "rated_at": null
  }
}
```

### request.failed

```json
{
  "id": "2d4f6a8c-0e1b-4c3d-a5e7-9b1d3f5a7c60",
  "type": "request.failed",
  "created_at": "2026-11-08T10:13:05Z",
  "data": {
    "id": "c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20",
    "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
    "channel": "email",
    "source": "api",
    "status": "failed",
    "contact": {
      "name": "Emma C.",
      "email": "em***@example.com",
      "phone": null
    },
    "rating": null,
    "created_at": "2026-11-08T10:13:05Z",
    "sent_at": null,
    "clicked_at": null,
    "rated_at": null
  }
}
```

## Check the signature

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`.

1. Read the raw body before parsing it
2. Compute the HMAC of `t`, a dot and the raw body with your secret
3. Compare it with `v1` in constant time
4. Refuse a `t` more than 300 seconds away from your clock

Node.js:

```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:

```js
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:

```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.

```
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_reason` `failing`
- An answer of 410 Gone turns it off at once with `disabled_reason` `gone`
- 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.
