# API reference

Every endpoint of the REST API with its parameters, an example call, an example answer and its errors. Generated from the OpenAPI document.

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

Base URL `https://api.citypicked.com/v1`. JSON in and out, times in ISO 8601 UTC, ids are UUIDs. Send your key as `Authorization: Bearer cp_live_xxxxxxxx` ([how](https://citypicked.com/en/developers/authentication)). Lists take `limit` (1 to 100, default 25) and `cursor`, and answer `next_cursor`, which is null on the last page.

The [OpenAPI document](https://api.citypicked.com/v1/openapi.json) has the full schemas. Errors and limits are on [Rate limits and errors](https://citypicked.com/en/developers/errors).

## Account

### GET /v1/me

The account, this key and whether it can send. Scope: `read`.

The connection test of every integration: 200 for every working key.

curl:

```bash
curl "https://api.citypicked.com/v1/me" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/me", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/me",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "account": {
    "id": "0b7c8f4e-5a1d-4c2e-9f3a-6d8e2b1c4a70",
    "name": "Sunnyside Salon",
    "plan": "pro",
    "language": "en",
    "time_zone": "America/New_York"
  },
  "key": {
    "name": "Booking system",
    "scopes": [
      "read",
      "send"
    ]
  },
  "can_send": true,
  "credits": {
    "balance": 180
  }
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `account` | object |  |
| `account.id` | uuid |  |
| `account.name` | string |  |
| `account.plan` | string or null |  |
| `account.language` | string |  |
| `account.time_zone` | string |  |
| `key` | object |  |
| `key.name` | string |  |
| `key.scopes` | array of (read | send) |  |
| `can_send` | boolean | False when the key lacks send, the plan has no API sending, or the account is behind the plan wall. |
| `credits` | object |  |
| `credits.balance` | integer | SMS credits (one SMS uses one or more by length and country; e-mail uses none). |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

## Locations

### GET /v1/locations

The locations this key can see. Scope: `read`.

Newest first by created_at. Pass next_cursor as cursor for the next page; null means there is none. Polling triggers dedupe by id and filter with since.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | string | no | 1 to 100, default 25. |
| `cursor` | query | string | no | next_cursor of the previous answer. |

curl:

```bash
curl "https://api.citypicked.com/v1/locations" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/locations", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/locations",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "data": [
    {
      "id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
      "name": "Sunnyside Salon Downtown",
      "country": "US",
      "review_link": "https://go.citypicked.com/sunnyside-salon",
      "created_at": "2026-11-08T09:30:00Z"
    }
  ],
  "next_cursor": null
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `data` | array of object |  |
| `data[].id` | uuid |  |
| `data[].name` | string |  |
| `data[].country` | string |  |
| `data[].review_link` | string or null |  |
| `data[].created_at` | string (date-time) |  |
| `next_cursor` | string or null | Pass as cursor for the next page; null when there is none. |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

### GET /v1/locations/{id}/review-link

A location's review page link, or its QR code as SVG or PNG. Scope: `read`.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |
| `format` | query | json | svg | png | no | json (default): the link. svg or png (1024 px): its QR code as an image. |

curl:

```bash
curl "https://api.citypicked.com/v1/locations/5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40/review-link" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/locations/5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40/review-link", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/locations/5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40/review-link",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
  "url": "https://go.citypicked.com/sunnyside-salon",
  "qr_svg_url": "https://api.citypicked.com/v1/locations/5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40/review-link?format=svg",
  "qr_png_url": "https://api.citypicked.com/v1/locations/5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40/review-link?format=png"
}
```

With `format=svg` or `format=png` the answer is the QR code itself (`image/svg+xml`, or `image/png` at 1024 px) instead of JSON.

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `location_id` | uuid |  |
| `url` | string |  |
| `qr_svg_url` | string |  |
| `qr_png_url` | string |  |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

## Requests

### GET /v1/requests

Review requests. Scope: `read`.

Newest first by created_at. Pass next_cursor as cursor for the next page; null means there is none. Polling triggers dedupe by id and filter with since. Triggers: status=rated (request rated), status=public_click (a click to a review site).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `location_id` | query | uuid | no | Only this location's requests. |
| `status` | query | scheduled | sent | delivered | failed | clicked | rated | public_click | feedback | canceled | no | Has reached: rated lists every request with a rating (also those that went on to public_click or feedback), public_click every one whose customer went on to a review site, clicked every opened one; scheduled and canceled are the current status. |
| `since` | query | string (date-time) | no | Inclusive. Without status: created at or after. With status: reached that status at or after (rated_at, ...). |
| `limit` | query | string | no | 1 to 100, default 25. |
| `cursor` | query | string | no | next_cursor of the previous answer. |

curl:

```bash
curl "https://api.citypicked.com/v1/requests?status=rated&limit=10" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/requests?status=rated&limit=10", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/requests",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
    params={
        "status": "rated",
        "limit": "10",
    },
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "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-08T09:30:00Z",
      "sent_at": "2026-11-08T09:30:04Z",
      "clicked_at": "2026-11-08T10:12:40Z",
      "rated_at": "2026-11-08T10:13:05Z"
    }
  ],
  "next_cursor": null
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `data` | array of object |  |
| `data[].id` | uuid |  |
| `data[].location_id` | uuid |  |
| `data[].channel` | sms | email | whatsapp | qr |  |
| `data[].source` | manual | csv | trigger | qr | integration | api |  |
| `data[].status` | scheduled | sent | delivered | failed | clicked | rated | public_click | feedback | canceled |  |
| `data[].contact` | object | Masked the way the app's sent list masks contacts: Anna M., an***@example.org, +49 170 ... 67. |
| `data[].contact.name` | string or null |  |
| `data[].contact.email` | string or null |  |
| `data[].contact.phone` | string or null |  |
| `data[].rating` | integer or null |  |
| `data[].created_at` | string (date-time) |  |
| `data[].sent_at` | string (date-time) or null |  |
| `data[].clicked_at` | string (date-time) or null |  |
| `data[].rated_at` | string (date-time) or null |  |
| `next_cursor` | string or null | Pass as cursor for the next page; null when there is none. |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

### POST /v1/requests

Send review requests. Scope: `send`.

The same way as the send form: same checks, same credits, same duplicate window. Up to 100 contacts per call, body up to 256 KB. Each contact gets an outcome; a call where no contact can be sent still answers 201, only a malformed body is 400. Idempotency-Key, per API key: the same key within 24 hours returns the first answer and sends nothing again; with a different body it is 409 idempotency_conflict. send_at plans the requests up to 30 days ahead inside the sending hours. A WhatsApp result carries whatsapp_url: the business sends it from its own phone. Needs the send scope and a plan with API sending; behind the plan wall 402.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | no | Any string up to 200 characters, per API key. The same key within 24 hours returns the first answer and sends nothing again; with another body 409 idempotency_conflict. |

**Body**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `location_id` | body | uuid | no | Optional when the key sees exactly one location. |
| `channel` | body | sms | email | whatsapp | yes |  |
| `send_at` | body | string (date-time) | no | When to send, at most 30 days ahead; default now. Moved into the location's sending hours like a send from the app. |
| `consent` | body | true | yes | You confirm that these customers agreed to be contacted. |
| `consent_method` | body | booking | in_person | form | written | no | Required for SMS to US and CA numbers: how these customers agreed. |
| `contacts` | body | array of object | yes |  |
| `contacts[].name` | body | string | no | The customer's name, used in the message. |
| `contacts[].email` | body | string | no | Needed for channel email. |
| `contacts[].phone` | body | string | no | Needed for sms and whatsapp. International format (+49 170 ...) or the location's national format. |

curl:

```bash
curl -X POST "https://api.citypicked.com/v1/requests" \
  -H "Authorization: Bearer cp_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: booking-1042" \
  -d '{
  "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
  "channel": "email",
  "consent": true,
  "contacts": [
    {
      "name": "Emma Clark",
      "email": "emma@example.com"
    }
  ]
}'
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/requests", {
  method: "POST",
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
    "Content-Type": "application/json",
    "Idempotency-Key": "booking-1042",
  },
  body: JSON.stringify({
    "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
    "channel": "email",
    "consent": true,
    "contacts": [
      {
        "name": "Emma Clark",
        "email": "emma@example.com"
      }
    ]
  }),
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.post(
    "https://api.citypicked.com/v1/requests",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx", "Idempotency-Key": "booking-1042"},
    json={
        "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
        "channel": "email",
        "consent": True,
        "contacts": [
            {
                "name": "Emma Clark",
                "email": "emma@example.com",
            },
        ],
    },
)
print(res.status_code, res.json())
```

Answer 201:

```json
{
  "results": [
    {
      "index": 0,
      "outcome": "created",
      "request_id": "c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20"
    }
  ],
  "created": 1
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `results` | array of object |  |
| `results[].index` | integer | Position in contacts. |
| `results[].outcome` | created | duplicate | invalid | opted_out | no_credits | consent_method_missing |  |
| `results[].request_id` | uuid or null |  |
| `results[].message` | string | Why, when the outcome is not created. |
| `results[].whatsapp_url` | string | WhatsApp only, created: the wa.me link the business opens on its own phone to send the message, as in the app. |
| `created` | integer |  |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 402: plan_wall: the account cannot send (trial over or plan without access)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 409: idempotency_conflict (the Idempotency-Key came with another body) or not_cancelable (the request already went out)
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

### POST /v1/requests/{id}/cancel

Cancel a request that is still planned. Scope: `send`.

200 with the request (status canceled, also when it was canceled before); 409 not_cancelable once it went out.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |

curl:

```bash
curl -X POST "https://api.citypicked.com/v1/requests/c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20/cancel" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/requests/c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20/cancel", {
  method: "POST",
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.post(
    "https://api.citypicked.com/v1/requests/c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20/cancel",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "id": "c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20",
  "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
  "channel": "email",
  "source": "api",
  "status": "canceled",
  "contact": {
    "name": "Emma C.",
    "email": "em***@example.com",
    "phone": null
  },
  "rating": null,
  "created_at": "2026-11-08T09:30:00Z",
  "sent_at": null,
  "clicked_at": null,
  "rated_at": null
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `id` | uuid |  |
| `location_id` | uuid |  |
| `channel` | sms | email | whatsapp | qr |  |
| `source` | manual | csv | trigger | qr | integration | api |  |
| `status` | scheduled | sent | delivered | failed | clicked | rated | public_click | feedback | canceled |  |
| `contact` | object | Masked the way the app's sent list masks contacts: Anna M., an***@example.org, +49 170 ... 67. |
| `contact.name` | string or null |  |
| `contact.email` | string or null |  |
| `contact.phone` | string or null |  |
| `rating` | integer or null |  |
| `created_at` | string (date-time) |  |
| `sent_at` | string (date-time) or null |  |
| `clicked_at` | string (date-time) or null |  |
| `rated_at` | string (date-time) or null |  |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 409: idempotency_conflict (the Idempotency-Key came with another body) or not_cancelable (the request already went out)
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

### GET /v1/requests/{id}

One review request. Scope: `read`.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |

curl:

```bash
curl "https://api.citypicked.com/v1/requests/c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/requests/c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/requests/c3f1a9e2-7b4d-4e8a-b6c2-1f9d3e5a7b20",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "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-08T09:30:00Z",
  "sent_at": "2026-11-08T09:30:04Z",
  "clicked_at": "2026-11-08T10:12:40Z",
  "rated_at": "2026-11-08T10:13:05Z"
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `id` | uuid |  |
| `location_id` | uuid |  |
| `channel` | sms | email | whatsapp | qr |  |
| `source` | manual | csv | trigger | qr | integration | api |  |
| `status` | scheduled | sent | delivered | failed | clicked | rated | public_click | feedback | canceled |  |
| `contact` | object | Masked the way the app's sent list masks contacts: Anna M., an***@example.org, +49 170 ... 67. |
| `contact.name` | string or null |  |
| `contact.email` | string or null |  |
| `contact.phone` | string or null |  |
| `rating` | integer or null |  |
| `created_at` | string (date-time) |  |
| `sent_at` | string (date-time) or null |  |
| `clicked_at` | string (date-time) or null |  |
| `rated_at` | string (date-time) or null |  |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

## Feedback

### GET /v1/feedback

Private feedback from the review page. Scope: `read`.

Newest first by created_at. Pass next_cursor as cursor for the next page; null means there is none. Polling triggers dedupe by id and filter with since. Trigger: new private feedback.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `location_id` | query | uuid | no | Only this location's feedback. |
| `status` | query | new | in_progress | resolved | no |  |
| `since` | query | string (date-time) | no | Only items created at or after this time (ISO 8601, inclusive). |
| `limit` | query | string | no | 1 to 100, default 25. |
| `cursor` | query | string | no | next_cursor of the previous answer. |

curl:

```bash
curl "https://api.citypicked.com/v1/feedback?status=new" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/feedback?status=new", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/feedback",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
    params={
        "status": "new",
    },
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "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-08T09:30:00Z"
    }
  ],
  "next_cursor": null
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `data` | array of object |  |
| `data[].id` | uuid |  |
| `data[].location_id` | uuid |  |
| `data[].request_id` | uuid or null |  |
| `data[].rating` | integer or null |  |
| `data[].text` | string or null |  |
| `data[].contact` | object |  |
| `data[].contact.name` | string or null |  |
| `data[].status` | new | in_progress | resolved |  |
| `data[].created_at` | string (date-time) |  |
| `next_cursor` | string or null | Pass as cursor for the next page; null when there is none. |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

## Stats

### GET /v1/stats

Numbers for a range of days. Scope: `read`.

One location or every location the key sees, in the account's time zone, as Analytics counts them.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `location_id` | query | uuid | no | Only this location; every location the key sees otherwise. |
| `from` | query | string (date) | no | First day (YYYY-MM-DD, in the account's time zone, inclusive), default 30 days ago; at most 366 days before to. |
| `to` | query | string (date) | no | Last day (inclusive), default today. |

curl:

```bash
curl "https://api.citypicked.com/v1/stats?from=2026-10-10&to=2026-11-08" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/stats?from=2026-10-10&to=2026-11-08", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/stats",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
    params={
        "from": "2026-10-10",
        "to": "2026-11-08",
    },
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "location_id": "5b1d0c7e-2f4a-4c3e-9a51-7d2e8f1b6a40",
  "from": "2026-10-10",
  "to": "2026-11-08",
  "requests_sent": 120,
  "clicked": 64,
  "rated": 41,
  "average_rating": 4.6,
  "public_clicks": 33,
  "feedback": 8
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `location_id` | uuid or null |  |
| `from` | string (date) |  |
| `to` | string (date) |  |
| `requests_sent` | integer |  |
| `clicked` | integer | Requests whose link was opened. |
| `rated` | integer | Requests that got stars. |
| `average_rating` | number or null | All stars of the days, as Analytics shows them (with QR code and direct link guests). |
| `public_clicks` | integer | Requests that went on to a review site. |
| `feedback` | integer | Private feedback received. |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

## Webhooks

### GET /v1/webhooks

The webhooks this key made. Scope: `read`.

Newest first by created_at. Pass next_cursor as cursor for the next page; null means there is none. Polling triggers dedupe by id and filter with since. Without secret: it is shown only when a webhook is made.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | string | no | 1 to 100, default 25. |
| `cursor` | query | string | no | next_cursor of the previous answer. |

curl:

```bash
curl "https://api.citypicked.com/v1/webhooks" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/webhooks", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/webhooks",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "data": [
    {
      "id": "7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30",
      "url": "https://example.com/hooks/citypicked",
      "events": [
        "feedback.created",
        "request.rated"
      ],
      "status": "active",
      "disabled_reason": null,
      "created_at": "2026-11-08T09:30:00Z",
      "last_success_at": "2026-11-08T09:30:04Z",
      "last_failure_at": null
    }
  ],
  "next_cursor": null
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `data` | array of object |  |
| `data[].id` | uuid |  |
| `data[].url` | string |  |
| `data[].events` | array of (feedback.created | request.rated | request.public_click | request.sent | request.failed) |  |
| `data[].status` | active | disabled |  |
| `data[].disabled_reason` | failing | gone | key_revoked or null | failing: deliveries kept failing; gone: the URL answered 410; key_revoked: the key that made it stopped. |
| `data[].created_at` | string (date-time) |  |
| `data[].last_success_at` | string (date-time) or null |  |
| `data[].last_failure_at` | string (date-time) or null |  |
| `next_cursor` | string or null | Pass as cursor for the next page; null when there is none. |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

### POST /v1/webhooks

Subscribe a URL to events (REST hook). Scope: `send`.

The URL gets each chosen event as a signed POST (see webhooks). https with a host name that resolves to public addresses only, checked now and before every delivery. The answer carries the signing secret, once. At most 50 webhooks per account. A webhook belongs to this key: it gets the events of the locations the key can see, and it stops when the key is revoked.

**Body**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `url` | body | string | yes | https, a host name (no IP address, no user:password), at most 2000 characters, resolving to public addresses only. |
| `events` | body | array of (feedback.created | request.rated | request.public_click | request.sent | request.failed) | yes | The events this URL gets. |

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())
```

Answer 201:

```json
{
  "id": "7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30",
  "url": "https://example.com/hooks/citypicked",
  "events": [
    "feedback.created",
    "request.rated"
  ],
  "status": "active",
  "disabled_reason": null,
  "created_at": "2026-11-08T09:30:00Z",
  "last_success_at": null,
  "last_failure_at": null,
  "secret": "cpwh_xxxxxxxx"
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `id` | uuid |  |
| `url` | string |  |
| `events` | array of (feedback.created | request.rated | request.public_click | request.sent | request.failed) |  |
| `status` | active | disabled |  |
| `disabled_reason` | failing | gone | key_revoked or null | failing: deliveries kept failing; gone: the URL answered 410; key_revoked: the key that made it stopped. |
| `created_at` | string (date-time) |  |
| `last_success_at` | string (date-time) or null |  |
| `last_failure_at` | string (date-time) or null |  |
| `secret` | string | cpwh_...: signs every delivery (Citypicked-Signature). Shown only in this answer. |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

### GET /v1/webhooks/{id}

One webhook of this key. Scope: `read`.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |

curl:

```bash
curl "https://api.citypicked.com/v1/webhooks/7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/webhooks/7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30", {
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/webhooks/7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "id": "7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30",
  "url": "https://example.com/hooks/citypicked",
  "events": [
    "feedback.created",
    "request.rated"
  ],
  "status": "active",
  "disabled_reason": null,
  "created_at": "2026-11-08T09:30:00Z",
  "last_success_at": "2026-11-08T09:30:04Z",
  "last_failure_at": null
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `id` | uuid |  |
| `url` | string |  |
| `events` | array of (feedback.created | request.rated | request.public_click | request.sent | request.failed) |  |
| `status` | active | disabled |  |
| `disabled_reason` | failing | gone | key_revoked or null | failing: deliveries kept failing; gone: the URL answered 410; key_revoked: the key that made it stopped. |
| `created_at` | string (date-time) |  |
| `last_success_at` | string (date-time) or null |  |
| `last_failure_at` | string (date-time) or null |  |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

### DELETE /v1/webhooks/{id}

Unsubscribe: remove a webhook of this key. Scope: `send`.

Nothing is delivered to it afterwards.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |

curl:

```bash
curl -X DELETE "https://api.citypicked.com/v1/webhooks/7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30" \
  -H "Authorization: Bearer cp_live_xxxxxxxx"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/webhooks/7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30", {
  method: "DELETE",
  headers: {
    Authorization: "Bearer cp_live_xxxxxxxx",
  },
});
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.delete(
    "https://api.citypicked.com/v1/webhooks/7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30",
    headers={"Authorization": "Bearer cp_live_xxxxxxxx"},
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "id": "7a5c3e1b-9d2f-4b6a-8c4e-3f1a5c7e9b30",
  "deleted": true
}
```

**Answer fields**

| Name | Type | Description |
| --- | --- | --- |
| `id` | uuid |  |
| `deleted` | true |  |

**Errors**

- 400: invalid_request: the query or body is not valid (field names the wrong one), or the webhook URL is not a public https address
- 401: unauthorized: no API key, or the key does not work (revoked, or its member can no longer use it)
- 403: forbidden (scope missing, plan without API sending) or account_closed
- 404: not_found: nothing with this id for this key
- 429: rate_limited: wait for Retry-After seconds
- 500: server_error: something went wrong on our side

## OpenAPI document

### GET /v1/openapi.json

This document. No key needed.

curl:

```bash
curl "https://api.citypicked.com/v1/openapi.json"
```

JavaScript:

```js
const res = await fetch("https://api.citypicked.com/v1/openapi.json");
const data = await res.json();
console.log(res.status, data);
```

Python:

```python
import requests

res = requests.get(
    "https://api.citypicked.com/v1/openapi.json",
)
print(res.status_code, res.json())
```

Answer 200:

```json
{
  "openapi": "3.1.0",
  "info": {
    "title": "Citypicked API",
    "version": "..."
  },
  "paths": "..."
}
```

The whole document is OpenAPI 3.1, including the events under `webhooks`. Import it into Postman, Insomnia or a code generator.
