# Quickpay API Reference

Base URL: `https://api.quickpay.ge/v1`

OpenAPI spec: https://quickpay.ge/docs/api

## What you can build

Quickpay is not just a card payment API. A single `POST /v1/payments` call covers six distinct payment flows — the `gateway_slug` field determines which flow the customer sees on the hosted payment page.

> For all flows the integration is identical: **POST /payments → redirect to `payment_url` → receive the `payment.paid` webhook.** Only the `gateway_slug` changes.

| Flow | Slugs | Notes |
|---|---|---|
| **Card payment** | bog_card · tbc_card · flitt · unipay · fastoo · moka · keepz | One-time card charge. Customer enters card details at the bank's secure interface. Visa/MC, Apple Pay, and Google Pay where supported. |
| **Installment loan** | bog_installment · tbc_installment · credo_installment · flitt_installment | Customer picks a repayment plan (3–36 months). **Merchant receives the full amount upfront** — the bank handles instalment collection. |
| **BNPL (0% financing)** | bog_bnpl · flitt_bnpl | Buy-now-pay-later split into equal 0-interest instalments. The bank absorbs the financing cost — no extra charge to customer or merchant. |
| **Open banking / QR / Wallet** | keepz · paysera | Card + QR code + open banking + digital wallet in one gateway. Supports all major Georgian banks via open banking. |
| **Crypto** | citypay | Customer pays in cryptocurrency. CityPay handles the conversion and settles GEL to the merchant. No crypto wallet required on the merchant side. |
| **Offline / manual** | bank_transfer · cash_on_delivery | No hosted payment page. Bank transfer: customer receives your IBAN + order UUID as reference. COD: merchant confirms delivery manually. |

## Quick Start

Get your first real payment working in three steps.

**1. Get your API key** — Dashboard → API Keys → copy your `qpk_live_...` key. Your key is tied to one Brand in your account.

**2. Create a payment** — call `POST /v1/payments` with your amount, description, and return URL. The response contains a `payment_url`.

```bash
curl -X POST https://api.quickpay.ge/v1/payments \
  -H "Authorization: Bearer qpk_live_..." \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 149.99,
    "currency": "GEL",
    "description": "Order #1234",
    "return_url": "https://yourshop.ge/thank-you"
  }'
```

**3. Redirect & handle webhook** — redirect your customer to the `payment_url`. When payment completes, Quickpay POSTs a `payment.paid` event to your Webhook URL (Dashboard → Brand Settings). See Webhooks.

> **Using test mode?** Use your `qpk_test_...` key — no real charges, no real gateway calls. Switch back to your live key for production.

## Authentication

All API requests (except exchange rates) require a bearer token. Use your live key (`qpk_live_...`) for production and your test key (`qpk_test_...`) for development. Keys are brand-scoped — every key is tied to one Brand in your account.

```
Authorization: Bearer qpk_live_your_key_here
```

Rate limit: **100 requests per minute** per API key. Rate-limit headers are returned on every response: `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

## Idempotency

Include an `Idempotency-Key` header on all POST requests to safely retry without double-charging. The same key returns the same payment response (HTTP `200`). If you supply a key that was previously used with a *different amount or currency*, the API returns `409 Conflict`.

```
Idempotency-Key: order-1234-attempt-1
```

## Amounts & Currencies

All monetary values are decimal numbers. 5.00₾ = `"amount": 5.00`. Supported currencies: `GEL` · `USD` · `EUR` · `GBP`. When `currency` is omitted it defaults to `GEL`.

## Errors

Errors always include a `message` field and a machine-readable `error_code` string. Validation errors (422) also include an `errors` object keyed by field name. See Error codes for the full list.

| Code | Meaning |
|---|---|
| 401 | Missing or invalid API key |
| 403 | Account suspended, test key on live brand, or no active gateway subscription |
| 404 | Resource not found |
| 409 | Idempotency key conflict (different amount or currency) |
| 422 | Validation failed — see the errors object |
| 429 | Rate limit or charge-frequency exceeded — Retry-After present |
| 500 | Server or gateway error |

## Official SDKs

Don't want to call the raw REST API by hand? These are thin, typed clients over this same API — no card data ever touches your server.

| SDK | Package | Install | Repository |
|---|---|---|---|
| PHP SDK | `quickpayge/php-sdk` | `composer require quickpayge/php-sdk` | [quickpay.ge/php-sdk](https://quickpay.ge/php-sdk) · [GitHub](https://github.com/QuickPayGe/php-sdk) |
| Laravel Package | `quickpayge/laravel` | `composer require quickpayge/laravel` | [quickpay.ge/laravel-package](https://quickpay.ge/laravel-package) · [GitHub](https://github.com/QuickPayGe/laravel-package) |
| Node.js / TypeScript SDK | `@quickpay/js` | `npm install @quickpay/js` | [quickpay.ge/node-sdk](https://quickpay.ge/node-sdk) · [GitHub](https://github.com/QuickPayGe/node-sdk) |

## MCP Server (for AI Agents)

Building with an AI coding agent (Claude, Cursor, and others)? `https://mcp.quickpay.ge` is a public, no-auth Model Context Protocol server — a live reference your agent can query directly instead of copy-pasting these docs into its context.

```json
{
  "mcpServers": {
    "quickpay": {
      "url": "https://mcp.quickpay.ge"
    }
  }
}
```

| Tool / Resource | What it does |
|---|---|
| search-docs | Search across this reference and the integration guides |
| get-endpoint-reference | Exact request/response fields for one endpoint |
| get-webhook-signature-example | Webhook signature verification code (PHP / Node / Python) |
| list-gateways | Supported Georgian payment gateways |
| create-test-payment | Create a sandbox payment end-to-end (requires a `qpk_test_...` key) |
| quickpay://docs/api-reference, quickpay://docs/openapi, quickpay://docs/rag/{topic} | This same reference, the OpenAPI spec, and per-topic integration guides, as MCP resources |

All tools are read-only except `create-test-payment`, which only ever creates sandbox data behind a `qpk_test_...` key — no real money moves through this server. Looking for an AI agent that can manage your live account (create payments, issue refunds, update products)? That's a separate OAuth-authenticated server — see Dashboard → Integrations → Connected AI Apps.

## Platform Plugins

Building on WooCommerce, WHMCS, OpenCart, CS-Cart, PrestaShop, or Easy Digital Downloads? Skip the API entirely — install the matching plugin and configure it with an API key.

