> ## 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. Registering a webhook requires both `retries` and `expectedCode` - omitting either returns HTTP 500, and `retries: 0` means no retry at all. 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. If the merchant has no `productId` yet, do not stop at a placeholder: ask for the API key, the `partnerId` and the shop name, propose defaults for `location` and `timezone` (`Europe/Warsaw`), offer the optional fields once, then create the product for them with `POST /api/product/pay` and take `productId` from `id` in the response. Do the same for the webhook: register it, then assign it to the product. Guided procedure: https://docs.paymove.io/skill.md (Steps 1-2).
> 15. 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

# Refunds

> Refund a payment over the API: feature availability, endpoint, refund statuses, listing refunds and the limitations.

You can request a refund yourself with a single HTTP call. A `GET` on the same path returns the refunds of that payment, so you check a refund's state exactly where you requested it.

> [!WARNING]
>
> **API refunds are not available to every partner.** The feature is enabled individually, after you let Paymove know in advance - do not assume your account has it until you have confirmed that. Write to [integration@paymove.io](mailto:integration@paymove.io) to have it switched on; until it is active, every call returns `403`.

> [!WARNING]
>
> Call the API server-side only. A refund moves money - the API key must never reach frontend code.

## 1. Endpoints

```
POST https://gateway-api.sandbox.paymove.io/api/pay/payment/{orderId}/refund
GET  https://gateway-api.sandbox.paymove.io/api/pay/payment/{orderId}/refund
```

One path, two methods: `POST` creates a refund, `GET` lists the refunds of that payment.

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

### Headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-API-KEY` | Your API key - `sk_test_…` in sandbox, `sk_live_…` in production |

