{
  "openapi": "3.0.3",
  "info": {
    "title": "Платіжні посилання з QR НБУ",
    "version": "2.0.0",
    "description": "Створення сторінок оплати з QR-кодом НБУ (формат 003 або 002 — за налаштуванням у панелі) для переказу на IBAN.\n\nДва способи підключення:\n1. **API з ключем** (`/api/links`) — ви самі передаєте реквізити або `template_id` збереженого одержувача.\n2. **Приймач** (`/hook/{secret}`) — система надсилає дані замовлення в будь-якому вигляді, а відповідність полів і одержувач налаштовуються в панелі. Заголовки авторизації не потрібні: підключення впізнається за секретом в адресі.\n\nТексти очищуються до набору символів, дозволеного правилами НБУ (переноси рядків стають пробілами, емодзі відкидаються). Увесь платіж обмежений 356 байтами: зі звичайними налаштуваннями (формат 003, UTF-8, сума й термін дії) на назву одержувача й призначення разом лишається приблизно 125 кириличних літер, у кодуванні Windows-1251 — приблизно вдвічі більше. Якщо текст не вміщається, відповідь 400 називає, на скільки його скоротити."
  },
  "servers": [
    {
      "url": "https://sellerua.com"
    }
  ],
  "paths": {
    "/api/links": {
      "post": {
        "summary": "Створити посилання на оплату",
        "description": "Повторний запит з тим самим `external_id` і тими самими даними повертає те саме посилання (`existing: true`), поки воно не прострочене і до кінця його строку лишається не менше половини запитаного. Інакше видається нове з новим строком (`replaced` містить id старого, `renewed: true`). Якщо дані інші — теж створюється нове посилання (`replaced` містить id старого). Старе посилання в обох випадках веде на нове, крім одного: якщо старе вже прострочене, а дані інші, воно згасає (status expired) і на нове не веде. У відповіді `warnings` — перелік символів, прибраних з назви чи призначення (їх не можна передати в QR НБУ), а `ttl_ignored: true` означає, що повернуте посилання діє довше, ніж просили: коротший запитаний строк виданого посилання не скорочує.",
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LinkInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Створено",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Link"
                }
              }
            }
          },
          "200": {
            "description": "Таке посилання вже є",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Link"
                }
              }
            }
          },
          "400": {
            "description": "Помилки в даних",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errors"
                }
              }
            }
          },
          "401": {
            "description": "Немає або неправильний ключ API"
          },
          "413": {
            "description": "Тіло запиту завелике"
          }
        }
      },
      "get": {
        "summary": "Список посилань або пошук за external_id",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "external_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "id посилання, після якого (у порядку списку) читати далі"
          }
        ],
        "responses": {
          "200": {
            "description": "Посилання або список"
          },
          "404": {
            "description": "Не знайдено; прострочене посилання теж вважається не знайденим"
          },
          "400": {
            "description": "external_id порожній або неприпустимий; before вказує на невідоме чи видалене посилання"
          },
          "401": {
            "description": "Немає або неправильний ключ API"
          }
        },
        "description": "Без `external_id` — історія сторінками: `limit` (до 500, типово 100) і `before` — id останнього показаного посилання; у заголовках відповіді `X-Total-Count` (скільки всього) і `X-Next-Before` (курсор на наступну сторінку, якщо вона є). З `external_id` — чинне посилання цього замовлення; прострочене вважається не знайденим (404), і повторний POST видасть нове."
      }
    },
    "/api/links/{id}": {
      "get": {
        "summary": "Отримати посилання",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Посилання",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Link"
                }
              }
            }
          },
          "404": {
            "description": "Не знайдено"
          },
          "401": {
            "description": "Немає або неправильний ключ API"
          }
        }
      },
      "delete": {
        "summary": "Видалити посилання",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Видалено"
          },
          "404": {
            "description": "Не знайдено"
          },
          "401": {
            "description": "Немає або неправильний ключ API"
          }
        }
      }
    },
    "/api/public/links": {
      "post": {
        "summary": "Створити посилання без входу",
        "description": "Без авторизації: так створює посилання форма на сторінці /payment-links. Перевірка даних та сама, що й у `/api/links`, але строк дії — лише цілі дні від 1 до 30, а `external_id` і `template_id` не приймаються. Лише `Content-Type: application/json`; заголовків CORS немає, тож з браузера маршрут працює тільки зі сторінок цього ж сайту. Такі посилання лежать окремо: в історії, API й статистиці кабінету їх немає. Сторінка оплати попереджає, що посилання створено без реєстрації, і має кнопку скарги. Посилання зберігається до кінця строку дії і ще 30 днів, потім видаляється. Межі: 30 створених посилань на годину і 100 на добу з однієї адреси (для IPv6 — з підмережі /64), 3000 на добу на весь сервіс; рахуються лише створені посилання.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PublicLinkInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Створено",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicLink"
                }
              }
            }
          },
          "400": {
            "description": "Помилки в даних",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errors"
                }
              }
            }
          },
          "403": {
            "description": "Цей рахунок заблоковано через скарги"
          },
          "413": {
            "description": "Тіло запиту завелике"
          },
          "415": {
            "description": "Потрібен Content-Type: application/json"
          },
          "429": {
            "description": "Забагато посилань або запитів; заголовок Retry-After — через скільки секунд повторити"
          }
        }
      }
    },
    "/api/templates": {
      "get": {
        "summary": "Збережені одержувачі (для template_id)",
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Список одержувачів"
          },
          "401": {
            "description": "Немає або неправильний ключ API"
          }
        }
      }
    },
    "/hook/{secret}": {
      "post": {
        "summary": "Приймач: створити посилання з даних замовлення (POST або PUT)",
        "description": "Тіло — JSON або форма (`application/x-www-form-urlencoded`) з довільними полями замовлення; які з них номер, сума й ознака одержувача — налаштовується в панелі. Одержувач береться лише зі збережених у панелі; реквізити з запиту не читаються. Параметри з адреси (крім `tag` і `strict`) додаються до полів тіла; JSON-масив у тілі доступний як поле `items`. Приймається й PUT (так само, як POST).\n\nВідповідь завжди містить ключі `ok`, `url`, `qr_png_url`, `qr_svg_url`, `page_qr_png_url`, `id`, `expires_at`, `error`, `code`; успішна додає `amount`, `action`, `replaced` і `notes`, а помилка з налаштованого підключення — `listening`. Без `strict=1` код відповіді 200 для всього, крім 404 (невідома адреса), 405 (інший метод) і 429 для забагатьох звернень на невідомі адреси з однієї адреси; завелике тіло, перевищення частоти підключення й збій сервера теж приходять як 200 з кодом у `code` і порожнім `url`. Зі `strict=1` помилки отримують коди 413, 422, 429, 500.",
        "parameters": [
          {
            "name": "secret",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Секрет підключення з панелі"
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Мітка для окремого посилання на те саме замовлення, наприклад prepay. Лише латинські літери, цифри, - і _, до 20 символів"
          },
          {
            "name": "strict",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Повертати помилки з кодом 4xx (422, а для завеликого запиту й перевищення частоти — 413 і 429)"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            },
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Результат",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HookResult"
                }
              }
            }
          },
          "422": {
            "description": "Помилка (лише зі strict=1) (лише зі strict=1; без нього — 200 з кодом у code)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HookResult"
                }
              }
            }
          },
          "404": {
            "description": "Невідома адреса приймача"
          },
          "405": {
            "description": "Метод не POST і не PUT"
          },
          "413": {
            "description": "Тіло запиту завелике (понад 1 МБ; у кабінеті користувача — понад 100 КБ) (лише зі strict=1; без нього — 200 з кодом у code)"
          },
          "429": {
            "description": "Забагато запитів до цього підключення або невідомих адрес з однієї адреси"
          },
          "500": {
            "description": "Збій сервера; ті самі ключі, code = server_error (лише зі strict=1; без нього — 200 з кодом у code)"
          }
        }
      },
      "put": {
        "summary": "Те саме, що POST",
        "description": "Приймач однаково обробляє POST і PUT.",
        "parameters": [
          {
            "name": "secret",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Секрет підключення з панелі"
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Мітка для окремого посилання на те саме замовлення, наприклад prepay. Лише латинські літери, цифри, - і _, до 20 символів"
          },
          {
            "name": "strict",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Повертати помилки з кодом 4xx (422, а для завеликого запиту й перевищення частоти — 413 і 429)"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            },
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Результат",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HookResult"
                }
              }
            }
          },
          "422": {
            "description": "Помилка (лише зі strict=1) (лише зі strict=1; без нього — 200 з кодом у code)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HookResult"
                }
              }
            }
          },
          "404": {
            "description": "Невідома адреса приймача"
          },
          "405": {
            "description": "Метод не POST і не PUT"
          },
          "413": {
            "description": "Тіло запиту завелике (понад 1 МБ; у кабінеті користувача — понад 100 КБ) (лише зі strict=1; без нього — 200 з кодом у code)"
          },
          "429": {
            "description": "Забагато запитів до цього підключення або невідомих адрес з однієї адреси"
          },
          "500": {
            "description": "Збій сервера; ті самі ключі, code = server_error (лише зі strict=1; без нього — 200 з кодом у code)"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Authorization: Bearer <ключ>. Ключ кабінету (plk_…) створюється у вкладці «API» панелі після входу через Google: показується один раз, діє лише для свого кабінету, його можна перевипустити й видалити. Для простору власника сервісу ключ — змінна API_KEY. У кабінеті користувача діють межі: 300 створених посилань на годину і 2000 на добу (понад межу — 429 з Retry-After); з рахунком, заблокованим через скарги, посилання не створюється (403)."
      }
    },
    "schemas": {
      "LinkInput": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "Одержувач, до 140 символів"
          },
          "iban": {
            "type": "string",
            "description": "IBAN одержувача (UA + 27 цифр)"
          },
          "code": {
            "type": "string",
            "description": "ЄДРПОУ (8 цифр) або РНОКПП (10 цифр)"
          },
          "template_id": {
            "type": "integer",
            "description": "Замість name, iban і code — збережений одержувач"
          },
          "amount": {
            "type": "number",
            "description": "Сума в гривнях; без неї платник введе суму сам"
          },
          "purpose": {
            "type": "string",
            "description": "Призначення платежу; з template_id можна не передавати, якщо в збереженого одержувача воно є. Не може починатися зі знака «?»"
          },
          "external_id": {
            "type": "string",
            "description": "Номер замовлення у вашій системі: текст або ціле число до 100 символів; різні номери ніколи не зливаються в один"
          },
          "ttl_days": {
            "type": "number",
            "description": "Термін дії посилання в днях. Якщо не вказати — 5 днів; 0 — без обмеження; інакше ціле число від 1 до 3650. Після закінчення сторінка й QR не працюють, а повторний запит з тим самим external_id видає нове посилання; щонайменше 5 хвилин (0,0035 дня). Повторний запит з тими самими даними повертає те саме посилання, поки до кінця його строку лишається не менше половини запитаного; коротший запитаний строк виданого посилання не скорочує (`ttl_ignored: true`)"
          }
        }
      },
      "Link": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "description": "Сторінка оплати для клієнта"
          },
          "qr_png_url": {
            "type": "string",
            "description": "QR НБУ для сканера в застосунку банку; камера телефона за ним відкриває банк без вибору"
          },
          "qr_svg_url": {
            "type": "string"
          },
          "page_qr_png_url": {
            "type": "string",
            "description": "QR для камери телефона: відкриває сторінку оплати, де платник обирає банк. Саме його варто показувати клієнтові"
          },
          "page_qr_svg_url": {
            "type": "string"
          },
          "nbu_url": {
            "type": "string",
            "nullable": true,
            "description": "Спільне посилання НБУ, закодоване в QR; null для заміненого чи простроченого посилання (реквізити описують старий рахунок, а url і qr_* ведуть на актуальний)"
          },
          "amount": {
            "type": "number",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "replaced",
              "expired"
            ]
          },
          "expires_at": {
            "type": "string",
            "nullable": true,
            "description": "До якого моменту посилання дійсне (ISO 8601); null — без обмеження"
          },
          "expired": {
            "type": "boolean"
          },
          "existing": {
            "type": "boolean"
          },
          "replaced": {
            "type": "string",
            "nullable": true,
            "description": "id посилання, яке замінено цим"
          },
          "renewed": {
            "type": "boolean",
            "description": "true, якщо дані ті самі, а нове посилання видано лише через термін дії попереднього"
          },
          "created_at": {
            "type": "string",
            "description": "Коли створено (ISO 8601, UTC)"
          },
          "ttl_ignored": {
            "type": "boolean",
            "description": "true, якщо повернуте вже видане посилання діє довше, ніж просили"
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Символи, прибрані з назви чи призначення, бо їх не можна передати в QR НБУ"
          },
          "name": {
            "type": "string",
            "nullable": true,
            "description": "Одержувач"
          },
          "iban": {
            "type": "string",
            "nullable": true,
            "description": "IBAN одержувача"
          },
          "code": {
            "type": "string",
            "nullable": true,
            "description": "Код одержувача"
          },
          "purpose": {
            "type": "string",
            "nullable": true,
            "description": "Призначення платежу"
          },
          "external_id": {
            "type": "string",
            "nullable": true,
            "description": "Номер замовлення (API)"
          },
          "order_ref": {
            "type": "string",
            "nullable": true,
            "description": "Номер замовлення (приймач)"
          },
          "tag": {
            "type": "string",
            "nullable": true,
            "description": "Мітка приймача (?tag=)"
          },
          "replaced_by": {
            "type": "string",
            "nullable": true,
            "description": "id посилання, яке замінило це"
          },
          "nbu_url_v1": {
            "type": "string",
            "nullable": true,
            "deprecated": true,
            "description": "Те саме, що nbu_url; застаріле, буде прибране"
          }
        }
      },
      "HookResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "url": {
            "type": "string",
            "description": "Сторінка оплати; порожньо, якщо помилка"
          },
          "qr_png_url": {
            "type": "string",
            "description": "QR НБУ для сканера в застосунку банку"
          },
          "qr_svg_url": {
            "type": "string"
          },
          "page_qr_png_url": {
            "type": "string",
            "description": "QR для камери телефона: відкриває сторінку оплати з вибором банку"
          },
          "id": {
            "type": "string"
          },
          "expires_at": {
            "type": "string",
            "description": "До якого моменту посилання дійсне (ISO 8601); порожньо — без обмеження"
          },
          "error": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "description": "Код помилки: not_configured, no_order, bad_order, no_amount, bad_amount, recipient_missing, recipient_unmatched, recipient_template_gone, no_purpose, too_long, bad_mapping, bad_request, too_large, bad_tag, disabled, method, unknown_hook, rate_limited, too_many_links, blocked_iban, server_error; порожньо, якщо помилки немає"
          },
          "action": {
            "type": "string",
            "enum": [
              "created",
              "existing",
              "replaced"
            ]
          },
          "amount": {
            "type": "number",
            "nullable": true,
            "description": "Лише в успішній відповіді"
          },
          "replaced": {
            "type": "string",
            "nullable": true,
            "description": "id посилання, яке замінено цим; лише в успішній відповіді"
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Пояснення, наприклад що призначення скорочено; лише в успішній відповіді"
          },
          "listening": {
            "type": "boolean",
            "description": "Лише при помилці: чи ввімкнено прослуховування (запит міг зберегтися як зразок)"
          }
        }
      },
      "Errors": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "PublicLinkInput": {
        "type": "object",
        "required": [
          "name",
          "iban",
          "code",
          "purpose"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Одержувач, до 140 символів"
          },
          "iban": {
            "type": "string",
            "description": "IBAN одержувача (UA + 27 цифр)"
          },
          "code": {
            "type": "string",
            "description": "ЄДРПОУ (8 цифр) або РНОКПП (10 цифр)"
          },
          "amount": {
            "type": "number",
            "description": "Сума в гривнях; без неї платник введе суму сам"
          },
          "purpose": {
            "type": "string",
            "description": "Призначення платежу. Не може починатися зі знака «?»"
          },
          "ttl_days": {
            "type": "integer",
            "minimum": 1,
            "maximum": 30,
            "description": "Термін дії посилання в днях: ціле число від 1 до 30. Якщо не вказати — 5 днів. Без обмеження строку тут немає"
          }
        }
      },
      "PublicLink": {
        "type": "object",
        "required": [
          "id",
          "url",
          "expires_at",
          "warnings"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "description": "Сторінка оплати; картинки QR — за адресами `{url}/qr.svg` і `{url}/qr.png`"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "До якого моменту посилання діє"
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Символи, прибрані з назви чи призначення: їх не можна передати в QR НБУ"
          }
        }
      }
    }
  }
}
