Skip to content
campaiDeveloper
campai.com

Your club keeps financial records in campai and uses a separate archive for its document copies. Build a private review desk that downloads selected invoice PDFs, preserves their original bytes and records a checksum for each version.

This is an external API integration: your server reads campai and writes to your archive destination. A Developer App is useful for a staff workflow inside campai, such as creating draft invoices from usage records. It does not give an external archive access to documents automatically.

The complete example writes to a local private directory. It includes a clear mapping point for your archive provider. No vendor upload, retention policy or compliance certification is supplied by this program.

What you’ll have at the end

A private archive desk that copies selected receipt PDFs without changing their bytes, verifies checksums and recovers interrupted results.

  1. Selected campai receipt
  2. Fresh signed download URL
  3. Original PDF bytes
  4. Private copy and checksum index
Before you start
  • Node.js 22+ and a private writable directory
  • A synthetic unsent offer with its stored PDF
  • Mandates Read and Invoices and receipts Read for one mandate
On this page

Start with a disposable, unsent offer that has a generated PDF. Use a synthetic customer and leave the offer unaccepted. An offer exercises the same receipt-download endpoint without issuing an invoice for a test. You can then select an already issued invoice that your organization has authorized you to archive.

The example supports completed invoice and offer PDFs. A draft, a document still being generated, a missing file or another document type stops the read. A preview PDF is not a substitute for the stored original.

Start with an unsent offer and its stored PDF.

Start with an unsent offer and its stored PDF.

An open unsent campai offer with its stored document Download action

Create a temporary key restricted to the selected mandate with Mandates Read and Invoices and receipts Read. The example makes no changes in campai. Follow API permissions and Find organization and mandate IDs.

Use Find and filter receipts to locate the chosen document by its exact receipt number. Verify its customer account and mandate, then copy the returned 24-character _id. The receipt number itself is not the ID. Keep the key, IDs and downloaded documents in a private working directory. Do not put your configured source or archive below a public website directory.

Request the stored document’s download URL. This response contains url and fileName; it does not contain the PDF bytes.

Example

curl --fail-with-body --silent --show-error --max-time 30 --globoff \
  --request GET 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/finance/receipts/YOUR_RECEIPT_ID/download' \
  --header 'X-API-Key: YOUR_API_KEY'

Inspect the URL’s hostname privately. The complete program requires that exact hostname in downloadHosts before it will fetch a document. Do not paste the signed query string into configuration, a screenshot, a log or a third-party website.

The signed URL authorizes access to this file. Preserve it exactly when requesting the bytes, but do not send the campai API key to storage. Obtain a fresh URL just before downloading; an expired link needs another read, not a changed signature or disabled TLS validation.

Download the complete example. It runs with Node.js 22 or newer and needs no packages. Edit these values in server.mjs:

Setting What to enter
apiKey The private, read-only key.
organization and mandate The full IDs for your authorized scope.
receiptIds Between one and twenty exact receipt IDs.
downloadHosts The exact reviewed HTTPS storage hostname from the download response.

Run node server.mjs and open http://127.0.0.1:9401/. Select Read selected receipts.

Check the document before copying it.

Check the document before copying it.

The archive desk displays the selected offer and its original PDF filename

Compare the type, number, date and original filename with the native receipt. The desk checks that every selected record belongs to the configured mandate and has a ready PDF. A canceled receipt receives a separate warning: its original PDF may not contain the cancellation record.

A review expires after five minutes. The program checks the selected metadata again before and after downloading, so a changed record needs another review. It does not send addresses, customer names, signed storage URLs or API keys to the browser.

Select I reviewed this document and its destination, then Archive PDF. The server downloads the PDF with no campai key attached, rejects redirects and oversized responses, and checks for a PDF signature. It preserves the bytes under a filename derived from the receipt ID and SHA-256 hash.

Repeated reads keep one copy of identical bytes.

Repeated reads keep one copy of identical bytes.

One stored PDF version with its byte count, SHA-256 and download action

Select Verify stored PDFs. The stored file’s byte count and checksum must match its index entry. This verification makes no request to campai. Download stored PDF returns the indexed bytes after the same check.

