Operating it
Examples
Working clients you can lift into your own codebase. Each one covers the same four concerns: authenticate, pace, upsert, reconcile.
Start from these, not from the schema
These already handle the things that are easy to get wrong — the 201 vs
200 distinction, pacing that absorbs response-time variance, and taking
a product you no longer carry down to zero stock rather than leaving
phantom stock in the shop.
Node.js — a complete sync
The full loop: open a run, fingerprint and skip unchanged products,
upsert what changed, zero what departed, close the run. Your only job is
transform() — the mapping from your catalog to the
product contract.
const PORTAL = process.env.BG_PORTAL_URL;
const headers = {
Authorization:
"Basic " +
Buffer.from(
`${process.env.BG_USER}:${process.env.BG_APP_PASSWORD}`
).toString("base64"),
"X-Vendor-Id": process.env.BG_VENDOR_ID,
"Content-Type": "application/json",
};
/** Add or update one product. Send your own words; we map them. */
async function send(source) {
const res = await fetch(
`${PORTAL}/api/v1/products/${encodeURIComponent(source.id)}`,
{
method: "PUT",
headers,
body: JSON.stringify({
values: {
brand: source.brand,
gender: source.gender,
color: source.color,
material: source.composition,
country: source.madeIn,
category: source.categoryPath,
},
product: {
type: source.sizes?.length ? "variable" : "simple",
name: source.title,
sku: source.sku,
images: source.images.map((src) => ({ src })),
short_description: source.description,
...(source.sizes?.length
? { variations: source.sizes.map(toVariation) }
: {
regular_price: money(source.retail),
sale_price: money(source.price),
stock_quantity: source.stock,
manage_stock: true,
global_unique_id: source.ean,
}),
meta_data: [
{ key: "_external_id", value: String(source.id) },
{ key: "_vendor_cost", value: money(source.cost) },
{ key: "_vendor_sku", value: source.sku },
],
},
}),
}
);
const body = await res.json();
if (res.status === 201) return { outcome: "created" };
if (res.ok) return { outcome: "updated" };
// 422 means nothing reached the shop. Either a value did not map, or the
// product did not pass validation — the body says which.
return {
outcome: "rejected",
unresolved: body.unresolved,
errors: body.errors,
code: body.code,
message: body.message,
};
}
const money = (n) => Number(n).toFixed(2);
/** Two writes per second, absorbing response-time variance. */
async function paced(tasks, perSecond = 2) {
const gap = 1000 / perSecond;
let next = Date.now();
const out = [];
for (const task of tasks) {
const wait = next - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
next = Math.max(next + gap, Date.now());
out.push(await task());
}
return out;
}
export async function sync(catalog, previousIds = []) {
const results = await paced(catalog.map((source) => () => send(source)));
// Anything you sold last run and no longer carry goes to zero stock. This
// is the half integrations forget, and the reason phantom stock gets sold.
const live = new Set(catalog.map((s) => String(s.id)));
const gone = previousIds.filter((id) => !live.has(String(id)));
await paced(
gone.map((id) => () =>
fetch(`${PORTAL}/api/v1/products/${encodeURIComponent(id)}`, {
method: "PUT",
headers,
body: JSON.stringify({
product: { type: "simple", manage_stock: true, stock_quantity: 0 },
}),
}),
),
);
return results;
}
PHP — the client
<?php
final class BrandsGatewayClient
{
private array $headers;
public function __construct(
private string $portal,
string $vendorId,
string $user,
string $appPassword,
) {
$this->headers = [
'Content-Type: application/json',
'X-Vendor-Id: ' . $vendorId,
'Authorization: Basic ' . base64_encode("{$user}:{$appPassword}"),
];
}
/** @return array{outcome:string, body:array} */
public function send(string $externalId, array $values, array $product): array
{
$url = sprintf(
'%s/api/v1/products/%s',
rtrim($this->portal, '/'),
rawurlencode($externalId),
);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => $this->headers,
CURLOPT_POSTFIELDS => json_encode(
['values' => $values, 'product' => $product],
JSON_UNESCAPED_SLASHES,
),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$body = json_decode((string) $raw, true) ?: [];
return [
'outcome' => match (true) {
$status === 201 => 'created',
$status < 300 => 'updated',
default => 'rejected',
},
'body' => $body,
];
}
/** A product you no longer carry: zero its stock, never delete it. */
public function zero(string $externalId): array
{
return $this->send($externalId, [], [
'type' => 'simple',
'manage_stock' => true,
'stock_quantity' => 0,
]);
}
}
Python — the client
import base64, time
import httpx
class BrandsGateway:
def __init__(self, portal: str, vendor_id: str, user: str, app_password: str):
token = base64.b64encode(f"{user}:{app_password}".encode()).decode()
self.portal = portal.rstrip("/")
self.client = httpx.Client(
headers={
"Authorization": f"Basic {token}",
"X-Vendor-Id": vendor_id,
},
timeout=90.0,
)
self._next_write = 0.0
def _pace(self, per_second: float = 2.0) -> None:
wait = self._next_write - time.monotonic()
if wait > 0:
time.sleep(wait)
self._next_write = max(self._next_write + 1 / per_second, time.monotonic())
def send(self, external_id: str, values: dict, product: dict) -> dict:
"""Add or update. `values` is your own vocabulary; we map it."""
self._pace()
res = self.client.put(
f"{self.portal}/api/v1/products/{external_id}",
json={"values": values, "product": product},
)
body = res.json()
if res.status_code == 201:
return {"outcome": "created", **body}
if res.is_success:
return {"outcome": "updated", **body}
# 422: nothing reached the shop. `unresolved` means a value did not
# map; `errors` means the product failed validation.
return {"outcome": "rejected", **body}
def zero(self, external_id: str) -> dict:
"""A product you no longer carry: zero its stock, never delete it."""
return self.send(
external_id,
{},
{"type": "simple", "manage_stock": True, "stock_quantity": 0},
)
Validating in CI
The most useful place for the validator is your pipeline, not your browser. Fail the build when a feed change breaks the contract, and you will never ship a run that rejects thousands of products.
# Validate a whole directory of payloads in CI, before anything
# reaches BrandsGateway. Exit non-zero on the first blocking error.
for f in payloads/*.json; do
result=$(curl -s -X POST https://vendors.brandsgateway.com/api/v1/validate \
-H 'Content-Type: application/json' --data @"$f")
if [ "$(echo "$result" | jq -r .valid)" != "true" ]; then
echo "FAIL $f"
echo "$result" | jq -r '.errors[] | " \(.path): \(.message)"'
exit 1
fi
done