Skip to content

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.

Terminal window
bun add @porulle/plugin-channel-connector @porulle/adapter-shopify @porulle/adapter-woocommerce

The engine plugin is standalone — it does not require plugin-marketplace. Providers are thin adapter packages you register with it.

commerce.config.ts
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:

drizzle.config.ts
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"),
],
Terminal window
bunx drizzle-kit push --config drizzle.config.ts

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:

  1. Imports the catalog — the store’s products land in sellable_entities, each with a channel_entity_map row linking it to its external id, and sourceStoreId marking it channel-owned.
  2. Mirrors inventory in external-owns-stock mode (the store is the source of truth; level-set writes).
  3. 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.

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, compareAtAmount included.
  • 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.

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.

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" });

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/create webhook 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 approvals
await fetch(`${PORULLE}/api/channels/refund-requests/${id}/approve`, { method: "POST" });

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 }

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:

Terminal window
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.

| 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-marketplace dependency.
  • 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, and app/uninstalled compliance 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 by shop_domain in the payload. Public-app operators set this URL in shopify.app.toml; custom/single-store apps do not need it. app/uninstalled is 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 ChannelConnector interface — see Store Connector.