go4.fashion...Sign in
← All docsFor developers

go4.fashion API reference for developers

The go4.fashion API lets a partner's software (marketplace, partner shop, point of sale) read a brand's catalogue and stock, place orders and receive webhooks. One key identifies one brand and one sales channel.

Integration model

StatusThis page describes version v1 of the go4.fashion API as of 25 September 2026. The OpenAPI specification is the machine-readable contract.
TermMeaning
BrandThe go4.fashion customer whose catalogue, stock and orders you reach; "organization_id" in the API
Sales channelThe brand's channel for your company. It decides which products you see, their prices and the quantities available to you
PartnerYour company and its software: a marketplace, a partner shop or a point of sale
CustomerThe brand's customer your orders are placed for, when you buy for your own stock
End customerYour customer, sent in "customer" with each order when you run a marketplace
API keyOne key for one brand and one sales channel. It reads all orders of that sales channel

Base URL and versioning

ItemValue
Base URL"https://api.go4.fashion"
Version prefix"/v1" in every path
Specification"GET https://api.go4.fashion/v1/openapi.json" (OpenAPI 3.1, public, no key)
TransportHTTPS only; JSON request and response bodies ("application/json")
Deprecationgo4.fashion announces a deprecated field or endpoint in the Changelog at least 12 months before removing it

Authentication

All endpoints require Bearer authentication, except "GET /v1/openapi.json":

HTTP
GET /v1/ping HTTP/1.1
Host: api.go4.fashion
Authorization: Bearer g4_live_3f9a…   (64 hex characters after g4_live_)
RuleDetail
Key format"g4_live_<64 hex>" for production, "g4_test_<64 hex>" for the sandbox
Who issues itThe brand, in go4.fashion under "Settings" → "API access". The key is shown once, when the brand creates it; go4.fashion cannot show it again. If you lose it, ask the brand for a new key
Where it belongsOnly on your server. Never in browser code, a mobile app, a public repository, client-side configuration, logs or analytics tools. Data endpoints do not accept calls from a browser
EnvironmentsA "g4_test_" key works only against the sandbox brand; a "g4_live_" key only against the brand's real data
RevocationThe brand can revoke a key at any time; the next call returns "401 revoked_key"
ExpiryA key may carry an expiry date; after it, calls return "401 expired_key"
RotationAsk the brand for a new key with the same settings. When your calls work with it, ask the brand to revoke the old key

"GET /v1/ping" confirms the key and returns what it grants:

JSON
{
  "ok": true,
  "organization_id": "…",
  "scopes": ["catalog:read", "stock:read", "orders:read", "orders:write"],
  "channel": { "id": "…", "code": "example_partner", "label": "Example Partner (API)" },
  "customer": { "id": "…", "name": "EXAMPLE PARTNER" },
  "environment": "live"
}
FieldMeaning
"organization_id"The brand your key belongs to
"scopes"What the key may do (see "Scopes")
"channel"The brand's sales channel assigned to your key. It decides which products you see, their prices and the quantities available to you
"customer"The brand's customer your orders are placed for, when you buy for your own stock, as a boutique or a partner shop does. Null for a marketplace key: each order then names its end customer in "customer"
"environment""live" for a "g4_live_" key, "test" for a "g4_test_" key

Scopes

ScopeGrants
"catalog:read"collections, products
"stock:read"stock levels
"orders:read"orders, shipments, documents, returns
"orders:write"placing, cancelling, returning orders

A call without the required scope returns "403 insufficient_scope".

Conventions

