OBX Protocol (read side) (0.1.0)

Download OpenAPI specification:

How retailers' software discovers a brand node, gets access and pulls product content.

Normative. The Open Brand Exchange (OBX) Protocol is the read side of a brand node: a retailer's software (shop, PIM, ERP, content hub) discovers the node, requests access, authenticates with OAuth2 client credentials and pulls brands, products, variants (with GTIN) and final images with role, view, order, rights and AI provenance.

The prose specification in docs/protocol.md is authoritative where this file is silent. Key words MUST, SHOULD, MAY are to be interpreted as described in RFC 2119.

Conventions for every endpoint:

  • JSON is UTF-8, field names are snake_case, timestamps are RFC 3339.
  • Clients MUST ignore unknown fields, unknown enum values and unknown change types. Extension fields are prefixed x-<vendor>- (for example x-acme-crop).
  • Errors are application/problem+json (RFC 9457) with an obx_error code, except the OAuth2 token endpoint which uses RFC 6749 error JSON.
  • Responses carry OBX-Version: 0.1 and rate limit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset); 429 carries Retry-After.
  • A retailer never sees more than it could see in the brand's portal: only published objects of brands in its grant.

Discovery

Find out what a node offers.

Discovery document

Public. MUST be served with Access-Control-Allow-Origin: * and SHOULD be cacheable for 5 minutes. It is the only endpoint with CORS headers; all others send none (security.md section 4).

Responses

Response samples

Content type
application/json
{}

Access

Request access and obtain tokens.

Request access (unauthenticated)

Creates a retailer access request that the brand approves or rejects in its admin. Unauthenticated and rate limited (RECOMMENDED: 5 per hour per IP). The response is the same whether or not the brand already knows the retailer (no enumeration). Nodes whose brand disabled requests answer 403 forbidden.

Request Body schema: application/json
required
required
object
required
object
access
Array of strings non-empty unique
Items Enum: "portal" "api"

What is requested: portal account and/or API client credentials. Default ["portal"].

requested_brand_ids
Array of strings or null (Id) [ items^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$ ]

Brands of interest; null = all.

message
string or null <= 2000 characters
^x-[a-z0-9]+-[a-z0-9_-]+$
pattern property
any

Responses

Request samples

Content type
application/json
{
  • "retailer": {
    },
  • "contact": {},
  • "access": [
    ],
  • "requested_brand_ids": null,
  • "message": "We stock Kestrel since 2024."
}

Response samples

Content type
application/json
{}

Poll an access request

Authenticated with the request_token from the 202 response as Bearer token. After approval with API access the response contains credentials with the client_secret exactly once (first read); later reads return client_secret: null and claimed: true. Poll at most once per minute.

Authorizations:
requestToken
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/json
Example
{}

OAuth2 token endpoint (client credentials)

RFC 6749 section 4.4. Nodes MUST support client_secret_basic and MAY support client_secret_post. scope is optional; when absent all scopes of the client are granted. Requesting a scope the client does not have yields invalid_scope. A revoked client or blocked retailer yields invalid_client. Access tokens SHOULD live 3600 s; there are no refresh tokens (request a new token).

Authorizations:
clientBasic
Request Body schema: application/x-www-form-urlencoded
required
grant_type
required
string
Value: "client_credentials"
scope
string

Space-separated subset of catalog:read media:read media:original usage:write.

client_id
string

Only for client_secret_post.

client_secret
string

Only for client_secret_post.

Responses

Response samples

Content type
application/json
{
  • "access_token": "obxa_ZXhhbXBsZS1hY2Nlc3MtdG9rZW4",
  • "token_type": "Bearer",
  • "expires_in": 3600,
  • "scope": "catalog:read media:read"
}

Who am I and what may I see

Authorizations:
oauth2

Responses

Response samples

Content type
application/json
{
  • "id": "grt_3",
  • "retailer": {
    },
  • "brand_ids": [
    ],
  • "scopes": [
    ],
  • "original_allowed": false,
  • "status": "active",
  • "updated_at": "2026-09-28T10:00:00Z"
}

