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
- Wymagania, klucze i rozdzielenie sandbox/produkcja
- Podpis HMAC: żądania i webhooki
- initiatePayment i pułapka z externalId
- Webhook i finalizacja zamówienia
- Włączenie providera w regionie
- Statusy płatności i ich mapowanie
- Pułapki po stronie sklepu: potwierdzenie i e-mail
- Testy w sandbox i checklista przed produkcją
- Najczęściej zadawane pytania
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.

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 API —
Api-KeyiSignature-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).

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.

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 Paynow | Znaczenie | Status sesji Medusa | Akcja webhooka |
|---|---|---|---|
NEW | płatność utworzona | requires_more | brak (pending) |
PENDING | klient na ekranie płatności | pending | brak (pending) |
WAITING_FOR_CONFIRMATION | oczekiwanie na potwierdzenie | pending | brak (pending) |
CONFIRMED | sukces, środki pobrane | captured | finalizuj zamówienie |
REJECTED | bank lub klient odrzucił | error | oznacz nieudaną |
ERROR | błąd techniczny Paynow | error | oznacz nieudaną |
EXPIRED | sesja wygasła | canceled | anuluj |
ABANDONED | klient zamknął okno | canceled | anuluj |
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:
- Klucze produkcyjne w env i
API_URLbez „sandbox". - Notification URL w panelu produkcyjnym Paynow — to inny panel niż sandbox; ustawienie z sandboxa tu nie zadziała.
- Provider przypisany do regionu PLN —
GET /store/payment-providersto potwierdza. - Surowe body na webhooku (
preserveRawBody) — inaczej podpis się nie zgodzi. - Idempotencja — drugi webhook nie tworzy drugiego zamówienia.
- 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:
datazamiastcontextprzy sesji płatności,externalId === cart_id, brakresolveprovidera w webhooku (weryfikuj podpis inline) orazupdateRegionsWorkflowzamiastupdateRegionsprzy 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.



