Documentation · Capabilities

PRRO cash register — signing receipts via an invisible iframe

A ready-made example for a software cash register (PRRO) site: one SDK import, the cashier's key is saved once per shift, and after that every receipt is signed with a qualified electronic signature (QES) with no interaction at all — no modal opens, the iframe stays invisible. All the cryptography happens in a background iframe of our origin, and neither your code nor your server ever sees the key or the password.

This is an applied scenario of automatic mode: the “silent session” (ttlMinutes) turns the signing widget into an invisible background signer.

How it works — three steps

When Step What the cashier sees
Once per workstation Save the key picks the key file, enters the password and sets a PIN; the encrypted container stays in the widget's storage for up to 30 days
Once per shift Unlock with the PIN the first receipt of the shift asks for the PIN only; after that the silent session applies (480 minutes in the example)
Every receipt Silent signing signer.sign(xml) returns the CMS right away — no modals, passwords or PIN

1. Connecting the widget

One import from the DSTUcrypt origin — nothing is hosted on your side, the SDK builds the widget URL itself. The session option enables “remember the key”:

import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';

//  One widget per page. The key+password are stored under the cashier's PIN;
//  after the first unlock — 8 hours of silent signing in this tab.
const signer = await embed('sign', {
  session: { ttlMinutes: 480 },
});

While the session is unlocked, sign() runs silently: no modal window opens, the iframe is not shown — to the cashier it looks like an ordinary function call.

2. One-time key saving

A separate “Save cashier's key” button — needed once per workstation (and after a key change):

setupButton.addEventListener('click', async () => {
  await signer.session.setup();
  //  also available: signer.session.status() / lock() / forget() — as needed
});

3. Signing a receipt

The receipt XML is signed into attached CAdES format; the accredited field confirms that this is a genuine QES (a certificate from an accredited provider):

const { signature, accredited } = await signer.sign(checkXml, {
  format: 'CAdES-BES',
  fileName: `check-${checkNumber}.xml`,
});

if (accredited === false) {
  //  the signature is valid, but the certificate is not from an accredited provider
}

//  signature.base64 — the ready CMS (receipt XML + QES inside).
//  '/api/fiscal/checks' is NOT a ready-made service but an example endpoint of YOUR
//  backend: the widget does only the cryptography; you implement receipt delivery.
await fetch('/api/fiscal/checks', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ p7s: signature.base64 }),
});

Where the receipt goes next. Your backend forwards the signed CMS to the State Tax Service fiscal server (FSKO, fs.tax.gov.ua) — that is what assigns the receipt its fiscal number. This cannot be done straight from the browser (no CORS, its own exchange protocol), so your server always sits between the cash register and the State Tax Service: it stores receipts, handles the PRRO offline mode and returns to the register a confirmation with the fiscal number for printing.

Error handling

Two codes are handled by the widget itself (it shows its own modal) — just swallow them; show everything else to the cashier:

catch (e) {
  if (e.code === 'not_licensed' || e.code === 'load_failed') return;
  showMessage('Failed to sign the receipt: ' + e.message);
}

Session variants

Option Behavior When appropriate
session: true the key is saved under a PIN; the PIN is asked for on every signature maximum control, infrequent operations
session: { ttlMinutes: 480 } PIN once — then silent signing until the end of the shift (within the tab) PRRO cash register — the recommended mode
session: { mode: 'password' } only the key file is saved; the password is asked for every time and never stored shared workstations
session: { noPin: true } saving without a PIN — signing never asks for anything trusted kiosks/terminals only: the protection is mere obfuscation

The saved key is bound to your site's domain and sits in the storage of the dstucrypt.io origin — other sites cannot get at it, and your code never sees the key or the password (they live in an iframe of a different origin).

The silent session lives in the tab's sessionStorage: a new tab after the old one is closed will ask for the PIN once again. On a new domain the widget has a 7-day free trial, then a subscription (UAH 4,500/month per domain).

Complete working file

The same example in full — you can drop it onto any site as is (in the repository: examples/prro-widget-example.html):

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>PRRO: signing receipts with DSTUcrypt</title>
</head>
<body>
  <h1>PRRO cash register — signing a receipt with a QES</h1>
  <button id="setup-key">Save cashier's key</button>
  <button id="sign-check">Sign receipt</button>
  <p id="status"></p>

  <script type="module">
    import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';

    const status = document.getElementById('status');

    //  Session: PIN once, then 8 hours of silent signing
    const signer = await embed('sign', {
      session: { ttlMinutes: 480 },
    });

    //  One-time workstation setup
    document.getElementById('setup-key').addEventListener('click', async () => {
      try {
        await signer.session.setup();
        status.textContent = 'Key saved. You can now sign receipts.';
      } catch (e) { showError(e); }
    });

    //  Your receipt XML — simplified here for the example
    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
          ? 'Warning: the signature is valid but is not a QES.'
          : `Receipt No. ${checkNumber} signed (${signature.size} bytes).`;

        //  Sending to YOUR backend ('/api/fiscal/checks' is an example name,
        //  you implement the endpoint; the backend then forwards the CMS to the
        //  State Tax Service fiscal server and returns the receipt's fiscal number
        //  to the register):
        //  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 — the widget has already shown its own modal
      if (e?.code === 'not_licensed' || e?.code === 'load_failed') return;
      status.textContent = e?.message ?? String(e);
    }
  </script>
</body>
</html>

Integration reference — Quick start · Signing · Automatic mode.