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

# Authentication

> Authenticate with the SalesOS Integration API using tenant-scoped API Keys.

# Authentication

<Tip>
  Try signed requests in your browser at the [API Sandbox](https://play2sellsa.github.io/api-sandbox/) — paste your API key and secret, and the playground signs requests automatically.
</Tip>

The SalesOS Integration API uses the **P2S-SIGN-V1** signed-request scheme in the `Authorization` header. API Keys are scoped to a single tenant, hashed with bcrypt, and support rate limiting and IP allowlists.

| Scheme          | When to use                                                 | Format                                    |
| --------------- | ----------------------------------------------------------- | ----------------------------------------- |
| **P2S-SIGN-V1** | Server-to-server API calls — signed with the API key secret | `P2S-SIGN-V1 API_KEY:TIMESTAMP:SIGNATURE` |

## Environments

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

    **Dashboard:** `https://dashboard.play2sell.com`

    **App:** `https://app.play2sell.com`
  </Tab>

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

    **Dashboard:** `https://dashboard-staging.play2sell.com`

    **App:** `https://app-staging.play2sell.com`
  </Tab>
</Tabs>

## Quick Start

### 1. Create an API Key

Go to **Integrations > API Keys** in the SalesOS Dashboard:

1. Click **Create API Key**
2. Name your key (e.g., "CRM Nightly Sync", "Website Form Integration")
3. Select the scope: `default:sync`
4. Click **Create**
5. **Copy both values immediately** — they are only shown once:
   * **API Key** — public identifier, e.g. `sk_live_a1b2c3d4...`
   * **API Key Secret** — used to sign requests, never sent over the wire

### 2. Sign and Send a Request

For server-to-server calls, build the `Authorization` header as `P2S-SIGN-V1 API_KEY:TIMESTAMP:SIGNATURE`. The signature is hex HMAC-SHA256 of a 5-step derived-key chain:

1. `k1  = HMAC_SHA256(key=API_KEY_SECRET, msg=API_KEY)`
2. `k2  = HMAC_SHA256(key=k1, msg=TIMESTAMP)`
3. `k3  = HMAC_SHA256(key=k2, msg=METHOD)`
4. `k4  = HMAC_SHA256(key=k3, msg=PATH)`
5. `SIG = HMAC_SHA256_HEX(key=k4, msg=PAYLOAD_SHA256_HEX)`

`TIMESTAMP` is Unix epoch seconds, valid for 30 seconds. `PAYLOAD_SHA256_HEX` is the lowercase hex SHA-256 of the raw request body (use the empty-string digest `e3b0c4...b855` if there is no body).

<Tabs>
  <Tab title="Node.js">
    ```js theme={null}
    import crypto from 'node:crypto';

    const API_KEY = process.env.SALESOS_API_KEY;
    const SECRET  = process.env.SALESOS_API_SECRET;

    const hmac = (key, msg) =>
      crypto.createHmac('sha256', key).update(msg).digest();

    async function signedRequest(method, path, body) {
      const ts          = Math.floor(Date.now() / 1000).toString();
      const bodyStr     = body ? JSON.stringify(body) : '';
      const payloadHash = crypto.createHash('sha256').update(bodyStr).digest('hex');

      const k1  = hmac(SECRET, API_KEY);
      const k2  = hmac(k1, ts);
      const k3  = hmac(k2, method);
      const k4  = hmac(k3, path);
      const sig = hmac(k4, payloadHash).toString('hex');

      const res = await fetch(`https://api.play2sell.com${path}`, {
        method,
        headers: {
          Authorization: `P2S-SIGN-V1 ${API_KEY}:${ts}:${sig}`,
          'Content-Type': 'application/json',
        },
        body: bodyStr || undefined,
      });
      return res.json();
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hashlib, hmac, json, os, time, requests

    API_KEY = os.environ['SALESOS_API_KEY']
    SECRET  = os.environ['SALESOS_API_SECRET']

    def hmac_raw(key: bytes, msg: str) -> bytes:
        return hmac.new(key, msg.encode(), hashlib.sha256).digest()

    def signed_request(method: str, path: str, body=None):
        ts           = str(int(time.time()))
        body_str     = json.dumps(body) if body else ''
        payload_hash = hashlib.sha256(body_str.encode()).hexdigest()

        k1  = hmac_raw(SECRET.encode(), API_KEY)
        k2  = hmac_raw(k1, ts)
        k3  = hmac_raw(k2, method)
        k4  = hmac_raw(k3, path)
        sig = hmac_raw(k4, payload_hash).hex()

        return requests.request(
            method, f'https://api.play2sell.com{path}',
            headers={
                'Authorization': f'P2S-SIGN-V1 {API_KEY}:{ts}:{sig}',
                'Content-Type': 'application/json',
            },
            data=body_str or None,
        ).json()
    ```
  </Tab>

  <Tab title="Bash">
    ```bash theme={null}
    API_KEY="sk_live_..."
    SECRET="..."
    METHOD="POST"
    PATH_="/functions/v1/default-integration/sync_collaborators"
    BODY='{"action":"sync_collaborators","collaborators":[{"external_id":"emp-101","name":"Maria Santos","email":"maria@yourcompany.com"}]}'
    TS=$(date +%s)

    PAYLOAD=$(printf '%s' "$BODY"        | openssl dgst -sha256 -hex                                  | awk '{print $2}')
    K1=$(printf '%s'      "$API_KEY"     | openssl dgst -sha256 -hmac "$SECRET" -hex                  | awk '{print $2}')
    K2=$(printf '%s'      "$TS"          | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$K1" -hex   | awk '{print $2}')
    K3=$(printf '%s'      "$METHOD"      | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$K2" -hex   | awk '{print $2}')
    K4=$(printf '%s'      "$PATH_"       | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$K3" -hex   | awk '{print $2}')
    SIG=$(printf '%s'     "$PAYLOAD"     | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$K4" -hex   | awk '{print $2}')

    curl -X "$METHOD" "https://api.play2sell.com$PATH_" \
      -H "Authorization: P2S-SIGN-V1 $API_KEY:$TS:$SIG" \
      -H "Content-Type: application/json" \
      -d "$BODY"
    ```
  </Tab>
</Tabs>

<Tip>
  Don't want to write the signing code yet? The [API Sandbox](https://play2sellsa.github.io/api-sandbox/) signs requests for you in the browser — paste your API key and secret, then click *Try it out*.
</Tip>

### 3. Check the Response

**Success (200):**

```json theme={null}
{
  "data": { "created": 1, "existing": 0, "errors": [], "total": 1 },
  "meta": { "request_id": "f47ac10b-...", "timestamp": "2026-03-17T10:30:00.000Z" }
}
```

**Invalid signature or expired timestamp (401):**

```json theme={null}
{
  "error": { "code": "UNAUTHORIZED", "message": "Invalid signature for P2S-SIGN-V1 request" }
}
```

***

## API Key Properties

| Property         | Details                                                             |
| ---------------- | ------------------------------------------------------------------- |
| **Prefix**       | `sk_live_` (production) or `sk_test_` (testing)                     |
| **Scope**        | `default:sync` — enables `sync_collaborators` and `sync_activities` |
| **Rate limit**   | Configurable per key (default: 1000 requests/hour)                  |
| **Expiration**   | Optional — set an expiry date or leave as never-expires             |
| **IP allowlist** | Optional — restrict to specific IP addresses                        |
| **Storage**      | Hashed with bcrypt — the plaintext key is never stored              |

***

## Key Formats

SalesOS uses two key prefixes to distinguish environments:

| Prefix     | Environment | Use case                                                 |
| ---------- | ----------- | -------------------------------------------------------- |
| `sk_live_` | Production  | Real data, real missions, real points                    |
| `sk_test_` | Testing     | Safe to use during development — no impact on production |

<Tip>
  Use `sk_test_` keys during development and integration testing. Switch to `sk_live_` when you go to production.
</Tip>

***

## Authentication Errors

| HTTP Status | Error Code     | Meaning                               | What to do                                                         |
| ----------- | -------------- | ------------------------------------- | ------------------------------------------------------------------ |
| 401         | `UNAUTHORIZED` | Key is missing, invalid, or expired   | Check the `Authorization` header. Verify the key in the Dashboard. |
| 403         | `FORBIDDEN`    | Key is valid but lacks required scope | Edit the key and add the `default:sync` scope                      |
| 429         | `RATE_LIMITED` | Too many requests this hour           | Wait `retry_after` seconds, then retry                             |

### Example: Missing Authorization header

```bash theme={null}
curl -X POST https://api.play2sell.com/functions/v1/default-integration \
  -H "Content-Type: application/json" \
  -d '{"action": "sync_collaborators", "collaborators": [...]}'
```

```json theme={null}
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Missing or malformed Authorization header"
  }
}
```

### Example: Signature mismatch

A signature that doesn't match the server's recomputation — usually caused by a body change after signing, a path-canonicalization mismatch, or an outdated key:

```json theme={null}
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid signature for P2S-SIGN-V1 request"
  }
}
```

### Example: Timestamp outside the 30s window

```json theme={null}
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Request timestamp is outside the 30-second validity window"
  }
}
```

### Example: Key without required scope

If your key only has `leads:read` but the endpoint requires `default:sync`:

```json theme={null}
{
  "error": {
    "code": "FORBIDDEN",
    "message": "API key missing required scopes: default:sync"
  }
}
```

### Example: Rate limit exceeded

```json theme={null}
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded",
    "retry_after": 1847
  }
}
```

The `retry_after` field tells you how many seconds to wait. The rate limit window resets every hour.

***

## Rate Limits

Each API key has an independent rate limit counter that resets hourly:

| Setting           | Default | Range       |
| ----------------- | ------- | ----------- |
| Requests per hour | 1000    | 1 — 100,000 |

**How it works:**

1. Each successful request increments the counter
2. When the counter reaches the limit, further requests return `429`
3. The counter resets to 0 one hour after the first request in the window

**Handling rate limits in code:**

```javascript theme={null}
// signedRequest() is the helper from the Quick Start above.
async function callWithRetry(method, path, body) {
  const response = await signedRequest(method, path, body);

  if (response.status === 429) {
    const { error } = await response.json();
    const waitSeconds = error.retry_after || 60;
    console.log(`Rate limited. Retrying in ${waitSeconds}s...`);
    await new Promise(r => setTimeout(r, waitSeconds * 1000));
    return callWithRetry(method, path, body); // retry
  }

  return response.json();
}
```

***

## Security Best Practices

<Warning>
  **Never expose API keys in client-side code.** Browser JavaScript, mobile apps, and public repositories can all leak your key. Always call the SalesOS API from your backend server.
</Warning>

* **Use environment variables** — Store `SALESOS_API_KEY` in env vars or a secrets manager, never in source code
* **Rotate keys periodically** — Create a new key, update your integration, then revoke the old one
* **Use IP allowlists** — If your integration runs from fixed IPs, restrict the key to those IPs only
* **Monitor usage** — Check the API usage logs in the Dashboard for unexpected patterns
* **Use `sk_test_` for development** — Test keys isolate your dev environment from production
* **Revoke compromised keys immediately** — Go to Dashboard > Admin > API Keys > Revoke

### Key rotation example

```bash theme={null}
# 1. Create new key in Dashboard → copy both values:
#    - SALESOS_API_KEY=sk_live_NEW_KEY
#    - SALESOS_API_SECRET=NEW_SECRET
# 2. Update both env vars in your deployment.
# 3. Verify it works using the signed request helper from the Quick Start
#    (Node, Python, or Bash). Example payload:
#    { "action": "sync_collaborators",
#      "collaborators": [{"external_id":"test","name":"Test","email":"test@co.com"}] }
# 4. Revoke the old key in Dashboard.
```

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Default Integration" icon="plug" href="/api/integrations/default">
    Start sending activities to SalesOS
  </Card>

  <Card title="API Keys" icon="key" href="/api/integrations/api-keys">
    Manage keys programmatically
  </Card>
</CardGroup>
