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

## 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 | Pre-select a gateway. Omit to show all available gateways. See List gateways and What you can build for valid slugs. |
| `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`, or `api`. |
| `idempotency_key` | string | optional | Alternative to the `Idempotency-Key` header (max 255 chars). |

**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.

## 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 either `product_uuid` (price comes from the product) or a fixed `amount`.

**Body parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `product_uuid` | string | optional | Attach an existing product — its price is used automatically. Required if `amount` is absent. |
| `amount` | number | optional | Fixed amount in the chosen currency. Required if `product_uuid` is absent. |
| `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 |
| 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.

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

```
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.

## 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` — fired by `POST /v1/payments/charge`.

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