`POST` needs a key with write access, `GET` with read access. A key without it returns `401`; generate the right one in the [panel](https://panel.paymove.io).

> [!NOTE]
>
> The `pay` segment in the path selects the payment gateway. Other paymove products have their own segments (`go`, `parking`, `multi`) - refund under the segment of the product the payment belongs to.

### Where to get `orderId`

`{orderId}` is the **internal order id assigned by Paymove**, for example `PAY1784798914400`. You get it in two places:

* the `orderId` field of the [webhook payload](/en/payment-status.md#webhook-payload),
* the response of the [payment status query](/en/payment-status.md#querying-the-status).

> [!WARNING]
>
> `orderId` is **not** your `externalId`, and not the 10-character payment hash from `redirectUrl`. Neither of those works in this path. Store `orderId` when you handle the webhook - otherwise every refund needs a status query first.

## 2. Creating a refund

```bash theme={"theme":{"light":"one-dark-pro","dark":"one-dark-pro"}}
curl --request POST \
  --url https://gateway-api.sandbox.paymove.io/api/pay/payment/PAY1784798914400/refund \
  --header 'Content-Type: application/json' \
  --header 'X-API-KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
  --data '{
    "amountMinor": 15000,
    "email": "customer@example.com",
    "reason": "Customer cancelled"
  }'
```

The same in Node.js:

```javascript theme={"theme":{"light":"one-dark-pro","dark":"one-dark-pro"}}
const url = `https://gateway-api.sandbox.paymove.io/api/pay/payment/${orderId}/refund`;

const response = await fetch(url, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-KEY": process.env.PAYMOVE_API_KEY,
  },
  body: JSON.stringify({
    amountMinor: Math.round(150.0 * 100), // always an integer of grosz
    email: "customer@example.com",
    reason: "Customer cancelled",
  }),
});

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

const refund = await response.json();
console.log(refund.id, refund.status); // e.g. "9f2c1b44-…" "PENDING"
```

The shortest valid request is an **empty body** `{}` - it refunds the full payment amount.

### Body parameters

| Field | Type | Required | Description |
| - | - | - | - |
| `amountMinor` | `integer` | No | Refund amount in grosz. If you send it, it must **equal the full payment amount** - partial refunds are not supported. Omit it to refund everything |
| `email` | `string` | No | The payer's email. Used for the authorization that precedes a refund; a placeholder is used when it is absent. Payments taken without an email (a QR terminal, for instance) refund fine without it |
| `reason` | `string` | No | Free text, stored with the refund and returned in `comment` |

> [!WARNING]
>
> **The request carries no `productId`.** Paymove reads the product - and with it the settlement details - from the payment itself. A `productId` you send is silently ignored, like [every unknown field](/en/errors.md#errors-that-look-like-successes).

> [!NOTE]
>
> Mind the field name: the amount is `amountMinor` here, while [creating a payment](/en/rest-api.md#body-parameters) calls it `price`. The unit is the same in both places - grosz as an integer.

### Response

`POST` returns one refund, `GET` an array of exactly the same type - one parser serves both calls.

```json theme={"theme":{"light":"one-dark-pro","dark":"one-dark-pro"}}
{
  "id": "9f2c1b44-6d2f-4f0e-9a71-2b8c5d3e1a90",
  "type": "REFUND",
  "orderId": "PAY1784798914400-r1",
  "parentOrderId": "PAY1784798914400",
  "amountMinor": 15000,
  "status": "PENDING",
  "paymentMethod": "BLIK",
  "partnerId": "3a7d1f52-8b64-4c19-9e03-7d2a5f8c1b44",
  "productId": "891412c8-8717-4449-9543-e34112bec470",
  "locale": "pl",
  "comment": "Customer cancelled",
  "createdAt": "2026-10-06T11:42:03Z",
  "updatedAt": "2026-10-06T11:42:04Z"
}
```

| Field | Description |
| - | - |
| `id` | Paymove's id for this refund |
| `type` | Always `REFUND` |
| `orderId` | The id of the **refund itself**, shaped `{payment orderId}-rN` |
| `parentOrderId` | The `orderId` of the refunded payment - the one you put in the path |
| `amountMinor` | Refunded amount in grosz |
| `status` | [Refund status](#3-refund-statuses) |
| `paymentMethod` | The method the refunded payment was paid with |
| `partnerId` | Your partner id |
| `productId` | UUID of the product the payment belongs to |
| `locale` | The payment's locale |
| `comment` | The text you sent in `reason` |
| `createdAt`, `updatedAt` | ISO 8601 timestamps (UTC) |

> [!WARNING]
>
> **`orderId` in the response identifies the refund, not the payment.** The payment's id is in `parentOrderId`. Storing the refund's `orderId` as the payment id is the easiest mistake to make on this page.

## 3. Refund statuses

`status` takes the same values as a [payment status](/en/payment-status.md#statuses):

| Status | Meaning | Terminal |
| - | - | - |
| `INITIALIZED` | Refund recorded, not yet submitted for settlement | No |
| `PENDING` | Refund in progress | No |
| `WAITING_FOR_EXTERNAL_ACTION` | A transient state inside Paymove | No |
| `COMPLETED` | Refund settled - **the only successful status** | Yes |
| `CANCELED` | Refund cancelled | Yes |
| `ERROR` | Refund failed | Yes |

> [!WARNING]
>
> **A `200` does not mean the money is back with the customer.** It means Paymove accepted the refund for processing - the typical response is `status: "PENDING"`. Poll `GET …/refund` for the terminal state; refunds usually settle within a minute.

A successful refund sets the status of **the whole payment** to [`REFUNDED`](/en/payment-status#statuses).

## 4. Listing a payment's refunds

```bash theme={"theme":{"light":"one-dark-pro","dark":"one-dark-pro"}}
curl --request GET \
  --url https://gateway-api.sandbox.paymove.io/api/pay/payment/PAY1784798914400/refund \
  --header 'X-API-KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
```

```json theme={"theme":{"light":"one-dark-pro","dark":"one-dark-pro"}}
[
  {
    "id": "9f2c1b44-6d2f-4f0e-9a71-2b8c5d3e1a90",
    "type": "REFUND",
    "orderId": "PAY1784798914400-r1",
    "parentOrderId": "PAY1784798914400",
    "amountMinor": 15000,
    "status": "COMPLETED",
    "paymentMethod": "BLIK",
    "comment": "Customer cancelled",
    "createdAt": "2026-10-06T11:42:03Z",
    "updatedAt": "2026-10-06T11:42:31Z"
  }
]
```

A payment with no refunds returns an empty array `[]`. This is the endpoint to poll for a refund's state - call it every few seconds until `status` is [terminal](#3-refund-statuses).

> [!NOTE]
>
> The list also contains **failed attempts**. Every refund is recorded before it is submitted for settlement, so an attempt that is rejected outright is not lost - the `POST` answers `400` and the attempt shows up in the list with `status: ERROR`.

## 5. What cannot be refunded

Each of these returns `400`:

* **An incomplete payment.** Only a `COMPLETED` payment can be refunded. There is nothing to refund on `INITIALIZED`, `PENDING`, `CANCELED` or `ERROR`.
* **A payment that already has a refund.** An existing refund in `PENDING` or `COMPLETED` blocks further attempts - [one refund per payment](#6-limitations).
* **An amount other than the full one.** An `amountMinor` different from the payment amount is rejected; partial refunds are not supported.
* **Some payments cannot be refunded over the API at all**, depending on how they were settled. If you get a `400` on a completed payment with the full amount, write to [integration@paymove.io](mailto:integration@paymove.io) - we will issue the refund for you.

> [!NOTE]
>
> **PayPo** refunds are supported, but the confirmation arrives asynchronously - the `POST` response always carries `status: "PENDING"`, never `COMPLETED` straight away.

## 6. Limitations

> [!WARNING]
>
> **No partial refunds and one refund per payment.** `amountMinor` must equal the full payment amount, and a second refund attempt on the same payment is rejected. Both limits exist because a refund is settled under the payment's own reference, so a second one would be indistinguishable from the first.

> [!WARNING]
>
> **There is no idempotency key.** Two identical requests mean two refunds - today the "one refund per payment" check stops the second one, but do not build retry logic on that. Check `GET …/refund` before you repeat a `POST`.

## 7. Status codes

| Status | When |
| - | - |
| `200` | The refund was accepted for processing, or the list was returned. It does **not** mean the money has moved |
| `400` | The payment is not completed; `amountMinor` is not the full amount; the payment already has a refund; the payment has no product recorded, so the settlement details cannot be resolved; the payment cannot be refunded over the API; the refund was rejected |
| `401` | No `X-API-KEY` header, an unrecognised key, or a key without write access (`POST`) or read access (`GET`) |
| `403` | API refunds are not enabled for your account, the payment belongs to another partner, or the payment does not exist |
| `404` | An inconsistency on Paymove's side - the payment exists but its source record is missing. Report it to [integration@paymove.io](mailto:integration@paymove.io); this is not a client error |

> [!NOTE]
>
> A payment that does not exist and a payment that belongs to another partner both return **the same `403`**. That is intentional - a `404` would confirm which order ids exist in Paymove. Do not infer from a `403` whether a payment exists.

Ownership is part of the lookup, not a check after it: the payment is fetched by `orderId` **and** by the partner your key belongs to. There is no way to refund another partner's payment or to point a refund at their settlement details.

## 8. Recommended flow

1. When you handle the webhook, store the payment's `orderId` next to your own `externalId`.
2. Before refunding, confirm the payment is `COMPLETED` and that you have not refunded it already - `GET …/refund`.
3. Send `POST …/refund`. Store the refund's `id` and `orderId` from the response.
4. Poll `GET …/refund` until the refund's status is terminal. `COMPLETED` means settled; `ERROR` means it was rejected.
5. Mark the refund as done on your side only after `COMPLETED`.

## What's next

> **[Payment statuses](/en/payment-status.md)**
>
> Where to get `orderId` and how to check a payment's status.

> **[Errors](/en/errors.md)**
>
> The error format and the mechanisms this API does not have.

> **[REST API](/en/rest-api.md)**
>
> Creating the payment you later refund.

> **[Support](/en/support.md)**
>
> Contact us when a refund cannot be done over the API.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.