Overview
Icon for POS Payments (Brazil)

POS Payments (Brazil)

Brazilian card terminal and PIX payments

Medusa POS Payments

In-person Brazilian card-terminal (maquininha) payments for Medusa v2.

Copy to clipboard@voolulabs/medusajs-plugin-pos-payments adds a Copy to clipboardpos-terminal 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

Features

  • Copy to clipboardpos-terminal payment provider registered as Copy to clipboardpp_pos-terminal_card, Copy to clipboardpp_pos-terminal_pix, Copy to clipboardpp_pos-terminal_cash, Copy to clipboardpp_pos-terminal_transfer
  • Manual / terminal-present flow: no acquirer credentials required in Phase 1
  • Authenticated health endpoint Copy to clipboardGET /admin/pos-payments/health
  • Provider ids consumed verbatim by the POS app from Copy to clipboardstore.metadata.pos — zero front-end changes
  • State kept in Medusa payment Copy to clipboarddata/Copy to clipboardmetadata (JSONB) — no migrations, no extra tables
  • Hardened session-data handling: zod validation at the boundary, prototype-pollution rejection

Prerequisites

How to Install

  1. Run the following command in your Medusa backend project:
npm install @voolulabs/medusajs-plugin-pos-payments
  1. Enable the manual providers in your Copy to clipboard.env:
POS_PAYMENTS_MANUAL=true
  1. In Copy to clipboardmedusa-config.js, register the plugin (routes) and the provider(s) (payment module):
