InPost (Koda Plus)
InPost lockers and courier with cash on delivery
InPost by Koda Plus
Ship with InPost from Medusa: Paczkomat parcel lockers and the InPost courier, both also with cash on delivery, on InPost's ShipX API. A fulfillment provider with four shipping options checks the chosen locker at checkout and records it on every fulfillment. The plugin turns that fulfillment into a ShipX shipment from a plan a person reads first, prints the label through the backend, follows the parcel to the locker by webhook with a fallback status pass, and tells the rest of your store about it through events.
Writes are off by default. Without them the plugin already works: fulfillments record the locker and the service, plans show exactly what would be sent, labels and statuses of existing shipments are read. Creating, paying, ordering a courier and canceling in InPost happen only when you allow the writer in the options and arm it in the admin, and then either by a button on the plan or, if you choose, automatically.
Live demo: Medusa admin medusa.koda.plus/app/inpost, signed in to a public demo account by the link itself. The demo runs the plugin in demo mode: sample shipments built from the demo store's own orders, simulated labels, statuses and courier pickups, and zero requests to ShipX.
What it does
- Checkout: four shipping options, Paczkomat, Paczkomat with cash on delivery, courier, courier with cash on delivery. A Paczkomat option needs a locker code of the right shape that exists in InPost's list; a courier option needs a Polish address with a building number; cash on delivery needs a cart in PLN. The storefront picks the locker with InPost's Geowidget or with the plugin's own locker search.
- Fulfillment: creating a fulfillment in Medusa records the option, the locker, the parcel size and the cash on delivery amount (the order's gross total, to the grosz). Nothing is sent to InPost at this moment, so a fulfillment works even when InPost is down.
- Panel in the admin, in the lists a warehouse works with: to create, waiting for pickup, in transit, in the locker, delivered, problems and returns (plus skipped and canceled), each shipment next to its order, with its locker or address, cash on delivery, status and tracking number.
- Plan, then create: the plan of a shipment shows the receiver (read from the order at that moment), the locker or the address, the size and the weight, cash on delivery with its insurance, the reference, the sending method, the sender and the exact ShipX request. Create sends that plan, once.
- Prepaid accounts: when InPost prepares offers instead of buying a shipment (Copy to clipboard
offers_prepared), the plugin buys the offer of the shipment's service, as the plan announced, as soon as the webhook or the status pass sees it (with the shipment writer armed; Pay the offer does it by hand). Until then the Panel shows the shipment as waiting for payment. - Courier pickups for shipments sent with the Copy to clipboard
dispatch_ordermethod: one dispatch order for all confirmed shipments, from the sender's address. - Labels: PDF in A6 or A4 through the backend, so the ShipX token never reaches the browser.
- Tracking: statuses by webhook, with a status pass every 15 minutes as the fallback; the tracking link on inpost.pl; the names of all 53 ShipX statuses in English and Polish.
- Medusa follows InPost (optional writer): the fulfillment is marked shipped with the tracking number and link when InPost has the parcel, and delivered when it is delivered.
- Order widget on the order page: the customer's choice with the locker and a map link, each shipment with its status, tracking, cash on delivery, label, plan, actions and history.
- Locker search in the admin (to fix a wrong locker before sending) and for the storefront (Copy to clipboard
GET /store/inpost/points). - Setup guide and Settings in the admin: the InPost account and the webhook, the sender with the default parcel and label, the writers, the history.
Features
- The right ShipX hosts: Copy to clipboard
https://api-shipx-pl.easypack24.netand, with Copy to clipboardsandbox: true, Copy to clipboardhttps://sandbox-api-shipx-pl.easypack24.net. - Exact cash on delivery: amounts are integer grosze end to end and go to ShipX as decimals with two places; the insurance equals the amount, as InPost requires for couriers. A cart in another currency cannot choose a cash on delivery option.
- Parcel sizes A (small, 8 x 38 x 64 cm), B (medium, 19 x 38 x 64 cm) and C (large, 41 x 38 x 64 cm): a default in the options or in Settings, changeable per shipment before it is sent. Lockers take one parcel up to 25 kg, couriers up to 30 kg; the weight comes from the variants, or Copy to clipboard
defaultWeightKg. - Validation before InPost sees it: locker codes, Polish phone numbers of nine digits (Copy to clipboard
+48and spaces removed), e-mail, the street split from the building and flat number, postal codes like 00-950, the reference of 3 to 100 characters. Every problem is named in the plan with what to fix. - Created exactly once: one row per fulfillment (unique index), an atomic claim with a lease before the request, and an unclear answer (a timeout, a 5xx) is never retried blindly: the shipment is looked up in ShipX by its receiver and reference first, and adopted when it exists.
- No second parcel for orders shipped elsewhere: Copy to clipboard
skipMetadataKeysnames the order metadata keys another system writes when it ships an order; such orders are skipped, and a key holding Copy to clipboard{ shipment_id }is tracked instead. - Webhook with a fallback: the webhook only says which shipment changed; the plugin reads the shipment from ShipX itself. Every change is applied once (compare and set), whether the webhook or the status pass sees it first.
- Events other plugins build on: Copy to clipboard
inpost.shipment.created, Copy to clipboardinpost.shipment.status_changed, Copy to clipboardinpost.shipment.delivered, a documented payload without personal data. - Never breaks the boot: missing or broken options are listed in the admin; without a token the plugin records fulfillments and says "Not configured"; demo mode runs only with Copy to clipboard
demo: true. - Demo mode from the store's own orders, through the same plans, writers and events, with zero requests to InPost.
- Admin in English and Polish, with "Copy prompt" for an AI agent and help on the Koda Plus Discord.
Requirements
- Medusa 2.12 or newer and Node.js 20+ (see Compatibility).
- An InPost business account in InPost Manager with ShipX API access: the API token and the organization id. For tests, an account in the ShipX sandbox.
- Shipping to Poland. Cash on delivery needs orders in PLN and a payment method that does not take the money online (Medusa's manual provider, or one for payment on delivery, listed in Copy to clipboard
codPaymentProviders). - Only one InPost plugin per store. Do not install this plugin next to another InPost fulfillment plugin for Medusa: both would answer the same orders and could send two parcels for one. Remove the other one first. This plugin keeps its data in its own tables (Copy to clipboard
inpost_parcel, Copy to clipboardinpost_parcel_event, Copy to clipboardinpost_setting) and never reads or writes the tables of another plugin.
Installation
1npm install @koda-plus/medusa-plugin-inpost
Yarn and pnpm work the same way: Copy to clipboardyarn add @koda-plus/medusa-plugin-inpost or Copy to clipboardpnpm add @koda-plus/medusa-plugin-inpost.
Configuration
The plugin is registered twice with the same options object: as a plugin (its data module, the admin page, the routes, the jobs and subscribers) and as a provider of Medusa's fulfillment module (the shipping options and the checkout checks). Keep the object in one constant:
1234567891011121314151617181920import { defineConfig } from "@medusajs/framework/utils"const inpost = {apiToken: process.env.INPOST_API_TOKEN,organizationId: process.env.INPOST_ORGANIZATION_ID,webhookSecret: process.env.INPOST_WEBHOOK_SECRET,// sandbox: true,// Writes: off unless allowed here AND armed in the admin (allowed by default in demo mode, simulated).// shipmentWriter: true, // create, pay a prepaid offer, order a pickup, cancel// fulfillmentStatusWriter: true, // mark Medusa fulfillments shipped and delivered// demo: true, // sample shipments from your orders, nothing sent to InPost}module.exports = defineConfig({// ...plugins: [{ resolve: "@koda-plus/medusa-plugin-inpost", options: inpost }],modules: [{resolve: "@medusajs/medusa/fulfillment",options: {
Keep the id Copy to clipboardinpost: the provider is then Copy to clipboardinpost_inpost, the id the earlier InPost providers of Koda Plus used, so shipping options created with them keep working. If your Copy to clipboardmedusa-config.ts already has the fulfillment module, add the provider to its Copy to clipboardproviders instead of a second entry.
An app that carries the plugin's code in its own Copy to clipboardsrc/ instead of the package registers the same two entries by path: Copy to clipboard{ resolve: "./src/modules/inpost", options: inpost } in Copy to clipboardmodules, and Copy to clipboard{ resolve: "./src/providers/inpost", id: "inpost", options: inpost } among the fulfillment providers.
Set the variables in Copy to clipboard.env:
123INPOST_API_TOKEN=... # InPost Manager: My account, API, ShipXINPOST_ORGANIZATION_ID=... # shown next to the tokenINPOST_WEBHOOK_SECRET=... # openssl rand -hex 24
Run the migrations, then open InPost in the admin sidebar:
1npx medusa db:migrate
Then create the shipping options: Settings, Locations and Shipping, the shipping zone of your location, Create option, the InPost provider and one of its four fulfillment options (ids Copy to clipboardinpost-paczkomat, Copy to clipboardinpost-paczkomat-cod, Copy to clipboardinpost-kurier, Copy to clipboardinpost-kurier-cod). The price is the shipping option's own: the provider does not calculate prices.
Options
Every option is optional. Numbers and booleans may come as strings from environment variables, lists as comma separated strings; broken values fall back to the defaults and are listed in the admin. Missing options never break the boot.
Connection:
- Copy to clipboard
apiToken(default none): the ShipX API token from InPost Manager (My account, API). Only in the Copy to clipboardAuthorizationheader of the server's requests; masked in every log, error and admin screen. The Geowidget token of the same page is another, public token: ShipX answers it with 401. - Copy to clipboard
organizationId(default none): the organization id shown next to the token, digits only. - Copy to clipboard
sandbox(default Copy to clipboardfalse): the ShipX sandbox instead of production, for the API, the points and the Geowidget. - Copy to clipboard
demo(default Copy to clipboardfalse): sample shipments built from the store's orders; nothing is sent to InPost. Only Copy to clipboardtrueturns it on: a missing token never does, so a variable lost on production cannot turn real fulfillments into sample rows. Without a token the plugin records fulfillments, says "Not configured" in the admin and sends nothing.
Writes:
- Copy to clipboard
shipmentWriter(default Copy to clipboardfalse; in demo mode Copy to clipboardtrue, acting on the simulation only): allows the ShipX writes: create a shipment, buy the offer of a prepaid account, order a courier pickup, cancel a shipment ShipX still allows to cancel. The admin cannot arm it while this is not Copy to clipboardtrue. - Copy to clipboard
fulfillmentStatusWriter(default Copy to clipboardfalse; in demo mode Copy to clipboardtrue, simulated): allows marking Medusa fulfillments shipped and delivered. - Copy to clipboard
autoCreate(default Copy to clipboardfalse): with the shipment writer armed, create the shipment as soon as its fulfillment is created, from its plan, without a click, and let the status pass order the courier pickup of confirmed Copy to clipboarddispatch_ordershipments. Plans with problems wait for a person either way. - Copy to clipboard
autoCreateMaxAgeHours(default Copy to clipboard48, 1 to 720): the status pass creates only fulfillments recorded after the shipment writer was armed and within these hours. A backlog (fulfillments shipped by hand while the writer was off) is never turned into new, paid labels. - Copy to clipboard
codPaymentProviders(default Copy to clipboard["pp_system_default"]): the payment providers that mean "pay at the door". When an order has a payment by any other provider (a card, BLIK, Przelewy24), its cash on delivery plan stops with Copy to clipboardcod_paid_online: the buyer never pays twice.
Shipments:
- Copy to clipboard
defaultParcelSize(default Copy to clipboard"medium"): Copy to clipboard"small", Copy to clipboard"medium"or Copy to clipboard"large"(or Copy to clipboard"A", Copy to clipboard"B", Copy to clipboard"C"). A value saved in Settings wins over the option. - Copy to clipboard
labelFormat(default Copy to clipboard"A6"): Copy to clipboard"A6"or Copy to clipboard"A4". Courier labels exist only as A6. Settings may override it. - Copy to clipboard
sender(default none): Copy to clipboard{ companyName, firstName, lastName, email, phone, street, buildingNumber, flatNumber, city, postCode }. Without it InPost uses your organization's data from InPost Manager, which InPost recommends. The sender goes into a shipment only with an e-mail and a phone; a courier pickup needs the full address. Settings may override it. - Copy to clipboard
sendingMethod(default: your account's own): ShipX Copy to clipboardsending_methodper kind, like Copy to clipboard{ locker: "parcel_locker", courier: "dispatch_order" }. Values: Copy to clipboardparcel_locker, Copy to clipboardpok, Copy to clipboardpop, Copy to clipboardcourier_pok, Copy to clipboardbranch, Copy to clipboarddispatch_order, Copy to clipboardany_point. - Copy to clipboard
dropoffPoint(default none): the locker you drop parcels off at, sent with the Copy to clipboardparcel_lockersending method. - Copy to clipboard
referenceTemplate(default Copy to clipboard"Order {display_id}"): the shipment reference, with Copy to clipboard{display_id}, Copy to clipboard{order_id}and Copy to clipboard{fulfillment_id}. The second parcel of an order gets Copy to clipboard/2, the third Copy to clipboard/3. ShipX takes 3 to 100 characters. - Copy to clipboard
weightUnit(default Copy to clipboard"g"): the unit of variant weights in Medusa, Copy to clipboard"g"or Copy to clipboard"kg". - Copy to clipboard
defaultWeightKg(default Copy to clipboard1): the parcel weight when the variants have none (the plan says it is estimated). - Copy to clipboard
skipMetadataKeys(default none): order metadata keys that mean "shipped outside Medusa". An order carrying any of them gets no shipment; when the key holds Copy to clipboard{ shipment_id }(or Copy to clipboard{ shipment_ids }), that ShipX shipment is tracked instead. A store whose order management system records its InPost shipments under Copy to clipboardinpost_shipmentsets Copy to clipboard["inpost_shipment"].
Checkout:
- Copy to clipboard
verifyLockers(default Copy to clipboardtrue): check at checkout that the chosen locker exists, in InPost's public points list (cached; when the list does not answer, the checkout goes on).
Tracking and limits:
- Copy to clipboard
webhookSecret(default none, so no webhook): the secret part of the webhook URL, at least 24 lowercase letters and digits (Copy to clipboardopenssl rand -hex 24). A value of another shape keeps the webhook closed and is listed in the admin. - Copy to clipboard
pollEnabled(default Copy to clipboardtrue): the status pass every 15 minutes, the fallback of the webhook. - Copy to clipboard
pollMaxAgeDays(default Copy to clipboard30, from 1 to 365): shipments older than this are no longer read. - Copy to clipboard
requestsPerMinute(default Copy to clipboard60, at most 600): the plugin's own limit towards ShipX. - Copy to clipboard
timeoutMs(default Copy to clipboard20000, from 3000 to 120000): one ShipX request. - Copy to clipboard
pointsPerMinute(default Copy to clipboard60): searches one IP may send to Copy to clipboardGET /store/inpost/pointsper minute.
Presentation:
- Copy to clipboard
references(default none): stores running the integration, shown in the admin ("Running in stores built by Koda Plus"): Copy to clipboard[{ name, url, icon?, description?, soon? }], texts plain or Copy to clipboard{ en, pl }. Copy to clipboardsoon: truemarks a store that starts on Medusa soon: a "Soon" badge, no link, Copy to clipboardurloptional. Entries without a name, or live entries without an https address, are dropped.
Storefront
When the customer picks a Paczkomat option, show InPost's Geowidget (v5) and send the chosen point with the shipping method. Generate the Geowidget token in InPost Manager (My account, API, Geowidget) for your storefront's domain:
1234567891011121314151617181920<link rel="stylesheet" href="https://geowidget.inpost.pl/inpost-geowidget.css" /><script src="https://geowidget.inpost.pl/inpost-geowidget.js" defer></script><!-- config="parcelCollectPayment" for the cash on delivery option --><inpost-geowidget token="YOUR_GEOWIDGET_TOKEN" language="pl" config="parcelCollect" onpoint="onInpostPoint"></inpost-geowidget><script>async function onInpostPoint(point) {await sdk.store.cart.addShippingMethod(cartId, {option_id: shippingOptionId,data: {machine_id: point.name, // the locker code, like "KRA01M"machine_name: point.name,machine_address: {line1: point.address.line1,line2: point.address.line2,city: point.address_details.city,post_code: point.address_details.post_code,},},
With Copy to clipboardsandbox: true, load the sandbox Geowidget from Copy to clipboardhttps://sandbox-easy-geowidget-sdk.easypack24.net with a sandbox token.
The shipping method data fields:
- Copy to clipboard
machine_id(required for the Paczkomat options): the locker code. Copy to clipboardtarget_pointand Copy to clipboardlocker_codeare accepted as well. - Copy to clipboard
machine_name(optional): what the storefront shows, kept on the order. - Copy to clipboard
machine_address(optional): Copy to clipboard{ line1, line2, city, post_code }, shown in the admin and the order widget. - Copy to clipboard
inpost_parcel_size(optional): Copy to clipboard"small", Copy to clipboard"medium"or Copy to clipboard"large", when the storefront knows better than the default.
The courier options need no data: the order's shipping address is the delivery address. At checkout the provider answers a Paczkomat option without a valid locker, a locker InPost does not list, a courier option without a full Polish address and cash on delivery outside PLN with a 400 and a message the storefront can show.
The provider stores the method data in one shape, with the earlier fields first (Copy to clipboardtype: "paczkomat" | "kurier", Copy to clipboardcod, Copy to clipboardmachine_id, Copy to clipboardmachine_name, Copy to clipboardmachine_address), then Copy to clipboardinpost_option, Copy to clipboardinpost_kind, Copy to clipboardinpost_service and Copy to clipboardinpost_parcel_size. The shipping options' own data carries Copy to clipboardid, Copy to clipboardname, Copy to clipboardkind, Copy to clipboardcod, Copy to clipboardservice and the earlier Copy to clipboardtype, so a storefront can tell a locker option from a courier one.
Store API
Copy to clipboardGET /store/inpost/points finds parcel lockers for a storefront without the Geowidget. Like every Copy to clipboard/store route it needs the publishable API key (Copy to clipboardx-publishable-api-key).
- Copy to clipboard
q: a locker code (Copy to clipboardKRA01M), a postal code (Copy to clipboard30-415, nearest first) or a city (Copy to clipboardKrakĂłw). - Copy to clipboard
latand Copy to clipboardlnginstead of Copy to clipboardq: the nearest lockers to a place in Poland. - Copy to clipboard
cod=true: only points that take card payments, for the cash on delivery options. - Copy to clipboard
type=any: every kind of point, not only parcel lockers. - Copy to clipboard
limit(default 10, at most 25).
The answer is Copy to clipboard{ points, mode, demo }. Each point has Copy to clipboardcode, Copy to clipboardname, Copy to clipboardtype, Copy to clipboardstatus, Copy to clipboardaddress (Copy to clipboardline1, Copy to clipboardline2, Copy to clipboardstreet, Copy to clipboardbuilding_number, Copy to clipboardcity, Copy to clipboardpost_code, Copy to clipboardprovince), Copy to clipboardlocation (Copy to clipboardlat, Copy to clipboardlng), Copy to clipboarddescription, Copy to clipboardopening_hours, Copy to clipboardis_24_7, Copy to clipboardpayment_available and Copy to clipboarddistance in metres when the search had a place. Store the chosen one as Copy to clipboardmachine_id: code, Copy to clipboardmachine_name: code, Copy to clipboardmachine_address: { line1, line2, city, post_code }.
Only InPost's public points data is returned. Answers are cached for ten minutes on the server and may be cached for five in the browser. Each client address (as Medusa resolves it behind your proxy) may send Copy to clipboardpointsPerMinute searches a minute; above that the route answers 429 with Copy to clipboardRetry-After. A search that is neither a code, a postal code, a city nor a place in Poland answers 400; when InPost's list does not answer, 502. Demo mode answers with the demo lockers.
Webhook
ShipX calls Copy to clipboardhttps://<your backend>/hooks/inpost/<webhookSecret>. Paste that URL in InPost Manager (My account, API, webhook); Settings in the admin shows it ready to copy. InPost checks the URL with a GET before saving it.
- The secret in the path is the only key. It is compared in constant time; a wrong secret, or no Copy to clipboard
webhookSecretin the options, answers 404, as if the route did not exist. ShipX signs nothing, so the plugin trusts nothing else either. - The body is a doorbell. A delivery is answered 200 at once; then the plugin reads the named shipment from ShipX with its own token and applies what ShipX says. A forged call changes nothing.
- Once. Each delivery is recorded under its own key (event, shipment, status, time), so a repeated delivery stops there; a status already applied by the status pass is not applied again, and its events are not emitted again.
- Events handled: Copy to clipboard
shipment_confirmed, Copy to clipboardshipment_status_changedand Copy to clipboardoffers_prepared; anything else is answered 200 and ignored. Deliveries for another organization and, in demo mode, all deliveries are ignored. - InPost sends webhooks from Copy to clipboard
91.216.25.0/24, if your firewall allows only known addresses. - Medusa puts no authentication on Copy to clipboard
/hooks, so no middleware is needed.
The fallback: the job Copy to clipboardinpost-sync-shipments runs every 15 minutes (minutes 7, 22, 37 and 52). It reads open shipments again (each at most every 25 minutes, younger than Copy to clipboardpollMaxAgeDays), looks up creates without a clear answer, buys prepaid offers and catches up the fulfillment status writer. A store without the webhook still gets every status, only later.
Events
All three carry the same payload, Copy to clipboardInpostShipmentEvent. The shape is a contract: fields and events may be added, never changed.
- Copy to clipboard
inpost.shipment.created: once per shipment, when ShipX accepts it, or when the plugin adopts a shipment it found after an unclear answer. - Copy to clipboard
inpost.shipment.status_changed: once per change of the ShipX status, from the webhook or the status pass. - Copy to clipboard
inpost.shipment.delivered: once, when the status becomes Copy to clipboarddelivered, after its Copy to clipboardstatus_changed.
12345678910111213141516interface InpostShipmentEvent {id: string // the plugin's row (inpar_...)order_id: stringfulfillment_id: string | nullshipment_id: string // the ShipX shipment idtracking_number: string | nulltracking_url: string | null // https://inpost.pl/sledzenie-przesylek?number=...status: string // the ShipX status, like "ready_to_pickup"previous_status: string | nullstage: "preparing" | "ready" | "in_transit" | "in_locker" | "delivered" | "problem" | "returned" | "canceled"service: "inpost_locker_standard" | "inpost_courier_standard"kind: "locker" | "courier"locker: { code: string; name: string | null; address: { line1, line2, city, post_code } | null } | nullcod: { amount: string; currency: "PLN" } | null // exact, like "199.99"demo: boolean // a simulated shipment of demo mode}
The receiver's name, phone, e-mail and address are never in an event. A subscriber that writes to the outside world should skip Copy to clipboarddemo: true:
12345678910import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"import type { InpostShipmentEvent } from "@koda-plus/medusa-plugin-inpost/modules/inpost"export default async function parcelInLocker({ event, container }: SubscriberArgs<InpostShipmentEvent>) {const shipment = event.dataif (shipment.demo || shipment.stage !== "in_locker") return// "Your parcel is waiting in Paczkomat " + shipment.locker?.code}export const config: SubscriberConfig = { event: "inpost.shipment.status_changed" }
With the fulfillment status writer armed, Medusa's own Copy to clipboardshipment.created follows when InPost has the parcel, so your "order shipped" e-mail carries the InPost tracking link.
Setup in brief
The admin has the full guide (InPost, Setup guide, or Copy to clipboard/app/inpost?view=guide), each step with its state from your store.
- Get the ShipX token and the organization id in InPost Manager (manager.paczkomaty.pl): complete the company and invoice data, then My account, API.
- Try the sandbox first (sandbox-manager.paczkomaty.pl) with Copy to clipboard
sandbox: true. Sandbox courier shipments reach Copy to clipboardconfirmedbut never show in WebTrucker, and public tracking does not work there. - Install the plugin and its provider as above, with the same options object and the id Copy to clipboard
inpost. - Run Copy to clipboard
npx medusa db:migrate. - Create the shipping options for the four fulfillment options and set their prices.
- Let the storefront send the chosen locker (Geowidget or Copy to clipboard
GET /store/inpost/points) as Copy to clipboardmachine_id. - Connect the webhook: set Copy to clipboard
webhookSecret, restart, paste the URL in InPost Manager. - Settings: the sender (or InPost's organization data), the default parcel size and the label size.
- Read a plan, then arm the shipment writer: fulfill a test order, open its plan, read every line, allow Copy to clipboard
shipmentWriter, arm it in Settings and create the first shipment. Turn on Copy to clipboardautoCreatelater if you want shipments without a click. - Optionally arm the fulfillment status writer, so Medusa marks fulfillments shipped and delivered.
- Go live: the production token, the sandbox off, the go-live checklist.
Write safety
- Two switches per writer. The option (Copy to clipboard
shipmentWriter, Copy to clipboardfulfillmentStatusWriter) is the hard one: Copy to clipboardfalsewins and the admin cannot override it. The switch in Settings is the runtime one, stored with who flipped it and when. Demo and live toggles are separate. - Plan first. A person creates a shipment from its plan and sends the plan's hash with the click; when the order, the settings or the shipment changed since the plan was read, the create is refused and the new plan is shown. A plan with problems sends nothing.
- Auto or button. With Copy to clipboard
autoCreate, an armed writer creates the shipment when its fulfillment is created, from a plan without problems, and the status pass orders the courier pickups; otherwise every shipment and every pickup waits for its button. The pass never takes a backlog (Copy to clipboardautoCreateMaxAgeHours), and a fulfillment Medusa already marks shipped or delivered gets no shipment. - Cash on delivery once per order. The amount is what is still due (the order total minus what was captured), on the first live cash on delivery parcel of the order; further parcels of the same order go without it. An order paid online (Copy to clipboard
codPaymentProviders) gets no cash on delivery at all. - One shipment per fulfillment: a unique row before anything goes out, an atomic claim with a lease, and the Copy to clipboard
unknownstate after an unclear answer, resolved by a lookup in ShipX (by the receiver and the reference) before anything is sent again. Only a lookup a quarter of an hour later that still finds nothing lets a person try again. - Cancel only while ShipX allows it: the status is read first, and only Copy to clipboard
created, Copy to clipboardoffers_preparedand Copy to clipboardoffer_selectedcan be canceled. Later, the admin says to cancel in InPost Manager (lockers) or WebTrucker (courier). Shipments the plugin did not create (linked, or found under Copy to clipboardskipMetadataKeys) are only tracked, never canceled or paid. - Prepaid offers once: one purchase per shipment at a time (an atomic claim), whoever asks first, the webhook, the status pass or a person.
- One courier pickup per shipment: shipments are claimed before the dispatch order, so none is in two pickups.
- Medusa marks once: shipped and delivered are claimed on the row before Medusa's flow runs and released with the error if it fails; a fulfillment a person already marked is only noted.
Security and data
- The token stays on the server: only in the Copy to clipboard
Authorizationheader towards the ShipX host of the configured environment; masked in logs, errors, the history and the admin, which only knows whether it is set. Labels are fetched by the backend and streamed to the admin. - The webhook trusts only its secret, compared in constant time, and re-reads every shipment from ShipX.
- Personal data stays where it lives: the receiver's name, phone, e-mail and address are read from the order when a plan is built and sent to ShipX only when a shipment is created. The plugin's tables keep the order and fulfillment ids, the option, the locker (public data), the parcel, the cash on delivery amount, the reference and the statuses; problems name what is wrong, never the value. Events carry no personal data.
- Rate limited towards ShipX (Copy to clipboard
requestsPerMinute), with retries only for reads (network errors, 5xx, 429 with Copy to clipboardRetry-After); writes are sent once. - History: status passes, webhook deliveries, actions and statuses are kept for 120 days.
- Admin routes sit behind Medusa's admin authentication. Writes to Copy to clipboard
/admin/inpost/*take a JSON body or the Copy to clipboardx-koda-requestheader and answer 415 otherwise: Medusa's session cookie is Copy to clipboardSameSite=Nonein production, so a form on another site could otherwise post with it (shipments, offers and pickups cost money). The plugin's admin sends both. - Shopper metadata is not state. A shopper may not set Copy to clipboard
inpost_*keys or the keys of Copy to clipboardskipMetadataKeyson a cart or the account through the Store API (400 Copy to clipboardreserved_metadata_key); those are written by the store's own systems through the admin API. - Reads do not write: Copy to clipboard
GETroutes only read; demo samples are built by Copy to clipboardPOST /admin/inpost/demo/seedand by the status pass. - Copy to clipboard
GET /store/inpost/pointsserves public locker data only, limited per client address as Medusa resolves it behind your proxy (Copy to clipboardtrust proxy), never by a header the client sends. - Errors: unexpected server errors answer a plain sentence; the details stay in the server log, masked.
What this plugin does not do (yet)
- It does not create returns yet: self-service returns through InPost's Returns API (Szybkie Zwroty) and return labels are planned; until then use InPost Manager. A parcel returned to the sender shows under Problems and returns.
- It does not calculate shipping prices from InPost's price list: the shipping options' own prices apply.
- It does not send several parcels in one shipment: one shipment carries one parcel (split the items into more fulfillments for more parcels).
- It does not change a shipment after it is sent (the locker, the size, the receiver): InPost allows that only in InPost Manager.
- It uses two ShipX services, Copy to clipboard
inpost_locker_standardand Copy to clipboardinpost_courier_standard: no express, weekend, Allegro or international services. - It does not notify the customer itself: Medusa's Copy to clipboard
shipment.createdand the plugin's events let your e-mail plugin do that.
Admin API
Every route is behind Medusa's admin authentication. Writes answer 409 while the shipment writer is not allowed and armed.
- Copy to clipboard
GET /admin/inpost: the status (mode, configuration, counts, writers, settings, webhook, last pass). No call to ShipX. - Copy to clipboard
GET /admin/inpost/parcels?filter=all|to_create|waiting|in_transit|in_locker|delivered|problems|canceled|skipped&q=&offset=0&limit=20: the Panel lists. - Copy to clipboard
GET /admin/inpost/parcels/:id, Copy to clipboardGET /admin/inpost/parcels/:id/plan: a shipment with its history; its plan with the hash. - Copy to clipboard
POST /admin/inpost/parcels/:id/createCopy to clipboard{ planHash }: creates the shipment from the plan that was read. - Copy to clipboard
POST /admin/inpost/parcels/:id/cancel, Copy to clipboard.../buy, Copy to clipboard.../refresh, Copy to clipboard.../lookup: cancel while ShipX allows it, buy the prepaid offer, read the status, look up an unclear create. - Copy to clipboard
POST /admin/inpost/parcels/:id/lockerCopy to clipboard{ code }, Copy to clipboard.../sizeCopy to clipboard{ size }, Copy to clipboard.../retry, Copy to clipboard.../skip, Copy to clipboard.../linkCopy to clipboard{ shipmentId }: edits before sending, try a failed create again, mark as handled outside, link a shipment created elsewhere. - Copy to clipboard
GET /admin/inpost/parcels/:id/label?size=A6|A4&download=1: the label PDF. - Copy to clipboard
POST /admin/inpost/pickupCopy to clipboard{ ids? }: one courier pickup for the confirmed Copy to clipboarddispatch_ordershipments. - Copy to clipboard
GET /admin/inpost/orders/:orderId: the order widget. - Copy to clipboard
GET /admin/inpost/points?q=: the locker search. - Copy to clipboard
POST /admin/inpost/settings: the sender, the default parcel size and the label size of the current mode. - Copy to clipboard
POST /admin/inpost/writersCopy to clipboard{ writer: "shipment" | "fulfillmentStatus", on }: arms or disarms a writer. - Copy to clipboard
POST /admin/inpost/sync: a status pass now (202). Copy to clipboardPOST /admin/inpost/check: checks the token and the organization. - Copy to clipboard
GET /admin/inpost/events: the history. Copy to clipboardPOST /admin/inpost/demo/seed: demo mode only, builds the sample shipments once. Copy to clipboardPOST /admin/inpost/demo/reset: demo mode only, starts the simulation over. - Copy to clipboard
GET /admin/inpost/integration, Copy to clipboard/integration/summary, Copy to clipboard/integration/attention: the koda.integration/1 contract, see below.
Works with Koda Plus hosts
An app that shows every Koda Plus plugin in one place (like medusa.koda.plus) reads InPost through the shared contract Copy to clipboardkoda.integration/1 and never needs to know its tables:
- Copy to clipboard
GET /admin/inpost/integration: the manifest (mode, configuration, writers armed, widgets). - Copy to clipboard
GET /admin/inpost/integration/summary?entity=order&id=order_...(or Copy to clipboardids=, up to 50): one line per order, the worst parcel speaking (not created, a delivery problem or a return is red; to ship, an offer to pay or a canceled shipment with an open fulfillment is orange; on the way is blue; delivered is green), with the facts Copy to clipboarddelivery(Paczkomat or courier, tracking number and link) and Copy to clipboardpayment(cash on delivery with the amount). Facts come from the plugin's rows and the shipping method data the provider validated, never from order metadata. - Copy to clipboard
GET /admin/inpost/integration/attention?scope=orders: counters Copy to clipboardparcels_failed, Copy to clipboardparcels_problems, Copy to clipboardparcels_to_create, Copy to clipboardparcels_awaiting_payment, each with a link to its filtered list (Copy to clipboard/inpost?filter=...). - The order card registers as Copy to clipboard
inpost.orderfor the zone Copy to clipboardorder.details; a host that claims the zone shows it as a tab (Copy to clipboardembedded) and Medusa's own spot stays empty. Without a host nothing changes. - The page opens on deep links: Copy to clipboard
/inpost?filter=to_create, Copy to clipboard/inpost?q=1042.
Public API
What other code may import: Copy to clipboard@koda-plus/medusa-plugin-inpost/workflows, Copy to clipboard@koda-plus/medusa-plugin-inpost/modules/inpost (the module, its options and event types), Copy to clipboard@koda-plus/medusa-plugin-inpost/providers/inpost (the fulfillment provider) and Copy to clipboard/admin. Type declarations ship with the package. Every other path is internal and may change in any release.
Uninstall
- Move the shipping options off the InPost provider (Copy to clipboard
inpost_inpost) or delete them, so no new fulfillment uses it. - Remove the webhook URL in InPost Manager (My account, API, Webhook).
- Remove the plugin and the provider from Copy to clipboard
medusa-config.tsand the package from Copy to clipboardpackage.json. - The tables Copy to clipboard
inpost_parcel, Copy to clipboardinpost_parcel_eventand Copy to clipboardinpost_settingstay with your data; drop them by hand when you no longer need the history.
Compatibility
Medusa 2.12 to 2.21 (peer range Copy to clipboard^2.12.0) and Node.js 20+. The release of each version runs the smoke test on a fresh Medusa 2.12.6 and 2.21.2 app: migrations, build, start and the admin pages. Developing the plugin needs Node.js 22.6+ (the tests run the TypeScript sources directly).
Workflows
1234567import {createInpostShipmentWorkflow,refreshInpostShipmentWorkflow,syncInpostShipmentsWorkflow,} from "@koda-plus/medusa-plugin-inpost/workflows"await syncInpostShipmentsWorkflow(container).run({ input: { trigger: "manual" } })
The same rules as the admin: creating needs the shipment writer allowed and armed.
Demo mode
Copy to clipboarddemo: true builds 16 sample shipments from the store's newest orders: three to create (one without a locker), two waiting for pickup, one waiting for the payment of a prepaid offer, two in transit, two in the locker (one after a pickup reminder), three delivered, one not collected in time, one returned to the sender, and one skipped because another system shipped the order. The lockers are invented (KSP01M to KSP05G), tracking numbers start with 99 and never link to inpost.pl, labels say they are simulated. Arm the shipment writer in Settings and create a shipment from its plan: it moves along by itself, from the label to the locker. Demo rows never meet real ones, Medusa's fulfillments are never touched, and nothing is sent to InPost.
Development
1234npm installnpm testnpm run typechecknpm run build
Copy to clipboardnpm test covers the plan and the exact ShipX request for each option, cash on delivery in grosze, the address and locker parsing, the options and references, the ShipX client (hosts, retries of reads only, errors, masking), the points search, the SQL of the claims and the compare and set of statuses, the flows end to end against a fake Medusa and a scripted ShipX (create once, unclear answers, prepaid offers, pickups, cancel rules, the fulfillment status writer), the webhook, the demo generator and the house rules of the copy. With Copy to clipboardINPOST_TEST_PG_URL=postgres://... it also runs the migration (twice) and every SQL statement of the store on a real Postgres, in a scratch schema it drops afterwards. What the plugin relies on from InPost's documentation is in docs/shipx-api-notes.md. To try the plugin in a Medusa app, run Copy to clipboardnpx medusa plugin:publish here, then Copy to clipboardnpx medusa plugin:add @koda-plus/medusa-plugin-inpost in the app.
Commercial support
Built and maintained by Koda Plus, a Medusa agency from Poland, next to our Fakturownia, BaseLinker, Allegro, OLX, Subiekt nexo and Stripe integrations. Need a hand with shipping or a Medusa store? Write to kontakt@koda.plus.
Trademarks
InPost, Paczkomat and the InPost logo are trademarks of InPost, used here only to identify the service this plugin connects to. This is an independent integration built on InPost's public ShipX API, not affiliated with or endorsed by InPost.
License
MIT, see LICENSE.
Changelog
0.2.0 (first npm release)
Demo mode only with Copy to clipboarddemo: true; cash on delivery collected once per order and never after an online payment; the status pass picks open shipments in the database; no backlog for Copy to clipboardautoCreate; writes only from the admin or a server (JSON or Copy to clipboardx-koda-request); reads never write; the koda.integration/1 contract for Koda Plus hosts with an embeddable order card; type declarations. Full list in CHANGELOG.md.
