Flutter Quickstart

From zero to a certified receipt: onboard a merchant and register once, then submit sales from your app.

1. Onboard the merchant

Onboarding is a one-time server-side setup per merchant, done with your management API key (never from the app). Start with the taxpayer:

curl -X POST https://api.koolbase.com/v1/projects/$PROJECT_ID/fiscal/merchants \
  -H "Authorization: Bearer $KB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "legal_name": "Tilla Retail Ltd",
    "tax_id": "C0012345678"
  }'

2. Store the authority credential

Jurisdictions that certify invoices issue each taxpayer their own credential. The merchant gets theirs from the authority and you store it once — it's encrypted at rest and never readable again, not by you and not by us.

curl -X POST https://api.koolbase.com/v1/projects/$PROJECT_ID/fiscal/merchants/$MERCHANT_ID/credentials \
  -H "Authorization: Bearer $KB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "GRA production key",
    "credential_type": "gh-gra-v1",
    "payload": { "security_key": "..." }
  }'

Write-only by design

The response returns metadata only — id, name, status, version. The secret itself is never returned by any endpoint. A merchant who loses their key gets a new one from the authority, not from Koolbase. Rotating appends a new version; it never overwrites the evidence of what signed past submissions.

3. Activate a register

One device per till or branch. The counters start where the merchant's existing series is — if they're already at invoice 4,391, you start there. Koolbase never invents a starting point or restarts someone's series.

curl -X POST https://api.koolbase.com/v1/projects/$PROJECT_ID/fiscal/merchants/$MERCHANT_ID/devices \
  -H "Authorization: Bearer $KB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "jurisdiction": "GH",
    "adapter_id": "gh-gra",
    "initial_counters": { "INV": 4391 },
    "credential_id": "'$CREDENTIAL_ID'",
    "authority_registration_id": "C0012345678-001"
  }'
# -> {"device_id": "..."}  — keep this; your app submits against it

authority_registration_id is the merchant's identity with the authority (in Ghana, their TIN-branch reference). adapter_id selects the jurisdiction: use kb-ref where no authority integration exists yet.

4. Submit a sale

From here it's your app. The payload is shaped by the device's jurisdiction — see the Ghana guide for the exact fields.

final result = await Koolbase.fiscal.submit(
  deviceId: registerId,
  clientRef: sale.id,        // YOUR id — makes retries safe
  payload: {
    'kind': 'INVOICE',
    'currency': 'GHS',
    'exchange_rate': '1.0',
    'user_name': cashier.name,
    'calculation_type': 'EXCLUSIVE',
    'total_amount': 4500,
    'total_vat': 675,
    'total_levy': 225,
    'total_excise_amount': 0,
    'transaction_date': '2026-01-01',
    'business_partner_name': 'Cash Customer',
    'business_partner_tin': 'C0000000000',
    'sale_type': 'NORMAL',
    'discount_type': 'GENERAL',
    'discount_amount': 0,
    'tax_type': 'STANDARD',
    'reference': '',
    'group_reference_id': '',
    'purchase_order_reference': '',
    'items': [
      {
        'item_code': 'ABT301',
        'item_category': '',
        'description': 'Wax print, 6 yards',
        'quantity': 45,
        'unit_price': 100,
        'levy_a': 112.50,   // NHIL 2.5%
        'levy_b': 112.50,   // GETFund 2.5%
        'levy_d': 0,        // CST
        'levy_e': 0,        // Tourism
        'discount_amount': 0,
        'excise_amount': 0,
        'expire_date': '',
        'batch_code': '',
      },
    ],
  },
);

5. Handle the three outcomes

if (result.isFiscalized) {
  printReceipt(sale, result.certification);          // done in one call
} else if (result.isPending) {
  await _pollThenPrint(sale, registerId);            // authority still answering
} else if (result.status == FiscalStatus.blocked) {
  // Nothing was consumed. Surface the reason; resubmitting the same
  // clientRef after the cause is fixed resumes this exact intent.
  showBlocked(result.blockedReason);
}

Future<void> _pollThenPrint(Sale sale, String registerId) async {
  var intent = await Koolbase.fiscal.status(
    deviceId: registerId, clientRef: sale.id);
  for (var i = 0; i < 15 && intent.isPending; i++) {
    await Future<void>.delayed(const Duration(seconds: 2));
    intent = await Koolbase.fiscal.status(
      deviceId: registerId, clientRef: sale.id);
  }
  if (intent.isFiscalized) printReceipt(sale, intent.certification);
}

If a submit throws

A timeout means the sale may have fiscalized — the answer was lost, not the work. Poll status() with the same clientRef, or simply submit again. Both converge on the same single intent. FiscalException carries the server's message and status code; KoolbaseUnauthenticatedException means the API key or session was rejected.

6. Render the receipt

The certification is exactly what the authority granted, in the authority's own vocabulary. In Ghana, a compliant receipt shows the invoice number, the signature, the receipt number and a scannable QR built from qr_code.

final cert = intent.certification;
if (cert == null) {
  // Valid for non-certifying document kinds (a PURCHASE record is
  // acknowledged, not certified). Never treat this as an error.
  return;
}
final invoiceNumber = cert['num'] as String?;         // CXX000000YY-001-00004391
final signature     = cert['ysdcregsig'] as String?;  // GRA invoice signature
final receiptNumber = cert['ysdcrecnum'] as String?;
final qrUrl         = cert['qr_code'] as String?;     // render as a QR image

Ghana receipt requirement

GRA requires the QR code to be clear and readable at a minimum of 2.5 cm × 2.5 cm on the printed receipt. Encode the qr_code URL exactly as returned — it carries the authority's verification payload.