---
title: REST API
slug: HG88-personizely-public-api
description: How the Personizely REST API works: base URL, API key and OAuth 2.1 authentication, scopes, websiteId for multi-site accounts, errors and the endpoint index.
docTags: 
createdAt: 2026-10-16T19:49:53.313Z
---

The Personizely REST API lets your own code, scripts and AI tools read your campaigns, widgets, goals and targets, pull A/B test results with statistics, and start or stop campaigns and widgets. It's a JSON API over HTTPS, authenticated with an API key or an OAuth 2.1 access token. This page covers what every request has in common; each endpoint has its own reference page, linked in the [endpoint index](docId\:HG88NTwtq60d17m6jFErF).

## Base URL

```javascript
https://api.personizely.net/v1
```

All endpoints are relative to this URL. Requests and responses use JSON. An interactive OpenAPI reference is available at `https://api.personizely.net/docs`, and the machine-readable spec at `https://api.personizely.net/docs.json`, which you can import into Postman or a client generator.

## Authentication

Send a bearer token in the `Authorization` header of every request:

```bash
curl https://api.personizely.net/v1/websites \
  -H "Authorization: Bearer ply_live_XXXXXXXXXXXXXXXX"
```

The API accepts two kinds of token:

| Token                      | Looks like                | Best for                                                                                         | Access                                                                                |
| -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| **API key**                | Starts with `ply_live_`   | Your own scripts, internal dashboards, scheduled jobs, AI tools that accept a custom header      | Read data, and start and stop campaigns and widgets, for every website in the account |
| **OAuth 2.1 access token** | A JWT (starts with `eyJ`) | Apps that act on behalf of other Personizely users, and AI assistants such as Claude and ChatGPT | The scopes the user approved, for the account they chose                              |

Both kinds of token give access to one Personizely account and all its websites.

### API keys

An account owner or admin creates API keys under **Account → API**. The key is shown once, when it's created; store it in a secret manager. Keys can have an expiry date and can be revoked at any time. See [API keys and connected apps](docId\:T7lxFvTpjSqPdNkC67kun).

:::hint{type="warning"}
API keys can read all data and start and stop campaigns and widgets. Don't put them in browser code, mobile apps or public repositories. The storefront [JavaScript API (window.ply)](docId\:d64TegyOiwNA2GFG1eXPm) doesn't need one.
:::

### OAuth 2.1

Use OAuth when your app is used by Personizely customers other than you, so that each user signs in and approves access instead of pasting a key. Personizely runs a standard OAuth 2.1 authorization server:

|                             | Value                                                                   |
| --------------------------- | ----------------------------------------------------------------------- |
| Discovery metadata          | `https://app.personizely.net/.well-known/oauth-authorization-server`    |
| Authorization endpoint      | `https://app.personizely.net/oauth/authorize`                           |
| Token endpoint              | `https://app.personizely.net/oauth/token`                               |
| Dynamic client registration | `https://app.personizely.net/oauth/register` (RFC 7591)                 |
| Grant types                 | `authorization_code`, `refresh_token`                                   |
| PKCE                        | Required, `S256` only                                                   |
| Client authentication       | `none` (public clients), `client_secret_basic`, `client_secret_post`    |
| Scopes                      | `read` (view data), `write` (start and stop campaigns and widgets only) |
| Access token lifetime       | 1 hour                                                                  |
| Refresh token lifetime      | 30 days                                                                 |

**Registering a client.** Register your app by sending its metadata to the registration endpoint. There's no manual approval step.

```bash
curl -X POST https://app.personizely.net/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Acme Reporting",
    "redirect_uris": ["https://acme.example/oauth/callback"],
    "scope": "read write",
    "token_endpoint_auth_method": "client_secret_basic"
  }'
```

| Field                        | Rules                                                                                                                                                     | Default       |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `client_name`                | 1–255 characters; shown to users on the consent screen                                                                                                    | `Unknown App` |
| `redirect_uris`              | Required, at least one. `https` URLs; `http` only for `localhost`, `127.0.0.1` or `[::1]`; custom schemes for native apps.                                | –             |
| `grant_types`                | `authorization_code`, `refresh_token`                                                                                                                     | Both          |
| `token_endpoint_auth_method` | `none` creates a public client with no secret. `client_secret_basic` or `client_secret_post` creates a confidential client and returns a `client_secret`. | `none`        |
| `scope`                      | Space-separated: `read`, `write`                                                                                                                          | `read`        |

The response contains your `client_id`, and `client_secret` for confidential clients. The secret is returned only once.

**Authorizing.** Send the user to the authorization endpoint with your `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state` and a PKCE `code_challenge` (`code_challenge_method=S256`). The user signs in to Personizely, picks which account to connect if they belong to several, and approves. Only an **owner or admin** of that account can approve. You then exchange the code at the token endpoint for an access token and a refresh token.

**Scopes.** `read` covers every `GET` endpoint. `write` is needed to start and stop campaigns and widgets. If you don't request a scope, you get `read`.

