API keys and auth


Every Protect+ public-API call (other than the token-exchange endpoint itself) requires a JWT in the Authorization header. JWTs are minted by exchanging an API key + your store domain for a 60-minute token.

This guide walks you through the full lifecycle: generating a key, storing it safely, exchanging it for a JWT, refreshing the JWT cleanly, and rotating / revoking when needed.

TL;DR

# 1. Exchange API key for JWT (valid 60 minutes)
curl -X POST https://api.protectplus.io/auth/v1/token \
  -H "X-API-Key: pplus_4a2b8c9d1e3f7g8h2i4j6k8l0m2n4p6q8r0s" \
  -H "X-Shop-Domain: mystore.myshopify.com"

# Response:
# {
#   "success": true,
#   "data": {
#     "access_token": "eyJhbGciOi...",
#     "token_type": "Bearer",
#     "expires_in": 3600,
#     "expires_at": "2026-05-08T07:13:36.304Z",
#     "jti": "cba0f54d-82e2-4f05-8a02-4c25f24741f3"
#   },
#   "requestId": "..."
# }

# 2. Use the JWT on every other endpoint
curl https://api.protectplus.io/orders \
  -H "Authorization: Bearer eyJhbGciOi..."

Two-step auth model

CredentialLifetimeWhere it goesWhat it represents
API key (pplus_…)Until rotatedServer-side secret store. Never in client code.Long-lived identity, scoped to one store.
JWT (eyJhbGciOi…)60 minutesAuthorization: Bearer … header on every request.Short-lived bearer token derived from the API key.

The two-step model lets us rotate access cheaply (rotate the JWT every 60 min, or instantly via API key revoke) without your integration shipping a long-lived bearer token over the wire on every call.

Generating an API key

