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
| Term | Meaning |
|---|---|
| Brand | The go4.fashion customer whose catalogue, stock and orders you reach; "organization_id" in the API |
| Sales channel | The brand's channel for your company. It decides which products you see, their prices and the quantities available to you |
| Partner | Your company and its software: a marketplace, a partner shop or a point of sale |
| Customer | The brand's customer your orders are placed for, when you buy for your own stock |
| End customer | Your customer, sent in "customer" with each order when you run a marketplace |
| API key | One key for one brand and one sales channel. It reads all orders of that sales channel |
Base URL and versioning
| Item | Value |
|---|---|
| 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) |
| Transport | HTTPS only; JSON request and response bodies ("application/json") |
| Deprecation | go4.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":
GET /v1/ping HTTP/1.1
Host: api.go4.fashion
Authorization: Bearer g4_live_3f9a… (64 hex characters after g4_live_)| Rule | Detail |
|---|---|
| Key format | "g4_live_<64 hex>" for production, "g4_test_<64 hex>" for the sandbox |
| Who issues it | The 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 belongs | Only 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 |
| Environments | A "g4_test_" key works only against the sandbox brand; a "g4_live_" key only against the brand's real data |
| Revocation | The brand can revoke a key at any time; the next call returns "401 revoked_key" |
| Expiry | A key may carry an expiry date; after it, calls return "401 expired_key" |
| Rotation | Ask 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:
{
"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"
}| Field | Meaning |
|---|---|
| "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
| Scope | Grants |
|---|---|
| "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
| Topic | Rule |
|---|---|
| Identifiers | UUIDs |
| Timestamps | ISO 8601 in UTC ("2026-09-23T12:00:00Z") |
| Money | A 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 |
| Variant | One 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 sequence | Sizes in the brand's size order, colours alphabetically |
| Lists | Products, 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 order | Products 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 sync | Prices, 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 id | Every response carries "X-Request-Id". Quote it when you report a problem to the brand |
| Caching | Data 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".
| Situation | Answer |
|---|---|
| First call with a key | executed normally |
| Same key, same request (method, path and body) again — for example after a timeout | the 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 4xx | stored: the same request gets the same answer; a changed request gets "409 idempotency_conflict" |
| The first call ended in a 5xx | not stored; retry with the same key |
Request bodies are limited to 256 KB ("413 payload_too_large").
Errors
One envelope for every error:
{ "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
| Limit | Value |
|---|---|
| Calls per key | 300 per 60 s by default; the "X-RateLimit-Limit" header shows the limit of your key |
| Writes per key | 60 per 60 s |
| Failed authentications per IP address | 20 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
| Method | Path | Scope | Status | Purpose |
|---|---|---|---|---|
| GET | "/v1/ping" | any key | Available | Verify the key; read its brand, channel, customer, scopes, environment |
| GET | "/v1/openapi.json" | none | Available | OpenAPI 3.1 specification |
| GET | "/v1/collections" | "catalog:read" | Available | All collections on sale in your channel, in one response |
| GET | "/v1/products" | "catalog:read" | Available | Products visible in your channel, with colours, sizes, prices; "updated_since", "collection_id", "cursor", "limit" |
| GET | "/v1/products/{id}" | "catalog:read" | Available | One product with content, composition and care per colour, media |
| GET | "/v1/stock" | "stock:read" | Available | Available quantity per size for your channel; "updated_since", "cursor", "limit" |
| POST | "/v1/orders" | "orders:write" | Available | Place an order; "Idempotency-Key" header required |
| GET | "/v1/orders" | "orders:read" | Available | Your orders with items, oldest change first; "updated_since", "status", "cursor", "limit" |
| GET | "/v1/orders/{id}" | "orders:read" | Available | One order with items, shipments, awaiting units and billing documents |
| POST | "/v1/orders/{id}/cancel" | "orders:write" | Available | Cancel an order |
| POST | "/v1/orders/{id}/returns" | "orders:write" | Available | Request a return for shipped units |
| GET | "/v1/orders/{id}/returns" | "orders:read" | Available | Return 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")
{ "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)
{ "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": "…" }| Field | Meaning |
|---|---|
| "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")
{ "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"):
{ "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 } ] }| Field | Required | Rule |
|---|---|---|
| "external_ref" | yes | Your 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" | yes | 1–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" | no | A check: when you send it, it must equal the currency of your channel's prices; otherwise "422 validation_failed" with field: "currency" |
| "shipping_address" | no | Where 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")
{ "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": "…" }| Field | Values 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:
{ "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)
{ "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")
{ "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:
| Header | Value |
|---|---|
| "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):
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
- 1Ask the brand for a "g4_test_" key. It works on a sandbox brand with a demo catalogue and stock.
- 2Call "ping" and check "customer" in the answer: when it is an object, leave "customer" out of your orders.
- 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.
- 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
| Date | Change |
|---|---|
| 2026-08-25 | "GET /v1/ping", "GET /v1/openapi.json", key authentication, rate limits |
| 2026-09-25 | Keys are bound to a sales channel and optionally a customer; "ping" returns "channel" as an object and "customer"; per-key rate limit |
| 2026-09-25 | Catalogue 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-25 | Orders: "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-25 | Webhooks: signed deliveries for orders, shipments, returns, stock and catalogue; retries with back-off; "ping" from "Test connection" |