store.setup) that renders the full seller object as its data. That makes onboarding a full extension seam: drop a widget to add UI, carry the new value to the API on the built-in seller routes through additional_data, and persist it from a workflow hook — the same three layers you’d wire in plain Medusa, kept intact by Mercur.
Three layers, one flow. The panel (a
store.setup widget) renders and collects. The vendor seller route carries the value through additional_data — no core schema change. A sellersUpdated workflow hook persists it. Each layer is additive: nothing built-in is replaced.What you’ll build
A “Tax ID” prompt on the vendor store-setup surface. The vendor types a VAT number; it ridesadditional_data to POST /vendor/sellers/:id, and a workflow hook stores it durably through the Custom Fields module.
Register the typed targets (once)
Widget zones are typed ids the vendor panel generates from its own pages and ships as@mercurjs/vendor/extension-targets. Register them once (shipped by create-mercur-app):
apps/vendor/src/extension-targets.d.ts
store.setup autocompletes and an invalid zone fails tsc instead of silently doing nothing.
1 — Render on the onboarding surface
Add the store-setup widget
Drop a file under
src/widgets/. Export the component as the default and a config with zone: "store.setup.before". The zone hands your component the seller as data:apps/vendor/src/widgets/tax-id-setup.tsx
Understand where it renders
store.setup is hosted in two places, both passing the same seller as data:| Host | When it shows |
|---|---|
| The vendor shell (above the page outlet) | On top-level routes — the dashboard “home” onboarding banner |
| The store settings detail page | Always, above the store status banner |
store.setup.before / .after widgets stack in registration order.client is your app’s typed SDK. create-mercur-app ships apps/vendor/src/lib/client.ts — a createClient<Routes>() instance. client.vendor.sellers.$id.mutate(...) is the typed POST /vendor/sellers/:id, so the request and response types match the backend route.2 — Carry the value through additional_data
You don’t touch the seller route or its validator. Every vendor and admin seller route already wraps its body with WithAdditionalData, so an unknown additional_data object is accepted and forwarded into the workflow untouched:
packages/core/src/api/vendor/sellers/[id]/route.ts (built-in — for reference)
{ tax_id } payload arrives in the workflow as additional_data without a schema change.
3 — Persist it from a workflow hook
updateSellersWorkflow exposes a sellersUpdated hook that runs after the update with { sellers, additional_data }. Subscribe to it in your Medusa app and persist the value.
Declare a durable field
Register a
Seller custom field so the value gets a real, queryable column (no migration to hand-write):apps/api/medusa-config.ts
Subscribe to the hook
Drop a file under
src/workflows/ in your Medusa app. Medusa imports everything under src/workflows at boot, so registering the hook is just defining it. Read additional_data, upsert through the Custom Fields service:apps/api/src/workflows/hooks/seller-tax-id.ts
How the layers connect
Verify
- Open the vendor portal — the Tax ID prompt renders on the dashboard home and on Settings → Store.
- Enter a value and save — the mutation succeeds (
toast.success) and hitsPOST /vendor/sellers/:id. query.graph({ entity: "seller", fields: ["custom_fields.tax_id"] })returns the saved value.- Edit an unrelated field (store name) — the seller update still works and the guard skips the upsert.
- Set
zone: "not.a.zone"on the widget —bun run lint(tsc) fails againstWidgetZoneId. - Delete the widget file — the prompt disappears; the seller route and hook are unaffected.
FAQ
Why additional_data instead of adding a body field?
Why additional_data instead of adding a body field?
The seller routes’ validators are core-owned.
additional_data is the sanctioned escape hatch — every vendor and admin route wraps its body with WithAdditionalData, so you carry extra context to the workflow hooks without patching the request schema or forking the route.Which seller workflows expose hooks?
Which seller workflows expose hooks?
updateSellersWorkflow exposes sellersUpdated, and createSellerAccountWorkflow (the POST /vendor/sellers onboarding submit) exposes sellerAccountCreated — both carry { additional_data }. Use sellerAccountCreated to capture data at first registration and sellersUpdated for later edits. See the workflow references.Can I store it on the seller's metadata instead?
Can I store it on the seller's metadata instead?
Yes — for a quick, non-queryable value, resolve the seller module in the hook and write to
seller.metadata. Reach for the Custom Fields module when you want a typed, queryable column, which is what most onboarding data (tax IDs, compliance flags) needs.Does the hook run inside the request?
Does the hook run inside the request?
Yes — workflow hooks run as steps of the workflow the route invokes, with the same compensation/rollback semantics. If your hook throws, the seller update rolls back. Keep slow or best-effort work (external syncs) in a subscriber on the emitted
seller.updated event instead.Next steps
Extend a workflow
The full hook + compensation model for Mercur workflows.
Custom Fields module
Durable, queryable storage for the data your hook writes.