TopicRule
IdentifiersUUIDs
TimestampsISO 8601 in UTC ("2026-09-23T12:00:00Z")
MoneyA number with at most two decimal places ("38", "38.5", "38.25") next to an ISO 4217 currency code ("EUR"). Handle amounts as decimal values, not binary floating point
VariantOne colour of a product in one size: the unit you order, with its own "variant_id", SKU, price and available quantity. Example: AURE hoodie, Black, M is one variant with the SKU "0022-BLK-M"
Size and colour sequenceSizes in the brand's size order, colours alphabetically
ListsProducts, stock and orders come in pages: { "data": [...], "has_more": true, "next_cursor": "…" }. Use "?limit=" (default 25, maximum 100) and "?cursor=" from the previous page. The cursor is opaque; a malformed one returns "422 validation_failed" with field: "cursor"
Sort orderProducts and orders by "updated_at", then "id", ascending; stock items by "variant_id" ascending. A page is not a snapshot: a record that changes while you follow the cursors can come again on a later page, so upsert by "id" or "variant_id"
Incremental sync"?updated_since=<ISO 8601 with offset>" on products, stock and orders. A product's "updated_at" changes with any edit to the product, its sizes, colours, content, composition and care, labels or media. A stock item's "updated_at" changes when its size changes or when the brand records a stock movement for it. An order's "updated_at" changes with any change to the order. Store the greatest "updated_at" you processed and start the next call a few minutes earlier; records you get twice are updated again
Full syncPrices, available quantities and the list of products on sale in your channel can change without a change of "updated_at", and an incremental sync does not return removed products (see "Catalogue visibility and removals"). Run a full sync at least once a day
Request idEvery response carries "X-Request-Id". Quote it when you report a problem to the brand
CachingData responses are sent with "Cache-Control: no-store"

Idempotency

Every "POST" request needs the "Idempotency-Key" header: 1–255 printable characters that identify one operation on your side. Use one key per operation and send it again on every retry of that operation, for example after a timeout. For 24 hours a key belongs to its first request and its answer, a 4xx such as 422 included. Send a corrected request with a new key, for example your order number with an attempt number: "MKT-2026-0001-2". Without the header the call returns "422 validation_failed" with field: "Idempotency-Key".

SituationAnswer
First call with a keyexecuted normally
Same key, same request (method, path and body) again — for example after a timeoutthe stored response, with the header "Idempotent-Replayed: true"; no second order
Same key, different request"409 idempotency_conflict"
Same key while the first call is still running"409 idempotency_conflict" with details.state = "in_progress" — retry in a moment
The first call ended in a 4xxstored: the same request gets the same answer; a changed request gets "409 idempotency_conflict"
The first call ended in a 5xxnot stored; retry with the same key

Request bodies are limited to 256 KB ("413 payload_too_large").

Errors

One envelope for every error:

JSON
{ "error": { "code": "insufficient_stock", "message": "Not enough stock for one or more lines.",
             "field": "items", "request_id": "8b1d…", "details": { "shortages": [ … ] } } }
HTTP"code"When
401"invalid_key" / "expired_key" / "revoked_key"the key is unknown, expired or revoked
403"insufficient_scope"the key lacks the scope
404"not_found"the resource does not exist or belongs to another brand or channel
409"idempotency_conflict"the same "Idempotency-Key" was used with a different request, or the first call is still running
409"external_ref_conflict""external_ref" already used in this brand
413"payload_too_large"the request body is above 256 KB
422"validation_failed" (+ "field")the body failed validation
422"insufficient_stock"not enough stock in "all_or_nothing" mode, or nothing left to accept in "partial" mode
422"cutoff_passed"the collection's pre-order window has closed
422"cannot_cancel"the order can no longer be cancelled through the API
429"rate_limited" (+ "Retry-After")rate limit of the key, or too many failed authentications from your IP address
500"internal_error"an error on the go4.fashion side. Retry the call; for a write, use the same "Idempotency-Key". If the error repeats, report the "request_id" to the brand

What the "details" object carries:

"code""details"
"validation_failed""issues[]": { "field", "message" } for every problem; on a return line also "available": the units of that size you can still return
"insufficient_stock""shortages[]": { "variant_id", "sku", "product_name", "requested", "available" }
"cutoff_passed""collections[]": { "id", "cutoff_at" }
"cannot_cancel""reason": see "Cancel an order"
"external_ref_conflict""order_id" and "number" of the existing order
"idempotency_conflict"state: "in_progress" while the first call is still running

