Command Palette

Search for a command to run...

Migrate a Medusa Module Provider to the Integration Module

In this guide you'll learn how to migrate an existing Medusa provider to managing its settings through the Integration Module.

What is the Integration Module? 

The Integration Module lets any plugin describe its options as a schema, and store admins configure them in the Admin, with no edits and no redeploy of the Medusa app. It generates the UI, provides a CRUD API and validation, so you don't have to write your own data models, routes, forms, or workflows.


Why migrate

When a provider's options are set in , changing a token, password, or any other setting means editing code, updating environment variables, and redeploying Medusa. The Integration Module removes this routine. It builds a UI for managing the provider's settings from a simple declarative description of their schema, and the store admin manages them in the Admin, with no edits and no redeploy.

If you've already written a UI for your provider, migrating to the Integration Module lets you:

  • Drop excess code. You don't have to maintain data models, routes, UI forms, validation, or workflows.
  • Get a UI that follows Medusa standards, so your provider looks and works like a native integration.
  • Store secrets encrypted rather than in plain text.
  • Test the connection with the external service right from the Admin.
  • Register multiple provider instances, each with its own settings.

Migration example

The code examples in this guide come from a real migration of our ApiShip Fulfillment Module Provider.

Before the migration, ApiShip's settings lived in : the token, courier-service connections, the default sender address, product dimensions, and tax settings. Behind them stood a dedicated Admin UI and a separate API route for each CRUD operation. The Integration Module brings all of this into a single Settings → Integrations section.

The provider's option list is flat, and every option maps to a single control in the generated UI form. That makes the split easy to decide: scalar settings become descriptor options, and only what the catalog has no suitable control for moves to your custom UI, for instance options with a type. For our ApiShip provider that leaves exactly one thing: the list of courier-service connections, because it's an array of JSON objects.

Step 1: Describe the descriptor

Next to your existing fulfillment provider, create a new Integration Module provider in and describe the descriptor in it. It's the same list of settings as before, but every field is now declared explicitly: its type, whether it's required, its default, and how it renders in the UI form.

