iSeller — order_dispatch¶
Part of the iSeller adapter guide — see it for authentication, endpoints, pagination, rate limits and shared quirks.
Contract: transformer output. Runs as the provider transformer on the two-tier pipeline (ADR 0002) — owns URL/method/ headers/body and auth.
Endpoint: POST {baseUrl}/api/v4/CreateOrder (ERP API v4 — "Support Online
Ordering"), header Authorization: Bearer {access_token} (same OAuth token).
Outlet code resolution¶
The iSeller outlet_code (string, e.g. "MO") is sent as
CreateOrder.outlet_code. Its authoritative source is the Cata store's
ref_code, set from GetOutlets during list_remote_outlets (the outlet
external_id). The pipeline surfaces it to the transformer as
input.outlet.refCode, so no per-outlet setting is required.
input.outlet.refCode— preferred. Loaded from the store row by the dispatch pipeline (buildTransformerInput); no drift, single source of truth.outlet_providers.settings.outletCode— deprecated fallback for older mappings that set the code by hand. Used only whenrefCodeis empty.
Cata order → CreateOrder body¶
| iSeller field | Source / value |
|---|---|
order_reference |
Cata orderRefNo |
channel |
"web" (Cata = online-ordering channel) |
online_order_state |
"new" |
order_type |
map deliveryMethod → take_away / pick_up / delivery |
order_date |
createdAt (ISO-8601) |
pickup_time |
expectedPickupAt (required when order_type=pick_up) |
status |
"completed" |
currency |
"IDR" |
total_amount |
totalPay (whole IDR); tax_inclusive / tax_amount from tax |
outlet_code |
settings.outletCode |
order_details[] |
per item (below) |
transactions[] |
[{ payment_type_name, type:"Sale", amount: totalPay, transaction_date }] |
customer |
{ first_name, last_name, phone_number, email, external_id? } |
OrderDetail (per item): sku (= Cata plu), type
(standard/variant/composite/comboset), product_name, quantity, base_price
(qty=1, whole IDR), total_price, notes, modifiers[]
({sku,name,group,quantity,price}), combosets[]
({sku,quantity,base_price,price,category,modifiers}) when comboset.
Response → reconcile¶
{ "order_id": "e47696a0-…", "error_message": null, "status": true, "time": "…" }
- External order id =
order_id— persist on the dispatch row (the webhook'sorder_idreconciles back here). - iSeller returns HTTP 200 with
status:falseon a body-level error → treat as a failed dispatch (matches Cata's "2xx body indicated error" handling).
Implemented¶
- Script:
scripts/adapters/iseller/order_dispatch.js(v1.0.0). Mints a Client-Credentials bearer token per run (same as the other iSeller scripts), resolves the base URL from the token'sresource_url(falling back toinput.baseUrl, theninput.standard.urlfor Hookdeck testing), and returns theCreateOrderspec withAuthorization: Bearer {token}. The Go pipeline performs the POST. - Outlet code reaches the transformer: the pipeline loads the store's
ref_codeby DB ID and surfaces it asinput.outlet.refCode(buildTransformerInputininternal/connectors/dispatch_pipeline.go, via a narrowStoreReader). The script readsrefCodefirst, falling back tosettings.outletCode.ref_codestays the single source of truth so the dispatchedoutlet_codecan't drift from whatlist_remote_outletssynced. - Credentials reach the transformer: unlike header-auth providers (Revel,
which reads
outlet.settings), iSeller needs the tenant OAuth credentials to mint a token. The dispatch pipeline now surfaces theprovider_connectionsapiKey/apiSecret/baseUrlat the top level of the transformer input (buildTransformerInputininternal/connectors/dispatch_pipeline.go), mirroring the universal adapter-script input the other topics receive. - Discount (single combined object): iSeller reconciles
total_amountagainstΣ line totals − discount.amount; a discounted total with no matching discount is rejected (final amount … not match with total items amount …). iSeller's order-leveldiscountis a single Object — empirically adiscountarray is ignored (total not reduced), and thepromotionobject is not deducted (it expects a real server-side promotion record), sopromotionstaysnull. In Cata both "promotion" and loyalty "points" are really discounts, so the transformer sums the structuredorder.discounts[]breakdown ({type, name, amount, externalId}) into onediscountobject (amount= Σ,name= joined entry names,external_id= first entry's). Whendiscounts[]is absent it falls back to the aggregatediscountTotal.external_idis required by iSeller (rejected if blank despite the spec marking it optional). - Fees (provisional, line-item): iSeller v4 has no fee/service-charge
field, and
total_amountreconciles against the order_details totals — so a fee baked into the total with no representation is rejected. The transformer emits each fee as a standardorder_detailline so it adds to the item total:order.serviceChargeusessettings.serviceChargeSku,order.deliveryFeeusessettings.deliveryFeeSku,order.packagingFeeusessettings.packagingFeeSku, and eachorder.additionalFees[]usessettings.feeSkus[feeCode](falling back to the CatafeeCode). Each fee SKU must be a real iSeller fee product — a fee with no resolvable SKU is skipped (and the total then won't reconcile). Flash Coffee fee products:SERVICEFEE(Service Fee),DELIVERYFEE(Delivery Fee),PACKAGINGFEE(Packaging Fee). - Combosets / bundles — decomposed by default (
settings.bundleStrategy): iSeller rejects the bundle SKU (BDL001) for everyorder_detail.type—comboset/composite→unsupported type,standard→type not match— so the defaultbundleStrategy:"decompose"drops the bundle SKU and emits each component as its own standardorder_detailline (the TC-001/TC-004 shape iSeller accepts). The bundle line total is distributed across the components (each carries its surcharge; the remainder rides the first line) sototal_amountreconciles. Each component line'snotesis stampedBundle: <name>so the bundle origin is visible on every line (iSeller has no native bundle-grouping field for online orders). ✅ Sandbox-validated 2026-06-26 (dispatchedtrue, realexternalOrderId); iSeller accepts the resulting Rp 0 component line, so no even-distribution fallback is needed. The sample-shaped wrapper (bundleStrategy:"comboset": onetype:"standard"line withcombosets[], component price fieldcomboset_price, nested modifiersmodifier_name/modifier_group— per iSeller's v4 "Sample Usage (Comboset)", which overrides the parameter table for this path) is retained as a fallback for when iSeller confirms a working bundle wrapper. - Tip — not supported by iSeller (dropped): iSeller's Transaction object has
no tip field, so the tip is not sent. Cata's
totalPayincludes the tip, so the transformer sendstotal_amount=transaction.amount=totalPay − tip(the pre-tip total) — otherwise it would over-state the total and fail reconciliation against the (tip-less) order_details. The tip remains a Cata-side concept; it is not recorded in iSeller. - Response reconcile: the Go probe recognizes iSeller's snake_case
order_id, anddetectBodyErrortreats booleanstatus:false+error_messageas a failed dispatch. - Reference output: matches
docs/guides/adapters/samples/iseller/order_dispatch_request.iseller.json.
Sandbox validation & open items¶
✅ Sandbox-validated 2026-06-17 — full end-to-end pass against the Flash
Coffee iSeller sandbox: a Cata PAID order dispatched and iSeller returned
order_id (captured as externalOrderId). Gates cleared in this order — a
useful checklist when onboarding another outlet (see
ADR 0011):
outlet_coderesolves (fromstores.ref_code) — elseoutlet not found.- customer pre-registered in iSeller (matched by
email) — elsecustomer not found. - line
skuexists in the iSeller catalogue — elseproduct SKU … not found. payment_type_namematches a registered iSeller payment type — else… not found.
⚠️ Open: real payment_type_name pending vendor. Validation used "Cash"
only to unblock the pipeline — do not ship it. In iSeller, Cash
denotes a not-yet-paid (cash-on-delivery) order, which contradicts the invariant
that every Cata order is already paid before dispatch (ADR 0011 §C/D). The
production value must be a prepaid / online payment type — awaiting
confirmation from iSeller, then set settings.paymentTypeName to it.
⚠️ Open: customer auto-registration. Customers must currently be registered
in iSeller out of band (no create step exists). Tracked in
docs/guides/todo.md.