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.
| Credential | Format | Lifetime | Storage | Passed as |
|---|---|---|---|---|
client_id | UUID-ish string, ~30 chars | Until revoked | Cleartext in DB | Body of /auth/token, in logs, in support tickets |
api_key | sk_live_... 44 chars | Until rotated | Argon2id hash in DB | Body of /auth/token only — never in other endpoints |
access_token | JWT (HS256), ~300 chars | 1 hour | Not stored (stateless JWT) | Authorization: Bearer <token> |
refresh_token | rt_... 44 chars | 90 days | Argon2id hash in DB | Body of /auth/refresh |
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), useHttpOnly; Secure; SameSite=Strictcookies — never JavaScript-accessible storage. Refresh tokens must never reach a browser or mobile client — keep them on your server.
- .env file
- Kubernetes secrets
- HashiCorp Vault
- AWS Secrets Manager
# .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;
kubectl create secret generic rd-api-creds \
--from-literal=client_id=rd_client_01h8y3g7z8mnpqrsw \
--from-literal=api_key=sk_live_xxxxx...
Reference in deployment:
env:
- name: RD_CLIENT_ID
valueFrom: { secretKeyRef: { name: rd-api-creds, key: client_id } }
- name: RD_API_KEY
valueFrom: { secretKeyRef: { name: rd-api-creds, key: api_key } }
vault kv put secret/retaildigitals/api \
client_id=rd_client_01h8y3g7z8mnpqrsw \
api_key=sk_live_xxxxx...
Pull at boot with the Vault SDK for your language.
aws secretsmanager create-secret \
--name retaildigitals/api \
--secret-string '{"client_id":"rd_client_...","api_key":"sk_live_..."}'
Pull with the AWS SDK using IAM permissions.
Step 1 — Get an access token
POST /api/v1/auth/token — exchange client credentials for tokens.
- curl
- JavaScript
- PHP
- Python
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\"
}"
async function getToken() {
const res = await fetch('https://images.retaildigitals.com/api/v1/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.RD_CLIENT_ID,
api_key: process.env.RD_API_KEY,
}),
});
if (!res.ok) throw new Error(`Auth failed: ${res.status}`);
return res.json();
}
<?php
function getToken(): array {
$ch = curl_init('https://images.retaildigitals.com/api/v1/auth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'client_id' => getenv('RD_CLIENT_ID'),
'api_key' => getenv('RD_API_KEY'),
]),
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) throw new RuntimeException("Auth failed: $code");
return json_decode($body, true);
}
import os, requests
def get_token() -> dict:
r = requests.post(
"https://images.retaildigitals.com/api/v1/auth/token",
json={
"client_id": os.environ["RD_CLIENT_ID"],
"api_key": os.environ["RD_API_KEY"],
},
)
r.raise_for_status()
return r.json()
Response
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQi...",
"refresh_token": "rt_01h8y3g7z8mnpqrsw...",
"token_type": "Bearer",
"expires_in": 3600
}
| Field | Description |
|---|---|
access_token | JWT (HS256 signed). Attach as Authorization: Bearer <token> on every API call. |
refresh_token | Opaque single-use token. Save it; use to renew when access token expires. |
token_type | Always "Bearer". |
expires_in | Seconds until access token expires (1 hour = 3600 sec). |
Errors
| Status | Body | Cause |
|---|---|---|
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:
- Token signature (HMAC-SHA-256)
- Token not expired (
expclaim) - Token not revoked (blacklist check)
- Client still active
- 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
- JavaScript
curl -X POST https://images.retaildigitals.com/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d "{\"refresh_token\": \"$REFRESH_TOKEN\"}"
async function refreshToken(refreshToken) {
const res = await fetch('https://images.retaildigitals.com/api/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
return res.json();
}
Response
Identical shape to /auth/token:
{
"access_token": "eyJhbGci...", // new
"refresh_token": "rt_...", // new (rotated)
"token_type": "Bearer",
"expires_in": 3600
}
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
| Status | Body | Cause |
|---|---|---|
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.
- JavaScript
- PHP
- Python
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}`);
<?php
class RetailDigitalsClient {
private ?string $accessToken = null;
private ?string $refreshToken = null;
private int $expiresAt = 0;
public function __construct(private string $clientId, private string $apiKey) {}
public function fetch(string $path, array $opts = []): array {
$this->ensureToken();
$url = 'https://images.retaildigitals.com/api/v1' . $path;
$res = $this->rawFetch($url, $opts);
if ($res['code'] === 401) {
$this->refresh();
$res = $this->rawFetch($url, $opts);
}
return $res;
}
private function ensureToken(): void {
if (!$this->accessToken || time() >= $this->expiresAt) {
$this->refreshToken ? $this->refresh() : $this->authenticate();
}
}
private function authenticate(): void {
$res = $this->post('/api/v1/auth/token', [
'client_id' => $this->clientId,
'api_key' => $this->apiKey,
]);
$this->apply($res);
}
private function refresh(): void {
try {
$res = $this->post('/api/v1/auth/refresh', ['refresh_token' => $this->refreshToken]);
$this->apply($res);
} catch (\Throwable $e) {
$this->authenticate();
}
}
private function apply(array $tok): void {
$this->accessToken = $tok['access_token'];
$this->refreshToken = $tok['refresh_token'];
$this->expiresAt = time() + $tok['expires_in'] - 60;
}
private function post(string $path, array $body): array {
$url = 'https://images.retaildigitals.com' . $path;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) throw new RuntimeException("HTTP $code: $body");
return json_decode($body, true);
}
private function rawFetch(string $url, array $opts): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => array_merge(
$opts['headers'] ?? [],
["Authorization: Bearer {$this->accessToken}"],
),
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $code, 'body' => json_decode($body, true)];
}
/** Cheap balance poll — 0 credits, safe to hit repeatedly. */
public function balance(): float {
return (float) $this->fetch('/balance')['body']['credit_balance'];
}
}
// Usage
$rd = new RetailDigitalsClient(getenv('RD_CLIENT_ID'), getenv('RD_API_KEY'));
echo "Remaining credits: " . $rd->balance() . PHP_EOL;
import os, time, requests
class RetailDigitalsClient:
BASE = "https://images.retaildigitals.com/api/v1"
def __init__(self, client_id: str, api_key: str):
self.client_id = client_id
self.api_key = api_key
self.access_token = None
self.refresh_token = None
self.expires_at = 0
def fetch(self, path: str, **kwargs) -> requests.Response:
self._ensure_token()
url = f"{self.BASE}{path}"
headers = kwargs.pop("headers", {}) | {"Authorization": f"Bearer {self.access_token}"}
r = requests.request(kwargs.pop("method", "GET"), url, headers=headers, **kwargs)
if r.status_code == 401:
self._refresh()
headers["Authorization"] = f"Bearer {self.access_token}"
r = requests.request(kwargs.get("method", "GET"), url, headers=headers, **kwargs)
return r
def _ensure_token(self):
if not self.access_token or time.time() >= self.expires_at:
self._refresh() if self.refresh_token else self._authenticate()
def _authenticate(self):
r = requests.post(f"{self.BASE}/auth/token", json={
"client_id": self.client_id, "api_key": self.api_key,
})
r.raise_for_status()
self._apply(r.json())
def _refresh(self):
try:
r = requests.post(f"{self.BASE}/auth/refresh", json={"refresh_token": self.refresh_token})
r.raise_for_status()
self._apply(r.json())
except Exception:
self._authenticate()
def _apply(self, tok):
self.access_token = tok["access_token"]
self.refresh_token = tok["refresh_token"]
self.expires_at = time.time() + tok["expires_in"] - 60
def balance(self) -> float:
"""Cheap balance poll — 0 credits, safe to hit repeatedly."""
return float(self.fetch("/balance").json()["credit_balance"])
# Usage
rd = RetailDigitalsClient(os.environ["RD_CLIENT_ID"], os.environ["RD_API_KEY"])
print(f"Remaining credits: {rd.balance()}")
Rotating credentials
If your api_key leaks (accidental commit, screenshot, log dump), rotate immediately:
- Go to Account → API Keys
- Find the client and click Rotate
- Copy the new
api_key— the old one is invalidated in the next 5 seconds - 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:
- Go to Account → API Keys
- 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
| Endpoint | Limit | On breach |
|---|---|---|
POST /auth/token | 10 / minute per client_id | 429 auth_rate_limit, Retry-After header |
POST /auth/token | 100 / minute per source IP | 429 ip_rate_limit |
POST /auth/refresh | 60 / minute per client_id | 429 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.
Related
- 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