Channel Connectors
Channel Connectors let a platform built on Porulle list the catalogs of many independent merchants who each run their own Shopify or WooCommerce store, sell them through one unified checkout (the shopper pays once, on the platform), and inject each merchant’s paid slice back into their own store as an already-paid order — so the merchant’s existing fulfilment, notifications, and returns run unchanged.
Porulle owns the commerce truth (price-at-sale, payment, refund authority); the merchant store stays the control plane for catalog, stock, and fulfilment. The connector is a pure API client — nothing is installed inside the merchant’s store; they provide credentials and connect.
Install
Section titled “Install”bun add @porulle/plugin-channel-connector @porulle/adapter-shopify @porulle/adapter-woocommerceThe engine plugin is standalone — it does not require plugin-marketplace. Providers are thin adapter packages you register with it.
Register
Section titled “Register”import { channelConnectorPlugin } from "@porulle/plugin-channel-connector";import { shopifyConnector } from "@porulle/adapter-shopify";import { wooConnector } from "@porulle/adapter-woocommerce";
export default defineConfig({ plugins: [ channelConnectorPlugin({ connectors: [shopifyConnector(), wooConnector()], }), ],});Include the plugin schema and push the new tables:
import { getSchemaFiles } from "@porulle/core";import { createRequire } from "node:module";
const resolve = createRequire(import.meta.url).resolve;
schema: [ ...getSchemaFiles(), resolve("@porulle/plugin-channel-connector/schema"),],bunx drizzle-kit push --config drizzle.config.tsConnect a store: two front doors
Section titled “Connect a store: two front doors”A merchant provides credentials once — a Shopify Admin API access token, or a WooCommerce consumer key + secret. Your onboarding backend makes one call:
await fetch(`${PORULLE}/api/channels/stores`, { method: "POST", headers: { "x-api-key": platformApiKey, "content-type": "application/json" }, body: JSON.stringify({ provider: "shopify", storeDomain: "acme.myshopify.com", credentials: { accessToken: "shpat_..." }, }),});// → 201 { id, provider, status: "connected", credentials: "[redacted]" }Credentials are stored org-scoped and redacted on every read. Connecting a store automatically:
- Imports the catalog — the store’s products land in
sellable_entities, each with achannel_entity_maprow linking it to its external id, andsourceStoreIdmarking it channel-owned. - Mirrors inventory in external-owns-stock mode (the store is the source of truth; level-set writes).
- Registers webhooks on the store (product / inventory / order / refund events) pointing at Porulle.
Both catalog import and inventory sync run as durable jobs, keyed per store.
For one-click onboarding, configure OAuth on the same plugin and register the provider adapters with their OAuth options:
channelConnectorPlugin({ connectors: [ shopifyConnector({ clientId: process.env.SHOPIFY_CLIENT_ID, clientSecret: process.env.SHOPIFY_CLIENT_SECRET, appUrl: "https://platform.example", }), wooConnector(), ], oauth: { stateSecret: process.env.CHANNEL_OAUTH_SECRET!, postConnectRedirect: "https://platform.example/stores", },});The GET /api/channels/oauth/{provider}/start route requires channels:manage, accepts shop for Shopify or store for WooCommerce, and redirects to the provider’s approval screen. The unauthenticated GET/POST callback verifies the signed, single-use state and calls the connector’s optional completeAuth(request, { storeDomain }). Shopify exchanges its callback code; WooCommerce receives { consumer_key, consumer_secret } in the server-to-server POST callback. Both then call the same connectStore pipeline shown above, so imports, webhook registration, and credential redaction behave identically to paste onboarding.
The adapter contract for this capability is:
buildAuthUrl(params): Result<string, ChannelConnectorError>;completeAuth(request, ctx): Promise<Result<{ credentials: Record<string, unknown>; storeDomain: string;}, ChannelConnectorError>>;OAuth is optional: connectors without these methods continue to support paste onboarding, and routes return a clear configuration/capability error when OAuth is not enabled.
Lossless catalog import
Section titled “Lossless catalog import”The import carries the store’s full catalog shape, not just identity fields. A ChannelCatalogItem may include per-locale attributes[], images[] (with the same role enum as entity_media and per-variant links), options[] axes, tags[], brand, categories[], and status; variants carry optionValues and prices (integer minor units per currency, optional compareAtAmount). All of the extended members are optional — a minimal connector still works — and the import shape is structurally unable to express stock, orders, customers, or fulfilment (a compile-time contract test enforces the boundary). Importers fetch the store currency once per import and omit prices entirely when it cannot be determined.
Convergence writes the real PIM tables, idempotently:
- Attributes per locale via the catalog service — title and description land in
sellable_attributes, not in entity metadata. - Media as
imported-origin assets; with no storage adapter configured, images degrade gracefully to a warning instead of failing the import. - Option axes and prices onto variants,
compareAtAmountincluded. - Tags upserted org-scoped; categories and brands resolved by slug and created when missing — an archived category is never resurrected (the item gets a warning instead).
- Entity metadata merged per key — remote keys overwrite their own keys only, never the whole blob.
Every inbound write respects field ownership: platform-owned paths are skipped with attributed skip records, shared conflicts are held on channel_entity_map.held_field_paths, and an unchanged remote item (per the sync hash) writes nothing.
Selling
Section titled “Selling”Once connected, imported products are ordinary sellable_entities — the same catalog API, the same cart, the same checkout. The connector is invisible at the point of sale.
Channel-owned entities are write-protected: they’re editable only by the sync/system actor (via the catalog:sync permission), so staff edits can’t fight the merchant store, which owns catalog truth.
Before payment is captured, a checkout.beforePayment hook does a live stock read against the merchant store for every channel line (a read, not a reserve). Out-of-stock lines are blocked before charging; an unreachable store fails that line closed (the rest of the cart proceeds). Checks run in parallel across stores.
Order injection
Section titled “Order injection”When a checkout completes, an orders.afterCreate hook groups the order’s line items by owning store and enqueues one channel/push-order job per store. Each job injects the merchant’s slice into their store as an already-paid order — Shopify financial_status: paid, WooCommerce set_paid: true — carrying the platform’s exact price-at-sale amounts and the real customer name / email / shipping address, so the merchant’s normal fulfilment runs.
Export state lives on the per-merchant slice (channel_order_exports): pending → exported → confirmed | failed | abandoned. A permanently-failing injection ends abandoned → auto-refund on a tiered SLA (definitive failures refund fast; transient ones retry, then refund). Failed exports are queryable and re-triggerable:
await fetch(`${PORULLE}/api/channels/exports?state=failed`);await fetch(`${PORULLE}/api/channels/exports/${id}/retry`, { method: "POST" });Two-way sync
Section titled “Two-way sync”Inbound webhooks (per-store HMAC-verified, deduped) keep Porulle current:
- Catalog / inventory changes converge the mirror (level-set; a deleted product is soft-archived, never hard-deleted, so order history survives).
- Fulfilment / tracking lands on the order timeline.
- Store-side refunds drive a cross-boundary refund: a verified
refunds/createwebhook triggers a platform-side refund computed from what the customer actually paid, executed through the hardened refund engine. Small, clean refunds auto-execute; large / new-merchant / divergent ones queue for operator approval:
await fetch(`${PORULLE}/api/channels/refund-requests`); // pending approvalsawait fetch(`${PORULLE}/api/channels/refund-requests/${id}/approve`, { method: "POST" });Reconciliation
Section titled “Reconciliation”Webhooks optimize for speed; reconciliation optimizes for truth. A scheduled channel/reconcile job per store does a full diff of the store’s catalog + inventory against Porulle’s mirror via the id-map — importing what’s missing, converging drift, soft-archiving what’s gone — and emits a drift report. Query a store’s sync health:
await fetch(`${PORULLE}/api/channels/stores/${storeId}/reconcile-status`);// { lastReconcileAt, report: { imported, converged, archived }, driftAlert }Catalog backfill
Section titled “Catalog backfill”Catalogs imported before the lossless path hold only the basic shape. Backfill re-converges a store’s already-imported catalog through the full pipeline — same convergence, same ownership rules — without waiting for remote changes:
porulle channel:backfill --store <id> [--dry-run] [--restart]Or over REST (channels:manage):
await fetch(`${PORULLE}/api/channels/stores/${storeId}/backfill`, { method: "POST", body: JSON.stringify({ dryRun: false, restart: false }),});dryRun: true runs inline, write-free, and returns the report. Otherwise the call enqueues the durable channel/backfill-catalog job: keyed per store, one page per run, self-chaining until complete, with the cursor persisted so a restarted server resumes where it left off. restart: true discards the persisted cursor for a fresh pass.
Backfill also promotes legacy metadata-held title/description into en attributes — only for entities with no attribute rows (manually curated attributes are never overwritten), skipping platform-owned paths, and with a read-back verification before the metadata keys are removed.
REST surface
Section titled “REST surface”| Route | Purpose |
|---|---|
| POST /api/channels/stores | Connect a store |
| GET /api/channels/stores · /{id} | List / get stores (credentials redacted) |
| POST /api/channels/stores/{id}/disconnect | Disconnect (clears credentials) |
| GET /api/channels/exports?state=failed · POST …/{id}/retry | Failed order exports + re-trigger |
| POST /api/channels/webhooks/{storeId} | Inbound per-store webhooks (HMAC) |
| POST /api/channels/compliance/{provider} | Inbound app-level compliance webhooks (GDPR) |
| GET /api/channels/refund-requests · POST …/{id}/approve | Cross-boundary refund approval queue |
| GET /api/channels/stores/{storeId}/reconcile-status | Per-store sync health + drift report |
| POST /api/channels/stores/{storeId}/backfill | Re-converge an already-imported catalog (dry-run inline, else durable job) |
- Standalone. The connector works for a single-store brand or a giant multi-merchant aggregator with the same engine — no
plugin-marketplacedependency. - Nothing installed on the merchant side. Merchants may paste credentials or use one-click OAuth (Shopify App / WooCommerce
/wc-auth) through the generic engine routes, not a per-provider plugin. - Shopify public-app distribution (listed or unlisted) requires
customers/data_request,customers/redact,shop/redact, andapp/uninstalledcompliance webhooks. The three GDPR topics are delivered to a single app-level URL (POST /api/channels/compliance/shopify), signed with the app client secret, and resolve the store byshop_domainin the payload. Public-app operators set this URL inshopify.app.toml; custom/single-store apps do not need it.app/uninstalledis registered per-store via the Admin API and also arrives app-secret-signed at the compliance URL — both paths are supported. - To support a new platform, implement the
ChannelConnectorinterface — see Store Connector.