Phase 3: Shopee Slice (Inbound) — Kickoff Plan¶
Status: Ready to start 📋 Predecessor: Phase 2 Kickoff (staging validated 2026-04-22 — see Phase 2 Findings) Exit criterion (from Project Plan): "A real Shopee order lands in Medusa via the connector."
This plan turns the Phase 3 objective — end-to-end Shopee connector — into a concrete module scaffold, data model, ingestion + status + escrow flows, failure path, and validation checklist. Inbound only: orders flow Shopee → Medusa. Outbound (push price / stock / shipment back to Shopee) is deferred to Phase 6 alongside Lazada so both connectors generalize the outbound pattern at once.
Goal¶
Ship src/modules/connectors/shopee/: a connector that listens to Shopee's webhooks (with hourly polling as backstop), persists every raw payload, normalizes orders against the merchant's existing SKU discipline, creates Medusa orders with correct line items / customer / shipping / sales-channel attribution, tracks order status updates through the full Shopee lifecycle, and pulls escrow income detail when orders complete. Failures publish to a dedicated Telegram channel and persist as exception rows for operator action.
When Phase 3 exits, the merchant's real Shopee orders flow into Medusa within minutes of being placed, with status changes mirrored and final settlement amounts captured for Phase 4 (DCA / costing) to consume.
Scope¶
In scope¶
src/modules/connectors/shopee/connector module (service, SDK wrapper, normalizers, mappers, webhook endpoint, polling + reconciliation + escrow jobs).src/modules/connectors/shared/cross-connector primitives (ConnectorExceptionmodel +alerting.tsTelegram publisher).- Four new data models:
ShopeeRawEvent,ShopeeOrderSync,ShopeeEscrow,ConnectorException. - Inbound order ingestion: webhook → raw event → normalize → Medusa
createOrderWorkflow. - Order status sync: Shopee push code
3(order status update) → Medusa fulfill / cancel / complete workflows. Handler matrix covers all 10 validOrderStatusvalues for the SG (GLOBAL) endpoint. - Escrow income capture: webhook-triggered + daily backstop, populates
ShopeeEscrowrows for Phase 4 to consume. - Lost-push recovery (hourly via
getLostPushMessage()) and daily reconciliation job (03:00 SGT). - Webhook signature verification (HMAC-SHA256, Shopee's
Authorizationheader). - OAuth bootstrap script + persistent token storage on a Docker volume.
TCGChannelListingtable (deferred from Phase 2) — defined and populated during inbound, but not used for inbound matching (SKU-based matching is sufficient). Outbound consumers come in Phase 6.- Failure path:
ConnectorExceptionrow + Telegram alert via n8n webhook → dedicated#tcg-platform-alertschat. - n8n workflow JSON for the alert formatter, exported to
homelab/04_n8n_workflows/. - Tests: unit (normalizers, mappers, signature verification), integration (full webhook → order flow with mocked SDK), manual end-to-end (real test order on the merchant's Shopee shop).
tcg-platform/phase-3-findings.md— validation findings doc, written during the staging run.
Out of scope¶
| Out of scope | Home phase |
|---|---|
| Outbound stock decrement push to Shopee | Phase 6 |
| Outbound price update push to Shopee | Phase 6 |
| Outbound fulfillment / shipping label push | Phase 6 or 7 |
| Bulk listing import from Shopee | Phase 5 (admin UI) |
| Auto-retry beyond SDK transient errors | Phase 5 (exception queue UI with operator-driven retry) |
| Lazada connector | Phase 6 |
| Per-channel pricing | Phase 4 |
| DCA computation from escrow data | Phase 4 (Phase 3 just stores the data) |
| Custom Medusa admin widget for connector status | Phase 5 |
| Email / SMS alerting | Out of MVP — Telegram only |
| Multi-shop support (single-tenant per design) | Out of MVP — see deferred SaaS section |
| Shopee Ads / promotions / vouchers as first-class entities | Out of MVP — captured raw in escrow JSONB only |
Prerequisites¶
Two things must land before Phase 3 implementation starts:
- Local development environment —
docker-compose.dev.yml(Postgres + Redis sidecars, Medusa via host-sideyarn dev) so iteration is sub-second instead of 2-3 minute Docker rebuilds. Shipping as a separate small PR before Phase 3 begins. - Shopee credentials —
partner_id,partner_key,shop_id, OAuth refresh token from the merchant's in-house seller app. Stored locally in.env.development(gitignored), in staging viadocker-compose.ymlenv block (rotated per Credentials Index process), never committed.
Architecture¶
Module layout¶
src/modules/connectors/
├── index.ts registers the connectors module (umbrella)
├── shared/
│ ├── service.ts ConnectorExceptionService (CRUD + Telegram dispatch)
│ ├── alerting.ts publishAlert(payload) — POST to n8n webhook
│ └── models/
│ └── connector-exception.ts ConnectorException model
└── shopee/
├── service.ts ShopeeConnectorService (orchestration)
├── sdk.ts wraps @congminh1254/shopee-sdk client (singleton)
├── auth/
│ └── postgres-token-storage.ts implements TokenStorage backed by ShopeeAuthToken
├── normalizers/
│ ├── order.ts Shopee Order → canonical order shape
│ ├── address.ts Shopee shipping address → Medusa address shape
│ ├── status.ts OrderStatus → Medusa workflow dispatch
│ └── escrow.ts OrderIncome → ShopeeEscrow row
├── mappers/
│ └── sku-to-variant.ts model_sku → Medusa variant.sku
├── jobs/
│ ├── lost-push-recovery.ts hourly: getLostPushMessage() backstop
│ ├── reconcile-daily.ts 03:00 SGT — full get_order_list reconciliation
│ ├── fetch-escrow.ts on-demand: triggered by COMPLETED status
│ └── escrow-backstop-daily.ts 04:00 SGT — catches missed escrow fetches
├── subscribers/
│ └── raw-event-created.ts processes new ShopeeRawEvent rows
├── api/
│ └── webhook.ts POST /connectors/shopee/webhook
├── scripts/
│ └── shopee-oauth-bootstrap.ts one-shot OAuth dance executor
└── models/
├── shopee-raw-event.ts
├── shopee-order-sync.ts
├── shopee-escrow.ts
└── shopee-auth-token.ts single-row token storage (Postgres-backed)
src/links/ (cross-module Module Links via defineLink()):
shopee-order-sync-medusa-order.ts— 1:1 fromShopeeOrderSyncto Medusaorder.tcg-channel-listing-product-variant.ts— N:1 fromTCGChannelListingtoproductVariant(Phase 2 deferred this here).
Inbound flow¶
sequenceDiagram
participant SH as Shopee
participant WH as POST /connectors/shopee/webhook
participant DB as ShopeeRawEvent (table)
participant SUB as raw-event subscriber
participant SDK as Shopee SDK
participant NORM as Normalizer + Mapper
participant MEDUSA as Medusa workflows
participant SYNC as ShopeeOrderSync
participant EX as ConnectorException
participant TG as Telegram (#tcg-platform-alerts)
SH->>WH: POST webhook (any event_type)
WH->>WH: verify HMAC-SHA256(partner_key, url+body)
WH->>DB: insert raw event (signature_ok, processed=false)
WH-->>SH: 200 OK (immediate ack)
Note over SUB: subscriber listens for<br/>ShopeeRawEvent.created
SUB->>SDK: get_order_detail(ordersn) [if event needs detail]
SDK-->>SUB: Shopee order payload
SUB->>NORM: normalize order + resolve SKUs
alt All SKUs resolve
SUB->>MEDUSA: createOrderWorkflow / updateOrder / cancelOrder
MEDUSA-->>SUB: medusa_order_id
SUB->>SYNC: upsert (ordersn, status='created')
SUB->>DB: mark raw event processed=true
else SKU miss / signature fail / Medusa failure
SUB->>EX: insert exception (error_class, retry_count)
SUB->>TG: publish alert via n8n webhook
end
Lost-push recovery (hourly): Calls sdk.push.getLostPushMessage() — Shopee's purpose-built API that returns up to 100 missed webhook deliveries from the past 3 days, paginated via has_next_page / last_message_id. After processing, calls confirmConsumedLostPushMessage({ last_message_id }) to advance the cursor. Inserts each recovered message as a ShopeeRawEvent with source='poll' and runs through the same subscriber as live webhooks. This replaces blind get_order_list polling — Shopee's API knows exactly what we missed.
Daily reconciliation (03:00 SGT): Backstop for messages older than 3 days (the lost-push window) or other edge cases. Pulls full order list via sdk.order.getOrderList() for the past 24h. Compares against ShopeeOrderSync. Any orphans → process. Logs sanity warning if Medusa has orders that Shopee doesn't (impossible in inbound-only mode but worth catching in case of test-data confusion).
Escrow flow:
- Event-driven (primary): push code 3 webhook with new status =
COMPLETED→ enqueuefetchEscrowJob(ordersn)→ SDK callsdk.payment.getEscrowDetail({ order_sn })→ upsertShopeeEscrow. Per SDK docs, escrow data may not be immediately available for orders just transitioning out ofREADY_TO_SHIP; the connector handleserror_not_foundgracefully (re-enqueue with backoff rather than failing immediately). - Backstop (daily 04:00 SGT): scans
ShopeeOrderSyncwhere status =createdAND no matchingShopeeEscrowrow ANDshopee_status = 'COMPLETED'AND completed > 14 days ago → fetches escrow. Catches anything where the COMPLETED webhook was missed and lost-push recovery + reconciliation also didn't catch the status change.
Note on escrow_release_time: The getEscrowDetail() response does NOT include escrow_release_time — that field is only available on EscrowListItem from getEscrowList(). If we need the release time per-order, the backstop job uses getEscrowList() (paginated by release_time_from / release_time_to) instead of per-order getEscrowDetail(). For Phase 3 we capture release time only when available; Phase 4 (DCA) decides whether release timing is load-bearing for cashflow accounting.
Push event types subscribed¶
Shopee Push uses integer code values, not string event names. The SDK's PushManager receives all push types from a single endpoint; the connector decides per-code what to do. From the audit:
| Code | Meaning | Phase 3 handling |
|---|---|---|
| 1 | Shop authorization | Log only (we OAuth'd via in-house seller app — shouldn't see this normally) |
| 2 | Shop deauthorization | Telegram alert + log (operator action required: re-OAuth) |
| 3 | Order status update | Primary inbound trigger — fetch order detail, normalize, dispatch Medusa workflow |
| 4 | TrackingNo push | Update ShopeeOrderSync with tracking info, log only (Phase 6 handles outbound shipment sync) |
| 5–11 | Various non-order pushes (announcements, banned items, promotions, webchat, video, …) | Log + ack 200, no further action |
| 12 | OpenAPI authorization expiry | Telegram alert (high severity — token rotation needed) + log |
| 13 | Brand register result | Log only |
PushMessage.data is a JSON-encoded string (NOT a nested object) and must be JSON.parse()'d to access ordersn / status / etc. The webhook handler does this once per inbound message before storing the raw event.
Order lifecycle handler matrix¶
The SDK's OrderStatus union contains exactly 10 values for SG (ShopeeRegion.GLOBAL). Each maps to a deterministic Medusa effect. The INVOICE_PENDING value referenced in some Shopee docs is region-restricted to PH/BR/VN/TH and is NOT part of the SG OrderStatus union — the connector ignores it.
Shopee order_status |
Medusa workflow / effect | SDK calls |
|---|---|---|
UNPAID |
createOrderWorkflow — Medusa order in pending state |
getOrdersDetail() |
READY_TO_SHIP |
updateOrderWorkflow — payment captured, transition to processing |
getOrdersDetail() |
PROCESSED |
createOrderShipmentWorkflow — record shipment + tracking |
getOrdersDetail() |
RETRY_SHIP |
Log only — update shipment metadata if available | getOrdersDetail() |
SHIPPED |
markOrderFulfilledWorkflow |
getOrdersDetail() |
TO_CONFIRM_RECEIVE |
Log only — note awaiting buyer confirmation | getOrdersDetail() |
IN_CANCEL |
Log only — hold for resolution | getOrdersDetail() |
CANCELLED |
cancelOrderWorkflow |
getOrdersDetail() |
TO_RETURN |
Log only — Phase 3 scope is inbound only; return reconciliation lands in Phase 6 | getOrdersDetail() |
COMPLETED |
completeOrderWorkflow + enqueue fetchEscrowJob(ordersn) |
getOrdersDetail() + getEscrowDetail() |
pending_terms (e.g. SYSTEM_PENDING, KYC_PENDING) seen on UNPAID orders → log only, don't create a separate Medusa state.
Package-level LogisticsStatus (delivered, lost, etc.) is NOT used as an order-state trigger in Phase 3 — only the top-level order_status drives Medusa workflows. Logistics status is captured in the raw event payload and exposed for Phase 6 outbound use.
Data model¶
ConnectorException (cross-connector, lives in shared/)¶
id pk
connector enum: 'shopee' | 'lazada' | 'telegram' | ...
raw_event_id text (polymorphic — references shopee_raw_event.id, etc.)
error_class enum: 'signature_mismatch' | 'sku_not_found' | 'normalization_failed'
| 'medusa_workflow_failed' | 'escrow_fetch_failed' | 'sdk_transient'
| 'retry_exhausted' | 'unknown'
error_message text
retry_count int default 0
status enum: 'open' | 'resolved' | 'wont_fix'
resolved_at timestamptz nullable
resolved_by text nullable (operator email)
operator_note text nullable
created_at, updated_at
INDEX (connector, status) WHERE status = 'open'
ShopeeRawEvent (Shopee-specific)¶
id pk
event_type text ('order_status_update', 'return_status_update', etc.)
shopee_event_id text unique nullable (Shopee's request ID — idempotency key for webhooks)
shop_id bigint
ordersn text nullable (Shopee's order serial number)
payload jsonb (verbatim webhook body or polled order detail)
signature text nullable (Shopee's Authorization header — webhooks only)
signature_ok bool nullable
source enum: 'webhook' | 'poll' | 'reconcile'
processed bool default false
processed_at timestamptz nullable
created_at
INDEX (event_type, processed) WHERE processed = false
INDEX (ordersn)
INDEX (created_at) -- for 90-day retention sweep (Phase 9)
90-day retention per PRD line 273. Cleanup cron lands in Phase 9; the index is in place from Phase 3.
ShopeeOrderSync (Shopee-specific)¶
id pk
ordersn text unique (one row per Shopee order)
shop_id bigint
medusa_order_id text nullable (linked to Medusa order via Module Link)
status enum: 'pending' | 'created' | 'failed'
shopee_status text (last known Shopee status — UNPAID, READY_TO_SHIP, etc.)
last_raw_event_id text (FK-ish to shopee_raw_event)
created_at, updated_at
INDEX (status)
INDEX (shopee_status)
ShopeeEscrow (Shopee-specific)¶
Field names match the SDK's OrderIncome schema verbatim — the audit (2026-04-23) found several names in the original draft did not match the SDK. Schema source: [SDK:src/schemas/payment.ts].
id pk
ordersn text unique (1:1 with ShopeeOrderSync.ordersn)
escrow_amount numeric (what merchant actually receives — KEY FIELD for Phase 4)
original_price numeric (sticker price before any discount)
seller_discount numeric
shopee_discount numeric
voucher_from_seller numeric (renamed from seller_voucher to match SDK)
voucher_from_shopee numeric (renamed from shopee_voucher to match SDK)
commission_fee numeric (Shopee's cut)
service_fee numeric
seller_transaction_fee numeric (renamed from transaction_fee to match SDK)
actual_shipping_fee numeric
seller_lost_compensation numeric
buyer_total_amount numeric (renamed from buyer_paid_amount to match SDK)
currency text (sourced from Order.currency, hardcoded 'SGD' for SG shop —
getEscrowDetail does NOT return a currency field)
escrow_release_time timestamptz nullable (only populated when fetched via getEscrowList — getEscrowDetail
does not return this field; backstop job uses getEscrowList)
raw_escrow_payload jsonb (full OrderIncome — Phase 4 mines additional fields:
final_shipping_fee, shopee_shipping_rebate, seller_return_refund,
coins, escrow_tax, reverse_shipping_fee, drc_adjustable_refund,
credit_card_transaction_fee, seller_coin_cash_back, etc.)
fetched_at timestamptz
created_at, updated_at
ShopeeAuthToken (Shopee-specific, single-row)¶
Backs the custom PostgresTokenStorage implementation that replaces the SDK's default file-based token storage (which uses synchronous fs.writeFileSync and would block the Medusa event loop on every refresh). One row per shop_id; for the single-tenant MVP there will only ever be one row.
id pk
shop_id bigint unique
access_token text
refresh_token text
expire_in int (seconds — from Shopee response)
expired_at bigint (epoch ms — set by SDK at write time)
merchant_id_list jsonb nullable
shop_id_list jsonb nullable
supplier_id_list jsonb nullable
raw_token_payload jsonb (full AccessToken object for forward compat)
created_at, updated_at
The PostgresTokenStorage class implements the SDK's TokenStorage interface:
- store(token: AccessToken): Promise<void> — upsert by shop_id
- get(): Promise<AccessToken | null> — select latest
- clear(): Promise<void> — delete (used during clearAuth flows)
Passed as second arg to ShopeeSDK constructor: new ShopeeSDK(config, new PostgresTokenStorage(...)).
TCGChannelListing (Phase 2 deferral — defined here, lightly used)¶
id pk
variant_id fk to productVariant (nullable) (for fungible)
serialized_item_id fk to TcgSerializedItem (nullable) (for serialized)
channel enum: 'shopee' | 'lazada' | 'telegram' | 'carousell' | 'instagram' | 'in_store' | 'event'
external_listing_id text (e.g. Shopee item_id)
external_variation_id text nullable (e.g. Shopee model_id)
listing_url text nullable
status enum: 'active' | 'inactive' | 'out_of_stock' | 'paused'
last_synced_at timestamptz
CONSTRAINT exactly_one_target CHECK ((variant_id IS NULL) != (serialized_item_id IS NULL))
INDEX (channel, external_listing_id, external_variation_id)
In Phase 3, this table is populated during inbound (cheap to upsert when we already have the variant). It is not used for inbound matching — model_sku direct match is sufficient. Phase 6 outbound code will read this table to know which Shopee (item_id, model_id) to push price/stock changes to.
Module Link wiring¶
| Link | From | To | Cardinality |
|---|---|---|---|
shopee-order-sync-medusa-order |
ShopeeOrderSync |
order (Medusa) |
1:1 |
tcg-channel-listing-product-variant |
TCGChannelListing |
productVariant |
N:1 (when variant_id set) |
tcg-channel-listing-serialized-item |
TCGChannelListing |
TcgSerializedItem |
N:1 (when serialized_item_id set) |
Listing → variant mapping¶
The merchant maintains consistent model_sku discipline on every Shopee variation (generated from a Google Sheets formula). Phase 3 leverages this:
mapShopeeOrderItem(orderItem):
variant = await query.graph({
entity: "product_variant",
fields: ["id", "sku"],
filters: { sku: orderItem.model_sku }
})
if variant.length === 0:
throw SkuNotFoundError(orderItem.model_sku, ordersn)
if variant.length > 1:
throw AmbiguousSkuError(orderItem.model_sku) # Medusa SKU should be unique
return variant[0]
Edge cases:
model_skuempty on Shopee side — operator forgot to set it. Falls intosku_not_foundexception with the Shopeeitem_id+model_idin the error context so the operator can fix it on Shopee and re-process.- Multiple matches — should not happen (Medusa enforces unique SKU per variant) but guarded against with
AmbiguousSkuError. - Variant exists but has no
TCGVariantMetadata— fine, we still create the order. Accessories go through this path.
Future (Phase 5): the SKU generator formula moves into the admin product-creation UI so SKUs are generated in-platform and pushed to Shopee via outbound (Phase 6) rather than spreadsheet → manual paste.
Webhook signature verification¶
Per the SDK source audit, Shopee's actual algorithm is HMAC-SHA256(partner_key, JSON.stringify(body)) — body only, NOT push_url + body. The signature is returned in the authorization header in the format SHA256 <hex_digest>. The SDK's PushManager does not ship a verification helper; we implement it ourselves.
import crypto from 'node:crypto'
export function verifyWebhookSignature(
rawBody: string,
authorizationHeader: string | undefined,
partnerKey: string,
): boolean {
if (!authorizationHeader?.startsWith('SHA256 ')) return false
const provided = authorizationHeader.slice('SHA256 '.length)
const expected = crypto
.createHmac('sha256', partnerKey)
.update(rawBody)
.digest('hex')
// constant-time compare to prevent timing attacks
if (provided.length !== expected.length) return false
return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
}
The handler captures the raw request body before any JSON parsing (Express middleware order matters — express.raw({ type: 'application/json' }) for the webhook route specifically), passes it to verifyWebhookSignature(), then JSON.parse()s it for storage and downstream processing.
Mismatch → raw event still inserted (with signature_ok=false) but not processed by the subscriber. Exception row with error_class='signature_mismatch'. Telegram alert. Webhook still returns 200 (don't hint to attackers whether their signature was right or wrong).
Signature covers only webhooks, not lost-push recovery / reconciliation (those use authenticated SDK calls).
Idempotency + retries¶
Idempotency¶
- Webhook handler: unique on
shopee_raw_event.shopee_event_id(Shopee's per-request ID). Duplicate POSTs return 200 without re-inserting. - Order processing: unique on
shopee_order_sync.ordersn. Re-processing same order is a no-op (state already set). - Lost-push recovery / reconciliation: share the same processor. Lost-push uses Shopee's per-message
last_message_idcursor (advanced viaconfirmConsumedLostPushMessage) so the same message is never re-pulled. Daily reconciliation filters byordersn NOT IN (SELECT ordersn FROM shopee_order_sync)before injection. - Escrow: unique on
shopee_escrow.ordersn. Upsert pattern.
Retries¶
- Webhook ACK: always 200 immediately after raw event insert. Shopee's retry policy doesn't help us — 5xx-ing on processing failure just gets duplicates we'd dedupe anyway. Process async via subscriber.
- Subscriber processing failure:
ConnectorExceptionrow created. No automatic retry in Phase 3. Operator triggers manual retry via SQL (UPDATE shopee_raw_event SET processed=false WHERE id=…) until Phase 5 ships the exception queue UI with a retry button. Rationale: most failures are mapping issues that need operator action; auto-retry just makes the operator deal with N exceptions instead of 1. - SDK transient errors (network blips, 5xx from Shopee API): 3 in-process retries with exponential backoff (1s / 4s / 16s) before giving up and writing an exception with
error_class='sdk_transient'. These are likely to self-resolve.
Failure path → Telegram¶
shared/alerting.ts posts to a webhook URL configured via SHOPEE_ALERT_WEBHOOK_URL env var. URL points at an n8n webhook trigger on CT 102, which formats and posts to the dedicated #tcg-platform-alerts Telegram chat.
Alert payload (JSON sent to n8n):
{
"connector": "shopee",
"error_class": "sku_not_found",
"ordersn": "240422ABCDEF",
"shop_id": 12345,
"message": "SKU 'POK-OBF-223-NM-EN' not found in Medusa variants",
"exception_id": "cex_01ABC...",
"raw_event_id": "srev_01XYZ...",
"occurred_at": "2026-04-23T08:15:30Z"
}
n8n side (out of tcg-platform repo, lives in homelab/04_n8n_workflows/): receives the JSON, formats as Telegram message with a severity emoji + a copy-pastable line for finding the row by exception_id. Phase 3 ships the n8n workflow JSON export alongside the connector commit.
Severity policy: all failures alert. Per merchant decision — operational visibility outranks noise concerns at this stage. If alert volume becomes painful (e.g. Shopee webhooks for OOS items spamming the channel), filtering moves into n8n, not into the connector itself.
SDK + OAuth bootstrap¶
@congminh1254/shopee-sdk (v1.5.5+) is the SDK of record. CJS-only package (verify Medusa workspace consumes via require() cleanly at build time — no ESM entry exists). Bundled TypeScript types via lib/sdk.d.ts.
shopee/sdk.ts exports a singleton client constructed from env, with the Postgres-backed TokenStorage injected:
import ShopeeSDK, { ShopeeRegion } from '@congminh1254/shopee-sdk'
import { PostgresTokenStorage } from './auth/postgres-token-storage'
export const shopeeSdk = new ShopeeSDK(
{
partner_id: Number(process.env.SHOPEE_PARTNER_ID),
partner_key: process.env.SHOPEE_PARTNER_KEY!,
region: ShopeeRegion.GLOBAL, // SG uses the GLOBAL endpoint —
// there is no ShopeeRegion.SG
// in the actual SDK source
shop_id: Number(process.env.SHOPEE_SHOP_ID),
},
new PostgresTokenStorage(/* injected DB client */),
)
Region note: The SDK's ShopeeRegion enum (src/schemas/region.ts) only defines GLOBAL, CHINA, BRAZIL, TEST_GLOBAL, TEST_CHINA. The README claims twelve named regions (incl. ShopeeRegion.SG) but the source disagrees — the implementation plan must verify the compiled lib/sdk.js at build time. Singapore Shopee's API endpoint (https://partner.shopeemobile.com/api/v2) IS the GLOBAL one, so functionally ShopeeRegion.GLOBAL is correct regardless.
Token storage: custom PostgresTokenStorage backed by the ShopeeAuthToken table. Replaces the SDK's default file-based storage which uses synchronous fs.writeFileSync and would block the Medusa event loop on every refresh. No Docker volume needed — Postgres already persists across container rebuilds.
Auto-refresh: SDK handles token refresh automatically before each authenticated request (checks token.expired_at < Date.now(), refreshes + retries) and on invalid_access_token errors. The connector does not need to manage refresh manually.
Error wrapping: SDK throws ShopeeApiError (Shopee API-level failures with non-empty error field) and ShopeeSdkError (network / SDK-internal). Both are caught in the subscriber and converted to ConnectorException rows + Telegram alerts.
Response payload location: The SDK's FetchResponse<T> wrapper has both .result and .response fields. SDK usage patterns access actual data via .response, not .result.
OAuth bootstrap: one-shot script yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <auth_code> after operator completes Shopee's OAuth dance (using the merchant's in-house seller app). The auth_code → access_token + refresh_token exchange writes the first row into ShopeeAuthToken. Documented in the connector README.
Testing strategy¶
Unit tests¶
normalizers/order.ts— fixture-based against recorded Shopeeget_order_detailpayloads. Covers: simple single-line order, multi-line order, order with discounts, order with vouchers.normalizers/status.ts— every documented Shopee status maps to the right Medusa workflow (or no-op for transitional states).mappers/sku-to-variant.ts— known-SKU happy path, unknown-SKUSkuNotFoundError, ambiguous-SKUAmbiguousSkuError.webhook.tssignature verification — known-good payload + tampered payload + missing header.shared/alerting.ts— payload shape, error handling when n8n is unreachable.
Integration tests (Medusa's medusaIntegrationTestRunner)¶
- Webhook → raw event written, ACKed within 100ms.
- Subscriber → Medusa order created with correct line items + sales channel + customer.
- Status update flow:
READY_TO_SHIP → SHIPPEDupdates Medusa fulfillment status. - Cancellation flow:
CANCELLEDfrom Shopee → Medusa order canceled. - Escrow fetch: completed-order webhook →
ShopeeEscrowrow with non-zeroescrow_amount. - Idempotency: re-processing same
ordersnis no-op. - Mapping miss: SKU not found →
ConnectorExceptionrow + Telegram webhook fired (mocked endpoint).
End-to-end manual validation (the exit criterion)¶
On staging, with real Shopee credentials:
- Operator lists a low-value test SKU on Shopee (e.g. a $1 sleeves single from the Phase 2 seed catalogue).
- Operator (using a non-merchant Shopee account) places a real order.
- Within 5 min: Shopee webhook hit
https://tcg-staging.exzentcg.com/connectors/shopee/webhook, raw event row created, Medusa order appears in admin UI,ShopeeOrderSyncrow increatedstate, no exception rows, no Telegram alerts. - Operator marks the order shipped on Shopee → Medusa fulfillment status updates within 5 min.
- After buyer confirms receipt (or auto-confirm period elapses) →
ShopeeEscrowrow appears with non-zeroescrow_amount. - Negative test: temporarily rename a Medusa variant SKU → place test order → expect exception row + Telegram alert. Restore SKU.
CI runs unit + integration only. End-to-end is operator-driven on staging.
Open questions (to resolve before / during implementation)¶
- Test-order destruction. Once the end-to-end test order lands in Medusa, do we cancel it on the Shopee side (so no real fulfillment happens) and let the cancel-flow validation cover that, or do we let it complete naturally so escrow validation runs against a real settled order? Trade-off: real settlement takes 5-7 days; cancellation is instant but doesn't validate the escrow path on real data.
- Sales channel naming. New Medusa sales channel — call it
shopee(lowercase, matchesconnectorenum) orShopee(display-friendly)? Likelyshopeeinternally with a display name override. - Customer matching policy. Match Shopee buyers across orders by
buyer_user_id(stored in customer metadata)? Pro: unified order history per buyer. Con: if Shopee changes the user_id mapping, we get split customers. Default: yes, match bybuyer_user_id. Verify against SDK behaviour during implementation. - Currency handling. SGD-only for Phase 3? PRD doesn't pin it down. The merchant is Singapore-based so likely yes; multi-currency is Phase 6+ once Lazada (which has multiple SEA markets) lands.
- n8n alert chat lifecycle. Who creates the dedicated
#tcg-platform-alertsTelegram chat + records the bot token in the Credentials Index? Operator action, not a code task — flagging here so it doesn't get missed. - Stale
model_skucache invalidation. SKU → variant lookup is cheap (one indexed query). Do we need a cache layer at all in Phase 3, or just hit Postgres each time? Default: no cache (premature optimization). Revisit if order volume + lookup latency become measurable.
Exit criteria checklist¶
From the Project Plan Phase 3 row ("A real Shopee order lands in Medusa via the connector"), expanded:
- [ ]
src/modules/connectors/shopee/andsrc/modules/connectors/shared/shipped, registered inmedusa-config.ts, Medusa boots cleanly. - [ ] All five new migrations apply cleanly on a fresh Postgres (
ConnectorException,ShopeeRawEvent,ShopeeOrderSync,ShopeeEscrow,ShopeeAuthToken) plus the deferredTCGChannelListingmigration. - [ ] Webhook endpoint reachable at
https://tcg-staging.exzentcg.com/connectors/shopee/webhook(NPM proxy + Cloudflare Tunnel — same path config pattern as admin). - [ ] OAuth bootstrap script runs end-to-end against the merchant's in-house Shopee app, persisted tokens survive container restart.
- [ ] Real test order placed end-to-end → Medusa order created within 5 min with correct line items, customer, address, sales channel.
- [ ] Status update test: shipping the test order on Shopee → Medusa fulfillment status updates within 5 min.
- [ ] Cancellation test: cancelling a Shopee test order → Medusa order status flips to
canceledwithin 5 min. - [ ] Escrow test:
ShopeeEscrowrow appears for a settled order with non-zeroescrow_amount. - [ ] Negative test: SKU miss →
ConnectorExceptionrow + Telegram alert fired. - [ ] Lost-push recovery (
getLostPushMessage) runs hourly, advanceslast_message_idcursor, no duplicate orders created. - [ ]
PostgresTokenStoragesurvives container restart — token refreshes between restarts read back the same row, no re-OAuth needed. - [ ] Daily reconciliation runs, no false-positive orphans logged.
- [ ] CI green on all Phase 3 PRs (build + type-check + integration tests).
- [ ] n8n workflow JSON exported to
homelab/04_n8n_workflows/and the corresponding doc page added. - [ ]
tcg-platform/phase-3-findings.mdcommitted with verified scenarios + gaps + open questions. - [ ]
homelab/05_service_deployments/tcg-staging.mdupdated with "Phase 3 applied" line + newphase-3-shopee-liveProxmox snapshot. - [ ] Local dev environment PR (separate, prerequisite) shipped and verified.
Task split (D1 / D2 / D3)¶
In practice the merchant is currently solo on this; the split is documented for when D2 / D3 join. Until then, all tasks land via the subagent-driven flow used for Phase 2.
D1 — Lead (most)¶
- Connector module architecture, OAuth bootstrap, SDK wrapping, retry / idempotency strategy.
- Order normalizer + status mapper + escrow fetcher.
- Webhook signature verification.
ConnectorExceptionmodel + alerting infrastructure.- Phase 3 design + implementation plan + findings doc.
D2 — Mid¶
- Lost-push recovery + reconciliation jobs.
- Integration test suite using Medusa's test runner.
- TCGChannelListing migration + populate-during-inbound logic.
- README + connector-specific docs.
D3 — Support (low)¶
- Unit test fixtures (recorded Shopee payloads, anonymized).
- n8n workflow JSON authoring + export to homelab repo.
- Phase 3 findings doc shell + manual validation walkthrough docs.
Notes for the implementation plan stage¶
The pre-implementation audit (against the SDK source + Shopee Open Platform docs) ran on 2026-04-23. Findings folded into this spec; full report archived at /tmp/shopee-audit-2026-04-23.md (subagent transcript). Material deltas applied:
- Region —
region: 'sg'corrected toShopeeRegion.GLOBALper actual SDK source (only 5 regions exist insrc/schemas/region.ts; SG uses the GLOBAL endpoint regardless). - Webhook signature input — corrected from
HMAC-SHA256(partner_key, push_url + body)toHMAC-SHA256(partner_key, JSON.stringify(body)). Header format isAuthorization: SHA256 <hex>. SDK ships no helper; we write our own. - Escrow field names —
seller_voucher/shopee_voucher/transaction_fee/buyer_paid_amountrenamed to match the SDK'sOrderIncomeschema (voucher_from_seller,voucher_from_shopee,seller_transaction_fee,buyer_total_amount).payout_currencyremoved (does not exist in SDK response — sourced fromOrder.currencyinstead). escrow_release_time— only available onEscrowListItemfromgetEscrowList(), not fromgetEscrowDetail(). Backstop job uses the list endpoint to capture release times; primary path captures whatever else is available.- Order statuses —
INVOICE_PENDINGremoved (region-restricted to PH/BR/VN/TH, not in SGOrderStatusunion);TO_RETURNadded as a log-only status. Final list of 10 SG-valid statuses captured in the handler matrix. - Push event types — concrete code-to-meaning matrix added (codes 1–13). Code 3 (order status) and 4 (tracking) are inbound-relevant; code 12 (token expiry) needs Telegram alert.
- Recovery API — original "polling backstop" replaced with Shopee's purpose-built
getLostPushMessage()API (returns last 3 days of missed pushes, paginated cursor). - Token storage — switched from default file-based (sync
fs.writeFileSync, blocks event loop) to a customPostgresTokenStoragebacked by a newShopeeAuthTokentable. New model added to data model section. - Rate limit / retry confirmation — SDK does no 429 handling; only retries on
invalid_access_token. Our 3-retry exponential backoff inConnectorExceptionhandling is necessary, not redundant.
Items still not verifiable from the SDK alone (Shopee's docs are login-gated), to confirm during implementation against the live API:
- Shopee's actual webhook retry count + window if we 5xx (we treat lost-push API as authoritative recovery regardless).
- Exact rate limits on
getOrdersDetail/getEscrowDetail(the SDK has no constants — we'll measure during the manual end-to-end and tune retry timing if needed). - Whether the compiled
lib/sdk.jsexposes more region values than the source file (README claims 12 regions; source has 5).
References¶
- Project Plan — Phase 3 row
- Project Plan — Token and Connector Strategy
- PRD — Workflow 1 (channel order ingestion)
- Phase 2 Findings — open questions for Phase 3+
tcg-stagingrunbook- Shopee SDK —
@congminh1254/shopee-sdk - Shopee Open Platform — Push (webhook) docs
- Shopee Open Platform — Order API docs
- Shopee Open Platform — Payment / Escrow docs