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
| Credential | Lifetime | Where it goes | What it represents |
|---|---|---|---|
API key (pplus_…) | Until rotated | Server-side secret store. Never in client code. | Long-lived identity, scoped to one store. |
JWT (eyJhbGciOi…) | 60 minutes | Authorization: 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:
- Settings → API Keys (or Tracking → Integration if you're wiring up tracking specifically — both surfaces show the same key)
- Click Generate API Key (or Generate if you're regenerating)
- Copy the value immediately — it's shown exactly once. After you close the dialog, you can only see a masked preview.
- 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
POST /auth/v1/tokenSend your API key in X-API-Key and your store domain in X-Shop-Domain. The body is empty.
curl
curlcurl -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)
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)
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"
}| Field | Use |
|---|---|
access_token | The JWT. Send as Authorization: Bearer <access_token>. |
token_type | Always Bearer. |
expires_in | Lifetime in seconds (3600 = 60 min). |
expires_at | Absolute expiry timestamp — handy for cache TTL. |
jti | Unique JWT ID. Useful for log correlation; reserved for future revocation support. |
issued_at | When 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 family | Limit |
|---|---|
POST /auth/v1/token | 20 requests / 15 min, per API key |
| All other endpoints | 1,000 requests / hour, per merchant |
POST /shipments, PATCH /shipments | 600 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:
- Settings → API Keys in the dashboard
- Generate (or Regenerate if there's an existing key)
- The previous key is invalidated immediately. Any integration using the old key will start returning
401onPOST /auth/v1/token. - 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
400 Bad Request — missing or malformed headersdetail: "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
401 Unauthorized — key not recogniseddetail: "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
.envfile). Trim it before sending. - Wrong environment (dev key against prod URL or vice versa). API keys are environment-scoped.
403 Forbidden — wrong store domain
403 Forbidden — wrong store domaindetail: "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
429 Too Many RequestsYou'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
- Tracking integration — push shipment tracking with the JWT.
- Outbound webhooks — separate auth model (HMAC signing on inbound deliveries, no JWT involved).
- API Reference —
POST /auth/v1/token.