Rate limits

LimitValue
Calls per key300 per 60 s by default; the "X-RateLimit-Limit" header shows the limit of your key
Writes per key60 per 60 s
Failed authentications per IP address20 per 60 s; after that, every call from that IP address is rejected with "429 rate_limited", also with a valid key

Headers on every authenticated response: "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset" (ISO timestamp). On refusal: "Retry-After" (seconds).

Endpoints

MethodPathScopeStatusPurpose
GET"/v1/ping"any keyAvailableVerify the key; read its brand, channel, customer, scopes, environment
GET"/v1/openapi.json"noneAvailableOpenAPI 3.1 specification
GET"/v1/collections""catalog:read"AvailableAll collections on sale in your channel, in one response
GET"/v1/products""catalog:read"AvailableProducts visible in your channel, with colours, sizes, prices; "updated_since", "collection_id", "cursor", "limit"
GET"/v1/products/{id}""catalog:read"AvailableOne product with content, composition and care per colour, media
GET"/v1/stock""stock:read"AvailableAvailable quantity per size for your channel; "updated_since", "cursor", "limit"
POST"/v1/orders""orders:write"AvailablePlace an order; "Idempotency-Key" header required
GET"/v1/orders""orders:read"AvailableYour orders with items, oldest change first; "updated_since", "status", "cursor", "limit"
GET"/v1/orders/{id}""orders:read"AvailableOne order with items, shipments, awaiting units and billing documents
POST"/v1/orders/{id}/cancel""orders:write"AvailableCancel an order
POST"/v1/orders/{id}/returns""orders:write"AvailableRequest a return for shipped units
GET"/v1/orders/{id}/returns""orders:read"AvailableReturn requests and their state

The API has no endpoint for webhook subscriptions; see "Webhooks". Planned, not in v1 yet: "GET /v1/products/{id}/size-chart" with measurements per size.

Collections and products

Collection ("GET /v1/collections")

JSON
{ "id": "…", "name": "AW26", "season": "AW", "year": 2026, "sales_mode": "stock", "is_featured": true,
  "cutoff_at": null, "delivery_estimate": null, "products_count": 42, "cover_url": "https://…/cover.jpg",
  "updated_at": "…" }

Collections come in one response, featured collections first; do not rely on any other order. They do not support "updated_since": compare "updated_at" with your previous call to see which changed. The "sales_mode" field: "stock" — only units in stock; "quantity_available" limits your order. "preorder" — orders are accepted until "cutoff_at", without a stock limit. "stock_mto" — units in stock first, then made to order; the API does not show which units come from stock. The "delivery_estimate" field is a text the brand writes for the collection in your channel, for example "4–6 weeks"; null when not set.

"GET /v1/products" and "GET /v1/products/{id}" return the current unit price for every orderable variant in that channel. The price is agreed commercially between the brand and the partner outside the API.

Product ("GET /v1/products/{id}"; the list "GET /v1/products" has the same fields without "content" and "composition"; the OpenAPI specification has both schemas)

JSON
{ "id": "…", "name": "AURE hoodie", "product_number": "0022", "category": "hoodies",
  "product_type": "crop-hoodie", "collection": { "id": "…", "name": "AW26" }, "country_of_origin": "PL",
  "image_url": "https://…/main.jpg",
  "content": { "description_html": "…", "language": "en" },
  "composition": [ { "color_id": "…", "color_name": "Black", "composition": "100% organic cotton", "care": "Wash at 30°C, do not bleach" } ],
  "media": [ { "url": "https://…/….jpg", "type": "image", "video_url": null, "position": 1, "color_id": "…" } ],
  "colors": [ { "color_id": "…", "name": "Black", "hex": "#111111",
      "sizes": [ { "variant_id": "…", "size": "M", "sku": "0022-BLK-M", "ean": null, "weight_grams": 420,
                   "price": { "amount": 38, "currency": "EUR", "price_list": "Example Partner" },
                   "retail_price": { "amount": 109, "currency": "EUR" } } ] } ],
  "updated_at": "…" }
