InPost fulfillment provider for MedusaJS v2 - Paczkomat locker and courier integration via ShipX API
InPost fulfillment provider plugin for MedusaJS v2. Integrates with the InPost ShipX API to support Paczkomat locker and courier delivery.
π§ͺ Beta testers wanted
This plugin has been tested thoroughly against the InPost sandbox, but not yet end-to-end against a production InPost account. If you have production InPost ShipX credentials and are using Medusa v2, I'd love your help validating real locker and courier shipments.
What you'd get:
- Free setup assistance
- Priority bug fixes
Reach out by opening a GitHub issue or emailing the maintainer. Bug reports from production use are especially welcome.
This plugin currently integrates with the Polish InPost ShipX v1 API:
It does not yet use the newer InPost Global API ( on ). Migrating to the Global API requires separate changes to authentication, shipment payloads, label retrieval, tracking identifiers, and endpoint URLs.
npm install medusa-inpost-fulfillment
After installing or upgrading to a version that includes the InPost shipment module, run Medusa migrations:
npx medusa db:migrate
Add the plugin to your :
import { defineConfig } from "@medusajs/framework/utils";const inpostOptions = {// RequiredapiToken: process.env.INPOST_API_TOKEN,organizationId: process.env.INPOST_ORGANIZATION_ID,// Optional β use InPost sandbox environment (default: false)sandbox: true,// Optional β default parcel template for locker shipments// "small" | "medium" | "large" (default: "small")defaultParcelTemplate: "small",// Optional β default shipment label format// "pdf" | "zpl" (default: "pdf")defaultLabelFormat: "pdf",// Optional β return session token lifetime in minutes (default: 60)returnTokenTtlMinutes: 60,// Optional β enables self-service return tickets through InPost Returns API.// Uses the same sandbox flag as ShipX.returns: {clientId: process.env.INPOST_RETURNS_CLIENT_ID,clientSecret: process.env.INPOST_RETURNS_CLIENT_SECRET,defaultParcelSize: "A", // "A" | "B" | "C"magicLinkBaseUrl: "https://store.example.com/returns/session",description: "Please secure the returned items before shipping.",},// Required for courier shipments β sender detailssender: {company_name: "My Store",first_name: "John",last_name: "Doe",email: "shipping@mystore.com",phone: "500100200",address: {street: "Marszalkowska",building_number: "1",city: "Warsaw",post_code: "00-001",country_code: "PL",},},};export default defineConfig({// ...plugins: [// Registers the plugin module, Admin API routes, scheduled jobs, and migrations.{resolve: "medusa-inpost-fulfillment",options: inpostOptions,},],modules: [{resolve: "@medusajs/medusa/fulfillment",options: {providers: [// default provider{resolve: "@medusajs/medusa/fulfillment-manual",id: "manual",},{resolve: "medusa-inpost-fulfillment/providers/inpost",id: "inpost",options: inpostOptions,},],},},],});
The plugin registration must be placed in . It lets Medusa discover the plugin's module, Admin API routes, scheduled jobs, and migrations. Do not place in .
The fulfillment provider registration under must stay in , because it tells Medusa's fulfillment module that InPost is available as a shipping provider.
| Variable | Description |
|---|---|
| Your InPost ShipX API token | |
| Your InPost organization ID |
After installing the plugin, create shipping options in the Medusa admin that use the InPost fulfillment provider. The plugin exposes two services:
| Service ID | Description |
|---|---|
| Paczkomat locker delivery | |
| Courier home delivery |
InPost uses different apps for locker and courier shipments:
Note on sandbox for courier shipments: WebTrucker has no sandbox equivalent, so courier shipments created in sandbox will not appear in any UI. In sandbox, a correctly created courier shipment will simply reach status via the API β that is the sandbox success criterion. Only production courier shipments are visible in WebTrucker.
For Paczkomat locker delivery, the storefront must pass (the Paczkomat machine ID) when adding a shipping method to the cart:
await sdk.store.cart.addShippingMethod(cartId, {option_id: lockerShippingOptionId,data: {target_point: "WAW123", // Paczkomat machine ID},});
You can use the InPost Geowidget to let customers pick a Paczkomat on a map.
For courier delivery, no additional data is needed β the receiver address is taken from the cart's shipping address:
await sdk.store.cart.addShippingMethod(cartId, {option_id: courierShippingOptionId,});
InPost ShipX expects the street and building number as separate address fields. The storefront checkout should collect and save them separately:
| Storefront field | Medusa shipping address field | Required |
|---|---|---|
| First name | Yes | |
| Last name | Yes | |
| Street | Yes | |
| Building number | Yes | |
| Postal code | Yes | |
| City | Yes | |
| Country | Yes | |
| Phone | Yes | |
| Company | No | |
| State / province | No | |
| Apartment / flat number | No |
Do not put the full street address with building number into (for example ). Use for the street name only and for the building number.
If the storefront has a single field, split it into separate and inputs before using this provider.
For courier shipments, the plugin aggregates parcel dimensions from cart item variants (the , , , and fields on product variants). If no dimensions are available, must be provided in fulfillment data. The plugin does not silently fall back to placeholder parcel dimensions.
For locker shipments, a parcel template (, , or ) is used instead, configurable via the option or per-shipment via in fulfillment data.
The plugin includes an module that stores a local record after a ShipX shipment is created successfully. This makes shipment data available outside of and gives the admin API a stable source for list/detail views and status synchronization.
Local shipment history is recorded asynchronously: after Medusa creates an order fulfillment, the plugin listens to the event, reads the InPost shipment data from , and upserts an record.
Stored fields include:
The following admin routes are available:
| Method | Path | Description |
|---|---|---|
| List local InPost shipments | ||
| Retrieve one local shipment | ||
| Refresh shipment data from ShipX | ||
| Download shipment label | ||
| Cancel shipment in ShipX if allowed | ||
| List local InPost returns | ||
| Retrieve one local return with items | ||
| Refresh return data from InPost Returns API | ||
| Download return label PDF when available |
List filters: , , , , , , , , , , , , and .
Return list filters: , , , , , , , , , , , , , , and .
Supported list filter values:
| Query param | Description |
|---|---|
| Searches order ID, fulfillment ID, shipment ID, tracking number, and dispatch order ID | |
| or | |
| or | |
| or | |
| Shipment creation date lower bound, | |
| Shipment creation date upper bound, |
Label download accepts an optional query parameter:
/admin/inpost/shipments/:id/label?format=pdf/admin/inpost/shipments/:id/label?format=zpl
Active shipments are synchronized every 15 minutes by the scheduled job.
Active return tickets are synchronized every 30 minutes by the scheduled job. The job processes local returns with a and skips final statuses: , , , , and .
The plugin adds an Admin UI extension under InPost. The shipments view uses the plugin Admin API and lets store staff:
The plugin also adds an order details widget showing InPost shipments recorded for the current order.
The Admin UI groups shipments and returns under one InPost sidebar entry with internal tabs. The returns list uses server-side pagination and filters persisted in the URL, including status, return method, order ID, customer email, sync errors, and creation date range.
Return details are available from the returns list. The drawer shows returned items, return code, tracking number, expiration date, last sync/error fields, and the raw InPost Returns API response. Store staff can refresh return data from InPost, copy the return code or tracking number, open the Medusa order, and download the return label when the ticket has a .
The InPost Returns REST API exposes return ticket lookup through the list endpoint, not a single-ticket status endpoint. The plugin refreshes one local return by querying across the known InPost return statuses in a date window around the local return creation date, then matching the remote ticket by .
When a fulfillment is cancelled in Medusa, the plugin attempts to cancel the corresponding shipment in InPost.
ShipX cancellation is only possible before the shipment is confirmed. The plugin treats the following statuses as cancellable through the API:
Once a shipment reaches , ShipX rejects cancellation with . In that case, the plugin does not call the cancellation endpoint and lets Medusa cancel the fulfillment locally only. The physical shipment must then be cancelled manually in InPost Manager for locker shipments or WebTrucker for courier shipments.
The Admin UI disables the Cancel shipment action for non-cancellable statuses and shows a tooltip explaining that the shipment must be cancelled manually in InPost.
See the official InPost ShipX cancellation docs: Anulowanie przesyΕki.
The plugin supports retrieving shipment labels as PDF documents through Medusa's fulfillment documents API.
Medusa's default fulfillment-provider return flow does not provide enough data to create a full InPost return shipment automatically (for example, it does not include the customer's selected return locker). For that native Medusa flow, intentionally does not call ShipX.
The plugin includes the first part of a self-service return flow:
| Method | Path | Description |
|---|---|---|
| Looks up an order by and , then prepares a hashed return-session token if the order matches | ||
| Validates a return-session token and returns safe order/item data plus created return-ticket data for the return UI | ||
| Submits a return request from an active return-session token and creates an InPost return ticket if Returns API credentials are configured | ||
| Downloads the return label PDF for an active return session, when the InPost return ticket has a label |
Lookup request body:
{"order_id": "order_...","email": "customer@example.com","return_method": "locker"}
is optional and defaults to .
Submit return request body:
{"token": "return-session-token","items": [{"order_line_item_id": "ordli_...","quantity": 1,"reason": "Wrong size"}]}
The submit endpoint validates that the token is active, the requested items belong to the order, the quantities are returnable, and the same line items have not already been submitted in another active InPost return. It stores the selected items in , creates a return ticket through the InPost Returns REST API, and moves the local return to .
Depending on the InPost Returns Portal settings for your account, the response can include:
At this stage, the self-service returns flow is intended for InPost Returns ticket/code/label returns. Courier pickup for returns is not implemented yet, even though is typed to allow future support.
If the return response contains a , the storefront can download the label through:
curl "http://localhost:9000/store/inpost/returns/RETURN_ID/documents?token=RETURN_SESSION_TOKEN" \-H "x-publishable-api-key: pk_..."
The endpoint validates that the token belongs to the requested return and is still active. If your InPost Returns Portal account is configured for code-only returns, is empty and this endpoint returns a error; show the to the customer instead.
If the InPost Returns API call fails, the local return is marked as and stores the API error. Retrying the same request with the same active token reuses the existing local return items and attempts ticket creation again.
The lookup endpoint always returns the same neutral response, so it does not reveal whether an order exists. The raw token is never stored; only a SHA-256 hash and expiration date are saved in .
If is configured and the lookup data matches an order, the plugin emits an event. The event contains a ready-to-send magic link with the token added as a query parameter.
Event payload:
type InPostReturnSessionCreatedEvent = {email: stringorder_id: stringinpost_return_id: stringreturn_method: "locker" | "point" | "courier" | (string & {})magic_link: stringtoken_expires_at: Date | string | null}
The plugin does not send emails directly. Add a subscriber in your Medusa app and call your email or notification provider from there:
import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"type InPostReturnSessionCreatedEvent = {email: stringorder_id: stringinpost_return_id: stringreturn_method: stringmagic_link: stringtoken_expires_at: string | Date | null}export default async function sendInPostReturnMagicLink({event: { data },container,}: SubscriberArgs<InPostReturnSessionCreatedEvent>) {const logger = container.resolve("logger")logger.info(`Send InPost return magic link for order ${data.order_id} to ${data.email}: ${data.magic_link}`)// Call your email provider here.}export const config: SubscriberConfig = {event: "inpost.return_session_created",}
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| Yes | β | InPost ShipX API token | ||
| Yes | β | InPost organization ID | ||
| No | Use sandbox API environment | |||
| No | Default parcel template for locker shipments | |||
| No | Default label format for shipment documents | |||
| No | Store API return-session token lifetime | |||
| For returns | β | InPost Returns REST API OAuth client ID | ||
| For returns | β | InPost Returns REST API OAuth client secret | ||
| No | account default | Default parcel size for return tickets | ||
| No | β | Absolute storefront URL used to build return-session magic links | ||
| No | account default | Receiver override for return tickets | ||
| No | β | Description shown to the return sender | ||
| For courier | β | Sender details (required for courier shipments) | ||
| For courier | β | Sender company name | ||
| For courier | β | Sender first name | ||
| For courier | β | Sender last name | ||
| Yes | β | Sender email | ||
| Yes | β | Sender phone number | ||
| Yes | β | Sender address |
MIT