> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.perkss.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.perkss.io/_mcp/server.

# Getting started

The Perkss Partner API lives under `/api/v1`. All requests use HTTPS, JSON bodies (unless noted), and a Bearer token obtained with your API key.

```text
Base URL: https://api.perkss.io/api/v1
```

Every API key belongs to exactly one company. Everything you read or write is scoped to that company.

## Authentication

### Create an API key

In the Perkss CMS, go to **API Keys** in your "Admin" panel and create a `client_secret` (prefixed `perkss_sk_live_`).**The secret is shown exactly once** — store it in a secret manager. Only a display prefix is visible afterwards.

If you lose it, you will have to create a new one. **Keep it safe!**

### Exchange it for an access token

`POST /api/v1/auth/token` implements the OAuth 2.0 client-credentials grant (RFC 6749 §4.4). Send the credentials form-encoded (or as HTTP Basic auth):

```bash
curl -X POST https://api.perkss.io/api/v1/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"
```

```json
{ "access_token": "eyJ…", "token_type": "Bearer",
```

Tokens last 24 hours by default. Pass `ttl_seconds` (60–86400) to shorten or cap it.

### Call the API

Send the token on every request:

```bash
curl https://api.perkss.io/api/v1/customers \
  -H "Authorization: Bearer eyJ…"
```

Re-exchange for a new token before `expires_in` elapses. Exchanges are cheap; don't cache tokens past their lifetime.

`client_secret` is a server-side credential. Never embed it in a browser, mobile app, or public repository. If a secret leaks,
revoke the key in the CMS — revocation immediatelyery token minted from it\*\*.

`POST /api/v1/auth/logout` revokes the calling tok

## Rate limits

Limits are enforced per minute, in separate buckets:

| Scope                   | Limit     |
| ----------------------- | --------- |
| Token endpoint (per IP) | 10 / min  |
| Reads, per API key      | 600 / min |
| Writes, per API key     | 120 / min |

Reads and writes never share a bucket, so heavy re(and vice versa).

Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (seconds until the window resets). When you exceed a limit you get `429` with code `RATE_LIMIT.EXCEEDED` and a `Retry-After` header (seconds). **Honor `Retry-After`** — retrying earlier only consumes your next window.

## Idempotency

Transaction writes move money-like state, so they are protected against accidental double-execution.

Send an `Idempotency-Key` header — any unique stri

```bash
curl -X POST https://api.perkss.io/api/v1/customers/{customerId}/purchase \
  -H "Authorization: Bearer eyJ…" \
  -H "Idempotency-Key: 4f9d36c1-6c2e-4b7e-9d1a-0a4f5b3c2e1d" \
  -H "Content-Type: application/json" \
  -d '{ "amountCents": 1000 }'
```

| Endpoint                                                         | Idempotency-Key                          |
| ---------------------------------------------------------------- | ---------------------------------------- |
| `POST /customers/{id}/purchase`, `/adjustment`, `/redeem-reward` | **Required** — missing key returns `400` |
| `POST /customers` and `POST /customers/{id}/wallet/pass-link`    | Optional — honoured when present         |

How it behaves:

* **Retry with the same key and same body** → the original response is replayed (stored for 24 hours). The write executes once.
* **Same key, different body** → `409 IDEMPOTENCY.PAYLOAD_MISMATCH`. Keys identify one operation; never reuse them.
* **Duplicate while the first attempt is still running** → `409 IDEMPOTENCY.REQUEST_IN_PROGRESS` with `Retry-After`. Wait and retry\
  with the same key.
* **Failed requests are not stored** — retrying a rejected write with the same key re-executes it once the blocking condition is resolved.

Keys are scoped per company, method, and path, so a key used on one endpoint never replays another endpoint's response.

## Errors

Two shapes, by design:

**The token endpoint** returns standard OAuth error bodies (RFC 6749 §5.2):

```json
{ "error": "invalid_client", "error_description": "Client authentication failed", "request_id": "01J…" }
```

**Everything else** returns the Perkss error envelope:

```json
{ "error": { "code": "API_CUSTOMERS.NOT_FOUND", "message": "Customer not found", "requestId": "01J…", "httpStatus": 404 } }
```

Codes worth handling explicitly:

| Code                              | Status | Meaning                                                                                                                |
| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
| `API_CUSTOMERS.ALREADY_EXISTS`    | 409    | A customer with this phone already exists in your company                                                              |
| `CUSTOMERS.DUPLICATE_EXTERNAL_ID` | 409    | Your \`ed to another customer                                                                                          |
| `API_CUSTOMERS.NOT_FOUND`         | 404    | Unknown customer id, or it belongs to another company                                                                  |
| `API_TRANSACTIONS.WRITE_REJECTED` | 422    | The loyalty engine rejected the write — see `context.failureReason` (e.g. `INSUFFICIENT_BALANCE`, `NO_ACTIVE_PROGRAM`) |
| `RATE_LIMIT.EXCEEDED`             | 429    | Over a rate limit — honor `Retry-After`                                                                                |
| `VALIDATION.REQUEST_INVALID`      | 400    | Body/query ields are rejected                                                                                          |

Include the `requestId` when contacting support — es.

## Customer identity

* **Phone is the identity.** It's required at creation, accepted in any parseable format, and stored canonicalized to E.164. Creating a customer with an already-registered phone returns `409` — one phone, one customer per company.
* `externalId`**&#x20;is your alias.** Optional, uniqueterable (`GET /customers?externalId=…` returns 0 or1 rows). Use it to link Perkss customers to your own system without storing our ids.
* **Email is not an identity.** Multiple customers may share one; the email filter returns all of them.

## Pagination

List endpoints take `page` (1-based, default 1) and `pageSize` (default 20, max 100) and always return the same shape:

```json
{ "items": [ … ], "total": 137, "page": 1, "pageSi
```

The API is browser-callable (CORS allows any origin, Bearer-only, no cookies), but because the `client_secret` must stay server-side, the intended integration is from your backend.