Skip to main content

Authentication

The API uses a two-step credential exchange: your long-lived client_id + api_key are exchanged once for a short-lived access token (a JWT — a signed, self-contained string that carries your identity + expiry claims) and a long-lived refresh token. Every subsequent API call uses only the access token; the api_key never leaves the auth endpoint.

┌──────────────┐ POST /auth/token ┌─────────────────────┐
│ │ {client_id, api_key} │ │
│ Your app │──────────────────────►│ Retail Digitals │
│ │◄──────────────────────│ Auth server │
│ │ {access, refresh} │ │
└──────┬───────┘ └─────────────────────┘

│ GET /products/{barcode}
│ Authorization: Bearer <access>


┌──────────────┐ ┌─────────────────────┐
│ │──────────────────────►│ │
│ Your app │ │ API endpoints │
│ │◄──────────────────────│ │
└──────────────┘ └─────────────────────┘

Why two-step?

Client credentials (client_id + api_key) are your identity. They live for months or years. Passing them on every request is high-risk — if a proxy logs a header, a screenshot leaks, or a JS error is reported to a tool like Sentry, an attacker gets long-lived access.

Access tokens live 1 hour. Even if one leaks, its blast radius is tiny — an hour of impersonation before it self-expires. Refresh tokens live 90 days but are single-use (rotate on every use) and can be revoked instantly.

You trade one extra request at the start of each session for dramatically reduced credential exposure.


Credentials

Issued via Account → API Keys.

CredentialFormatLifetimeStoragePassed as
client_idUUID-ish string, ~30 charsUntil revokedCleartext in DBBody of /auth/token, in logs, in support tickets
api_keysk_live_... 44 charsUntil rotatedArgon2id hash in DBBody of /auth/token only — never in other endpoints
access_tokenJWT (HS256), ~300 chars1 hourNot stored (stateless JWT)Authorization: Bearer <token>
refresh_tokenrt_... 44 chars90 daysArgon2id hash in DBBody of /auth/refresh
warning

api_key is shown exactly once — at creation time. Copy it immediately. If you lose it, come back to Account → API Keys and click Rotate (which invalidates the old key and generates a new one). You cannot retrieve a past key.

Storing credentials safely

Two rules that trip up most first-time integrations:

  • api_key (the long-lived secret) is server-side only. Never embed it in mobile apps, browser JavaScript, or anything shipped to end users — treat it like a database password. If it ever leaks, rotate immediately.
  • Access tokens (JWTs) live in process memory only. Never localStorage, sessionStorage, or plain cookies. If you must persist them client-side (e.g., across page reloads in a SPA), use HttpOnly; Secure; SameSite=Strict cookies — never JavaScript-accessible storage. Refresh tokens must never reach a browser or mobile client — keep them on your server.
# .env — never commit
RD_CLIENT_ID=rd_client_01h8y3g7z8mnpqrsw
RD_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Add .env to your .gitignore before your first commit:

echo '.env' >> .gitignore && git rm --cached .env 2>/dev/null || true

Load in your app:

// Node.js
require('dotenv').config();
process.env.RD_CLIENT_ID;

Step 1 — Get an access token

POST /api/v1/auth/token — exchange client credentials for tokens.

curl -X POST https://images.retaildigitals.com/api/v1/auth/token \
-H "Content-Type: application/json" \
-d "{
\"client_id\": \"$RD_CLIENT_ID\",
\"api_key\": \"$RD_API_KEY\"
}"

Response

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQi...",
"refresh_token": "rt_01h8y3g7z8mnpqrsw...",
"token_type": "Bearer",
"expires_in": 3600
}
FieldDescription
access_tokenJWT (HS256 signed). Attach as Authorization: Bearer <token> on every API call.
refresh_tokenOpaque single-use token. Save it; use to renew when access token expires.
token_typeAlways "Bearer".
expires_inSeconds until access token expires (1 hour = 3600 sec).

Errors

StatusBodyCause
400 Bad Request{"error": "missing_credentials"}client_id or api_key not in request body
401 Unauthorized{"error": "invalid_credentials"}client_id unknown or api_key doesn't match
403 Forbidden{"error": "client_disabled"}Client has been revoked or expired
429 Too Many Requests{"error": "auth_rate_limit"}You've hit the 10 auth attempts / minute limit — back off

Enumeration-resistance note. Both "unknown client_id" and "wrong api_key" return the identical 401 invalid_credentials response — this is intentional. It prevents attackers from probing which client_id values exist. If your SOC tries to differentiate the two client-side, they can't (and shouldn't).

Token integrity note. Access tokens are HS256-signed only. alg: none, alg: RS256, and signature-stripped tokens are all rejected with 401 unauthenticated. Basic scheme and query-string tokens are rejected — Bearer only.


Step 2 — Make authenticated API calls

Attach the access token to every request:

curl https://images.retaildigitals.com/api/v1/products/017000161563 \
-H "Authorization: Bearer $ACCESS_TOKEN"

The server verifies:

  1. Token signature (HMAC-SHA-256)
  2. Token not expired (exp claim)
  3. Token not revoked (blacklist check)
  4. Client still active
  5. Rate limits not exceeded

If all pass, your request is served and one row is added to the audit log.


Refreshing tokens

Access tokens expire after 1 hour. Use your refresh_token to get a new access token without needing your original credentials:

