iSeller — Adapter Guide¶
Status: SKELETON. This pack was scaffolded with the new-POS onboarding framework. The iSeller Public API v2 docs are received and authentication is documented below; per-topic endpoints + sample payloads are still being gathered. Sections marked
[NEEDS API DOCS]/[TBC]are intake questions to answer; do not generate a topic's script until that topic has an endpoint and a real sample payload (see the onboarding playbook). So fartest_connectionandlist_remote_outletsare generated (scripts/adapters/iseller/); the remaining topics are still skeletons.iSeller is an Indonesian cloud POS / commerce platform. Fill this in as the integrator supplies answers (paste a docs URL and samples into the onboarding conversation).
Provider Info¶
| Field | Value |
|---|---|
| Slug | iseller |
| Auth Type | oauth (OAuth 2.0 — Authorization Code or Client Credentials) |
| Base URL | Per-store resource_url — https://{storename}.isellershop.com; OAuth/token endpoints are global on https://isellershop.com |
| API Docs | iSeller Public API v2 + ERP API v3/v4 + Webhook Notification (partner PDFs) |
| Sample fixtures | samples/iseller/ — derived from the vendor docs |
| Sandbox access | [TBC] |
| Topics in scope | test_connection, list_remote_outlets, sync_products, order_dispatch, order_status_update (optional: sync_outlet, snooze_items) |
Authentication¶
iSeller uses OAuth 2.0 (auth_type = oauth). Two grant types are available;
the token endpoint is global, the API base URL is per-store.
Authorization Code (per-merchant, self-service)¶
- Send the merchant to
https://isellershop.com/oauth/authorize?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&state={STATE}→ they log in to their store and grant access. - iSeller redirects to
{REDIRECT_URI}?code=…&state=…. - Exchange the code:
POST https://isellershop.com/oauth/tokenwithgrant_type=authorization_code,code,redirect_uri,client_id,client_secret→
{
"access_token": "…",
"token_type": "bearer",
"expires_in": 1209599,
"refresh_token": "…",
"resource_url": "https://{storename}.isellershop.com"
}
- Call the API at
{resource_url}/api/v2/…with headerAuthorization: Bearer {access_token}. - Refresh:
POST https://isellershop.com/oauth/tokenwithgrant_type=refresh_token,refresh_token,client_id,client_secret.
expires_in ≈ 1,209,599s (~14 days). iSeller also ships a JS SDK
(iSellerOAuth.Open(...)) for embedding the grant dialog — relevant to the
self-service Connect UI (INTR-766).
Client Credentials (app-level — used for Phase 1)¶
POST https://isellershop.com/oauth/token with grant_type=client_credentials,
client_id, client_secret → token response. Verified (Flash Coffee sandbox):
the response includes access_token, token_type:"bearer", expires_in
(≈14 days), refresh_token, and resource_url (the store base URL, e.g.
https://flashcoffee.isellershop.com) — so baseUrl comes straight from the token.
How it maps to a provider connection¶
| Connection field | iSeller value |
|---|---|
apiKey |
client_id |
apiSecret |
client_secret |
baseUrl |
store URL https://{storename}.isellershop.com (the resource_url) |
config (optional) |
{ "tokenUrl": "https://isellershop.com/oauth/token" } |
⚠️ Token host ≠ API host. Mint the token at the global
https://isellershop.com/oauth/token; call data at the per-storehttps://{storename}.isellershop.com. Do not usebaseUrlfor the token request.
What the adapter scripts do (Client Credentials path)¶
Scripts mint a fresh bearer token at the start of each run — no stored token, no expiry handling needed:
// every iSeller script begins with a token mint.
// IMPORTANT: the token endpoint needs application/x-www-form-urlencoded — pass a
// STRING body (the runtime sends strings verbatim; an object would be JSON-encoded
// and the endpoint returns HTTP 400).
var form = "grant_type=client_credentials"
+ "&client_id=" + encodeURIComponent(input.apiKey)
+ "&client_secret=" + encodeURIComponent(input.apiSecret);
var resp = context.http.post(
"https://isellershop.com/oauth/token",
form,
{ "Content-Type": "application/x-www-form-urlencoded" }
);
var token = resp.body.access_token;
// then call the per-store API with the bearer token (Get* calls are POST + JSON body):
var data = context.http.post(input.baseUrl + "/api/v2/GetStoreInfo", {}, { "Authorization": "Bearer " + token });
Secrets stay in the Go-resolved connection fields; the script receives only
input.apiKey / input.apiSecret / input.baseUrl.
🛑 Script input contract — applies to EVERY iSeller adapter script (
test_connection,list_remote_outlets,sync_products,order_dispatch,order_status_update). The transform receivesinput.apiKey,input.apiSecret,input.baseUrl— nothing else for auth. Each script mints its own bearer token per run fromapiKey/apiSecret(the snippet above) and preferstokenResp.body.resource_urloverinput.baseUrlfor the API host.Do NOT read
input.config.accessToken/input.config.refreshToken. There is no stored token on the Phase-1 (Client Credentials) path — requiringinput.config.*makes the script fail at test time withinput.config.accessToken is required. The stored-token model belongs to the future Authorization Code flow (INTR-766) and is out of scope for these scripts. When in doubt, mirror the already-activatedlist_remote_outlets/test_connectionscripts inscripts/adapters/iseller/.For the self-service Authorization Code flow (browser grant + tokens stored in
config.accessToken/refreshTokenwith server-side auto-refresh), see INTR-766 (OAuth Connect UI + token refresh). Not required for Phase 1.
Logging convention (all topics)¶
Every script should call context.log(...) at each meaningful step so the
job-log dialog shows live progress (especially for long syncs). Prefix every
message with iseller.<topic>: to match the activated scripts. Log at
least: token mint, each paged fetch + running count, any skipped rows (with the
reason), and a final summary count. Keep messages free of secrets (never log the
token, apiSecret, or full auth headers).
context.log("iseller.sync_products: minting bearer token");
context.log("iseller.sync_products: fetching page " + page);
context.log("iseller.sync_products: page " + page + " returned " + batch.length + " (total " + products.length + ")");
context.log("iseller.sync_products: skipping product with no item_code (product_id=" + p.product_id + ")");
context.log("iseller.sync_products: mapped " + out.length + " products (" + nStandard + " standard, " + nVariant + " variant, " + nBundle + " comboset)");
Connection & outlet settings¶
What to store where when connecting iSeller.
provider_connections (tenant-level) — one row for the iseller provider:
| Field | Value |
|---|---|
apiKey |
iSeller client_id |
apiSecret |
iSeller client_secret |
baseUrl |
store URL https://{storename}.isellershop.com (the resource_url) |
config |
Phase 1: leave empty. (Future: server-managed OAuth token state — see ADR 0006.) |
✅ Phase 1 (Client Credentials) — the ONLY model these adapter scripts use. A connection has just
apiKey/apiSecret/baseUrl. Scripts mint a token per run from those; there is no stored token, so scripts never readconfig/config.accessToken/config.refreshToken. Adding a precondition likeif (!input.config.accessToken) throw …is a bug — it fails at test time becauseconfigis empty.
Future only (INTR-766) — Authorization Code stored-token config shape. Adapter scripts MUST NOT read this.
When the self-service Authorization Code flow lands, a token manager writes/
refreshes this `config` server-side. It is **not** consumed by the adapter
scripts (they still receive a usable token path via the Phase-1 contract):
{
"accessToken": "… DO NOT read from a script …",
"refreshToken": "…",
"expiresAt": "2026-06-23T00:00:00Z",
"resourceUrl": "https://{storename}.isellershop.com",
"tokenUrl": "https://isellershop.com/oauth/token"
}
outlet_providers.settings (per-outlet) — one row per mapped Cata outlet:
{ "outletCode": "MO", "outletId": "134b57bb-2d34-fe5c-39dd-58b6fb035341" }
| Field | Value | Used by |
|---|---|---|
outletCode |
iSeller outlet_code (e.g. "MO") |
order_dispatch → CreateOrder.outlet_code |
outletId |
iSeller outlet_id GUID |
resolving GetProducts.outlet_prices[].outlet_id / GetStoreInfo.active_outlets[] to this outlet (per-outlet pricing) |
iSeller exposes two outlet identifiers and different endpoints key on different ones:
outlet_code(CreateOrder.outlet_code) vsoutlet_idGUID (GetProducts.outlet_prices,GetStoreInfo). The outletexternal_idstays theoutlet_code(the dispatch key — seelist_remote_outlets); the GUID lives insettings.outletId. Do not pack both intoexternal_id—order_dispatchreadsexternal_iddirectly and GUIDs contain hyphens, so a composite would be ambiguous and break dispatch.Resolving the GUID (lazy, no FE/
list_remote_outletschange needed):GetOutletsreturns bothoutlet_codeand theoutlet_idGUID, so a script that needs the map callsGetOutletsonce per run, buildsoutlet_id → outlet_code, and re-keysoutlet_pricesbyoutlet_code(=external_id). Per-run resolution is always fresh (the JS VM is recreated each run, so cross-run caching would have to be Go-side or persisted — persistsettings.outletIdat mapping time only if per-run lookups ever become a cost).census/iseller.jsdoes this for sync-doctor pricing.
API Endpoints¶
iSeller Public API v2 is a pull / reporting API. Calls are POST with a
JSON request body (despite the Get* names — the docs define each call's filters
as a "request body"), under {resource_url}/api/v2 (the per-store base URL). Send
Authorization: Bearer {access_token} on every call.
| Endpoint | Use for | Notes |
|---|---|---|
POST /api/v2/GetStoreInfo |
test_connection · sync_outlet | store info + active_outlets[] + payment types (no params) |
POST /api/v3/GetOutlets |
list_remote_outlets | ERP API — outlets incl. outlet_code; page/page_size (max 200) + has_next_item |
POST /api/v2/GetProducts |
sync_products | products incl. variants, combos, modifier groups, per-outlet prices, inventory |
POST /api/v2/GetOrders |
order status (pull only) | orders + fulfillment status; no push/webhook |
POST /api/v2/GetOrderSummary · GetTransactions · GetTransactionSummary · GetRegisterShifts |
reporting (not used by the 5 core topics) | — |
GET /api/v1/version |
test_connection (lightweight alt) | — |
POST /api/v4/CreateOrder |
order_dispatch | ERP API — create order (Online Ordering); returns order_id |
POST /api/v3/CancelOrder · RefundOrder |
(cancel / refund) | ERP API (future) |
Two API families on the same per-store base URL: Public API v2 (
/api/v2/, read-onlyGet*— outlets, products, orders) and the ERP API v3/v4 (/api/v3/,/api/v4/— writes:CreateOrder,CancelOrder,RefundOrder,CreateProducts, …).order_dispatchuses ERPPOST /api/v4/CreateOrder(Online Ordering); status returns via the inbound webhook (order_status_update).
Pagination¶
Page-based: page + page_size in the JSON request body (on GetProducts,
GetOrders, etc.) — not cursor / meta.next. context.http.getAll won't
auto-detect this; the script loops page until has_next_item is false (ERP list calls) or a short/empty page. Incremental
sync via modified_after (ISO-8601, no timezone).
Rate Limits¶
Not documented in the PDF — [verify with vendor]. Respect 429 + backoff defensively.
Quirks & Constraints¶
| Quirk | Detail | Confidence |
|---|---|---|
| Two API families | Public API v2 (/api/v2/, read-only) for catalog/outlets; ERP API v3/v4 (/api/v3/, /api/v4/) for writes incl. CreateOrder. Plus inbound webhooks for status. Same per-store base URL. |
VERIFIED (docs) |
| Response key casing varies per endpoint | Verify each endpoint by curl — casing is inconsistent: GetOutlets → Outlets (PascalCase) with id/name; GetStoreInfo → store_info (snake_case) with active_outlets[] of outlet_id/outlet_name. Read tolerantly (body.Outlets \|\| body.outlets). Metadata (status, error_message, has_next_item) is snake_case. |
VERIFIED (curl) |
200 + status:false on error |
Calls can return HTTP 200 with status:false + error_message for body-level errors — check status, not just the HTTP code. |
VERIFIED (docs/curl) |
| Prices are whole-number IDR | price is rupiah (e.g. 100000 = Rp100,000), not cents — do not divide by 100. IDR has no minor unit; pass through to basePrice. |
VERIFIED (docs) |
| Per-outlet pricing | outlet_prices[] gives a price per outlet; top-level price is the default. Use the outlet's price when syncing for a specific outlet. |
VERIFIED |
| IDs are GUID strings | product_id, outlet_id, modifier_group_id, … are GUIDs — already strings, no conversion. |
VERIFIED |
🔴 list_remote_outlets id = outlet_code, NOT the GUID id |
GetOutlets returns both a GUID id and a short outlet_code (e.g. "MO"). The list's id MUST be outlet_code — it becomes the outlet external_id and order_dispatch sends it verbatim as CreateOrder.outlet_code. Mapping the GUID lists fine but breaks dispatch (surfaces only on the first order, not at mapping time). |
VERIFIED |
| Token host ≠ API host | Mint token at https://isellershop.com/oauth/token; call data at https://{storename}.isellershop.com. |
VERIFIED |
| Timestamps | modified_date / modified_after are ISO-8601 without timezone (store-local: WIB/WITA/WIT). |
VERIFIED |
| Product types | type ∈ standard / variant / composite / comboset / composite_variant; includes=OutletPrice,Tags,ModifierGroups,Modifiers expands extras. |
VERIFIED |
Feature Parity¶
| Topic | iSeller Public API v2 |
|---|---|
| test_connection | ✅ GetStoreInfo |
| list_remote_outlets | ✅ GetOutlets (/api/v3) → outlets[] (incl. outlet_code) |
| sync_products | ✅ GetProducts |
| sync_outlet (optional) | ✅ GetStoreInfo |
| order_dispatch | ✅ ERP API POST /api/v4/CreateOrder (Online Ordering); returns order_id |
| order_status_update | ✅ Inbound webhook — Order / Fulfillment events (SHA256-signed) |
| snooze_items | ❌ not in API |
Topics¶
Each topic has its own generation guide so the script generator ingests it whole. Shared Authentication, API Endpoints, Pagination, Rate Limits and Quirks live in the sections above and apply to every topic.
| Topic | Guide |
|---|---|
test_connection |
iseller/test_connection.md |
list_remote_outlets |
iseller/list_remote_outlets.md |
sync_products |
iseller/sync_products.md |
order_dispatch |
iseller/order_dispatch.md |
order_status_update |
iseller/order_status_update.md |
sync_outlet (optional) |
iseller/sync_outlet.md |
snooze_items (optional) |
iseller/snooze_items.md |
Changelog¶
| Date | Version | Change |
|---|---|---|
| 2026-06-15 | 1.0.0 | order_dispatch.js generated — POST /api/v4/CreateOrder, per-run token mint, maps Cata order → CreateOrder body (output matches the sample request fixture). Required a pipeline change: buildTransformerInput now surfaces the tenant connection apiKey/apiSecret/baseUrl at the top level of the transformer input (Revel-style header-auth providers ignore them; OAuth providers like iSeller mint a token from them). Also extended the Go response handling: probeExternalOrderID recognizes snake_case order_id, and detectBodyError flags boolean status:false + error_message. |
| 2026-06-10 | — | Added Add Product admin-UI screenshots (Flash Coffee "Simple Bundle" comboset) under images/iseller/ and embedded them in the Admin UI reference section (general, pricing, inventory/comboset, include-products Drinks + Snack, shipping/SEO). Human reference only. |
| 2026-06-09 | — | list_remote_outlets.js generated (v1.0.1). 🔴 KB: the list id MUST be outlet_code, not the GUID id — it becomes the outlet external_id and order_dispatch reuses it as CreateOrder.outlet_code. GUID lists fine but breaks dispatch. Added a Quirks row + section callout. |
| 2026-06-09 | — | First script generated: scripts/adapters/iseller/test_connection.js (v1.0.1) — token mint + GetStoreInfo, guards status:false, reads the store_info wrapper. Doc references the source file (no inline code). |
| 2026-06-09 | — | curl-verified GetStoreInfo: wrapper is store_info (snake_case — unlike GetOutlets' PascalCase Outlets), with store_name + active_outlets[]. Confirms casing varies per endpoint; test_connection reads store_info.* and guards status:false. |
| 2026-06-09 | — | curl-verified GetOutlets (Flash Coffee): live key is Outlets (PascalCase; docs show lowercase), items carry outlet_code. Map outlet_code→id; read arrays case-tolerantly. Added quirks: response-key casing + 200-with-status:false. Verify GetProducts casing the same way. |
| 2026-06-09 | — | First-generation fixes: token mint must be form-urlencoded (string body; runtime updated to send string bodies verbatim). list_remote_outlets switched to GetOutlets (/api/v3) which returns outlet_code — resolves the outlet_code/outlet_id gap. |
| 2026-06-09 | — | Token verified (Flash Coffee sandbox): the response returns access_token + refresh_token + expires_in (≈14d) + resource_url — so baseUrl comes from the token. |
| 2026-06-09 | — | Correction: Public API v2 Get* calls are POST with a JSON request body (the docs define each call's filters as a "request body"), not GET. |
| 2026-06-09 | — | ERP API v3/v4 received — order_dispatch fully specced: POST /api/v4/CreateOrder (Online Ordering), returns order_id; mapped Cata order → CreateOrder body. iSeller now has endpoints for all in-scope topics. |
| 2026-06-09 | — | Correction: order_dispatch is in scope (Cata dispatches paid orders to iSeller). |
| 2026-06-09 | — | Mapped endpoints from iSeller Public API v2 + Webhook Notification docs: GetStoreInfo (outlets), GetProducts (catalog), inbound Order/Fulfillment webhooks (SHA256-signed) for status. |
| 2026-06-09 | — | Authentication documented (OAuth 2.0: Authorization Code + Client Credentials). |
| 2026-06-09 | — | Skeleton created via the onboarding framework. |
Open uncertainties¶
outlet_code(resolved):GetOutlets(/api/v3) returnsoutlet_codeper outlet, solist_remote_outletsmaps it to the outletexternal_idandorder_dispatchreuses it directly — no separate lookup.- Reconciliation: the inbound webhook
order_idmaps back to theorder_idreturned byCreateOrder(stored at dispatch); confirm filtering of iSeller-native (pos/web) orders. - Status enums: full value sets for
status/payment_status/fulfillment_status(docs show only examples). - Rate limits: not documented.