Medusa v2 plugin for Brazilian terminal (maquininha) payments - shared platform + per-acquirer adapters behind one pos-terminal provider
In-person Brazilian card-terminal (maquininha) payments for Medusa v2.
adds a payment provider family to Medusa so a point-of-sale front end can take BRL payments made on physical card terminals (cash, card, Pix, bank transfer). Phase 1 ships the manual / terminal-present flow: the cashier confirms the charge made on the terminal, and the backend records the payment state. Acquirer adapters (Mercado Pago Point, SumUp, Stone, Cielo) plug into the same provider in later phases.
Documentation | Medusa Website | Medusa Repository
Run the following command in your Medusa backend project:
Enable the manual providers in your :
In , register the plugin (routes) and the provider(s) (payment module):
Enable the providers on the region your POS uses (e.g. in the seed or via the admin API).
Register the provider on the payment module (alongside or instead of the manual ones) and provide the credentials via environment (never literals — CONSTRAINTS 8):
The provider registers a native webhook handler at . Point your Mercado Pago application webhook at the full URL (application-level config in the developer panel; the panel summary shows only the domain — configure the complete path).
The core Medusa payment module emits the webhook event on an internal queue with and a ms by default. Mercado Pago retries its delivery for up to 22s and only while it does NOT receive a 2xx — so if every internal attempt fails, a terminal-originated refund could stay unreconciled. Two defenses, use both:
Raise the internal budget on the payment module ( / are core options read from the payment module registration — verified in the 2.19 hook route):
Scheduled reconciliation job (registered automatically by this plugin, daily at 04:00): scans captured payments of from the last 30 days (the charge state lives in , written by the provider's capture/refund), re-fetches each charge from the Mercado Pago Orders API and reconciles refunds that were missed by the event bus, reusing the same idempotent decision and transaction id as the webhook subscriber. The guard is the Medusa payment's refunds list; job×subscriber parallelism is serialized by the core's refund row lock ( under a mandatory transaction, verified in 2.19.0) plus the refund idempotency key at the acquirer — engine-level mutual exclusion remains a T5 residual tracked in ADR 0007. Volume: one GET per captured payment within the window, sequential, per daily run. The job is presence-gated: without the block it does nothing.
The plugin also keeps both entries in your honest: if the provider entry of the payment module and the plugin's block diverge (token/secret/test mode), the provider fails to resolve with the diverging key NAMES (providers are constructed lazily — first session, webhook or capture). Manual providers (///) are exempt, since they do not consume the block.
maps the Mercado Pago Orders API contract by charge state (official .mx docs, 2026-10-07 — note that Mercado Pago's docs currently diverge by region AND language; the .br pages are stale):
| Charge state at MP | HTTP | Body |
|---|---|---|
| 200 | with (synchronous cancel) | |
| 202 | with and — the cancellation is REQUESTED, not done: the order stays until the webhook/poll confirms, and the terminal may prioritize the charge and capture anyway | |
| / / | 409 | — refusal body is a public contract of this plugin |
| already canceled | 200 | re-fetch confirms (idempotent); a diverging re-fetch answers 409 with the real ( is when the re-fetch itself fails) |
| any other state echoed by a 2xx cancel | 202 | defensive: a successful cancel over is async by contract — the body carries the state verbatim |
The header is sent unconditionally (MP ignores it for and requires it for ). Poll responses carry while the cancellation is in flight. Idempotency keys are canonical UUIDv5 under a plugin-fixed namespace — deterministic, so retrying the same operation reuses the same key (docs accept "UUID v4 or random string"): for cancel, for create, for refund.
Merchants connect their own Mercado Pago account from the Medusa Admin (Settings → POS Payments) — no platform-held merchant token required for onboarding. The charging provider keeps using the platform until the per-merchant cutover (1.0.0 decision); the onboarding platform itself is fully merchant-scoped.
Every value can also be provided as plugin options ( in ); environment takes precedence. Missing pieces fail closed with a typed error (). Run on deploy — the onboarding module's tables do not migrate on .
| Route | Purpose |
|---|---|
| Per-acquirer connection summary (non-sensitive) | |
| Detail · pasted credential (validate-then-activate) · disconnect (purge secrets, keep audit) | |
| Emits single-use + (OAuth) | |
| Revalidates now ( / ) with lazy token refresh | |
| · | Stores/POS CRUD (MP §10.1) — binding: store = Medusa unit, POS = stock location |
| Selected terminal — global default or per-register binding | |
| Registers reported by the POS app ("Terminais por caixa") | |
| Plugin health + non-sensitive connection summary | |
| Public OAuth callback — protected by the single-use server-side , 302 back to the Admin |
Security properties: merchant tokens are encrypted at rest (AES-256-GCM envelope, master key outside the database) and never appear in API responses, URLs or logs; is CSPRNG, expires in ≤10 min and is consumed once; refresh rotates the token pair atomically (lazy single-flight); moves the connection to — never a silent fallback to the platform token. Every onboarding operation is audited (actor + acquirer + timestamp, minimal payload).
Run your Medusa backend ( / ).
Log in and list the providers enabled on your POS region — on Medusa 2.19 the listing lives on the Store API:
The providers must be listed (the admin API does not expose this route on 2.19).
Place a test order with the provider (draft order → payment collection → payment session → mark as paid) and confirm is with the provider id preserved.
npm install @voolulabs/medusajs-plugin-pos-paymentsPOS_PAYMENTS_MANUAL=true1// 1. routes — in the top-level `plugins` array:2plugins: [3 // ...4 {5 resolve: "@voolulabs/medusajs-plugin-pos-payments",6 options: {},7 },8]9
10// 2. provider — in `modules`, on the payment module:11modules: [12 {13 resolve: "@medusajs/payment",14 options: {15 providers: [16 ...(process.env.POS_PAYMENTS_MANUAL === "true"17 ? ["card", "pix", "cash", "transfer"].map((method) => ({18 resolve: "@voolulabs/medusajs-plugin-pos-payments/providers/pos-terminal",19 id: method,20 options: { acquirer: "manual" },21 }))22 : []),23 ],24 },25 },26]1// in `modules`, on the payment module providers array:2{3 resolve: "@voolulabs/medusajs-plugin-pos-payments/providers/pos-terminal",4 id: "mercadopago",5 options: {6 acquirer: "mercadopago",7 accessToken: process.env.MP_ACCESS_TOKEN, // App_USR-... (publisher/merchant token)8 webhookSecret: process.env.MP_WEBHOOK_SECRET, // x-signature secret from the DevPanel9 // mpPointTestMode: true, // sandbox virtual terminal (SBX*) — dev/homologation only10 },11}1# .env2MP_ACCESS_TOKEN=APP_USR-...3MP_WEBHOOK_SECRET=...4# POS_PAYMENTS_MP_TEST=true # alternative to the option above1modules: [2 {3 resolve: "@medusajs/payment",4 options: {5 webhook_retries: 6, // default 36 webhook_delay: 10000, // default 50007 providers: [/* ... */],8 },9 },10]1# .env of the Medusa backend2POS_PAYMENTS_MASTER_KEY=<hex 32 bytes> # AES-256-GCM envelope for merchant credentials (engenharia §3.4)3POS_PAYMENTS_MP_CLIENT_ID=<app client id> # OAuth app (Point solution) from the DevPanel4POS_PAYMENTS_MP_CLIENT_SECRET=<app client secret> # exists after production credentials activation5POS_PAYMENTS_MP_REDIRECT_URI=https://<host>/pos-payments/callback/mercadopago # EXACT match, registered in the app6POS_PAYMENTS_MP_OAUTH_TEST_TOKEN=true # sandbox exchanges (test_token=true) — dev only7POS_PAYMENTS_ADMIN_PATH=/app # admin.path of the host (OAuth callback redirect base)1curl "http://<backend>:9000/store/payment-providers?region_id=<region>" \2 -H "x-publishable-api-key: pk_..."