Catalog

Brands, products, variants and GTIN lookups.

Brands visible to the caller

Authorizations:
oauth2

Responses

Response samples

Content type
application/json
{
  • "items": [
    ]
}

One brand

Authorizations:
oauth2
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/json
{
  • "id": "prd_01J9ZK3Q7T5W",
  • "external_id": "KO-1001",
  • "name": "string",
  • "logo_url": "http://example.com",
  • "website": "http://example.com",
  • "description": { },
  • "updated_at": "2026-09-28T10:00:00Z"
}

Products with variants and assets

Keyset-paginated, ordered by id ascending. For synchronisation use /changes; this endpoint is for browsing and targeted reads. Objects outside the caller's grant are never returned (unknown ids in ids are silently omitted).

Authorizations:
oauth2
query Parameters
ids
string

Comma-separated product ids (max 100). Other filters are ignored when set.

brand_id
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: brand_id=prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

updated_since
string <date-time> (DateTime)
Examples: updated_since=2026-09-28T10:00:00Z

Products whose own data, a variant or an asset changed at or after this time.

cursor
string (Cursor) [ 1 .. 4096 ] characters

Opaque pagination or feed cursor. Clients MUST NOT parse or construct it.

limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "next_cursor": "string"
}

One product with variants and assets

Authorizations:
oauth2
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/json
{
  • "id": "prd_01J9ZK3Q7T5W",
  • "external_id": "KO-1001",
  • "brand_id": "prd_01J9ZK3Q7T5W",
  • "style_number": "string",
  • "name": { },
  • "description": { },
  • "item_type": "string",
  • "gpc_brick": "string",
  • "target_group": "string",
  • "season": "string",
  • "collection": "string",
  • "materials": [
    ],
  • "care": [
    ],
  • "country_of_origin": "NL",
  • "dpp_url": "http://example.com",
  • "attributes": {
    },
  • "created_at": "2026-09-28T10:00:00Z",
  • "updated_at": "2026-09-28T10:00:00Z",
  • "variants": [
    ],
  • "assets": [
    ]
}

Look up products by GTIN (short lists)

Same as the POST form, for up to 100 codes in the query string.

Authorizations:
oauth2
query Parameters
gtin
required
string

Comma-separated codes (max 100).

Responses

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "products": [
    ]
}

Look up products by GTIN (up to 2000)

Each input is trimmed and spaces removed; 8, 12, 13 or 14 digits are normalised to GTIN-14 by left-padding zeros and compared with Variant.gtin. Check digits are not validated here (a wrong check digit simply won't match). Other inputs are invalid. Duplicate inputs (after normalisation) are reported once. More than 2000 inputs: 400 limit_exceeded.

Authorizations:
oauth2
Request Body schema: application/json
required
gtins
required
Array of strings <= 2000 items [ items <= 64 characters ]

Responses

Request samples

Content type
application/json
{
  • "gtins": [
    ]
}

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "products": [
    ]
}

Changes

The change feed, the heart of synchronisation.

Change feed

Without cursor the node starts a snapshot: one upsert per currently visible object (brands, then products, then variants, then assets), phase: snapshot; then the feed switches to phase: live at the sequence number where the snapshot started. With a cursor it returns entries after it. Pages never mix phases; the last snapshot page has has_more: true.

Entries are at-least-once: the same object may appear several times, data is always the CURRENT representation. Deletes are tombstones for anything no longer visible to the caller. Tombstones are kept at least 90 days; an older cursor yields 410 cursor_expired (restart without cursor). A grant entry with resync: true means restart without a cursor. next_cursor is always returned; poll again later (RECOMMENDED: not more often than every 60 s when has_more is false, or on a changes.available webhook).

Authorizations:
oauth2
query Parameters
cursor
string (Cursor) [ 1 .. 4096 ] characters

Opaque pagination or feed cursor. Clients MUST NOT parse or construct it.

limit
integer [ 1 .. 500 ]
Default: 100

Page size, 1-500 (default 100). Nodes MAY return fewer.

Responses