FieldMeaning
"price"The current unit price of the variant in your channel. "price": null means the variant cannot be ordered
"retail_price"The brand's recommended retail price; can be null
"ean"Null until the brand assigns GS1 codes
"composition"One entry per colour, or one entry without a colour when all colours share one care label; the brand's own composition and care text
"country_of_origin"The country where the product was made (ISO 3166-1 alpha-2), or null
"media"Images and videos in display order ("position"). "color_id": null means shared by all colours. For type "video": the poster in "url", the playable file in "video_url". URLs are public HTTPS addresses and can change when the brand replaces a file; do not use them as identifiers

Stock

Stock item ("GET /v1/stock")

JSON
{ "variant_id": "…", "product_id": "…", "quantity_available": 3, "updated_at": "…" }

Map "variant_id" to the variant you synced from "/v1/products". The "quantity_available" field is a snapshot of what your channel can order now, and it can be lower than the brand's total stock. It reserves nothing: go4.fashion checks stock again when it creates the order, so an order can be rejected or partly accepted even after a stock call showed enough.

Catalogue visibility and removals

  • "GET /v1/products" returns only products on sale in your channel, also in incremental calls.
  • A product leaves your channel when the brand ends the collection's offer, hides the product in your channel or archives it. It is then no longer returned, "GET /v1/products/{id}" returns "404 not_found", and no webhook is sent.
  • A variant the brand archives disappears from "sizes" and from "/v1/stock".
  • An incremental sync cannot detect a removal. Run a full sync at least once a day and compare variants by "variant_id": when a product, or a variant in a product's "sizes", is missing, deactivate your offer for it and keep your order history.
  • A variant with "price": null cannot be ordered. A variant with "quantity_available": 0 is out of stock and can come back.

Place an order

"POST /v1/orders", with the header "Idempotency-Key" (see "Idempotency"):

JSON
{ "external_ref": "MKT-2026-0001", "currency": "EUR", "accept": "partial",
  "shipping_address": { "name": "Example Partner GmbH", "contact": "Lena", "email": "ops@partner.example", "phone": "+49…",
                        "line1": "Hafenstr. 1", "line2": null, "city": "Hamburg", "state": null, "zip": "20457", "country": "DE" },
  "items": [ { "variant_id": "…", "quantity": 2, "unit_price": 38 } ] }
FieldRequiredRule
"external_ref"yesYour order number: 1–64 printable ASCII characters without spaces, unique within the brand. Reuse returns "409 external_ref_conflict" with the existing "details.order_id"
"items"yes1–500 lines. Each line has "variant_id" and "quantity" (a whole number from 1), and optionally "unit_price", the price you expect. A line whose variant is not on sale in your channel, or has no price in your channel, is rejected with "422 validation_failed". Two lines with the same "variant_id" are rejected with "422 validation_failed" — merge the quantities
"accept"no, default "all_or_nothing""all_or_nothing": any shortage returns "422 insufficient_stock" with "details.shortages[]". "partial": short lines come back with "status": "rejected" and the order is created with the rest
"currency"noA check: when you send it, it must equal the currency of your channel's prices; otherwise "422 validation_failed" with field: "currency"
"shipping_address"noWhere the goods go; needs "name", "line1", "city" and "country" (ISO 3166-1 alpha-2, upper case). Omitted = the default address the brand has for your customer. With a marketplace key, send it with every order: the brand has no address for your end customers
"customer"only with a marketplace key"email" and "name", optionally "phone". Send it when "customer" in the ping answer is null. Send the end customer's own e-mail address: go4.fashion uses it to identify the end customer. When the ping answer has a customer object, your orders are placed for that customer, and a request with "customer" is rejected with "422 validation_failed"