**Refreshing.** Access tokens expire after an hour. Use the refresh token to get a new pair; refresh tokens last 30 days. Users can disconnect your app from **Account → API**, which revokes its tokens.

## Choosing a website

A Personizely account can have [Multiple websites](docId\:xTmxPcoGPGtWkYgcZd69E), each with its own campaigns, widgets, goals and targets. Every endpoint except `GET /websites` works on one website, chosen with the `websiteId` query parameter:

```bash
curl "https://api.personizely.net/v1/campaigns?websiteId=4821&active=true" \
  -H "Authorization: Bearer $PLY_API_KEY"
```

- If the account has **exactly one website**, you can leave `websiteId` out.
- If it has **more than one**, `websiteId` is required. Without it, you get a `400` telling you to pass it.
- Get the IDs from `GET /websites`, which lists every website in the account with its `id` and `domain`, sorted by domain:

```json
[
  { "id": 4821, "domain": "shop.example.com" },
  { "id": 5310, "domain": "wholesale.example.com" }
]
```

:::hint{type="info"}
Always send `websiteId` in integrations you share with others. An account with one website today may add a second one later.
:::

## Pagination

List endpoints use `offset` and `limit` and return a plain JSON array.

| Endpoint                         | `limit` range | Default `limit` |
| -------------------------------- | ------------- | --------------- |
| `GET /campaigns`, `GET /widgets` | 1–500         | 100             |
| `GET /goals`, `GET /targets`     | 1–1000        | 500             |

`offset` starts at 0. Responses don't include a total count: keep requesting the next page until you get fewer items than your `limit`. Campaigns are sorted newest first.

## Results and filters

The results endpoints return the same numbers as the campaign and widget reports in the dashboard: reach, views, conversions, revenue and, for experiments, significance and improvement against control. See [Statistics methodology](docId\:Jl7imbqyTMR8uy4RyPYQL) for how they're calculated.

Narrow results with `filters`, passed as a query-string map: `filters[field]=value`, or `filters[field][]=a&filters[field][]=b` for lists.

| Filter                                                           | Type                 | Notes                                                                                                                     |
| ---------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `dateFrom`, `dateTo`                                             | date                 | `2026-03-15` or `2026-03-15T10:30:00Z`. Without them, results run from when the campaign or widget was created until now. |
| `device`, `browser`, `os`                                        | list                 |                                                                                                                           |
| `country`, `region`, `city`                                      | list                 |                                                                                                                           |
| `utmSource`, `utmMedium`, `utmCampaign`, `utmContent`, `utmTerm` | string               |                                                                                                                           |
| `referrer`, `firstVisitPage`                                     | string               |                                                                                                                           |
| `visitorType`                                                    | `new` or `returning` |                                                                                                                           |
| `goalValue`, `zScore`                                            | number               | Results endpoints only                                                                                                    |
| `goalTags`                                                       | list                 | Shopify order tags; counts only orders carrying at least one of them                                                      |

Unknown filter names are rejected with a `400`.

```bash
curl -G "https://api.personizely.net/v1/campaigns/3107/results" \
  -H "Authorization: Bearer $PLY_API_KEY" \
  --data-urlencode "websiteId=4821" \
  --data-urlencode "compareMetric=valuePerVisitor" \
  --data-urlencode "filters[dateFrom]=2026-09-01" \
  --data-urlencode "filters[device][]=mobile"
```

## Errors

Errors use standard HTTP status codes. The JSON body has the status `code` and a human-readable `message`. Authentication errors (`401`) return only the `message`.

```json
{ "code": 404, "message": "<what went wrong>" }
```

| Status | Meaning           | Common causes                                                                                                                                                                                                 |
| ------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Bad request       | `websiteId` missing on a multi-website account; a parameter out of range or not in its allowed list; an unknown filter; `conversionRate` or `completionRate` used on a widget experiment with a control group |
| `401`  | Not authenticated | Missing, invalid, revoked or expired API key or access token                                                                                                                                                  |
| `403`  | Not allowed       | The token only has the `read` scope and you called a start or stop endpoint                                                                                                                                   |
| `404`  | Not found         | The website, campaign, widget, goal or target doesn't exist or belongs to another account                                                                                                                     |
| `5xx`  | Server error      | Retry with backoff; contact support if it persists                                                                                                                                                            |

```json
{ "message": "API key has expired" }
```

The `401` messages are `Invalid API key`, `API key has been revoked`, `API key has expired`, `Invalid OAuth access token`, `Access token not found`, `Access token has been revoked` and `Access token has expired`. With OAuth, an expired access token means it's time to use your refresh token.

## Endpoint index

