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.
- Save
invoice-http.mjsbeside your invoice. - Create a private
.envfile containingINVOICE_API_KEY=your_api_key. Keep that file out of source control. - 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.
node --env-file=.env invoice-http.mjs ./invoice.pdf application/pdf invoice-2026-0842 ./invoice-result.jsonThe 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
// 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
- Create
POST /v1/documentswith one idempotency key per invoice. - Upload directly
Send the file with exactly the returned method, URL and headers. Do not add your API key or the idempotency header.
- Confirm
POST /v1/documents/{document_id}/upload-complete. Uploading bytes alone never starts processing. - Poll or receive a callback
GET the authenticated document until its status is
completed,needs_review, orfailed. TreatRetry-Afteras a minimum delay.
Create the document
curl --fail-with-body --no-location \
https://api.temlavo.com/v1/documents \
-H "Authorization: Bearer inv_live_your_key" \
-H "Idempotency-Key: invoice-2026-0842" \
-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 } }
}'Save the exact body you sent with each key. A request that timed out may still have created the document, so retry with the same body and key. Never switch to a new key just because you did not get a response.
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. When a retry is compared with the original request, 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 idempotency key returns idempotency_conflict. Omitted duplicate detection is equivalent to true. See idempotency guidance.
When you are not sure the upload worked
If the upload clearly failed, for example with an error response, upload the complete file again. If you do not know whether it worked, after a timeout or dropped connection, call upload-complete first and upload again only if it returns upload_not_complete. Do the same when a create retry returns 200 awaiting_upload: the file may already be there.
API calls and the file upload must not follow redirects automatically. Treat a 3xx response as an error: never send your API key or the signed upload URL to the Location it points to.
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.