In the Protect+ merchant dashboard:

  1. Settings → API Keys (or Tracking → Integration if you're wiring up tracking specifically — both surfaces show the same key)
  2. Click Generate API Key (or Generate if you're regenerating)
  3. Copy the value immediately — it's shown exactly once. After you close the dialog, you can only see a masked preview.
  4. Store it in your secret manager (1Password, AWS Secrets Manager, GCP Secret Manager, GitHub / GitLab CI secrets, etc.) — never commit it to a repo, never paste it in Slack, never put it in client-side code.

If you lose the key, regenerate. The previous key is immediately invalidated on regeneration (any in-flight request using it will start returning 401).

Exchanging the API key for a JWT — POST /auth/v1/token

Send your API key in X-API-Key and your store domain in X-Shop-Domain. The body is empty.

curl

curl -X POST https://api.protectplus.io/auth/v1/token \
  -H "X-API-Key: $PPLUS_API_KEY" \
  -H "X-Shop-Domain: mystore.myshopify.com"

Node.js (fetch)

async function getJwt(apiKey, shopDomain) {
  const r = await fetch('https://api.protectplus.io/auth/v1/token', {
    method: 'POST',
    headers: {
      'X-API-Key': apiKey,
      'X-Shop-Domain': shopDomain,
    },
  });
  if (!r.ok) {
    const err = await r.json();
    throw new Error(`Auth failed: ${err.title} (${err.detail})`);
  }
  const { data } = await r.json();
  return {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
}

Python (requests)

import requests, time

def get_jwt(api_key: str, shop_domain: str) -> dict:
    r = requests.post(
        'https://api.protectplus.io/auth/v1/token',
        headers={'X-API-Key': api_key, 'X-Shop-Domain': shop_domain},
        timeout=10,
    )
    r.raise_for_status()
    body = r.json()['data']
    return {
        'token': body['access_token'],
        'expires_at': time.time() + body['expires_in'],
    }

Response

{
  "success": true,
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "expires_at": "2026-05-08T07:13:36.304Z",
    "jti": "cba0f54d-82e2-4f05-8a02-4c25f24741f3",
    "issued_at": "2026-05-08T06:13:36.304Z"
  },
  "requestId": "11af4994-c2ff-48bd-bc03-c3a0ba054af3",
  "traceId": "830f07a5-dae0-4f43-8f96-843996fc5fa1"
}
FieldUse
access_tokenThe JWT. Send as Authorization: Bearer <access_token>.
token_typeAlways Bearer.
expires_inLifetime in seconds (3600 = 60 min).
expires_atAbsolute expiry timestamp — handy for cache TTL.
jtiUnique JWT ID. Useful for log correlation; reserved for future revocation support.
issued_atWhen the token was minted.

Using the JWT

Send it on every other endpoint:

curl https://api.protectplus.io/orders \
  -H "Authorization: Bearer $JWT"

The JWT carries merchantId + storeId derived from the API key — you don't need to send those separately, and the gateway will reject any X-Merchant-Id / X-Store-Id header you do send (defence-in-depth against cross-tenant spoofing).

Token refresh strategy

JWTs are valid for 60 minutes. After that, every request returns:

HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json

{
  "type": "https://api.protectplus.io/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Token expired"
}

There are two reasonable refresh patterns. Pick one based on your traffic shape — either works.

Pattern A — proactive (recommended for high-throughput integrations)

Cache the JWT in memory along with expiresAt. Before each request, check Date.now() >= expiresAt - 60_000 (refresh 1 minute before expiry to give yourself a buffer for clock skew + the round-trip).

let cached = null;

async function jwt() {
  if (!cached || Date.now() >= cached.expiresAt - 60_000) {
    cached = await getJwt(API_KEY, SHOP_DOMAIN);
  }
  return cached.token;
}

This avoids the wasted retry round-trip and any user-visible latency on the boundary call.

Pattern B — lazy (simpler, fine for low-volume / batch jobs)

Catch 401, refresh once, retry the original request once. Don't loop forever.

async function callWithRefresh(url, opts = {}) {
  let token = cached?.token ?? (cached = await getJwt(...)).token;
  let r = await fetch(url, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } });
  if (r.status === 401) {
    cached = await getJwt(API_KEY, SHOP_DOMAIN);
    r = await fetch(url, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${cached.token}` } });
  }
  return r;
}

Either way, share the cached JWT across requests within a single process. Don't mint a new JWT per call — the token endpoint has a stricter rate limit than the data endpoints (see below).

Rate limits

Endpoint familyLimit
POST /auth/v1/token20 requests / 15 min, per API key
All other endpoints1,000 requests / hour, per merchant
POST /shipments, PATCH /shipments600 requests / minute, per store (machine-caller limiter, separate counter)

The token endpoint has a tight limit on purpose — minting a fresh JWT for every API call burns the budget. Cache the JWT.

A 429 response carries Retry-After (seconds). Back off and retry.

Rotating an API key

You should rotate keys:

  • Periodically — every 90 days is a sensible default.
  • Immediately — on any suspected compromise (key leaked in logs, pasted in Slack, surfaced in a public repo, etc.).
  • On personnel changes — when the engineer who set it up leaves the team.

To rotate:

  1. Settings → API Keys in the dashboard
  2. Generate (or Regenerate if there's an existing key)
  3. The previous key is invalidated immediately. Any integration using the old key will start returning 401 on POST /auth/v1/token.
  4. Update your secret store + redeploy. JWTs minted before the rotation remain valid for the rest of their 60-minute lifetime — old JWTs aren't revoked, just no new ones can be minted with the old API key.

If you need to revoke a key without minting a new one — e.g. you've identified a compromise but haven't yet figured out what to replace it with — generate a new key first to invalidate the old one, then store the new key offline until you're ready to deploy.

Multi-store accounts

If your merchant has multiple stores under one Protect+ account, each store gets its own API key. The X-Shop-Domain header on the token exchange tells us which store's JWT to mint.

There's no "global" API key that spans stores — this is intentional:

  • Your cross-store integration code stays explicit about which store each request targets (no accidental writes to the wrong store).
  • Revoking access for one store doesn't affect the others.

In code, key your JWT cache by (api_key, shop_domain) and you're done.

Common errors

400 Bad Request — missing or malformed headers

detail: "X-API-Key header is required"

You forgot to send X-API-Key or X-Shop-Domain on the token call. Note that on every other endpoint the header is Authorization: Bearer <jwt> — don't mix them up.

401 Unauthorized — key not recognised

detail: "API key not recognised, revoked, or inactive"

The API key string doesn't match an active key in our database. Most common causes:

  • Stale key from before a rotation. Pull the current value from your secret store.
  • Whitespace in the env var (a trailing newline from a .env file). Trim it before sending.
  • Wrong environment (dev key against prod URL or vice versa). API keys are environment-scoped.

403 Forbidden — wrong store domain

detail: "API key does not belong to the supplied X-Shop-Domain"

The key is valid but was issued for a different store. Either fix X-Shop-Domain to match the key's store, or use the key for that store.

429 Too Many Requests

You've blown the token endpoint limit (20 req / 15 min / API key). The fix is always the same: cache the JWT instead of minting a new one per request. See the refresh strategy section above.

Security checklist

  • API key stored in a secret manager, not committed to a repo
  • API key not logged, not printed, not visible in URLs
  • JWT cached in-process so the token endpoint isn't hammered
  • Refresh at 55 min (or lazy on 401) — don't ignore expiry
  • Rotation cadence agreed (90-day default)
  • On personnel offboarding, key rotation is part of the runbook
  • On suspected compromise, regenerate immediately

Related