Compare the downloaded file with the native PDF and open it only in a viewer you trust. A %PDF- signature and matching checksum establish byte identity, not document safety, legal validity or correct rendering.

Archive the same document again. The desk should report that the exact PDF is already stored, keeping one file and one entry. If campai later provides different PDF bytes for that receipt, the program saves a separate version and preserves the earlier copy.

For your first copy, you can enable Test a lost reply after the PDF and index save before archiving. The program saves both and then deliberately returns an error. Select Load saved index, followed by Verify stored PDFs. The committed copy should remain visible and verified. Restarting the server must preserve it too.

Read the saved index and verify its files.

Read the saved index and verify its files.

The saved PDF remains verified after the test reply was interrupted

Revoke your disposable test key and try Read selected receipts again. The campai read must fail while the saved local copies remain verifiable. A missing source record is not an instruction to delete an archive copy.

The sample writes the PDF before replacing the index. A process crash between these operations can leave an unindexed file. Repeating the same read checks those bytes and adds the index entry without overwriting the file. Keep unknown files for investigation; do not automatically delete them.

The underlying metadata request lets you inspect draft, completing, type, receiptNumber, receiptDate, receiptFile and receiptFileName. The full response also contains private financial and contact fields, so keep it out of public logs.

Example

curl --fail-with-body --silent --show-error --max-time 30 --globoff \
  --request GET 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/finance/receipts/YOUR_RECEIPT_ID' \
  --header 'X-API-Key: YOUR_API_KEY'

Both the metadata and download URL requests use the same organization, mandate and receipt ID. A successful metadata read does not prove that the subsequent storage download will succeed; check each response independently.

The local index stores one entry per receipt/content version. Map it to your provider’s document metadata:

Local field Destination use
Organization + mandate + receiptId + sha256 Stable document-version reference.
type, number, date Searchable receipt metadata.
originalFileName Display filename, never an unchecked filesystem path.
bytes and sha256 Upload verification and read-back comparison.
archivedAt When this integration saved the copy.

Replace the local file/index persistence after validation with your provider’s upload workflow. Save a reservation for the stable reference before uploading. After a lost reply, query that reference and compare the stored checksum before creating anything else. Verify the provider’s duplicate-reference and versioning behavior with its own test environment.

Keep access restrictions, retention, backups and recovery rules explicit. This sample is a file-copy adapter, not immutable records storage or a complete accounting export. Cancellation records, associated documents and structured electronic-invoice data may need separate export paths. It does not mark campai receipts as archived.

The local index is bound to the configured organization, mandate and receipt selection. Preserve the existing archive separately before deliberately changing that scope. Key rotation within the same scope is allowed. The directory supports up to 200 versions and each PDF is limited to 20 MiB.

Shared deployment needs staff authentication, HTTPS, private storage and audit identities. The local server assumes one trusted operator. Stop it after testing, revoke the remaining temporary key and remove only the disposable campai fixtures you created.

The complete document archive desk

Review selected receipt PDFs, preserve their original bytes in private storage and verify each version. Map the saved metadata to your archive provider.

Download ZIP
View and copy the complete source

The complete document archive desk

import { createServer } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import { open, readFile, rename, unlink, mkdir, link } from 'node:fs/promises';
import { resolve, join } from 'node:path';

