Outsourcing16 czerwca 2026

Integracja Paynow (mBank) z Medusa — poradnik wdrożenia

Integracja Paynow z Medusa krok po kroku — payment provider, podpis HMAC, webhooki i cztery pułapki z wdrożenia, których nie ma w dokumentacji.

#Paynow#Medusa.js#integracja płatności#BLIK#headless commerce#mBank#payment provider#webhook#e-commerce#Node.js
Integracja Paynow z Medusa.js — schemat przepływu płatności i payment providera w headless e-commerce

Większość poradników o integracji płatności kończy się na „zarejestruj payment provider i obsłuż webhook". Praktyka wygląda inaczej. Ten tekst to integracja Paynow z Medusa przeprowadzona od zera do działającej produkcji — z kompletem kodu i, co ważniejsze, z pułapkami, które kosztują kilka godzin debugowania, a nie ma ich w żadnej dokumentacji.

Paynow to bramka płatnicza mBanku (BLIK, karty, szybkie przelewy, Google Pay, Apple Pay). Medusa to headless framework e-commerce w Node.js — backend sklepu, do którego frontend piszesz osobno. Medusa nie ma oficjalnej wtyczki Paynow, więc bramkę podpinasz jako własny payment provider. Brzmi prosto. Diabeł siedzi w czterech-pięciu miejscach, w których Medusa v2 zachowuje się inaczej, niż się spodziewasz.

Tekst jest dla osób, które stawiają sklep na Medusa v2 i chcą podłączyć Paynow zamiast Stripe czy Przelewy24. Zakłada znajomość TypeScriptu i podstaw Medusa.

Spis treści

Architektura: payment provider w Medusa v2

W Medusa v2 płatności obsługuje payment module. Każda metoda płatności to instancja klasy dziedziczącej po AbstractPaymentProvider. Twój moduł trzymasz w src/modules/paynow/ i rejestrujesz w medusa-config.ts. Wzorzec i pełną listę metod znajdziesz w dokumentacji Medusa oraz w źródłach na GitHubie.

Provider implementuje zestaw metod, które Medusa wywołuje na różnych etapach: initiatePayment (tworzy płatność po stronie Paynow), authorizePayment, capturePayment, cancelPayment, refundPayment, getPaymentStatus oraz getWebhookActionAndData (do obsługi notyfikacji). Komunikacja z Paynow idzie przez REST API w wersji v3 — to ważne, bo w sieci krąży sporo przykładów na starym v1. Aktualne endpointy to POST /v3/payments, GET /v3/payments/{id}/status i POST /v3/payments/{id}/refunds (dokumentacja deweloperska Paynow).

Jedna rzecz, którą warto zapamiętać od razu: id providera w Medusa ma format pp_{id}_{identifier}. Przy module o id: "paynow" i statycznym identifier = "paynow" pełne id to pp_paynow_paynow. Tym ciągiem posługujesz się we frontendzie (wybór metody) i w panelu — i, jak się okaże, to właśnie on stoi za jedną z pułapek.

Schemat integracji Paynow z Medusa.js — od checkoutu klienta przez API do potwierdzenia płatności
Schemat integracji Paynow z Medusa.js — od checkoutu klienta przez API do potwierdzenia płatności

Wymagania, klucze i rozdzielenie sandbox/produkcja

Zanim napiszesz pierwszą linijkę, przygotuj:

  • Konto Paynow Merchant — rejestracja przez paynow.pl z weryfikacją firmy (NIP, CEIDG lub KRS).
  • Klucze APIApi-Key i Signature-Key. Sandbox i produkcja mają rozłączne klucze i osobne panele.
  • Działający backend Medusa v2 z PostgreSQL i Redisem — najprościej w Dockerze. Jeśli odświeżasz podstawy, mamy osobny poradnik Dockera od Dockerfile do YAML.
  • Publiczny endpoint HTTPS na webhook — Paynow nie zna localhost. Na czas developmentu użyj ngrok lub Cloudflare Tunnel.

