Готовий приклад для сайту програмного РРО: один імпорт SDK, ключ касира зберігається один раз на зміну, і далі кожен чек підписується КЕП без жодної взаємодії — модалка не відкривається, iframe лишається невидимим. Уся криптографія відбувається у фоновому iframe нашого origin, а ваш код і сервер ключа й пароля не бачать.
Це прикладний сценарій автоматичного режиму: «тиха сесія»
(ttlMinutes) перетворює віджет підпису на невидимий фоновий підписувач.
Як це працює — три кроки
| Коли | Крок | Що бачить касир |
|---|---|---|
| Раз на робоче місце | Зберегти ключ | обирає файл ключа, вводить пароль і задає PIN; зашифрований контейнер лишається у сховищі віджета до 30 днів |
| Раз на зміну | Розблокувати PIN-ом | перший чек зміни питає лише PIN; далі діє тиха сесія (у прикладі — 480 хв) |
| Кожен чек | Тихий підпис | signer.sign(xml) повертає CMS одразу — без модалок, паролів і PIN |
1. Підключення віджета
Один імпорт з origin DSTUcrypt — нічого не хоститься у вас, адресу віджета SDK
будує сам. Опція session вмикає «запам'ятати ключ»:
import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';
// Один віджет на сторінку. Ключ+пароль зберігаються під PIN касира;
// після першого розблокування — 8 годин тихого підпису в цій вкладці.
const signer = await embed('sign', {
session: { ttlMinutes: 480 },
});
Поки сесія розблокована, sign() виконується тихо: модальне вікно не
відкривається, iframe не показується — для касира це схоже на звичайний виклик
функції.
2. Одноразове збереження ключа
Окрема кнопка «Зберегти ключ касира» — потрібна один раз на робочому місці (і після зміни ключа):
setupButton.addEventListener('click', async () => {
await signer.session.setup();
// далі: signer.session.status() / lock() / forget() — за потреби
});
3. Підпис чека
XML чека підписується у формат attached CAdES; поле accredited підтверджує, що
це саме КЕП (сертифікат від акредитованого надавача):
const { signature, accredited } = await signer.sign(checkXml, {
format: 'CAdES-BES',
fileName: `check-${checkNumber}.xml`,
});
if (accredited === false) {
// підпис дійсний, але сертифікат не від акредитованого надавача
}
// signature.base64 — готовий CMS (XML чека + КЕП всередині).
// '/api/fiscal/checks' — НЕ готовий сервіс, а приклад ендпойнта ВАШОГО
// бекенда: віджет робить лише криптографію, доставку чека далі реалізуєте ви.
await fetch('/api/fiscal/checks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ p7s: signature.base64 }),
});
Куди чек іде далі. Підписаний CMS ваш бекенд передає на фіскальний сервер ДПС (ФСКО,
fs.tax.gov.ua) — саме він присвоює чеку фіскальний номер. Прямо з браузера цього не зробити (немає CORS, свій протокол обміну), тому між касою і ДПС завжди стоїть ваш сервер: він зберігає чеки, обробляє офлайн-режим ПРРО і повертає касі квитанцію з фіскальним номером для друку.
Обробка помилок
Два коди віджет обробляє сам (показує власну модалку) — їх треба лише проковтнути; решту показуйте касиру:
catch (e) {
if (e.code === 'not_licensed' || e.code === 'load_failed') return;
showMessage('Не вдалося підписати чек: ' + e.message);
}
Варіанти сесії
| Опція | Поведінка | Коли доречно |
|---|---|---|
session: true |
ключ збережено під PIN; PIN питається на кожен підпис | максимальний контроль, нечасті операції |
session: { ttlMinutes: 480 } |
PIN один раз — далі тихий підпис до кінця зміни (у межах вкладки) | каса ПРРО — рекомендований режим |
session: { mode: 'password' } |
зберігається лише файл ключа; пароль питається щоразу й не зберігається | спільні робочі місця |
session: { noPin: true } |
збереження без PIN — підпис ніколи нічого не питає | лише довірені кіоски/термінали: захист — тільки обфускація |
Збережений ключ прив'язаний до домену вашого сайту й лежить у сховищі origin
dstucrypt.io— інші сайти його не дістануть, а ваш код ключа й пароля не бачить ніколи (вони живуть в iframe іншого origin).
Тиха сесія живе у
sessionStorageвкладки: нова вкладка після закриття старої знову спитає PIN один раз. На новому домені віджет має 7 днів безкоштовного тріалу, далі — підписка (4 500 грн/міс за домен).
Повний робочий файл
Той самий приклад цілком — можна відкрити на будь-якому сайті як є (у
репозиторії: examples/prro-widget-example.html):
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8" />
<title>ПРРО: підпис чеків через DSTUcrypt</title>
</head>
<body>
<h1>Каса ПРРО — підпис чека КЕП</h1>
<button id="setup-key">Зберегти ключ касира</button>
<button id="sign-check">Підписати чек</button>
<p id="status"></p>
<script type="module">
import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';
const status = document.getElementById('status');
// Сесія: PIN один раз, далі 8 годин тихого підпису
const signer = await embed('sign', {
session: { ttlMinutes: 480 },
});
// Одноразове налаштування робочого місця
document.getElementById('setup-key').addEventListener('click', async () => {
try {
await signer.session.setup();
status.textContent = 'Ключ збережено. Можна підписувати чеки.';
} catch (e) { showError(e); }
});
// Ваш XML чека — тут спрощений для прикладу
let checkNumber = 0;
function buildCheckXml() {
checkNumber += 1;
const now = new Date();
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<CHECK>',
' <CHECKHEAD>',
' <DOCTYPE>SaleGoods</DOCTYPE>',
` <ORDERNUM>${checkNumber}</ORDERNUM>`,
` <ORDERDATE>${now.toISOString().slice(0, 10).replaceAll('-', '')}</ORDERDATE>`,
` <ORDERTIME>${now.toTimeString().slice(0, 8).replaceAll(':', '')}</ORDERTIME>`,
' </CHECKHEAD>',
' <CHECKTOTAL><SUM>123.45</SUM></CHECKTOTAL>',
'</CHECK>',
].join('\n');
}
document.getElementById('sign-check').addEventListener('click', async () => {
try {
const xml = buildCheckXml();
const { signature, accredited } = await signer.sign(xml, {
format: 'CAdES-BES',
fileName: `check-${checkNumber}.xml`,
});
status.textContent = accredited === false
? 'Увага: підпис дійсний, але не КЕП.'
: `Чек № ${checkNumber} підписано (${signature.size} байт).`;
// Відправка на ВАШ бекенд ('/api/fiscal/checks' — приклад назви,
// ендпойнт реалізуєте ви; далі бекенд передає CMS на фіскальний
// сервер ДПС і повертає касі фіскальний номер чека):
// await fetch('/api/fiscal/checks', {
// method: 'POST',
// headers: { 'content-type': 'application/json' },
// body: JSON.stringify({ p7s: signature.base64 }),
// });
} catch (e) { showError(e); }
});
function showError(e) {
// not_licensed / load_failed — модалку вже показав сам віджет
if (e?.code === 'not_licensed' || e?.code === 'load_failed') return;
status.textContent = e?.message ?? String(e);
}
</script>
</body>
</html>
Довідник інтеграції — Швидкий старт · Підпис · Автоматичний режим.