// Read-only in campai. PDF copies and their index remain in this private directory.
const apiKey = 'YOUR_API_KEY';
const organization = 'YOUR_ORGANIZATION_ID';
const mandate = 'YOUR_MANDATE_ID';
const receiptIds = ['YOUR_RECEIPT_ID'];
const downloadHosts = ['YOUR_VERIFIED_STORAGE_HOST']; // Exact hostname from the download response.
const apiBase = 'https://cloud.campai.com/api';
const port = 9401;
const maxPdfBytes = 20 * 1024 * 1024;
const origin = `http://127.0.0.1:${port}`;
const id = x => typeof x === 'string' && /^[a-f0-9]{24}$/.test(x);
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
const hash = x => digest(JSON.stringify(x));
const fault = (message, status = 400) => Object.assign(new Error(message), { status });
if (!id(organization) || !id(mandate) || !apiKey || apiKey.includes('YOUR_') || /\s/.test(apiKey)) throw Error('Configure the read-only key and full organization/mandate IDs.');
if (!Array.isArray(receiptIds) || !receiptIds.length || receiptIds.length > 20 || receiptIds.some(x => !id(x)) || new Set(receiptIds).size !== receiptIds.length) throw Error('Configure 1–20 distinct authorized receipt IDs.');
if (!Array.isArray(downloadHosts) || !downloadHosts.length || downloadHosts.some(x => typeof x !== 'string' || !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/.test(x) || /(?:^|\.)(localhost|internal|local)$/.test(x))) throw Error('Configure exact, reviewed HTTPS storage hostnames.');
const scope = { organization, mandate, receiptIds }, scopeHash = hash(scope);
const directory = resolve('document-archive'), statePath = join(directory, 'index.json');
await mkdir(directory, { recursive: true, mode: 0o700 });
const lock = await open(statePath + '.lock', 'wx', 0o600);
let state = { version: 1, scopeHash, revision: 0, documents: [] }, review = null, verification = null;
const csrf = randomBytes(24).toString('hex');
try {
  const saved = JSON.parse(await readFile(statePath, 'utf8'));
  if (saved.version !== 1 || saved.scopeHash !== scopeHash || !Number.isSafeInteger(saved.revision) || saved.revision < 0 || !Array.isArray(saved.documents) || saved.documents.length > 200) throw Error('Archive index or scope changed. Preserve the directory and review it.');
  const seen = new Set();
  for (const d of saved.documents) {
    if (!receiptIds.includes(d.receiptId) || !/^[a-f0-9]{64}$/.test(d.sha256) || d.file !== d.receiptId + '-' + d.sha256 + '.pdf' || !Number.isSafeInteger(d.bytes) || d.bytes < 5 || d.bytes > maxPdfBytes || seen.has(d.file)) throw Error('Invalid archive entry. Preserve the index.');
    seen.add(d.file);
  }
  if (saved.indexHash !== hash(saved.documents)) throw Error('Archive index checksum does not match.');
  state = saved;
} catch (error) { if (error.code !== 'ENOENT') { await lock.close(); await unlink(statePath + '.lock'); throw error; } }
let pending = Promise.resolve();
function exclusive(action) { const result = pending.then(action); pending = result.catch(() => {}); return result; }
async function save(documents) {
  const next = { version: 1, scopeHash, revision: state.revision + 1, documents, indexHash: hash(documents) };
  const temporary = statePath + '.' + process.pid + '.tmp', file = await open(temporary, 'wx', 0o600);
  try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync(); } finally { await file.close(); }
  await rename(temporary, statePath); state = next;
}
async function limited(response, limit) {
  if (!response.body) throw Error('Empty response body.');
  const declared = Number(response.headers.get('content-length'));
  if (Number.isFinite(declared) && declared > limit) { await response.body.cancel(); throw Error('Response exceeds the size limit.'); }
  const reader = response.body.getReader(), chunks = []; let size = 0;
  try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > limit) { await reader.cancel(); throw Error('Response exceeds the size limit.'); } chunks.push(value); } } finally { reader.releaseLock(); }
  return Buffer.concat(chunks);
}
async function api(path) {
  const response = await fetch(`${apiBase}/${organization}/${mandate}${path}`, { headers: { 'X-API-Key': apiKey }, redirect: 'error', signal: AbortSignal.timeout(15000) });
  if (!response.ok) throw Error(`campai HTTP ${response.status}.`);
  const bytes = await limited(response, 2 * 1024 * 1024);
  try { return JSON.parse(bytes.toString('utf8')); } catch { throw Error('The receipt response was not valid JSON.'); }
}
function storageUrl(value) {
  let url; try { url = new URL(value); } catch { throw Error('Invalid storage URL.'); }
  if (url.protocol !== 'https:' || url.username || url.password || url.port || url.hash || !downloadHosts.includes(url.hostname)) throw Error('The download host does not match the reviewed HTTPS allowlist.');
  return url;
}
function dateOnly(value) {
  const text = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/.test(value) ? value.slice(0, 10) : value;
  if (typeof text !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(text) || !Number.isFinite(Date.parse(text + 'T00:00:00Z')) || new Date(text + 'T00:00:00Z').toISOString().slice(0, 10) !== text) throw Error('Receipt date is unavailable or invalid.');
  return text;
}
async function metadata(receiptId) {
  const r = await api('/finance/receipts/' + receiptId);
  if (!r || r._id !== receiptId || r.mandate !== mandate || !['invoice', 'offer'].includes(r.type)) throw Error('The receipt scope or supported document type does not match.');
  if (r.draft !== false || r.completing) throw Error('This receipt is still a draft or its document is being prepared.');
  if (typeof r.receiptFile !== 'string' || !r.receiptFile || typeof r.receiptFileName !== 'string' || !/\.pdf$/i.test(r.receiptFileName) || r.receiptFileName.length > 250 || /[\u0000-\u001f]/.test(r.receiptFileName)) throw Error('No ready PDF document is available.');
  if (typeof r.receiptNumber !== 'string' || !r.receiptNumber || r.receiptNumber.length > 100) throw Error('Receipt number is unavailable.');
  const url = storageUrl(r.receiptFile);
  // Query signatures expire; the hostname and resource path identify the source file.
  return { receiptId, type: r.type, number: r.receiptNumber, date: dateOnly(r.receiptDate), originalFileName: r.receiptFileName, resource: url.origin + url.pathname, canceled: !!r.canceledAt };
}
function publicMetadata(row) { const { resource, ...rest } = row; return rest; }
function view() { return { revision: state.revision, documents: state.documents, review: review ? { id: review.id, expiresAt: review.expiresAt, rows: review.rows.map(publicMetadata) } : null, verification }; }
async function reviewReceipts() {
  const started = Date.now(), rows = [];
  for (const receiptId of receiptIds) { if (Date.now() - started > 60000) throw Error('Review exceeded one minute. The previous review is unchanged.'); rows.push(await metadata(receiptId)); }
  if (Date.now() - started > 60000) throw Error('Review exceeded one minute. The previous review is unchanged.');
  review = { id: randomBytes(16).toString('hex'), rows, expiresAt: new Date(started + 300000).toISOString() };
  return view();
}
async function verifyDocument(entry) {
  const bytes = await readFile(join(directory, entry.file));
  if (bytes.length !== entry.bytes || digest(bytes) !== entry.sha256 || !bytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) throw Error('Stored PDF failed its size or SHA-256 check. Preserve the file and investigate.');
  return bytes;
}
async function archive(input) {
  if (input.confirmed !== true || !review || input.reviewId !== review.id || Date.now() >= Date.parse(review.expiresAt)) throw fault('Read the receipts again and confirm the selected document.', 409);
  const selected = review.rows.find(r => r.receiptId === input.receiptId); if (!selected) throw fault('Choose a reviewed receipt.', 400);
  const current = await metadata(selected.receiptId); if (hash(current) !== hash(selected)) throw fault('The receipt changed after review. Read it again before archiving.', 409);
  const download = await api('/finance/receipts/' + selected.receiptId + '/download');
  if (!download || download.fileName !== current.originalFileName) throw Error('The download filename changed. Review the receipt again.');
  const url = storageUrl(download.url); if (url.origin + url.pathname !== current.resource) throw Error('The download resource does not match the reviewed receipt.');
  // The signed URL authorizes this request. NEVER forward X-API-Key to storage.
  const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30000) });
  if (!response.ok) throw Error(`Storage HTTP ${response.status}. Request a fresh review; the index is unchanged.`);
  const bytes = await limited(response, maxPdfBytes);
  if (!bytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) throw Error('The download is not a PDF. Nothing was indexed.');
  if (hash(await metadata(selected.receiptId)) !== hash(selected)) throw fault('The receipt changed during download. No copy was indexed. Read it again.', 409);
  const sha256 = digest(bytes), fileName = selected.receiptId + '-' + sha256 + '.pdf';
  const existing = state.documents.find(d => d.file === fileName);
  if (existing) { await verifyDocument(existing); return { ...view(), unchanged: true }; }
  if (state.documents.length >= 200) throw Error('This example supports at most 200 document versions.');
  const temporary = join(directory, '.' + process.pid + '-' + randomBytes(8).toString('hex') + '.tmp');
  const file = await open(temporary, 'wx', 0o600);
  try { await file.writeFile(bytes); await file.sync(); } finally { await file.close(); }
  try { await link(temporary, join(directory, fileName)); }
  catch (error) { if (error.code !== 'EEXIST') throw error; const prior = await readFile(join(directory, fileName)); if (digest(prior) !== sha256) throw Error('Existing PDF differs. Preserve it and investigate.'); }
  finally { await unlink(temporary); }
  // File first, index second. A crash can leave an unindexed file; repeating this read
  // checks the same content-addressed file instead of overwriting it.
  const entry = { ...publicMetadata(current), sha256, bytes: bytes.length, file: fileName, archivedAt: new Date().toISOString() };
  await save([...state.documents, entry]); verification = null;
  if (input.simulateLostReply === true) throw fault('The PDF and index were saved, but this test reply was interrupted. Verify stored PDFs to read back the result.', 502);
  return view();
}
function json(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }
async function body(req) {
  if (req.headers['content-type']?.split(';')[0] !== 'application/json') throw fault('Use JSON.', 415);
  const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > 3000) throw fault('Request too large.', 413); chunks.push(chunk); }
  try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fault('Invalid JSON.'); }
}
const server = createServer(async (req, res) => {
  res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Robots-Tag', 'noindex, nofollow'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer');
  try {
    if (req.headers.host !== `127.0.0.1:${port}`) throw fault('Use the configured local address.', 403);
    if (req.method === 'GET' && req.url === '/') {
      const nonce = randomBytes(18).toString('base64');res.setHeader('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });res.end(page(nonce));return;
    }
    if (req.method === 'GET' && req.url === '/api/state') { json(res, 200, view()); return; }
    if (req.method === 'GET' && req.url?.startsWith('/files/')) {
      const name = req.url.slice(7), entry = state.documents.find(d => d.file === name); if (!entry) throw fault('No indexed PDF with that name.', 404);
      const bytes = await verifyDocument(entry);res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${entry.file}"` });res.end(bytes);return;
    }
    if (req.method !== 'POST' || req.headers.origin !== origin || req.headers['x-csrf-token'] !== csrf) throw fault('Request rejected.', 403);
    const input = await body(req);
    const result = await exclusive(async () => {
      if (!input || input.revision !== state.revision) throw fault('Load the saved index before continuing.', 409);
      if (req.url === '/api/review') return reviewReceipts();
      if (req.url === '/api/archive') return archive(input);
      if (req.url === '/api/verify') { for (const entry of state.documents) await verifyDocument(entry);verification = { count: state.documents.length, checkedAt: new Date().toISOString() };return view(); }
      throw fault('Not found.', 404);
    });json(res, 200, result);
  } catch (error) { json(res, error.status || 502, { error: error.message || 'Archive operation failed.', ...view() }); }
});
server.listen(port, '127.0.0.1', () => console.log(`Document archive: ${origin}`));
async function stop() { server.close(); server.closeAllConnections();await pending;await lock.close();await unlink(statePath + '.lock');process.exit(0); }
process.once('SIGTERM', stop);process.once('SIGINT', stop);