| Method | Path                                               | What it does                                                                                                                                                                   | Scope |
| ------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| GET    | `/websites`                                        | [List the websites in the account](https://docs.personizely.net/list-the-websites-in-the-authenticated-account)                                                                | read  |
| GET    | `/campaigns`                                       | [List campaigns](https://docs.personizely.net/list-campaigns), filtered by `active` and `type`                                                                                 | read  |
| GET    | `/campaigns/{id}`                                  | [Get a campaign](https://docs.personizely.net/get-a-single-campaign) with its variations                                                                                       | read  |
| POST   | `/campaigns/{id}/start`                            | [Start a campaign](https://docs.personizely.net/start-a-campaign-sets-activetrue)                                                                                              | write |
| POST   | `/campaigns/{id}/stop`                             | [Stop a campaign](https://docs.personizely.net/stop-a-campaign-sets-activefalse)                                                                                               | write |
| GET    | `/campaigns/{id}/results`                          | [Get campaign results](https://docs.personizely.net/get-campaign-analytics-results), by goal, with optional breakdown (`device`, `country`, `channel`, `referrer`) and filters | read  |
| GET    | `/widgets`                                         | [List widgets](https://docs.personizely.net/list-widgets), filtered by `active` and `type` (`popup`, `callout`, `bar`, `embedded`)                                             | read  |
| GET    | `/widgets/{id}`                                    | [Get a widget](https://docs.personizely.net/get-a-single-widget-includes-variations-and-experiments) with its variations and experiments                                       | read  |
| POST   | `/widgets/{id}/start`                              | [Start a widget](https://docs.personizely.net/start-a-widget-sets-activetrue)                                                                                                  | write |
| POST   | `/widgets/{id}/stop`                               | [Stop a widget](https://docs.personizely.net/stop-a-widget-sets-activefalse)                                                                                                   | write |
| GET    | `/widgets/{id}/results`                            | [Get widget results](https://docs.personizely.net/get-widget-analytics-results)                                                                                                | read  |
| GET    | `/widgets/{id}/experiments/{experimentId}/results` | [Get per-variation results for a widget experiment](https://docs.personizely.net/get-per-variation-results-for-a-widget-experiment)                                            | read  |
| GET    | `/goals`                                           | [List goals](https://docs.personizely.net/list-goals)                                                                                                                          | read  |
| GET    | `/goals/{id}`                                      | [Get a goal](https://docs.personizely.net/get-a-single-goal)                                                                                                                   | read  |
| GET    | `/targets`                                         | [List targets](https://docs.personizely.net/list-targets) (names and dates, not rules)                                                                                         | read  |
| GET    | `/targets/{id}`                                    | [Get a target](https://docs.personizely.net/get-a-single-target)                                                                                                               | read  |

The API can't create or edit campaigns, widgets, goals or targets. Use the dashboard for that.

## Behavior details and limitations

- **Start and stop have the same effect as in the dashboard.** The change goes live on your site and is recorded as an update to the campaign or widget. Starting something that's already running, or stopping something already stopped, returns it unchanged.
- **Results default to the primary goal and primary metric.** `GET /campaigns/{id}/results` uses the campaign's primary goal unless you pass `goal`, and its primary metric unless you pass `compareMetric` (`goalConversionRate`, `valuePerVisitor`, `profitPerVisitor` or `aov`).
- **Significance is only calculated without a breakdown.** When you pass `dimension`, you get per-segment numbers but no significance, as in the dashboard. Personalizations return raw numbers without statistics.
- **Widget experiment statistics use fixed settings:** frequentist, 95% significance, 80% power and a 20% minimum detectable effect.
- **Conversion rate counts visitors.** Each result row includes both `goalsReached` (total goal completions) and `uniqueGoalsReached` (visitors who completed the goal). Conversion rate is based on visitors.
- **Results refresh every few minutes.** A conversion that just happened may take a few minutes to appear. There's no need to poll more often than that.
- **Targets don't expose their rules.** The target endpoints return ID, name and dates, so you can map `targetId` values to names.
- **Results can't be filtered by product.** Order tags (`goalTags`) are supported; product filters available in the dashboard aren't.

## Examples

**Daily Slack digest.** A scheduled job calls `GET /campaigns?active=true`, then `GET /campaigns/{id}/results` for each one, and posts each variation's improvement and significance to a channel.

**Stop a test from your release pipeline.** Before deploying a redesign that would invalidate a running test, call `POST /campaigns/{id}/stop` with a key stored in your CI secrets.

**Ask an AI assistant about your tests.** Connect Claude, ChatGPT or another MCP client to the [MCP server](docId\:usaccUEvfdBFWxe_Z1uuN), which uses this API with OAuth.

## Related

- [API keys and connected apps](docId\:T7lxFvTpjSqPdNkC67kun)
- [MCP server](docId\:usaccUEvfdBFWxe_Z1uuN)
- [JavaScript API (window.ply)](docId\:d64TegyOiwNA2GFG1eXPm)
- [Statistics methodology](docId\:Jl7imbqyTMR8uy4RyPYQL)
- [Multiple websites](docId\:xTmxPcoGPGtWkYgcZd69E)
