BridalOp · Technical Spec
Syncing BridalOp inventory to WooCommerce and FluentCart first, then Shopify.
Every section has a In plain terms box. Skip the code, read those, and you'll have the whole picture.
Section 1
In plain terms
A boutique keeps her real inventory in BridalOp. She also wants a shop page on her website where brides can buy a veil or a sash without calling.
The job is to keep those two lists agreeing with each other. When a veil sells in the shop, the website should stop offering it. When one sells on the website, BridalOp should know before anyone tries to sell it again on the floor.
Everything below is about doing that without ever selling the same gown twice.
BridalOp is the system of record. The storefront is a mirror. Catalogue, price and stock all originate in BridalOp and get pushed outward. The only thing that originates on the platform is an order.
Three moving parts:
More of this is built than you'd think.
products.show_on_ecom | Per-product ecom flag, already in the schema |
inventory_items | status, reserved_until, reserved_by_order_id — the reservation model is done |
ReleaseExpiredReservations | Already releases stale holds on a schedule |
tenant_integrations | credentials encrypted, plus settings, status, last_error, last_synced_at |
IntegrationProvider | Contract + registry pattern, proven across 5 CRM providers |
| OAuth refresh | Google / QuickBooks / Square commands — Shopify follows the same shape |
| Queue | Redis, already running |
Section 2
In plain terms
BridalOp tracks individual gowns. Barcode 4231 is a size 10 Aurora, it's on the rack in the downtown shop, and it's brand new.
WooCommerce and Shopify don't think that way. They just count: "size 10 Aurora — we have 3."
So going out, you have to turn a pile of individual items into a number. Coming back, a website order says "one sold" and you have to decide which physical gown that was. That translation is where nearly every bug in this project will come from.
BridalOp stock is serialized — one row per physical unit in inventory_items, each with barcode, condition, location and status. Every platform stock model is a counter — an integer against a variant.
Outbound is an aggregation. Inbound is a resolution — and the resolution has to happen inside a database transaction that locks the row, or two simultaneous orders both grab the same gown.
Section 3
None of these are engineering questions. Answer them and the code is straightforward; leave them open and it never settles.
Every unit has a status (available / reserved / sold / on-order) and a condition (new / floor-sample / used).
status = 'available' AND location is in the published set. Status only — no condition filter. Sample sales are a real bridal revenue channel, and show_on_ecom already gives per-product control, so a boutique excludes a gown by unflagging it rather than by its condition. reserved is excluded automatically, which is what keeps a gown on hold from being sold underneath someone.Multi-location shops may not want the second store's stock sold online, or may want one shop to fulfil everything.
Made-to-order gowns aren't backed by a physical unit, so they can't map to a stock counter at all.
Someone edits a price in Woo. Someone edits it in BridalOp. Reconciliation runs. What happens?
A bride buys the last veil online at the same moment it sells on the floor. Her card is already charged by the time the webhook reaches you — you cannot refuse the order.
needs_attention, and surface it in an exception queue with a notification. The boutique decides: source another, or refund. Trying to prevent this outright is how you lose weeks.Optionally publish stock minus N, so the last unit of anything never sells online.
Section 4
In plain terms
Three new tables. One remembers which BridalOp product is which Woo product. One remembers which website orders you've already imported, so a repeated message doesn't create a duplicate sale. One is a log for when someone asks why the numbers disagree.
The mapping table. Without it there's no way to know that BridalOp product 812 is Woo product 4471.
id
tenant_id
provider woo | fluentcart | shopify
product_id → products.id
product_variant_id → product_variants.id (nullable)
external_product_id
external_variant_id
external_inventory_id Shopify only — inventory item handle
last_pushed_hash hash of what we last sent
last_pushed_qty last stock number we sent
last_synced_at
sync_status ok | error | pending
last_error
last_pushed_hash is what stops you re-pushing 800 unchanged products every cycle and burning your rate limit.
Idempotency. Platforms retry webhooks; without a unique key you create the same order twice.
id
tenant_id
provider
external_order_id UNIQUE with (tenant_id, provider)
order_id → orders.id (nullable until created)
status imported | needs_attention | failed
payload raw webhook, for replay and debugging
imported_at
Rolling record of pushes, pulls and conflicts. The first thing you'll open when a boutique says the site is wrong.
Section 5
In plain terms
Write one list of things any store must be able to do — show me your products, set this stock number, tell me about new orders. Then write one translator per platform.
Do this and Shopify is a few days of work instead of starting over. It's the same trick already used for Klaviyo, Mailchimp and the rest.
Mirror App\Services\Crm\Contracts\IntegrationProvider, which is already proven across five providers.
interface EcomProvider
{
public function key(): string; // woo | fluentcart | shopify
public function name(): string;
public function authType(): string; // api_key | oauth | app_password
public function validate(TenantIntegration $i): bool;
// Catalogue
public function pushProduct(TenantIntegration $i, Product $p): ExternalRef;
public function pushVariants(TenantIntegration $i, Product $p): array;
public function archiveProduct(TenantIntegration $i, EcomProductLink $l): bool;
// Stock — the hot path
public function setStock(TenantIntegration $i, EcomProductLink $l, int $qty): bool;
public function setStockBatch(TenantIntegration $i, array $pairs): array;
// Orders
public function verifyWebhook(TenantIntegration $i, Request $r): bool;
public function normalizeOrder(array $payload): NormalizedOrder;
public function fetchOrdersSince(TenantIntegration $i, Carbon $since): array;
// Reconciliation
public function fetchAllStock(TenantIntegration $i): array;
}
setStockBatch is not optional. Woo needs it (a 50-item order would otherwise be 50 requests) and Shopify's rate limits assume it.
verifyWebhook and normalizeOrder are Shopify-only concerns. WordPress platforms are plugin-mediated — our plugin listens to their PHP hooks and posts an already-normalized payload to a single BridalOp endpoint (see §7). So BridalOp has exactly two inbound paths: the plugin, and Shopify's HMAC-signed webhooks. Adding another WordPress cart later touches only the plugin.
fetchOrdersSince remains the reconciliation sweep for Shopify, and the fallback for any site running a cart without our plugin installed.
Section 6
In plain terms
Something changes in the shop — a gown is received, sold, or put on hold. Count how many are left that qualify for the website, and tell the website that number.
An inventory_items observer fires on status change, plus a nightly full pass.
count(units where status='available' and location in [set]), minus the safety buffer.
Compare against last_pushed_qty. No change, no call.
Redis job per provider. Debounce a few seconds — receiving a PO fires dozens of changes at once.
In plain terms
Someone buys on the website. Their card is already charged. You take that message, find the actual gown on your rack, mark it sold, and write it into BridalOp as a real order.
If the gown isn't there any more, you still take the order — the money's gone through — and flag it for a human.
Check the signature. Reject anything unsigned.
Write the raw payload to ecom_order_links, return 200, do the work on the queue. Shopify enforces a 5-second ceiling and will disable a webhook that keeps timing out.
Unique on (tenant_id, provider, external_order_id). A retry is a no-op.
Inside a transaction: SELECT … FOR UPDATE on a qualifying available unit, set status='sold'. This lock is the only thing preventing a double-sell.
Order + lines, payment recorded as captured externally, source tagged so reports can separate web from floor.
No unit available → order created without one, marked needs_attention, boutique notified.
In plain terms
Once a night, ask the website what it thinks it has, compare to what's actually on the rack, and correct it. Messages get lost; this is the safety net.
Pull all stock via fetchAllStock, diff against computed sellable, push corrections, log every discrepancy. Shopify's own docs assume you do this. Also pull orders since last_synced_at to catch any webhook that never arrived.
Section 7 · Build first
In plain terms
Home turf. Both are WordPress plugins with REST APIs you talk to over normal HTTP with a key. No app store, no review, no approval.
| Item | Detail |
|---|---|
| Base | /wp-json/wc/v3 |
| Auth | Consumer key + secret, generated in WooCommerce → Settings → Advanced → REST API. Store in tenant_integrations.credentials (already encrypted). |
| Products | /products, /products/{id}/variations |
| Batch | /products/batch, /products/{id}/variations/batch — use these |
| Webhooks | Native, with automatic retry. Subscribe to order.created and order.updated. |
Documented race condition. Writing stock through the Woo REST API while Woo is simultaneously processing its own orders can set the wrong quantity and oversell. Never treat Woo's counter as truth — it's a display of BridalOp's number. Inbound orders must reserve in BridalOp first (Flow B step 4), and the nightly reconcile is what corrects the drift this causes.
| Item | Detail |
|---|---|
| Base | /wp-json/fluent-cart/v2 |
| Auth | WordPress Application Passwords (HTTP Basic) |
| Coverage | 367+ endpoints — products, orders, customers, subscriptions |
| Webhooks | Confirmed. Full webhook module with HMAC-SHA256 signing, retry logic, logging, async delivery via Action Scheduler. |
| Hooks | 315+ action and filter hooks |
Considerably more mature than expected, and cheap to add once Woo's shape exists — same HTTP client, same site, different paths. No polling fallback needed.
In plain terms
Our plugin is already sitting on her website, right next to the store. So instead of asking Woo or FluentCart to send a message across the internet to BridalOp, the plugin just notices the sale happening beside it and tells BridalOp directly.
Fewer moving parts, nothing to lose in transit, and it works identically whichever store she runs.
Both platforms fire PHP actions in-process. Because the BridalOp plugin runs on the same site, it subscribes directly and posts a single canonical payload to one BridalOp endpoint, authenticated with the token from the connect flow.
// FluentCart — "main hook for payment completion", per their docs
add_action('fluent_cart/order_paid_done', ...); // order, transaction, customer
add_action('fluent_cart/order_refunded', ...); // order, refunded_items, refunded_amount
// WooCommerce
add_action('woocommerce_order_status_changed', ...);
add_action('woocommerce_order_refunded', ...);
What this buys:
manageStock flag, so the plugin can tell whether the cart already adjusted its own stock before reporting.Their HTTP webhook system stays the fallback for anyone who wants BridalOp connected without installing our plugin — but the plugin path is the one to build.
FluentCart fires order_refunded, order_fully_refunded and order_partially_refunded, each carrying refunded_items. Woo has the equivalent. So the open "does a platform refund restock the unit?" question has a clean signal on both — decide the policy, the data's there.
Section 7b · Build first
In plain terms
The boutique installs one plugin. It asks two things: which store are you running — Woo or FluentCart? — and click here to connect your BridalOp account.
After that it does everything on its own. No API keys to copy, nothing to paste, no settings file to edit.
There are two directions to authenticate, which is the part that's easy to miss:
The plugin brokers both in a single click, because it's running inside WordPress as an admin and can mint its own credentials — no human ever handles a key.
Plugin generates the credential BridalOp will need: a WooCommerce consumer key/secret (created programmatically — same thing the WooCommerce settings screen makes), or a scoped WordPress Application Password for FluentCart.
Carrying the site URL, the chosen platform, a signed state nonce, and a return URL. No credentials in the URL — those never travel through a browser address bar or a server log.
Usually she's already logged in, so this is one button. If she has more than one boutique she picks which, and which locations feed the site.
Short-lived, single-use. Still not a token.
WordPress POSTs the code back to BridalOp and receives the long-lived token, handing over its own WP credential in the same call. Both sides are now authenticated, and nothing sensitive ever touched the browser.
Two rules that keep this safe.
Use the exchange-code pattern, not a token in the redirect. Redirect URLs end up in browser history, referrer headers and access logs. A one-time code that's useless five minutes later doesn't matter if it leaks.
The BridalOp token never reaches the browser. The Vue app talks to your own WordPress REST routes; WordPress calls BridalOp server-side. If the token were in the front-end bundle, any admin-area XSS would hand over the boutique's whole account.
In plain terms
WordPress admin pages are just a blank div you're allowed to draw in. Load Vue into it and you can build whatever you want — it doesn't have to look like WordPress at all.
That's exactly what FluentCart and FluentCRM do. Their admin screens are Vue apps that ignore WordPress styling completely, which is why they look the way they do.
Vue 3 + Vite, same stack as the BridalOp app, so components and design tokens carry over. Mount into a single admin page and route client-side.
Settings → BridalOp
├── Connection connect / disconnect, account + location
├── Platform Woo or FluentCart ← the selector
├── Catalogue which products publish, stock buffer
├── Sync last run, queue depth, manual "sync now"
└── Activity recent pushes, orders, conflicts
To actually look like FluentCart rather than like WordPress:
.notice markup at the top of any admin page — Fluent's plugins strip them, and it's most of why their UI feels calm.wp-admin typography or form styles.Detect what's actually installed and let her confirm rather than choosing blind:
| Detected | Behaviour |
|---|---|
| Only WooCommerce active | Preselect Woo, show it as detected, allow override |
| Only FluentCart active | Preselect FluentCart |
| Both active | Force an explicit choice — no default. Syncing into both would double-sell against one pool of stock. |
| Neither | Block connect, link to install either one |
Store the choice on the BridalOp side too, in tenant_integrations.settings — the provider that gets used is decided by the server, not by whatever the plugin last reported.
Section 8 · Build second
In plain terms
Shopify isn't a plugin on someone's site — it's a hosted platform, and your code talks to it as a registered "app."
Two flavours. A custom app is built for one shop, installs in minutes, nobody reviews it. A public app lives in Shopify's App Store, any shop can install it, and Shopify reviews it first.
Start with custom apps. One per boutique. Move to a public app only when enough shops want it to justify the review.
Free. This is where apps are created — as of 2026 that's the only route; the old in-admin custom apps were retired on 1 January.
You'll need write_inventory, read_products, write_products, read_orders. Scopes are permissions the merchant approves.
Same handshake as your Square and QuickBooks integrations — she clicks approve, you receive a token, you store it encrypted. Custom and public apps both use OAuth now.
Reuse the token-refresh pattern from RefreshSquareTokens.
| Woo | Shopify |
|---|---|
| REST, JSON | GraphQL — you describe the fields you want. Different to write, not harder. |
Set stock_quantity on the product | Stock is its own object. Adjust with the inventoryAdjustQuantities mutation against an inventory item at a location. |
| Stock lives on the product | Stock lives per location. Maps well onto BridalOp's location_id. |
| Webhooks retry, timing relaxed | 5-second ack ceiling. Verify HMAC, save, return 200, work on the queue. |
| Reconcile because you should | Reconcile because Shopify's docs tell you to. Drift is expected. |
Shopify separates product → variant → inventory item → inventory level (per location). A "variant" is the sellable thing; the "inventory item" is what stock attaches to; the "inventory level" is how many at one location. You'll store external_variant_id and external_inventory_id — that second one is what stock calls need, and it's the detail most first attempts get wrong.
Section 9
In plain terms
These are the ones that will actually happen. Design for them now rather than discovering them on a Saturday.
fetchOrdersSince sweep is the backstop; without it a paid order silently never exists.error, notify — don't fail silently.Section 10
An afternoon with Section 3. No code. Everything downstream assumes these answers, and changing them later means reworking the flows.
Products and stock out to WooCommerce. No order ingestion yet — the storefront is a catalogue that happens to be accurate.
EcomProvider contract, WooProviderDemoable and genuinely useful on its own — this is what show_on_ecom was added for, and it's the honest answer to Elizabeth's April request.
Webhook → verify → queue → lock a unit → create the order. Plus the exception queue for oversells. This is where real money starts moving, and where the testing effort belongs.
The cheapest phase. A second translator behind the same contract for the push side, plus two extra add_action subscriptions in the plugin for orders and refunds. No new BridalOp endpoint, no new auth, no polling — the webhook question resolved in its favour.
Partner account, OAuth install, GraphQL translator, HMAC webhooks. New platform knowledge, but the contract, tables, flows and exception handling are all already built and proven by then. Custom apps per boutique; public app only if demand justifies review.