Pierwsza decyzja architektoniczna, która oszczędza nerwów: nie podmieniaj kluczy przy przełączaniu środowiska. Trzymaj oba komplety i wybieraj je na podstawie adresu API. Dzięki temu przejście sandbox → produkcja to zmiana jednej zmiennej, a nie ręczne kopiowanie sekretów (i ryzyko, że na produkcji zostaną klucze testowe).

// medusa-config.ts
const paynowApiUrl = process.env.PAYNOW_API_URL || 'https://api.sandbox.paynow.pl';
const isSandbox = paynowApiUrl.includes('sandbox');
const paynowApiKey = isSandbox
  ? process.env.PAYNOW_SANDBOX_API_KEY
  : process.env.PAYNOW_API_KEY;
const paynowSignatureKey = isSandbox
  ? process.env.PAYNOW_SANDBOX_SIGNATURE_KEY
  : process.env.PAYNOW_SIGNATURE_KEY;

module.exports = defineConfig({
  modules: [
    {
      resolve: '@medusajs/medusa/payment',
      options: {
        providers: [
          {
            resolve: './src/modules/paynow',
            id: 'paynow',
            options: {
              apiKey: paynowApiKey,
              signatureKey: paynowSignatureKey,
              apiUrl: paynowApiUrl,
              returnUrl: process.env.PAYNOW_RETURN_URL,
            },
          },
        ],
      },
    },
  ],
});

Klucze produkcyjne nie zadziałają na endpoincie sandbox i odwrotnie — to nie ten sam system z flagą, to dwa osobne środowiska. Jeśli zobaczysz 401/403 mimo poprawnego kodu, najpierw sprawdź, czy klucz pasuje do adresu API.

Podpis HMAC: żądania i webhooki

Każde żądanie do Paynow wymaga trzech nagłówków: Api-Key, Idempotency-Key (UUID — Paynow odrzuca duplikaty) oraz Signature. I tu jest pierwsza rozbieżność, która potrafi zjeść godzinę: podpis żądania i podpis notyfikacji liczy się inaczej.

Podpis żądania to HMAC-SHA256 (kodowany Base64) policzony nie z samego body, ale z ustrukturyzowanego obiektu zawierającego wybrane nagłówki, parametry zapytania i body:

import crypto from 'node:crypto';

function signatureV3(signatureKey: string, apiKey: string, idempotencyKey: string, body: string) {
  const payload = {
    headers: { 'Api-Key': apiKey, 'Idempotency-Key': idempotencyKey },
    parameters: {},
    body, // dokładnie ten sam string, który wysyłasz
  };
  return crypto.createHmac('sha256', signatureKey)
    .update(JSON.stringify(payload))
    .digest('base64');
}

Podpis notyfikacji (webhooka) jest prostszy — to HMAC-SHA256 policzony z surowego body przesłanego przez Paynow, tym samym kluczem Signature-Key:

function signatureNotification(signatureKey: string, rawBody: string) {
  return crypto.createHmac('sha256', signatureKey).update(rawBody).digest('base64');
}

Najczęstszy błąd na tym etapie to podpisywanie przeparsowanego i ponownie zserializowanego JSON-a. JSON.parse + JSON.stringify zmienia kolejność kluczy i białe znaki, więc podpis przestaje pasować. Musisz mieć dostęp do dokładnie tego stringa, który poszedł na wyjściu (przy żądaniach) albo przyszedł na wejściu (przy webhookach). W Medusa v2 surowe body na trasie webhooka włączasz jawnie:

// src/api/middlewares.ts
export const config = {
  routes: [
    { matcher: '/webhooks/paynow', method: ['POST'], bodyParser: { preserveRawBody: true } },
  ],
};

Do porównania podpisów użyj crypto.timingSafeEqual, a nie zwykłego === — to standardowa ochrona przed atakami czasowymi (szczegóły w dokumentacji modułu crypto Node.js).

Architektura payment provider w Medusa v2 — klasa rozszerzająca AbstractPaymentProvider i przepływ metod
Architektura payment provider w Medusa v2 — klasa rozszerzająca AbstractPaymentProvider i przepływ metod

