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

> From zero to your first payment — all the code to copy: create a payment, redirect, and verify the webhook.

This page walks through a complete payment gateway integration: creating a payment, redirecting the customer and safely handling the payment notification. The code is framework-agnostic - examples in `curl` and Node.js.

## What you need

| Value                    | Where from                                                                                   |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| `PAYMOVE_API_KEY`        | An `sk_test_…` key generated in the [Paymove panel](https://panel.paymove.io)                |
| `PAYMOVE_PRODUCT_ID`     | The UUID of your store, returned when you created the product                                |
| `PAYMOVE_WEBHOOK_SECRET` | The `signingSecret` field from the webhook registration response                             |
| A public webhook URL     | An address on your side reachable from the internet - locally e.g. through an `ngrok` tunnel |

<Info>
  Creating a product and registering a webhook are one-time steps. If you have not done them yet, start with [Configuration](/en/webhooks) and come back here.
</Info>

<Warning>
  Make every call server-side. The API key must not reach frontend code, and a browser request is rejected by CORS.
</Warning>

## 1. Create a payment

```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://your-shop.com/payment/return",
      "productName": "Sports T-shirt",
      "email": "customer@example.com"
    }
  }'
```

Response:

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

In 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, always an integer
        externalId: orderId,
        details: {
          returnUrl: "https://your-shop.com/payment/return",
          productName: "Sports T-shirt",
        },
      }),
    }
  );

  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` must be an integer in grosze. The value `12.99` is silently truncated to **12 grosze** and the API still returns `200`. Hence `Math.round(amount * 100)` in the example.
</Warning>

<Info>
  Store the `externalId` parameter from the returned `redirectUrl` (here `ec6RtwTZKb`). It is a payment hash generated by Paymove - different from your own `externalId` - and it is what you use to check the status later.
</Info>

<Info>
  **Working in Node.js or TypeScript?** Instead of hand-written `fetch`, use the official SDK - `npm install @paymove-io/sdk`. It is MIT-licensed, has zero dependencies, requires Node 18+ and ships TypeScript types. It assembles the nested request body for you and returns typed errors. Details: [JavaScript SDK](/en/sdk/javascript).

  The SDK does **not** include webhook signature verification - you write step 3 yourself either way.
</Info>

## 2. Redirect the customer

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

The customer lands on the Paymove checkout, picks a payment method and completes the transaction. Afterwards they see a confirmation screen with a "back to shop" button - only a click on it takes them to the address given in `details.returnUrl`.

<Warning>
  Returning to `returnUrl` **does not mean the payment succeeded** - and it may never happen. A customer who closes the tab never gets there, and the address can be opened directly, without paying. Show only a "processing your payment" message on that page - fulfil the order after the webhook.
</Warning>

## 3. Receive and verify the webhook

This is the only trustworthy confirmation of payment. The handler below does four things: takes the raw body, verifies the signature, responds immediately and fulfils the order idempotently.

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

  // 5-minute tolerance window - protects against replaying an old request
  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 on differing lengths - check the length 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" }), // raw body - without it the signature will not match
  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);

    // Respond immediately - Paymove applies no timeout to this call
    res.status(200).json({ status: "ok" });

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

Your server must respond with a status equal to `expectedCode` (`200` by default) - the response body is not inspected.

<Warning>
  Fulfil orders **idempotently**, keyed on `externalId`. Paymove may redeliver the same notification, and a double fulfilment means shipping the goods twice.
</Warning>

<Info>
  By default the webhook arrives only for the `COMPLETED` status. If you also need notifications about cancellations and failures, write to [integration@paymove.io](mailto:integration@paymove.io). Full description: [Payment statuses](/en/payment-status).
</Info>

## 4. Fallback status check

If the customer returned to `returnUrl` but the webhook has not arrived yet, you can poll for the status. Use the payment hash you stored in step 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>
  Note the host: this query is served by a **different host** (`pay-api.sandbox.paymove.io`, in production `pay-api.paymove.io`) than payment creation. Through `gateway-api` the path is not routed at all and returns `404` with the text `No route found for: GET …`. The endpoint needs no API key.
</Warning>

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

Treat this as a supplement, not a replacement for the webhook - the webhook is the source of truth.

## Before going live

* Switch the base URL to `https://api.paymove.io` and the key to `sk_live_…`.
* Make sure `externalId` is unique per order - reusing it returns the old payment with the old amount.
* Make sure the amount passed to `price` is computed server-side, not sent from the browser.
* Set `retries` when registering the webhook - it defaults to `0`, meaning no retries.

## What's next

<CardGroup cols={2}>
  <Card title="REST API" icon="code" href="/en/rest-api">
    Every parameter, full responses and changing a payment's amount.
  </Card>

  <Card title="Signature verification" icon="shield-check" href="/en/webhook-signature">
    Code for Node.js, Python and Java plus a test vector.
  </Card>

  <Card title="Error codes" icon="triangle-exclamation" href="/en/errors">
    What each error means and how to handle it.
  </Card>

  <Card title="JavaScript SDK" icon="cube" href="/en/sdk/javascript">
    A ready-made client for Node.js.
  </Card>
</CardGroup>