Response samples

Content type
application/json
{
  • "changes": [
    ],
  • "next_cursor": "bGl2ZToxMDQ0",
  • "has_more": false,
  • "phase": "live"
}

Media

Assets and rendition downloads.

One asset (fresh rendition URLs)

Use this to refresh expired signed rendition URLs.

Authorizations:
oauth2
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/json
{
  • "id": "prd_01J9ZK3Q7T5W",
  • "external_id": "KO-1001",
  • "product_id": "prd_01J9ZK3Q7T5W",
  • "brand_id": "prd_01J9ZK3Q7T5W",
  • "media_type": "image",
  • "color_code": "string",
  • "variant_ids": [
    ],
  • "role": "packshot",
  • "view": "front",
  • "position": 1,
  • "filename": "string",
  • "alt_text": { },
  • "content_type": "image/jpeg",
  • "width": 1,
  • "height": 1,
  • "bytes": 1,
  • "sha256": "string",
  • "rights": {
    },
  • "provenance": {
    },
  • "renditions": {
    },
  • "updated_at": "2026-09-28T10:00:00Z"
}

Download a rendition

Authorised by Bearer token (scope media:read; media:original for original) OR by a valid signature in the URL as handed out in Rendition.url. Supports Range (206), If-None-Match (304). ETag is the quoted sha256; OBX-Content-SHA256 is the sha256 of the full representation. original bytes are served exactly as uploaded (embedded C2PA is preserved). Nodes log each download for the brand's statistics.

Authorizations:
oauth2signedUrl
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

name
required
string (RenditionName)
Enum: "original" "web" "square" "thumb"

Rendition name.

header Parameters
Range
string
If-None-Match
string

Responses

Response samples

Content type
application/problem+json
{
  • "title": "string",
  • "status": 400,
  • "detail": "string",
  • "instance": "string",
  • "obx_error": "invalid_request",
  • "errors": [
    ],
  • "retry_after": 0
}

Exports

Bulk exports as NDJSON manifest plus zip parts.

Start a bulk export

Capability exports. Asynchronous; poll GET /exports/{id} (RECOMMENDED every 10 s) or subscribe to export.completed. Nodes MAY limit concurrent exports per client (409 conflict with the running export id in detail).

Authorizations:
oauth2
header Parameters
Idempotency-Key
string <= 200 characters

Repeating a request with the same key within 24 h returns the original response.

Request Body schema: application/json
required
object

All given filters must match (AND). Empty = everything visible.

renditions
required
Array of strings (RenditionName) unique
Items Enum: "original" "web" "square" "thumb"

Renditions to include as files; [] = data only. 'original' requires media:original.

include_data
boolean
Default: true

Include product/variant/asset lines in the manifest (default true).

^x-[a-z0-9]+-[a-z0-9_-]+$
pattern property
any

Responses

Request samples

Content type
application/json
{
  • "filter": {
    },
  • "renditions": [
    ],
  • "include_data": true
}

Response samples

Content type
application/json
{
  • "id": "prd_01J9ZK3Q7T5W",
  • "status": "queued",
  • "created_at": "2026-09-28T10:00:00Z",
  • "finished_at": "2026-09-28T10:00:00Z",
  • "expires_at": "2026-09-28T10:00:00Z",
  • "progress": {
    },
  • "manifest_url": "http://example.com",
  • "parts": [],
  • "error": {
    }
}

Export status

Authorizations:
oauth2
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/json
{}

Export manifest (NDJSON)

Authorizations:
oauth2signedUrl
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/x-ndjson
{
  • "type": "file",
  • "asset_id": "prd_01J9ZK3Q7T5W",
  • "rendition": "original",
  • "part": 1,
  • "path": "string",
  • "bytes": 1,
  • "sha256": "string"
}

Download a zip part

Authorizations:
oauth2signedUrl
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

number
required
integer >= 1
header Parameters
Range
string

Responses

Response samples

Content type
application/problem+json
{
  • "title": "string",
  • "status": 400,
  • "detail": "string",
  • "instance": "string",
  • "obx_error": "invalid_request",
  • "errors": [
    ],
  • "retry_after": 0
}

