Skip to content
StraventaDocs
Now in Sandbox.
API Reference

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

EnvironmentHostname
Sandboxhttps://sandbox.505pay.link
Productionhttps://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

ZoneRoutesTenant comes fromCaller proves it with
Seller / console…/storefront/*, …/products/*/images, …/shipping/admin/*, …/promos/admin*, …/orders/admin*, …/reviews/admin*the verified claima marketplace.* permission
Buyer / BFF…/storefront, …/catalog*, …/captcha/challenge, …/cart*, POST …/orders, …/orders/track/*, …/shipping/methods, …/legal/*the request HostBFF subject pin, plus captcha on every write
Shopper…/buyer/*the request Hostthe 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.

PUT /v1/marketplace/storefront/settings
{
  "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": []
  }
}
200 — the store whose settings were written
{ "store_id": "01916b2a-f3c0-7000-0000-000000000s01" }

Three field-level rules do most of the work:

FieldOmittednullPresent
openleaves the switch alone—sets the switch
hoursleaves the schedule aloneclears it (back to "no schedule")replaces it wholesale
featureschanges no switchclears 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 … sun are required when hours is 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". close must be strictly after open.
  • Intervals are half-open: open at exactly open, shut at exactly close. That is what lets 09:00–12:00 and 12:00–17:00 mean one continuous opening rather than an overlap or a one-minute hole.
  • No interval may span midnight. A shop open past midnight writes …24:00 today plus 00:00… the next day. 24:00 is 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:

GET /v1/marketplace/storefront
{
  "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_reason is "" while open, manual when the seller's switch is off, and hours when the switch is on but now is outside the schedule. It is a distinct field rather than an inference from next_open_at, because a schedule that never reopens is hours with no reopening time.
  • next_open_at is RFC 3339 in the store's offset, and is only ever set when closed_reason is hours. Under manual only the seller knows when they will flip the switch back. It is also "" when the schedule has no opening in the next eight days.
  • timezone is the IANA zone the server actually used. A store whose stored timezone is blank or unparsable is evaluated in Asia/Jakarta and reports that, so a client formats next_open_at against the same clock the server read.
  • hours is null when 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".

KeyCovers
shippingdelivery methods, quotes and the checkout address step
promospromo codes on the cart and at placement
buyer_accountsbuyer sign-in and the account area
wishlistsaved items (needs buyer_accounts to be usable)
reviewsproduct 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:

404 — GET /v1/marketplace/shipping/methods on a shop with shipping off
{
  "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:

PUT /v1/marketplace/storefront/settings — partial feature merge
{
  "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 pos listing has store_id IS NULL; every non-pos listing (marketplace, own_site) must carry a store_id. This is a table CHECK, 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

ParameterDefaultMeaning
page11-based page number.
limit20Clamped 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.
sortnamename (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

GET /v1/marketplace/catalog?q=beras&sort=price_asc&limit=2
{
  "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_id is 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, since PUT /v1/marketplace/buyer/reviews/{product_id} and GET /v1/marketplace/buyer/reviews/eligible both name a product.
  • product_name vs name. product_name is the product ("Beras Premium Setra Ramos"); name is the variant ("5 kg"). Variant names are sizes and options, so two different products routinely share one.
  • available_qty is 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.
  • images is cover-first, [] when empty, and image_url stays. image_url is the cover — the first image, or "". The contract states the relationship, so the two can never disagree. An empty image_url is what tells the card to render its initial-letter placeholder.
  • rating_avg is a number with one decimal and is 0 when rating_count is 0 — never null. 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 a count per category — not just the categories on this page.
  • description, image_url, category_id and category_name are "" when unset, never null.

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:

GET /v1/marketplace/catalog/01916b2a-f3c0-7000-0000-0000000000aa/related
{
  "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.

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.

PUT /v1/marketplace/products/01916b2a-f3c0-7000-0000-0000000000a0/images
{
  "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:

CodeMeaning
insufficient_stocknot enough units exist to hold
hold_ceiling_reachedthis variant is already held too heavily across all carts
cart_line_limit_reachedthis cart is at its per-variant unit cap or its distinct-variant cap
reservation_expiredthe cart's holds have lapsed

Those ceilings are deployment configuration, with these defaults:

VariableDefaultMeaning
MARKETPLACE_MAX_UNITS_PER_CART50units of one variant per cart (Ceiling A)
MARKETPLACE_HOLD_CEILING_ABS200absolute units of a variant held across carts
MARKETPLACE_HOLD_CEILING_PCT20…or this percentage of stock, whichever binds
MARKETPLACE_CART_LINE_CEILING50distinct variants per cart (Ceiling B)
MARKETPLACE_HOLD_TTL15mhow long a hold survives unconfirmed
MARKETPLACE_INTENT_TTL30mhow 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.

GET /v1/marketplace/captcha/challenge
{
  "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

GET /v1/marketplace/shipping/methods
{
  "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

POST /v1/marketplace/cart/01916b2a-f3c0-7000-0000-0000000000c1/shipping-quote
{
  "method_id": "01916b2a-f3c0-7000-0000-000000000m03",
  "province": "Jawa Barat"
}
200
{
  "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.

POST /v1/marketplace/shipping/admin/methods
{
  "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 }
  ]
}
GET /v1/marketplace/shipping/admin/methods?store_id=01916b2a-f3c0-7000-0000-000000000s01
{
  "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 zone method'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 pickup method 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 shipping feature 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 shipping block — 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

POST /v1/marketplace/cart/01916b2a-f3c0-7000-0000-0000000000c1/promo
{ "code": "HEMAT10" }
200
{
  "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.

POST /v1/marketplace/promos/admin
{
  "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"
}
201
{
  "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 percent code's value is 1..100. A fixed code's value is an amount in minor units. max_discount_cents caps a percentage code and is meaningless on a fixed one.
  • usage_limit is the campaign-wide redemption cap; per_buyer_limit is per shopper. Both null means 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 neutral 422 is 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_limit below the redemptions already taken does not revoke them; the code simply refuses from then on.
  • listMarketplacePromos is deliberately detailed. The neutral buyer 422 exists 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.

POST /v1/marketplace/orders
{
  "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"
}
201
{
  "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:

RepeatOutcome
Same key, same body, first attempt running409 idempotency_in_progress
Same key, different body409 idempotency_key_mismatch
Same key, same body, first attempt finishedthe 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 + shipping

Every 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 5000000 code on a 2000000 cart discounts 2000000. The alternative is a negative total, which is the platform paying the buyer to shop. max_discount_cents caps 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

POST /v1/marketplace/buyer/login/request
{ "email": "[email protected]", "captcha": "<solved token>" }
202 — always, and always this body
{}

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:

  1. 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.
  2. There is no "does this buyer exist" read anywhere on the path, so no branch differs in cost.
  3. 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

POST /v1/marketplace/buyer/login/verify
{ "email": "[email protected]", "code": "204817" }
200
{
  "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:

PropertyValue
Code length6 digits, from crypto/rand
Code lifetime10 minutes
Wrong guesses5, then the code is burned — enforced inside the same transaction that reads it, so five concurrent guesses cannot all see "attempt 1"
Comparisonconstant-time
Storageonly the keyed HMAC of the code; the plaintext is never stored
Refusalexactly one — 401 invalid_code — for wrong digits, an expired code, a burned code, and an address that never requested one
Session lifetime30 days, absolute, never extended
Session token storageonly 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

GET /v1/marketplace/buyer/me
{
  "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.

GET /v1/marketplace/buyer/orders/MKT-01916B2AF3C0
{
  "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.

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.

SurvivesGoes
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.

  • addMarketplaceBuyerWishlistItem is PUT and idempotent — a shopper double-tapping a heart icon is the normal case, not an error. 204.
  • removeMarketplaceBuyerWishlistItem is 204 even when nothing was saved. "Remove" states a desired end state, and that state holds either way.
  • mergeMarketplaceBuyerWishlist is the sign-in handoff:
POST /v1/marketplace/buyer/wishlist/merge
{
  "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.

GET /v1/marketplace/catalog/01916b2a-f3c0-7000-0000-0000000000aa/reviews?page=1&limit=2
{
  "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"
    }
  ]
}
PUT /v1/marketplace/buyer/reviews/01916b2a-f3c0-7000-0000-0000000000a0
{
  "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: a POST would 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, a 403 rather than a 404 because 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.

PUT /v1/marketplace/reviews/admin/01916b2a-f3c0-7000-0000-000000000r01/visibility
{ "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.

PUT /v1/marketplace/orders/admin/01916b2a-f3c0-7000-0000-0000000000o1/fulfilment
{
  "state": "shipped",
  "tracking_number": "JNE0012345678",
  "carrier": "JNE"
}
200
{
  "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.

WhenTemplateCarries
The order is placedmarketplace_order_receiptorder number, lines, totals, and the signed guest tracking link
Each accepted fulfilment transitionmarketplace_order_fulfilmentthe 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.


GET /v1/marketplace/legal/{buyer-terms|privacy-notice}?locale=id
200
{
  "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 tenant

That 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:

VariableRefusal semantics
AUTH_ISSUER, AUTH_AUDIENCE, AUTH_ENVrequired, non-empty
PAYMENTS_BASE_URLrequired, non-empty
HCAPTCHA_SECRETrequired, non-empty under the hCaptcha provider (the published test secret is accepted — see above)
ALTCHA_HMAC_KEYrequired under CAPTCHA_PROVIDER=altcha; at least 32 characters
PLATFORM_TENANT_IDrequired and must parse as a uuid
MARKETPLACE_BFF_SUBJECTSunset refuses boot; set-but-empty boots and then refuses every caller
MARKETPLACE_RESERVED_HOSTNAMESrequired and non-empty — an empty reserved list is not "nothing is reserved"
MARKETPLACE_STOREFRONT_APEXESrequired 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:

VariableIf unset
MARKETPLACE_BUYER_SECRETbuyer sign-in and order tracking answer 503 service_misconfigured; receipts ship with no tracking link
SMTP_FROMthe 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 32 yields 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

MigrationAdds
0177_pos_stores_storefront_hoursthe store's opening schedule and timezone columns
0178_marketplace_features_and_product_imagesthe per-store feature switch set and the product gallery
0179_marketplace_shippingshipping methods, zone rates and the order shipping snapshot
0180_marketplace_promospromo codes and redemptions
0181_marketplace_buyer_planebuyers, buyer sessions, login codes, saved addresses
0182_marketplace_wishlistsaved items
0183_marketplace_reviewsreviews, seller replies and the moderation audit
0184_tenant_legal_settingsthe 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.

GET/v1/marketplace/address/areas

Children of one Indonesian administrative area

GET/v1/marketplace/address/lookup

Indonesian administrative-area typeahead

GET/v1/marketplace/address/postal

Postal codes of one kelurahan

GET/v1/marketplace/buyer/consents/history

The signed-in shopper's consent trail

GET/v1/marketplace/buyer/orders/{order_number}/invoice

The shopper's invoice for one of their orders

GET/v1/marketplace/buyer/orders/{order_number}/issues

The issues raised on one order

GET/v1/marketplace/cart/restore/{cart_id}

Read back a cart from a recovery link

GET/v1/marketplace/catalog/export

Download the catalogue or the order history as CSV

GET/v1/marketplace/catalog/imports

The store's bulk-import history

GET/v1/marketplace/catalog/imports/{id}

One import job and its dry-run report

GET/v1/marketplace/commission

The tenant's commission rate table

GET/v1/marketplace/deliveries

The seller's own-delivery dispatch board

GET/v1/marketplace/feed/{store_id}/{feed_token}.xml

The store's Google-compatible product feed

GET/v1/marketplace/feeds/admin

Read a store's product-feed settings

GET/v1/marketplace/integrations

The configured provider credentials, secrets masked

GET/v1/marketplace/issues/admin

The store's cancellation requests and complaints (seller)

GET/v1/marketplace/notifications/wa

The seller's WhatsApp message log

GET/v1/marketplace/orders/admin/{id}/invoice

The seller's copy of an order invoice

GET/v1/marketplace/orders/admin/{id}/slip

The printable packing slip

GET/v1/marketplace/products/{product_id}/options

Read a product's option axes

GET/v1/marketplace/reports/commission

Commission charged per seller, per period (tenant)

GET/v1/marketplace/reports/commission.csv

The commission report as CSV (tenant)

GET/v1/marketplace/reports/commission/mine

The seller's own commission, per period (merchant)

GET/v1/marketplace/reports/low-stock

Variants at or below their reorder point

GET/v1/marketplace/reports/sources

Which campaign earned what

GET/v1/marketplace/reports/summary

The store's sales summary, day series and best sellers

GET/v1/marketplace/search/admin/misses

Searches that returned nothing

GET/v1/marketplace/search/admin/synonyms

The store's search synonym rules

GET/v1/marketplace/seller-mode

The tenant's selling mode

GET/v1/marketplace/sellers

The seller queue

GET/v1/marketplace/sellers/{id}

One seller (admin), or a seller's public shop header (storefront)

GET/v1/marketplace/sender-identity

The tenant's (or store's) resolved email sender identity

GET/v1/marketplace/settings

Resolved marketplace settings plus the registry that describes them

GET/v1/marketplace/shipping/admin/balance

The aggregator account balance, when the provider reports one

GET/v1/marketplace/shipping/admin/couriers

The carriers and services a store may configure

GET/v1/marketplace/shipping/admin/delivery

Read a store's own-delivery rules for one shipping method

GET/v1/marketplace/shipping/admin/ledger

What the store has been charged for shipping

GET/v1/marketplace/shipping/admin/shipments

The store's consignments

GET/v1/marketplace/shipping/admin/shipments/{id}/label

A short-lived link to the printable label

GET/v1/marketplace/storefront/shipping-origin

Where this store's parcels leave from

GET/v1/marketplace/webhooks/whatsapp

Meta's subscription handshake

POST/v1/marketplace/buyer/issues/photos

Upload a photo to attach to a complaint

POST/v1/marketplace/buyer/orders/{order_number}/issues

Open a cancellation request or a complaint on an order

POST/v1/marketplace/buyer/orders/{order_number}/received

The shopper confirms a shipped order received

POST/v1/marketplace/cart/{cart_id}/contact

Save a shopper's email against a cart

POST/v1/marketplace/catalog/imports

Upload a catalogue CSV for a dry run

POST/v1/marketplace/catalog/imports/{id}/apply

Confirm a validated import and write the catalogue

POST/v1/marketplace/deliveries/{id}/assign

Name the rider for a delivery

POST/v1/marketplace/deliveries/{id}/complete

Record a completed hand-over

POST/v1/marketplace/deliveries/{id}/dispatch

Mark a delivery as out for delivery

POST/v1/marketplace/deliveries/{id}/fail

Record a failed delivery attempt

POST/v1/marketplace/deliveries/{id}/reschedule

Move a delivery to another window and start a new attempt

POST/v1/marketplace/deliveries/photos

Upload a proof-of-delivery photo

POST/v1/marketplace/feeds/admin/rotate

Mint a new feed token, revoking the old URL

POST/v1/marketplace/integrations/test

Probe the stored credential against the provider

POST/v1/marketplace/issues/admin/{id}/resolve

Close a cancellation request or complaint (seller)

POST/v1/marketplace/orders/admin/draft

Record an order the seller took themselves (draft)

POST/v1/marketplace/orders/admin/fulfilment/batch

Move many orders to one fulfilment state

POST/v1/marketplace/products/{product_id}/images/upload

Upload one product image

POST/v1/marketplace/sellers/{id}/approve

Approve a seller and bind its store

POST/v1/marketplace/sellers/{id}/offboard

Off-board a seller, delisting but never deleting

POST/v1/marketplace/sellers/{id}/reinstate

Return a suspended seller to active

POST/v1/marketplace/sellers/{id}/suspend

Suspend a seller and delist its catalogue

POST/v1/marketplace/sellers/apply

A merchant applies to sell

POST/v1/marketplace/sender-identity/verify

Re-probe the sender domain's DKIM and SPF records now

POST/v1/marketplace/settings/reset

Return the named keys to inheritance

POST/v1/marketplace/shipping/admin/shipments

Book a consignment with the store's courier

POST/v1/marketplace/shipping/admin/shipments/{id}/cancel

Cancel a consignment before it is collected

POST/v1/marketplace/webhooks/shipping/{store_id}/{token}

The courier tracking callback

POST/v1/marketplace/webhooks/whatsapp

Meta delivery-status callbacks

PUT/v1/marketplace/buyer/consents

Record a shopper's UU PDP consents

PUT/v1/marketplace/commission

Set the tenant's commission rate table

PUT/v1/marketplace/feeds/admin

Turn a store's product feed on or off

PUT/v1/marketplace/integrations

Replace one provider credential

PUT/v1/marketplace/products/{product_id}/options

Replace a product's option axes

PUT/v1/marketplace/products/{product_id}/variant-options

Map a product's variants onto its option axes

PUT/v1/marketplace/search/admin/synonyms

Replace the store's search synonym rules

PUT/v1/marketplace/seller-mode

Switch the tenant between tenant_only and merchants

PUT/v1/marketplace/sender-identity

Set the sender identity for one rung

PUT/v1/marketplace/settings

Write settings at the addressed scope

PUT/v1/marketplace/shipping/admin/delivery

Replace a store's own-delivery rules for one shipping method

PUT/v1/marketplace/storefront/shipping-origin

Set where this store's parcels leave from

PUT/v1/marketplace/variants/{variant_id}/image

Point a variant at one of its product's images

DELETE/v1/marketplace/integrations

Remove one rung's credential

DELETE/v1/marketplace/products/{product_id}/images/{id}

Remove one image from a product's gallery

Was this page helpful?

On this page