// 1. routes — in the top-level `plugins` array:
plugins: [
// ...
{
resolve: "@voolulabs/medusajs-plugin-pos-payments",
options: {},
},
]
// 2. provider — in `modules`, on the payment module:
modules: [
{
resolve: "@medusajs/payment",
options: {
providers: [
...(process.env.POS_PAYMENTS_MANUAL === "true"
? ["card", "pix", "cash", "transfer"].map((method) => ({
resolve: "@voolulabs/medusajs-plugin-pos-payments/providers/pos-terminal",
id: method,
options: { acquirer: "manual" },
  1. Enable the Copy to clipboardpp_pos-terminal_* providers on the region your POS uses (e.g. in the seed or via the admin API).

Mercado Pago (terminal charges — Fase 2)

Register the Copy to clipboardmercadopago provider on the payment module (alongside or instead of the manual ones) and provide the credentials via environment (never literals — CONSTRAINTS 8):

// in `modules`, on the payment module providers array:
{
resolve: "@voolulabs/medusajs-plugin-pos-payments/providers/pos-terminal",
id: "mercadopago",
options: {
acquirer: "mercadopago",
accessToken: process.env.MP_ACCESS_TOKEN, // App_USR-... (publisher/merchant token)
webhookSecret: process.env.MP_WEBHOOK_SECRET, // x-signature secret from the DevPanel
// mpPointTestMode: true, // sandbox virtual terminal (SBX*) — dev/homologation only
},
}
# .env
MP_ACCESS_TOKEN=APP_USR-...
MP_WEBHOOK_SECRET=...
# POS_PAYMENTS_MP_TEST=true # alternative to the option above

Webhooks and Reconciliation (mercadopago)

The provider registers a native webhook handler at Copy to clipboardPOST /hooks/payment/pos-terminal_mercadopago. 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 Copy to clipboardattempts: 3 and a Copy to clipboarddelay: 5000ms 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:

  1. Raise the internal budget on the payment module (Copy to clipboardwebhook_retries / Copy to clipboardwebhook_delay are core options read from the payment module registration — verified in the Copy to clipboard@medusajs/medusa 2.19 hook route):
modules: [
{
resolve: "@medusajs/payment",
options: {
webhook_retries: 6, // default 3
webhook_delay: 10000, // default 5000
providers: [/* ... */],
},
},
]
  1. Scheduled reconciliation job (registered automatically by this plugin, daily at 04:00): scans captured payments of Copy to clipboardpp_pos-terminal_mercadopago from the last 30 days (the charge state lives in Copy to clipboardpayment.data, 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 (Copy to clipboardFOR UPDATE under a mandatory transaction, verified in Copy to clipboard@medusajs/payment 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 Copy to clipboardposTerminal.acquirer: "mercadopago" block it does nothing.

The plugin also keeps both entries in your Copy to clipboardmedusa-config.js honest: if the provider entry of the payment module and the plugin's Copy to clipboardposTerminal 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 (Copy to clipboardcard/Copy to clipboardpix/Copy to clipboardcash/Copy to clipboardtransfer) are exempt, since they do not consume the block.

Cancellation contract (mercadopago)

Copy to clipboardPOST /admin/pos-payments/charges/:id/cancel 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 Copy to clipboardcreated 200 Copy to clipboard{chargeId, ...view} with Copy to clipboardstate: "canceled" (synchronous cancel) Copy to clipboardat_terminal 202 Copy to clipboard{chargeId, ...view} with Copy to clipboardstate: "awaiting_terminal" and Copy to clipboardcancelRequested: true — the cancellation is REQUESTED, not done: the order stays Copy to clipboardat_terminal until the webhook/poll confirms, and the terminal may prioritize the charge and capture anyway Copy to clipboardaction_required / Copy to clipboardexpired / Copy to clipboardprocessed 409 Copy to clipboard{code: "cannot_cancel_order", message, state} — refusal body is a public contract of this plugin already canceled 200 re-fetch confirms Copy to clipboardstate: "canceled" (idempotent); a diverging re-fetch answers 409 with the real Copy to clipboardstate (Copy to clipboardstate is Copy to clipboard"desconhecido" when the re-fetch itself fails) any other state echoed by a 2xx cancel 202 defensive: a successful cancel over Copy to clipboardat_terminal is async by contract — the body carries the state verbatim

The Copy to clipboardx-allow-cancelable-status: at_terminal header is sent unconditionally (MP ignores it for Copy to clipboardcreated and requires it for Copy to clipboardat_terminal). Poll responses carry Copy to clipboardcancelRequested: true 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"): Copy to clipboard<chargeId>:cancel for cancel, Copy to clipboard<sessionReference>:charge for create, Copy to clipboard<chargeId>:refund:<amountMinor> for refund.

Merchant Onboarding (mercadopago — Fase 2b)

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 Copy to clipboardMP_ACCESS_TOKEN until the per-merchant cutover (1.0.0 decision); the onboarding platform itself is fully merchant-scoped.

Platform configuration (deploy-side, never per-merchant)

# .env of the Medusa backend
POS_PAYMENTS_MASTER_KEY=<hex 32 bytes> # AES-256-GCM envelope for merchant credentials (engenharia §3.4)
POS_PAYMENTS_MP_CLIENT_ID=<app client id> # OAuth app (Point solution) from the DevPanel
POS_PAYMENTS_MP_CLIENT_SECRET=<app client secret> # exists after production credentials activation
POS_PAYMENTS_MP_REDIRECT_URI=https://<host>/pos-payments/callback/mercadopago # EXACT match, registered in the app
POS_PAYMENTS_MP_OAUTH_TEST_TOKEN=true # sandbox exchanges (test_token=true) — dev only
POS_PAYMENTS_ADMIN_PATH=/app # admin.path of the host (OAuth callback redirect base)

Every value can also be provided as plugin options (Copy to clipboardonboarding.mercadopago.* in Copy to clipboardmedusa-config.js); environment takes precedence. Missing pieces fail closed with a typed error (Copy to clipboardplatform_config). Run Copy to clipboardmedusa db:migrate on deploy — the onboarding module's tables do not migrate on Copy to clipboardmedusa start.

Route Purpose Copy to clipboardGET /admin/pos-payments/connections Per-acquirer connection summary (non-sensitive) Copy to clipboardGET/POST/DELETE /admin/pos-payments/connections/mercadopago Detail · pasted credential (validate-then-activate) · disconnect (purge secrets, keep audit) Copy to clipboardPOST /admin/pos-payments/connections/mercadopago/start Emits single-use Copy to clipboardstate + Copy to clipboardauthorize_url (OAuth) Copy to clipboardPOST /admin/pos-payments/connections/mercadopago/test Revalidates now (Copy to clipboardconnected / Copy to clipboarddegraded) with lazy token refresh Copy to clipboardGET/POST /admin/pos-payments/stores · Copy to clipboardGET/POST/DELETE .../stores/pos[/:id] Stores/POS CRUD (MP §10.1) — Copy to clipboardexternal_id binding: store = Medusa unit, POS = stock location Copy to clipboardPOST /admin/pos-payments/terminals/:id/select Selected terminal — global default or per-register binding Copy to clipboardGET/POST /admin/pos-payments/registers Registers reported by the POS app ("Terminais por caixa") Copy to clipboardGET /admin/pos-payments/health Plugin health + non-sensitive connection summary Copy to clipboardGET /pos-payments/callback/mercadopago Public OAuth callback — protected by the single-use server-side Copy to clipboardstate, 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; Copy to clipboardstate is CSPRNG, expires in ≤10 min and is consumed once; refresh rotates the token pair atomically (lazy single-flight); Copy to clipboardinvalid_grant moves the connection to Copy to clipboardaction_required: reauthorize — never a silent fallback to the platform token. Every onboarding operation is audited (actor + acquirer + timestamp, minimal payload).

Test the Plugin

  1. Run your Medusa backend (Copy to clipboardpnpm dev / Copy to clipboardpnpm start).
  2. Log in and list the providers enabled on your POS region — on Medusa 2.19 the listing lives on the Store API: The Copy to clipboardpp_pos-terminal_* providers must be listed (the admin API does not expose this route on 2.19).
curl "http://<backend>:9000/store/payment-providers?region_id=<region>" \
-H "x-publishable-api-key: pk_..."
  1. Place a test order with the provider Copy to clipboardpp_pos-terminal_card (draft order → payment collection → payment session → mark as paid) and confirm Copy to clipboardpayment_status is Copy to clipboardcaptured with the provider id preserved.

Additional Resources

You may also like

Browse all integrations

Build your own

Develop your own custom integration

Build your own integration with our API to speed up your processes. Make your integration available via npm for it to be shared in our Library with the broader Medusa community.

gift card interface

Ready to build your custom commerce setup?