openapi: 3.0.3 info: title: Retail Digitals Image API version: 1.8.2 summary: Kosher CPG product images and metadata over REST. description: | A REST API for studio-shot product photography (front / back, JPG / PNG-transparent) and structured metadata (brand, ingredients, hechshers, nutrition, plus catalog department / category / web description) for **30,000+ kosher CPG product images**. Used by retailers, POS vendors, and marketplaces. ### Getting started 1. Create an account at https://images.retaildigitals.com/register 2. Issue an API client at https://images.retaildigitals.com/account/api-keys 3. Exchange your `client_id + api_key` for an `access_token` via `POST /auth/token` 4. Attach `Authorization: Bearer ` to every subsequent request See the [Getting Started guide](https://images.retaildigitals.com/developers/getting-started) for a complete 5-minute walkthrough. ### Request headers you MUST send | Header | When | Why it matters | |---|---|---| | `Content-Type: application/json` | Every request with a body (`POST`) | Without it the body is parsed as form data, so your fields arrive empty and the request fails validation. | | `Accept: application/json` | Recommended | The API forces JSON on every `/api/v1/*` response, so omitting this no longer breaks anything — but sending it is still good practice and makes intent explicit. | | `Authorization: Bearer ` | Every authenticated endpoint | From `POST /auth/token`. | ### Trailing slashes are accepted `POST /api/v1/auth/token` and `POST /api/v1/auth/token/` both work and both return `200` — API paths are exempt from the site's trailing-slash redirect, so a `POST` is never redirected and never loses its body. The form without the trailing slash is canonical. ### Response headers you can rely on Every `/api/v1/*` response — 2xx, 4xx, 5xx, HEAD, 302 — includes: | Header | Meaning | |---|---| | `X-Request-ID` | Correlation ID (same value as `meta.request_id` in the body). Log this and include it in every support ticket. | | `X-Credits-Debited` | Credits charged by this request (`0` for free endpoints). Read this on `HEAD` responses too. | | `X-Credits-Remaining` | Client balance after this request. Present on authenticated responses only. | | `X-RateLimit-Limit-Minute` / `X-RateLimit-Remaining-Minute` | Per-minute rate-limit bucket. | | `X-RateLimit-Limit-Day` / `X-RateLimit-Remaining-Day` | Per-day rate-limit bucket (authenticated endpoints only). | | `X-RateLimit-Reset` | Unix epoch second when the tightest bucket refills. | | `Content-Type: application/json; charset=utf-8` | UTF-8 explicit for Hebrew / kosher-terminology fields. | ### Roadmap items (not yet available) - Cursor-based pagination on `GET /products` — planned; current offset-based `page` / `per_page` remains supported - `Sunset` / `Deprecation` headers for future v1 → v2 migration — planned - Webhooks for product updates — planned ### Recently shipped - **`Idempotency-Key` header on `POST /products/bulk-check` and `POST /auth/revoke`** — see each endpoint's parameters. 24-hour cache. - **`updated_since` filter on `GET /products`** — pass an ISO date to fetch only products updated on or after that date. contact: name: Retail Digitals API Support email: api@retaildigitals.com url: https://images.retaildigitals.com/developers/support license: name: Proprietary — Commercial use requires paid credits url: https://images.retaildigitals.com/developers/terms servers: - url: https://images.retaildigitals.com/api/v1 description: Production security: - BearerAuth: [] tags: - name: Authentication description: Exchange credentials for access tokens; refresh; revoke. - name: Products description: Retrieve individual products or paginated catalog browsing. - name: Images description: Signed URL delivery for product image bytes. - name: Metadata description: | Which metadata a barcode HAS — by scope and by individual field. Availability only; these endpoints never return metadata values. Use them to avoid paying for a scope that would come back empty (catalogue coverage runs roughly 20–37% per scope). - name: Account description: Query your credit balance, usage, quotas. - name: Meta description: Service health, pricing, and other public metadata. paths: /auth/token: post: tags: [Authentication] summary: Exchange client credentials for an access token description: | Trade your long-lived `client_id + api_key` for a short-lived access token (1 hour) and a long-lived refresh token (90 days). This is the only endpoint that accepts the raw `api_key`. security: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AuthTokenRequest' responses: '200': description: Tokens issued content: application/json: schema: $ref: '#/components/schemas/AuthTokenResponse' '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/InvalidCredentials' } '403': { $ref: '#/components/responses/ClientDisabled' } '429': { $ref: '#/components/responses/RateLimited' } /auth/refresh: post: tags: [Authentication] summary: Refresh an expired access token description: | Trade a `refresh_token` for a new `access_token` (and a new rotated `refresh_token`). Refresh tokens are **single-use** — the response includes a new refresh token; store it and discard the old one. Reusing an already-consumed refresh token revokes all outstanding refresh tokens for the client as a compromise-detection safeguard. security: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AuthRefreshRequest' responses: '200': description: Tokens refreshed content: application/json: schema: $ref: '#/components/schemas/AuthTokenResponse' '400': { $ref: '#/components/responses/BadRequest' } '401': description: Refresh token invalid, expired, or reused content: application/json: schema: $ref: '#/components/schemas/Error' /auth/revoke: post: tags: [Authentication] summary: Revoke the current access + refresh token description: | Explicitly invalidate the caller's tokens. Useful for logout, session end, or when a token is suspected to have leaked. The client itself is not disabled — you can obtain new tokens by calling `/auth/token` again with your credentials. **Idempotent replay:** send `Idempotency-Key: <20-255 chars>` to make retries safe against network hiccups. Replaying the same key within 24 hours returns the cached response with `Idempotency-Replayed: 1` header. parameters: - $ref: '#/components/parameters/IdempotencyKey' responses: '204': { description: Tokens revoked } '401': { $ref: '#/components/responses/Unauthenticated' } '409': description: Idempotency key already used with a different request content: application/json: schema: { $ref: '#/components/schemas/Error' } /products/{barcode}: parameters: - $ref: '#/components/parameters/Barcode' - $ref: '#/components/parameters/Include' - $ref: '#/components/parameters/Variants' get: tags: [Products] summary: Retrieve a single product with metadata + signed image URLs description: | Fetch a full product record by barcode. Returns three independent metadata scopes, selectable via the `include=` query parameter: - **`image_meta`** — per-variant image facts (photo_date, resolution, ownership, size) - **`product_meta`** — brand, ingredients, hechshers, nutrition — the whole product record - **`images`** — signed URLs for each available image variant (15-min TTL, IP-bound, single-use) Use **`variants=`** to explicitly pick which image variants you want URLs for (`front`, `back`, `front_clean`, `back_clean`). When omitted, all available variants are returned. **Pricing is per requested variant** — asking for `variants=front` on a 4-variant product costs 1× `per_image_rate` instead of 4×, so specify what you actually need. See the [Product Metadata Reference](https://images.retaildigitals.com/developers/reference/product-metadata) for the complete schema. responses: '200': description: Product found content: application/json: schema: $ref: '#/components/schemas/ProductResponse' '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthenticated' } '402': { $ref: '#/components/responses/InsufficientCredits' } '403': { $ref: '#/components/responses/ScopeForbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } head: tags: [Products] summary: Check whether a barcode exists (no body returned) description: | Availability check — returns `200` if the barcode exists in the catalog, `404` if not. No response body, no metadata cost — just `X-Credits-Debited: 0.05` in the response headers. Useful for POS autocomplete or bulk-existence checks under 1 barcode at a time. responses: '200': { description: Barcode exists } '401': { $ref: '#/components/responses/Unauthenticated' } '404': { description: Barcode not found } /products: get: tags: [Products] summary: List / search the catalog (paginated) description: | Paginated catalog listing with rich filters. Returns barcodes + variant availability only — for full metadata + images, call `GET /products/{barcode}` per barcode. Free of charge (no credit cost) to encourage discovery. parameters: - name: page in: query schema: { type: integer, minimum: 1, default: 1 } - name: per_page in: query schema: { type: integer, minimum: 1, maximum: 500, default: 50 } - name: min_digits in: query description: Minimum barcode length (mirrors the admin catalog `min_barcode_length` setting) schema: { type: integer, minimum: 1 } - name: max_digits in: query schema: { type: integer } - $ref: '#/components/parameters/UpdatedSince' - name: has_front in: query description: Filter to products with a `front` JPG variant available schema: { type: boolean } - name: has_front_clean in: query schema: { type: boolean } - name: has_back in: query schema: { type: boolean } - name: has_back_clean in: query schema: { type: boolean } - name: ownership in: query description: Filter by ownership tag (e.g. `"Studio A"`) schema: { type: string } - name: search in: query description: Barcode prefix search (e.g. `071662` matches all barcodes starting with 071662) schema: { type: string } responses: '200': description: Paginated list content: application/json: schema: $ref: '#/components/schemas/ProductListResponse' '401': { $ref: '#/components/responses/Unauthenticated' } '429': { $ref: '#/components/responses/RateLimited' } /products/bulk-check: post: tags: [Products] summary: Check existence of up to 500 barcodes in one request description: | Batch existence check — pass up to 500 barcodes, get back which exist and which variants each has. Priced at `products.bulk_check.per_barcode` per barcode checked (see `GET /pricing`). Ideal for nightly marketplace syncs — combine with `updated_since` on `GET /products` to drive incremental catalog sync. **Idempotent replay:** send `Idempotency-Key: <20-255 chars>` to make retries safe. Replaying the same key + same body within 24 hours returns the cached response (with `Idempotency-Replayed: 1` header) — no re-charging. Replaying with a different body returns `409 idempotency_key_mismatch`. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BulkCheckRequest' responses: '200': description: Bulk check result (or replayed cached result) headers: Idempotency-Replayed: schema: { type: string, enum: ['1'] } description: Present with value "1" when the response is a cached replay of a prior Idempotency-Key hit. content: application/json: schema: $ref: '#/components/schemas/BulkCheckResponse' '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthenticated' } '402': { $ref: '#/components/responses/InsufficientCredits' } '409': description: Idempotency key already used with a different request body content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: 'idempotency_key_mismatch', message: 'The Idempotency-Key has been used before with a different request body. Use a fresh key for a new request, or replay the identical body.' } /products/{barcode}/image: parameters: - $ref: '#/components/parameters/Barcode' - name: variant in: query required: true schema: type: string enum: [front, back, front_clean, back_clean] get: tags: [Images] summary: 302 redirect to a fresh signed image URL description: | Convenience endpoint for use in ``. Returns `302 Found` with the `Location` header pointing at a fresh signed URL. The browser follows the redirect, delivering the image bytes. Charged at the same rate as `products.get` for image delivery. responses: '302': description: Redirect to signed URL headers: Location: schema: { type: string, format: uri } '401': { $ref: '#/components/responses/Unauthenticated' } '402': { $ref: '#/components/responses/InsufficientCredits' } '404': { description: Barcode + variant combination not available } /image/{token}: parameters: - name: token in: path required: true description: Signed HMAC-encoded token from a `products` response `images.*` URL schema: { type: string } get: tags: [Images] summary: Delivered signed URL destination description: | Serves the actual image bytes with correct `Content-Type` (`image/jpeg` or `image/png`). Signed URL rules: - **15 minute TTL** — token payload includes `exp` claim - **IP-bound** — must be requested from the same IP that received the URL - **Single-use** — after first successful `GET`, the `jti` is blacklisted - **Watermark applied inline** — global watermark + per-client traceable overlay This endpoint is not directly authenticated — the signed token proves authorization. security: [] responses: '200': description: Image bytes streamed content: image/jpeg: { schema: { type: string, format: binary } } image/png: { schema: { type: string, format: binary } } '403': description: IP mismatch or watermark policy violation '404': description: Token invalid or barcode+variant no longer exists '410': description: Token expired or already consumed (single-use protection) /account: get: tags: [Account] summary: Get current client's balance, quotas, usage description: | Free endpoint. Returns your client's credit balance, current rate-limit budget remaining, and usage counters (today + this month). responses: '200': description: Account snapshot content: application/json: schema: $ref: '#/components/schemas/AccountResponse' '401': { $ref: '#/components/responses/Unauthenticated' } /balance: get: tags: [Account] summary: Quick credit balance check description: | Free, minimal-payload shortcut of `GET /account`. Returns just the current credit balance and tier — nothing else. Use this for cheap high-frequency balance polling (dashboard widgets, pre-flight checks before expensive calls, low-balance alerts). Charged at 0 credits; does not affect rate limits differently from any other request. responses: '200': description: Balance snapshot content: application/json: schema: $ref: '#/components/schemas/BalanceResponse' example: client_id: 'rd_client_01h8y3g7z8mnpqrsw' credit_balance: 500.00 tier: 'standard' meta: request_id: 'req_01H8Y3G7Z8mnpqrsw' credits_debited: 0 credits_remaining: 500.00 response_generated: '2026-07-02T16:30:00-04:00' '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/ClientDisabled' } '429': { $ref: '#/components/responses/RateLimited' } /usage: get: tags: [Account] summary: Historical usage query description: Time-series usage data for the caller's client. Free. parameters: - name: from in: query schema: { type: string, format: date } - name: to in: query schema: { type: string, format: date } - name: granularity in: query schema: type: string enum: [hour, day, month] default: day responses: '200': description: Usage data content: application/json: schema: $ref: '#/components/schemas/UsageResponse' '401': { $ref: '#/components/responses/Unauthenticated' } /health: get: tags: [Meta] summary: Service health check description: No auth required. Returns 200 if the API is operational. security: [] responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/HealthResponse' /pricing: get: tags: [Meta] summary: Current per-action credit prices description: | Snapshot of the current pricing table. Prices can change (rarely) at admin discretion — we announce changes in the changelog with 30 days notice. security: [] responses: '200': description: Pricing table content: application/json: schema: $ref: '#/components/schemas/PricingResponse' /metadata-fields: get: tags: [Metadata] summary: Catalogue of every metadata scope and field description: | Lists every scope and field name that can be used as a key in `has=` / `missing=` on `GET /metadata/products`. A key is either a scope (`nutrition`) or a `scope.field` (`nutrition.calories`). Pass `counts=1` to also get catalogue-wide coverage — how many barcodes have each scope and each individual field — so you can size a sync before running it. Priced at `metadata.fields.catalogue` (see `GET /pricing`). parameters: - name: counts in: query schema: { type: boolean } description: Include catalogue-wide coverage counts per scope and per field. responses: '200': description: Scope and field catalogue content: application/json: schema: { $ref: '#/components/schemas/MetadataFieldsResponse' } '401': { $ref: '#/components/responses/Unauthenticated' } '402': { $ref: '#/components/responses/InsufficientCredits' } /metadata/{barcode}: parameters: - name: barcode in: path required: true schema: { type: string, pattern: '^[0-9]{4,14}$' } description: Barcode or 4-digit PLU code. get: tags: [Metadata] summary: Field-level metadata availability for one barcode description: | Returns which metadata scopes and which individual fields are populated for this barcode — booleans only, never the values themselves. `worth_requesting` is the practical answer: the list of `include=` scopes that would actually return something for this barcode. `GET /products/{barcode}` never charges for a scope that comes back empty, with or without checking this first -- use `worth_requesting` to skip the wasted round-trip on a large sync, not to avoid a charge you would not have paid anyway. Priced at `metadata.get.per_barcode` (see `GET /pricing`). responses: '200': description: Availability map content: application/json: schema: { $ref: '#/components/schemas/MetadataAvailabilityResponse' } '401': { $ref: '#/components/responses/Unauthenticated' } '402': { $ref: '#/components/responses/InsufficientCredits' } '404': description: Barcode not in the catalogue content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: 'not_found' } /metadata/bulk-check: post: tags: [Metadata] summary: Metadata availability for up to 500 barcodes description: | Batch form of `GET /metadata/{barcode}`. Send up to 500 barcodes and get back which metadata scopes each one has. Set `fields: true` for per-field detail instead of scope-level only. Priced per barcode at `metadata.bulk_check.per_barcode`, or `metadata.bulk_check.fields.per_barcode` when `fields: true` (see `GET /pricing`). **Idempotent replay:** send `Idempotency-Key: <20-255 chars>` to make retries safe. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/MetadataBulkCheckRequest' } responses: '200': description: Availability per barcode content: application/json: schema: { $ref: '#/components/schemas/MetadataBulkCheckResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthenticated' } '402': { $ref: '#/components/responses/InsufficientCredits' } /metadata/products: get: tags: [Metadata] summary: List barcodes filtered by the metadata they have or lack description: | Catalogue listing filtered by metadata availability — the discovery counterpart to `GET /metadata/{barcode}`. - `has=` — return only barcodes that have ALL the listed keys - `missing=` — return only barcodes that have NONE of the listed keys A key is a scope (`nutrition`) or a `scope.field` (`nutrition.calories`). Comma-separate multiple keys; they are ANDed. `GET /metadata-fields` lists every valid key. Typical uses: `has=nutrition.calories` to sync only products with usable nutrition panels, or `missing=info` to find the backfill backlog. Priced at `metadata.products.list` (see `GET /pricing`). parameters: - name: has in: query schema: { type: string, maxLength: 400 } description: 'Comma-separated scope or scope.field keys the barcode must ALL have. Example: `nutrition,info.brand`' example: 'nutrition,info.brand' - name: missing in: query schema: { type: string, maxLength: 400 } description: 'Comma-separated keys the barcode must NOT have. Example: `hechshers`' example: 'hechshers' - name: include_metadata in: query schema: { type: boolean } description: Embed the per-scope availability map on each row. - name: include_fields in: query schema: { type: boolean } description: Embed the per-field availability map on each row (implies include_metadata). - name: search in: query schema: { type: string, maxLength: 24 } description: Barcode prefix match. - name: updated_since in: query schema: { type: string, format: date-time } description: Only products updated at or after this timestamp. - name: page in: query schema: { type: integer, minimum: 1 } - name: per_page in: query schema: { type: integer, minimum: 1, maximum: 500 } responses: '200': description: Filtered barcode list content: application/json: schema: { $ref: '#/components/schemas/MetadataProductsResponse' } '401': { $ref: '#/components/responses/Unauthenticated' } '402': { $ref: '#/components/responses/InsufficientCredits' } '422': description: Unknown scope or field key content: application/json: schema: { $ref: '#/components/schemas/Error' } example: error: 'invalid_metadata_key' message: "unknown metadata field 'nutrition.bogus_field'" hint: 'GET /v1/metadata-fields lists every valid scope and field.' components: securitySchemes: BearerAuth: type: http scheme: bearer bearerFormat: JWT description: | Access token obtained from `POST /auth/token`. Attach as `Authorization: Bearer `. Token is a signed JWT (HS256), lives 1 hour. Use `POST /auth/refresh` to renew. parameters: Barcode: name: barcode in: path required: true description: Product barcode (UPC-A, EAN-13, etc). Leading zeros preserved. schema: { type: string, pattern: '^[0-9]+$', minLength: 8, maxLength: 14, example: '017000161563' } Include: name: include in: query description: | Comma-separated list of metadata scopes to return. Each scope has its own admin-set price (see [`GET /pricing`](#tag/Meta/paths/~1pricing/get)). **Fine-grained scopes** (each billed independently): - `info` — brand, product name, description, category (curator-overridable), weight, country, manufacturer, distributor, bracha, boolean dietary flags. Rate key: `products.get.info`. - `hechshers` — array of kosher certifications (symbol, agency, logo, dairy/meat/pareve, additional designation). Rate key: `products.get.hechshers`. - `nutrition` — full nutrition facts (serving size, calories, macros, vitamins, DVs). Rate key: `products.get.nutrition`. - `ingredients` — ingredients text, contains/may-contain statements, structured allergen array. Rate key: `products.get.ingredients`. - `catalog_meta` — catalog-authored merchandising fields: `department` (one of ~18 controlled values), `subcategory` (free text), `web_description` (marketing copy), `variety` (produce-only cultivar), plus a `category` override that appears in the `category` field when set. Rate key: `products.get.catalog_meta`. **Legacy composite alias:** - `product_meta` — requests all 5 fine-grained scopes above at once. Each is still billed independently at its own rate (`products.get.info`, `products.get.hechshers`, etc.) — this is a request-shape convenience, not a flat bundle rate. **Image scopes:** - `image_meta` — per-variant availability metadata (photo date, resolution, ownership). Rate key: `products.get.image_meta`. - `images` — signed image URLs. Rate key: `products.get.per_image` per URL, controlled by the `variants=` parameter. **Omit `include=` → all scopes are requested (default).** **Billing is per-scope and only for data that exists.** A requested scope that returns empty for this barcode (no row, empty array, no active image) costs nothing — cost is computed from what was found, not from what was asked for. This holds whether or not you check [`GET /metadata/{barcode}`](#tag/Metadata/paths/~1metadata~1{barcode}/get) first; that endpoint saves a wasted round-trip on large syncs, it does not change what any single call is billed. Cheapest patterns: - `?include=nutrition` — just nutrition facts, cheapest one-scope read - `?include=info,hechshers` — barcode + kashrus quickly - `?include=catalog_meta` — just catalog-authored fields (department, web copy) - `?include=images&variants=front` — one image URL only - `?include=info,catalog_meta,images&variants=front` — e-commerce PDP fetch Rates are admin-adjustable — always fetch [`GET /pricing`](#tag/Meta/paths/~1pricing/get) at daemon startup rather than hard-coding. See the [Pricing page](https://images.retaildigitals.com/developers/pricing) for the full formula and worked patterns. schema: type: string example: 'info,hechshers' IdempotencyKey: name: Idempotency-Key in: header required: false description: | Client-generated idempotency token. 20-255 chars, URI-unreserved (`A-Za-z0-9._~-`). Replaying the same key + same request body within 24 hours returns the cached response with an `Idempotency-Replayed: 1` header — safe for retries during network hiccups. Replaying with a different body returns `409 idempotency_key_mismatch`. Recommended: use a UUID per logical operation. schema: type: string minLength: 20 maxLength: 255 pattern: '^[A-Za-z0-9._~-]+$' example: 'idem_01h8y3g7z8mnpqrsw_2026-07-02' UpdatedSince: name: updated_since in: query required: false description: | Only return products whose `updated_at` is on or after this timestamp. Ideal for incremental catalog sync — save the current time before a sync, use it as `updated_since` on the next run to fetch just what changed. ISO date or datetime format. schema: type: string format: date-time example: '2026-07-01T00:00:00Z' Variants: name: variants in: query description: | Comma-separated list of image variants to include (`front`, `back`, `front_clean`, `back_clean`). **Omit to return every variant available for the barcode** — up to 4 URLs at the `products.get.per_image` rate each. **Pricing is per requested variant.** Combine with `include=images` to skip metadata scopes you don't need — otherwise you'll still pay the default metadata cost on top. Invalid values return `400 bad_request` with 0 credits charged. Current per-variant rate lives at [`GET /pricing`](#tag/Meta/paths/~1pricing/get) under `actions["products.get.per_image"]`. schema: type: string example: 'front' responses: BadRequest: description: Malformed request content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: 'bad_request', message: 'Missing required field: client_id' } Unauthenticated: description: Missing or invalid access token content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: 'unauthenticated', message: 'Access token expired' } InvalidCredentials: description: client_id + api_key rejected content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: 'invalid_credentials' } ClientDisabled: description: Client has been revoked or expired content: application/json: schema: { $ref: '#/components/schemas/Error' } InsufficientCredits: description: Not enough credits to charge content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: 'insufficient_credits', message: 'Need 2.6 credits; balance is 0.4' } OutOfScope: description: This client is not scoped to see the requested barcode content: application/json: schema: { $ref: '#/components/schemas/Error' } ScopeForbidden: description: | The `include=` query requested one or more scopes that an admin has fully locked for this API key. Response body includes `blocked_scopes` so the caller can retry with a narrower `include=`. Partial (per-field) locks do NOT trigger this — those scopes still return their allowed fields and list what was removed in `meta.redacted_fields`. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: error: scope_forbidden message: 'This API key is not permitted to request scope(s): nutrition, ingredients. Contact your account admin.' blocked_scopes: [nutrition, ingredients] request_id: req_01H8Y3G7Z8 NotFound: description: Barcode not in catalog content: application/json: schema: { $ref: '#/components/schemas/Error' } RateLimited: description: Too many requests headers: Retry-After: schema: { type: integer, description: 'Seconds to wait before retrying' } content: application/json: schema: { $ref: '#/components/schemas/Error' } schemas: AuthTokenRequest: type: object required: [client_id, api_key] properties: client_id: { type: string, example: 'rd_client_01h8y3g7z8mnpqrsw' } api_key: { type: string, example: 'sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' } AuthRefreshRequest: type: object required: [refresh_token] properties: refresh_token: { type: string, example: 'rt_01h8y3g7z8mnpqrsw...' } AuthTokenResponse: type: object properties: access_token: { type: string, description: 'JWT to attach as Bearer token' } refresh_token: { type: string, description: 'Opaque single-use refresh token' } token_type: { type: string, example: 'Bearer' } expires_in: { type: integer, description: 'Seconds until access token expires', example: 3600 } ProductResponse: type: object properties: barcode: { type: string, example: '017000161563' } image_meta: nullable: true $ref: '#/components/schemas/ImageMetaBlock' product_meta: nullable: true $ref: '#/components/schemas/ProductMetaBlock' images: nullable: true $ref: '#/components/schemas/ImagesBlock' meta: $ref: '#/components/schemas/ResponseMeta' ImageMetaBlock: description: Facts about the physical image files (dates, resolution, ownership). type: object properties: front: { $ref: '#/components/schemas/ImageMetaEntry' } back: { $ref: '#/components/schemas/ImageMetaEntry' } front_clean: { $ref: '#/components/schemas/ImageMetaEntry' } back_clean: { $ref: '#/components/schemas/ImageMetaEntry' } ImageMetaEntry: nullable: true description: null when this variant doesn't exist for the product type: object properties: photo_date: { type: string, format: date, description: 'When the shutter fired', example: '2023-10-19' } upload_date: { type: string, format: date-time, description: 'When landed on server (server-local UTC-4)' } resolution: { type: string, example: '1000x1000' } ownership: { type: string, example: 'Studio A' } uploaded_by: { type: string, nullable: true, example: 'Photographer 1' } file_size_bytes: { type: integer, example: 184521 } source_batch: type: string example: 'zip_upload' description: | Import-batch tag. Two stable values customers can rely on: `zip_upload` (the standard admin bulk-upload path) and `review_approved` (approved via the pending-review queue). Additional values may appear for one-off operational batches (e.g. legacy migrations); treat this as an open string, not a closed enum. ProductMetaBlock: description: Product-level metadata — brand, ingredients, hechshers, nutrition. type: object properties: brand: { type: string, nullable: true, example: "Example Brand" } product_name: { type: string, nullable: true } product_description: { type: string, nullable: true } net_weight: { type: string, nullable: true, example: 'Net Wt 20 oz (1 lb 4 oz / 567 g)' } category: { type: string, nullable: true, example: 'frozen' } country_of_origin: { type: string, nullable: true, example: 'United States' } manufacturer: { type: string, nullable: true } distributor: { type: string, nullable: true } additional_certifications: { type: string, nullable: true } bracha: type: string nullable: true enum: [hamotzi, mezonos, hagafen, haetz, haadama, shehakol] kosher_for_passover: { type: boolean } gluten_free: { type: boolean } vegan: { type: boolean } organic: { type: boolean } hechshers: type: array items: { $ref: '#/components/schemas/Hechsher' } ingredients: { $ref: '#/components/schemas/Ingredients' } nutrition: nullable: true $ref: '#/components/schemas/Nutrition' notes: { type: string, nullable: true } scanned_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } Hechsher: type: object properties: symbol: { type: string, example: 'OK' } org: { type: string, nullable: true, example: 'OK Kosher Certification' } logo: { type: string, nullable: true, example: 'ok.png' } dairy_meat_pareve: type: string nullable: true enum: [dairy, meat, pareve] additional_designation: type: string nullable: true example: 'Pas Yisroel' description: | Free-text designations layered on top of the base certification. Common values: Cholov Yisroel, Pas Yisroel, Yoshon, Bishul Yisroel, Non-Gebrokts, Kosher for Passover. Ingredients: type: object properties: ingredients_text: { type: string, nullable: true } contains_statement: { type: string, nullable: true, example: 'Contains: Fish (Pollock), Wheat.' } may_contain_statement: { type: string, nullable: true } allergens: type: array items: type: string example: 'wheat' description: | Normalized allergen names. Common values: milk, eggs, fish, shellfish, tree nuts, peanuts, wheat, soy, sesame, sulfites, gluten, crustacean shellfish, mollusks. Nutrition: type: object properties: servings_per_container: { type: string, nullable: true, example: 'About 5' } serving_size: { type: string, nullable: true, example: '4 sticks (112g)' } calories: { type: string, nullable: true, example: '230' } total_fat: { $ref: '#/components/schemas/NutritionEntry' } saturated_fat: { $ref: '#/components/schemas/NutritionEntry' } trans_fat: { $ref: '#/components/schemas/NutritionEntry' } cholesterol: { $ref: '#/components/schemas/NutritionEntry' } sodium: { $ref: '#/components/schemas/NutritionEntry' } total_carbohydrate: { $ref: '#/components/schemas/NutritionEntry' } dietary_fiber: { $ref: '#/components/schemas/NutritionEntry' } total_sugars: { $ref: '#/components/schemas/NutritionEntry' } added_sugars: { $ref: '#/components/schemas/NutritionEntry' } protein: { $ref: '#/components/schemas/NutritionEntry' } vitamin_d: { $ref: '#/components/schemas/NutritionEntry' } calcium: { $ref: '#/components/schemas/NutritionEntry' } iron: { $ref: '#/components/schemas/NutritionEntry' } potassium: { $ref: '#/components/schemas/NutritionEntry' } NutritionEntry: type: object properties: amount: { type: string, nullable: true, example: '8g' } dv: { type: string, nullable: true, description: '% Daily Value', example: '10%' } ImagesBlock: description: Signed URLs for each available variant. null when variant doesn't exist. type: object properties: front: { type: string, format: uri, nullable: true } back: { type: string, format: uri, nullable: true } front_clean: { type: string, format: uri, nullable: true } back_clean: { type: string, format: uri, nullable: true } ResponseMeta: type: object required: [request_id, credits_debited, credits_remaining, response_generated] properties: request_id: { type: string, example: 'req_01H8Y3G7Z8mnpqrsw' } credits_debited: { type: number, format: float, example: 4.6 } credits_remaining: { type: number, format: float, example: 4321.8 } response_generated: { type: string, format: date-time } redacted_fields: type: array description: | Only present when an admin has locked one or more fields on this API key (partial lock). Lists the dotted paths that were removed from the response — e.g. `info.brand`, `nutrition.calories`, `images.back_clean`. Whole-scope locks return `403 scope_forbidden` instead and never appear here. items: { type: string } example: [info.brand, nutrition.total_sugars, images.back_clean] ProductListResponse: type: object properties: data: type: array items: type: object properties: barcode: { type: string } variants: type: array items: type: string enum: [front, back, front_clean, back_clean] pagination: type: object properties: page: { type: integer } per_page: { type: integer } total: { type: integer } total_pages: { type: integer } meta: { $ref: '#/components/schemas/ResponseMeta' } BulkCheckRequest: type: object required: [barcodes] properties: barcodes: type: array minItems: 1 maxItems: 500 items: { type: string } BulkCheckResponse: type: object properties: results: type: object additionalProperties: type: object properties: exists: { type: boolean } variants: type: array items: { type: string } meta: { $ref: '#/components/schemas/ResponseMeta' } AccountResponse: type: object properties: client_id: { type: string } client_name: { type: string } tier: { type: string, enum: [free, standard, enterprise] } credit_balance: { type: number, format: float, example: 4327.4 } usage: type: object properties: requests_today: { type: integer } requests_this_month: { type: integer } credits_spent_today: { type: number, format: float } credits_spent_this_month: { type: number, format: float } rate_limits: type: object properties: requests_per_minute: { type: integer } requests_per_day: { type: integer } remaining_this_minute: { type: integer } remaining_today: { type: integer } scope: { type: object, description: 'Barcode scope this client can access' } BalanceResponse: type: object description: | Minimal shortcut response from `GET /balance` — only the fields you need for a cheap balance-poll widget. For full details, use `GET /account`. required: [client_id, credit_balance, tier, meta] properties: client_id: { type: string, example: 'rd_client_01h8y3g7z8mnpqrsw' } credit_balance: type: number format: float description: Credits remaining on this client example: 500.00 tier: type: string enum: [free, standard, enterprise] example: standard meta: { $ref: '#/components/schemas/ResponseMeta' } UsageResponse: type: object properties: data: type: array items: type: object properties: timestamp: { type: string, format: date-time } endpoint: { type: string } request_count: { type: integer } credits_spent: { type: number, format: float } error_count: { type: integer } meta: { $ref: '#/components/schemas/ResponseMeta' } HealthResponse: type: object properties: status: { type: string, enum: [ok, degraded, down] } version: { type: string, example: '1.0.0' } timestamp: { type: string, format: date-time } PricingResponse: type: object description: | The live pricing model. Every `GET /products/{barcode}` charges the sum of the individual scope rates for whatever `include=` scopes actually returned data, plus one `products.get.per_image` per image variant that actually exists and was requested. A requested scope or variant that comes back empty for that barcode is never charged. The legacy `include=product_meta` alias requests all 5 fine-grained metadata scopes at once; each is still billed independently, not at a flat bundle rate. The `actions` map is the raw per-unit rates from the DB; `descriptions` explains what each key charges for. Read `notes` for the cheapest request patterns. required: [currency, pricing_model, formula, actions] properties: currency: { type: string, example: credits } pricing_model: type: string example: per_scope_plus_per_variant description: Identifier of the current pricing scheme formula: type: string example: 'credits_per_get = Σ(scope_rate × scopes_with_data) + (per_image_rate × image_variants_that_exist)' description: Plain-language formula for the compound cost of GET /products/{barcode} notes: type: array description: Cheapest-usage guidance for common patterns items: { type: string } example: - 'Cheapest one-scope read: ?include=nutrition (1× products.get.nutrition).' - 'Cheapest e-commerce pull: ?include=info,hechshers,catalog_meta,images&variants=front.' - 'Omitting include= requests every scope; you are only billed for the ones that return data.' actions: type: object description: Per-unit rates from the DB (safe to fetch and cache) additionalProperties: { type: number, format: float } example: products.get: 7.0 products.get.info: 2.0 products.get.hechshers: 1.5 products.get.nutrition: 1.5 products.get.ingredients: 1.0 products.get.catalog_meta: 1.0 products.get.image_meta: 0.5 products.get.per_image: 1.0 products.head: 0.05 products.list: 0 products.bulk_check.per_barcode: 0.01 image.get: 1 account.get: 0 descriptions: type: object description: Human-readable per-action explanations. Read these to understand what each `actions` rate covers. additionalProperties: { type: string } example: products.get: 'Billing action label for GET /products/{barcode}. Each fine-grained scope (info, hechshers, nutrition, ingredients, catalog_meta, image_meta) is billed at its own rate below, and only when it actually returns data for that barcode.' products.get.per_image: 'Per signed image URL returned. Use ?variants=front (etc) to limit which URLs are returned and pay only for what you display.' MetadataScopeMap: type: object description: One boolean per metadata scope — true when that scope has any data for the barcode. additionalProperties: { type: boolean } example: info: true hechshers: true nutrition: true ingredients: false catalog_meta: true image_meta: true MetadataFieldMap: type: object description: Per-scope map of individual field names to whether that field is populated. additionalProperties: type: object additionalProperties: { type: boolean } example: nutrition: { calories: true, serving_size: true, vitamin_d: false } info: { brand: true, product_name: true, organic: false } MetadataAvailabilityResponse: type: object properties: barcode: { type: string, example: '000413000222' } metadata: { $ref: '#/components/schemas/MetadataScopeMap' } metadata_fields: { $ref: '#/components/schemas/MetadataFieldMap' } image_variants: type: array items: { type: string, enum: [front, back, front_clean, back_clean] } worth_requesting: type: array description: The include= scopes that would actually return data for this barcode. GET /products/{barcode} never charges for a scope outside this list either -- empty scopes are always free -- so this is for planning efficient syncs, not avoiding a charge. items: { type: string } example: ['info', 'nutrition', 'catalog_meta', 'images'] meta: { $ref: '#/components/schemas/ResponseMeta' } MetadataFieldsResponse: type: object properties: scopes: type: array items: type: object properties: scope: { type: string, example: 'nutrition' } source_fields: type: array items: { type: string } example: ['serving_size', 'calories', 'total_fat'] barcodes_with_scope: type: integer description: Present only when counts=1. example: 6824 barcodes_with_field: type: object description: Present only when counts=1. Barcode count per individual field. additionalProperties: { type: integer } total_products: { type: integer, nullable: true, example: 30313 } usage: type: object description: Ready-made example calls for each way of querying availability. additionalProperties: { type: string } meta: { $ref: '#/components/schemas/ResponseMeta' } MetadataBulkCheckRequest: type: object required: [barcodes] properties: barcodes: type: array minItems: 1 maxItems: 500 items: { type: string, pattern: '^[0-9]{4,14}$' } example: ['000413000222', '3035'] fields: type: boolean description: Return per-field availability instead of scope-level only. Charged at the fields rate. example: false MetadataBulkCheckResponse: type: object properties: results: type: object description: Keyed by the barcode you sent. additionalProperties: type: object properties: exists: { type: boolean } metadata: { $ref: '#/components/schemas/MetadataScopeMap' } metadata_fields: { $ref: '#/components/schemas/MetadataFieldMap' } example: '000413000222': exists: true metadata: { info: true, nutrition: true, ingredients: false } '0000000000000': exists: false meta: { $ref: '#/components/schemas/ResponseMeta' } MetadataProductsResponse: type: object properties: data: type: array items: type: object properties: barcode: { type: string } variants: type: array items: { type: string, enum: [front, back, front_clean, back_clean] } metadata: { $ref: '#/components/schemas/MetadataScopeMap' } metadata_fields: { $ref: '#/components/schemas/MetadataFieldMap' } filters: type: object description: Echo of the has/missing filters applied. properties: has: { type: string, nullable: true } missing: { type: string, nullable: true } pagination: type: object properties: page: { type: integer } per_page: { type: integer } total: { type: integer } total_pages: { type: integer } meta: { $ref: '#/components/schemas/ResponseMeta' } Error: type: object required: [error] properties: error: type: string description: Stable error code — safe to switch on in client code example: 'invalid_credentials' message: type: string description: Human-readable message; may change wording, don't switch on request_id: type: string example: 'req_01H8Y3G7Z8mnpqrsw'