LealUp Docs
API & Webhooks

Authentication

OAuth2 (OIDC) for apps, API keys for server-to-server integrations, bearer tokens.

LealUp supports two authentication mechanisms:

  • OAuth2 (OIDC) — for apps acting on behalf of a user.
  • API keys — for server-to-server integrations (scripts, backends, sync jobs).

Both issue bearer tokens sent in the Authorization header.

OAuth2 / OIDC

Use this flow when your integration acts on behalf of a specific user (e.g., an app showing LealUp data inside your product).

Setup (one-time)

  1. Admin → API → OAuth Apps → Create app.
  2. Configure:
    • Name — visible to users during consent.
    • Redirect URIs — one or more (e.g., https://yourapp.com/callback).
    • Requested scopes — see list below.
  3. You receive:
    • client_id — public.
    • client_secretkeep secret (store in vault, never in client code).

Flow (Authorization Code with PKCE — required)

1. Redirect user to:
   https://api.lealup.com/v1/oauth/authorize?
     response_type=code&
     client_id=YOUR_CLIENT_ID&
     redirect_uri=https://yourapp.com/callback&
     scope=customers:read playbooks:read&
     state=RANDOM_NONCE&
     code_challenge=CODE_CHALLENGE&
     code_challenge_method=S256

2. User grants consent → redirect to your callback with ?code=AUTH_CODE

3. Exchange code for token:
   POST https://api.lealup.com/v1/oauth/token
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code&
   code=AUTH_CODE&
   redirect_uri=https://yourapp.com/callback&
   client_id=YOUR_CLIENT_ID&
   client_secret=YOUR_CLIENT_SECRET&
   code_verifier=CODE_VERIFIER

4. Response:
   {
     "access_token": "eyJ...",
     "refresh_token": "eyJ...",
     "token_type": "Bearer",
     "expires_in": 3600,
     "scope": "customers:read playbooks:read",
     "tenant_id": "01HXYZ..."
   }

Using the token

curl https://api.lealup.com/v1/customers \
  -H "Authorization: Bearer eyJ..."

Refresh

Access tokens last 1h. Refresh tokens last 90 days and rotate on each use.

POST /v1/oauth/token
grant_type=refresh_token&
refresh_token=OLD_REFRESH_TOKEN&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET

Response includes a new access_token + new refresh_token (the previous one is invalidated).

API keys

Use API keys for server-to-server integrations without a human user (cron jobs, sync scripts, inbound webhooks).

Create

Admin → API → API Keys → New key

Fields:

  • Name — readable identifier ("data warehouse sync", "zendesk alerts").
  • Scopes — what the key can do (see list).
  • IP allowlist — optional, CIDR ranges.
  • Expires — optional (90 days recommended).

You get:

lealup_sk_live_abc123def456...

The key is shown only once. Copy it and store in vault / secrets manager.

Format

  • lealup_sk_live_* — production.
  • lealup_sk_test_* — dev.

Use

Direct header:

curl https://api.lealup.com/v1/customers \
  -H "Authorization: Bearer lealup_sk_live_abc123def456..."

Rotation

  • Create a new key.
  • Deploy with the new key.
  • Revoke the old one in Admin → API → API Keys → [key] → Revoke.

Best practice: rotate every 90 days, after any suspicion of leak, or when someone with access leaves the company.

Revoke

Immediate — Admin → API → API Keys → [key] → Revoke. Requests with a revoked key return 401.

Scopes

Scopes control what a token can do.

Format

{resource}:{action}, e.g., customers:read, events:write.

Full list

ScopeAllows
customers:readGET customers and contacts
customers:writePOST/PATCH customers and contacts
customers:deleteDELETE customers (soft-delete)
health:readGET health scores
health:writePOST recalculate, PATCH models
playbooks:readGET playbooks and tasks
playbooks:writePOST/PATCH playbooks
tasks:readGET tasks
tasks:writePATCH tasks, complete tasks
events:writePOST events (ingestion)
events:readGET events (debugging)
webhooks:readGET webhook subscriptions
webhooks:writePOST/PATCH webhook subscriptions
users:readGET users
users:writePOST invite, PATCH role (restricted)
analytics:readGET analytics endpoints
admin:*full admin access (only OAuth with admin role or key created by admin)

Ask for the minimum needed. If you only read, don't ask for :write.

Auth errors

StatusCodeMeaning
401unauthenticatedno token sent, or malformed
401token_expiredaccess token expired — use refresh
401token_revokedkey or token was revoked
403insufficient_scopetoken is valid but lacks the required scope
403ip_not_allowedkey has an allowlist and your IP is not on it
429rate_limitedtoo many requests — see Retry-After header

Example response:

{
  "type": "https://api.lealup.com/errors/insufficient_scope",
  "title": "Insufficient scope",
  "status": 403,
  "detail": "Required scope 'customers:write' but token only has 'customers:read'",
  "instance": "/v1/customers",
  "trace_id": "01HXYZ123ABC"
}

Always include the trace_id in issues reported to support.

Security

  • HTTPS only — HTTP requests redirect to HTTPS; some endpoints reject with 400.
  • Token expiration — access tokens are short (1h). Refresh tokens are longer (90 days) but rotate.
  • API keys at rest: stored hashed (SHA-256 + salt). We cannot recover them.
  • Audit log — every API-key use is logged with actor_id, trace_id, endpoint, timestamp.
  • Anomaly detection — unusual usage spikes trigger an admin alert.

Identity vs authorization

  • Authentication (who) — valid token.
  • Authorization (what they can do) — scopes + user/key role.

A token can be valid (auth OK) but lack the scope for an action → 403, not 401.

End-to-end example (Python)

import httpx

BASE = "https://api.lealup.com"
KEY = "lealup_sk_live_..."

async def list_customers():
    async with httpx.AsyncClient() as client:
        r = await client.get(
            f"{BASE}/v1/customers",
            headers={"Authorization": f"Bearer {KEY}"},
            params={"limit": 50, "status": "active"},
        )
        r.raise_for_status()
        return r.json()

End-to-end example (TypeScript with SDK)

import { LealUpClient } from "@lealup/sdk";

const client = new LealUpClient({
  apiKey: process.env.LEALUP_API_KEY!,
});

const customers = await client.customers.list({
  status: "active",
  limit: 50,
});

On this page