Structure
1src/providers/
2├── integration-apiship/ # new: settings descriptor for the Integration Module
3└── fulfillment-apiship/ # existing: fulfillment-module provider that reads settings through the Integration Module
providers/integration-apiship/services/apiship-integration.ts
1import { AbstractIntegrationProvider, defineIntegration, z } from "@gorgo/medusa-integration"
2import { ProviderKeys } from "../../../types"
3import { APISHIP_ICON } from "../icon"
4
5const descriptor = defineIntegration({
6 category: "fulfillment",
7 displayName: "apiship.name",
8 description: "apiship.description",
9 icon: APISHIP_ICON,
10 preferredLayoutId: "core:two-column",
11 supportsMultipleInstances: true,
12
13 options: {
14 token: {
15 type: "string",
16 required: true,
17 secret: true,
18 control: "secret",
19 label: "apiship.fields.token"
20 },
21 is_test: {
22 type: "boolean",
23 default: false,
24 control: "switch",
25 label: "apiship.fields.isTest"
26 },
27 is_cod: {
28 type: "boolean",
29 default: false,
30 control: "switch",
31 label: "apiship.fields.isCod"
32 },
33 delivery_cost_vat: {
34 type: "enum",
35 // Enum values are strings; ApiShip's numeric VAT rate is coerced back on read.
36 values: ["-1", "0", "5", "10", "20" /* ... */],
37 default: "-1",
38 control: "select",
39 label: "apiship.fields.deliveryCostVat",
40 // Only sent to ApiShip together with cash on delivery, so hide it otherwise.
41 visibleWhen: {
42 field: "is_cod",
43 equals: true
44 },
45 valueLabels: {
46 "-1": "apiship.vat.noVat",
47 "0": "apiship.vat.vat0",
48 "5": "apiship.vat.vat5",
49 "10": "apiship.vat.vat10",
50 "20": "apiship.vat.vat20"
51 /* ... */
52 },
53 },
54 default_product_length: {
55 type: "number",
56 default: 10,
57 positive: true,
58 control: "number",
59 label: "apiship.fields.defaultProductLength"
60 },
61 // ...width, height, weight
62 sender_country_code: {
63 type: "enum",
64 values: ["RU", "KZ" /* ... */],
65 control: "select",
66 label: "apiship.fields.senderCountryCode"
67 },
68 // ...sender_address_string, sender_contact_name, sender_phone
69 // Courier-service connections are a list of records, and the catalog has no control
70 // for that, so they keep their own Admin UI (see step 4). Still part of the schema:
71 // encrypted and validated on par with the other fields.
72 settings: {
73 type: "json",
74 default: {
75 connections: []
76 },
77 control: "json",
78 label: "apiship.fields.settings"
79 },
80 },
81
82 sections: [
83 {
84 id: "credentials",
85 title: "apiship.sections.credentials",
86 options: ["token", "is_test"]
87 },
88 {
89 id: "payment_and_tax",
90 title: "apiship.sections.paymentAndTax",
91 column: "side",
92 options: ["is_cod", "delivery_cost_vat"]
93 },
94 {
95 id: "default_product_sizes",
96 title: "apiship.sections.defaultProductSizes",
97 column: "side",
98 options: ["default_product_length" /* ...width, height, weight */]
99 },
100 {
101 id: "sender",
102 title: "apiship.sections.sender",
103 options: ["sender_country_code" /* ...address, contact name, phone */]
104 },
105 ],
106
107 testConnection: async ({ options }) => {
108 // A lightweight read-only call to the ApiShip API confirming the token is valid.
109 // Returns { status, message }; throws no exceptions.
110 },
111})
112
113export type ApishipIntegrationOptions = z.infer<typeof descriptor.optionsSchema>
114
115export class ApishipIntegrationProvider extends AbstractIntegrationProvider {
116 static identifier = ProviderKeys.APISHIP
117
118 get descriptor() {
119 return descriptor
120 }
121}
122
123export default ApishipIntegrationProvider

on the option encrypts that field before saving. Keep your defaults in one shared constant and reference it from . The resolver applies them at runtime, the edit form pre-fills the inputs, and the read-only card shows the default as the current value, so all three stay in sync.

Warning: 

Options without a stay absent from the resolved configuration. That's what you want for a field where "empty" is a real state. The ApiShip sender address, for instance, isn't needed to quote a price, only to create an order, so the provider validates it at order time rather than declaring it . Marking such a field makes any configuration that hasn't filled it in incomplete, and the resolver treats an incomplete integration as not configured, so the fulfillment-module provider stops working and throws an error at runtime.

Step 2: Register the provider in

medusa-config.ts
1// ...
2
3const APISHIP_INTEGRATION_ID = "apiship-1"
4
5module.exports = defineConfig({
6 // ...
7 plugins: [
8 {
9 resolve: "@gorgo/medusa-integration",
10 options: {
11 encryptionKey: process.env.INTEGRATION_ENCRYPTION_KEY,
12 providers: [
13 {
14 resolve: "@gorgo/medusa-fulfillment-apiship/providers/integration-apiship",
15 id: APISHIP_INTEGRATION_ID,
16 options: {},
17 },
18 ],
19 },
20 },
21 // ...
22 ],
23 modules: [
24 {
25 resolve: "@medusajs/medusa/fulfillment",
26 options: {
27 providers: [
28 {
29 resolve: "@gorgo/medusa-fulfillment-apiship/providers/fulfillment-apiship",
30 id: "apiship",
31 options: {
32 id: APISHIP_INTEGRATION_ID
33 },
34 },
35 ],
36 },
37 },
38 // ...
39 ],
40})
Warning: 

The Integration Module provider takes an option, from which the final instance key is assembled. The same must be passed to the fulfillment-module provider so it knows which settings to read. If the integration provider supports multiple instances, each has its own , which you can pass to different fulfillment-module providers.

Next, add the secret encryption key to the environment variables. The Integration Module needs it to encrypt secrets before storing them in the database:

