Search for a command to run...
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.
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.
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 Settings → Integrations section.
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.
Structure1src/providers/2├── integration-apiship/ # new: settings descriptor for the Integration Module3└── fulfillment-apiship/ # existing: fulfillment provider
providers/integration-apiship/services/apiship-integration.ts1import { AbstractIntegrationProvider, defineIntegration, z } from "@gorgo/medusa-integration"2import { ProviderKeys } from "../../../types"3import { APISHIP_ICON } from "../../icon.ts"45const descriptor = defineIntegration({6 category: "fulfillment",7 displayName: "apiship.name",8 icon: APISHIP_ICON,9 preferredLayoutId: "core:two-column",10 supportsMultipleInstances: true,1112 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 by27 // 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 },3940 sections: [41 {42 id: "credentials",43 title: "apiship.sections.credentials",44 options: ["token", "is_test"]45 },46 ],4748 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})5354export type ApishipIntegrationOptions = z.infer<typeof descriptor.optionsSchema>5556export class ApishipIntegrationProvider extends AbstractIntegrationProvider {57 static identifier = ProviderKeys.APISHIP5859 get descriptor() {60 return descriptor61 }62}6364export default ApishipIntegrationProvider
medusa-config.ts1// ...23const APISHIP_INTEGRATION_ID = "apiship-1"45module.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})
Add the secret encryption key to :
.envINTEGRATION_ENCRYPTION_KEY=supersecret
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.ts1// ...2import { resolveIntegrationOptions } from "@gorgo/medusa-integration"3import { ProviderKeys } from "../../../types"45class ApishipBase extends AbstractFulfillmentProviderService {6 protected instanceId_: string | null78 constructor({ logger }, options?: Record<string, unknown>) {9 super()10 this.logger_ = logger11 this.instanceId_ = (options?.id as string | undefined) ?? null12 }1314 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 }2122 // ...23}
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.tsx1// ...2import { defineWidgetConfig } from "@medusajs/admin-sdk"3import type { IntegrationSectionData } from "@gorgo/medusa-integration"4import { ProviderKeys } from "../../../types"56const ApishipIntegrationWidget = ({ data }: { data: IntegrationSectionData }) => {7 const providerId = data.providerId // instance key from the module: "int_apiship" or "int_apiship_<instance>"89 const options = await resolveIntegrationOptions<DeepPartial<ApishipOptionsDTO>>({10 identifier: ProviderKeys.APISHIP,11 instance_id: providerId,12 })1314 return (15 <>16 // ...your existing components; providerId is passed on into every hook and request17 </>18 )19}2021export const config = defineWidgetConfig({22 // module extension zone: the widget sits below the form on the ApiShip integration page23 zone: "gorgo.integration.apiship.main.after",24})2526export 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.
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: .