Command Palette

Search for a command to run...

Migrate a Provider to the Integration Module

Changing a provider's token or password means editing code and redeploying when those options are passed through . The Integration Module removes that workflow: settings move into Medusa Admin, secrets get encrypted, and the provider receives a ready-made config without a single line of code.

In this guide you'll learn how to migrate an existing provider to , using a real migration of the ApiShip integration plugin as an example.


What is the Integration Module

The Integration Module lets any plugin describe its options and store admins configure them as integrations right in the admin panel: no edits, no redeploy. It generates the admin CRUD API and validation for you, so you don't have to write data models, routes, or forms.

The descriptor is covered in detail in Concepts. Here we only cover what's needed to migrate an existing provider.

Migration

Before the migration, ApiShip's settings lived in : the token, courier-service connections, and the default sender address. 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 SettingsIntegrations section.

Step 1: Describe the descriptor

Next to your existing fulfillment provider, create a new integration provider: . Describe the descriptor in it — the same list of settings as before, but every field is now declared explicitly: its type, whether it's required, whether it's secret, and how it renders in the form.

Structure
1src/providers/
2├── integration-apiship/ # new: settings descriptor for the Integration Module
3└── fulfillment-apiship/ # existing: fulfillment provider
providers/integration-apiship/services/apiship-integration.ts
1import { AbstractIntegrationProvider, defineIntegration, z } from "@gorgo/medusa-integration"
2import { ProviderKeys } from "../../../types"
3import { APISHIP_ICON } from "../../icon.ts"
4
5const descriptor = defineIntegration({
6 category: "fulfillment",
7 displayName: "apiship.name",
8 icon: APISHIP_ICON,
9 preferredLayoutId: "core:two-column",
10 supportsMultipleInstances: true,
11
12 options: {
13 token: {
14 type: "string",
15 required: true,
16 secret: true,
17 control: "secret",
18 label: "apiship.fields.token"
19 },
20 is_test: {
21 type: "boolean",
22 default: false,
23 control: "switch",
24 label: "apiship.fields.isTest"
25 },
26 // Courier-service connections, the sender address, and product parameters are handled by
27 // their own Admin UI (see step 4), but they're still part of the schema:
28 // encrypted and validated on par with the other fields.
29 settings: {
30 type: "json",
31 default: {
32 connections: []
33 // ...
34 },
35 control: "json",
36 label: "apiship.fields.settings"
37 },
38 },
39
40 sections: [
41 {
42 id: "credentials",
43 title: "apiship.sections.credentials",
44 options: ["token", "is_test"]
45 },
46 ],
47
48 testConnection: async ({ options }) => {
49 // A lightweight read-only call to the ApiShip API confirming the token is valid.
50 // Returns { status, message }; throws no exceptions.
51 },
52})
53
54export type ApishipIntegrationOptions = z.infer<typeof descriptor.optionsSchema>
55
56export class ApishipIntegrationProvider extends AbstractIntegrationProvider {
57 static identifier = ProviderKeys.APISHIP
58
59 get descriptor() {
60 return descriptor
61 }
62}
63
64export default ApishipIntegrationProvider
on the option encrypts that field before saving. For nested structures like the list of courier-service connections, use with its own .

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: { id: APISHIP_INTEGRATION_ID },
32 },
33 ],
34 },
35 },
36 // ...
37 ],
38})
Warning: The integration provider's field is set at the top level of the entry: it's exactly what the final instance key is assembled from. If you accidentally place it inside , the provider silently registers under the default (unnamed) instance. There's no error, but the config from the admin panel never reaches the provider, and you'll only discover it after the fact. Note also that the here and on the fulfillment-provider side are set in two independent places, so you have to keep them in sync manually.

Add the secret encryption key to :

.env
INTEGRATION_ENCRYPTION_KEY=supersecret

Step 3: Switch the provider to read through

Previously the fulfillment provider read its settings straight from . Now it gets them from the Integration Module through , passing its and (the same from ). The module returns an already-decrypted and validated config:

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

Step 4: Move your custom Admin UI

ApiShip used to have its own page in the admin panel. Now the module itself renders the integration page with the form built from the descriptor, and your custom UI (courier-service connections, sender address) plugs into it as a widget through an extension zone:

admin/widgets/apiship-integration-main.tsx
1// ...
2import { defineWidgetConfig } from "@medusajs/admin-sdk"
3import type { IntegrationSectionData } from "@gorgo/medusa-integration"
4import { ProviderKeys } from "../../../types"
5
6const ApishipIntegrationWidget = ({ data }: { data: IntegrationSectionData }) => {
7 const providerId = data.providerId // instance key from the module: "int_apiship" or "int_apiship_<instance>"
8
9 const options = await resolveIntegrationOptions<DeepPartial<ApishipOptionsDTO>>({
10 identifier: ProviderKeys.APISHIP,
11 instance_id: providerId,
12 })
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 form on the ApiShip integration page
23 zone: "gorgo.integration.apiship.main.after",
24})
25
26export default ApishipIntegrationWidget

The module hands the widget a ready-made instance key via , so you can pass it straight into your own hooks and requests to your own API routes. As a result, a single page combines both the module's shared form (token, ) and the provider-specific UI (courier-service connections, sender address) instead of two separate screens.


Result

The migration is done. The provider now gets its settings from Medusa Admin with no code changes and no redeploy, stores secrets encrypted, supports multiple instances out of the box, and tests the connection right from the admin panel. Your custom UI stays yours, but now lives on the same page as the module's form.

The full migration code is available in the open repository: .

References

Edited Jul 31, 2026·Edit this page