Marketplace Service API
The per-tenant storefront sales channel — opening hours, catalogue browse, feature switches, shipping, promo codes, buyer accounts, wishlist, reviews, fulfilment and the storefront's legal documents
The Marketplace Service is the per-tenant storefront sales channel. Each tenant sells to its own consumers on its own hostname — this is not a Straventa-operated marketplace, and Straventa never holds buyer funds.
Base URLs
| Environment | Hostname |
|---|---|
| Sandbox | https://sandbox.505pay.link |
| Production | https://api.505pay.link |
Every endpoint here is service-to-service, not anonymous
The buyer's browser never calls this API. It calls the marketplace BFF, which holds a client-credentials token and calls these paths server-side.
The reason is tenant selection: an anonymous request carries nothing that identifies a tenant, so a Host on an unauthenticated request would be a client-supplied tenant selector — and hostnames are public and enumerable. The authenticated caller is pinned by subject (MARKETPLACE_BFF_SUBJECTS), not by audience: aud in this estate is issuer-global, so a token with aud=payops.marketplace proves only "a token from our issuer".
Three trust zones
| Zone | Routes | Tenant comes from | Caller proves it with |
|---|---|---|---|
| Seller / console | …/storefront/*, …/products/*/images, …/shipping/admin/*, …/promos/admin*, …/orders/admin*, …/reviews/admin* | the verified claim | a marketplace.* permission |
| Buyer / BFF | …/storefront, …/catalog*, …/captcha/challenge, …/cart*, POST …/orders, …/orders/track/*, …/shipping/methods, …/legal/* | the request Host | BFF subject pin, plus captcha on every write |
| Shopper | …/buyer/* | the request Host | the BFF pin and an X-Buyer-Session token |
The /admin suffix on the seller routes exists precisely so one path never carries two trust zones — that is how a gate ends up applied to a path instead of to an operation.
The storefront is selected by the request Host, resolved against tenant_hostnames. X-Forwarded-Host and a host query parameter are deliberately ignored — both are settable by anyone who can reach the edge.
404 is neutral on purpose
An unknown host, a suspended tenant, a storefront that is switched off, and a tenant whose status cannot be resolved all return the same 404 body. Any observable difference between them would be a tenant-existence oracle over a public, enumerable namespace. The same neutrality applies to bindings (another tenant's hostname vs. a nonexistent one), to hold releases, to a variant this storefront does not list, and to another shopper's address or order number.
Enabling the channel for a tenant
Permissions. Migration 0140_marketplace_permissions registers four keys — marketplace.storefront.read, marketplace.storefront.write, marketplace.order.read, marketplace.order.write — and grants the storefront pair to tenant_admin, merchant_owner, merchant_pos_manager, pos-owner and pos-manager; marketplace.order.read to tenant_admin, merchant_owner and merchant_support; marketplace.order.write to tenant_admin and merchant_owner.
Bindings. putMarketplaceStorefrontBinding points one of the tenant's own hostnames at one of its own stores, or unbinds it when store_id is null. It creates no hostname and no DNS record. A bind is refused with hostname_reserved when the hostname is one of the install's own front doors (console, IdP, API host), sits outside the owned-apex allowlist, or is the tenant's primary hostname. Unbinding is never refused on those grounds — it must always be possible to clear a binding an earlier, looser rule allowed.
Only active stores are offered. getMarketplaceStorefrontAdmin omits inactive stores, because host resolution filters on status = 'active' and offering one would let a seller bind and publish a store that can never resolve. It reads hostnames and stores in one transaction, so the console cannot render a hostname bound to a store the store list does not contain.
The switch. putMarketplaceStorefrontEnabled with enabled: true — and a hostname already bound — makes the catalogue readable by buyers on the public internet at that hostname. With false, every bound hostname resolves to the neutral not-found.
For the exact SQL, env vars and verification steps on a self-hosted install, see the operator runbook docs/runbooks/enable-sales-channel.md.
Opening hours and the open switch
A shop has two independent closure controls, and the effective state is the AND of them:
effective open = manual switch is on
AND (no schedule is set, OR now is inside the schedule
in the store's own timezone)The manual switch stays the master, so a seller can always close early regardless of what the schedule says. A store with no schedule behaves exactly as it did before schedules existed — the manual switch alone decides.
Writing a schedule
putMarketplaceStorefrontSettings carries both controls. open is the manual switch and hours is the schedule.
{
"store_id": "01916b2a-f3c0-7000-0000-000000000s01",
"open": true,
"closed_message": "Tutup sementara untuk stok opname.",
"hours": {
"mon": [{ "open": "08:00", "close": "17:00" }],
"tue": [{ "open": "08:00", "close": "17:00" }],
"wed": [{ "open": "08:00", "close": "17:00" }],
"thu": [{ "open": "08:00", "close": "17:00" }],
"fri": [{ "open": "08:00", "close": "11:30" }, { "open": "13:00", "close": "17:00" }],
"sat": [{ "open": "09:00", "close": "14:00" }],
"sun": []
}
}{ "store_id": "01916b2a-f3c0-7000-0000-000000000s01" }Three field-level rules do most of the work:
| Field | Omitted | null | Present |
|---|---|---|---|
open | leaves the switch alone | — | sets the switch |
hours | leaves the schedule alone | clears it (back to "no schedule") | replaces it wholesale |
features | changes no switch | clears the whole set (every feature ON) | merges the keys present |
open is a boolean, and an absent boolean decodes to false — so clear-on-absent would let a caller that only meant to change a logo close the shop. hours needs the third state because without it a seller could never remove a schedule once saved. features merges rather than replaces because the five switches are five independent decisions made on five different screens, and a replace would let the shipping page switch reviews off.
The schedule grammar
- All seven keys
mon…sunare required whenhoursis present. A missing day would read as "closed" and would silently shut a shop a client merely forgot to send. - An empty array means closed that day.
- At most three intervals per day, sorted and non-overlapping.
- Times are zero-padded 24-hour
"HH:MM".closemust be strictly afteropen. - Intervals are half-open: open at exactly
open, shut at exactlyclose. That is what lets09:00–12:00and12:00–17:00mean one continuous opening rather than an overlap or a one-minute hole. - No interval may span midnight. A shop open past midnight writes
…24:00today plus00:00…the next day.24:00is the only legal spelling of "ends at midnight".
A grammar violation is 400 invalid_storefront_hours — its own code, not the generic validation_failed, because the console renders a seven-row grid and needs to know it was the hours that were refused. The message names the day and the interval.
What the buyer sees
getMarketplaceStorefront reports the resolved state:
{
"name": "Toko Sinar Jaya",
"about": "Sembako dan kebutuhan rumah tangga, diantar ke seluruh Jakarta.",
"logo_url": "https://cdn.example/sinarjaya/logo.png",
"banner_url": "https://cdn.example/sinarjaya/banner.jpg",
"accent_hex": "#1B6AC9",
"open": false,
"closed_message": "",
"closed_reason": "hours",
"next_open_at": "2026-09-22T08:00:00+07:00",
"timezone": "Asia/Jakarta",
"hours": {
"mon": [{ "open": "08:00", "close": "17:00" }],
"tue": [{ "open": "08:00", "close": "17:00" }],
"wed": [{ "open": "08:00", "close": "17:00" }],
"thu": [{ "open": "08:00", "close": "17:00" }],
"fri": [{ "open": "08:00", "close": "11:30" }, { "open": "13:00", "close": "17:00" }],
"sat": [{ "open": "09:00", "close": "14:00" }],
"sun": []
},
"features": {
"shipping": true,
"promos": true,
"buyer_accounts": true,
"wishlist": true,
"reviews": true
}
}closed_reasonis""while open,manualwhen the seller's switch is off, andhourswhen the switch is on but now is outside the schedule. It is a distinct field rather than an inference fromnext_open_at, because a schedule that never reopens ishourswith no reopening time.next_open_atis RFC 3339 in the store's offset, and is only ever set whenclosed_reasonishours. Undermanualonly the seller knows when they will flip the switch back. It is also""when the schedule has no opening in the next eight days.timezoneis the IANA zone the server actually used. A store whose stored timezone is blank or unparsable is evaluated inAsia/Jakartaand reports that, so a client formatsnext_open_atagainst the same clock the server read.hoursisnullwhen the store has no schedule — which is not the same as being closed all week (that is a present document with seven empty days).
`open` is a hint; 409 `storefront_closed` is the enforcement
Every buyer write — mint cart, add item, release a hold, shipping quote, promo preview, place order — answers 409 storefront_closed while the shop is closed, computed from the same row the read reports. The reads keep serving, so a closed storefront still renders its catalogue and its reason. The same request succeeds unchanged once the shop reopens.
Per-store feature switches
Five optional storefront features are switched per store. All five keys are always present in MarketplaceStorefront.features and always boolean — the server renders the complete set, so a client never has to distinguish "off" from "this server is older than that feature".
| Key | Covers |
|---|---|
shipping | delivery methods, quotes and the checkout address step |
promos | promo codes on the cart and at placement |
buyer_accounts | buyer sign-in and the account area |
wishlist | saved items (needs buyer_accounts to be usable) |
reviews | product reviews and ratings |
A missing key in storage means true. The column is added to existing stores empty, and defaulting an absent key to false would silently switch every feature off for every shop that has never opened the settings page.
A feature switched off makes its buyer routes answer 404 feature_disabled — not 403, and not an empty list:
{
"error": "feature_disabled",
"message": "this storefront does not offer that",
"request_id": "01K5Q7J2Z0000000000000000"
}An empty list is a different, legitimate state — the feature is on and the seller has configured nothing — and the storefront renders the two differently. The seller's administration routes are never gated by these switches, or a seller could not turn a feature back on.
Switch one key without touching the others:
{
"store_id": "01916b2a-f3c0-7000-0000-000000000s01",
"features": { "reviews": false }
}An unknown key or a non-boolean value is 400 invalid_storefront_features, again its own code so the console can point at the toggle.
Listings and catalogue browse
A product variant is offered on the marketplace only when it is listed there. Listings live in pos_sales_channel_listings, whose sales_channel column is the enum pos_sales_channel — ('pos', 'marketplace', 'own_site').
Two constraints shape how you write them:
- Grain differs by channel. A
poslisting hasstore_id IS NULL; every non-poslisting (marketplace, own_site) must carry astore_id. This is a tableCHECK, not a convention. - Uniqueness is enforced twice — once per
(tenant_id, store_id, variant_id, sales_channel)and once tenant-wide per(tenant_id, variant_id, sales_channel).
listMarketplaceCatalog returns only variants listed on sales_channel='marketplace' for that store. A variant listed only on pos or own_site, or listed nowhere, is not offered.
Query parameters
| Parameter | Default | Meaning |
|---|---|---|
page | 1 | 1-based page number. |
limit | 20 | Clamped to [1,100]. The clamped value is echoed back — paginate against that, not against what you asked for. |
q | — | Free-text, matched case-insensitively against product name, variant name and SKU. Trimmed, then matched literally — % and _ are escaped. Over 80 characters is a 400. |
category | — | One category id. A malformed value is a 400; a well-formed id belonging to another store simply matches nothing. |
sort | name | name (shelf order, product then variant), price_asc, price_desc (both by the effective price, i.e. the listing's per-channel override where the seller set one), or newest. Anything else is a 400. |
The page envelope
{
"products": [
{
"variant_id": "01916b2a-f3c0-7000-0000-0000000000aa",
"product_id": "01916b2a-f3c0-7000-0000-0000000000a0",
"sku": "BERAS-5KG",
"product_name": "Beras Premium Setra Ramos",
"name": "5 kg",
"price_cents": 7500000,
"currency": "IDR",
"available_qty": 12,
"description": "Beras pulen, kemasan 5 kg.",
"image_url": "https://cdn.example/sinarjaya/beras-5kg-1.jpg",
"category_id": "01916b2a-f3c0-7000-0000-0000000000c7",
"category_name": "Sembako",
"images": [
{ "url": "https://cdn.example/sinarjaya/beras-5kg-1.jpg", "alt": "Karung beras 5 kg" },
{ "url": "https://cdn.example/sinarjaya/beras-5kg-2.jpg", "alt": "Butiran beras" }
],
"rating_avg": 4.6,
"rating_count": 38
}
],
"categories": [
{ "id": "01916b2a-f3c0-7000-0000-0000000000c7", "name": "Sembako", "count": 24 },
{ "id": "01916b2a-f3c0-7000-0000-0000000000c8", "name": "Minuman", "count": 11 }
],
"total": 24,
"page": 1,
"limit": 2,
"total_pages": 12
}price_cents is 7500000 — integer minor units, so Rp 75.000. Never a float, never a formatted string.
Field notes:
product_idis the owning product. It is on the wire because two surfaces key on the product and cannot derive it: the gallery (one set of photos for every size of one item) and — the load-bearing one — reviews, sincePUT /v1/marketplace/buyer/reviews/{product_id}andGET /v1/marketplace/buyer/reviews/eligibleboth name a product.product_namevsname.product_nameis the product ("Beras Premium Setra Ramos");nameis the variant ("5 kg"). Variant names are sizes and options, so two different products routinely share one.available_qtyis already net of live stock holds. The raw stock level is deliberately never returned: a buyer who could see both would learn how many carts are currently open.imagesis cover-first,[]when empty, andimage_urlstays.image_urlis the cover — the first image, or"". The contract states the relationship, so the two can never disagree. An emptyimage_urlis what tells the card to render its initial-letter placeholder.rating_avgis a number with one decimal and is0whenrating_countis0— nevernull. A client decides between "4.6" and "no ratings yet" by looking at the count. The pair rides on the catalogue row rather than a route of its own, so a shelf never renders its stars a beat after its prices.categories[]is the category bar for the whole storefront, with acountper category — not just the categories on this page.description,image_url,category_idandcategory_nameare""when unset, nevernull.
One variant, and related items
getMarketplaceCatalogItem returns a single MarketplaceCatalogItem — byte-identical in shape to an element of products, because it is the same projection over the same query. It exists so a product permalink does not have to page through the whole catalogue.
Refusals are pooled: an unknown variant, another tenant's variant, one listed on a different sales channel, one whose product is inactive, and a variant_id that is not a uuid at all all return the same neutral 404 as an unknown Host. Variant ids travel in public permalinks.
listMarketplaceRelatedItems returns up to six items:
{
"items": [
{
"variant_id": "01916b2a-f3c0-7000-0000-0000000000ab",
"product_id": "01916b2a-f3c0-7000-0000-0000000000a1",
"sku": "MINYAK-2L",
"product_name": "Minyak Goreng Sawit",
"name": "2 L",
"price_cents": 3600000,
"currency": "IDR",
"available_qty": 40,
"description": "",
"image_url": "https://cdn.example/sinarjaya/minyak-2l.jpg",
"category_id": "01916b2a-f3c0-7000-0000-0000000000c7",
"category_name": "Sembako",
"images": [{ "url": "https://cdn.example/sinarjaya/minyak-2l.jpg", "alt": "" }],
"rating_avg": 0,
"rating_count": 0
}
]
}The ranking is derived from paid orders only, never from views or carts: two items are related when at least two distinct captured orders of this store contained both. Below that threshold a single buyer's basket would define the shop's recommendations. The list is padded with other active listings in the same category so a new shop still renders something, and sold-out items sort last. There is no pagination envelope — the query caps at six, so total, page and limit could only ever say 6, 1 and 6.
Related items are not feature-gated: they are part of the catalogue, and the five switches do not include one for them.
Product gallery (seller)
replaceMarketplaceProductImages replaces the whole gallery in the order given; position is the array index, so reordering is a re-PUT of the same URLs in a different order.
{
"images": [
{ "url": "https://cdn.example/sinarjaya/beras-5kg-1.jpg", "alt": "Karung beras 5 kg" },
{ "url": "https://cdn.example/sinarjaya/beras-5kg-2.jpg", "alt": "Butiran beras" }
]
}The product's cover column is kept in step with position 0 in the same transaction. At most 8 images; URLs must be absolute https and at most 2048 characters; alt at most 160. A violation is 400 invalid_product_images rather than the generic validation_failed, so the console can point at the offending row. An empty array clears the gallery. Both gallery routes require marketplace.storefront.write — a gallery is a presentation setting, and the buyer-visible copy is served by the catalogue.
Stock holds and reservations
A hold is a real, cross-channel inventory lock
POS available-quantity subtracts live holds, so units held by an open cart cannot be sold at the seller's counter until the hold expires, is released, or is committed by placement. That is the point — it is what stops the same unit being sold twice — and it is why the cart routes carry a captcha and two velocity limits.
The cart id is the only credential an anonymous buyer holds. createMarketplaceCart ignores the request body entirely — it does not read it, not even to validate it — and mints a server-side UUIDv7. An endpoint that parsed a client-supplied id in order to reject it would be one edit away from honouring it. A cart id that was not minted there addresses nothing: every read and write is scoped by (tenant, cart).
addMarketplaceCartItem folds a repeat add of the same variant into the existing hold rather than creating a second one, and the quantity in the response is the cart's new total for that variant, not the delta just added.
Four distinct bounds can refuse a hold with 409 — read error, they are not interchangeable:
| Code | Meaning |
|---|---|
insufficient_stock | not enough units exist to hold |
hold_ceiling_reached | this variant is already held too heavily across all carts |
cart_line_limit_reached | this cart is at its per-variant unit cap or its distinct-variant cap |
reservation_expired | the cart's holds have lapsed |
Those ceilings are deployment configuration, with these defaults:
| Variable | Default | Meaning |
|---|---|---|
MARKETPLACE_MAX_UNITS_PER_CART | 50 | units of one variant per cart (Ceiling A) |
MARKETPLACE_HOLD_CEILING_ABS | 200 | absolute units of a variant held across carts |
MARKETPLACE_HOLD_CEILING_PCT | 20 | …or this percentage of stock, whichever binds |
MARKETPLACE_CART_LINE_CEILING | 50 | distinct variants per cart (Ceiling B) |
MARKETPLACE_HOLD_TTL | 15m | how long a hold survives unconfirmed |
MARKETPLACE_INTENT_TTL | 30m | how long a placement's payment intent lives |
MARKETPLACE_HOLD_TTL and MARKETPLACE_INTENT_TTL are validated as a pair — HoldTTL must be at least 60s and must not exceed IntentTTL, and IntentTTL must fall between 60s and 30 days. Both marketplace-service and the scheduler read them through one shared reader so the two deployables cannot be provisioned into disagreement one at a time.
removeMarketplaceCartItem returns the units to available stock immediately. Ceiling B is deliberately not applied on release — applying it would refuse the very request that brings a full cart back under the ceiling.
Captcha on public writes
Every buyer write — mint cart, add item, remove item, shipping quote, promo preview, place order, request a sign-in code — is gated by RequireCaptcha, which is fail-closed: a nil verifier, a missing token, a transport failure and a failed verification all return the single refusal 403 captcha_required. The buyer's solved token is forwarded by the BFF in:
X-Marketplace-Captcha-Token: <solved token>CAPTCHA_PROVIDER picks the arm, and it must be set to the same value on marketplace-service and the marketplace web app.
altcha — self-hosted proof of work
No vendor account and no vendor key. The BFF fetches one challenge per buyer attempt from getMarketplaceCaptchaChallenge, the browser solves it (a bounded SHA-256 search across web workers) and presents the solution on the write that follows.
{
"algorithm": "SHA-256",
"challenge": "8f5a1d0c…",
"maxNumber": 100000,
"salt": "b1c9…",
"signature": "6d21…"
}Each challenge is signed with ALTCHA_HMAC_KEY (a key only marketplace-service holds), expires ten minutes after minting, and verifies exactly once — a solution presented twice is refused with the same captcha_required as no solution at all. The response is never cached (Cache-Control: no-store); a cached challenge is one solution shared by every buyer behind the cache.
The mint is rate-limited: 120/min per forwarded buyer address, and 20/min shared across every caller whose address the BFF could not attribute. The cost is not the mint, it is the set — every solved challenge occupies a slot in the single-use replay memory, and once that set is full the verifier starts refusing honest buyers. Over the limit the route answers 429; a solution minted before the limit was reached still verifies.
Under the hCaptcha provider this route answers 501 — that widget never calls it.
hcaptcha
Verification posts to https://hcaptcha.com/siteverify with HCAPTCHA_SECRET.
hCaptcha's published test secret makes the gate a no-op
0x0000000000000000000000000000000000000000 is hCaptcha's published test secret — siteverify accepts any token at all against it. Boot validation refuses an unset HCAPTCHA_SECRET and happily accepts this one, so a present-but-inert secret looks exactly like a working one.
The service logs this at ERROR and deliberately does not refuse to boot: refusing would CrashLoop the live storefront and turn a security gap into an outage. The remedy is a real key — check the log line on every deployment.
Buyer writes are additionally subject to per-IP and per-cart velocity limits, which refuse with 429.
Shipping
Gated by the shipping feature switch on the buyer side only.
What the buyer is shown
{
"methods": [
{ "id": "01916b2a-f3c0-7000-0000-000000000m01", "name": "Ambil di toko", "kind": "pickup", "free_over_cents": null },
{ "id": "01916b2a-f3c0-7000-0000-000000000m02", "name": "Kurir dalam kota", "kind": "flat", "free_over_cents": 20000000 },
{ "id": "01916b2a-f3c0-7000-0000-000000000m03", "name": "JNE REG", "kind": "zone", "free_over_cents": null }
],
"provinces": ["Aceh", "Bali", "Banten", "DKI Jakarta", "Jawa Barat", "Jawa Tengah", "Jawa Timur"]
}The rate card is not published
flat_cents and the per-province rates are absent from this read: they are the seller's pricing model, and an anonymous endpoint that served them would hand it to every competitor. A buyer needs the amount for their province, which the quote answers one province at a time. free_over_cents is published, because it is a marketing promise the seller wants shown.
An empty methods means the seller has shipping switched on and has configured none — a different fact from the feature being off (404 feature_disabled), and the storefront renders the two differently.
Quoting
{
"method_id": "01916b2a-f3c0-7000-0000-000000000m03",
"province": "Jawa Barat"
}{
"method_id": "01916b2a-f3c0-7000-0000-000000000m03",
"shipping_cents": 1800000,
"free_shipping_applied": false
}The quote is display only and writes nothing. It holds no stock, reserves no rate and stores nothing. Placement recomputes it from the same function over the same re-resolved method, so a quote that has gone stale — the seller reprised, a threshold now applies — is corrected at checkout rather than honoured.
The cart's value is read server-side, never taken from the body: it decides whether the free-shipping threshold fires, and a buyer-supplied subtotal here would be a buyer-chosen delivery price. The threshold is tested against the pre-discount subtotal, so a promo code cannot silently revoke free delivery. A body carrying an amount is 400 unexpected_price_field.
400 invalid_shipping covers an unknown method, an unknown province, and a zone method with no rate for that province — one code for all three. The distinction matters to a seller debugging a rate card and not at all to a buyer, and separating them would say which method ids exist.
The seller's rate card
Every seller shipping route requires store_id — as a query parameter on the list, and in the body on create and update.
{
"store_id": "01916b2a-f3c0-7000-0000-000000000s01",
"name": "JNE REG",
"kind": "zone",
"free_over_cents": null,
"active": true,
"position": 3,
"zone_rates": [
{ "province": "DKI Jakarta", "rate_cents": 1200000 },
{ "province": "Jawa Barat", "rate_cents": 1800000 },
{ "province": "Jawa Timur", "rate_cents": 2600000 }
]
}{
"methods": [
{
"id": "01916b2a-f3c0-7000-0000-000000000m02",
"name": "Kurir dalam kota",
"kind": "flat",
"flat_cents": 1000000,
"free_over_cents": 20000000,
"active": true,
"position": 2,
"zone_rates": []
}
]
}- A
zonemethod's rates must name canonical provinces and must not repeat one. A repeat is refused rather than last-wins, because the seller has not said which rate they meant. - A
pickupmethod takes no amounts. - Violations are
400 invalid_shipping. - The update replaces the whole zone table wholesale, not merged — removing a province is a PUT without it.
- The delete is a soft delete: the method is deactivated, not removed. Orders keep a name snapshot rather than a foreign key, but the row is kept so a mistaken deactivation can be restored and an old audit line still resolves.
- The seller routes are not gated on the
shippingfeature switch. Gating the editor on the switch would make "shipping: off" a state a seller could enter and never leave. - Deactivating the store's last active method makes placement stop requiring a
shippingblock — the same state a store that never had one is in.
At placement
When the store has at least one active method, placeMarketplaceOrder requires a shipping block:
{
"shipping": {
"method_id": "01916b2a-f3c0-7000-0000-000000000m03",
"recipient_name": "Siti Rahmawati",
"phone": "081234567890",
"street": "Jl. Cihampelas No. 88",
"city": "Bandung",
"province": "Jawa Barat",
"postal_code": "40131",
"note": "Titip di pos satpam."
}
}method_id, recipient_name and phone are required; the rest varies by courier. shipping_cents is never in the body — it is refused as unexpected_price_field like every other amount, and is recomputed server-side inside the placement transaction from the re-resolved method. The stored address is a snapshot taken at placement and never re-read: a buyer may edit a saved address the day after they order, and an order that re-read it would report a delivery that never happened.
Promo codes
Gated by the promos feature switch on the buyer side only.
Preview
{ "code": "HEMAT10" }{
"code": "HEMAT10",
"discount_cents": 1500000,
"description": "Diskon 10% untuk pembelian sembako"
}The preview is display only. It takes no lock and writes no redemption: a preview that locked the promo row would let any anonymous caller block every placement using that code for the length of a request, denying the seller's whole campaign for the price of one HTTP call. The amount is recomputed under the row's lock at placement, so a preview that goes stale becomes a refusal at checkout — which is the contract: the buyer must see the price change before paying.
One neutral 422, for every cause
{
"error": "promo_not_applicable",
"message": "this code cannot be used on this order",
"request_id": "01K5Q7J2Z0000000000000000"
}Unknown, malformed, another store's, paused, outside its window, under its minimum, exhausted, or past this buyer's limit — all the same 422 promo_not_applicable. Distinguishable answers turn this route into a code-enumeration oracle, and "expired" in particular confirms the code exists, which is most of the work of guessing a seller's next campaign.
A body carrying an amount is 400 unexpected_price_field.
At placement
placeMarketplaceOrder takes promo_code as a plain string beside the lines. The discount is resolved under the promo row's lock inside the placement transaction and reported back as discount_cents and promo_code on the order.
Seller CRUD
Both promo reads and writes require store_id — query parameter on the list, body field on create.
{
"store_id": "01916b2a-f3c0-7000-0000-000000000s01",
"code": "HEMAT10",
"kind": "percent",
"value": 10,
"max_discount_cents": 2500000,
"min_subtotal_cents": 10000000,
"starts_at": "2026-10-01T00:00:00+07:00",
"ends_at": "2026-10-31T23:59:59+07:00",
"usage_limit": 500,
"per_buyer_limit": 1,
"active": true,
"description": "Diskon 10% untuk pembelian sembako"
}{
"id": "01916b2a-f3c0-7000-0000-000000000p01",
"code": "HEMAT10",
"kind": "percent",
"value": 10,
"max_discount_cents": 2500000,
"min_subtotal_cents": 10000000,
"starts_at": "2026-10-01T00:00:00+07:00",
"ends_at": "2026-10-31T23:59:59+07:00",
"usage_limit": 500,
"per_buyer_limit": 1,
"active": true,
"description": "Diskon 10% untuk pembelian sembako",
"redeemed_count": 0
}- Codes are stored upper-cased and are unique per store, so two stores of one tenant may both run
HEMAT10. - A
percentcode'svalueis1..100. Afixedcode'svalueis an amount in minor units.max_discount_centscaps a percentage code and is meaningless on a fixed one. usage_limitis the campaign-wide redemption cap;per_buyer_limitis per shopper. Bothnullmeans unlimited.- A duplicate code on the same store is
409 promo_code_exists. - Malformed code, out-of-range value, or a window that ends before it starts is
400 invalid_promo— a seller-facing code with a specific message. The buyer's single neutral422is a different surface for a different reason. - The code itself is immutable. Renaming it would rewrite what already-placed orders say they used, and the redemption history would attach to a string no buyer ever typed. To change the spelling, deactivate this one and create another.
- Lowering
usage_limitbelow the redemptions already taken does not revoke them; the code simply refuses from then on. listMarketplacePromosis deliberately detailed. The neutral buyer422exists so codes cannot be enumerated by an anonymous caller, not to keep a seller from seeing their own campaign.
Placing an order
The request body carries no money, and a body that carries any is rejected
unit_cents, line_total_cents, subtotal_cents, shipping_cents, tax_cents, total_cents and order_number are refused with 400 unexpected_price_field wherever they appear, at any depth — including inside lines[], where they would most naturally be smuggled. They are not silently ignored: a silently-ignored field is indistinguishable from an honoured one, and this is the payer trust zone, where there is no authentication to fall back on.
Every cent is resolved server-side from the listing, inside the placement transaction, and first becomes visible to the buyer in this endpoint's response.
{
"cart_id": "01916b2a-f3c0-7000-0000-0000000000c1",
"client_operation_id": "checkout-2026-09-21-7f3a",
"buyer": {
"email": "[email protected]",
"name": "Siti Rahmawati",
"phone": "081234567890"
},
"lines": [
{ "variant_id": "01916b2a-f3c0-7000-0000-0000000000aa", "quantity": 2 }
],
"shipping": {
"method_id": "01916b2a-f3c0-7000-0000-000000000m03",
"recipient_name": "Siti Rahmawati",
"phone": "081234567890",
"street": "Jl. Cihampelas No. 88",
"city": "Bandung",
"province": "Jawa Barat",
"postal_code": "40131",
"note": "Titip di pos satpam."
},
"promo_code": "HEMAT10"
}{
"id": "01916b2a-f3c0-7000-0000-0000000000o1",
"order_number": "MKT-01916B2AF3C0",
"status": "placed",
"cart_id": "01916b2a-f3c0-7000-0000-0000000000c1",
"env": "production",
"currency": "IDR",
"subtotal_cents": 15000000,
"discount_cents": 1500000,
"promo_code": "HEMAT10",
"tax_cents": 0,
"shipping_cents": 1800000,
"total_cents": 15300000,
"shipping_method_name": "JNE REG",
"shipping_address": {
"recipient_name": "Siti Rahmawati",
"phone": "081234567890",
"street": "Jl. Cihampelas No. 88",
"city": "Bandung",
"province": "Jawa Barat",
"postal_code": "40131",
"note": "Titip di pos satpam."
},
"payment_shortcode": "MKT01916B2AF3C0",
"lines": [
{
"id": "01916b2a-f3c0-7000-0000-0000000000l1",
"variant_id": "01916b2a-f3c0-7000-0000-0000000000aa",
"sku": "BERAS-5KG",
"name": "Beras Premium Setra Ramos — 5 kg",
"quantity": 2,
"unit_cents": 7500000,
"line_total_cents": 15000000
}
]
}payment_shortcode is the entire handoff to checkout: the storefront sends the buyer to the checkout host's /pay/{payment_shortcode} and collects no payment details of its own. This service publishes no per-order read, so the response body is the only place the shortcode is ever returned — which is also why it sits inside the body a repeated client_operation_id replays.
Idempotency is bound to both the key and the body. client_operation_id plus the cart identifies one placement attempt; the body is hashed and stored with it:
| Repeat | Outcome |
|---|---|
| Same key, same body, first attempt running | 409 idempotency_in_progress |
| Same key, different body | 409 idempotency_key_mismatch |
| Same key, same body, first attempt finished | the first response replayed byte-for-byte, with its original status |
A failed placement does not release the key. Releasing it would re-open the key to an immediate identical retry, which is the double-submit the protocol exists to stop. A genuine retry after failure needs a new client_operation_id — which is a new intent to buy.
The order is created unpaid. Payment is a separate step: the BFF hands the returned shortcode to the checkout app.
Cross-tenant isolation on listMarketplaceSellerOrders is the marketplace_orders FORCE-RLS policy — statements run inside a tenant-scoped transaction, so another tenant's orders are not filtered out, they are not visible. A tenant with no orders gets 200 and an empty orders array, never a 404.
Totals
One identity, enforced in the placement transaction and again by the
marketplace_orders_totals_sum_check database CHECK:
total = subtotal − discount + tax + shippingEvery term is an integer in minor units — 15300000 is Rp 153.000. There is no float anywhere on this path and no formatted amount on the wire; formatting is the client's job.
Two orderings in that formula are deliberate:
- Shipping is added after the discount. A code that covers the whole basket still leaves the delivery to pay. A discount that could eat the shipping would have the seller funding a courier out of a coupon they paid for themselves.
- The discount is capped at the subtotal, not at the total. A fixed
5000000code on a2000000cart discounts2000000. The alternative is a negative total, which is the platform paying the buyer to shop.max_discount_centscaps a percentage code first, then the subtotal cap applies.
total_cents is exactly what checkout charges: the payment intent is created for that number.
The platform fee is not in this formula. It derives from the captured payment, not from the order, and is produced by an owner-meter hook that runs inside the paid-flip transaction in payments-service — see Fees and metering below. platform_fee_cents and platform_fee_status appear on the seller's order row, and platform_fee_cents is null until the payment is captured and priced.
Buyer accounts
Gated by the buyer_accounts feature switch.
There is no password anywhere in this flow. A shopper proves an email address by reading a six-digit code out of it.
Requesting a code
{ "email": "[email protected]", "captcha": "<solved token>" }{}The request route cannot be used to learn who shops here
A storefront's customer list is exactly what a competitor would pay to enumerate, so the response never varies by whether the address is known. Three mechanisms keep that true:
- No buyer row is created here. The shopper is created at verify, once the code has been read out of a mailbox — so the database state after a request for a stranger is identical to the state after a request for a customer.
- There is no "does this buyer exist" read anywhere on the path, so no branch differs in cost.
- Every failure after validation — database, outbox, missing secret — is swallowed into the same
202, with the class logged for the operator.
The one exception is a malformed address, which is a 400: that reveals nothing about who shops here, and silently accepting a typo leaves a shopper waiting for mail that will never come.
Verifying
{ "email": "[email protected]", "code": "204817" }{
"session_token": "Zk9wXQ1r…",
"expires_at": "2026-10-21T09:14:03+07:00",
"buyer": {
"id": "01916b2a-f3c0-7000-0000-000000000b01",
"email": "[email protected]",
"name": "Siti Rahmawati",
"phone": "081234567890",
"marketing_opt_in": false,
"addresses": []
}
}Security properties, all of them load-bearing:
| Property | Value |
|---|---|
| Code length | 6 digits, from crypto/rand |
| Code lifetime | 10 minutes |
| Wrong guesses | 5, then the code is burned — enforced inside the same transaction that reads it, so five concurrent guesses cannot all see "attempt 1" |
| Comparison | constant-time |
| Storage | only the keyed HMAC of the code; the plaintext is never stored |
| Refusal | exactly one — 401 invalid_code — for wrong digits, an expired code, a burned code, and an address that never requested one |
| Session lifetime | 30 days, absolute, never extended |
| Session token storage | only its sha256. It is shown exactly once, never logged, and never echoed on any later route |
On success the buyer row is created if it did not exist, this address's guest orders on this store are attached to it, and the session is minted — all in one transaction, so a shopper never holds a token whose order history is still catching up.
If the deployment has no MARKETPLACE_BUYER_SECRET, verify answers 503 service_misconfigured rather than comparing two empty digests.
Carrying the session
The BFF stores session_token in an HttpOnly cookie named mp_buyer (Secure in production, SameSite=Lax, Path=/, Max-Age derived from expires_at) and forwards it server-side as:
X-Buyer-Session: <session_token>The token never reaches the browser as JS-readable state. Every …/buyer/* route answers 401 buyer_unauthenticated for a missing header, an expired session, a revoked session, and a session belonging to a different storefront — one refusal for all four, because any difference would confirm whether a token someone holds is live, and on which shop.
logoutMarketplaceBuyer answers 204 even when the token names nothing, and is deliberately not gated on a live session: a shopper whose session already expired must still be able to clear it.
Profile and addresses
{
"id": "01916b2a-f3c0-7000-0000-000000000b01",
"email": "[email protected]",
"name": "Siti Rahmawati",
"phone": "081234567890",
"marketing_opt_in": false,
"addresses": [
{
"id": "01916b2a-f3c0-7000-0000-000000000ad1",
"label": "Rumah",
"recipient_name": "Siti Rahmawati",
"phone": "081234567890",
"street": "Jl. Cihampelas No. 88",
"city": "Bandung",
"province": "Jawa Barat",
"postal_code": "40131",
"is_default": true
}
]
}The email is absent from the update body and cannot be changed here. It is the login identity: a route that rewrote it would move an account — and every order attached to it — to an address the holder never proved they control. updateMarketplaceBuyer takes name, phone and marketing_opt_in only.
The address book holds at most 10 addresses. recipient_name and street are the only required fields — a parcel needs a person and a place, and the rest varies by country and by how the seller's courier wants it written. listMarketplaceBuyerAddresses returns { "addresses": [...] }, never null.
The shopper's buyer id is a predicate in the statement, not a check before it: another shopper's address id matches zero rows and answers the same neutral 404 a nonexistent id gets.
Order history
listMarketplaceBuyerOrders pages with { items, total, page, limit } (limit defaults to 20); getMarketplaceBuyerOrder takes the order number. The buyer id is in the WHERE clause, so another shopper's order number — which is short, human-readable and appears in emails — matches nothing and answers the same 404 as a number that was never issued.
{
"order_number": "MKT-01916B2AF3C0",
"status": "placed",
"payment_status": "captured",
"placed_at": "2026-09-21T09:14:03+07:00",
"lines": [
{
"variant_id": "01916b2a-f3c0-7000-0000-0000000000aa",
"product_id": "01916b2a-f3c0-7000-0000-0000000000a0",
"sku": "BERAS-5KG",
"name": "Beras Premium Setra Ramos — 5 kg",
"quantity": 2,
"unit_cents": 7500000,
"line_total_cents": 15000000,
"image_url": "https://cdn.example/sinarjaya/beras-5kg-1.jpg"
}
],
"subtotal_cents": 15000000,
"discount_cents": 1500000,
"promo_code": "HEMAT10",
"shipping_cents": 1800000,
"shipping_method_name": "JNE REG",
"shipping_address": {
"recipient_name": "Siti Rahmawati",
"phone": "081234567890",
"street": "Jl. Cihampelas No. 88",
"city": "Bandung",
"province": "Jawa Barat",
"postal_code": "40131",
"note": "Titip di pos satpam."
},
"tax_cents": 0,
"total_cents": 15300000,
"currency": "IDR",
"fulfilment": {
"state": "packed",
"tracking_number": "",
"carrier": "",
"events": [
{ "state": "placed", "at": "2026-09-21T09:14:03+07:00" },
{ "state": "packed", "at": "2026-09-21T14:02:55+07:00" }
]
},
"pay_url": ""
}pay_url is a relative path to the hosted checkout, and "" once the order is paid. Relative because the SPA holds the checkout origin; an absolute URL would mean this API inventing a host it does not own.
The guest tracking link
trackMarketplaceOrder is the only route here that returns one person's order without a buyer session:
GET /v1/marketplace/orders/track/MKT-01916B2AF3C0?t=<signed token>It returns the same MarketplaceBuyerOrder shape as the signed-in read — the two differ in how the caller proves they may see the order, never in what they are shown.
The token is an HMAC over (order id, lower-cased buyer email) under the server secret, minted into the order's email links. It is not a random token in a table: there is nothing to store, nothing to expire, and no window in which a guest's order is reachable by a row someone forgot to clean up.
Every failure is 404, including a real order with a wrong token. Not 401, not 403. Order numbers are short and sequential-looking; a 403 on a real number and a 404 on a fake one is an order-number oracle that tells a stranger how many orders this shop has taken. The comparison is constant-time and fails closed when the deployment has no secret.
Deletion is anonymisation
DELETE /v1/marketplace/buyer/me answers 202 and is idempotent.
| Survives | Goes |
|---|---|
Orders, in full, with their snapshot columns (buyer_email included) | the account email — replaced by a unique tombstone deleted+<uuid>@invalid |
| Reviews — rating and text, shown as "Deleted buyer" | name and phone, blanked |
| addresses, wishlist, sessions, any live login code |
The orders stay because they are the seller's records: tax, accounting and dispute obligations do not end because a shopper closed their account, and a deleted order row would leave a settlement referencing nothing. The orders' own snapshot columns are untouched — buyer_email on an order is what the seller packed and shipped against, and rewriting it would corrupt the seller's record of a real delivery. Reviews keep their rating so a product page does not silently change score when someone leaves.
202 rather than 204 because the local state is gone when the call returns but downstream effects have not necessarily drained.
Wishlist
Gated by the wishlist switch, and needs buyer_accounts to be usable.
getMarketplaceBuyerWishlist returns { "items": [...] } of full MarketplaceCatalogItem rows. The payload is built by handing the saved variant ids to the catalogue's own projection, which already drops anything not currently listed on this storefront. That is what makes "delisted items disappear" true without a cleanup job: the row stays, so relisting brings the item back, and it simply does not render while the product is not for sale. It is also why a wishlist can never show a product from another store.
addMarketplaceBuyerWishlistItemisPUTand idempotent — a shopper double-tapping a heart icon is the normal case, not an error.204.removeMarketplaceBuyerWishlistItemis204even when nothing was saved. "Remove" states a desired end state, and that state holds either way.mergeMarketplaceBuyerWishlistis the sign-in handoff:
{
"variant_ids": [
"01916b2a-f3c0-7000-0000-0000000000aa",
"01916b2a-f3c0-7000-0000-0000000000ab"
]
}Local storage is caller-controlled and may hold ids from any storefront the browser has visited, so the merge statement selects the ids through this store's listing table before inserting: unknown and foreign ids are dropped by the query rather than by a validation loop, so there is no version of this route that inserts an unvalidated id. A malformed id in the list is skipped rather than failing the call — the list comes from storage the shopper never sees, and one corrupt entry must not cost them the others. More than 100 ids is 400, refused rather than truncated silently.
Reviews
Gated by the reviews switch on the buyer side; the seller's moderation queue is not.
Reading
listMarketplaceProductReviews is public — no buyer session. A shopper deciding whether to buy has not signed in.
{
"items": [
{
"id": "01916b2a-f3c0-7000-0000-000000000r01",
"rating": 5,
"title": "Berasnya pulen",
"body": "Sampai sehari setelah pesan, kemasan rapi.",
"buyer_display": "Siti R.",
"created_at": "2026-09-14T19:20:11+07:00",
"seller_reply": "Terima kasih, Bu Siti!",
"seller_replied_at": "2026-09-15T08:05:00+07:00"
}
],
"total": 38,
"page": 1,
"limit": 2,
"histogram": { "1": 1, "2": 0, "3": 3, "4": 9, "5": 25 }
}Addressed by variant, stored by product. The catalogue's unit is the variant — that is what a shopper has in front of them — but a review is of the product. A review of "the blue one, size M" that did not appear on "size L" would split one product's reputation across its size chart. The variant-to-product resolution is also the store fence: a variant this storefront does not list answers the neutral 404.
Hidden reviews are absent from all three outputs — the list, the total and the histogram — because one status = 'published' predicate governs all three. The same predicate feeds rating_avg and rating_count on the catalogue row. A hidden review that still counted in the average would let a seller remove criticism from view while the score kept the damage.
Writing
GET /v1/marketplace/buyer/reviews/eligible is the same eligibility rule the write path enforces, surfaced as a list, so a shopper is never offered a form that will then 403:
{
"items": [
{
"product_id": "01916b2a-f3c0-7000-0000-0000000000a0",
"product_name": "Beras Premium Setra Ramos",
"order_number": "MKT-01916B2AF3C0"
}
]
}{
"rating": 5,
"title": "Berasnya pulen",
"body": "Sampai sehari setelah pesan, kemasan rapi.",
"order_number": "MKT-01916B2AF3C0"
}PUT, and therefore an upsert, keyed by (buyer, product). One shopper, one review per product: aPOSTwould let a determined buyer post the same one-star ten times and move an average they are entitled to move once. Editing is the same call with the same key.- Eligibility is a paid order on this store containing the product, and it is proven in the same transaction as the write — a refund landing between a separate check and the insert would leave a review nothing would ever revisit. Not eligible is
403 review_not_eligible, a403rather than a404because the product plainly exists and the shopper is looking at it; what is refused is the action. - The status is not part of the update. A buyer editing a review the seller hid does not un-hide it, or moderation would last exactly until the next edit.
Seller moderation
listMarketplaceSellerReviews returns { reviews, total, page, limit, total_pages } and takes an optional status of published or hidden; absent means every status, and an unrecognised value is a 400, not a silent "all". It is gated on marketplace.storefront.write — a read gated on a write permission, deliberately: the queue's whole purpose is choosing what to hide, and a read-only role has no use for it.
The rows carry buyer_display, never the buyer's email. A seller who needs to contact a reviewer does so through the order, which carries the address because a parcel cannot be delivered without one. A moderation list that returned an email per review would be an export of the customer base dressed as a support tool.
The seller reply is once-editable, enforced by the statement's own predicate rather than by a read-then-check, so two console tabs cannot both pass a check and both write. A second edit is 409 reply_locked. The rule exists because a seller who can rewrite a public reply indefinitely can answer a one-star review politely, wait for the buyer to stop watching, and replace it. Empty or over 1000 characters is a 400.
{ "status": "hidden", "reason": "Berisi data pribadi pembeli lain." }Hiding needs a reason and is audit-logged in the same transaction
Hiding a review is a seller suppressing a customer's published words about a purchase they made. That is legitimate for abuse, spam and mistaken postings, and illegitimate as a way to curate a rating — and the only thing that distinguishes the two after the fact is a recorded reason and a recorded actor. A best-effort audit write after the commit would drop exactly the records someone was motivated to lose, so a failed audit write fails the whole operation.
Re-publishing needs no reason. Restoring a review is the action that undoes the suppression, and demanding a justification for it would put friction on the safe direction.
A hide with no reason is 400. A hidden review leaves the public list, the total and the average.
Fulfilment and buyer email
updateMarketplaceOrderFulfilment is gated on marketplace.order.write, not marketplace.storefront.write. The two are different jobs held by different people: storefront write is the merchant admin who binds a hostname and publishes the shop; order write is whoever packs parcels. Gating fulfilment on the storefront permission would force every warehouse account to also hold the ability to unpublish the storefront.
{
"state": "shipped",
"tracking_number": "JNE0012345678",
"carrier": "JNE"
}{
"state": "shipped",
"tracking_number": "JNE0012345678",
"carrier": "JNE",
"events": [
{ "state": "placed", "at": "2026-09-21T09:14:03+07:00" },
{ "state": "packed", "at": "2026-09-21T14:02:55+07:00" },
{ "state": "shipped", "at": "2026-09-22T08:31:40+07:00" }
]
}Legal moves are placed → packed → shipped → completed, plus any unshipped order → cancelled. packed additionally requires the order to be paid. Anything else is 409 invalid_transition, which also covers a repeat of the current state — all of them are the same answer to the seller: the order is not in a state from which that move is available. The check runs under a row lock, so two staff members packing the same order at once cannot both succeed.
The buyer's mail
Two templates, both enqueued through outbox.Enqueue inside the caller's transaction — a mail row and the state change that caused it commit together or not at all. Each has an Indonesian sibling resolved by locale.
| When | Template | Carries |
|---|---|---|
| The order is placed | marketplace_order_receipt | order number, lines, totals, and the signed guest tracking link |
| Each accepted fulfilment transition | marketplace_order_fulfilment | the new state, the tracking link, plus carrier and tracking number on shipped |
The receipt exists mainly to carry the tracking link. A guest holds no session and no account, so without it they had no route back to their own order once they closed the tab — and the fulfilment mails cannot fill the gap, because they only start at the seller's first transition.
Every amount in the receipt goes through the one shared Rupiah formatter, so Rp 1.250.000 in a buyer's inbox is the same string the storefront and the console render. The receipt carries no PII beyond what the recipient already knows: order number, lines and totals, but not the delivery address or the phone — a receipt is forwarded, printed and screenshotted.
One fulfilment template covers all four transitions, switching on the state. An unknown state renders a neutral "your order was updated" rather than an empty paragraph: a template that produces a blank body for a value it does not recognise is a silent failure the outbox cannot detect.
If the deployment has no MARKETPLACE_BUYER_SECRET, receipts still send but ship without a tracking link, and the tracking route refuses.
The storefront's legal documents
GET /v1/marketplace/legal/{buyer-terms|privacy-notice}?locale=id{
"title": "Syarat dan Ketentuan Pembeli",
"body_markdown": "## 1. Ruang lingkup\n\n…",
"version": "2026-09-01+r4",
"effective_at": "2026-09-01T00:00:00+07:00"
}Public, behind the storefront gate, no buyer session. A privacy notice you have to create an account to read is not a privacy notice.
The document is rendered by payments-service from the tenant's own legal parameters and fetched over the existing internal service-to-service channel — see Tenant legal terms for how an operator fills them in.
The document name is an allowlist, and that allowlist is the authorisation for this route. payments-service also renders the MSA, the DPA, the acceptable use policy and the seller terms, which are agreements between the platform and the merchant and are none of a shopper's business. An unknown name is refused before any upstream call is made, so this route cannot be used to probe payments-service.
Both upstream refusals collapse to 404 — a missing settings row and an incomplete one are the same fact from the storefront's position: this shop has no published terms. Responses are cached in-process for five minutes, keyed on (tenant, document, locale). locale accepts en and id; anything other than id resolves to English rather than being guessed at.
The shipped legal text is an engineering-prepared draft
The six bilingual templates in this repository were prepared by an engineering agent from public sources. They are not legal advice, have not been reviewed by a licensed Indonesian advocate, and must not be published to merchants or buyers as counsel-approved text.
An operator must have its own Indonesian counsel review the rendered documents — with that operator's parameters filled in — before the storefront is opened to real buyers.
Fees and metering
The marketplace platform fee is produced by an owner-meter hook that runs inside the paid-flip transaction in payments-service, not from an outbox drain. The meter row commits with the intent flip or not at all, which is what makes "a captured sale that was never metered" unrepresentable.
The party pair is what makes the fee land in the right books:
owner = the Straventa PLATFORM tenant (PLATFORM_TENANT_ID)
merchant = the SELLING tenantThat pair selects the cross-tenant accrual slice, so owner_fee_receivable lands in Straventa's books. Owner == seller would select the intra-tenant branch and book Straventa's revenue in the seller's own ledger, billing Straventa nothing — so the meter constructor panics on a nil PLATFORM_TENANT_ID rather than falling back. There is no safe default, so there is no default.
Recognition is from the in-scope intent, not from the attempt: the hook reads payment_intents.source and metadata->>'marketplace_order_id'. A source != 'marketplace' intent is a clean no-op. An intent with source='marketplace' and no marketplace_order_id is a hard error, not a skip — a skip there is a captured sale that is never billed.
The seller's order row reports the outcome as platform_fee_status (unmetered, unpriced, accrued, settled, reversed) and platform_fee_cents, which is null until the fee is priced.
PLATFORM_TENANT_ID is only syntax-checked on marketplace-service
marketplace-service requires PLATFORM_TENANT_ID at boot and refuses a non-uuid value, but it does not validate over the S2S seam that the tenant exists, is the platform tenant, and has commercial_class='owner'. payments-service and the scheduler do. A syntactically valid uuid naming a nonexistent tenant boots marketplace-service cleanly. Tracked as DF-MARKETPLACE-SERVICE-PLATFORM-TENANT-DEAD-CONFIG.
Boot configuration
marketplace-service refuses to start with all faults reported at once, rather than one per restart. Required:
| Variable | Refusal semantics |
|---|---|
AUTH_ISSUER, AUTH_AUDIENCE, AUTH_ENV | required, non-empty |
PAYMENTS_BASE_URL | required, non-empty |
HCAPTCHA_SECRET | required, non-empty under the hCaptcha provider (the published test secret is accepted — see above) |
ALTCHA_HMAC_KEY | required under CAPTCHA_PROVIDER=altcha; at least 32 characters |
PLATFORM_TENANT_ID | required and must parse as a uuid |
MARKETPLACE_BFF_SUBJECTS | unset refuses boot; set-but-empty boots and then refuses every caller |
MARKETPLACE_RESERVED_HOSTNAMES | required and non-empty — an empty reserved list is not "nothing is reserved" |
MARKETPLACE_STOREFRONT_APEXES | required and non-empty — an empty apex allowlist binds nothing, never everything |
Read from the environment but not boot-required — the buyer surface fails closed instead, so an estate that has not turned on buyer accounts still boots:
| Variable | If unset |
|---|---|
MARKETPLACE_BUYER_SECRET | buyer sign-in and order tracking answer 503 service_misconfigured; receipts ship with no tracking link |
SMTP_FROM | the estate-wide operator sender is unset and buyer mail has no From |
Operator notes
MARKETPLACE_BUYER_SECRET
A server-runtime-only HMAC key introduced with the buyer plane. It keys two things: the stored digest of the emailed sign-in code, and the guest order-tracking token minted into every receipt.
- Self-minted —
openssl rand -base64 32. It is not issued by any vendor. - At least 32 characters is the enforced floor (
buyertoken.MinSecretLen);openssl rand -base64 32yields 44, so the minted value clears it with room to spare. The floor exists because the key protects an HMAC over a 10⁶ search space, and a short key is the one input that makes that output guessable offline from a single leaked row. - It must be stable across pods and restarts. A per-pod value means a code minted on one pod cannot be verified on another.
- Never rotate it casually. Rotating invalidates every tracking link already sitting in a buyer's inbox and burns every login code in flight. Rotate deliberately, never as part of a redeploy.
- An empty value is a refusal, never a degraded mode. HMAC with a zero-length key is a perfectly well-defined function that produces perfectly stable output, so a nil-secret fallback would not crash and would not log — it would simply leave every login code recoverable by anyone holding a copy of the table.
Session tokens are stored as a bare sha256, deliberately not keyed by this secret: a session token has 2²⁵⁶ possible values, so preimage search is not a threat model, and keying it would tie every live shopper session to the secret's lifetime — rotating would then sign out every buyer on every storefront at once.
Provisioned in .env.example, deploy/docker-compose.yml, deploy/docker-compose.test.yml, the Helm chart's secret template, and the whitelabel bundle installer.
Migrations
| Migration | Adds |
|---|---|
0177_pos_stores_storefront_hours | the store's opening schedule and timezone columns |
0178_marketplace_features_and_product_images | the per-store feature switch set and the product gallery |
0179_marketplace_shipping | shipping methods, zone rates and the order shipping snapshot |
0180_marketplace_promos | promo codes and redemptions |
0181_marketplace_buyer_plane | buyers, buyer sessions, login codes, saved addresses |
0182_marketplace_wishlist | saved items |
0183_marketplace_reviews | reviews, seller replies and the moderation audit |
0184_tenant_legal_settings | the tenant's configurable legal parameter row |
Apply them in order with golang-migrate as usual. 0184 is read by payments-service, the other seven by marketplace-service; a deployment that rolls the services independently should run all eight before either image is promoted.
For the full provisioning, rotation and verification procedure on a self-hosted install, see the operator runbook docs/runbooks/marketplace-buyer-plane.md.
Endpoint reference
Generated from the service's embedded OpenAPI spec
(services/marketplace-service/internal/http/openapi/openapi.yaml). Groups follow the
trust zones described above — seller/console writes, buyer/BFF reads, and the
session-bearing shopper routes.
Storefront administration (seller)
Console-facing writes. The tenant comes from the verified claim, not from the request Host, and every route gates on a marketplace.storefront.* permission.
Product gallery (seller)
Up to eight images per product, position ordered. The PUT replaces the whole gallery and keeps the product's cover column in step with position 0, so the catalogue's image_url and images[0] can never disagree.
Shipping methods and rates (seller)
The store's full rate card, including deactivated methods. Every route requires store_id and none of them is gated on the shipping feature switch — gating the editor on the switch would make "shipping: off" a state a seller could enter and never leave.
Promo codes (seller)
The seller's own view of a campaign, with redemption counts and specific refusal codes. Requires store_id. The code itself is immutable once created.
Orders and fulfilment (seller)
The seller order queue and the fulfilment state machine. The queue gates on marketplace.order.read, the transition on marketplace.order.write — packing parcels and publishing a storefront are different jobs.
Review moderation (seller)
The moderation queue, the once-editable seller reply, and hide/re-publish. Hiding requires a reason and is audit-logged in the same transaction as the status change.
Storefront & catalogue (buyer, BFF-only)
Buyer-anonymous reads served behind the storefront gate. The tenant is derived from the request Host; the caller must be a pinned BFF subject.
Captcha
The self-hosted ALTCHA challenge mint. Present only when the deployment runs CAPTCHA_PROVIDER=altcha; under the hCaptcha provider it answers 501.
Cart & stock holds
The cart id is the only credential an anonymous buyer holds. Holds are real cross-channel inventory locks — POS available-quantity subtracts them. Every write is captcha-gated and velocity-limited.
Shipping & promos at checkout (buyer)
Display-only reads that write nothing. Both quotes are recomputed under lock at placement, so a stale one is corrected rather than honoured. Both return 404 feature_disabled when the shop has the feature switched off.
Orders (buyer)
Placement carries no money in the body; every cent is resolved server-side. The guest tracking read is the only route here that returns one person's order without a buyer session.
Buyer sign-in
Passwordless email codes. The request always answers 202 and the verify has exactly one refusal, so neither route can be used to learn who shops here.
Buyer account and addresses
Authenticated by X-Buyer-Session. The email is absent from the update body and cannot be changed; deletion anonymises the person and keeps the seller's order records.
Buyer order history
The shopper's own orders. The buyer id is a predicate in the statement, so another shopper's order number matches nothing and answers the same 404 as a number that was never issued.
Wishlist
Saved items, rendered through the catalogue's own projection so a delisted variant disappears without a cleanup job. The merge route folds a signed-out browser's list into the account at sign-in.
Reviews (buyer)
One review per buyer per product, keyed by (buyer, product) and therefore a PUT. Eligibility is a paid order containing the product, proven in the same transaction as the write.
Legal documents (buyer)
The storefront's buyer terms and privacy notice, rendered by payments-service from the tenant's own legal parameters. Public behind the storefront gate — a privacy notice you need an account to read is not a privacy notice.
Ungrouped operations
85 operations in the spec are not listed in the groups file. Add their operationIds to content/docs/api/marketplace.groups.json.
Children of one Indonesian administrative area
Indonesian administrative-area typeahead
Postal codes of one kelurahan
The signed-in shopper's consent trail
The shopper's invoice for one of their orders
The issues raised on one order
Read back a cart from a recovery link
Download the catalogue or the order history as CSV
The store's bulk-import history
One import job and its dry-run report
The tenant's commission rate table
The seller's own-delivery dispatch board
The store's Google-compatible product feed
Read a store's product-feed settings
The configured provider credentials, secrets masked
The store's cancellation requests and complaints (seller)
The seller's WhatsApp message log
The seller's copy of an order invoice
The printable packing slip
Read a product's option axes
Commission charged per seller, per period (tenant)
The commission report as CSV (tenant)
The seller's own commission, per period (merchant)
Variants at or below their reorder point
Which campaign earned what
The store's sales summary, day series and best sellers
Searches that returned nothing
The store's search synonym rules
The tenant's selling mode
The seller queue
One seller (admin), or a seller's public shop header (storefront)
The tenant's (or store's) resolved email sender identity
Resolved marketplace settings plus the registry that describes them
The aggregator account balance, when the provider reports one
The carriers and services a store may configure
Read a store's own-delivery rules for one shipping method
What the store has been charged for shipping
The store's consignments
A short-lived link to the printable label
Where this store's parcels leave from
Meta's subscription handshake
Upload a photo to attach to a complaint
Open a cancellation request or a complaint on an order
The shopper confirms a shipped order received
Save a shopper's email against a cart
Upload a catalogue CSV for a dry run
Confirm a validated import and write the catalogue
Name the rider for a delivery
Record a completed hand-over
Mark a delivery as out for delivery
Record a failed delivery attempt
Move a delivery to another window and start a new attempt
Upload a proof-of-delivery photo
Mint a new feed token, revoking the old URL
Probe the stored credential against the provider
Close a cancellation request or complaint (seller)
Record an order the seller took themselves (draft)
Move many orders to one fulfilment state
Upload one product image
Approve a seller and bind its store
Off-board a seller, delisting but never deleting
Return a suspended seller to active
Suspend a seller and delist its catalogue
A merchant applies to sell
Re-probe the sender domain's DKIM and SPF records now
Return the named keys to inheritance
Book a consignment with the store's courier
Cancel a consignment before it is collected
The courier tracking callback
Meta delivery-status callbacks
Record a shopper's UU PDP consents
Set the tenant's commission rate table
Turn a store's product feed on or off
Replace one provider credential
Replace a product's option axes
Map a product's variants onto its option axes
Replace the store's search synonym rules
Switch the tenant between tenant_only and merchants
Set the sender identity for one rung
Write settings at the addressed scope
Replace a store's own-delivery rules for one shipping method
Set where this store's parcels leave from
Point a variant at one of its product's images
Remove one rung's credential
Remove one image from a product's gallery
Was this page helpful?