The order uses the current price of each variant in your channel. A "unit_price" you send does not change it: when it differs by more than 0.01, the order has "price_adjusted": true.

Order response and detail

Order (201 from "POST /v1/orders", entries of "data" in "GET /v1/orders")

JSON
{ "id": "…", "number": "1234_EXP", "status": "confirmed", "external_ref": "MKT-2026-0001",
  "channel": { "id": "…", "code": "example_partner", "label": "Example Partner (API)" },
  "customer": { "id": "…", "name": "EXAMPLE PARTNER" },
  "currency": "EUR", "exchange_rate": null,
  "totals": { "subtotal": 76, "discount": 0, "total": 76 }, "price_adjusted": false,
  "items": [ { "variant_id": "…", "product_id": "…", "sku": "0022-BLK-M", "product_name": "AURE hoodie", "color": "Black", "size": "M",
               "quantity": 2, "unit_price": 38, "original_price": null, "line_total": 76,
               "status": "accepted", "reason": null, "available": null },
             { "variant_id": "…", "product_id": "…", "sku": "0022-BLK-L", "product_name": "AURE hoodie", "color": "Black", "size": "L",
               "quantity": 3, "unit_price": 38, "original_price": null, "line_total": 0,
               "status": "rejected", "reason": "insufficient_stock", "available": 1 } ],
  "shipping_address": { "name": "…", "contact": null, "email": null, "phone": null, "line1": "…", "line2": null, "city": "…", "state": null, "zip": "…", "country": "DE" },
  "fulfillment_status": "unfulfilled", "payment_status": "unpaid",
  "created_at": "…", "confirmed_at": "…", "updated_at": "…" }
FieldValues or meaning
"status""confirmed" → "completed" (the brand closed the order) or "cancelled"
"fulfillment_status""unfulfilled" → "partial" → "fulfilled"
"payment_status""unpaid", "partial" (partly paid), "paid" or "refunded"
Item "status""accepted", or "rejected" with "reason": "insufficient_stock"
Item "available"For a rejected line: the units you could have ordered at that moment
"exchange_rate"The order currency per unit of the brand's base currency; null when they are equal

Store the response of "POST /v1/orders": rejected lines are not returned by later reads of the order.

The "totals" object is the sum of the accepted order lines. Shipping and other charges the brand adds later are not in "totals". The API does not calculate tax; whether a price includes VAT follows your agreement with the brand. The brand's invoice in "documents" shows tax and the amount to pay, which can differ from "totals".

Order detail ("GET /v1/orders/{id}", also returned by "POST /v1/orders/{id}/cancel") adds:

JSON
{ "shipments": [ { "id": "…", "number": 1, "status": "shipped", "packed_at": "…", "shipped_at": "…", "delivered_at": null,
                   "carrier": "ups", "carrier_service": "UPS Standard", "tracking_number": "1Z…", "tracking_url": "https://www.ups.com/track?tracknum=1Z…",
                   "ship_to": { … }, "items": [ { "variant_id": "…", "sku": "0022-BLK-M", "quantity": 1 } ],
                   "cartons": [ { "number": 1, "tracking_number": "1Z…", "weight_grams": 600, "length_cm": 40, "width_cm": 30, "height_cm": 20,
                                  "items": [ { "variant_id": "…", "sku": "0022-BLK-M", "quantity": 1 } ] } ] } ],
  "awaiting": [ { "variant_id": "…", "sku": "0022-BLK-M", "quantity": 1 } ],
  "documents": [ { "id": "…", "kind": "final", "number": "11/S/EX/09/2026", "status": "issued", "amount": 76, "currency": "EUR",
                   "issued_at": "…", "paid_at": null, "pdf_url": "https://…" } ] }

Shipment "status": "packed" → "shipped" → "delivered". The "awaiting" list has the units not shipped yet; units in a packed carton still count as awaiting. The "documents" list has the brand's billing documents; drafts and cancelled documents are left out. The "pdf_url" field is a link to the PDF, or null when there is no file yet. Some links expire after 5 minutes: download the file when you read the order, or read the order again for a new link.

