Process automation
Marketplace feeds in Postgres: een playbook van twee weken
Een playbook van twee weken voor het vervangen van driedubbel ingevoerde marketplace-listings door één Postgres-bron en een nachtelijke diff worker. Code, valkuilen, de kill switch.

Het is een dinsdagavond in mei. De operations lead bij een groothandel in keukenapparatuur vlakbij de Maasvlakte heeft drie browsertabs open: Bol Partnerplatform, Amazon Seller Central, Marktplaats Admarkt. De zomeractieprijzen gaan vrijdag live. Ze zit op regel 412 van een spreadsheet van 1.840 regels en kopieert prijzen handmatig in drie portals, omdat de voorraad friteuses, mixers en inductiekookplaten sneller verandert dan haar assistent kan bijhouden. Twee weken geleden ging er door een verkeerde decimaal een combi-stomer van €1.299 de deur uit voor €129. Bol annuleerde de orders automatisch. Amazon niet.
Dit is het playbook dat we met die groothandel hebben gedraaid. Twee weken, één Postgres-database, één nachtelijke worker. Geen drievoudige invoer meer.
Eén bron van waarheid, drie sinks
De eerste beslissing is de enige die telt. Je kiest één plek waar het product bestaat en je maakt elke marketplace een downstream sink. Nooit andersom. Niet Bol. Niet Amazon. Niet je ERP als je ERP een gedeelde Google Sheet uit 2019 is met samengevoegde cellen.
We kozen Postgres omdat de groothandel er al één draaide voor hun B2B-portal, en omdat Postgres goed genoeg overweg kan met JSON-kolommen om de eigenaardigheden per kanaal (Amazons product-type schema, de categorieboom van Marktplaats, de leveringscode van Bol) in een sidecar-kolom te zetten in plaats van de producttabel te vervuilen.
Het lastige is je team overtuigen om te stoppen met bewerken in de portals. Dat hebben we op dag één opgelost: editor-rechten intrekken, één persoon read-only-toegang geven voor sanity checks, de nieuwe regel met datum in Slack zetten. Krijg je die buy-in niet, stop dan met lezen. De rest van het playbook gaat je niet redden.
Dag 1 tot 3: modelleer het product, niet de listing
Een product heeft een SKU, een naam, afmetingen, gewicht, een prijs, voorraad en een set foto's. Een listing is wat één marketplace toont. Dat zijn verschillende dingen. Mensen halen het constant door elkaar, omdat de marketplace-UI's dat aanmoedigen.
Dit is het schema waar we op uitkwamen. Teruggebracht tot het essentiële.
create table products (
sku text primary key,
ean text not null,
title text not null,
brand text,
weight_grams integer,
length_mm integer,
width_mm integer,
height_mm integer,
cost_cents integer not null,
list_price_cents integer not null,
stock_on_hand integer not null default 0,
attrs jsonb not null default '{}'::jsonb,
updated_at timestamptz not null default now()
);
create table channel_listings (
sku text not null references products(sku) on delete cascade,
channel text not null check (channel in ('bol','amazon','marktplaats')),
channel_sku text not null,
state text not null check (state in ('active','paused','draft')),
last_pushed_hash text,
last_pushed_at timestamptz,
channel_attrs jsonb not null default '{}'::jsonb,
primary key (sku, channel)
);
create index on channel_listings (channel, last_pushed_at);
De kolom attrs bevat de canonieke productattributen. channel_attrs bevat de overrides per kanaal (een langere titel voor Amazon, de Bol-leveringscode, de Marktplaats-categorie-id). De kolom last_pushed_hash is de truc: een SHA-256 van de payload die we voor die SKU het laatst naar dat kanaal hebben gepusht. De worker leest 'm voordat hij besluit opnieuw te pushen.
Dag 4 tot 6: backfill vanuit de marketplaces
Je kunt niet beginnen met pushen voordat je weet wat er al live staat. Elke marketplace geeft je een manier om je bestaande aanbod op te lijsten.
Voor Bol biedt de Retailer API een request-an-export-patroon: je POST naar /retailer/offers/export, polled op het report-id, en downloadt daarna een CSV van elke offer die je nu live hebt staan. Voor Amazon geven de Listings Items endpoint van de Selling Partner API en de Reports API samen hetzelfde plaatje, al lever je per kanaal meer gevechten met enums. Marktplaats Admarkt is van de drie het vriendelijkst: een platte CSV erin, een platte CSV eruit.
We laadden alles in een staging-tabel, matchten op EAN waar de SKU ontbrak, losten met de hand de 23 SKU's op die niet aansloten, en schreven daarna de gematchte data weg in channel_listings. Het aantal mismatches is je beste enkele signaal van hoe rommelig het handmatige tijdperk was. Drieëntwintig op 1.840 is prima. Tweehonderd zou betekenen dat het bedrijf eerst een dieper dataprobleem moet fixen voordat je iets anders aanpakt.
EAN-matching heeft een stille failure mode die het noemen waard is. Sommige van je SKU's delen een EAN: een sixpack van dezelfde friteuse draagt dezelfde barcode als de losse unit. Match alleen op EAN en je drukt twee producten samen tot één. De marketplace accepteert de offer wel, want die geeft niet om welke je bedoelde; de klantenservice-inbox geeft daar twee weken later wél om. We voegden een uniekheidscheck toe op (ean, pack_size) en markeerden de duplicaten zodat de ops lead ze kon ontwarren. Twintig minuten triage op dag vijf scheelde een week verwarde ordertickets later.
Dag 7 tot 9: de diff worker
Dit is de motor. Elke nacht om 03:00 wordt de worker wakker, loopt door de producttabel en berekent voor elke SKU hoe elk kanaal eruit zou moeten zien. Daarna hashed hij die payload, vergelijkt 'm met last_pushed_hash, en pusht alleen als ze verschillen.
import { createHash } from 'node:crypto';
import { Pool } from 'pg';
import { renderBol, renderAmazon, renderMarktplaats, push } from './adapters';
const pool = new Pool();
export async function syncOne(sku: string) {
const { rows: [product] } = await pool.query(
'select * from products where sku = $1', [sku]
);
if (!product) return;
const renders = {
bol: renderBol(product),
amazon: renderAmazon(product),
marktplaats: renderMarktplaats(product),
};
for (const [channel, payload] of Object.entries(renders)) {
const hash = createHash('sha256')
.update(JSON.stringify(payload))
.digest('hex');
const { rows: [listing] } = await pool.query(
`select last_pushed_hash from channel_listings
where sku = $1 and channel = $2`,
[sku, channel]
);
if (listing?.last_pushed_hash === hash) continue;
const result = await push[channel](payload);
if (!result.accepted) continue;
await pool.query(
`update channel_listings
set last_pushed_hash = $1, last_pushed_at = now()
where sku = $2 and channel = $3`,
[hash, sku, channel]
);
}
}
Twee dingen om op te merken. Eén: de hash gaat over de gerenderde payload, niet over het bronproduct, dus een wijziging aan een kanaal-specifieke override triggert precies de kanalen die hij zou moeten triggeren. Twee: last_pushed_hash wordt pas geschreven nadat de marketplace de acceptatie bevestigt. Mislukt de push, dan probeert de volgende run het opnieuw. Er is geen aparte retry-tabel.
Concurrency is het andere ding waar niemand je voor waarschuwt tot je in productie zit. Bol heeft een rate limit per offer-id, dus je kunt over SKU's heen agressief fan-outen. Amazon limiteert per operation type met een token bucket die de docs op één plek beschrijven en daarna stil aandraait onder belasting. We draaien de worker per kanaal single-threaded, met een queue per kanaal, en laten het trage kanaal zichzelf vertragen. De totale nachtrun is in elf minuten klaar voor 1.840 SKU's. Sneller zou de ops lead niks opleveren dat ze zou merken.
Amazon verwerkt de meeste listing-wijzigingen asynchroon. Een 200 van patchListingsItem betekent geaccepteerd-voor-verwerking, niet geaccepteerd. Lees het submission report voordat je last_pushed_hash schrijft, anders sla je op basis van je hash een payload over die de marketplace stilletjes heeft geweigerd.
Dag 10 tot 12: de drie adapters
Elke adapter is een pure functie van canoniek product naar channel payload, plus een push-functie die de API aanroept. We hielden ze klein. Elke adapter blijft onder de 200 regels.
De Retailer API van Bol is de prettigste. PUT naar /retailer/offers/{offer-id}, krijg een process-status-id terug, poll één keer per minuut, maximaal tien keer. We sturen prijs, voorraad, fulfilment en leveringscode. Titel en beschrijving leven op het product, dat je apart aanmaakt en waar je vervolgens de offer aan hangt.
Amazons SP-API is een gevecht. Het juiste endpoint voor lopende updates is patchListingsItem, dat een JSON Patch-document tegen de listing aanvaardt. De product-type schemas zijn geversioneerd en groot. We trokken het schema per product type één keer op, cachten het in Postgres en valideerden payloads lokaal voor verzending. Dat vangt ongeveer 80% af van wat anders een afgewezen submission en een rondje van vijftien minuten was om dat uit te zoeken.
Marktplaats is een CSV-upload naar een bekend SFTP-endpoint, met een aparte statusfeed die je ophaalt om te zien welke regels zijn geaccepteerd. Het voelt gedateerd. Het is ook de betrouwbaarste van de drie.
Alle drie de adapters delen twee patronen: een idempotency key afgeleid van sku + channel + hash, en één gestructureerde logregel per push, zodat we maandagochtend om 09:00 channel=amazon sku=XYZ result=rejected kunnen grep'en zonder een dashboard te openen.
De vorm van die logregel verdient een eigen alinea. Elke push spuugt één JSON-object uit met sku, channel, action, hash, status, latency_ms, en (bij een rejection) de error code van de marketplace letterlijk. Eén regel per push. Geen stack traces over meerdere regels, tenzij er iets binnen de adapter zelf ontploft. Als er iets misgaat, is de vraag altijd "wat is er voor deze SKU veranderd tussen gisteren en vandaag," en het antwoord ligt twee grep-commando's verderop.
Dag 13 en 14: cutover en de kill switch
Twee flags in een config-tabel sturen de worker: dry_run en enabled_channels. We draaiden dag 13 met dry_run = true over alle drie kanalen. De worker berekende elke diff en logde wat hij zou pushen, maar pushte niets. De ops lead las het log bij haar koffie, markeerde twee SKU's waarbij onze title-renderer een eenheid had weggestript ("RVS 4L" werd "RVS"), we patchten de renderer, draaiden opnieuw. Schoon.
Dag 14, dry-run eerst uit voor Marktplaats. Vier uur lang kijken. Daarna Bol. Amazon als laatste, want Amazon is het kanaal dat elke schema-bug aan het licht brengt die je hebt gemist.
De kill switch is één SQL-update: update worker_config set enabled_channels = '{}'. We hebben een briefje met dat exacte commando op de monitor geplakt. Drie mensen hebben psql-toegang. Niemand heeft het hoeven draaien.
Het ochtendritueel telt meer dan de cutover zelf. Elke dag, de eerste twee weken, opende de ops lead om 09:00 met koffie het worker-log, scande op alles wat niet groen was, en fixte het of zette het op de standup-lijst. We zaten de eerste drie ochtenden bij haar. Op dag vijf belde ze niet meer. Op dag tien stuurde ze het log door naar haar assistent. Het punt van automatisering is niet de worker. Het is de nieuwe vorm van iemands ochtend.
Wat dit je niet meer kost
De operations lead van de groothandel was vroeger zo'n negen uur per week kwijt aan portal-updates over de drie kanalen. Na de cutover is dat ongeveer 40 minuten per week, het meeste daarvan het doorlezen van het ochtendlog van de worker en beslissen of de vier of vijf gemarkeerde waarschuwingen om een mens vragen. Prijsfouten die voorheen doorglipten (de combi-stomer van €129) worden bij de renderer afgevangen, omdat de adviesprijs een sanity check heeft tegen de kostprijs plus een instelbare ondergrensmarge.
Het andere dat het je niet meer kost is de meeting waarin iemand vraagt "wie heeft de titel op Amazon veranderd" en drie mensen stilletjes hun browser history checken. Eén bron van waarheid betekent dat het antwoord in products.updated_at en een git-log van de renderers staat.
En de saaie failure mode (waar niemand een screenshot van maakt omdat het te gênant is om toe te geven) verdwijnt. Tot april betaalde een groothandelskoper op Amazon €1.299 adviesprijs voor een SKU die op Bol al was afgeprijsd naar €949, omdat niemand had onthouden de einddatum van de promotie door te zetten naar Amazon. Eén bron van waarheid betekent dat de volgende promotie alle drie de kanalen op precies hetzelfde moment triggert, of geen enkele.
Wat we anders zouden doen
Drie dingen, in volgorde van hoeveel pijn ze hadden bespaard.
Eén: bouw dry-run-modus op dag één, niet op dag twaalf. Het merendeel van de renderer-bugs die we op dag 13 vingen, was op dag 4 al duidelijk geweest als we vanaf het begin diffs hadden gelogd tegen de bestaande portal-state.
Twee: hash niet de hele payload. We begonnen met het hashen van de complete gerenderde JSON. Dat betekende dat elke schema-version bump van Amazon een re-push afdwong van elke SKU op dat kanaal. We hashen nu alleen de semantische velden (prijs, voorraad, titel, beschrijving, afmetingen, foto's), en behandelen veranderingen in de schema-envelope apart.
Drie: schrijf het audit-log naar dezelfde Postgres. We stuurden de logs eerst naar een aparte observability tool. Toen moest iemand antwoorden op "laat me elke wijziging aan SKU 8421 in mei zien" en moesten we van tool wisselen. Nu is er een tabel push_events, en is de query één join.
Toen we dit bouwden voor de Rotterdamse groothandel, was de hoek waar de meeste tijd van onze tweede week in ging zitten Amazons product-type schema-validatie: die verandert vaker dan de docs suggereren, en stille rejections zien er het eerste uur identiek uit aan stille acceptatie. We losten het op door schemas lokaal te cachen en bij een mislukte push opnieuw te valideren, wat het detecteren van mislukkingen verschoof van "morgenochtend" naar "twee seconden voordat de request de worker verlaat." Dat soort loodgieterswerk is grotendeels waar ons werk aan procesautomatisering in de praktijk uit bestaat.
Sta je op het punt om hier iets vergelijkbaars aan te beginnen, dan is de kleinste eerste stap één query op je eigen data: hoeveel van je live SKU's over alle kanalen hebben vandaag een titel die niet matcht met je master-spreadsheet. Het antwoord is je startpunt.
Kern
Als je team productdata op meer dan één plek bewerkt, heb je geen productdatabase. Je hebt een synchronisatieprobleem dat zich voordoet als invoerwerk.
FAQ
Waarom Postgres en geen volwaardige PIM?
Voor 1.800 SKU's over drie kanalen is een PIM overkill en traag op te tuigen. Postgres met een JSONB attrs-kolom dekt hetzelfde af in een week, en je team kent SQL al.
Hoe ga je om met afwijzingen van marketplaces?
De worker schrijft last_pushed_hash pas nadat de marketplace acceptatie bevestigt. Mislukte pushes blijven zonder hash en de volgende nachtrun probeert ze automatisch opnieuw.
Hoe zit het met realtime voorraad-updates?
Een nachtelijke run is prima voor prijzen en beschrijvingen. Voor voorraad draai je dezelfde worker elke 15 minuten met een filter dat alleen het voorraadveld raakt. Dezelfde hash-truc, hogere frequentie.
Kun je de dry-run-modus overslaan?
Kan. Daar krijg je op dag één spijt van. Dry-run bouw je in een uur en het vangt de renderer-bugs af die anders je live listings raken.