---
title: "paymove integration skill"
description: "A complete, step-by-step procedure for an AI agent implementing paymove checkout."
noindex: true
---

# paymove integration skill

You are implementing payments with **paymove**, a Polish payment gateway. Follow this procedure exactly. Everything here is verified against the live API.

## When to use this

Use this document whenever the task is to accept payments, add a checkout, integrate a payment gateway, or handle BLIK / Google Pay / Apple Pay in a project that uses paymove.

## How paymove works

1. Your **server** calls the paymove API to create a payment. You get back a `redirectUrl`.
2. You redirect the **customer** to that URL. paymove hosts the checkout page.
3. When the payment completes, paymove sends a **signed webhook** to your server.
4. You **verify the signature**, respond `200`, and only then fulfil the order.

The customer's return to your `returnUrl` is *not* proof of payment. The webhook is.

## Facts you must not get wrong

1. **Amounts are integers in grosze** (minor units). `1000` = 10.00 PLN.
2. **A float `price` is silently truncated.** `12.99` becomes `12` grosze and the API still returns `200`. Always `Math.round(pln * 100)`.
3. **PLN only.** There is no currency field in the REST API. (The Node SDK requires a `currency` argument but the API ignores it — pass `"PLN"`.)
4. **Auth header is `X-API-KEY`.** Never `Authorization: Bearer`. Sandbox keys start `sk_test_`, production `sk_live_`.
5. **Server-side only.** A `fetch` from the merchant's page is rejected. Never put the key in frontend code.
6. **Unknown fields are silently ignored and still return `200`.** A wrong body shape looks like success. Match the documented shape exactly.
7. **A missing `externalId` returns `500 Something went wrong`**, not `400`. A missing `price` or `details.returnUrl` returns **`200`** and a usable `redirectUrl` — no error at all. Validate the body yourself before sending it.
8. **Errors are `{"status": <int>, "message": "<text>"}`.** Branch on the HTTP status only. Never parse `message` — it is unstable and leaks internal class names.
9. **No rate limiting, no `429`, no `422`, no `Idempotency-Key`, no API versioning.** Do not write code for them.
10. **Re-POSTing an existing `externalId` returns `200` with the ORIGINAL `redirectUrl`** and silently discards **every** field you send — price, description and details alike. Use a unique `externalId` per order.
11. **Webhooks fire only on `COMPLETED` by default**, and `retries` defaults to `0`. Delivery succeeds when your HTTP status equals `expectedCode`; the response body is never inspected.
12. **Never fulfil an order in the `returnUrl` handler.** The checkout does not redirect there by itself — the customer has to click "back to shop", and it may never happen. Only fulfil after a verified webhook, idempotently.
13. **Do not pin a version of `@paymove-io/sdk`.**

## Inputs you need from the user

Ask for these and **stop until you have them**. Do not invent values, do not use placeholders in committed code, and do not guess the product ID.

| Variable | What it is |
|---|---|
| `PAYMOVE_API_KEY` | The API key, `sk_test_…` in sandbox |
| `PAYMOVE_PRODUCT_ID` | UUID of the merchant's store in paymove |
| `PAYMOVE_WEBHOOK_SECRET` | The `signingSecret` returned when the webhook was registered |

All three go in environment variables. Never commit them.

If the user does not have them yet, tell them: the API key is generated in the paymove panel at https://panel.paymove.io, and the product plus webhook are a one-time setup described at https://docs.paymove.io/en/webhooks.md

Also confirm with the user: **the public URL of their webhook endpoint**. It must be reachable from the internet — locally that means a tunnel such as `ngrok`.

## Base URLs

| Environment | URL |
|---|---|
| Sandbox | `https://gateway-api.sandbox.paymove.io` |
| Production | `https://api.paymove.io` |

## There is an official SDK — use it on Node.js

paymove publishes an official JavaScript/TypeScript SDK on npm:

```bash
npm install @paymove-io/sdk
```

`@paymove-io/sdk` is MIT-licensed, has **zero runtime dependencies**, requires Node 18+, and ships both ESM and CommonJS builds with TypeScript types. On a Node or TypeScript project, prefer it over hand-written HTTP calls — it assembles the nested request body for you and surfaces typed errors.

On any other stack (Python, PHP, Go, Ruby, …) there is no official SDK yet — call the REST API directly, as shown in Step 1, Option B.

**What the SDK does not do:** it contains no webhook signature verification helper. Step 3 is hand-written regardless of which option you pick.

## Step 1 — Create a payment

### Option A — the official Node SDK (preferred on Node/TypeScript)