POST /api/v1/auth/refresh

curl -X POST https://images.retaildigitals.com/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d "{\"refresh_token\": \"$REFRESH_TOKEN\"}"

Response

Identical shape to /auth/token:

{
"access_token": "eyJhbGci...", // new
"refresh_token": "rt_...", // new (rotated)
"token_type": "Bearer",
"expires_in": 3600
}
info

Refresh tokens are single-use. When you consume one, we invalidate it and issue a new one in the response. Store the new refresh token; discard the old one. If you try to reuse an already-consumed refresh token, we assume it was leaked and revoke all outstanding refresh tokens for your client_id as a safety measure — you'll need to re-authenticate with your client_id + api_key.

Errors

StatusBodyCause
400 Bad Request{"error": "missing_refresh_token"}No refresh_token in body
401 Unauthorized{"error": "refresh_token_expired"}Token is > 90 days old
401 Unauthorized{"error": "refresh_token_reused"}Someone already used this token — likely compromised, all refresh tokens for this client have been revoked. Log in again.
403 Forbidden{"error": "client_disabled"}Your client has been revoked

The suggested pattern

Build a thin auth wrapper that transparently handles token expiry. Every real API call goes through the wrapper — the wrapper checks token validity, refreshes if needed, and retries once on 401.

class RetailDigitalsClient {
constructor({ clientId, apiKey }) {
this.clientId = clientId;
this.apiKey = apiKey;
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = 0;
}

async #authenticate() {
const res = await fetch('https://images.retaildigitals.com/api/v1/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: this.clientId, api_key: this.apiKey }),
});
if (!res.ok) throw new Error(`Auth failed: ${res.status}`);
const { access_token, refresh_token, expires_in } = await res.json();
this.accessToken = access_token;
this.refreshToken = refresh_token;
this.expiresAt = Date.now() + (expires_in - 60) * 1000; // renew 60s early
}

async #refresh() {
const res = await fetch('https://images.retaildigitals.com/api/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: this.refreshToken }),
});
if (!res.ok) return this.#authenticate(); // fall back to full auth
const { access_token, refresh_token, expires_in } = await res.json();
this.accessToken = access_token;
this.refreshToken = refresh_token;
this.expiresAt = Date.now() + (expires_in - 60) * 1000;
}

async #ensureToken() {
if (!this.accessToken) return this.#authenticate();
if (Date.now() >= this.expiresAt) return this.#refresh();
}

async fetch(path, init = {}) {
await this.#ensureToken();
const url = `https://images.retaildigitals.com/api/v1${path}`;
const res = await fetch(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${this.accessToken}` },
});
if (res.status === 401) { // race: token expired mid-call
await this.#refresh();
return fetch(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${this.accessToken}` },
});
}
return res;
}
}

// Usage:
const rd = new RetailDigitalsClient({
clientId: process.env.RD_CLIENT_ID,
apiKey: process.env.RD_API_KEY,
});

// Default fetch — one credit per available image variant (up to 4)
const product = await rd.fetch('/products/017000161563').then(r => r.json());

// Cheaper: only ask for the front image (1 credit instead of up to 4)
const productFrontOnly = await rd
.fetch('/products/017000161563?include=images&variants=front')
.then(r => r.json());
console.log(productFrontOnly.images.front); // URL
console.log(productFrontOnly.images.back); // null — you didn't request it

// Cheap balance poll — 0 credits debited
const { credit_balance } = await rd.fetch('/balance').then(r => r.json());
console.log(`Remaining credits: ${credit_balance}`);

Rotating credentials

If your api_key leaks (accidental commit, screenshot, log dump), rotate immediately:

  1. Go to Account → API Keys
  2. Find the client and click Rotate
  3. Copy the new api_key — the old one is invalidated in the next 5 seconds
  4. Update your .env / secret store / deployment config

All outstanding access tokens and refresh tokens for the rotated client are invalidated. Any deployed service running with the old credentials will hit 401 invalid_credentials and fail. Deploy new credentials before the rotation window elapses to avoid an outage.

For zero-downtime rotation on production traffic: create a second client first (My App v2), deploy your service with the new credentials, verify traffic, then revoke the old client. This gives you a rollback window.


Revoking credentials

To fully disable a client without generating new credentials:

  1. Go to Account → API Keys
  2. Find the client and click Revoke

The client is marked revoked_at = now(). All future requests (with any access token derived from it) return 403 client_disabled. Cannot be un-revoked — issue a new client if needed.

Admin can revoke too, via /admin/api-clients/{id}/revoke — used in incident response.


Rate limits on auth endpoints

EndpointLimitOn breach
POST /auth/token10 / minute per client_id429 auth_rate_limit, Retry-After header
POST /auth/token100 / minute per source IP429 ip_rate_limit
POST /auth/refresh60 / minute per client_id429 refresh_rate_limit

These are separate from your data-endpoint rate limits. You won't accidentally exhaust your data quota by refreshing tokens.

Failed-auth backoff: 5 failed /auth/token attempts in a row triggers a 5-minute lockout on that client_id. Successful auth resets the counter.


  • Rate limits — full data-endpoint rate limit tables
  • Errors — every error code explained
  • Security — signed URLs, IP binding, traceable watermarks
  • SDKs — official libraries with auth built in