> ## Documentation Index
> Fetch the complete documentation index at: https://docs.paymove.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> ## paymove integration rules for AI agents
>
> These rules are verified against the live API. Follow them exactly.
>
> 1. Amounts are INTEGERS in grosze (minor units). `1000` means 10.00 PLN. PLN is the only supported currency and there is no currency field in the API.
> 2. A float `price` (e.g. `12.99`) is SILENTLY TRUNCATED to 12 grosze and still returns HTTP 200. Always send an integer: `Math.round(pln * 100)`.
> 3. Authenticate with the `X-API-KEY` header. Never `Authorization: Bearer`. Sandbox keys start with `sk_test_`, production keys with `sk_live_`.
> 4. Server-side only. A call from a merchant page is rejected. Never put the key in frontend code.
> 5. Unknown request fields are silently ignored and still return HTTP 200 — a wrong body shape looks like success. Match the documented shape exactly.
> 6. A missing `externalId` returns HTTP 500 `Something went wrong`, not 400. A missing `price` or `details.returnUrl` returns HTTP 200 and a usable `redirectUrl` — no error at all. Validate the body yourself before sending it.
> 7. Always verify the `X-Paymove-Signature` header on incoming webhooks before trusting them: https://docs.paymove.io/en/webhook-signature.md
> 8. Errors are `{"status": <int>, "message": "<text>"}`. Branch on the HTTP status only — never on `message`, which is unstable and leaks internal class names.
> 9. There is no rate limiting, no HTTP 429, no HTTP 422, no `Idempotency-Key` header and no API versioning. Do not write code that handles them.
> 10. Re-POSTing an `externalId` that already exists returns HTTP 200 with the ORIGINAL `redirectUrl` and silently discards EVERY field you send — the new price, description and details are all ignored. Use `PATCH /api/pay/product/{productId}/subproduct/{externalId}` to change a price.
> 11. Webhooks fire only on `COMPLETED` by default, and `retries` defaults to `0` (no retries) unless you set it explicitly. Delivery counts as successful when the HTTP status equals `expectedCode` — the response body is never inspected.
> 12. Never fulfil an order on the `returnUrl` redirect. The checkout does not redirect there by itself: the customer has to click "back to shop", and inside the widget modal that redirect never happens. Fulfil only in the webhook handler, after verifying the signature.
> 13. Do not pin a version of `@paymove-io/sdk` — install the latest.
> 14. Full documentation index: https://docs.paymove.io/llms.txt · Copy-paste quickstart: https://docs.paymove.io/en/quickstart.md · Agent skill: https://docs.paymove.io/skill.md

# Quickstart

> Od zera do pierwszej płatności — komplet kodu do skopiowania: utworzenie płatności, przekierowanie i weryfikacja webhooka.

Ta strona prowadzi przez kompletną integrację bramki płatniczej: utworzenie płatności, przekierowanie klienta i bezpieczną obsługę powiadomienia o zapłacie. Kod jest niezależny od frameworka - przykłady w `curl` i Node.js.

## Czego potrzebujesz