```javascript
import { PaymoveClient, PaymoveApiError, PaymoveValidationError } from "@paymove-io/sdk";

const client = new PaymoveClient({
  apiKey: process.env.PAYMOVE_API_KEY,       // sk_test_… / sk_live_…
  productId: process.env.PAYMOVE_PRODUCT_ID,
  environment: "sandbox",                    // or "production"
});

try {
  const { redirectUrl } = await client.createPayment({
    amount: Math.round(amountPln * 100),     // integer grosze — never a float
    currency: "PLN",                         // required by the SDK, ignored by the API
    externalId: orderId,                     // unique per order
    returnUrl: "https://your-shop.example/payment/return",
    productName: "Sports T-shirt",
    email: customerEmail,                    // optional
  });
  return redirectUrl;
} catch (err) {
  if (err instanceof PaymoveValidationError) {
    // rejected locally before any request was sent; err.field names the parameter
  } else if (err instanceof PaymoveApiError) {
    // non-2xx from the API; err.statusCode and err.responseBody hold the original response
  }
  throw err;
}
```

SDK specifics that matter:

- Parameters are **flat**. The SDK builds the nested `{price, externalId, details:{…}}` body itself: `amount` → `price`, `returnUrl` → `details.redirectUrl`, `externalId` goes **both** top-level and into `details.orderId`, `description`, `bankAccount` and `nip` stay **top-level**, and every remaining key spreads into `details`.
- `currency` is **required by the SDK** even though the API ignores it. Always pass `"PLN"`.
- The SDK validates `amount` only as "a number greater than zero" — it does **not** check that it is an integer. `49.99` passes the SDK and is then truncated to 49 grosze by the API. Keep using `Math.round(pln * 100)`.
- Errors: `PaymoveValidationError` (local, has `.field`), `PaymoveApiError` (has `.statusCode`, `.responseBody`), `PaymoveNetworkError` (has `.cause`), all extending `PaymoveError`.
- The client takes no `baseUrl` override, no timeout and no retry options; `environment` selects the host.
- `merchantId` is a deprecated alias for `productId` — use `productId`.

### Option B — plain HTTP (any language)

`POST {baseUrl}/api/pay/product/{productId}/subproduct/pricing`

```javascript
async function createPayment({ orderId, amountPln, productName }) {
  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), // integer grosze — never a float
        externalId: orderId,               // unique per order
        details: {
          returnUrl: "https://your-shop.example/payment/return",
          productName,
        },
      }),
    }
  );

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

  return response.json(); // { redirectUrl }
}
```

Response is HTTP `200` (not `201`) with one field:

```json
{ "redirectUrl": "https://checkout.sandbox.paymove.io/{productId}?externalId=ec6RtwTZKb" }
```

**Important:** the `externalId` query parameter inside that URL (`ec6RtwTZKb`) is a 10-character hash generated by paymove — *not* the `externalId` you sent. Store it if you want to poll the payment status later.

Fields in `details` that the checkout actually reads: `returnUrl`, `redirectUrl`, `productName`, `email`, `locale`, `orderId`, `recipient`, `triggerPayment`, `qrStepEnabled`, `autoclose`. Anything else is stored but never displayed.

The amount must be computed **server-side**. Never accept a price from the browser.

## Step 2 — Redirect the customer

```javascript
res.redirect(303, redirectUrl);
```

**The customer is not sent back automatically.** After a successful payment the checkout shows a confirmation screen with a "back to shop" button, and only a click on that button navigates to `details.returnUrl` — no query parameters are appended. A customer who closes the tab never reaches your `returnUrl` at all.

On the `returnUrl` page, show only a neutral "we are processing your payment" message. Do not mark the order paid there.

## Step 3 — Verify and acknowledge the webhook

paymove signs every webhook with HMAC-SHA256 and sends it in a single header:

```
X-Paymove-Signature: t=1708790400,v1=<base64>
```

The signature covers the string `"{timestamp}.{raw_body}"`. The HMAC key is the **full secret including the `whsec_` prefix**, as UTF-8 bytes — do not strip the prefix, do not base64-decode it. The output is standard base64 with padding, not hex.

```javascript
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);

  // Replay protection: reject anything older than 5 minutes
  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 throws when lengths differ — guard first
  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" }), // MUST be the raw body
  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);

    // Acknowledge immediately — paymove applies no timeout to this call
    res.status(200).json({ status: "ok" });

    // Then fulfil, asynchronously and idempotently
    try {
      await fulfillOrderOnce(event.externalId ?? event.orderId);
    } catch (err) {
      console.error("fulfilment failed", err);
    }
  }
);
```