initiatePayment i pułapka z externalId

W initiatePayment robisz POST /v3/payments z kwotą w groszach (nie w złotówkach — 129,99 zł to 12999). Z odpowiedzi zapisujesz paymentId i redirectUrl (adres, na który frontend przekierowuje klienta). Do Paynow przekazujesz też externalId — i to jest sedno pierwszej dużej pułapki.

externalId MUSI być równe cart_id. Webhook po zakończonej płatności dostaje z powrotem externalId i po nim odnajduje koszyk, żeby zamienić go w zamówienie. Jeśli ustawisz externalId na id sesji płatności albo losowy ciąg, płatność się powiedzie, ale zamówienie nigdy nie powstanie — webhook nie znajdzie koszyka.

Tu wchodzi pułapka numer dwa, tym razem po stronie frontendu. Naturalnym odruchem jest przekazanie cart_id w polu context przy tworzeniu sesji płatności. Store API Medusa v2 to odrzuca z błędem Unrecognized fields: 'context'. Poprawnie przekazujesz dane w polu data:

// frontend: tworzenie sesji płatności
await fetch(`/store/payment-collections/${collectionId}/payment-sessions`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'x-publishable-api-key': KEY },
  body: JSON.stringify({
    provider_id: 'pp_paynow_paynow',
    data: { cart_id: cartId, email },  // NIE 'context'
  }),
});

A w providerze odczytujesz cart_id z input.data i ustawiasz je jako externalId:

async initiatePayment(input) {
  const data = (input.data ?? {}) as Record<string, unknown>;
  const externalId = (data.cart_id as string) ?? `medusa-${input.context?.idempotency_key}`;
  const amountGrosze = Math.round(Number(input.amount) * 100);
  const res = await this.client.createPayment({
    amount: amountGrosze,
    currency: 'PLN',
    externalId,
    description: `Zamówienie ${externalId}`,
    continueUrl: `${this.returnUrl}?ref=${externalId}`,
    buyer: { email: (data.email as string) ?? '[email protected]' },
  });
  return { id: res.paymentId, data: { paynowPaymentId: res.paymentId, redirectUrl: res.redirectUrl, externalId } };
}

Z naszego doświadczenia ten jeden szczegół — data zamiast context i externalId === cart_id — przeszedł niezauważony aż do testu pełnej ścieżki w przeglądarce. Smoke test samego backendu nie wykrył go, bo nie wysyłał pola context. Wniosek: testuj realną ścieżką użytkownika, nie tylko żądaniami curl-em.

Webhook i finalizacja zamówienia

Po płatności Paynow wysyła POST z notyfikacją na adres ustawiony w panelu Merchant (nie w kodzie — w Paynow v3 adres notyfikacji konfigurujesz w panelu, osobno dla sandbox i produkcji). Body zawiera paymentId, externalId i status.

I tu czeka najboleśniejsza pułapka. Intuicyjnie chcesz w trasie webhooka pobrać instancję providera z kontenera, żeby skorzystać z jego logiki weryfikacji podpisu:

const provider = req.scope.resolve('pp_paynow_paynow'); // ❌ "Could not resolve 'pp_paynow_paynow'"

To nie zadziała. Payment provider nie jest zarejestrowany w kontenerze żądania pod tym kluczem, więc resolve rzuca wyjątkiem. Efekt jest okrutny: webhook zwraca 200 (bo wyjątek łapiesz), Paynow uznaje notyfikację za dostarczoną, a zamówienie nie powstaje mimo pobranych środków.

Najpewniejsze rozwiązanie to weryfikacja podpisu inline w trasie webhooka, bez sięgania po providera, a następnie wywołanie completeCartWorkflow:

// src/api/webhooks/paynow/route.ts
export async function POST(req, res) {
  const rawBody = typeof req.rawBody === 'string' ? req.rawBody : req.rawBody?.toString('utf8') ?? '';
  const expected = signatureNotification(getSignatureKey(), rawBody);
  const got = pickHeader(req.headers, 'signature');
  if (!got || !constantTimeEqual(expected, got)) {
    return res.status(200).json({ received: true, processed: false }); // nie ujawniaj szczegółów
  }

  const { status, externalId } = req.body;
  if (paynowToAction(status) !== 'captured') {
    return res.status(200).json({ received: true, processed: true }); // status nieterminalny — nic nie rób
  }

  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
  const { data: [cart] } = await query.graph({ entity: 'cart', filters: { id: externalId }, fields: ['id', 'completed_at'] });
  if (!cart) return res.status(200).json({ received: true, processed: false });
  if (cart.completed_at) return res.status(200).json({ received: true, processed: true }); // idempotencja

  await completeCartWorkflow(req.scope).run({ input: { id: cart.id } });
  return res.status(200).json({ received: true, processed: true });
}

Dwie rzeczy obsłuż od razu. Idempotencja: Paynow ponawia webhook, dopóki nie dostanie 200 — sprawdzaj completed_at, żeby nie utworzyć zamówienia dwa razy. Kolejność: notyfikacje NEW, PENDING, CONFIRMED mogą przyjść w dowolnej kolejności, a nawet wielokrotnie — reaguj tylko na CONFIRMED, resztę kwituj 200 bez akcji.

Webhook Paynow w Medusa — weryfikacja podpisu HMAC i mapowanie statusów płatności na zamówienia
Webhook Paynow w Medusa — weryfikacja podpisu HMAC i mapowanie statusów płatności na zamówienia

Włączenie providera w regionie

Provider jest napisany i zarejestrowany, ale w checkoucie nadal go nie ma. W Medusa v2 metoda płatności musi być przypisana do regionu. I tu kolejna pułapka: powiązanie region–provider to relacja przez link module, której nie zapiszesz wywołaniem regionModule.updateRegions({ payment_providers: [...] }). Metoda modułu po cichu to ignoruje — skrypt „dodaje" providera w kółko, a Store API dalej zwraca pustą listę metod.

Działają dwie drogi: Admin API (POST /admin/regions/{id} z polem payment_providers) albo updateRegionsWorkflow z core-flows — i to ten sam mechanizm, którego używa panel admina:

import { updateRegionsWorkflow } from '@medusajs/medusa/core-flows';

await updateRegionsWorkflow(container).run({
  input: { selector: { id: region.id }, update: { payment_providers: ['pp_paynow_paynow'] } },
});

Weryfikacja zajmuje sekundę: GET /store/payment-providers?region_id=... powinno teraz zwrócić pp_paynow_paynow.

Statusy płatności i ich mapowanie

Provider tłumaczy statusy Paynow na statusy sesji płatności Medusa oraz na akcję webhooka. Tak wygląda mapowanie sprawdzone w boju:

Status PaynowZnaczenieStatus sesji MedusaAkcja webhooka
NEWpłatność utworzonarequires_morebrak (pending)
PENDINGklient na ekranie płatnościpendingbrak (pending)
WAITING_FOR_CONFIRMATIONoczekiwanie na potwierdzeniependingbrak (pending)
CONFIRMEDsukces, środki pobranecapturedfinalizuj zamówienie
REJECTEDbank lub klient odrzuciłerroroznacz nieudaną
ERRORbłąd techniczny Paynowerroroznacz nieudaną
EXPIREDsesja wygasłacanceledanuluj
ABANDONEDklient zamknął oknocanceledanuluj

Tylko CONFIRMED tworzy zamówienie. Pozostałe statusy kwitujesz 200 i — jeśli chcesz — aktualizujesz stan sesji, żeby frontend wiedział, co pokazać.

Pułapki po stronie sklepu: potwierdzenie i e-mail

Płatność działa, zamówienie powstaje. Zostają dwie rzeczy, które potrafią popsuć doświadczenie klienta mimo poprawnego backendu.

Strona potwierdzenia dla gościa. Po płatności frontend chce pokazać „Dziękujemy za zamówienie #X". Problem: zapytanie GET /store/orders?cart_id=... wymaga zalogowanego klienta, więc dla zakupu jako gość zwróci pustkę. Rozwiązanie: w webhooku, po completeCartWorkflow, zapisz numer zamówienia w metadanych koszyka — koszyk jest czytelny przez Store API z kluczem publicznym, więc strona potwierdzenia odczyta order_id także dla gościa.

