API დოკუმენტაცია
Base URL: https://api.quickpay.ge/v1
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.
payment_url → receive the payment.paid webhook. Only the gateway_slug changes.💳 Card payment
One-time card charge. Customer enters card details at the bank's secure interface. Visa/MC, Apple Pay, and Google Pay where supported.
bog_card · tbc_card · flitt · unipay · fastoo · moka · keepz📆 Installment loan
Customer picks a repayment plan (3–36 months). Merchant receives the full amount upfront — the bank handles instalment collection.
bog_installment · tbc_installment · credo_installment · flitt_installment0️⃣ BNPL (0% financing)
Buy-now-pay-later split into equal 0-interest instalments. The bank absorbs the financing cost — no extra charge to customer or merchant.
bog_bnpl · flitt_bnpl📱 Open banking / QR / Wallet
Card + QR code + open banking + digital wallet in one gateway. Supports all major Georgian banks via open banking.
keepz · paysera₿ Crypto
Customer pays in cryptocurrency. CityPay handles the conversion and settles GEL to the merchant. No crypto wallet required on the merchant side.
citypay🏦 Offline / manual
No hosted payment page. Bank transfer: customer receives your IBAN + order UUID as reference. COD: merchant confirms delivery manually.
bank_transfer · cash_on_deliveryQuick 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.
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.
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_hereRate 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-1Amounts & 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 · GitHub |
| Laravel Package | quickpayge/laravel | composer require quickpayge/laravel | quickpay.ge/laravel-package · GitHub |
| Node.js / TypeScript SDK | @quickpay/js | npm install @quickpay/js | quickpay.ge/node-sdk · GitHub |
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.
{
"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 |
| WHMCS | Hosting / billing | quickpay.ge/whmcs-plugin |
| OpenCart | E-commerce | quickpay.ge/opencart-plugin |
| CS-Cart | E-commerce | quickpay.ge/cscart-plugin |
| PrestaShop | E-commerce | quickpay.ge/prestashop-plugin |
| Easy Digital Downloads (WordPress) | Digital goods | 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 → Try in Playground
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 | 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. |
| fallback_to_gel | boolean | optional | Only valid with gateway_slug. When currency is not supported by that gateway, convert amount to GEL at the live exchange rate and charge in GEL instead of failing. Ignored when the gateway already supports the currency. The response carries a currency_fallback object with the original amount and the rate used. See the note below. |
| 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. |
| shipping | object | optional | Let the customer choose a delivery option on the hosted checkout, using your brand's shipping packages — no Products module needed. Hosted checkout only (omit gateway_slug). The chosen delivery cost is added on top of amount, so do not also send a shipping line item. See the schema below. Omit it to use the brand's Checkout Settings / template default. |
| 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 | 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). |
shipping object
| Parameter | Type | Description | |
|---|---|---|---|
| mode | string | required | packages (customer picks from your shipping packages), free (shows a "Free shipping" badge) or none (no delivery step — overrides a brand-wide shipping default). |
| package_ids | array | optional | packages mode only: restrict the choice to these shipping package ids (from your dashboard → Shipping). Omit to offer every active package whose order-amount rules match. |
| quantity | integer | optional | Units in the order (default 1). per_item shipping options are priced × this quantity; amount is still the whole order total. |
| free_days_en | string | optional | free mode only: delivery-time text in English, e.g. 2-3 business days. |
| free_days_ka | string | optional | free mode only: delivery-time text in Georgian. |
custom_fields_schema[] object
| Parameter | Type | 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 -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": "[email protected]",
"return_url": "https://yourshop.ge/thank-you"
}'$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');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();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']// 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,
"currency_fallback": null
}gateway_slug is omitted the customer picks the gateway on the hosted page, and the response omits discount_gel, coupon_code, and redirect_url.Idempotency-Key returns the original payment with HTTP 200; a different amount or currency for the same key returns 409.fallback_to_gel: true (requires gateway_slug) charges the GEL equivalent when the gateway does not support currency. The response amount and currency are then the GEL amount actually charged, and currency_fallback is { "original_amount", "original_currency", "exchange_rate", "converted_amount" } (it is null when no conversion happened). The same object is stored in the payment metadata and so appears on webhooks. Coupons and line_items are validated in the original currency; line_items totals are not converted. Refunds are issued in GEL. Show your customer the GEL amount — that is what their bank statement will say. Without this flag an unsupported currency is sent to the gateway as-is and will likely be rejected.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} → Try in Playground
Returns the full payment object.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Payment UUID. |
// 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": "[email protected]",
"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). |
pending · paid · failed · refunded · partially_refunded · cancelled · expired · transfer_pending · cod_pending · deposit_paid.List payments
GET /v1/payments → Try in Playground
Returns a paginated list (20 per page) sorted newest first.
Query parameters
| Parameter | Type | 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). |
// 200 OK
{
"data": [ { "...payment object..." } ],
"meta": { "current_page": 1, "last_page": 5, "total": 92 }
}Refund a payment
POST /v1/payments/{uuid}/refund → Try in Playground
Initiates a full or partial refund. Omit amount to refund the full remaining amount. Returns the updated payment object.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Payment UUID. |
Body parameters
| Parameter | Type | 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). |
{ "amount": 50.00, "reason": "Customer requested partial refund" }Returns 200 OK.
refunds array and refunded_amount.List products
GET /v1/products → Try in Playground
Returns a paginated list (20 per page) sorted newest first.
Query parameters
| Parameter | Type | Description | |
|---|---|---|---|
| active | boolean | optional | true or false — filter by active status. |
| page | integer | optional | Page number (default 1). |
// 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 → Try in Playground
At least one of name_en or name_ka is required.
Body parameters
| Parameter | Type | 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.
Get a product
GET /v1/products/{uuid} → Try in Playground
Returns the full product object.
Path parameters
| Parameter | Type | 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} → Try in Playground
Partial update — include only the fields you want to change. Same field rules as create. Returns the full product object.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Product UUID. |
Returns 200 OK.
Delete a product
DELETE /v1/products/{uuid} → Try in Playground
Deletes the product.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Product UUID. |
// 200 OK
{ "deleted": true }Create a checkout link
POST /v1/checkout-links → Try in Playground
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 | 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). |
| shipping | object | optional | Delivery options for this link, using your brand's shipping packages: { "mode": "packages" | "free" | "none", "package_ids": [..], "quantity": 1 }. Same shape as shipping on Create a payment. Omit to inherit the checkout template / brand default (links with a product use the product's own shipping unless this is set). |
{
"amount": 50.00,
"currency_override": "GEL",
"expires_at": "2026-12-31T23:59:59Z",
"max_uses": 100
}// 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.
<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". |
script-src https://qpy.ge.List invoices
GET /v1/invoices → Try in Playground
Returns a paginated list (20 per page) sorted newest first.
Query parameters
| Parameter | Type | 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 → Try in Playground
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 | 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 | 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). |
{
"customer_name": "Nino Kvaratskhelia",
"customer_email": "[email protected]",
"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 }
]
}// 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} → Try in Playground
Returns the full invoice object including payment_url, sent_at, viewed_at, and paid_at timestamps.
Path parameters
| Parameter | Type | 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 → Try in Playground
Emails the invoice to the customer and transitions status to sent. Cannot be called when status is paid or cancelled.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Invoice UUID. |
// 200 OK
{
"status": "sent",
"sent_at": "2026-06-01T10:05:00+00:00",
"email_delivered": true
}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 → Try in Playground
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.
// 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 → Try in Playground
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.
// 200 OK
{
"templates": [
{
"id": 1,
"uuid": "018f...",
"name": "Default",
"is_default": true,
"gateway_count": 3,
"fields_count": 2
}
]
}Exchange rates
GET /v1/exchange-rates → Try in Playground
Public endpoint — no authentication required. Converts an amount between two currencies using live rates.
Query parameters
| Parameter | Type | 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. |
// 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.
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.
- Merchant passes an installment or BNPL
gateway_sluginPOST /v1/payments. - Customer lands on the hosted payment page — Quickpay fetches available plans live and displays them.
- Customer selects a plan and completes checkout at the bank.
- Merchant receives the full payment amount upfront.
- Webhook fires
payment.paid— identical to a card payment.
{
"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} → Try in Playground
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 | Description | |
|---|---|---|---|
| gateway_slug | string | required | One of bog_installment, bog_bnpl, tbc_installment, credo_installment, flitt_installment. |
Query parameters
| Parameter | Type | Description | |
|---|---|---|---|
| amount | number | required | Order amount as a decimal (e.g. 149.99). Min 0.01. |
// 200 OK
{
"supports_installments": true,
"plans": [
{ "months": 3, "monthly_payment": 50.33, "total": 150.99, "discount_code": "STANDARD", "label": "3 თვე — 50.33 ₾/თვე" }
]
}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." }.{ "supports_installments": false, "plans": null }.(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 → Try in Playground
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 | 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). |
// 202 Accepted
{
"uuid": "018f...",
"status": "pending",
"amount": 29.99,
"currency": "GEL",
"subscription_token": "a1b2c3d4-...",
"message": "Charge initiated. Final result will be delivered via webhook."
}429 charge_too_frequent with a retry_after timestamp.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.
{
"gateway_slug": "tbc_card_subscriptions",
"tokenize_only": true,
"customer_email": "[email protected]"
}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.
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 CustomerSubscriptions 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 → Try in Playground
Lists your brand's recurring subscriptions, newest first.
Query parameters
| Parameter | Type | 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. |
// 200 OK
{
"data": [
{
"uuid": "018f...",
"plan_uuid": "018f...",
"customer_id": 42,
"customer_name": "Giorgi Beridze",
"customer_email": "[email protected]",
"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 → Try in Playground
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 | 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. |
// 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"
}subscription.created webhook.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} → Try in Playground
Returns a single subscription by UUID.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Returns 200 OK.
List subscription events
GET /v1/subscriptions/{uuid}/events → Try in Playground
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 | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
// 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 → Try in Playground
Cancels a subscription, immediately by default.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Body parameters
| Parameter | Type | 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.
422 subscription_already_cancelled for a subscription that is already cancelled or pending_cancellation.Reactivate a subscription
POST /v1/subscriptions/{uuid}/reactivate → Try in Playground
Undoes a cancellation — moves a cancelled, past_due, or pending_cancellation subscription back to active.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Returns 200 OK.
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 → Try in Playground
Pauses an active subscription — no further charges happen while paused.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Body parameters
| Parameter | Type | 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.
subscription.paused webhook.active subscription can be paused — returns 422 subscription_inactive otherwise.Resume a subscription
POST /v1/subscriptions/{uuid}/resume → Try in Playground
Resumes a paused subscription back to active.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Returns 200 OK.
subscription.resumed webhook.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 → Try in Playground
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 | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
// 200 OK
{
"update_url": "https://qpy.ge/update-card/eyJpdiI6...",
"expires_at": "2026-09-18T10:20:00Z"
}subscription.payment_method_updated webhook once the customer completes the flow.*_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 → Try in Playground
Computes what Change plan would charge or credit, without changing anything — build an upgrade/downgrade confirmation screen against this.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Body parameters
| Parameter | Type | 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. |
// 200 OK
{
"mode": "immediate",
"amount": 10.0,
"currency": "GEL",
"from_plan_uuid": "018f...",
"to_plan_uuid": "018f...",
"remaining_days": 15,
"total_days": 30
}422 plan_currency_mismatch otherwise.Change plan
POST /v1/subscriptions/{uuid}/change-plan → Try in Playground
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 | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Body parameters
| Parameter | Type | 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. |
// 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"
}
}subscription.plan_changed webhook.immediate amount) is never auto-refunded — it is recorded for audit only.Get a hosted portal link
POST /v1/subscriptions/{uuid}/portal-session → Try in Playground
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 | Description | |
|---|---|---|---|
| uuid | string | required | Subscription UUID. |
Body parameters
| Parameter | Type | 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. |
// 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.
- 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. - 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. - 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. - 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).
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"
}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.
- On your customer's account/orders page, call
GET /v1/subscriptions?customer_email=...(orcustomer_id) to list what they have. Each row includesstatus,amount,current_period_end, and the plan. - For the detail view,
GET /v1/subscriptions/{uuid}for the current state andGET /v1/subscriptions/{uuid}/eventsfor 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. - "Cancel" →
POST /v1/subscriptions/{uuid}/cancel. Pass"at_period_end": truefor a "cancel at the end of the current billing period" option (access continues, no further charge) instead of immediate cancellation. - "Pause" / "Resume" →
POST /v1/subscriptions/{uuid}/pause(optionally withresumes_atfor a scheduled auto-resume) andPOST /v1/subscriptions/{uuid}/resume. - "Change plan" →
POST /v1/subscriptions/{uuid}/preview-plan-changeto show the customer the exact prorated amount before they confirm, thenPOST /v1/subscriptions/{uuid}/change-planto apply it. - "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.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 → Try in Playground
Lists your brand's subscription plans, newest first.
Query parameters
| Parameter | Type | 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 → Try in Playground
Creates a versioned billing plan that subscriptions can be created or moved onto.
Body parameters
| Parameter | Type | 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. |
// 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} → Try in Playground
Returns a single subscription plan by UUID.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| uuid | string | required | Plan UUID. |
Returns 200 OK.
Edit a subscription plan
PATCH /v1/subscription-plans/{uuid} → Try in Playground
"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 | Description | |
|---|---|---|---|
| uuid | string | required | Plan UUID. |
Body parameters
| Parameter | Type | 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.
422 subscription_plan_archived for an already-archived plan — create a new plan instead.Archive a subscription plan
DELETE /v1/subscription-plans/{uuid} → Try in Playground
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 | Description | |
|---|---|---|---|
| uuid | string | required | Plan UUID. |
Returns 200 OK.
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 → Try in Playground
Lists the saved, reusable payment methods on file for one customer.
Path parameters
| Parameter | Type | Description | |
|---|---|---|---|
| id | integer | required | Customer ID. |
Query parameters
| Parameter | Type | Description | |
|---|---|---|---|
| status | string | optional | One of active, expired, failed, removed. Defaults to everything except removed. |
// 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 → Try in Playground
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 | 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 | 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. |
// 201 Created
{
"uuid": "018f...",
"payment_url": "https://qpy.ge/pay/018f...",
"status": "pending"
}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} → Try in Playground
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 | Description | |
|---|---|---|---|
| uuid | string | required | Payment method UUID. |
Returns 200 OK.
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 → Try in Playground
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 | 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.
{
"event": "payment.paid",
"uuid": "018f2a3b-...",
"merchant_order_id": "ORD-1234",
"status": "paid",
"amount": 149.99,
"currency": "GEL",
"customer_name": "Giorgi Beridze",
"customer_email": "[email protected]",
"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...[$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");
}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');
}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', 400Error 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.
{ "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 |