**The raw body matters.** If a JSON body parser runs first and the object is re-serialized, the signature will not match. Use `express.raw(...)` on this route, or `await request.text()` in Next.js route handlers.

The acknowledgement must have a status **exactly equal** to the webhook's `expectedCode` (`200` by default). The response body is never inspected — `expectedResponse` is stored on the webhook but not compared against what you return, so any body will do.

A `201` or `204` counts as a failure **and is not retried**: only 4xx, 5xx and network errors trigger the retry schedule, so a wrong 2xx loses the notification for good.

## Step 4 — Fulfil idempotently

```javascript
async function fulfillOrderOnce(orderId) {
  const order = await db.orders.findByExternalId(orderId);
  if (!order || order.status === "paid") return; // already handled — do nothing
  await db.orders.markPaid(orderId);
  // ship goods, send confirmation email, etc.
}
```

paymove may redeliver the same notification. Fulfilment must be safe to run twice.

## Optional — poll the status

Useful when the customer is back on `returnUrl` but the webhook has not arrived yet. Use the 10-character hash from `redirectUrl`:

```
GET https://pay-api.sandbox.paymove.io/api/payment/product/{productId}/subproduct/{paymentHash}/status
```

**This endpoint is on a different host** — `pay-api.sandbox.paymove.io` in sandbox, `pay-api.paymove.io` in production. It is *not* routed through the `gateway-api` / `api.paymove.io` host used for creating payments: that host answers with `404` and a plain-text `No route found for: GET …`, which is not even the usual `{status, message}` error shape.

Returns `{"status": "COMPLETED", "orderId": "..."}`. It needs no API key. Treat it as a supplement to the webhook, never a replacement.

Statuses, always as strings: `INITIALIZED`, `PENDING`, `COMPLETED`, `CANCELED` (one L), `ERROR`, `REFUNDED`, `WAITING_FOR_EXTERNAL_ACTION`.

## Verification checklist

Before you report the integration as done, check each of these against the code you wrote:

- [ ] The API key comes from an environment variable, not a literal, and appears nowhere in frontend code.
- [ ] Requests use the `X-API-KEY` header, not `Authorization: Bearer`.
- [ ] Every amount reaching `price` is an integer, produced by `Math.round(x * 100)` or already in grosze.
- [ ] No decimal literal is ever assigned to `price`.
- [ ] There is no `currency` field in the REST request body.
- [ ] `externalId` is unique per order.
- [ ] The webhook route captures the raw body before any JSON parsing.
- [ ] The signature is verified with HMAC-SHA256, `"{timestamp}.{body}"`, base64 output, full `whsec_` secret as the key.
- [ ] Signature comparison guards against differing lengths before `timingSafeEqual`.
- [ ] A timestamp older than 300 seconds is rejected.
- [ ] The handler responds with the status equal to the webhook's `expectedCode` (`200` by default) before doing slow work.
- [ ] Fulfilment happens in the webhook handler, not in the `returnUrl` handler.
- [ ] Fulfilment is idempotent.
- [ ] There is no code handling `429`, `422`, rate limits or `Idempotency-Key`.
- [ ] On a Node/TypeScript project, `@paymove-io/sdk` is used rather than hand-rolled HTTP calls.
- [ ] No `@paymove-io/sdk` version is pinned.

## Do not

- Do not use `Authorization: Bearer`.
- Do not call the API from the browser.
- Do not send `currency` in the REST body, or `amount` instead of `price`.
- Do not send a float as `price`.
- Do not fulfil an order on the `returnUrl` redirect.
- Do not trust an unverified webhook.
- Do not write retry logic for `429` — the API has no rate limiting.
- Do not send an `Idempotency-Key` header — it does not exist.
- Do not branch on the `message` text of an error.
- Do not reuse an `externalId` to change a price; call `PATCH /api/pay/product/{productId}/subproduct/{externalId}` with `{"price": <int>}` instead.

## Deeper reading

- Quickstart: https://docs.paymove.io/en/quickstart.md
- REST API: https://docs.paymove.io/en/rest-api.md
- Webhook signature: https://docs.paymove.io/en/webhook-signature.md
- Payment statuses: https://docs.paymove.io/en/payment-status.md
- Errors: https://docs.paymove.io/en/errors.md
- Node SDK: https://docs.paymove.io/en/sdk/javascript.md
- Full index: https://docs.paymove.io/llms.txt

If something does not work as documented, tell the user to write to integration@paymove.io with the prompt used — paymove treats that as a documentation bug.