Cancel an order

"POST /v1/orders/{id}/cancel" cancels the order. It needs an "Idempotency-Key"; the body is optional: { "reason": "…" }. Cancelling an already cancelled order returns it unchanged. When the order can no longer be cancelled, the call returns "422 cannot_cancel", and "details.reason" says why:

"details.reason"Meaning
"picking"The brand has started picking the order
"shipped"A shipment of the order exists
"delivered"The order is delivered
"fulfilled"All units are shipped
"completed"The order is completed
"locked"The brand has locked the order
"archived"The brand has archived the order
"billing_documents"The brand has issued a proforma, an advance invoice or a final invoice for the order
"cutoff_passed"The pre-order cutoff of the order has passed
"editable_until_passed"The date until which the brand accepts changes to the order has passed

To stop the order after that, contact the brand; after a shipment, request a return.

Returns

Return request ("POST /v1/orders/{id}/returns", "Idempotency-Key" required)

JSON
{ "resolution": "refund", "note": "Wrong size",
  "items": [ { "variant_id": "…", "quantity": 1, "reason": "size", "condition": "unworn", "note": null, "external_line_id": "L-1" } ] }

The "resolution" field: "refund", "credit", "exchange" or "none" — what the customer asks for. With "exchange", the replacement is arranged with the brand outside the API. The "reason" field: "size", "fit", "quality", "wrong_item", "damaged_in_transit", "changed_mind" or "other". The "condition" field: "unworn", "worn", "damaged" or "defective". Only shipped units can be returned; a quantity above what is left is rejected with "422 validation_failed" on that line, with "available" in "details".

Return (201 from the request, entries of "data" in "GET /v1/orders/{id}/returns")

JSON
{ "id": "…", "order_id": "…", "number": 1, "reference": "1234_EXP/R1", "kind": "return", "resolution": "refund", "status": "requested",
  "requested_at": "…", "approved_at": null, "declined_at": null, "cancelled_at": null, "closed_at": null,
  "ship_back_by": null, "customer_shipped_at": null, "parcel_received_at": null, "return_carrier": null, "return_tracking_number": null,
  "currency": "EUR", "goods_amount": 38, "refund_amount": 38, "qty_requested": 1, "qty_received": 0,
  "items": [ { "variant_id": "…", "sku": "0022-BLK-M", "quantity": 1, "reason": "size", "condition": "unworn", "unit_price": 38, "line_amount": 38, "external_line_id": "L-1" } ] }

Return "status": "requested" → "approved" (by the brand) → "in_transit" (parcel on its way) → "partly_received" or "received" → "closed"; or "declined" or "cancelled". The brand sets "ship_back_by" when approving.

Webhooks

Send the brand your endpoint, an HTTPS address on a public host, and the events you want. You and the brand share a signing secret: a long random string that both sides keep. The brand adds both in go4.fashion under "Settings" → "Integrations" → "Add integration" → "Webhook". You receive events only for your sales channel. To test your endpoint, ask the brand to click "Test connection": it sends a "ping" event. Each delivery is a POST:

HeaderValue
"Go4-Event-Id"UUID of the event — deduplicate on it; a retry carries the same id
"Go4-Event-Type"for example "order.confirmed"
"Go4-Signature"t=<unix seconds>, v1=<hex HMAC-SHA256(secret, t + "." + raw body)> — reject it when "t" is older than 5 minutes; compare in constant time

Body: { "id", "type", "created_at", "api_version": "v1", "data": { … } }.

