seller.setup, that renders the full seller object as its data. That makes onboarding a full extension seam. You 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. These are the same three layers you wire in plain Medusa, kept intact by Mercur.
Three layers, one flow. The panel, a
seller.setup widget, renders and collects. The vendor seller route carries the value through additional_data with no core schema change. A sellersUpdated workflow hook persists it. Each layer is additive: nothing built-in is replaced.What you 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
Widget zones are typed ids the vendor panel generates from its own pages and ships as@mercurjs/vendor/extension-targets. Register them once. create-mercur-app ships this file for you.
apps/vendor/src/extension-targets.d.ts
seller.setup autocompletes and an invalid zone fails tsc instead of silently doing nothing.
Render on the onboarding surface
1
Add the store-setup widget
Drop a file under
src/widgets/. Export the component as the default and a config with zone: "seller.setup.before". The zone hands your component the seller as data.apps/vendor/src/widgets/tax-id-setup.tsx
2
Understand where it renders
seller.setup is hosted in two places, both passing the same seller as data.A single widget file covers both. Multiple
seller.setup.before and .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.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 withWithAdditionalData, so an unknown additional_data object is accepted and forwarded into the workflow untouched.
packages/core/src/api/vendor/sellers/[id]/route.ts
{ tax_id } payload arrives in the workflow as additional_data without a schema change.
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.
1
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
Terminal
2
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 and upsert through the Custom Fields service.apps/api/src/workflows/hooks/seller-tax-id.ts
3
Read it back
The value is now linked to the seller and queryable through Medusa’s remote query.Add
custom_fields.* to the /vendor/sellers/me query config if you want the widget to reflect the saved value on reload.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, such as 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 Store workflows.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 such as tax IDs or 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 and rollback semantics. If your hook throws, the seller update rolls back. Keep slow or best-effort work, such as external syncs, in a subscriber on the emitted
seller.updated event instead.Next steps
Extend a workflow
The full hook and compensation model for Mercur workflows.
Custom Fields module
Durable, queryable storage for the data your hook writes.