Raw HTTP

The lifecycle, with every recovery boundary visible.

Use this language-neutral path when the TypeScript SDK is not appropriate. The source bytes never pass through the public API server.

On this page

Run the complete HTTP example

This standalone Node.js 22+ script uses built-in fetch, with no SDK or packages. Each HTTP exchange is explicit so you can translate it to your own language. You need an active Trial or paid plan, an API key, and one PDF, JPEG, or PNG invoice through 20 MiB and 20 pages.

Download HTTP example

  1. Save invoice-http.mjs beside your invoice.
  2. Create a private .env file containing INVOICE_API_KEY=your_api_key. Keep that file out of source control.
  3. Run the command below in macOS/Linux or PowerShell. Replace the file, MIME type, and stable key with your submission's values. Use a new output filename; the script never overwrites an existing result.
Terminal
node --env-file=.env invoice-http.mjs ./invoice.pdf application/pdf invoice-2026-0842 ./invoice-result.json

The script measures the actual bytes, creates the document, uploads using only the returned method and headers, confirms, and polls for up to two minutes. It prints only the document ID and status; the full result goes into your local JSON file. needs_review and failed are terminal results to inspect, not reasons to submit the invoice again.

Inspect or copy the complete script
invoice-http.mjs
// Node.js 22+. Raw HTTP only; no SDK or other packages.
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';