Event"data"
"order.confirmed", "order.completed"{ "order": <order object> }
"order.status_changed"{ "order", "previous_status", "status" }
"order.cancelled"{ "order", "reason" }
"shipment.shipped", "shipment.delivered"{ "order_id", "order_number", "external_ref", "shipment": <shipment object>, "awaiting": [ … ] }
"return.status_changed"{ "order_id", "order_number", "external_ref", "return": <return object> }
"stock.updated"{ "variants": [ { "variant_id", "product_id", "sku", "quantity_available", "updated_at" } ] } — every 5 minutes, only sizes whose available quantity changed in your channel, at most 500 per delivery
"product.updated"{ "products": [ { "id", "updated_at" } ] } — every 5 minutes, at most 500 products per delivery; "updated_at" is the product's own. Fetch each product from "/v1/products/{id}"; "404" means it left your channel
"ping""{}" — sent by "Test connection"

Answer with a 2xx status within 10 s. A timeout, a network error, a 5xx, 401 or 429 is retried after 1 min, 5 min, 30 min, 2 h and 12 h. Any other 4xx stops the delivery at once. A stopped delivery, and one that fails its last retry, is not sent again automatically; ask the brand to retry it in go4.fashion.

Webhooks are delivered at least once: the same event can arrive more than once, and events can arrive out of order. Deduplicate on "Go4-Event-Id". Do not take the current state from the order in which events arrive: compare "updated_at", or read the resource from the API. For a product event, "404 not_found" from "GET /v1/products/{id}" means the product left your channel; deactivate your offer. Webhooks shorten the delay; keep the daily full sync.

To change the signing secret, first make your endpoint accept both the current and the new secret. Then give the new secret to the brand, which saves it in go4.fashion. Keep accepting both during the agreed transition, then remove the old one.

Verifying the signature (Node.js):

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyGo4Signature(rawBody, header, secret) {
  const t = /(?:^|,)\s*t=(\d+)(?:,|$)/.exec(header)?.[1];
  const v1 = /(?:^|,)\s*v1=([0-9a-f]{64})(?:,|$)/.exec(header)?.[1];
  if (!t || !v1) return false;
  const timestamp = Number(t);
  if (!Number.isSafeInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const received = Buffer.from(v1, "hex");
  return received.length === expected.length && timingSafeEqual(expected, received);
}

Verify the signature on the raw body exactly as received, before you parse the JSON — re-serialised JSON produces a different signature. Reject a delivery with an invalid signature before you queue it. Record "Go4-Event-Id" in the same transaction as your change, so a retry does not apply it twice.

Sandbox and go-live

  1. 1Ask the brand for a "g4_test_" key. It works on a sandbox brand with a demo catalogue and stock.
  2. 2Call "ping" and check "customer" in the answer: when it is an object, leave "customer" out of your orders.
  3. 3Test the whole flow: sync catalogue and stock → place a test order → cancel it → receive a webhook. For the webhook, send the brand your endpoint and ask it to switch on "order.confirmed" and "order.cancelled" for the sandbox.
  4. 4Ask the brand for the "g4_live_" key. Keep it on your server; ask the brand to rotate it when a person who saw it leaves your team. Run a full sync with the live key: identifiers from the sandbox do not exist in the brand's live data.

Changelog

DateChange
2026-08-25"GET /v1/ping", "GET /v1/openapi.json", key authentication, rate limits
2026-09-25Keys are bound to a sales channel and optionally a customer; "ping" returns "channel" as an object and "customer"; per-key rate limit
2026-09-25Catalogue and stock: "GET /v1/collections", "GET /v1/products", "GET /v1/products/{id}", "GET /v1/stock" with cursor pagination and "updated_since"; prices and quantities for your channel
2026-09-25Orders: "POST /v1/orders" (idempotent, "accept" all_or_nothing or partial), "GET /v1/orders", "GET /v1/orders/{id}" with shipments and documents, "POST /v1/orders/{id}/cancel", returns "POST" and "GET /v1/orders/{id}/returns"; "Idempotency-Key" on every write; 256 KB body limit; 60 writes per minute
2026-09-25Webhooks: signed deliveries for orders, shipments, returns, stock and catalogue; retries with back-off; "ping" from "Test connection"