> ## 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

# Weryfikacja podpisu webhooka

> Jak sprawdzić nagłówek X-Paymove-Signature — algorytm HMAC-SHA256 i gotowy kod w Node.js, Pythonie i Javie.

Paymove podpisuje każde wychodzące żądanie webhooka algorytmem HMAC-SHA256. Dzięki temu możesz potwierdzić, że powiadomienie faktycznie pochodzi od Paymove, a nie od kogoś, kto poznał adres Twojego endpointu.

<Warning>
  Weryfikacja podpisu jest obowiązkowa. Bez niej dowolna osoba znająca Twój `endpoint` może wysłać spreparowane powiadomienie o płatności i uzyskać realizację zamówienia bez zapłaty.
</Warning>

## Nagłówek

Każde żądanie zawiera jeden nagłówek podpisu:

```
X-Paymove-Signature: t=1708790400,v1=K4oSHnBOYVPuGCRnKv8bDLACmwbHBZPxPC+r24/lqHk=
```

| Element | Opis                                                                                                                           |
| ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `t=`    | Czas podpisania w sekundach epoki uniksowej                                                                                    |
| `v1=`   | Podpis HMAC-SHA256 zakodowany w base64. Prefiks `v1` pozwoli w przyszłości zmienić algorytm bez psucia istniejących integracji |

## Sekret podpisujący

Każdy webhook ma własny sekret w formacie `whsec_<base64>`. Otrzymujesz go w odpowiedzi na [rejestrację webhooka](/webhooks#2-rejestracja-webhooka), w polu `signingSecret`.

<Warning>
  Jako klucza HMAC użyj **całego łańcucha razem z prefiksem `whsec_`**, zakodowanego w UTF-8. Nie odcinaj prefiksu i nie dekoduj części base64 - to najczęstsza przyczyna niedziałającej weryfikacji.
</Warning>

Jeśli zgubisz sekret, odczytasz go ponownie w odpowiedzi `GET /api/pay/plugin/webhook/{webhookId}` - nie musisz go z tego powodu wymieniać.

## Algorytm

1. Odczytaj nagłówek `X-Paymove-Signature`.
2. Podziel wartość po przecinku na część `t=<timestamp>` i `v1=<podpis>`.
3. Zbuduj podpisywaną treść: `{timestamp}.{surowe_body_żądania}`.
4. Policz HMAC-SHA256, używając pełnego sekretu (z prefiksem) jako klucza.
5. Zakoduj wynik w base64 i poprzedź go `v1=`.
6. Porównaj z podpisem z nagłówka, używając porównania odpornego na atak czasowy.

<Warning>
  Podpis liczony jest z **surowego body żądania**, bajt w bajt. Jeśli Twój framework sparsuje JSON i zserializuje go ponownie, wynik prawie na pewno się nie zgodzi. Przechwyć surowe body przed parsowaniem - w Express przez `express.raw({ type: "application/json" })`, w Next.js przez `await request.text()`.
</Warning>

## Kod

### Node.js

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

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 hash = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("base64");
  const expected = `v1=${hash}`;

  // 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));
}
```

Użycie w Express:

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

app.post(
  "/api/payments/webhook",
  express.raw({ type: "application/json" }), // surowe body, bez parsowania
  (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,
    // a wolny endpoint blokuje przetwarzanie po stronie bramki.
    res.status(200).json({ status: "ok" });

    // Realizację zamówienia wykonaj asynchronicznie i idempotentnie
    fulfillOrder(event.orderId ?? event.externalId).catch(console.error);
  }
);
```

### Python

```python theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
import hmac
import hashlib
import base64
import time


def verify_webhook(secret: str, signature_header: str, raw_body: str) -> bool:
    if not signature_header:
        return False

    parts = signature_header.split(",", 1)
    if len(parts) != 2 or not parts[0].startswith("t=") or not parts[1].startswith("v1="):
        return False

    timestamp, signature = parts[0][2:], parts[1]

    # Okno tolerancji 5 minut
    try:
        if abs(int(time.time()) - int(timestamp)) > 300:
            return False
    except ValueError:
        return False

    digest = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.{raw_body}".encode("utf-8"),
        hashlib.sha256,
    ).digest()
    expected = "v1=" + base64.b64encode(digest).decode("utf-8")

    return hmac.compare_digest(expected, signature)
```

### Java

```java theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;

public boolean verifyWebhook(String secret, String signatureHeader, String rawBody) throws Exception {
    if (signatureHeader == null) return false;

    String[] parts = signatureHeader.split(",", 2);
    if (parts.length != 2 || !parts[0].startsWith("t=") || !parts[1].startsWith("v1=")) return false;

    String timestamp = parts[0].substring(2);
    String signature = parts[1];

    // Okno tolerancji 5 minut
    long age = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp));
    if (age > 300) return false;

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] hash = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
    String expected = "v1=" + Base64.getEncoder().encodeToString(hash);

    return MessageDigest.isEqual(
        expected.getBytes(StandardCharsets.UTF_8),
        signature.getBytes(StandardCharsets.UTF_8)
    );
}
```

## Wektor testowy

Sprawdź swoją implementację na poniższych danych - bez okna tolerancji, bo znacznik czasu jest z przeszłości:

| Element             | Wartość                                                        |
| ------------------- | -------------------------------------------------------------- |
| Sekret              | `whsec_dGVzdHNlY3JldHRlc3RzZWNyZXR0ZXN0c2VjcmV0cw==`           |
| Body                | `{"event":"payment.completed"}`                                |
| Znacznik czasu      | `1708790400`                                                   |
| Podpisywana treść   | `1708790400.{"event":"payment.completed"}`                     |
| Oczekiwany nagłówek | `t=1708790400,v1=1rODbpFCfITTJax9V5WnIWRfrxR0cvvg3yXGVGhlV7s=` |

Jeśli Twoja funkcja zwraca inny podpis, sprawdź kolejno: czy używasz pełnego sekretu z prefiksem `whsec_`, czy kodujesz w base64 (a nie w hex) i czy podpisujesz surowe body bez ponownej serializacji.

## Ochrona przed powtórzeniem żądania

Znacznik czasu jest objęty podpisem, więc nie da się go podmienić - ale samo Paymove nie odrzuca starych żądań. To Twój serwer decyduje, jak długo podpis pozostaje ważny. Zalecane okno to **5 minut**; powyższe przykłady już je stosują.

Dodatkowo realizuj zamówienia idempotentnie, po `externalId`. Ponowione doręczenie tego samego powiadomienia nie może skutkować podwójną wysyłką towaru.

## Wymiana sekretu

```bash theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
curl --request POST \
  --url https://gateway-api.sandbox.paymove.io/api/pay/plugin/webhook/6b23ecd9-14c8-47fc-add0-b71ec50e9d66/rotate-secret \
  --header 'X-API-KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
```

<Warning>
  Poprzedni sekret przestaje działać **natychmiast** - nie ma okresu, w którym oba byłyby akceptowane. Zaktualizuj konfigurację po swojej stronie w tym samym momencie, w przeciwnym razie zaczniesz odrzucać prawdziwe powiadomienia.
</Warning>

## Co dalej

<CardGroup cols={2}>
  <Card title="Statusy płatności" icon="list-check" href="/payment-status">
    Co zawiera payload webhooka i jak sprawdzić stan płatności.
  </Card>

  <Card title="Konfiguracja webhooka" icon="webhook" href="/webhooks">
    Rejestracja webhooka i przypisanie go do produktu.
  </Card>
</CardGroup>