async function processInvoice(input, io = {}) {
  const send = io.fetch ?? fetch;
  const now = io.now ?? Date.now;
  const sleep = io.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
  const deadline = now() + 120_000;
  const idempotencyKey = input.idempotencyKey;
  let documentId;
  const stop = (code, requestId, recovery = {}) => {
    const error = new Error(code);
    Object.assign(error, { code, documentId, idempotencyKey, requestId, ...recovery });
    throw error;
  };
  const root = new URL(input.apiRoot ?? 'https://api.temlavo.com');
  const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(root.hostname);
  if ((root.protocol !== 'https:' && !(root.protocol === 'http:' && loopback)) ||
      root.username || root.password || root.search || root.hash ||
      /\/v1\/?$/.test(root.pathname)) stop('invalid_api_root');
  const base = root.href.replace(/\/$/, '');
  const bytes = new Uint8Array(input.file); // Snapshot bytes and create identity once.
  if (!input.apiKey || !idempotencyKey || !input.filename ||
      !['application/pdf', 'image/jpeg', 'image/png'].includes(input.contentType) ||
      bytes.byteLength < 1 || bytes.byteLength > 20 * 1024 * 1024) stop('invalid_input');
  const createBody = JSON.stringify({ filename: input.filename,
    content_type: input.contentType, file_size_bytes: bytes.byteLength });
  const apiHeaders = { Authorization: 'Bearer ' + input.apiKey };
  const remaining = () => {
    const ms = deadline - now();
    if (ms <= 0) stop('polling_timeout'); // Server-side work keeps running.
    return ms;
  };
  const pause = async (response) => {
    const value = response?.headers.get('Retry-After');
    const date = value ? Date.parse(value) : NaN;
    const delay = value && /^\d+$/.test(value) ? Number(value) * 1000
      : Number.isFinite(date) ? Math.max(0, date - now()) : 1000;
    if (!Number.isSafeInteger(delay) || delay >= remaining()) stop('polling_timeout');
    await sleep(delay); // Never shorten Retry-After to fit our deadline.
  };
  const request = async (path, method, extra = {}, body) => {
    for (let attempt = 0; attempt < 3; attempt++) {
      let response;
      try {
        response = await send(base + path, { method,
          headers: { ...apiHeaders, ...extra }, body, redirect: 'error',
          signal: AbortSignal.timeout(Math.min(15_000, remaining())) });
      } catch {
        if (attempt === 2) stop('transport_error');
        await pause();
        continue; // Same method, path, body, and key. Never a replacement create.
      }
      let data;
      try { data = await response.json(); } catch { stop('invalid_response'); }
      if (response.status >= 300 && response.status < 400) stop('redirect_rejected');
      if (!response.ok && data?.error?.retryable === true &&
          (response.status === 429 || response.status >= 500) && attempt < 2) {
        await pause(response);
        continue;
      }
      return { response, data };
    }
  };
  const accept = ({ response, data }, statuses) => {
    if (!statuses.includes(response.status)) {
      const code = data?.error?.code;
      stop(typeof code === 'string' && code.trim() ? code : 'http_error',
        response.headers.get('X-Request-Id'),
        { status: response.status, retryable: data?.error?.retryable === true });
    }
    if (typeof data?.id !== 'string' || !/^doc_[A-Za-z0-9]+$/.test(data.id) ||
        (documentId && data.id !== documentId) ||
        !['awaiting_upload', 'queued', 'processing', 'completed', 'needs_review', 'failed'].includes(data.status)) {
      stop('invalid_response');
    }
    documentId = data.id;
    return data;
  };

  // 1. Create with the exact measured byte count and caller-owned stable key.
  const create = () => request('/v1/documents', 'POST', {
    'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey,
    'Temlavo-Upload-Recovery': 'confirm-existing',
  }, createBody);
  let reply = await create();
  let document = accept(reply, [200, 201]);
  const path = '/v1/documents/' + documentId;
  const confirm = () => request(path + '/upload-complete', 'POST',
    { 'Content-Type': 'application/json' }, '{}');
  const absent = ({ response, data }) =>
    response.status === 409 && data?.error?.code === 'upload_not_complete';

  for (let recovery = 0; document.status === 'awaiting_upload' && document.upload === null; recovery++) {
    if (reply.response.status !== 200) stop('invalid_response');
    reply = await confirm();
    if (!absent(reply)) { document = accept(reply, [200, 202]); break; }
    if (recovery >= 2) stop('upload_not_complete');
    reply = await create(); // Same snapshot/key; never PUT without a real capability.
    document = accept(reply, [200, 201]);
  }

  if (document.status === 'awaiting_upload') {
    const upload = document.upload;
    let shouldUpload = reply.response.status === 201;
    if (!shouldUpload) {
      // Create replay cannot tell us whether a prior PUT succeeded.
      reply = await confirm();
      shouldUpload = absent(reply);
      if (!shouldUpload) document = accept(reply, [200, 202]);
    }
    if (shouldUpload) {
      let url;
      try { url = new URL(upload?.url); } catch { stop('invalid_upload_capability'); }
      const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
      if ((url.protocol !== 'https:' && !(root.protocol === 'http:' && local && url.protocol === 'http:')) ||
          url.username || url.password || url.hash || upload.method !== 'PUT' ||
          !upload.headers || typeof upload.headers !== 'object' || Array.isArray(upload.headers) ||
          Object.values(upload.headers).some((value) => typeof value !== 'string') ||
          !Number.isFinite(Date.parse(upload.expires_at)) || Date.parse(upload.expires_at) <= now()) {
        stop('invalid_upload_capability');
      }
      // 2. Separate origin: ONLY returned headers + bytes. No API auth/key.
      try {
        const put = await send(url.href, { method: upload.method, headers: upload.headers,
          body: bytes, redirect: 'error', signal: AbortSignal.timeout(Math.min(30_000, remaining())) });
        await put.body?.cancel(); // Do not print storage responses or signed URLs.
      } catch { /* Ambiguous PUT: confirm before any future upload attempt. */ }
      // 3. Confirmation decides whether the source is usable, even after a timeout.
      reply = await confirm();
      document = accept(reply, [200, 202]);
      // upload_not_complete stops safely. Rerun with the SAME file and key;
      // its create replay probes confirmation before another PUT.
    }
  }
  if (document.status === 'awaiting_upload') stop('invalid_response');

  // 4. Bounded polling. Respect Retry-After from confirmation and every GET.
  for (let polls = 0; ['queued', 'processing'].includes(document.status); polls++) {
    if (polls >= 120) stop('polling_timeout');
    await pause(reply.response);
    reply = await request(path, 'GET');
    document = accept(reply, [200]);
    if (document.status === 'awaiting_upload') stop('invalid_response');
  }
  return document; // completed, needs_review, and failed are all terminal results.
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const [filePath, contentType, idempotencyKey, outputPath] = process.argv.slice(2);
  let completedDocumentId;
  try {
    if (!filePath || !contentType || !idempotencyKey || !outputPath) {
      console.error('Usage: node invoice-http.mjs FILE MIME STABLE_KEY NEW_OUTPUT_FILE');
      throw Object.assign(new Error('invalid_arguments'), { code: 'invalid_arguments' });
    }
    const document = await processInvoice({
      file: await readFile(filePath), filename: basename(filePath), contentType,
      idempotencyKey, apiKey: process.env.INVOICE_API_KEY,
    });
    completedDocumentId = document.id;
    // The full result contains financial data. Save privately; do not log it.
    await writeFile(outputPath, JSON.stringify(document, null, 2), { flag: 'wx', mode: 0o600 });
    console.log(document.id + ': ' + document.status);
  } catch (error) {
    // Never dump transport errors, API keys, signed URLs, or provider content.
    console.error('Stopped:', JSON.stringify(error.code ?? 'local_error'));
    if (error.status) console.error('HTTP:', error.status, 'Retryable:', error.retryable);
    const documentId = error.documentId ?? completedDocumentId;
    if (documentId) console.error('Document:', documentId);
    if (completedDocumentId) console.error('Result was not saved. Retrieve this document or rerun with the same inputs/key and a new output filename.');
    if (error.requestId) console.error('Request:', error.requestId);
    console.error('Keep the original file, create inputs, and stable key for recovery.');
    process.exitCode = 1;
  }
}

