Skip to main content

Authentication

Try signed requests in your browser at the API Sandbox — paste your API key and secret, and the playground signs requests automatically.
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.
SchemeWhen to useFormat
P2S-SIGN-V1Server-to-server API calls — signed with the API key secretP2S-SIGN-V1 API_KEY:TIMESTAMP:SIGNATURE

Environments

Base URL: https://api.play2sell.comDashboard: https://dashboard.play2sell.comApp: https://app.play2sell.com

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).
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();
}
Don’t want to write the signing code yet? The API Sandbox signs requests for you in the browser — paste your API key and secret, then click Try it out.

3. Check the Response

Success (200):
{
  "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):
{
  "error": { "code": "UNAUTHORIZED", "message": "Invalid signature for P2S-SIGN-V1 request" }
}

API Key Properties

PropertyDetails
Prefixsk_live_ (production) or sk_test_ (testing)
Scopedefault:sync — enables sync_collaborators and sync_activities
Rate limitConfigurable per key (default: 1000 requests/hour)
ExpirationOptional — set an expiry date or leave as never-expires
IP allowlistOptional — restrict to specific IP addresses
StorageHashed with bcrypt — the plaintext key is never stored

Key Formats

SalesOS uses two key prefixes to distinguish environments:
PrefixEnvironmentUse case
sk_live_ProductionReal data, real missions, real points
sk_test_TestingSafe to use during development — no impact on production
Use sk_test_ keys during development and integration testing. Switch to sk_live_ when you go to production.

Authentication Errors

HTTP StatusError CodeMeaningWhat to do
401UNAUTHORIZEDKey is missing, invalid, or expiredCheck the Authorization header. Verify the key in the Dashboard.
403FORBIDDENKey is valid but lacks required scopeEdit the key and add the default:sync scope
429RATE_LIMITEDToo many requests this hourWait retry_after seconds, then retry

Example: Missing Authorization header

curl -X POST https://api.play2sell.com/functions/v1/default-integration \
  -H "Content-Type: application/json" \
  -d '{"action": "sync_collaborators", "collaborators": [...]}'
{
  "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:
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid signature for P2S-SIGN-V1 request"
  }
}

Example: Timestamp outside the 30s window

{
  "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:
{
  "error": {
    "code": "FORBIDDEN",
    "message": "API key missing required scopes: default:sync"
  }
}

Example: Rate limit exceeded

{
  "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:
SettingDefaultRange
Requests per hour10001 — 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:
// 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

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

# 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

Default Integration

Start sending activities to SalesOS

API Keys

Manage keys programmatically