Webhooks

Optional wake-up calls for consumers.

Event delivery to a consumer's webhook URL Webhook

Sent by the node to each active subscription. Headers: OBX-Webhook-Id (event id), OBX-Webhook-Timestamp (unix seconds), OBX-Webhook-Signature: v1=<hex> where hex = HMAC-SHA256(secret, <timestamp>.<raw body>). Receivers MUST verify in constant time and reject timestamps more than 300 s away. Respond 2xx within 10 s; otherwise the node retries with exponential backoff for up to 24 h. Webhooks are wake-up calls: always fetch state via /changes.

Authorizations:
oauth2
header Parameters
OBX-Webhook-Id
required
string
OBX-Webhook-Timestamp
required
string^[0-9]+$
OBX-Webhook-Signature
required
string^v1=[a-f0-9]{64}(, ?v1=[a-f0-9]{64})*$
Request Body schema: application/json
required
id
required
string [ 1 .. 128 ] characters

Unique event id (use for de-duplication).

type
required
string^[a-z_]+\.[a-z_]+$

Protocol: changes.available, grant.updated, export.completed. Brand admin: access_request.created, import_run.completed, ingest_job.completed. Both: webhook.test. Receivers MUST ignore unknown types.

created_at
required
string <date-time> (DateTime)

RFC 3339 timestamp, UTC recommended.

data
required
object

changes.available: {latest_seq}. grant.updated: {grant: Grant}. export.completed: {export_id, status}. access_request.created: {access_request_id, retailer_name}. import_run.completed: {import_run_id, status, counts}. ingest_job.completed: {job_id, status}. webhook.test: {}. Webhooks are wake-up calls: always fetch state through the API.

^x-[a-z0-9]+-[a-z0-9_-]+$
pattern property
any

Responses

Request samples

Content type
application/json
{
  • "id": "string",
  • "type": "string",
  • "created_at": "2026-09-28T10:00:00Z",
  • "data": { }
}

The caller's webhook subscriptions

Authorizations:
oauth2

Responses

Response samples

Content type
application/json
{
  • "items": [
    ]
}

Subscribe

Capability webhooks. Events for consumers: changes.available, grant.updated, export.completed. The URL MUST be https and MUST NOT resolve to a private, loopback or link-local address (422 unsafe_url). Max 5 subscriptions per client.

Authorizations:
oauth2
Request Body schema: application/json
required
url
required
string <uri> (Url) <= 2048 characters ^https?://

Absolute http(s) URL.

events
required
Array of strings non-empty
Items Enum: "changes.available" "grant.updated" "export.completed"

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "id": "whk_1",
  • "events": [
    ],
  • "secret": "whsec_Tm90LWEtcmVhbC1zZWNyZXQtZXhhbXBsZQ",
  • "status": "active",
  • "created_at": "2026-09-28T10:00:00Z",
  • "last_delivery_at": null,
  • "last_status": null
}

Unsubscribe

Authorizations:
oauth2
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/problem+json
{
  • "title": "string",
  • "status": 400,
  • "detail": "string",
  • "instance": "string",
  • "obx_error": "invalid_request",
  • "errors": [
    ],
  • "retry_after": 0
}

Send a webhook.test event now

Authorizations:
oauth2
path Parameters
id
required
string (Id) ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
Examples: prd_01J9ZK3Q7T5W

Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.

Responses

Response samples

Content type
application/json
{
  • "delivered": true,
  • "status": 0
}

Usage

Optional usage events reported back to the brand.

Report usage events

Capability usage. Organisation-level events only; never personal data. Unknown asset ids are ignored.

Authorizations:
oauth2
Request Body schema: application/json
required
required
Array of objects (UsageEvent) [ 1 .. 1000 ] items
^x-[a-z0-9]+-[a-z0-9_-]+$
pattern property
any

Responses

Request samples

Content type
application/json
{
  • "events": [
    ]
}

Response samples

Content type
application/json
{
  • "accepted": 0
}