.env
INTEGRATION_ENCRYPTION_KEY=supersecret

Step 3: Switch to reading options through

Previously the fulfillment-module provider read its options from . Now it gets them from the Integration Module through , passing its and instance (the same from ). The module returns the option values already decrypted, validated, and with defaults applied:

providers/fulfillment-apiship/core/apiship-base.ts
1// ...
2import { resolveIntegrationOptions } from "@gorgo/medusa-integration"
3import { ProviderKeys } from "../../../types"
4import { ApishipOptions } from "../../integration-apiship/services/apiship-integration"
5
6
7class ApishipBase extends AbstractFulfillmentProviderService {
8 protected instanceId_: string | null
9
10 constructor({ logger }, options?: Record<string, unknown>) {
11 super()
12 this.logger_ = logger
13 this.instanceId_ = (options?.id as string | undefined) ?? null
14 }
15
16 private async getApishipOptions_(): Promise<ApishipOptions> {
17 const options = await resolveIntegrationOptions<ApishipOptions>({
18 identifier: ProviderKeys.APISHIP,
19 instance_id: this.instanceId_,
20 })
21 return options
22 }
23
24 // ...
25}

Step 4: Move your custom Admin UI

ApiShip used to have its own settings page in the Admin. Now the Integration Module builds the integration page from the descriptor: credentials, payment and VAT, product dimensions, and the sender are generated as sections.

The only settings section that stays custom is the courier-service connections list. It plugs into the same page as a widget through the extension zone. The widget receives the instance key from the Integration Module via and passes it into all its hooks and requests:

admin/widgets/apiship-integration-main.tsx
1// ...
2import { defineWidgetConfig } from "@medusajs/admin-sdk"
3import type { IntegrationSectionData } from "@gorgo/medusa-integration"
4import { useApishipOptions } from "../hooks/api/apiship"
5
6const ApishipIntegrationWidget = ({ data }: { data: IntegrationSectionData }) => {
7 // Instance key from the module: "int_apiship" or "int_apiship_<id>".
8 const providerId = data.providerId
9
10 // Read through your own admin route, passing the key along: the widget runs in the
11 // browser, so it cannot resolve options server-side.
12 const { apiship_options } = useApishipOptions(providerId)
13
14 return (
15 <>
16 // ...your existing components; providerId is passed on into every hook and request
17 </>
18 )
19}
20
21export const config = defineWidgetConfig({
22 // module extension zone: the widget sits below the generated sections
23 zone: "gorgo.integration.apiship.main.after",
24})
25
26export default ApishipIntegrationWidget
Warning: 

is server-side only. It runs a workflow in the app container. A widget reads through your own admin route instead, and that route decides what to expose: keep secrets out of the response, and let the Integration Module's own serve everything a descriptor section already covers.

Write option values the same way. Compose the new value of your option and pass it to the Integration Module's . It scopes the write to declared options, merges them with the stored values, validates, encrypts secrets, and emits the event to invalidate the resolver's cache:

workflows/update-apiship-options.ts
1import { upsertIntegrationWorkflow } from "@gorgo/medusa-integration"
2
3// ...compose the next connection list from the stored one, then:
4upsertIntegrationWorkflow.runAsStep({
5 input: transform({ input, settings }, (d) => ({
6 provider_id: d.input.provider_id ?? DEFAULT_APISHIP_PROVIDER_ID,
7 // No `section_id`: a widget submits option ids directly.
8 values: { settings: d.settings },
9 })),
10})

The module hands the widget a ready-made instance key via , so you can pass it straight into your own hooks and requests. As a result, a single ApiShip settings page combines the generated sections and the custom UI.


Result

The migration is done. The fulfillment-module provider now gets its settings from Medusa Admin with no code changes and no redeploy, stores secrets encrypted, supports multiple instances by default, and can test the connection with the external API. Almost all of the old custom UI is gone, because the module generates it from the descriptor, and what's left lives on the same page as the generated sections.

The full code of the ApiShip fulfillment-module provider after migration is available in the repository.

References

Edited Aug 6, 2026·Edit this page