function page(nonce) { return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Invoice document archive</title><style nonce="${nonce}">
/* Standard Tailwind palette, embedded for dependency-free use. */
:root{--slate-50:oklch(98.4% 0.003 247.858);--slate-100:oklch(96.8% 0.007 247.896);--slate-200:oklch(92.9% 0.013 255.508);--slate-300:oklch(86.9% 0.022 252.894);--slate-600:oklch(44.6% 0.043 257.281);--slate-900:oklch(20.8% 0.042 265.755);--amber-50:oklch(98.7% 0.022 95.277);--amber-200:oklch(92.4% 0.12 95.746);--amber-500:oklch(76.9% 0.188 70.08);--amber-800:oklch(47.3% 0.137 46.201);--emerald-50:oklch(97.9% 0.021 166.113);--emerald-800:oklch(43.2% 0.095 166.913);--white:#fff;--black:#000}*{box-sizing:border-box}body{margin:0;background:var(--slate-50);color:var(--slate-900);font:15px/1.55 system-ui,sans-serif}header{background:var(--white);border-bottom:1px solid var(--slate-200);padding:24px max(24px,calc((100% - 1080px)/2));font-size:19px;font-weight:650}main{max-width:1128px;margin:auto;padding:36px 24px}h1{font-size:36px;line-height:1.2;letter-spacing:-1px;margin:0 0 12px}h2{font-size:19px;margin:0 0 16px}p{color:var(--slate-600);margin:0 0 20px}.controls{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0}button,a.button{font:600 14px system-ui;border:1px solid var(--slate-300);border-radius:10px;padding:12px 16px;color:var(--slate-900);background:var(--white);cursor:pointer;text-decoration:none}button.primary{background:var(--black);border-color:var(--black);color:var(--white)}button:disabled{opacity:.45;cursor:default}button:focus-visible,a:focus-visible{outline:3px solid var(--amber-500);outline-offset:3px}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:22px 0}.stat,.panel{padding:24px;border:1px solid var(--slate-200);border-radius:18px;background:var(--white)}.stat:first-child{background:var(--amber-50);border-color:var(--amber-200)}.stat strong{display:block;font-size:34px;margin-top:7px}.stat:first-child strong{color:var(--amber-800)}.status{padding:14px 18px;border-radius:12px;background:var(--emerald-50);color:var(--emerald-800)}.status.error{background:var(--amber-50);color:var(--amber-800)}.layout{display:grid;grid-template-columns:1fr 1.2fr;gap:20px}.document+.document{border-top:1px solid var(--slate-200);padding-top:20px;margin-top:20px}.document strong{display:block;font-size:16px}.meta{font-size:13px;color:var(--slate-600);overflow-wrap:anywhere;margin:7px 0}.hash{font:12px/1.5 ui-monospace,monospace;overflow-wrap:anywhere;background:var(--slate-50);padding:10px;border-radius:8px;margin:12px 0}.badge{display:inline-block;background:var(--amber-50);color:var(--amber-800);font-size:12px;padding:3px 8px;border-radius:6px;margin-bottom:8px}.check{display:flex;gap:9px;align-items:start;font-size:13px;margin:12px 0}.check input{margin-top:4px;accent-color:var(--black)}.empty{padding:20px 0}.foot{font-size:13px;margin:24px 0}.file-icon{font-size:24px;margin-bottom:8px;color:var(--amber-800)}@media(max-width:720px){main{padding:28px 16px}h1{font-size:29px}.layout{grid-template-columns:1fr}.stats{gap:8px}.stat{padding:14px}.stat strong{font-size:27px}.controls button{flex:1}}
</style><header>campai / Document archive</header><main><h1>Keep the document, verify the copy.</h1><p>Review selected invoice or offer PDFs, save their original bytes, and check each copy against its SHA-256 checksum.</p><div class="controls"><button class="primary" id="review">Read selected receipts</button><button id="verify">Verify stored PDFs</button><button id="refresh">Load saved index</button></div><div class="status" id="status" role="status">Ready to review. campai access is read-only.</div><div class="stats"><div class="stat">Stored PDF versions<strong id="count">0</strong></div><div class="stat">Original bytes saved<strong id="bytes">0</strong></div><div class="stat">Verified copies<strong id="verified">—</strong></div></div><div class="layout"><section class="panel"><h2>Review before copying</h2><p>Choose an existing completed document. Drafts and documents still being generated are rejected.</p><div id="reviewRows"></div><label class="check"><input type="checkbox" id="lost">Test a lost reply after the PDF and index save</label></section><section class="panel"><h2>Your private archive</h2><p id="verifiedAt">Verification reads stored files without calling campai.</p><div id="documents"></div></section></div><p class="foot">This is a local file-copy adapter. Retention, restricted access, backups and your archive provider’s records policies remain separate responsibilities.</p></main><script nonce="${nonce}">
const csrf=${JSON.stringify(csrf)};let state=null,busy=false;const el=id=>document.getElementById(id);function text(tag,value,className){const n=document.createElement(tag);n.textContent=value;if(className)n.className=className;return n;}function status(message,error=false){el('status').textContent=message;el('status').className='status'+(error?' error':'');}function controls(){for(const b of document.querySelectorAll('button'))b.disabled=busy;}
function render(next){state=next;el('count').textContent=state.documents.length;el('bytes').textContent=new Intl.NumberFormat('en').format(state.documents.reduce((sum,d)=>sum+d.bytes,0));el('verified').textContent=state.verification?state.verification.count:'—';el('verifiedAt').textContent=state.verification?'Checksums verified '+new Date(state.verification.checkedAt).toLocaleString('en-GB',{timeZone:'UTC'})+' UTC':'Verification reads stored files without calling campai.';el('reviewRows').replaceChildren();
if(state.review){for(const row of state.review.rows){const box=text('div','','document');box.append(text('span',row.type==='offer'?'Offer':'Invoice','badge'),text('strong',row.number),text('div',row.date+' · '+row.originalFileName,'meta'));if(row.canceled)box.append(text('div','Source receipt is canceled. This PDF may not contain the cancellation record.','meta'));const label=text('label','','check'),check=document.createElement('input');check.type='checkbox';label.append(check,text('span','I reviewed this document and its destination'));const button=text('button','Archive PDF');button.onclick=()=>run('/api/archive',{receiptId:row.receiptId,reviewId:state.review.id,confirmed:check.checked,simulateLostReply:el('lost').checked});box.append(label,button);el('reviewRows').append(box);}}else el('reviewRows').append(text('p','Read the configured receipts to begin.','empty'));
el('documents').replaceChildren();for(const d of state.documents){const box=text('div','','document');box.append(text('div','▤','file-icon'),text('strong',d.number+' · '+(d.type==='offer'?'Offer':'Invoice')),text('div',d.originalFileName+' · '+new Intl.NumberFormat('en').format(d.bytes)+' bytes','meta'),text('div','SHA-256 '+d.sha256,'hash'));const a=text('a','Download stored PDF','button');a.href='/files/'+d.file;a.download=d.file;box.append(a);el('documents').append(box);}if(!state.documents.length)el('documents').append(text('p','No PDF has been copied yet.','empty'));controls();}
async function run(path,data={}){if(!state||busy)return;busy=true;controls();status('Working. Existing archive files stay in place.');try{const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({revision:state.revision,...data})});const result=await response.json();render(result);status(response.ok?(result.unchanged?'This exact PDF is already stored. Its checksum still matches.':path==='/api/verify'?'Stored PDFs verified. No campai request was sent.':path==='/api/review'?'Documents ready for review. Choose one PDF to archive.':'Original PDF and archive index saved.'):result.error,!response.ok);}catch{status('The response was lost. Load the saved index, then verify stored PDFs.',true);}finally{busy=false;controls();}}
async function load(){if(busy)return;try{const response=await fetch('/api/state');if(!response.ok)throw Error();render(await response.json());}catch{status('Could not read the saved index.',true);}}el('review').onclick=()=>run('/api/review');el('verify').onclick=()=>run('/api/verify');el('refresh').onclick=load;load();
</script></html>`;}

The complete desk runs in Node.js; the HTTP reads above also provide independent cURL and PHP examples. Continue with Handling files to distinguish temporary storage URLs from durable resource identifiers.

Check your result

Copy the same PDF twice
One file and one index entry for identical bytes.
Interrupt the reply after saving
Load and verify the committed copy without another write.
Revoke the test key
Source reads fail; stored local PDFs remain verifiable.
Continue with Handling files