| Dane                     | Skąd                                                                           |
| ------------------------ | ------------------------------------------------------------------------------ |
| `PAYMOVE_API_KEY`        | Klucz `sk_test_…` wygenerowany w [Panelu Paymove](https://panel.paymove.io)    |
| `PAYMOVE_PRODUCT_ID`     | UUID Twojego sklepu, otrzymany przy tworzeniu produktu                         |
| `PAYMOVE_WEBHOOK_SECRET` | Pole `signingSecret` z odpowiedzi na rejestrację webhooka                      |
| Publiczny adres webhooka | URL po Twojej stronie, dostępny z internetu - lokalnie np. przez tunel `ngrok` |

<Info>
  Utworzenie produktu i rejestracja webhooka to czynności jednorazowe. Jeśli jeszcze ich nie wykonałeś, zacznij od [Konfiguracji](/webhooks) i wróć tutaj.
</Info>

<Warning>
  Wszystkie wywołania wykonuj po stronie serwera. Klucz API nie może trafić do kodu frontendowego, a żądanie z przeglądarki zostanie odrzucone przez CORS.
</Warning>

## 1. Utwórz płatność

```bash theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
curl --request POST \
  --url https://gateway-api.sandbox.paymove.io/api/pay/product/$PAYMOVE_PRODUCT_ID/subproduct/pricing \
  --header 'Content-Type: application/json' \
  --header "X-API-KEY: $PAYMOVE_API_KEY" \
  --data '{
    "price": 1000,
    "externalId": "order-123",
    "details": {
      "returnUrl": "https://twoj-sklep.pl/platnosc/powrot",
      "productName": "Koszulka sportowa",
      "email": "klient@example.com"
    }
  }'
```

Odpowiedź:

```json theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
{
  "redirectUrl": "https://checkout.sandbox.paymove.io/891412c8-8717-4449-9543-e34112bec470?externalId=ec6RtwTZKb"
}
```

W Node.js:

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
async function createPayment(orderId, amountPln) {
  const response = await fetch(
    `https://gateway-api.sandbox.paymove.io/api/pay/product/${process.env.PAYMOVE_PRODUCT_ID}/subproduct/pricing`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-KEY": process.env.PAYMOVE_API_KEY,
      },
      body: JSON.stringify({
        price: Math.round(amountPln * 100), // grosze, zawsze liczba całkowita
        externalId: orderId,
        details: {
          returnUrl: "https://twoj-sklep.pl/platnosc/powrot",
          productName: "Koszulka sportowa",
        },
      }),
    }
  );

  if (!response.ok) {
    const error = await response.json(); // { status, message }
    throw new Error(`Paymove ${response.status}: ${error.message}`);
  }

  const { redirectUrl } = await response.json();
  return redirectUrl;
}
```

<Warning>
  `price` musi być liczbą całkowitą w groszach. Wartość `12.99` zostanie po cichu obcięta do **12 groszy**, a API i tak zwróci `200`. Stąd `Math.round(kwota * 100)` w przykładzie.
</Warning>

<Info>
  Zapisz u siebie parametr `externalId` z otrzymanego `redirectUrl` (tutaj `ec6RtwTZKb`). To wygenerowany przez Paymove skrót płatności - inny niż Twój `externalId` - i to nim sprawdzisz później status.
</Info>

<Info>
  **Pracujesz w Node.js lub TypeScripcie?** Zamiast ręcznego `fetch` użyj oficjalnego SDK - `npm install @paymove-io/sdk`. Pakiet jest na licencji MIT, nie ma żadnych zależności, wymaga Node 18+ i zawiera typy TypeScript. Sam składa zagnieżdżone body żądania i zwraca typowane błędy. Szczegóły: [SDK JavaScript](/sdk/javascript).

  SDK **nie zawiera** funkcji weryfikacji podpisu webhooka - krok 3 piszesz samodzielnie niezależnie od wybranej drogi.
</Info>

## 2. Przekieruj klienta

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
res.redirect(303, redirectUrl);
```

Klient trafia na checkout Paymove, wybiera metodę płatności i finalizuje transakcję. Po zakończeniu widzi ekran potwierdzenia z przyciskiem „Wróć do sklepu" - i dopiero kliknięcie przenosi go pod adres podany w `details.returnUrl`.

<Warning>
  Powrót na `returnUrl` **nie oznacza, że płatność się powiodła** - i nie musi w ogóle nastąpić. Klient, który zamknie kartę, nigdy tam nie trafi, a sam adres można otworzyć bezpośrednio, bez płacenia. Na tej stronie wyświetl jedynie komunikat „przetwarzamy płatność" - zamówienie realizuj dopiero po webhooku.
</Warning>

## 3. Odbierz i zweryfikuj webhook

To jedyne wiarygodne potwierdzenie zapłaty. Poniższy handler robi cztery rzeczy: pobiera surowe body, weryfikuje podpis, odpowiada natychmiast i realizuje zamówienie idempotentnie.

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
const express = require("express");
const crypto = require("crypto");

const app = express();

