The simple rule
Confirm one credit charge, then do one job. Checking a feature does not spend a credit. A credit is a usage unit, not a euro payment or subscription invoice.
This example writes a real CSV and records its job in a private SQLite file. It uses the real SDK and a real metered Sandbox license. It does not mock billing, collect bank cards or sell credit packs.
1. Prepare a credit license
- Your KDPika platform plan must include usage credits. Check Billing & usage and your current plan capabilities.
- Create a separate tutorial software plan with Uses credits enabled. Start without floating seats to learn one model at a time.
- Issue its Sandbox license to an email you control.
- Open that license → Credits and grant a small whole-number balance, such as 3 units. The application consumes units; it does not carry a developer management key for granting them.
The operation itself is credit-gated in this example; it does not additionally require csv_export. If your real product combines feature flags and credits, design and test both the first authorization and recovery of already-paid jobs. Do not add a feature check after the final successful charge and accidentally reject the paid operation because the balance reached zero.
Define access and entitlements
SandboxNew metered licenses start with zero credits. Issue a new Sandbox license for this plan, then add units on that license.
Manage usage credits
SandboxUsage units, not money. This does not charge a card or refund a payment.
2. Run one job
Use the extracted Python projects, the same SDK installation and App ID/Sandbox environment setup from the desktop or web guide.
python credit_export.py --job export-order-001 --text "My paid export"
On first activation, enter the credit license key privately and its matching email. After a verified successful consume, the app prints the saved CSV path. Open Credits in KDPika: one unit was consumed.
3. Repeat the identical command
Run that exact command again, with the same job name, message, license and private state folder. The completed job returns its saved output without consuming another unit.
A genuinely new export uses a new logical job name. A retry of an uncertain export uses its original name. Do not generate a fresh UUID every time the retry button is clicked.
| Situation | Safe response |
|---|---|
| Signed charge succeeds | Do that one job, even if the resulting balance is zero |
| Not enough credits | Do not do the job. Ask the publisher to add units, then retry the same job |
| Connection drops before a reply | The outcome is uncertain; retain the pending job and retry its original key |
| Response is replayed | It identifies the original charge; do not perform a second independent job |
| Job name reused with different data/license | Refuse and ask the user to resume the original job or create a genuinely new one |
4. Understand the code
client.consume(1, idempotency_key=...) verifies the signed charge response. The server prevents a repeated request key from charging twice. SQLite retains the job before that request, and a deterministic output path/atomic replacement makes this CSV work repeatable after a crash.
The job journal is local application data, not proof of a valid license. Every pending run checks the online license and requires a verified consume receipt. A zero balance is allowed only for recovery of a receipt that already paid for that same operation; it is not permission for another job.
"""One credit per CSV job. SQLite retains each request key across retries/restarts."""
import argparse
import csv
import getpass
import hashlib
import os
import re
import sqlite3
import tempfile
from pathlib import Path
from common import AccessDenied, application_folder, make_client
from kdpika_licensing import CreditOperationError, InsufficientCreditsError
def run_job(client, job_id: str, text: str, folder: Path) -> Path:
if not re.fullmatch(r"[A-Za-z0-9_-]{8,80}", job_id) or not text or len(text) > 1000:
raise ValueError("Use an 8–80 character job name and a message of 1–1000 characters.")
# A zero balance may be the result of an already-charged pending job. It must
# still be recoverable with that job's original key, never a new charge key.
result = client.check(force_online=True)
if not result.signature_valid or (not result.valid and result.error_code != "INSUFFICIENT_CREDITS"):
raise AccessDenied("Reconnect and check this license before exporting.")
if not result.payload.get("metered"):
raise AccessDenied("Choose a usage-credit license for this example.")
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
name = hashlib.sha256(job_id.encode()).hexdigest()
target = folder / (name + ".csv")
db = sqlite3.connect(folder / "jobs.sqlite3", timeout=30)
db.execute("CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, license_id TEXT NOT NULL, text TEXT NOT NULL, state TEXT NOT NULL)")
temporary = None
try:
db.execute("BEGIN IMMEDIATE") # One local worker owns this job until its result is saved.
row = db.execute("SELECT license_id, text, state FROM jobs WHERE id=?", (job_id,)).fetchone()
if row and (row[0] != result.license_id or row[1] != text):
raise AccessDenied("Keep this job's original license and message when retrying.")
if row and row[2] == "DONE" and target.is_file():
db.commit()
return target # Reopening a completed output does not charge or execute a new job.
if row is None:
db.execute("INSERT INTO jobs VALUES (?, ?, ?, 'PENDING')", (job_id, result.license_id, text))
db.commit() # The key must exist durably BEFORE the first network request.
db.execute("BEGIN IMMEDIATE")
# Another process may have finished after the durable insert.
row = db.execute("SELECT state FROM jobs WHERE id=?", (job_id,)).fetchone()
if row and row[0] == "DONE" and target.is_file():
db.commit()
return target
client.consume(1, idempotency_key="python_export:" + job_id, reason="One CSV export")
# Reaching here means a signed receipt authorized THIS job, even if it left zero credits.
# Do not ask result.feature() again after the final paid credit was consumed.
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=folder,
prefix="export-", suffix=".tmp", delete=False) as output:
temporary = Path(output.name)
writer = csv.writer(output)
writer.writerow(["Message"])
writer.writerow(["'" + text if text.lstrip().startswith(("=", "+", "-", "@")) else text])
output.flush()
os.fsync(output.fileno())
os.replace(temporary, target) # Deterministic output; retry cannot create a second CSV job.
db.execute("UPDATE jobs SET state='DONE' WHERE id=?", (job_id,))
db.commit()
return target
except Exception:
db.rollback() # The earlier committed PENDING row survives an uncertain charge reply.
raise
finally:
db.close()
if temporary is not None:
temporary.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description="Export one CSV for one usage credit.")
parser.add_argument("--job", required=True, help="Persist and reuse this logical job name on retry.")
parser.add_argument("--text", required=True)
args = parser.parse_args()
client = make_client()
try:
if not client.activated:
result = client.activate(getpass.getpass("License key: "),
input("Matching customer email: ").strip() or None)
if not result.valid or not result.signature_valid:
raise AccessDenied("This license could not be activated. Check it in KDPika.")
print("Saved:", run_job(client, args.job, args.text, application_folder() / "credit-jobs"))
print("Run the identical job and message again to reuse its output without another charge.")
except InsufficientCreditsError:
raise SystemExit("Not enough credits. Ask the publisher to add credits, then retry this same job.")
except (CreditOperationError, AccessDenied, ValueError):
raise SystemExit("The job was not completed. Check access or reconnect; retry the same job and message.")
except Exception:
raise SystemExit("The result is uncertain. Do not choose a new job name; retry the same job and message.")
finally:
client.close()
if __name__ == "__main__":
main()
5. Use it in a web application
Import run_job into a backend worker. First verify your own user session, authorization and CSRF; associate the job with that authorized user/tenant in your real job table. Then call run_job(client, stable_job_id, text, private_folder) with server-controlled values. Never treat a user-supplied arbitrary job ID/license as permission to read another user's saved output.
The downloadable credit script is a single-customer local example. It does not add a public credit-consume route or a multi-tenant job system. Adapt it to the existing server-side account/job model, not browser code. SQLite serializes this tutorial's local jobs; a distributed backend needs its own transactional job ownership and idempotent downstream effects.
6. Read the balance correctly
from kdpika_licensing import CreditOperationError
try:
balance = client.get_balance()
show_balance(balance)
except CreditOperationError:
show_message("We could not refresh your balance. Please reconnect.")
# show_balance/show_message are callbacks in your own UI.
get_balance() makes an online signed check; a replayed receipt's balance belongs to its original transaction. Do not use an old receipt to display today's balance. Credit actions do not have an offline fallback.
7. Know the boundary
The server deduplicates the charge; your application deduplicates its work. This CSV example uses a stable file and journal. If your job sends email, transfers money or calls another service, use that service's own idempotency/recovery mechanism too. A SQLite row cannot guarantee exactly-once external effects.
Keep the journal and license state private and persistent. Never clear them just to retry an uncertain charge. A customer portal can show balances and recent transactions, but has no credit-pack checkout, invoices or payment refund tools.
Python feature map · Credits API reference · Customer portal.