| Platform | Type | Page |
|---|---|---|
| WooCommerce (WordPress) | E-commerce | [quickpay.ge/wordpress-plugin](https://quickpay.ge/wordpress-plugin) |
| WHMCS | Hosting / billing | [quickpay.ge/whmcs-plugin](https://quickpay.ge/whmcs-plugin) |
| OpenCart | E-commerce | [quickpay.ge/opencart-plugin](https://quickpay.ge/opencart-plugin) |
| CS-Cart | E-commerce | [quickpay.ge/cscart-plugin](https://quickpay.ge/cscart-plugin) |
| PrestaShop | E-commerce | [quickpay.ge/prestashop-plugin](https://quickpay.ge/prestashop-plugin) |
| Easy Digital Downloads (WordPress) | Digital goods | [quickpay.ge/edd-plugin](https://quickpay.ge/edd-plugin) |

Plugins are downloaded from Dashboard → Integrations after registering, and support self-hosted one-click updates.

## Create a payment

`POST /v1/payments`

Creates a payment and returns a `payment_url` — redirect your customer there to complete checkout. Omit `gateway_slug` to let the customer choose their preferred gateway on the hosted payment page.

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `amount` | number | required | Payment amount as a decimal (e.g. `49.99`). Min `0.01`, up to 2 decimal places. |
| `currency` | string | optional | One of `GEL`, `USD`, `EUR`, `GBP`. Defaults to `GEL`. |
| `description` | string | optional | Order description shown to the customer (max 500 chars). |
| `gateway_slug` | string | optional | Create the order directly at one gateway and restrict the checkout page to it only — use this when the customer already chose a method on your own site (e.g. separate 'Pay with TBC Installment' / 'Pay with Keepz' buttons). Omit to let the customer choose on the hosted checkout page. Mutually exclusive with `preselected_gateway`. See List gateways and What you can build for valid slugs. |
| `preselected_gateway` | string | optional | Highlight one gateway as the default choice on the checkout page while still showing every other available gateway — the customer can switch. Only valid on gateway-less payments (omit `gateway_slug`). Mutually exclusive with `gateway_slug`. |
| `merchant_order_id` | string | optional | Your own order reference — stored and returned on all responses (max 255 chars). |
| `bank_transfer_reference` | string | optional | Custom reference shown to the customer on the Bank Transfer instructions (e.g. your own invoice number). Only applies to `bank_transfer` payments — falls back to a Quickpay-generated code when omitted (max 140 chars). |
| `customer_name` | string | optional | Customer full name — pre-fills the checkout form (max 255 chars). |
| `customer_email` | string | optional | Customer email — pre-fills the form and used for the receipt. |
| `customer_phone` | string | optional | Customer phone — pre-fills the checkout form. |
| `return_url` | string | optional | Redirect URL after checkout completes. |
| `webhook_url` | string | optional | Webhook endpoint for this payment only. Overrides the brand-level webhook URL configured in the dashboard. Useful for multi-tenant setups where each payment routes to a different endpoint. |
| `expires_at` | datetime | optional | ISO 8601 datetime after which the payment link expires. Must be in the future. |
| `coupon_code` | string | optional | Coupon code to apply — the discount is deducted from `amount` before charging (max 50 chars). **Only applied when `gateway_slug` is set**; silently ignored on gateway-less payments. |
| `product_id` | integer | optional | Internal product ID to attach to the payment (used for coupon product-scoping). |
| `product_image_url` | string | optional | Product image URL shown on the checkout page. |
| `line_items` | array | optional | Itemised breakdown. The sum of `line_items[].total` must equal `amount`. |
| `checkout_template_id` | integer | optional | Override the checkout page template for this payment. Use an `id` from List checkout templates. |
| `custom_fields_schema` | array | optional | Extra form fields shown to the customer — see the schema below. |
| `prefill_only` | boolean | optional | When `true`, pre-fill customer info but still show the form for confirmation. |
| `source` | string | optional | Integration source tag: `woocommerce`, `whmcs`, `opencart`, `cscart`, `prestashop`, `edd`, `ecwid`, or `api`. |
| `idempotency_key` | string | optional | Alternative to the `Idempotency-Key` header (max 255 chars). |
| `tokenize_only` | boolean | optional | Save the customer's card without charging anything. Only valid with a `*_subscriptions` `gateway_slug` (excluding `paypal_subscriptions`) — `amount` is forced to `0` server-side regardless of what is posted. See the note below. |

**line_items[] object**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `type` | string | required | One of `product`, `shipping`, `tax`, `fee`, `discount`. |
| `name` | string | required | Line item label in English (max 255 chars). |
| `name_ka` | string | optional | Line item label in Georgian (max 255 chars). |
| `qty` | integer | required | Quantity (min 1). |
| `unit_price` | number | required | Price per unit. |
| `total` | number | required | Line total. The sum of every `total` must equal `amount`. |
| `sku` | string | optional | Product SKU (max 100 chars). |

**custom_fields_schema[] object**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `label` | string | required | Field label shown to the customer (max 200 chars). |
| `type` | string | required | One of `text`, `textarea`, `select`, `number`, `checkbox`, `radio`. |
| `required` | boolean | optional | Whether the customer must fill this field. |
| `options` | array | optional | Required for `select` and `radio`. Each item: `{ "label": "…", "value": "…" }`. |

**Request example**

*cURL*

```bash
curl -X POST https://api.quickpay.ge/v1/payments \
  -H "Authorization: Bearer qpk_live_..." \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 149.99,
    "currency": "GEL",
    "description": "Order #1234",
    "gateway_slug": "bog_card",
    "merchant_order_id": "ORD-1234",
    "customer_name": "Giorgi Beridze",
    "customer_email": "giorgi@example.ge",
    "return_url": "https://yourshop.ge/thank-you"
  }'
```

*PHP*

```php
$response = Http::withToken('qpk_live_...')
    ->withHeaders(['Idempotency-Key' => 'order-1234'])
    ->post('https://api.quickpay.ge/v1/payments', [
        'amount'      => 149.99,
        'currency'    => 'GEL',
        'description' => 'Order #1234',
        'gateway_slug'=> 'bog_card',
        'return_url'  => 'https://yourshop.ge/thank-you',
    ]);
$paymentUrl = $response->json('payment_url');
```

*Node.js*

```js
const res = await fetch('https://api.quickpay.ge/v1/payments', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer qpk_live_...',
    'Idempotency-Key': 'order-1234',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: 149.99, currency: 'GEL', description: 'Order #1234',
    gateway_slug: 'bog_card', return_url: 'https://yourshop.ge/thank-you',
  }),
});
const { payment_url } = await res.json();
```

*Python*

```python
import requests
r = requests.post(
    'https://api.quickpay.ge/v1/payments',
    headers={
        'Authorization': 'Bearer qpk_live_...',
        'Idempotency-Key': 'order-1234',
    },
    json={
        'amount': 149.99, 'currency': 'GEL', 'description': 'Order #1234',
        'gateway_slug': 'bog_card', 'return_url': 'https://yourshop.ge/thank-you',
    },
)
payment_url = r.json()['payment_url']
```

```json
// 201 Created (200 on idempotency replay)
{
  "uuid": "018f2a3b-...",
  "merchant_order_id": "ORD-1234",
  "payment_url": "https://qpy.ge/pay/018f2a3b-...",
  "status": "pending",
  "amount": 149.99,
  "currency": "GEL",
  "discount_gel": null,
  "coupon_code": null,
  "redirect_url": null
}
```

> When `gateway_slug` is omitted the customer picks the gateway on the hosted page, and the response omits `discount_gel`, `coupon_code`, and `redirect_url`.

> Replaying the same `Idempotency-Key` returns the original payment with HTTP `200`; a different `amount` or `currency` for the same key returns `409`.

> `bank_transfer_reference` is stored even when `gateway_slug` is omitted — it takes effect once the customer selects Bank Transfer on the hosted page.

> `tokenize_only: true` saves the customer's card on file without charging anything — the response `status` resolves to `paid` once the bank confirms the card save, and `payment.paid` fires on the webhook exactly like a normal charge, but no funds move. Requires a `*_subscriptions` `gateway_slug` (not `paypal_subscriptions`); anything else returns `422 tokenize_only_gateway_unsupported`. Some gateways (`bog_card_subscriptions`, `flitt_subscriptions`, `unipay_subscriptions`, `fastoo_subscriptions`) may reject a zero-amount save — that comes back as `422 zero_amount_not_supported`.

## Get a payment

`GET /v1/payments/{uuid}`

Returns the full payment object.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Payment UUID. |

```json
// 200 OK
{
  "uuid": "018f2a3b-...",
  "merchant_order_id": "ORD-1234",
  "status": "paid",
  "amount": 149.99,
  "currency": "GEL",
  "description": "Order #1234",
  "customer_name": "Giorgi Beridze",
  "customer_email": "giorgi@example.ge",
  "gateway_order_id": "BOG-XYZ789",
  "surcharge": null,
  "coupon": null,
  "form_data": {},
  "line_items": null,
  "refunded_amount": 0,
  "refunds": [],
  "paid_at": "2026-05-16T10:23:00Z",
  "expires_at": null,
  "created_at": "2026-05-16T10:20:00Z"
}
```

**Response fields**

| Field | Type | Description |
|---|---|---|
| `uuid` | string | Unique payment identifier (UUID v7). |
| `merchant_order_id` | string|null | Your order reference passed at creation. |
| `status` | string | Payment status — see the list below. |
| `amount` | number | Charged amount. |
| `currency` | string | Currency code. |
| `description` | string|null | Order description. |
| `customer_name` | string|null | Customer full name. |
| `customer_email` | string|null | Customer email address. |
| `gateway_order_id` | string|null | Order ID assigned by the payment gateway. |
| `bank_transfer_reference` | string|null | Reference shown to the customer for Bank Transfer payments — your requested value, or a Quickpay-generated code if none was provided. |
| `surcharge` | number|null | Gateway surcharge applied, if any. |
| `coupon` | object|null | Applied coupon details (`code`, `type`, `discount_gel`). |
| `form_data` | object | Custom field values submitted by the customer. |
| `paid_at` | datetime|null | ISO 8601 timestamp when payment was confirmed. |
| `expires_at` | datetime|null | ISO 8601 expiry timestamp. |
| `created_at` | datetime | ISO 8601 creation timestamp. |
| `line_items` | array|null | Itemised breakdown, if provided at creation. |
| `refunded_amount` | number | Total amount refunded so far. |
| `refunds` | array | Refund records (`uuid`, `amount`, `reason`, `status`, `initiated_by`, `created_at`). |

> Payment statuses: `pending` · `paid` · `failed` · `refunded` · `partially_refunded` · `cancelled` · `expired` · `transfer_pending` · `cod_pending` · `deposit_paid`.

## List payments

`GET /v1/payments`

Returns a paginated list (20 per page) sorted newest first.

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `status` | string | optional | Filter by status — see the status list above. |
| `gateway` | string | optional | Filter by gateway slug (e.g. `bog_card`, `tbc_card`). |
| `from` | date | optional | Start date — `YYYY-MM-DD`. |
| `to` | date | optional | End date — `YYYY-MM-DD`. Must be ≥ `from`. |
| `page` | integer | optional | Page number (default 1). |

```json
// 200 OK
{
  "data": [ { "...payment object..." } ],
  "meta": { "current_page": 1, "last_page": 5, "total": 92 }
}
```

## Refund a payment

`POST /v1/payments/{uuid}/refund`

Initiates a full or partial refund. Omit `amount` to refund the full remaining amount. Returns the updated payment object.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Payment UUID. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `amount` | number | optional | Amount to refund. Defaults to the full remaining refundable amount. |
| `reason` | string | optional | Internal reason for the refund — stored in the audit log (max 500 chars). |

```json
{ "amount": 50.00, "reason": "Customer requested partial refund" }
```

Returns `200 OK`.

> Returns the full payment object with an updated `refunds` array and `refunded_amount`.

## List products

`GET /v1/products`

Returns a paginated list (20 per page) sorted newest first.

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `active` | boolean | optional | `true` or `false` — filter by active status. |
| `page` | integer | optional | Page number (default 1). |

```json
// 200 OK
{
  "data": [
    {
      "uuid": "018f...",
      "name_en": "Product Name",
      "name_ka": "პროდუქტის სახელი",
      "short_description_en": "...",
      "short_description_ka": "...",
      "price": 99.99,
      "compare_price": 129.99,
      "base_currency": "GEL",
      "accepted_currencies": ["GEL", "USD"],
      "sku": "SKU-001",
      "track_stock": false,
      "stock_quantity": null,
      "image_url": "https://...",
      "categories": ["clothing"],
      "custom_fields_schema": [],
      "is_active": true,
      "created_at": "2026-01-01T10:00:00Z",
      "updated_at": "2026-01-01T10:00:00Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "total": 3 }
}
```

## Create a product

`POST /v1/products`

At least one of `name_en` or `name_ka` is required.

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `name_en` | string | optional | Product name in English (required if `name_ka` is absent). |
| `name_ka` | string | optional | Product name in Georgian (required if `name_en` is absent). |
| `short_description_en` | string | optional | Short description in English. |
| `short_description_ka` | string | optional | Short description in Georgian. |
| `price` | number | required | Sale price (min 0.01). |
| `compare_price` | number | optional | Original/crossed-out price shown to highlight a discount. |
| `base_currency` | string | optional | Base pricing currency. One of `GEL`, `USD`, `EUR`, `GBP`. Defaults to `GEL`. |
| `accepted_currencies` | array | optional | Currencies the customer may pay in (e.g. `["GEL","USD"]`). |
| `sku` | string | optional | Stock keeping unit identifier (max 100 chars). |
| `track_stock` | boolean | optional | Enable stock tracking. Defaults to `false`. |
| `stock_quantity` | integer | optional | Current stock level. Only used when `track_stock` is `true`. |
| `categories` | array | optional | Array of category strings (e.g. `["clothing","sale"]`). |
| `custom_fields_schema` | array | optional | Extra checkout form fields — same structure as the payment `custom_fields_schema`. |
| `is_active` | boolean | optional | Whether the product is available for purchase. Defaults to `true`. |

Returns `201 Created`.

> Returns the full product object (same fields as Get a product).

## Get a product

`GET /v1/products/{uuid}`

Returns the full product object.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Product UUID. |

Returns `200 OK`.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `uuid` | string | Unique product identifier. |
| `name_en` | string|null | Product name in English. |
| `name_ka` | string|null | Product name in Georgian. |
| `short_description_en` | string|null | Short description in English. |
| `short_description_ka` | string|null | Short description in Georgian. |
| `price` | number | Sale price. |
| `compare_price` | number|null | Original/crossed-out price. |
| `base_currency` | string | Base pricing currency. |
| `accepted_currencies` | array | Currencies the customer may pay in. |
| `sku` | string|null | Stock keeping unit. |
| `track_stock` | boolean | Whether stock tracking is enabled. |
| `stock_quantity` | integer|null | Current stock level. |
| `image_url` | string|null | Primary product image URL. |
| `categories` | array | Category strings. |
| `custom_fields_schema` | array | Extra checkout fields. |
| `is_active` | boolean | Whether the product is available. |
| `created_at` | datetime | Creation timestamp. |
| `updated_at` | datetime | Last update timestamp. |

## Update a product

`PUT /v1/products/{uuid}`

Partial update — include only the fields you want to change. Same field rules as create. Returns the full product object.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Product UUID. |

Returns `200 OK`.

## Delete a product

`DELETE /v1/products/{uuid}`

Deletes the product.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Product UUID. |

```json
// 200 OK
{ "deleted": true }
```

## Create a checkout link

`POST /v1/checkout-links`

Creates a reusable payment link. Provide `product_uuid` (price comes from the product), a fixed `amount`, or neither for a variable-amount link — the customer picks/enters the amount on the payment page (donations, tips, pay-what-you-want).

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `product_uuid` | string | optional | Attach an existing product — its price is used automatically. |
| `amount` | number|null | optional | Fixed amount in the chosen currency. Omit (with no `product_uuid`) to create a variable-amount link. |
| `preset_amounts` | number[] | optional | Variable-amount links only (no `product_uuid`/`amount`). Up to 6 quick-pick amounts shown as buttons on the payment page. |
| `allow_custom_amount` | boolean | optional | Variable-amount links only. Whether the customer can also type a custom amount. Defaults to `true`. |
| `currency_override` | string | optional | Override the default currency: `GEL`, `USD`, `EUR`, or `GBP`. |
| `hide_currency_selector` | boolean | optional | Hide the currency selector on checkout. Defaults to `false`. |
| `expires_at` | datetime | optional | ISO 8601 datetime after which the link is no longer usable. |
| `max_uses` | integer | optional | Maximum number of times the link can be used (min 1). |
| `allowed_gateways` | array | optional | Restrict which gateway slugs are shown. Defaults to all active gateways. |
| `custom_fields_schema` | array | optional | Extra checkout form fields (see note below). |
| `checkout_settings` | object | optional | Checkout page behaviour overrides (same structure as Brand Checkout Settings). |

```json
{
  "amount": 50.00,
  "currency_override": "GEL",
  "expires_at": "2026-12-31T23:59:59Z",
  "max_uses": 100
}
```

```json
// 201 Created
{
  "uuid": "018f...",
  "url": "https://qpy.ge/p/abc123",
  "slug": "abc123",
  "product_uuid": null,
  "amount": 50.00,
  "currency": "GEL",
  "expires_at": "2026-12-31T23:59:59+00:00",
  "max_uses": 100,
  "is_active": true,
  "created_at": "2026-06-01T09:00:00+00:00"
}
```

> `custom_fields_schema` uses the same shape as payments **except** `radio` is not supported and `label` is capped at 100 chars. `select` fields require an `options` array of `{ "label": "…", "value": "…" }`.

## Embed Button

Add a Quickpay checkout button to any HTML page with one script tag. Pass a checkout link UUID in `data-checkout-link`; the script renders a styled `<a>` immediately after the script tag, so right-click/open-in-new-tab still works.

```html
<script src="https://qpy.ge/embed.js"
        data-checkout-link="YOUR_CHECKOUT_LINK_UUID"
        data-label="Buy Now"
        data-color="#2563eb">
</script>
```

| Attribute | Default | Description |
|---|---|---|
| `data-checkout-link` | — | Required. Checkout link UUID/slug or a full `https://` URL. |
| `data-label` | `გადახდა` | Button text. |
| `data-color` | `#2563eb` | Button background color. |
| `data-text-color` | `#ffffff` | Button text color. |
| `data-size` | `md` | `sm`, `md`, or `lg`. |
| `data-full-width` | `false` | Set to `true` for a block button that stretches to the container width. |
| `data-radius` | `8px` | CSS border-radius value. |
| `data-target` | `_self` | Use `_blank` to open checkout in a new tab with `rel="noopener noreferrer"`. |

> If your site uses Content Security Policy, allow the Quickpay script host, for example: `script-src https://qpy.ge`.

## List invoices

`GET /v1/invoices`

Returns a paginated list (20 per page) sorted newest first.

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `status` | string | optional | One of `draft`, `sent`, `viewed`, `paid`, `cancelled`, `overdue`. |
| `customer_email` | string | optional | Filter by exact customer email. |
| `from` | date | optional | Start date — `YYYY-MM-DD`. |
| `to` | date | optional | End date — `YYYY-MM-DD`. Must be ≥ `from`. |
| `page` | integer | optional | Page number (default 1). |

Returns `200 OK`.

## Create an invoice

`POST /v1/invoices`

Creates an invoice in `draft` status. Use Send invoice to email it. The response includes a `payment_url` the customer can use to pay online.

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `customer_name` | string | required | Customer full name (max 255 chars). |
| `customer_email` | string | required | Customer email address. |
| `customer_phone` | string | optional | Customer phone number. |
| `currency` | string | optional | One of `GEL`, `USD`, `EUR`, `GBP`. Defaults to `GEL`. |
| `due_at` | date | optional | Payment due date — `YYYY-MM-DD`. Must be after today. |
| `notes` | string | optional | Notes shown to the customer on the invoice (max 2000 chars). |
| `terms` | string | optional | Terms & conditions shown on the invoice (max 2000 chars). |
| `discount_type` | string | optional | `percent` or `fixed`. |
| `discount_value` | number | optional | Discount amount. Percent: 0–100. Fixed: deducted from subtotal. |
| `items` | array | required | Line items array — at least one item required. |

**items[] object**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `description` | string | required | Line item description (max 500 chars). |
| `quantity` | number | required | Quantity (min 0.01 — supports fractional units). |
| `unit_price` | number | required | Price per unit (min 0). |

```json
{
  "customer_name": "Nino Kvaratskhelia",
  "customer_email": "nino@example.ge",
  "currency": "GEL",
  "due_at": "2026-07-01",
  "discount_type": "percent",
  "discount_value": 10,
  "items": [
    { "description": "Web design — 10 hours", "quantity": 10, "unit_price": 50.00 },
    { "description": "Hosting (monthly)", "quantity": 1, "unit_price": 15.00 }
  ]
}
```

```json
// 201 Created
{
  "uuid": "018f...",
  "invoice_number": "INV-0001",
  "status": "draft",
  "currency": "GEL",
  "subtotal": 515.00,
  "discount_type": "percent",
  "discount_value": 10,
  "discount_amount": 51.50,
  "total": 463.50,
  "due_at": "2026-07-01",
  "payment_url": "https://qpy.ge/invoice/018f...",
  "items": [
    { "description": "Web design — 10 hours", "quantity": 10, "unit_price": 50.00, "line_total": 500.00 },
    { "description": "Hosting (monthly)", "quantity": 1, "unit_price": 15.00, "line_total": 15.00 }
  ]
}
```

## Get an invoice

`GET /v1/invoices/{uuid}`

Returns the full invoice object including `payment_url`, `sent_at`, `viewed_at`, and `paid_at` timestamps.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Invoice UUID. |

Returns `200 OK`.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `uuid` | string | Unique invoice identifier. |
| `invoice_number` | string | Human-readable invoice number (e.g. `INV-0001`). |
| `status` | string | `draft`, `sent`, `viewed`, `paid`, `cancelled`, `overdue`. |
| `customer_name` | string | Customer full name. |
| `customer_email` | string | Customer email address. |
| `customer_phone` | string|null | Customer phone number. |
| `currency` | string | Currency code. |
| `subtotal` | number | Sum of all line items before discount. |
| `discount_type` | string|null | `percent` or `fixed`. |
| `discount_value` | number|null | Discount value as entered. |
| `discount_amount` | number|null | Actual amount deducted. |
| `total` | number | Final amount after discount. |
| `notes` | string|null | Notes shown to the customer. |
| `terms` | string|null | Terms & conditions text. |
| `due_at` | date|null | Payment due date. |
| `created_at` | datetime | Invoice creation timestamp. |
| `sent_at` | datetime|null | When the invoice was emailed to the customer. |
| `viewed_at` | datetime|null | When the customer first opened the invoice. |
| `paid_at` | datetime|null | When the invoice was paid. |
| `payment_url` | string | URL the customer can use to pay online. |
| `items` | array | Line items (`description`, `quantity`, `unit_price`, `line_total`). |

## Send an invoice

`POST /v1/invoices/{uuid}/send`

Emails the invoice to the customer and transitions status to `sent`. Cannot be called when status is `paid` or `cancelled`.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Invoice UUID. |

```json
// 200 OK
{
  "status": "sent",
  "sent_at": "2026-06-01T10:05:00+00:00",
  "email_delivered": true
}
```

> Returns `207 Multi-Status` if the status update succeeded but the email failed to deliver — check `email_delivered` in the response.

## List gateways

`GET /v1/gateways`

Returns the active gateways for your brand — gateways with an active module subscription. Useful for populating a gateway selector in your own UI. Requires authentication but does **not** require a gateway license.

```json
// 200 OK
{
  "gateways": [
    {
      "slug": "bog_card",
      "name": "BOG Card",
      "min_amount_gel": 0.5,
      "max_amount_gel": 50000,
      "brand_color": "#FF6600",
      "bg_color": "#FFF3E0",
      "icon_url": "https://cdn.quickpay.ge/gateways/bog.svg",
      "dark_icon_url": "https://cdn.quickpay.ge/gateways/bog-dark.svg"
    }
  ]
}
```

**All supported gateways — static reference.** `GET /v1/gateways` returns only the gateways with an active module subscription on your account.

| Slug | Display name | Type | Payment methods |
|---|---|---|---|
| bog_card | BOG Card | Card | Visa/MC, Apple Pay, Google Pay |
| bog_installment | BOG Installment | Installment loan | Card (3–36 month plans) |
| bog_bnpl | BOG Part by Part | BNPL | 0% interest split |
| tbc_card | TBC Card | Card | Visa/MC, Apple Pay, Google Pay |
| tbc_installment | TBC Installment | Installment loan | Card (instalment plans) |
| credo_installment | Credo Installment | Installment loan | Card (instalment plans) |
| flitt | Flitt | Card | Visa/MC |
| flitt_installment | Flitt Installment | Installment loan | Card (instalment plans) |
| flitt_bnpl | Flitt BNPL | BNPL | 0% interest split |
| keepz | Keepz | Multi-method | Card, QR code, open banking, digital wallet |
| citypay | CityPay | Crypto | Cryptocurrency (settled in GEL) |
| unipay | UniPay | Card | Visa/MC |
| fastoo | Fastoo | Card | Visa/MC |
| moka | Moka | Card | Visa/MC |
| paypal | PayPal | PayPal | PayPal balance, card via PayPal |
| paysera | Paysera | Multi-method | Card, open banking |
| liberty_card | Liberty Card | Card | Visa/MC via Liberty Bank |
| dodo_payments | Dodo Payments | Merchant of Record | Global card/wallet payments, Dodo handles tax compliance |
| bank_transfer | Bank Transfer | Manual | IBAN bank transfer (no hosted page) |
| cash_on_delivery | Cash on Delivery | Offline | No payment collected online |

Apple Pay and Google Pay are offered automatically by BOG and TBC on supported devices — no extra integration required.

## List checkout templates

`GET /v1/checkout-templates`

Returns the active checkout templates for your brand. Use the returned `id` as `checkout_template_id` when creating a payment. Requires authentication but does **not** require a gateway license.

```json
// 200 OK
{
  "templates": [
    {
      "id": 1,
      "uuid": "018f...",
      "name": "Default",
      "is_default": true,
      "gateway_count": 3,
      "fields_count": 2
    }
  ]
}
```

## Exchange rates

`GET /v1/exchange-rates`

**Public endpoint — no authentication required.** Converts an amount between two currencies using live rates.

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `from` | string | required | 3-letter source currency code (e.g. `GEL`). |
| `to` | string | required | 3-letter target currency code (e.g. `USD`). |
| `amount` | number | required | Amount to convert. |

```json
// 200 OK
{
  "from": "GEL",
  "to": "USD",
  "rate": 0.3676,
  "amount": 100,
  "converted": 36.76
}
```

## Installments & BNPL

Trigger installment or BNPL checkout with a single `POST /v1/payments` — no extra API calls needed. Quickpay fetches available plans from the bank and displays them on the hosted payment page automatically.

> **Merchants receive the full amount upfront.** The bank collects instalments from the customer — this is not a deferred payout.

**Installment slugs:** `bog_installment` · `tbc_installment` · `credo_installment` · `flitt_installment` — customer picks a repayment plan (3–36 months).

**BNPL slugs:** `bog_bnpl` (BOG "Part by Part") · `flitt_bnpl` — 0% interest split; the bank absorbs the financing cost.

1. Merchant passes an installment or BNPL `gateway_slug` in `POST /v1/payments`.
2. Customer lands on the hosted payment page — Quickpay fetches available plans live and displays them.
3. Customer selects a plan and completes checkout at the bank.
4. Merchant receives the full payment amount upfront.
5. Webhook fires `payment.paid` — identical to a card payment.

```json
{
  "amount": 999.00,
  "currency": "GEL",
  "description": "MacBook Pro",
  "gateway_slug": "bog_installment",
  "return_url": "https://yourshop.ge/thank-you"
}
```

Plan availability and terms are fetched live from each bank's API and cached for 5 minutes. If the bank API is temporarily unavailable, the hosted page shows a graceful "currently unavailable" message.

## Get installment plans

`GET /v1/installment-plans/{gateway_slug}`

Fetches real installment/BNPL plan numbers for a gateway before redirecting the customer — same live calculator `POST /v1/payments` uses internally on the hosted page, exposed directly for integrators who need to show plan terms on their own checkout UI first.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `gateway_slug` | string | required | One of `bog_installment`, `bog_bnpl`, `tbc_installment`, `credo_installment`, `flitt_installment`. |

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `amount` | number | required | Order amount as a decimal (e.g. `149.99`). Min `0.01`. |

```json
// 200 OK
{
  "supports_installments": true,
  "plans": [
    { "months": 3, "monthly_payment": 50.33, "total": 150.99, "discount_code": "STANDARD", "label": "3 თვე — 50.33 ₾/თვე" }
  ]
}
```

> For `tbc_installment`, `credo_installment`, and `flitt_installment` — gateways with no public calculator API — the response is `{ "supports_installments": true, "plans": null, "message": "Plan terms are shown after continuing to the bank's page." }`.

> For a gateway that does not support installments at all, the response is `{ "supports_installments": false, "plans": null }`.

> Results are cached for 5 minutes per `(brand, gateway_slug, amount)`. If the bank's calculator API is temporarily unavailable, a stale cached result is served with `"stale": true` when one exists.

Requires an active gateway license the same way `POST /v1/payments` does — the license check is domain-wide, not per gateway.

## Recurring Payments

Card-on-file and subscription billing using `*_subscriptions` gateway slugs. The customer completes the first payment on the hosted page, which charges and tokenises their card with the bank. Subsequent charges happen server-to-server with no customer interaction.

**Available recurring slugs:** `bog_card_subscriptions` · `tbc_card_subscriptions` · `flitt_subscriptions` · `unipay_subscriptions` · `fastoo_subscriptions` · `paypal_subscriptions` · `liberty_card_subscriptions` · `dodo_payments_subscriptions`

```
Step 1   POST /v1/payments { gateway_slug: "bog_card_subscriptions", amount: 29.99 }
         → customer pays on the hosted page
         → payment.paid webhook fires with "subscription_token": "<uuid>"   ← save this

Step 2   POST /v1/payments/charge { subscription_token: "<uuid>", amount: 29.99 }
         → 202 Accepted
         → subscription.charged webhook on success (subscription.failed on failure)
```

## Charge a subscription token

`POST /v1/payments/charge`

Charges a stored subscription token server-to-server. The customer completed the first payment on the hosted page using a `*_subscriptions` slug, which tokenised their card with the bank.

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `subscription_token` | string | required | Token returned in the `payment.paid` webhook after the first payment (UUID). |
| `amount` | number | optional | Override amount for this charge. Defaults to the original subscription amount. |
| `description` | string | optional | Description for this charge (max 500 chars). |

```json
// 202 Accepted
{
  "uuid": "018f...",
  "status": "pending",
  "amount": 29.99,
  "currency": "GEL",
  "subscription_token": "a1b2c3d4-...",
  "message": "Charge initiated. Final result will be delivered via webhook."
}
```

> A minimum of 24 hours must pass between charges on the same token, otherwise the API returns `429 charge_too_frequent` with a `retry_after` timestamp.

> **Looking for fully managed subscription scheduling?** The **Subscriptions** dashboard module handles billing cycles, dunning, and renewals automatically — no API calls needed.

## Zero-Amount Tokenization

Save a customer's card on file **without charging anything** — useful for signing a customer up before their first real charge is known (e.g. a free trial, or a plan the customer picks later). Pass `tokenize_only: true` on `POST /v1/payments` with a `*_subscriptions` `gateway_slug`. `amount` is forced to `0` server-side regardless of what you post.

```json
{
  "gateway_slug": "tbc_card_subscriptions",
  "tokenize_only": true,
  "customer_email": "nino@example.ge"
}
```

**Supported gateways:** `bog_card_subscriptions` · `tbc_card_subscriptions` · `flitt_subscriptions` · `unipay_subscriptions` · `fastoo_subscriptions` · `liberty_card_subscriptions` · `dodo_payments_subscriptions`. `paypal_subscriptions` is plan/billing-agreement based, not a generic saved-card token, so it always returns `422 tokenize_only_gateway_unsupported`.

The customer completes the save-card step on the hosted page exactly like a normal first `*_subscriptions` payment. Once the bank confirms the card is saved, the payment resolves to `status: "paid"` (amount `0`) and `payment.paid` fires on the webhook with a `subscription_token` — use it with `POST /v1/payments/charge` for the customer's first real charge.

> A handful of gateways (`bog_card_subscriptions`, `flitt_subscriptions`, `unipay_subscriptions`, `fastoo_subscriptions`) may reject a zero-amount card save at the bank's discretion — that comes back as `422 zero_amount_not_supported` rather than a generic failure. If you hit this consistently on a gateway, fall back to a small non-zero first charge instead.

## Managing subscriptions

Full lifecycle management for `CustomerSubscription`s beyond the basic recurring-charge flow above — list/filter, merchant-initiated creation, cancel (immediate or at period end), pause/resume, reactivate, and a paginated audit trail. See also Zero-Amount Tokenization for saving a card without a subscription.

## List subscriptions

`GET /v1/subscriptions`

Lists your brand's recurring subscriptions, newest first.

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `status` | string | optional | Filter by one of `active`, `past_due`, `cancelled`, `expired`, `paused`, `pending_cancellation`. |
| `from` | date | optional | Only subscriptions created on/after this date. |
| `to` | date | optional | Only subscriptions created on/before this date. |
| `customer_id` | integer | optional | Filter to one customer. |
| `customer_email` | string | optional | Filter to one customer by email. |
| `plan_uuid` | string | optional | Filter to subscriptions currently on one plan. |

```json
// 200 OK
{
  "data": [
    {
      "uuid": "018f...",
      "plan_uuid": "018f...",
      "customer_id": 42,
      "customer_name": "Giorgi Beridze",
      "customer_email": "giorgi@example.ge",
      "customer_phone": "+995555000000",
      "amount": 29.99,
      "currency": "GEL",
      "status": "active",
      "gateway_slug": "bog_card_subscriptions",
      "billing_interval_unit": "month",
      "billing_interval_count": 1,
      "billing_anchor_day": null,
      "current_period_start": "2026-08-16T10:20:00Z",
      "current_period_end": "2026-09-16T10:20:00Z",
      "next_billing_date": "2026-09-16T10:20:00Z",
      "last_charge_at": "2026-08-16T10:20:00Z",
      "cancelled_at": null,
      "created_at": "2026-05-16T10:20:00Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 1 }
}
```

## Create a subscription (merchant-initiated)

`POST /v1/subscriptions`

Starts a recurring subscription for a customer who already has a saved payment method — e.g. a phone-order subscription. This never collects card details itself: create the payment method first with Add a payment method. No initial charge is made; the first charge happens at the plan's next period end via the normal renewal cycle, exactly like a subscription created through the hosted checkout.

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `plan_uuid` | string | required | The `SubscriptionPlan` to subscribe onto — see List subscription plans. |
| `customer_id` | integer | required | The customer to subscribe. |
| `payment_method_uuid` | string | optional | A specific saved payment method to charge. Defaults to the customer's default active payment method for this brand. |

```json
// 201 Created
{
  "uuid": "018f...",
  "plan_uuid": "018f...",
  "customer_id": 42,
  "status": "active",
  "amount": 29.99,
  "currency": "GEL",
  "gateway_slug": "bog_card_subscriptions",
  "current_period_start": "2026-09-16T10:20:00Z",
  "current_period_end": "2026-10-16T10:20:00Z"
}
```

> Fires a `subscription.created` webhook.

> Returns `404 subscription_plan_not_found` for an unknown or archived plan, `404 customer_not_found` for an unknown customer, `404 payment_method_not_found` for an unknown `payment_method_uuid`, and `422 no_active_payment_method` when the customer has no saved payment method at all.

## Get a subscription

`GET /v1/subscriptions/{uuid}`

Returns a single subscription by UUID.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

Returns `200 OK`.

## List subscription events

`GET /v1/subscriptions/{uuid}/events`

The subscription's full audit trail, paginated, newest first — every charge, failure, cancellation, pause/resume, plan change, payment-method update, and reactivation.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

```json
// 200 OK
{
  "data": [
    {
      "id": 501,
      "event_type": "plan_changed",
      "actor_type": "merchant",
      "actor_id": 7,
      "payload": { "from_plan_id": 12, "to_plan_id": 13, "mode": "immediate", "amount": 10.0 },
      "created_at": "2026-09-01T09:00:00Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 1 }
}
```

> `event_type` is one of `created`, `charged`, `charge_failed`, `cancelled`, `paused`, `resumed`, `plan_changed`, `payment_method_updated`, `reactivated`.

## Cancel a subscription

`POST /v1/subscriptions/{uuid}/cancel`

Cancels a subscription, immediately by default.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `at_period_end` | boolean | optional | When `true`, the subscription keeps its access and billing period but stops renewing — status becomes `pending_cancellation` and it is finalized to `cancelled` once `current_period_end` passes. Ignored (cancels immediately) if the current period has already ended. |
| `reason` | string | optional | Optional cancellation reason, stored on the subscription and its audit trail (max 500 chars). |

Returns `200 OK`.

> Returns `422 subscription_already_cancelled` for a subscription that is already `cancelled` or `pending_cancellation`.

## Reactivate a subscription

`POST /v1/subscriptions/{uuid}/reactivate`

Undoes a cancellation — moves a `cancelled`, `past_due`, or `pending_cancellation` subscription back to `active`.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

Returns `200 OK`.

> Returns `422 subscription_reactivation_not_allowed` for an `active`, `paused`, or `expired` subscription — an `expired` subscription never had a payment method attached, so there is nothing to reactivate.

## Pause a subscription

`POST /v1/subscriptions/{uuid}/pause`

Pauses an active subscription — no further charges happen while paused.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `resumes_at` | datetime | optional | When set, a future date to automatically resume. Omit to pause indefinitely until Resume a subscription is called. |

Returns `200 OK`.

> Fires a `subscription.paused` webhook.

> Only an `active` subscription can be paused — returns `422 subscription_inactive` otherwise.

## Resume a subscription

`POST /v1/subscriptions/{uuid}/resume`

Resumes a paused subscription back to `active`.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

Returns `200 OK`.

> Fires a `subscription.resumed` webhook.

> Only a `paused` subscription can be resumed — returns `422 subscription_not_paused` otherwise.

## Plan changes & payment methods on file

Move a subscription onto a different `SubscriptionPlan` with configurable proration, or hand the customer a hosted link to update their card or manage everything themselves.

## Get an update-card link

`POST /v1/subscriptions/{uuid}/update-payment-method`

Mints a one-time hosted link that walks the customer through re-entering their card on the bank's own checkout page. On completion, the subscription is repointed at the newly-saved `CustomerPaymentMethod`.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

```json
// 200 OK
{
  "update_url": "https://qpy.ge/update-card/eyJpdiI6...",
  "expires_at": "2026-09-18T10:20:00Z"
}
```

> Fires a `subscription.payment_method_updated` webhook once the customer completes the flow.

> Only works for a `*_subscriptions` gateway (never `paypal_subscriptions`) — returns `422 tokenize_only_gateway_unsupported` otherwise, or `422 zero_amount_not_supported` for a gateway that has not confirmed zero-amount card saves.

## Preview a plan change

`POST /v1/subscriptions/{uuid}/preview-plan-change`

Computes what Change plan would charge or credit, without changing anything — build an upgrade/downgrade confirmation screen against this.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `plan_uuid` | string | required | The target `SubscriptionPlan`. |
| `mode` | string | optional | One of `immediate`, `deferred`, `none`. Defaults to the target plan's own `proration_mode`. |

```json
// 200 OK
{
  "mode": "immediate",
  "amount": 10.0,
  "currency": "GEL",
  "from_plan_uuid": "018f...",
  "to_plan_uuid": "018f...",
  "remaining_days": 15,
  "total_days": 30
}
```

> Plan changes are same-currency only — returns `422 plan_currency_mismatch` otherwise.

## Change plan

`POST /v1/subscriptions/{uuid}/change-plan`

Moves a subscription onto a different plan. `immediate` mode charges/credits the prorated difference right away; `deferred` folds it into the next renewal charge; `none` takes effect at the next renewal with no math. Every change writes its own auditable proration record regardless of mode.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `plan_uuid` | string | required | The target `SubscriptionPlan`. |
| `mode` | string | optional | One of `immediate`, `deferred`, `none`. Defaults to the target plan's own `proration_mode`. |

```json
// 200 OK
{
  "subscription": { "uuid": "018f...", "status": "active", "amount": 39.99 },
  "proration": {
    "id": 501,
    "mode": "immediate",
    "amount": 10.0,
    "from_plan_id": 12,
    "to_plan_id": 13,
    "applied_at": "2026-09-01T09:00:00Z",
    "applied_to_payment_id": 9001,
    "created_at": "2026-09-01T09:00:00Z"
  }
}
```

> Fires a `subscription.plan_changed` webhook.

> A downgrade credit (negative `immediate` amount) is never auto-refunded — it is recorded for audit only.

## Get a hosted portal link

`POST /v1/subscriptions/{uuid}/portal-session`

Mints a `qpy.ge/manage/{token}` hosted link — point your own "Manage Subscription" button at the returned `url` (Hybrid tier) rather than building subscription-management UI yourself.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Subscription UUID. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `scope` | string | optional | `subscription` (default) scopes the link to just this one, `all` widens it to every subscription sharing this customer's email on this brand. |
| `ttl_days` | integer | optional | Link lifetime in days, 1–365. Defaults to 30. |

```json
// 201 Created
{
  "url": "https://qpy.ge/manage/9f8c...",
  "scope": "subscription",
  "expires_at": "2026-10-16T10:20:00Z"
}
```

## Recipes: letting a customer manage their own subscription

Every `*_subscriptions` gateway requires the customer to be able to view and cancel their own subscription — this traces back to a National Bank of Georgia rule on recurring payments, and every Quickpay-hosted subscription notice email already links to it. You have two ways to offer this, and you can mix them per subscription — nothing below needs picking once for the whole integration.

|  | Hosted portal (recipe A) | Build your own (recipe B) |
|---|---|---|
| You write | One API call + a link/button | A UI against the full subscription API |
| Quickpay hosts | The entire page (`qpy.ge/manage/{token}`) | Nothing — you call the API directly |
| Branding | Your brand's logo/colors, no Quickpay UI to theme | Fully yours |
| Best for | Merchants who don't want to build subscription UI at all | Merchants with an existing customer dashboard |
| Also see | `my.qpy.ge` — a free, zero-code alternative: any customer can self-register there and see every subscription linked to their email across every merchant on Quickpay, with no integration work at all | — |

## Recipe A — use the hosted portal

Mint a signed link and point a "Manage Subscription" button at it. Quickpay renders the whole page — plan, next billing date, charge history, and cancel/update-card actions — themed with your brand's logo and colors. You never see the customer's card details or session state.

1. Call `POST /v1/subscriptions/{uuid}/portal-session` (see Plan changes & payment methods on file above) whenever you want to send the customer a management link — from your own dashboard, a support macro, an SMS, or a "Manage my subscription" email you send yourself.
2. Pass `"scope": "subscription"` (default) for a link scoped to just that one subscription, or `"scope": "all"` to cover every subscription sharing that customer's email on your brand — useful for a customer with more than one active subscription.
3. The response is `{ "url": "https://qpy.ge/manage/<token>", "expires_at": "..." }`. The link is multi-use until it expires (`ttl_days`, default 30) — send the same one in a follow-up reminder rather than minting a new one each time.
4. Nothing else to build: the page itself handles cancel and "update payment method" (which redirects into the same zero-amount re-tokenization flow as Zero-Amount Tokenization).

```json
POST /v1/subscriptions/f47ac10b-58cc-4372-a567-0e02b2c3d479/portal-session
{
  "scope": "subscription",
  "ttl_days": 30
}

→ 201 Created
{
  "url": "https://qpy.ge/manage/qpm_9f8e7d6c5b4a...",
  "scope": "subscription",
  "expires_at": "2026-10-17T09:00:00Z"
}
```

> This is exactly what every compliance notice email (the "you'll be charged in 4 weeks" / trial-ending reminders) links to automatically — you don't need to wire anything for those; they mint their own portal session behind the scenes.

> This is not Laravel's signed-URL mechanism and not a JWT — it's an opaque, randomly-generated token, only its hash ever stored server-side (the same pattern as your API keys). Treat the URL itself as a bearer credential: don't log it anywhere a support agent or analytics tool would see it in plain text, and don't embed it in a public page.

## Recipe B — build your own subscription portal

For a merchant with an existing logged-in customer dashboard who wants subscription management to feel native — same layout, same session, no redirect to a Quickpay-hosted page. Call the API directly for every action; nothing is hosted for you.

1. On your customer's account/orders page, call `GET /v1/subscriptions?customer_email=...` (or `customer_id`) to list what they have. Each row includes `status`, `amount`, `current_period_end`, and the plan.
2. For the detail view, `GET /v1/subscriptions/{uuid}` for the current state and `GET /v1/subscriptions/{uuid}/events` for the paginated audit trail (created, charged, charge_failed, cancelled, paused, resumed, plan_changed, payment_method_updated) — enough to render a full timeline without your own event log.
3. "Cancel" → `POST /v1/subscriptions/{uuid}/cancel`. Pass `"at_period_end": true` for a "cancel at the end of the current billing period" option (access continues, no further charge) instead of immediate cancellation.
4. "Pause" / "Resume" → `POST /v1/subscriptions/{uuid}/pause` (optionally with `resumes_at` for a scheduled auto-resume) and `POST /v1/subscriptions/{uuid}/resume`.
5. "Change plan" → `POST /v1/subscriptions/{uuid}/preview-plan-change` to show the customer the exact prorated amount before they confirm, then `POST /v1/subscriptions/{uuid}/change-plan` to apply it.
6. "Update card" → you still don't re-implement tokenization yourself: call `POST /v1/subscriptions/{uuid}/update-payment-method`, which returns a one-time hosted redirect URL to the bank's own card-entry page. Send the customer there (a new tab or a WebView), and the card is vaulted on return — same mechanism recipe A's portal uses internally.

> `paypal_subscriptions` has a reduced surface here by design — PayPal owns its own billing schedule, so `pause`/`resume`/`preview-plan-change`/`change-plan` all return `422 paypal_subscription_action_unsupported` for it. `cancel` still works normally and actually calls PayPal's own subscription-cancel API so billing stops for real, not just in Quickpay's own records.

> Every one of these endpoints is merchant-API-key-authenticated — they're for your backend to call on the customer's behalf after you've authenticated them in your own system. Never expose your API key to a browser; proxy these calls through your own backend the same way you already do for `POST /v1/payments`.

A `SubscriptionPlan` is a versioned billing definition — amount, interval, trial, proration default — that a subscription is created onto or moved between. Editing a plan never rewrites subscriptions already on it; it creates a new version and archives the old one.

## List subscription plans

`GET /v1/subscription-plans`

Lists your brand's subscription plans, newest first.

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `status` | string | optional | One of `active`, `archived`. |
| `product_id` | integer | optional | Filter to plans attached to one product. |

Returns `200 OK`.

## Create a subscription plan

`POST /v1/subscription-plans`

Creates a versioned billing plan that subscriptions can be created or moved onto.

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `product_id` | integer | optional | Attach this plan to an existing product. |
| `name` | string | required | Plan name (max 255 chars). |
| `description` | string | optional | Plan description (max 1000 chars). |
| `amount` | number | required | Billing amount as a decimal (e.g. `29.99`). |
| `currency` | string | optional | One of `GEL`, `USD`, `EUR`, `GBP`. Defaults to `GEL`. |
| `billing_interval_unit` | string | required | One of `day`, `week`, `month`, `year`. |
| `billing_interval_count` | integer | required | How many of `billing_interval_unit` between charges (e.g. `3` + `month` = quarterly). |
| `billing_anchor_day` | integer | optional | Day of month (1–28) charges anchor to, for `month`/`year` intervals. |
| `trial_days` | integer | optional | Free trial length in days. |
| `prorate_first_period` | boolean | optional | Shorten the first charge so renewals land on `billing_anchor_day`. Unrelated to plan-change proration below. |
| `proration_mode` | string | optional | Default proration mode for changes onto/off this plan — one of `immediate`, `deferred`, `none`. Defaults to `none`. |
| `is_mit_notice_exempt` | boolean | optional | NBG trial/upcoming-charge compliance notice exemption for utilities/telecom/insurance/existing-debt merchants. Do not set this unless your business genuinely qualifies. |

```json
// 201 Created
{
  "uuid": "018f...",
  "product_id": null,
  "name": "Pro monthly",
  "amount": 29.99,
  "currency": "GEL",
  "billing_interval_unit": "month",
  "billing_interval_count": 1,
  "status": "active",
  "version": 1
}
```

## Get a subscription plan

`GET /v1/subscription-plans/{uuid}`

Returns a single subscription plan by UUID.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Plan UUID. |

Returns `200 OK`.

## Edit a subscription plan

`PATCH /v1/subscription-plans/{uuid}`

"Editing" a plan never mutates it in place — it creates a new version (a fresh UUID, `version` incremented) and archives this one, so subscriptions already on it keep their own billing snapshot untouched. Send only the fields you want to change; the rest carry over from the current version.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Plan UUID. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `name` | string | optional | Plan name. |
| `description` | string | optional | Plan description. |
| `amount` | number | optional | Billing amount. |
| `currency` | string | optional | One of `GEL`, `USD`, `EUR`, `GBP`. |
| `billing_interval_unit` | string | optional | One of `day`, `week`, `month`, `year`. |
| `billing_interval_count` | integer | optional | Billing interval count. |
| `billing_anchor_day` | integer | optional | Day of month (1–28) charges anchor to. |
| `trial_days` | integer | optional | Free trial length in days. |
| `prorate_first_period` | boolean | optional | First-period proration flag. |
| `proration_mode` | string | optional | One of `immediate`, `deferred`, `none`. |
| `is_mit_notice_exempt` | boolean | optional | NBG compliance notice exemption flag. |

Returns `201 Created`.

> Returns `422 subscription_plan_archived` for an already-archived plan — create a new plan instead.

## Archive a subscription plan

`DELETE /v1/subscription-plans/{uuid}`

Archives a plan — it stops appearing as a choice for new subscriptions or plan changes, but existing subscriptions on it are untouched (they keep their own billing snapshot). Never actually deletes the row.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Plan UUID. |

Returns `200 OK`.

> Returns `422 subscription_plan_archived` for a plan that is already archived.

A `CustomerPaymentMethod` is a reusable, vaulted saved card — decoupled from any one subscription, so a customer can hold several and reuse one across subscriptions. Adding one reuses the same zero-amount tokenization mechanism as Zero-Amount Tokenization.

## List a customer's payment methods

`GET /v1/customers/{id}/payment-methods`

Lists the saved, reusable payment methods on file for one customer.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `id` | integer | required | Customer ID. |

**Query parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `status` | string | optional | One of `active`, `expired`, `failed`, `removed`. Defaults to everything except `removed`. |

```json
// 200 OK
{
  "data": [
    {
      "uuid": "018f...",
      "customer_id": 42,
      "gateway_slug": "bog_card_subscriptions",
      "card_brand": "visa",
      "card_last4": "4242",
      "card_exp_month": 12,
      "card_exp_year": 2029,
      "is_default": true,
      "status": "active",
      "created_at": "2026-05-16T10:20:00Z"
    }
  ]
}
```

> `card_brand`/`card_last4`/`card_exp_month`/`card_exp_year` are not populated by every gateway — several `*_subscriptions` gateways only ever hand back an opaque recharge token, never card details.

## Add a payment method

`POST /v1/customers/{id}/payment-methods`

Starts a card-save flow for a customer — reuses the same zero-amount `tokenize_only` mechanism as Create a payment, just scoped to a customer instead of a subscription. Redirect the customer to the returned `payment_url` to complete it on the gateway's own hosted page; the new `CustomerPaymentMethod` appears once they finish.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `id` | integer | required | Customer ID. Must have an email or phone on file — the finished payment method links back to this customer by matching one of those. |

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `gateway_slug` | string | required | A `*_subscriptions` gateway your brand has active (not `paypal_subscriptions`). |
| `return_url` | string | optional | Redirect URL after the flow completes. |

```json
// 201 Created
{
  "uuid": "018f...",
  "payment_url": "https://qpy.ge/pay/018f...",
  "status": "pending"
}
```

> Returns `422 tokenize_only_gateway_unsupported` for a non-`*_subscriptions` or `paypal_subscriptions` `gateway_slug`, `422 zero_amount_not_supported` for a gateway that has not confirmed zero-amount card saves, and `422 validation_failed` if the customer has neither email nor phone on file.

## Remove a payment method

`DELETE /v1/payment-methods/{uuid}`

Soft-removes a saved payment method (`status` becomes `removed`) — never a hard delete, so it stays visible on past subscription/event history.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Payment method UUID. |

Returns `200 OK`.

> Returns `422 payment_method_in_use` if an active, past-due, paused, or pending-cancellation subscription is still charging this payment method — update or cancel that subscription first.

## Set a payment method as default

`POST /v1/payment-methods/{uuid}/set-default`

Marks one payment method as the default for its customer — scoped per customer + brand + gateway, since a payment method for one gateway can never recharge a subscription on another.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `uuid` | string | required | Payment method UUID. |

Returns `200 OK`.

## Webhooks

When a payment status changes, Quickpay sends a `POST` request to your Webhook URL (Dashboard → Brand Settings). Your endpoint must respond with `HTTP 2xx` within 30 seconds. Failed deliveries are retried up to 5 times with exponential back-off.

**Payment events:** `payment.paid` · `payment.failed` · `payment.refunded` · `payment.partially_refunded` · `payment.refund_pending` · `payment.cancelled` · `payment.expired`

**Subscription events:** `subscription.charged` · `subscription.failed` · `subscription.created` · `subscription.paused` · `subscription.resumed` · `subscription.plan_changed` · `subscription.payment_method_updated` · `subscription.trial_ending` · `subscription.upcoming_charge` — `charged`/`failed` fire from `POST /v1/payments/charge` and the automatic renewal cycle; the rest fire from the Subscriptions management endpoints and the compliance notice scheduler.

**Invoice events:** `invoice.paid` — fired when an invoice is paid.

**Lead events:** `lead.submitted` — fired when a landing-page lead is captured.

```json
{
  "event": "payment.paid",
  "uuid": "018f2a3b-...",
  "merchant_order_id": "ORD-1234",
  "status": "paid",
  "amount": 149.99,
  "currency": "GEL",
  "customer_name": "Giorgi Beridze",
  "customer_email": "giorgi@example.ge",
  "gateway_order_id": "BOG-XYZ789",
  "subscription_token": "a1b2c3d4-...",
  "paid_at": "2026-05-16T10:23:00Z",
  "created_at": "2026-05-16T10:20:00Z",
  "form_data": {},
  "metadata": {}
}
```

`subscription_token` is only present when the payment used a `*_subscriptions` gateway slug. Save it to use with `POST /v1/payments/charge`.

## Signature verification

Every webhook request includes a `QUICKPAY-SIGNATURE` header. Always verify it to ensure the request is authentic and hasn't been tampered with.

```
QUICKPAY-SIGNATURE: t=1716123456,v1=abc123def456...
```

*PHP*

```php
[$timestamp, $signature] = explode(",", $request->header("QUICKPAY-SIGNATURE"));
$timestamp = substr($timestamp, 2);   // strip "t="
$v1        = substr($signature, 3);   // strip "v1="

// Reject stale payloads (> 5 minutes)
if (abs(time() - (int)$timestamp) > 300) {
    abort(400, "Webhook timestamp too old");
}

$payload  = "{$timestamp}." . $request->getContent();
$expected = hash_hmac("sha256", $payload, $webhookSecret);

if (!hash_equals($expected, $v1)) {
    abort(400, "Invalid signature");
}
```

*Node.js*

```js
const crypto = require('crypto');
const [ts, sig] = req.headers['quickpay-signature'].split(',');
const timestamp = ts.replace('t=', '');
const v1 = sig.replace('v1=', '');

if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('Webhook timestamp too old');
}

const payload = `${timestamp}.${req.rawBody}`;
const expected = crypto.createHmac('sha256', webhookSecret).update(payload).digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
    return res.status(400).send('Invalid signature');
}
```

*Python*

```python
import hmac, hashlib, time

header = request.headers.get('QUICKPAY-SIGNATURE', '')
parts = dict(p.split('=', 1) for p in header.split(','))
timestamp, v1 = parts.get('t', ''), parts.get('v1', '')

if abs(time.time() - int(timestamp)) > 300:
    return 'Webhook timestamp too old', 400

payload = f"{timestamp}.{request.data.decode()}"
expected = hmac.new(webhook_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()

if not hmac.compare_digest(expected, v1):
    return 'Invalid signature', 400
```

## Error codes

Every API error response includes a machine-readable `error_code` string alongside the human `message`. Switch on these stable codes instead of string-matching translated messages.

```json
{ "error_code": "coupon_expired", "message": "This coupon has expired.", "errors": { "coupon_code": ["This coupon has expired."] } }
```

The `errors` object is only present on 422 validation responses.

| error_code | HTTP | When it occurs | What to do |
|---|---|---|---|
| missing_api_key | 401 | No Authorization header | Add an `Authorization: Bearer qpk_live_...` header |
| invalid_api_key | 401 | Key does not exist or was revoked | Check your API key in Dashboard → API Keys |
| account_suspended | 403 | Merchant account is suspended | Contact Quickpay support |
| test_key_live_mode | 403 | Test key used on a live-mode brand | Use your live key or enable test mode on the brand |
| live_key_test_mode | 403 | Live key used on a test-mode brand | Use your test key or switch the brand to live mode |
| no_gateway_license | 403 | No active module subscription for this gateway | Subscribe to the gateway module in Dashboard → Marketplace |
| idempotency_conflict | 409 | Same Idempotency-Key with a different amount or currency | Use a new key, or keep amount/currency unchanged on retry |
| not_found | 404 | Resource does not exist | Check the UUID or slug |
| validation_failed | 422 | Request payload failed validation | Inspect the errors object and correct the offending fields |
| rate_limit_exceeded | 429 | More than 100 req/min per key | Retry after the Retry-After header interval |
| gateway_unavailable | 500 | Gateway API is temporarily down | Retry with exponential back-off |
| coupon_not_found | 422 | Coupon code does not exist | Check the coupon code value |
| coupon_inactive | 422 | Coupon is disabled | Contact merchant support |
| coupon_not_started | 422 | Coupon start date has not arrived | Use the coupon after its start date |
| coupon_expired | 422 | Coupon has expired | Use a different coupon code |
| coupon_product_mismatch | 422 | Coupon is only valid for a different product | Use the correct product |
| coupon_below_min_amount | 422 | Order amount is below the coupon minimum | Increase the order amount |
| coupon_max_uses_reached | 422 | Coupon has been fully redeemed | Coupon is no longer available |
| coupon_per_customer_limit | 422 | Customer has already used this coupon | Use a different coupon |
| coupon_code_taken | 422 | A coupon with this code already exists for the brand | Choose a different, unused coupon code |
| line_items_mismatch | 422 | Sum of line_items totals ≠ amount | Fix line items so their totals match the payment amount |
| template_not_found | 422 | checkout_template_id does not exist | Use a valid ID from GET /v1/checkout-templates |
| select_options_required | 422 | select/radio field missing its options array | Add an options array (with label and value keys) to the field schema |
| amount_or_product_required | 422 | Neither amount nor product_uuid was provided | Include one of amount or product_uuid |
| refund_exceeds_remaining | 422 | Refund amount > remaining refundable amount | Check refunded_amount in the payment object |
| payment_not_refundable | 422 | Payment status does not allow refunds | Only paid and partially_refunded payments can be refunded |
| payment_not_cancellable | 422 | Payment status does not allow cancellation | Only pending, initiated, and transfer_pending payments can be cancelled |
| gateway_refund_unsupported | 422 | This gateway does not support API refunds | Issue the refund manually via the bank's merchant portal |
| invoice_not_sendable | 422 | Invoice is paid or cancelled | Invoices with status draft, sent, viewed, or overdue can be (re)sent |
| subscription_not_found | 404 | subscription_token does not exist or belongs to a different brand | Check the token from the original payment.paid webhook |
| subscription_inactive | 422 | Subscription is cancelled or has expired | Only active subscriptions can be charged |
| subscription_already_cancelled | 422 | Subscription is already cancelled | No action needed — the subscription is already cancelled |
| charge_too_frequent | 429 | A charge was already initiated within the last 24 hours | Retry after the retry_after timestamp in the response body |
| webhook_not_configured | 422 | No webhook URL is configured for the brand | Set a webhook URL with PUT /v1/webhooks before testing or rotating the secret |
| tokenize_only_gateway_unsupported | 422 | `tokenize_only: true` was sent with a `gateway_slug` that is not a `*_subscriptions` gateway, or is `paypal_subscriptions` | Use a supported `*_subscriptions` gateway slug (not `paypal_subscriptions`) |
| zero_amount_not_supported | 422 | The bank rejected the zero-amount card-save request | Fall back to a small non-zero first charge for this gateway |
| subscription_plan_not_found | 404 | plan_uuid does not exist, is archived, or belongs to a different merchant | Use an active plan uuid from GET /v1/subscription-plans |
| plan_currency_mismatch | 422 | The target plan is in a different currency than the subscription's current plan | Plan changes are same-currency only — create a plan in the matching currency |
| subscription_plan_archived | 422 | PATCH/DELETE was called on a plan that is already archived | Create a new plan instead of editing an archived one |
| subscription_not_paused | 422 | POST .../resume was called on a subscription that is not paused | Only a paused subscription can be resumed |
| subscription_reactivation_not_allowed | 422 | POST .../reactivate was called on an active, paused, or expired subscription | Only cancelled, past_due, or pending_cancellation subscriptions can be reactivated |
| paypal_subscription_action_unsupported | 422 | POST /v1/subscriptions, POST .../pause, POST .../resume, POST .../preview-plan-change, or POST .../change-plan targeted a paypal_subscriptions payment method/subscription | PayPal manages its own billing schedule — create or manage the subscription through PayPal directly, not this endpoint. Cancel still works normally: POST .../cancel calls PayPal's own subscription-cancel API so billing actually stops. |
| customer_not_found | 404 | The customer id does not exist for this brand | Check the id from GET /v1/customers |
| payment_method_not_found | 404 | The payment method uuid does not exist for this brand/customer | Check the uuid from GET /v1/customers/{id}/payment-methods |
| payment_method_in_use | 422 | DELETE was called on a payment method still used by an active subscription | Update or cancel the subscription using it first |
| no_active_payment_method | 422 | POST /v1/subscriptions was called for a customer with no saved payment method | Create one first with POST /v1/customers/{id}/payment-methods |