const { result: order } = await completeCartWorkflow(req.scope).run({ input: { id: cart.id } });
const cartModule = req.scope.resolve(Modules.CART);
await cartModule.updateCarts(cart.id, {
  metadata: { ...(cart.metadata ?? {}), order_id: order.id, order_display_id: order.display_id },
});

E-mail z potwierdzeniem. Jeśli Twój subscriber order.placed zawsze dokleja instrukcję przelewu („wpłać na konto…"), to klient, który właśnie zapłacił online, dostanie prośbę o ponowną zapłatę. Klasyczna droga do podwójnej płatności. Treść e-maila uzależnij od statusu płatności: dla opłaconych online pokaż „Opłacono online", a dane do przelewu zostaw wyłącznie dla zamówień nieopłaconych.

Testy w sandbox i checklista przed produkcją

Sandbox Paynow jest pełnoprawny — ma osobny panel, klucze i symulator metod. Listę scenariuszy znajdziesz w dokumentacji środowiska sandbox Paynow. Do testów BLIK użyj kodów testowych:

  • 111111 — płatność udana (CONFIRMED),
  • 333333 — odrzucona (REJECTED),
  • 222222 — w toku (PENDING),
  • 444444 — błąd po ~20 s (ERROR).

Zanim przełączysz produkcję, przejdź realną ścieżką przez przeglądarkę (nie tylko curl-em) i odhacz:

  1. Klucze produkcyjne w env i API_URL bez „sandbox".
  2. Notification URL w panelu produkcyjnym Paynow — to inny panel niż sandbox; ustawienie z sandboxa tu nie zadziała.
  3. Provider przypisany do regionu PLN — GET /store/payment-providers to potwierdza.
  4. Surowe body na webhooku (preserveRawBody) — inaczej podpis się nie zgodzi.
  5. Idempotencja — drugi webhook nie tworzy drugiego zamówienia.
  6. Strona potwierdzenia i e-mail — gość widzi numer zamówienia, nikt nie dostaje prośby o ponowną wpłatę.

Pierwszą prawdziwą transakcję na produkcji najlepiej puścić ręcznie, z otwartym podglądem logów webhooka — zobaczysz, czy CONFIRMED realnie domyka koszyk.

Bezpieczne wdrożenie płatności

Integracja Paynow z Medusa to zwykle 2–4 dni roboczych z testami sandbox i przeniesieniem na produkcję — pod warunkiem, że ominiesz opisane pułapki. Jeśli Twój zespół ma teraz inne priorytety, wdrażamy i rozliczamy płatności e-commerce w FoxLink — od audytu checkoutu, przez podpięcie bramki, po monitoring webhooków na produkcji. Robimy też szersze projekty sklepowe i strony dla firm oraz automatyzację procesów e-commerce — faktury, magazyn, wysyłki.

Podsumowanie

  • Paynow podpinasz do Medusa v2 jako własny payment provider — komunikacja przez API v3, kwoty w groszach, id providera pp_paynow_paynow.
  • Podpis żądania liczysz z obiektu { headers, parameters, body }, a podpis webhooka z surowego body — to dwa różne algorytmy.
  • Cztery pułapki, które kosztują najwięcej czasu: data zamiast context przy sesji płatności, externalId === cart_id, brak resolve providera w webhooku (weryfikuj podpis inline) oraz updateRegionsWorkflow zamiast updateRegions przy włączaniu metody w regionie.
  • Przed produkcją: klucze i URL produkcyjne, Notification URL w panelu produkcyjnym, realny test BLIK-iem i podgląd logów webhooka.

Powiązana usługa

Outsourcing IT

Informatyk na abonament, Microsoft 365, serwery, migracja do chmury

Potrzebujesz pomocy IT?

Skontaktuj się z nami i dowiedz się, jak możemy pomóc Twojemu biznesowi

Napisz do nas