On timeout or an interrupted upload, keep the same file, filename, MIME type, and stable key when rerunning. Create replay checks the existing document before uploading again. The script stops safely on billing, expiry, invalid input, or exhausted retries; follow the recovery guidance. A polling timeout does not cancel server work. This example uses the default policy; choose your document policy before fixing the request identity for your production integration.

Create → upload → confirm → poll

  1. Create

    POST /v1/documents with one stable idempotency key for the logical submission.

  2. Upload directly

    Follow the returned opaque upload method, URL, and every returned header. Add no Invoice API credential or create idempotency header.

  3. Confirm

    POST /v1/documents/{document_id}/upload-complete. Uploading bytes alone never starts processing.

  4. Poll or receive a callback

    GET the authenticated document until its status is completed, needs_review, or failed. Treat Retry-After as a minimum delay.

Create one immutable submission identity

TerminalBash / Zsh syntax (macOS, Linux, or WSL).
curl --fail-with-body --no-location \
  https://api.temlavo.com/v1/documents \
  -H "Authorization: Bearer inv_live_your_key" \
  -H "Idempotency-Key: your-stable-logical-key" \
  -H "Content-Type: application/json" \
  --data '{
    "filename":"invoice.pdf",
    "content_type":"application/pdf",
    "file_size_bytes":12345,
    "policy": { "required_fields": [], "line_items": "advisory", "checks": { "duplicate_detection": true } }
  }'

Snapshot the body associated with the key. A timeout may have created the resource, so retry only the exact body and key; never rotate the key merely because a create response was ambiguous.

Raw HTTP accepts the complete snake_case policy object in the create body, or selected members with defaults for omissions. See all review policy settings for their shared meaning.

Responses include the complete policy. For request identity, omitted defaults equal explicit defaults, and required-field order does not matter. Changing the required-field set, line-item mode, or switching duplicate detection between true and false under the same live idempotency key returns idempotency_conflict. Omitted duplicate detection is equivalent to true. See idempotency guidance.

Ambiguous upload recovery

A definite failed PUT may retry the complete PUT. If the PUT outcome is ambiguous, probe upload-complete first. Re-upload only after an authoritative upload_not_complete response. A create replay returning 200 awaiting_upload also probes confirmation before sending bytes because source presence is unknown.

Authenticated API calls and the signed PUT must not follow redirects automatically. A 3xx is a protocol failure, never permission to forward credentials or a signed capability to Location.

Generated contracts

Use OpenAPI for the full document envelope and invoice.v1 JSON Schema for the strict object under data. The HTTP artifact version,/v1 route family, and invoice.v1 schema version are independent identifiers.