function verifyWebhook(secret, signatureHeader, rawBody) {
  if (!signatureHeader) return false;

  const [tPart, signature] = signatureHeader.split(",", 2);
  if (!tPart?.startsWith("t=") || !signature?.startsWith("v1=")) return false;

  const timestamp = tPart.slice(2);

  // Okno tolerancji 5 minut - ochrona przed powtórzeniem starego żądania
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`, "utf8")
      .digest("base64");

  // timingSafeEqual rzuca wyjątkiem przy różnej długości - sprawdź ją najpierw
  if (expected.length !== signature.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

app.post(
  "/api/payments/webhook",
  express.raw({ type: "application/json" }), // surowe body - bez tego podpis się nie zgodzi
  async (req, res) => {
    const rawBody = req.body.toString("utf8");

    if (!verifyWebhook(process.env.PAYMOVE_WEBHOOK_SECRET, req.get("X-Paymove-Signature"), rawBody)) {
      return res.status(401).json({ error: "invalid signature" });
    }

    const event = JSON.parse(rawBody);

    // Odpowiedz natychmiast - Paymove nie stosuje limitu czasu na tym wywołaniu
    res.status(200).json({ status: "ok" });

    // Realizacja asynchroniczna i idempotentna
    const orderId = event.externalId ?? event.orderId;
    try {
      await fulfillOrderOnce(orderId);
    } catch (err) {
      console.error("fulfilment failed", orderId, err);
    }
  }
);
```

Twój serwer musi odpowiedzieć kodem równym `expectedCode` (domyślnie `200`) - treść odpowiedzi nie jest sprawdzana.

<Warning>
  Realizuj zamówienie **idempotentnie**, po `externalId`. Paymove może doręczyć to samo powiadomienie ponownie, a podwójna realizacja oznacza wysłanie towaru dwa razy.
</Warning>

<Info>
  Domyślnie webhook przychodzi wyłącznie dla statusu `COMPLETED`. Jeśli potrzebujesz powiadomień także o anulowaniach i błędach, napisz na [integration@paymove.io](mailto:integration@paymove.io). Pełen opis: [Statusy płatności](/payment-status).
</Info>

## 4. Awaryjne sprawdzenie statusu

Jeśli klient wrócił na `returnUrl`, a webhook jeszcze nie dotarł, możesz odpytać o status. Użyj skrótu płatności zapisanego w kroku 1:

```bash theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
curl "https://pay-api.sandbox.paymove.io/api/payment/product/$PAYMOVE_PRODUCT_ID/subproduct/ec6RtwTZKb/status"
```

<Warning>
  Zwróć uwagę na adres: to zapytanie obsługuje **inny host** (`pay-api.sandbox.paymove.io`, na produkcji `pay-api.paymove.io`) niż tworzenie płatności. Przez `gateway-api` ta ścieżka w ogóle nie jest routowana i zwróci `404` z tekstem `No route found for: GET …`. Endpoint nie wymaga klucza API.
</Warning>

```json theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
{
  "status": "COMPLETED",
  "orderId": "PAY1784798914400"
}
```

Traktuj to jako uzupełnienie, a nie zamiennik webhooka - to on jest źródłem prawdy o płatności.

## Zanim wypuścisz na produkcję

* Zmień adres bazowy na `https://api.paymove.io` i klucz na `sk_live_…`.
* Sprawdź, czy `externalId` jest unikalny dla każdego zamówienia - ponowne użycie zwróci starą płatność ze starą kwotą.
* Upewnij się, że kwota trafiająca do `price` powstaje po stronie serwera, a nie przychodzi z przeglądarki.
* Ustaw `retries` przy rejestracji webhooka - domyślnie wynosi `0`, czyli brak ponowień.

## Co dalej

<CardGroup cols={2}>
  <Card title="REST API" icon="code" href="/rest-api">
    Wszystkie parametry, pełne odpowiedzi i zmiana kwoty płatności.
  </Card>

  <Card title="Weryfikacja podpisu" icon="shield-check" href="/webhook-signature">
    Kod w Node.js, Pythonie i Javie oraz wektor testowy.
  </Card>

  <Card title="Kody błędów" icon="triangle-exclamation" href="/errors">
    Co znaczy każdy błąd i jak go obsłużyć.
  </Card>

  <Card title="SDK JavaScript" icon="cube" href="/sdk/javascript">
    Gotowy klient dla Node.js.
  </Card>
</CardGroup>
