> ## Documentation Index
> Fetch the complete documentation index at: https://docs.play2sell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API Conventions

> The rules every SalesOS API page follows — authentication, error shapes, batching, identity resolution and versioning — so you can learn one contract instead of one per endpoint.

# API Conventions

Every SalesOS API follows the rules on this page. Read it once and the individual endpoint pages become short: they only tell you what is specific to them.

<Note>
  This page is the contract we hold ourselves to. Where an endpoint departs from it, that endpoint's page says so **explicitly** — a silent exception is a bug, and we want to hear about it.
</Note>

***

## Base URLs

<Tabs>
  <Tab title="Production">
    `https://api.play2sell.com`
  </Tab>

  <Tab title="Staging">
    `https://api-staging.play2sell.com`
  </Tab>
</Tabs>

Every endpoint lives under `/functions/v1/<name>`. There is no `/v1/...` REST surface — if you find a page describing one, it is out of date; please tell us.

***

## Authentication

Two schemes, and they are **disjoint** — an endpoint accepts one or the other, never both for the same caller.

### Partner (server-to-server)

```
Authorization: P2S-SIGN-V1 API_KEY:TIMESTAMP:SIGNATURE
```

The signature is an HMAC over `{api_key_id}.{timestamp}.{raw_body}`. See [Authentication](/api/authentication) for the `signedRequest` helper.

* Timestamps older than **300 seconds** are rejected (replay protection)
* Sign the **raw body bytes**, before any re-serialization
* Each key is scoped to a single company; a document from another company answers `found: false`

### Self mode (federated session)

```
Authorization: Bearer <jwt>
```

Used when your app already has the end user signed in. The response covers **only that user**.

<Warning>
  Sending a `collaborators` list together with a user token is rejected with `SELF_MODE_NO_COLLABORATORS`. Batch lookups require a partner API key — otherwise any signed-in user could read other people's data by CPF.
</Warning>

***

## Error shapes

Requests fail in two different layers, and **they answer differently**. Handle both — this is the single most common integration bug we see.

### Authentication layer

Before your request reaches the endpoint's own logic:

```json theme={null}
{ "error": "Missing Authorization header", "code": "header_missing" }
```

`error` is a **string**. Common codes: `header_missing`, `header_malformed`, `invalid_key`, `signature_mismatch`, `timestamp_expired`.

### Endpoint layer

Once authentication passed:

```json theme={null}
{ "error": { "code": "VALIDATION_ERROR", "message": "Human-readable description", "details": [] } }
```

`error` is an **object**. `details` points at the offending item by `index` when the request carried a list.

<Warning>
  **`error` is a string in one case and an object in the other.** Code that assumes `error.code` will read `undefined` on every authentication failure — and code that assumes `error` is a string will print `[object Object]` on validation failures. Branch on the type:

  ```js theme={null}
  const code = typeof body.error === "string" ? body.code : body.error?.code;
  ```

  We know this is not ideal. It is documented rather than hidden because the two layers ship separately, and a partner discovering it in production is worse than a partner reading it here.
</Warning>

### Payments

The Payments API answers with [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) `problem+json` instead:

```json theme={null}
{
  "type": "https://docs.play2sell.com/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Missing Authorization header",
  "instance": "099d23e0-5d6e-4882-9208-9e8038419c3c",
  "code": "unauthorized"
}
```

It is a separate service with its own conventions. `instance` is the request id — quote it when opening a ticket.

***

## Status codes

| Code  | Meaning                                              | What to do                                                       |
| ----- | ---------------------------------------------------- | ---------------------------------------------------------------- |
| `200` | Success. **Includes "found nothing"**                | Read the payload — an empty list is a valid answer, not an error |
| `400` | The request is malformed or exceeds a limit          | Fix the request; retrying unchanged will fail again              |
| `401` | Key missing, invalid, expired, or signature mismatch | Check the key and clock skew                                     |
| `403` | Valid key, missing scope                             | Ask for the scope on the key                                     |
| `405` | Wrong method                                         | Every partner endpoint is `POST`                                 |
| `429` | Rate limit for this hour                             | Wait `retry_after` seconds                                       |
| `5xx` | Our fault                                            | Retry with backoff (2s, 4s, 8s)                                  |

<Tip>
  **Absence is not an error.** No campaign running, nobody checked in, no missions today — all answer `200` with an empty structure. An endpoint that returned `404` for "nothing today" would make your screen say something is broken when the day simply started.
</Tip>

***

## Batching and identity

Partner endpoints take a `collaborators` array and answer in **the same order**, one entry per request item.

### Identity resolution

Precedence is `user_id` > `cpf` > `email`. When more than one is sent, the first present wins — the others are ignored, not used as a fallback.

* **CPF** is accepted in any format; digits are normalized. It must contain exactly 11 digits.
* Unknown person answers `"found": false`. That is **not** an error, and it keeps the array aligned with your request.
* `"ambiguous": true` appears when more than one person matched — treat it as unresolved.

### Limits

Batch limits are per action and stated on each page, because they follow the payload each action produces. As a rule: a status action allows **500**, an action that expands a list per person allows **100 or fewer**.

<Note>
  Shared data — campaign metadata, the tenant's shifts — comes back **once at the top of the response**, not repeated per person. On a 500-person batch that is the difference between a lean response and a multi-megabyte one.
</Note>

***

## Configuration belongs to the company, never to your code

This is the rule that most often breaks integrations months after they ship.

Names, labels, counts, thresholds and currencies are **configured per company** and returned in the response. They are not constants.

| What you might hardcode          | Why it breaks                                                    |
| -------------------------------- | ---------------------------------------------------------------- |
| The currency name                | Each company names its own; the API returns the configured label |
| Three shifts, at fixed hours     | Quantity, labels and hours come from the company's schedule      |
| A fixed number of daily missions | It varies by day and by role                                     |
| Level names and colors           | The ladder is configuration, not an enum                         |

<Warning>
  Render what the response gives you. A screen that hardcodes a label works until the day the customer renames it — and then it lies, quietly, with no error anywhere.
</Warning>

***

## Timezones and dates

Every "today" is computed in **the company's timezone**, never UTC. The response states which one it used:

```json theme={null}
{ "timezone": "America/Sao_Paulo", "reference_date": "2026-08-13" }
```

Timestamps are ISO 8601 in UTC. Convert for display using the `timezone` from the same response — not the device's.

***

## Rate limits

Configurable per key, defaulting to **1000 requests/hour**. On `429`, the response carries `retry_after` in seconds. Back off; do not spin.

***

## Versioning

Contracts are extended, not broken. We may **add** fields to a response at any time, so parse permissively and ignore what you do not know.

New behavior that changes an existing payload ships **opt-in**, behind a request flag (for example `include_day`). Omitting the flag keeps the response you already handle.

***

## Security

* Partner APIs are **read-only** unless the page says otherwise — they never redeem, never change balances, never accept terms
* Requests are HMAC-signed and logged for audit; documents and emails are **never written to logs**
* Never call these APIs from client-side code: the key would be exposed

***

## Next steps

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/api/authentication">
    Create keys and sign requests
  </Card>

  <Card title="Check-in API" icon="location-check" href="/api/integrations/checkin">
    Duty presence and the day's shift track
  </Card>

  <Card title="Missions API" icon="bullseye-arrow" href="/api/integrations/missions">
    Progress, points earned and points available
  </Card>

  <Card title="Campaigns API" icon="gift" href="/api/integrations/campaigns">
    Reward catalog and spendable balance
  </Card>
</CardGroup>
