Skip to main content
Add a custom field to the vendor onboarding flow and store its value durably, end to end. The vendor store-setup and onboarding surface is a widget zone, 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 rides additional_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
With it present, 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 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
That is the whole wiring step. Your { 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
The hook fires for every seller update, not only your widget’s. Always guard on the field being present (typeof taxId !== "string") so unrelated edits, such as name, address, or status, pass through untouched.
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

  1. Open the vendor portal. The Tax ID prompt renders on the dashboard home and on Settings → Store.
  2. Enter a value and save. The mutation succeeds (toast.success) and hits POST /vendor/sellers/:id.
  3. query.graph({ entity: "seller", fields: ["custom_fields.tax_id"] }) returns the saved value.
  4. Edit an unrelated field, such as store name. The seller update still works and the guard skips the upsert.
  5. Set zone: "not.a.zone" on the widget. bun run lint (tsc) fails against WidgetZoneId.
  6. Delete the widget file. The prompt disappears. The seller route and hook are unaffected.

FAQ

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