API Keys
API keys allow your systems and third-party tools to authenticate with the PurpletGo API without a user session. They are ideal for server-to-server integrations, HRIS webhook bridges, and automation pipelines.
For a general overview of authentication, see Authentication.
Key format
All PurpletGo API keys follow the same format: pgo_live_ followed by 32 hex characters. There is currently no sandbox/test key type - every key created is a live, production-capable credential.
Keys issued before the rename to PurpletGo start with fnl_live_. They remain valid indefinitely - the prefix is part of the key value, so only newly created keys use pgo_live_.
Only the raw key's prefix (the first 12 characters, e.g. pgo_live_a1b) is stored for display, plus an HMAC-SHA256 of the key under a server-side pepper that is not in the database - PurpletGo never stores or shows the full value again after creation. See how keys are stored.
A key can be rotated in place: it gets a new secret and keeps its name, scopes, expiry and history, with a grace period during which the old secret still works.
An organisation can have at most 20 active keys at a time; creating a 21st fails until you revoke an unused one.
Only Super Admins can list, create, rotate, and revoke keys. When the person who created a key is removed from the organisation, leaves it, or deletes their account, their keys in that organisation are revoked automatically, and each revocation is recorded in the audit log (api_key.revoked, with the reason in the details).
Scopes
Every API key is created with one or more scopes. Requests are rejected with 403 Forbidden if the key does not carry the required scope for the endpoint being called.
| Scope | Description |
|---|---|
offboardings:read | List and view offboarding records |
offboardings:write | Create offboardings and update their status |
employees:read | List employees |
webhooks:read | List registered webhooks |
webhooks:write | Create and delete webhooks. Subscribing to an event also needs the scope that reads its data: employees:read for asset.* events, offboardings:read for the rest |
Grant only the scopes your integration actually needs. A key with offboardings:write can trigger live offboarding workflows. Never embed production keys in client-side code or public repositories.
Endpoints
These management endpoints are for the PurpletGo dashboard itself - you authenticate with your regular session (cookie or Bearer JWT), not an API key, and they live under /api/keys rather than /api/v1.
The entire /api/keys surface requires the publicApi plan feature. On a plan without it, requests here return 403 with { "error": "...", "feature": "publicApi", "currentPlan": "...", "upgradeRequired": true } instead of the responses documented below. The one exception is a read-only org (trial expired or subscription ended): its GET /api/keys still works, so you can see which keys exist, but creating or revoking a key is refused until a plan is chosen.
List API keys
GET /api/keys
Returns all API keys for your organisation, most recently created first. Key values are never returned - only the stored prefix.
Required role: superadmin (Super Admin only - a custom role can't grant this).
Response 200 OK - a plain array, not wrapped in data/total:
[
{
"id": "3f9c8b1a-...",
"name": "BambooHR Webhook Bridge",
"key_prefix": "pgo_live_a3f",
"scopes": ["offboardings:write", "employees:read"],
"last_used_at": "2025-06-24T14:05:33Z",
"expires_at": null,
"revoked_at": null,
"created_at": "2025-03-12T08:22:00Z",
"created_by_name": "Jane Smith",
"rotated_at": "2025-06-20T09:00:00Z",
"previous_key_prefix": "pgo_live_7qd",
"previous_valid_until": "2025-06-27T09:00:00Z",
"usage": {
"calls7d": [0, 0, 12, 340, 511, 498, 87],
"callsTotal7d": 1448,
"errors7d": 9,
"lastStatus": 200,
"lastSeenFrom": "52.14.3.0"
}
}
]rotated_at is when the key was last rotated, or null. previous_key_prefix and previous_valid_until describe the previous secret and are set only while it still authenticates (its grace period has not ended); after that both are null.
usage is always present, and is zero-filled for a key that has never been called.
| Field | Meaning |
|---|---|
calls7d | Calls per UTC day for the last 7 days, oldest first, always exactly 7 entries. |
callsTotal7d | Sum of calls7d. |
errors7d | Calls in the same window that returned a 4xx or 5xx. |
lastStatus | HTTP status of the most recent call, or null if the key has not been used. |
lastSeenFrom | Truncated address of the most recent caller, or null. IPv4 has its last octet dropped (52.14.3.0), IPv6 is cut to the /48 (2001:db8:1::). PurpletGo deliberately does not keep the precise address, and clears this after 30 days without use. |
Only daily totals are retained, for 30 days. Individual requests are not logged.
Create an API key
POST /api/keys
Creates a new API key. The full key value is returned only in this response, in the raw_key field - store it immediately in a secrets manager.
Required role: superadmin (Super Admin only - a custom role can't grant this).
Step-up required. Because a key keeps acting for the org after the session that created it is gone, a session alone can't mint one: the caller must have recently re-entered their password (and their 2FA code, if 2FA is enabled). Without a fresh step-up the request returns 428 with { "error": "step_up_required", "totpRequired": true|false }. Complete the step-up and retry.
Request body
{
"name": "Workday Offboarding Trigger",
"scopes": ["offboardings:write", "employees:read"],
"expires_at": "2026-12-31T00:00:00Z"
}| Field | Required | Type | Description |
|---|---|---|---|
name | ✅ | string | Label shown in the dashboard (max 80 characters) |
scopes | ✅ | string[] | At least one scope. There is no default |
expires_at | ✅ | date-time or null | Must be in the future; null for a key that never expires. There is no default. The dashboard sends the end of the chosen day in your timezone, so a key set to expire today works until midnight tonight |
Response 201 Created
{
"id": "6a1e2d90-...",
"name": "Workday Offboarding Trigger",
"key_prefix": "pgo_live_kx9",
"scopes": ["offboardings:write", "employees:read"],
"expires_at": "2026-12-31T00:00:00Z",
"created_at": "2025-06-26T10:00:00Z",
"raw_key": "pgo_live_kx9mrpwqlzdvntyja2cbs7hueog3"
}The raw_key field is returned exactly once. PurpletGo does not store the plaintext value. If you lose it, revoke the key and generate a new one.
An organisation is capped at 20 active keys - creating another returns 400 until you revoke one.
Rotate an API key
POST /api/keys/{keyId}/rotate
Issues a new secret for an existing key. The key's id, name, scopes, expiry, usage history and audit trail are unchanged; only the secret is replaced. The new value is returned only in this response, in raw_key.
The old secret keeps authenticating for the chosen grace period, so the integration can be switched over without an outage. When the period ends, the old secret gets the same generic 401 as any invalid key. Rotating again inside a grace period replaces the previous secret, so at most two secrets work for a key at any time. Revoking the key ends both at once.
Required role: superadmin (Super Admin only - a custom role can't grant this).
Step-up required. The response hands out a credential that outlives the session, exactly as creating a key does, so the same fresh password (and 2FA) check applies: without it the request returns 428 with { "error": "step_up_required", "totpRequired": true|false }.
Request body
{
"grace": "24h"
}| Field | Required | Type | Description |
|---|---|---|---|
grace | - | string | How long the old secret keeps working: "0" (stops at once), "1h", "24h" or "7d". Defaults to "24h". Anything else returns 400 |
Response 200 OK
{
"id": "6a1e2d90-...",
"name": "Workday Offboarding Trigger",
"key_prefix": "pgo_live_m3t",
"previous_key_prefix": "pgo_live_kx9",
"previous_valid_until": "2025-07-02T10:00:00Z",
"scopes": ["offboardings:write", "employees:read"],
"expires_at": "2026-12-31T00:00:00Z",
"rotated_at": "2025-07-01T10:00:00Z",
"raw_key": "pgo_live_m3t8vq2kplzdr0ncyw5ahs7ejb1f"
}With "grace": "0", previous_key_prefix is null and previous_valid_until is the time of the rotation.
raw_key is returned exactly once. Update the secret in your integration before previous_valid_until, after which only the new one works.
Returns 400 { "error": "Invalid id" } if keyId isn't a UUID, 400 for an unknown grace, and 404 if the key doesn't exist in your organisation or has been revoked. Each rotation is recorded in the audit log as api_key.rotated, with both prefixes and the grace period in the details.
Revoke an API key
DELETE /api/keys/{keyId}
Immediately invalidates the API key, including a previous secret still inside its rotation grace period. Any in-flight requests using this key will receive 401 Unauthorized. This action is irreversible.
Required role: superadmin (Super Admin only - a custom role can't grant this).
Response 200 OK
{ "message": "API key revoked" }Returns 400 { "error": "Invalid id" } if keyId isn't a UUID (checked before any lookup), and 404 if the key doesn't exist in your organisation or was already revoked.
Using a key in requests
Pass the API key in the Authorization header as a Bearer token on every request:
curl https://app.purpletgo.com/api/v1/offboardings \
-H "Authorization: Bearer pgo_live_kx9mrpwqlzdvntyja2cbs7hueog3" \
-H "Content-Type: application/json"Error responses
| Status | Meaning |
|---|---|
401 | Missing, malformed, revoked, or expired API key, or the previous secret of a rotated key after its grace period (one message for all of them, so a key can't be enumerated) |
403 | The organisation has been suspended |
403 | The organisation's plan no longer includes the public API. A read-only org (trial expired or subscription ended) is still let through here; its writes are refused further on |
403 | Key doesn't carry the scope required for the endpoint |
429 | Too many failed authentication attempts from your IP address: 20 failures in 60 seconds blocks every request from that address, including ones with a valid key, until the window passes |
429 | Key has exceeded 120 requests per 60 seconds |
A blocked IP is refused before the key is even looked up; the scope check runs last, after the per-key rate limit.
Every error follows the standard { "error": "..." } shape - see Errors & Rate Limiting.
Related
- Authentication - key format and session auth concepts
- Errors & Rate Limiting - error shape and per-key rate limits
- Webhooks - deliver events to your own endpoints