@zanreal/medusa-allegro
v0.1.0
Published
Medusa v2 plugin integrating Allegro, the Polish marketplace: OAuth connection, encrypted token storage, offer/SKU mapping and price-automation audit models.
Maintainers
Readme
@zanreal/medusa-allegro
Medusa v2 plugin for Allegro, the largest marketplace in Poland.
Full documentation, in English and Polish, is published at
https://zanreal.com/docs/oss/medusa-allegro and authored in docs/.
Status: pre-release. The full sync engine is here: offer discovery, a read-only pricing monitor, price-automation writes, the quantity push, and the order event drain. Treat the schema as settled and the API surface as still moving until 1.0.
What is here:
- A zero-dependency, fetch-based Allegro REST client, ported from a production integration. Offers, promo options, price-automation rules and commands, order events, checkout forms, categories, fee preview.
- OAuth 2.0 authorization-code flow with a CSRF-protected callback, refresh-token rotation, and AES-256-GCM encryption of both tokens at rest.
- Offer discovery matches every seller offer's sygnatura to a Medusa variant SKU, sweeps promotion state in one paginated pass, creates category rate rows, and records conflicts instead of guessing.
- A read-only pricing monitor records each offer's price mode, attached rule and drift, and audits real rule transitions.
- Price sync attaches the rule the promotion state calls for and asserts
[break-even, SRP]bounds, with a per-run change cap, per-offer quarantine, a circuit breaker, and write-scope detection. - Stock push reconciles Medusa's available quantity into Allegro through the quantity-change command.
- Order sync drains
GET /order/eventsinto Medusa orders, with fulfillment write-back and an operator import window for gaps beyond the event retention period. - Invoice attach puts an issued invoice PDF onto the Allegro order, driven by an event from an invoicing module and retried by a bounded sweep. A soft dependency in one direction only - see The invoice chain.
- Admin surfaces built around one rule - per-product state on the product, everything else under Settings. A product detail widget shows each variant SKU's linked offer, status, drift, promotion state and per-offer price sync opt-out (with a push-history drawer); a compact product-list banner rolls up linked / drifting / conflicting counts; the connection, writer toggles, category rates, the cross-catalogue offers table and the orders task-flow all live under Settings -> Allegro, with nothing Allegro-specific in the main ecommerce sidebar. See Admin UI.
Nothing writes to Allegro until you arm it. Every writer is governed by a
persisted, admin-flippable toggle that ships off on a fresh install, so a newly
connected store publishes nothing until an operator arms each writer under Settings
-> Allegro - no redeploy needed to arm or disarm, and the environment can still hard
force-disable any writer regardless. On top of that: price sync is inert without
automationRules, and a fresh install starts its order cursor at "now" rather than
importing history. See Runtime toggles and
Turning the writers on.
Install
This package is not on npm yet. It installs as a git dependency, pinned to a commit:
// package.json
{
"dependencies": {
"@zanreal/medusa-allegro": "github:zanreal-labs/medusa-allegro#b0e864ab6a05e63e6d57a20b3c8adaf943049cdd"
}
}Pin the commit you tested against. There is no published tag yet, so #main would
move under you on the next push.
The package compiles itself on install - prepare runs medusa plugin:build,
which turns the checked-out source into the .medusa/server output its exports
point at. pnpm 10 and newer refuse to run that script for a dependency they do not
already trust, so a fresh install needs it allowed once in your own project:
# pnpm-workspace.yaml
allowBuilds:
"@zanreal/medusa-allegro@https://codeload.github.com/zanreal-labs/medusa-allegro/tar.gz/b0e864ab6a05e63e6d57a20b3c8adaf943049cdd": trueThe key is the exact tarball URL pnpm resolves the pinned commit to, which is why it carries the same SHA as the dependency line - move both together.
Register it in medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils";
module.exports = defineConfig({
plugins: [
{
resolve: "@zanreal/medusa-allegro",
options: {
clientId: process.env.ALLEGRO_CLIENT_ID,
clientSecret: process.env.ALLEGRO_CLIENT_SECRET,
environment: process.env.ALLEGRO_ENVIRONMENT ?? "production",
// App identity. `appName` must match the app registered in the Allegro
// Developer Portal; Allegro rejects requests whose User-Agent does not
// identify a real app.
appName: "MyStoreAllegro",
appVersion: "1.0.0",
docsUrl: "https://mystore.example.com/integrations/allegro",
// openssl rand -base64 32
encryptionKey: process.env.ALLEGRO_ENCRYPTION_KEY,
},
},
],
});Then run the migrations:
npx medusa db:migrateOptions
| Option | Type | Required | Default | Notes |
| ------------------------------ | --------------------------- | -------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| clientId | string | yes | - | Allegro application client id. |
| clientSecret | string | yes | - | Allegro application client secret. |
| environment | "production" \| "sandbox" | no | "production" | Sandbox talks to api.allegro.pl.allegrosandbox.pl. |
| appName | string | yes | - | Must match the registered app name. No whitespace or HTTP separators. |
| appVersion | string | yes | - | Your integration version, e.g. "1.0.0". |
| docsUrl | string | yes | - | Public http(s) URL documenting or contacting the integration. |
| encryptionKey | string | yes | - | Base64-encoded 32 bytes. Seals the stored tokens. Rotating it makes existing tokens unreadable: reconnect after a rotation. |
| redirectPath | string | no | "/admin/allegro/oauth/callback" | A rooted path on this backend, matching the redirect URI registered for the app character for character. //host/... is rejected: it is a protocol-relative URL, not a path. |
| scopes | string | no | "allegro:api:sale:offers:read allegro:api:sale:offers:write allegro:api:orders:read" | Space-separated. Drop :write if you only ever want read access; the plugin then reports the missing write scope in the UI. |
| priceSyncDisabled | boolean | no | false | Force-disable override for price writes. It can only force the writer OFF - the live arming is the persisted runtime toggle. Must be a real boolean: a string throws at boot rather than failing open. Prefer ALLEGRO_PRICE_SYNC_DISABLED for the env-driven incident case. |
| fulfillmentWritebackDisabled | boolean | no | false | Force-disable override for the fulfillment write-back (the seller-status push on a Medusa fulfillment/shipment). NEW: this event-driven writer had no kill switch before. Live arming is its runtime toggle; env is ALLEGRO_FULFILLMENT_WRITEBACK_DISABLED. |
| invoiceAttachDisabled | boolean | no | false | Force-disable override for attaching invoice PDFs to Allegro orders. Its own switch, not a reading of ordersSyncDisabled - see The invoice chain. Same boolean-only contract. |
| backendUrl | string | no | derived | Absolute base URL of this backend. Set it when a proxy rewrites Host. Falls back to MEDUSA_BACKEND_URL, then the request. |
Sync options
| Option | Type | Required | Default | Notes |
| -------------------- | ---------------------------------------- | -------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| pricingMode | "monitor" \| "automation_rule" \| "fixed_price" | no | "automation_rule" | How this store prices its Allegro offers - see Pricing modes. This is the DEFAULT; the persisted admin choice wins over it. "automation_rule" is what this plugin did before the mode existed, so an upgrade changes nothing. |
| automationRules | { promoted: string; standard: string } | no | - | Names of two price-automation rules that must already exist on the Allegro account. Resolved by name every run; missing, renamed or ambiguous aborts the run with nothing written. Omit it and price sync is inert. Also editable and persisted from the admin - see Sync configuration fields. |
| changeCap | number | no | 1 | Price-automation commands per run. Positive integer; 0 is rejected - use a kill switch to stop writes, not a zero cap. Also editable and persisted from the admin. The default is a deliberately minimal placeholder, not a recommendation - see Choosing a change cap. |
| stockSyncDisabled | boolean | no | false | Force-disable override for quantity writes. Can only force OFF; the live arming is the runtime toggle. Same boolean-only contract as priceSyncDisabled. |
| ordersSyncDisabled | boolean | no | false | Force-disable override for the order drain. Forced off, the journal is not consumed at all, so the cursor holds and nothing is skipped. Live arming is the runtime toggle. |
| salesChannelId | string | no | - | Scopes which products are sync-eligible. With neither this nor salesChannelName, the whole catalogue is eligible. Also editable and persisted from the admin - wiring-critical, see Sync configuration fields. |
| salesChannelName | string | no | - | Resolved by name at run time. A configured name that does not exist is an error, not a fallback to the whole catalogue. Also editable and persisted from the admin. |
| stockLocationIds | string[] | no | every location | Locations whose available quantity is summed for the push. ALLEGRO_STOCK_LOCATION_IDS overrides it. |
| srpMetadataKey | string | no | - | Reads the SRP (the price-range ceiling) from that key in the variant's metadata, falling back to the product's. Mutually exclusive with srpPriceListId. Also editable and persisted from the admin. |
| srpPriceListId | string | no | - | Reads the SRP from the variant's price in that price list. Also editable and persisted from the admin. |
| costsModuleKey | string | no | "productCosts" | Container key of @zanreal/medusa-product-costs, resolved lazily and optionally. Without it, every offer is skipped with missing-break-even. There is never a default floor. |
| invoiceModuleKey | string | no | "infakt" | Container key of the invoicing module that issues your documents, resolved lazily and optionally. Without it the invoice chain is inert. See The invoice chain. |
| marketplaceId | string | no | "allegro-pl" | Marketplace the rule assignment targets. Also editable and persisted from the admin - wiring-critical, see Sync configuration fields. |
| regionId | string | no | derived | Region Allegro orders are created in. Falls back to the first region matching the order currency, then the first region at all (with a warning). |
All options are validated in a module loader, so a misconfiguration fails at boot with a specific message instead of surfacing as an opaque Allegro error later. The validations worth knowing about, because each catches a mistake that would otherwise present as a silently inert loop:
- A boolean-looking string on any kill switch throws.
priceSyncDisabled: process.env.Xyields"true", which a truthiness test honours and a=== truetest ignores - the switch would read as enabled while you believed it was off. - One rule name used for both promotion states throws. A promotion flip would then be a no-op switch, so the promoted commission rate would never reach the price floor, and price sync would look healthy while systematically under-flooring every promoted offer.
- Both SRP sources set at once throws. The ceiling is what stops an automation rule ratcheting a price down; two sources means an ambiguous ceiling.
Environment variables
| Variable | Effect |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ALLEGRO_PRICE_SYNC_DISABLED | 1, true or yes force-disables price writes, beating a persisted toggle that is armed. A hard override that can only force OFF - it never arms a writer. The env wins on purpose: an operator setting it is responding to an incident. |
| ALLEGRO_STOCK_SYNC_DISABLED | The same, for quantity writes. ALLEGRO_PRICE_SYNC_DISABLED alone does not stop all writes - the quantity command is a separate writer. |
| ALLEGRO_ORDERS_SYNC_DISABLED | The same, for the order drain. |
| ALLEGRO_FULFILLMENT_WRITEBACK_DISABLED | The same, for the fulfillment write-back (the seller-status push on a Medusa fulfillment/shipment). NEW - this event-driven writer previously had no kill switch. See Fulfillment write-back. |
| ALLEGRO_INVOICE_ATTACH_DISABLED | The same, for attaching invoice PDFs. A separate switch from the drain on purpose - see The invoice chain. |
| ALLEGRO_OFFER_SYNC_CRON | Schedule for the hourly catalogue pass (discovery, monitor, price sync). Default "15 * * * *". |
| ALLEGRO_STOCK_SYNC_CRON | Schedule for the quantity push. Default "*/15 * * * *". |
| ALLEGRO_ORDERS_SYNC_INTERVAL_MS | Interval, in ms, for the order drain. Default 20000 (20s). The drain schedules on an interval by default because Medusa's cron only resolves to the minute and a fresh order should be drained sub-minute. |
| ALLEGRO_ORDERS_SYNC_CRON | Switches the order drain back to a cron expression instead of an interval. The two are mutually exclusive in Medusa's scheduler; when both are set the cron wins. |
| ALLEGRO_ORDERS_RECONCILE_UNPAID_INTERVAL_MS | Minimum gap, in ms, between reconciliation sweeps of orders with no registered payment. Default 0 - every drain tick, i.e. every 20s. An unpaid order is one the buyer may be paying right now, so this is deliberately the fastest thing the plugin does. |
| ALLEGRO_ORDERS_RECONCILE_OPEN_INTERVAL_MS | The same, for orders that are paid but not finished (awaiting shipment, in transit). Default 900000 (15 min): a lost event here delays a status label, not the money. |
| ALLEGRO_ORDERS_RECONCILE_BATCH | Maximum orders re-read from Allegro per sweep. Default 50. Allegro's global limit is 9000 requests/minute per client id and the order endpoints carry no per-resource limit, so this is a backlog guard rather than a rate-limit one. |
| ALLEGRO_ORDERS_RECONCILE_SENT_GRACE_MS | How long a shipped Medusa fulfillment is left to the shipment.created subscriber before the sweep pushes SENT itself. Default 600000 (10 min). It exists because Allegro's checkout-form read model lags its own writes by about 45 seconds (measured 2026-08-25: a 2xx PUT .../fulfillment followed by a GET that still read READY_FOR_SHIPMENT, correct only on a re-read ~45s later), so a sweep straight after a successful push would still see READY_FOR_SHIPMENT and re-push. The same lag means a status read back immediately after a push is stale rather than evidence the push failed. Set it to 0 only if you have disabled the subscriber. |
| ALLEGRO_STOCK_LOCATION_IDS | Comma-separated stock location ids, overriding stockLocationIds. |
| ALLEGRO_PRICING_MODE | Locks the pricing mode, beating both the admin picker and pricingMode. Ignored (read as unset) unless it names a real mode. See Pricing modes. |
| ALLEGRO_AUTOMATION_RULE_STANDARD | Locks the standard-offer automation rule name, beating both the admin field and automationRules.standard. See Sync configuration fields. |
| ALLEGRO_AUTOMATION_RULE_PROMOTED | The same, for the promoted-offer rule name. |
| ALLEGRO_SRP_METADATA_KEY | The same, for the SRP metadata key. |
| ALLEGRO_SRP_PRICE_LIST_ID | The same, for the SRP price list id. |
| ALLEGRO_CHANGE_CAP | The same, for the per-run change cap. Ignored (read as unset) unless it is a positive integer. |
| ALLEGRO_MARKETPLACE_ID | The same, for the marketplace id. Wiring-critical - see Sync configuration fields. |
| ALLEGRO_SALES_CHANNEL_ID | The same, for the sales-channel id. Wiring-critical. |
| ALLEGRO_SALES_CHANNEL_NAME | The same, for the sales-channel name. |
| MEDUSA_BACKEND_URL | Fallback for backendUrl when deriving the OAuth redirect URI. |
The schedules and force-disable overrides are env vars rather than plugin options
because Medusa evaluates a scheduled job's schedule at plugin-load time, before the
DI container - and therefore this plugin's options - exists. There is no way to read
a module's resolved options from that static export.
The schedules start firing as soon as the plugin loads, but the writers are armed by the persisted toggles, which ship off. Installing or upgrading this plugin runs the loops on their cadence, but every writer stays disarmed until you arm it under Settings -> Allegro (or the environment force-disable, if set, keeps it off regardless). The read paths are harmless (discovery and the monitor write nothing to Allegro). If you are staging a cutover from another system and want belt-and-braces, set the force-disable env vars BEFORE the version that reads them ships. See Runtime toggles and Turning the writers on.
Pricing modes
How this store prices its Allegro offers is a setting, not an assumption. Pick one of three modes under Settings -> Allegro; it takes effect on the next sync run, with nothing to restart.
| Mode | What it writes to Allegro |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| monitor (Monitor only) | Nothing at all. Every run still works out each linked offer's break-even floor and SRP ceiling and counts how many offers sit outside them. |
| automation_rule (Allegro automation rule) | One POST /sale/offer-price-automation-commands per offer: attach the named rule its promotion state calls for, with [floor, ceiling] as the rule's price range. Allegro's engine then picks the number inside that range. |
| fixed_price (Fixed price from Medusa) | One PUT /sale/offer-price-change-commands/{id} per offer, setting the Buy Now price to the variant's own Medusa price - preceded by a rule-REMOVAL command when the offer still carries an automation rule. |
automation_rule is the default, because it is what this plugin did before the mode
existed. Upgrading changes nothing about what your store writes.
The floor and the ceiling apply in every mode
The break-even floor (from
@zanreal/medusa-product-costs,
grossed for VAT and for the category commission) and the SRP ceiling (from variant
metadata or a price list) are the safety story of this whole plugin, so no mode is
allowed to skip them:
monitorcomputes both and reports how many offers are priced outside them. That report is what you read before choosing a mode that writes.automation_rulesends them as the rule's price range, so Allegro's engine cannot move the price outside them.fixed_pricechecks the Medusa price against them and refuses to push a price below the floor or above the ceiling, counting it asprice-outside-bounds.
A refusal is deliberate, and it is not a clamp. Clamping would sell at a price the store never set; pushing would sell below cost. Refusing does neither, and it names the variants whose Medusa price needs fixing.
What fixed-price mode needs, and what it does about it
Two things are true and worth stating plainly before you switch a live store to it:
- The scope is already there.
PUT /sale/offer-price-change-commands/{commandId}needsallegro:api:sale:offers:write, which is in this plugin's default scope string and is the same scope the rule assignment already uses. Moving to fixed-price mode needs no reconnect and no new consent. - An automation rule beats a fixed price, so the rule has to go first. Allegro's
engine recalculates an offer on its own schedule, so a price pushed under a live rule
does not survive it. Fixed-price mode therefore issues
POST /sale/offer-price-automation-commandswith aremovemodification for the offer's marketplace, waits for it to confirm, and only then sets the price. If the removal does not confirm, the price is not sent at all - a half-applied pair that left the rule attached and the price changed is precisely the fight with Allegro's engine the sequencing exists to avoid. Re-running the pair next tick is idempotent.
Both commands count as one offer against the per-run change cap.
Where the fixed price comes from
The variant's own default price in Medusa, in the offer's own currency. Two rules, both fail-closed:
- Price-list rows are ignored. A price carrying a
price_list_idis a sale or a customer-group override with its own validity window and conditions, none of which this plugin evaluates. Pushing one would leave a sale price on Allegro long after the sale ended. - There is no currency conversion. A variant with no price in the offer's currency is
skipped as
missing-medusa-pricerather than priced from a rate this plugin cannot audit.
Auditing
Every mode writes to the same append-only allegro_price_push trail, but they fill
different columns, and the difference is load-bearing:
- automation-rule rows carry
bound_floor/bound_ceilingand the rule ids. Those two columns are the only memory of the price range attached to a rule, because Allegro accepts a range and never returns one. - fixed-price rows carry
price_amount/price_currencyand leave the bounds columns null, plusrule_id_old/rule_name_oldfor the rule that was removed. Writing the guard rails into the bounds columns would make a later automation-rule run read back a price range that was never attached, and skip an offer it should have re-attached.
Monitor mode and the price-write toggle
Monitor mode runs even while the Price writes toggle is off, because it has no command path to reach - exactly like the read-only price-automation monitor, which has never had a kill switch. The two writing modes honour the toggle as they always have, re-reading it before every single command. An explicit per-offer push from a product page is refused in monitor mode rather than quietly performed.
Runtime toggles
Every writer that reaches Allegro is governed by a persisted, operator-flippable
toggle, stored as a one-row allegro_settings singleton. This is the live arming an
operator controls from Settings -> Allegro - flip a switch and it takes effect on
the next tick or event, with no redeploy, because every runtime path resolves its
effective state from the persisted row at the top of each run rather than from a value
captured at boot.
The five governed writers:
| Toggle | Column | Fresh-install default | Env force-disable |
| ---------------------- | ------------------------------- | --------------------- | ---------------------------------------- |
| Price writes | price_sync_enabled | off | ALLEGRO_PRICE_SYNC_DISABLED |
| Quantity writes | stock_sync_enabled | off | ALLEGRO_STOCK_SYNC_DISABLED |
| Order drain | orders_sync_enabled | off | ALLEGRO_ORDERS_SYNC_DISABLED |
| Fulfillment write-back | fulfillment_writeback_enabled | off | ALLEGRO_FULFILLMENT_WRITEBACK_DISABLED |
| Invoice attach | invoice_attach_enabled | on (inert) | ALLEGRO_INVOICE_ATTACH_DISABLED |
Precedence
The environment (and the boot-time plugin option) is a hard override that can only force a writer OFF. It never arms one. The effective state is:
effectiveEnabled = persistedEnabled && !forceDisabledSo:
- persisted on + env unset -> on (the writer runs)
- persisted on + env force-disable -> off (the override wins; an operator responding to an incident is not undone by a stale armed toggle)
- persisted off + env unset -> off (nothing arms a writer but the toggle)
The admin shows a switch that the environment forces off as locked and "forced off by environment", with the env var to clear - it never renders an armed-looking switch for a writer the environment is holding down. A write to a forced-off toggle is still accepted and stored, so the intent is preserved for when the override is lifted.
Fresh-install defaults
Every writer ships off, so a freshly connected store publishes nothing to Allegro until an operator arms each writer deliberately. Invoice attach is the one exception - it ships on but inert: by the time an invoice event reaches this plugin the document already exists as a legal record, so delivering it is the safe default, and there is nothing to attach until an invoicing module is wired and emitting events.
The singleton row is created lazily under a fixed primary key on first read, with these defaults. Upgrading an existing install runs the additive migration that creates the table; the row appears the first time any runtime path or the admin reads it.
Sync configuration fields
Nine settings - the pricing mode, the two automation rule names, the
SRP source, the change cap, the marketplace id and the sales-channel scope - are
editable from Settings -> Allegro, on the same allegro_settings singleton the
runtime toggles use. An edit persists and takes effect on the next sync run, no
redeploy - the same property the toggles have. A store that never touches these admin
fields behaves exactly as before: every persisted column starts null, and null
falls through to the medusa-config.ts option.
| Field | Column | medusa-config.ts option | Env lock |
| ---------------------------------- | -------------------------- | -------------------------- | ---------------------------------- |
| Pricing mode | pricing_mode | pricingMode | ALLEGRO_PRICING_MODE |
| Automation rule (standard) | automation_rule_standard | automationRules.standard | ALLEGRO_AUTOMATION_RULE_STANDARD |
| Automation rule (promoted) | automation_rule_promoted | automationRules.promoted | ALLEGRO_AUTOMATION_RULE_PROMOTED |
| SRP source: metadata key | srp_metadata_key | srpMetadataKey | ALLEGRO_SRP_METADATA_KEY |
| SRP source: price list id | srp_price_list_id | srpPriceListId | ALLEGRO_SRP_PRICE_LIST_ID |
| Change cap | change_cap | changeCap | ALLEGRO_CHANGE_CAP |
| Marketplace id (wiring-critical) | marketplace_id | marketplaceId | ALLEGRO_MARKETPLACE_ID |
| Sales channel id (wiring-critical) | sales_channel_id | salesChannelId | ALLEGRO_SALES_CHANNEL_ID |
| Sales channel name | sales_channel_name | salesChannelName | ALLEGRO_SALES_CHANNEL_NAME |
Precedence
Adapted from the toggles' "the override can only force off" contract to a value rather than a boolean - there is no "off" for a string or a number, so a set environment lock wins outright:
effectiveValue = envLock ?? persistedValue ?? medusaConfigDefault- an env lock, when set, is authoritative - it beats both a persisted admin edit AND
the
medusa-config.tsoption, and the admin shows the field locked, the same treatment a forced-off toggle gets - otherwise the persisted admin value governs, when one has been entered
- otherwise the
medusa-config.tsoption governs, exactly as it always did
Clearing a field in the admin (blank it and Save) writes null, which falls back to
the medusa-config.ts option rather than to an empty value - the same "clear"
contract the category-rates page already uses.
Marketplace id and sales channel id are wiring-critical
Editing marketplaceId or salesChannelId re-scopes which Medusa products this
plugin matches against Allegro offers, not merely a tuning knob - a wrong value
breaks the mapping silently rather than producing an obviously bad result. Both stay
editable and persisted, with the same env-lock escape hatch as everything else: set
ALLEGRO_MARKETPLACE_ID or ALLEGRO_SALES_CHANNEL_ID to pin the correct value against
an admin mistake during a cutover. The admin renders an explicit warning on both
inputs saying so.
Automation rule names and SRP source stay mutually consistent
Two invariants that already existed as boot-time checks in resolveAllegroOptions -
the standard and promoted rule names must differ, and at most one SRP source may be
set - are now also enforced on every admin write, because persisting a field
independently of the other can newly create a collision the boot-time check never saw
(one half configured, the other newly persisted to the same value). The write is
rejected with a MedusaError rather than silently accepted.
OAuth setup
1. Register an application with Allegro
Sign in to apps.developer.allegro.pl with the seller account you want to connect (use apps.developer.allegro.pl.allegrosandbox.pl for sandbox).
Create a new application. Choose the type that has a web application with a redirect URI - the plugin uses the authorization-code grant, not the device flow.
Name it something stable and machine-safe, with no spaces:
MyStoreAllegro. This exact string goes into theappNameoption, because Allegro requires every request to carry aUser-Agentthat identifies the registered app one-to-one. The plugin composes{appName}/{appVersion} (+{docsUrl})and validates it at construction time, so a name with a space is rejected before any request is sent.Set the redirect URI to your backend plus
redirectPath:https://your-medusa-backend.example.com/admin/allegro/oauth/callbackAllegro compares this byte for byte during the token exchange. A trailing slash difference is a failed connection.
Grant the app the scopes you configured: offer read, offer write, order read.
Copy the client id and secret into
ALLEGRO_CLIENT_IDandALLEGRO_CLIENT_SECRET.
2. Generate an encryption key
openssl rand -base64 32Put it in ALLEGRO_ENCRYPTION_KEY. The plugin refuses to boot unless the value is
canonical base64 (standard or URL-safe) for exactly 32 bytes, rather than
silently accepting a weak one. The check is deliberately strict about the
encoding and not only the length, because Buffer.from(value, "base64") never
throws: it drops every character outside the alphabet, so a length test alone
accepts mangled input, and "A".repeat(43) decodes to a well-formed all-zero
key. Both are rejected.
The key also signs the OAuth state (see below), so rotating it invalidates any
connection flow that is mid-air as well as the stored tokens.
3. Connect from the admin
Open Settings -> Allegro in the Medusa Admin and click Connect Allegro. You land on Allegro's consent screen, approve, and come back to the settings page with the account login, granted scopes, and token expiry filled in.
The page distinguishes three unhealthy states from a working connection, because
each needs a different response: a missing refresh token (reconnect before the
access token expires), an unreadable token envelope (the encryptionKey no longer
opens what is stored - restore the old key or reconnect), and price sync disabled.
A row whose envelope will not open is reported as such rather than as a green
"Connected", which would send you looking at Allegro instead of at your own
configuration.
How the flow is protected
GET /admin/allegro/oauth/startmints astate, parks it in an httpOnlySameSite=Laxcookie with a 10-minute lifetime, and returns the authorization URL for the admin to navigate to. Over https the cookie carries the__Host-prefix, so a sibling subdomain cannot shadow it; over plain http it does not, because a__Host-cookie withoutSecureis dropped and local development would break.- The
stateis not an opaque nonce. It isv1.<issuedAt>.<nonce>.<mac>, where the MAC is HMAC-SHA256 over the mint time, the nonce and the admin user'sactor_id, keyed byencryptionKey. The admin id itself is not in the value, because the value travels through Allegro's authorize URL and into browser history and access logs. GET /admin/allegro/oauth/callbackrequires thatstateto match the cookie, compared in constant time, and to verify against the actor completing the flow. The cookie proves same-browser; the signature proves same-server, same admin, and minted within the last ten minutes. A state planted in someone else's browser fails the second check.- The state cookie is cleared only once the authorization code has actually been
handed to Allegro - which is when the state is spent, so it stays single-use.
Branches that run before the state is verified (
?error=..., a missing code, a state mismatch) deliberately leave it alone, so a lured GET to the callback cannot destroy a flow the operator legitimately started in another tab. - Both routes live under
/admin, which Medusa authenticates by default. The callback keeps that default: Allegro's redirect back is a top-level GET navigation and Medusa's admin session cookie isSameSite=Lax, so the session survives the hop. Making it public would also remove theactor_idthe signed state is verified against, so every flow would fail instead.
If your deployment authenticates the admin with a bearer token in local storage rather than a session cookie, the callback will 401, because the browser has no cookie to send on that navigation. Serve the admin and the backend on the same origin with session auth; do not make the callback public.
Disconnecting
POST /admin/allegro/disconnect revokes the refresh and access tokens at Allegro
and then deletes the stored row. Revocation is best-effort - if Allegro is
unreachable the local connection is still removed, because refusing to disconnect
would leave an operator unable to remove access they asked to remove.
When revocation is skipped or fails, the response carries a warning and the
settings page shows it. That matters here more than in most places: the stored
rows are the only copy of the tokens, so after this call there is nothing left to
revoke with, and the refresh token stays valid at Allegro until it expires unless
you remove the application's access by hand in the developer panel.
Admin UI
The information architecture answers two things at once: you should not have to open a separate table to see a product's Allegro state, and nothing Allegro-specific belongs in the main ecommerce sidebar - it is an integration's configuration and operator tooling, not a merchandising surface, so every non-per-product view lives under Settings.
Product detail widget (
product.details.after) - the authoritative per-product view. For every variant SKU it shows the linked offer (with a link to the live listing), a short status (linked / not linked / conflict), the observed price mode and drift, promotion state, the last price and stock sync times, and the per-offer price sync opt-out switch. The push history - the only record of the bounds ever sent - opens in a drawer. This is where an operator checks or opts a single product out, without touching the catalogue table.It fetches its own variants, and must. The dashboard loads the product for this zone with
PRODUCT_DETAIL_FIELDS = getLinkedFields("product", "*categories,*shipping_profile,-variants")(@medusajs/dashboard/src/routes/products/product-detail/constants.ts). That-variantsis an explicit exclusion - the page fetches the variant table separately withuseProductVariants- sodata.variantshanded to aproduct.details.*widget isundefined. This widget derived its SKU list fromdata.variantsand returnednullwhen that list was empty, which meant it rendered nothing, on every product, on every store. It now callssdk.admin.product.listVariants(productId, { fields: "id,title,sku" })itself and still prefersdata.variantsif a future dashboard version passes it.Product list banner (
product.list.before) - a compact roll-up (N linked / N unlinked / N drifting / N conflicts) above the stock products table, each count linking into Settings -> Allegro offers filtered to those rows. Medusa 2.18 does not allow injecting a custom column into the core products data table, and it exposes no list-row widget zone, so on that page a roll-up is the only thing a plugin can offer. It stays as the zero-dependency fallback for a store that has not installed admin-kit.Catalog columns (
src/admin/widgets/register-variant-columns.tsx) - two columns registered into@zanreal/medusa-admin-kit's Catalog route, which lists one variant per row.Allegrois the live offer price, fromallegro_offer.price_amountwith itsprice_currency. It is a separate column rather than a second line inside the status badge, because the whole point of showing it is comparing it with the shop price and the SRP the kit renders two columns to the left, and that comparison needs a figure in a money column lined up with those, not a number inside a coloured badge. Priority 9 puts it immediately before the status column, so the three prices sit together. A price on apausedorendedoffer is muted: the figure is real, but nobody can buy at it, and rendering it like a live price would read as current. A SKU with no offer, or an offer with no price observed yet, renders a muted-; never0, and never an error.Allegro statusis the mapping state. The cell names what is wrong with that one SKU: the conflict code (duplicate-sku,no-offer, ...) in red,driftin orange, Allegro's own offer status in green when it is listed and healthy,unlinkedin grey, and a muted "not listed" when the SKU has no mapping at all. This is the real per-row status the banner could only approximate, and it does not cost this plugin a competing products list of its own.Both columns share one request per page.
loadDataruns per row, so two columns over a 100-row page would be 200 single-SKU requests for the same table.src/admin/lib/offer-batch.tscoalesces every SKU asked for within a tick - React flushes all the cells' effects in one pass - into a single/admin/allegro/offers?skus=...call, de-duplicating the SKU the two columns both want. It deliberately keeps no cache across batches: a price is exactly the thing a sync changes underneath the operator, so a re-render has to be able to re-read it.price_amountis a decimal string (model.text(), Allegro's own value verbatim), not a MedusaBigNumber, andresolveVariantOfferPricereads it as one - withNumberrather thanNumber.parseFloat, so"365,31"is rejected instead of silently becoming365. An unreadable amount isnull, which renders as the dash;0is left meaning zero.It used to read
"3 offers / 1 conflict", because an admin-kit row was a product and a product spans many SKUs - which told an operator that something was broken without telling them which SKU, the one thing they needed in order to act. Now a row is one variant with at most one offer. The registration moved fromregisterProductColumntoregisterVariantColumnand the SKU roll-up insrc/admin/libis gone:summarizeOfferStatus/formatOfferStatusbecameresolveVariantOffer/formatVariantOffer/variantOfferColor. The status column's header also changed fromAllegrotoAllegro status, so that the price column can carry the plainer name next toShopandSRP.Settings -> Allegro - the configuration and control home: the OAuth connection, the live writer toggles (interactive switches backed by the persisted runtime settings - arm or disarm each writer without a redeploy; a writer the environment forces off is shown locked), the sync configuration fields (editable inputs backed by the same singleton - see Sync configuration fields; a field an environment variable locks is shown locked, same treatment as a forced-off toggle), a catalogue roll-up, sync health, and links into the three nested Settings pages below.
Settings -> Allegro -> Offers - the cross-catalogue offer table with conflict and drift filters, bulk rediscovery, and manual push. An operator triage surface for catalogue-wide "which offers are not syncing, and fix them" work - a genuine multi-item workflow the per-product widget cannot serve, and distinct from the browse case the admin-kit Catalog column covers.
Settings -> Allegro -> Orders - the orders quarantine repair and import window. Operational task-flow, not a setting itself, but still nested here rather than in the main sidebar.
Settings -> Allegro -> Category rates - the per-category sale commissions that set every price floor. Pure configuration, hand-maintained from Allegro's published fee table.
The sygnatura / SKU mapping principle
A Medusa variant and an Allegro offer are linked by SKU, and only by SKU.
Allegro lets a seller put their own identifier on every offer, in the field the
API calls external.id and the seller panel calls sygnatura. This plugin's
contract is that you put the Medusa variant SKU there. Offer discovery then
matches external.id against variant SKUs, and allegro_offer.sku carries a
unique constraint because it is the identity of the row.
allegro_offer.offer_id is a resolved cache, never the identity. Allegro
offer ids are not stable across an item's life: re-listing an ended offer
produces a new id, and one SKU legitimately moves between offers over time. A
mapping keyed on the offer id turns every re-list into a silent orphan that stops
receiving stock and price updates while still looking healthy. A mapping keyed on
the SKU turns the same event into a row whose offer_id needs re-resolving,
which the next discovery pass does on its own.
Practical consequence: fill in the sygnatura on every Allegro offer you want managed. An offer without one is invisible to this plugin by design. That is the correct default - it means a seller can keep offers outside Medusa's control simply by leaving the field empty.
Data model
| Table | What it holds |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| allegro_auth | The OAuth connection. Both tokens AES-256-GCM encrypted, plus expiry, granted scope, and the account login. |
| allegro_offer | SKU-to-offer mapping. sku unique, offer_id a resolved cache. Money as text, verbatim from Allegro. |
| allegro_category_rate | Sale commission per Allegro category, plain and promoted. Maintained by an operator - see below. |
| allegro_price_push | Append-only audit of every pricing decision: the rule and the pushed [floor, ceiling] in automation-rule mode, the exact price_amount / price_currency in fixed-price mode. |
| allegro_order | One row per Allegro checkout form: the Medusa order it produced, the raw and derived statuses, conflicts, and the attached invoice document. |
| allegro_sync_state | Per-loop health: status, cursor, counters, last error, failure state, the write-scope flag, and the claim's fencing token plus its heartbeat. |
| allegro_settings | The one-row singleton of persisted settings: the pricing mode, the sync configuration fields, and the runtime toggles - the live, operator-flippable arming of each writer. Writers default off, invoice-attach on. |
Three of these carry non-obvious constraints worth knowing before you build on them.
allegro_price_push is append-only, and it is the only record of pushed price
bounds. Allegro's API accepts a [min, max] price range when you attach a
price-automation rule to an offer, and it will tell you afterwards which rule is
attached - but it never returns the range. The bounds are write-only. So this
table is the only place that can answer "what floor is this offer pinned to, and
who set it". Never update or delete a row; correct a mistake by appending. Rows
with result: "observed" record state the plugin saw without touching, which is
what makes a read-only monitoring pass worth running.
allegro_category_rate is filled in by hand, on purpose. Allegro does publish
a fee calculator (POST /pricing/offer-fee-preview, wrapped by the SDK as
offerFeePreview), but in production it rejects the offer bodies you can build
from a seller's own live offers, so sweeping a real catalogue returns errors
rather than rates. Until that changes, an operator enters rates from the published
fee table. Both rate columns are nullable so "unknown" stays distinguishable from
"zero commission": a margin calculation that reads a missing rate as 0% quietly
turns a loss-making price into an acceptable one.
allegro_order is separate from the Medusa order on purpose. A checkout form
can exist without an order (creation failed, so the form stays visible with its
error rather than vanishing), Allegro's status ladder is richer than Medusa's enum,
and derived_status has to be the comparison basis for "did Allegro move?" - see
Status mapping. Its two invoice columns follow the same
write-last discipline: allegro_invoice_id is stamped when the document is registered
and invoice_attached_at only once Allegro has the file, so a row reading attached
carries a PDF the buyer can download - see The invoice chain.
The sync architecture
Five loops, each with its own row in allegro_sync_state, its own single-flight
claim, and - for the ones that write - its own kill switch. They are independently
observable and independently runnable from the admin.
Two things write to Allegro outside the loops, both on a Medusa event: the fulfillment write-back and the invoice attach. Neither is a loop because neither has reconcilable state to compare - see Fulfillment write-back and The invoice chain.
| Loop | Provider | Schedule | Writes to Allegro? |
| --------------- | ------------------ | --------------------------------------- | ------------------------------------ |
| Offer discovery | offers | ALLEGRO_OFFER_SYNC_CRON, 15 * * * * | No |
| Pricing monitor | price-automation | chained after discovery | No |
| Price sync | prices | chained after the monitor | Yes - price-automation command |
| Stock push | stock | ALLEGRO_STOCK_SYNC_CRON, */15 * * * | Yes - quantity-change command |
| Order drain | orders | ALLEGRO_ORDERS_SYNC_CRON, * * * * * | Only fulfillment status, on an event |
The first three are chained into one job rather than scheduled separately because they all need the same input - a complete listing of the seller's offers - and paging a full catalogue three times an hour is how a well-behaved integration earns a rate limit. The order matters: discovery establishes which offer owns which SKU and which mappings are conflicted, and price sync refuses to write to anything conflicted, so running price sync against a stale mapping is exactly the case where a command lands on the wrong offer.
Stock has its own cadence because stock moves on every order and an hour-stale
marketplace quantity is how a sold-out item stays purchasable. Orders runs per
minute because an unapplied BOUGHT event is an order nobody has been told about.
Reconciliation first, events almost never
Every loop except fulfillment write-back is a reconciliation: it reads the whole relevant state on each run and computes the difference. None of them depends on a Medusa event firing.
That is deliberate. Medusa's inventory events are not a reliable trigger (medusa#11691), and a design that depended on them would leave a permanently wrong marketplace quantity behind every missed event. With reconciliation, a missed event costs at most one cycle of staleness.
The one exception is fulfillment write-back, and it is an exception for a structural reason rather than a convenient one: a fulfillment is a point-in-time act, not reconcilable state. There is no "current fulfillment status" in Medusa for a sweep to compare against Allegro's, so the event is the only signal there is.
Medusa inventory is the source of truth for stock
The quantity pushed to Allegro is retrieveAvailableQuantity - stocked minus
reserved, so units already promised to unfulfilled Medusa orders are not advertised
again.
Keeping Medusa inventory honest is explicitly not this plugin's job. In this stack that belongs to a separate inventory plugin, which owns the supplier snapshot and the arming gate that refuses to propagate an untrustworthy one into Medusa inventory. That guard lives one layer up, where the supplier response is actually visible; a second one here would be a guess about data this plugin has no source for.
What this loop does refuse on is its own uncertainty, and the line is drawn at UNKNOWNS rather than at gaps. An ambiguous SKU match, or a quantity that could not be READ on either side, refuses the whole plan: a partial push in that state leaves some offers fresh and others stale with nothing recording which is which, so the next run cannot tell either.
A KNOWN, bounded exclusion does not refuse anything. Each is counted, reported in
last_error, and leaves exactly one offer alone: an inactive offer, a variant that does
not manage inventory (so Medusa has no quantity to publish - a digital product, say), an
offer that contradicts its mapping row, a mapped offer absent from the listing, an offer
whose own Allegro listing carried no usable stock.available, and an eligible variant no
mapped offer claims. Treating "this variant has no inventory" as an
unknown is what previously let a single digital product with an Allegro offer refuse the
entire catalogue's stock sync indefinitely.
A configured stockLocationIds is validated against the locations that exist, and an
unknown id aborts the run. Medusa reports zero available quantity for a location that does
not exist rather than failing, so a single typo produced the same catastrophe as the empty
case below: every variant reads 0, the plan looks safe, and the whole catalogue is delisted
by a run that reports itself clean.
A store with no stock locations aborts the run. Medusa's retrieveAvailableQuantity
answers 0 for an empty location list rather than failing, so every variant would read
as out of stock, the plan would look perfectly safe, and the run would push a quantity of
0 across the whole catalogue and report itself complete - a full marketplace delisting
presented as a healthy sync. Create a stock location, or set stockLocationIds.
The offer listing is read before quantities. Paging a full catalogue is the slowest step in the run, so reading quantities first left every figure ageing across the whole pagination window before it was compared and written.
Conflicts are recorded, never resolved
Five mapping conflicts are recorded on allegro_offer.conflict, and a conflicted row is
stripped of its offer_id and its promoted flag so no write path can act on it:
| Conflict | Meaning | What to do |
| --------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------ |
| duplicate-sku | Two live offers claim one SKU, or two variants share one | Decide which one keeps it. The message names the competing
