@gemina/sdk
v0.15.0
Published
Official TypeScript/JavaScript SDK for the Gemina API — invoice OCR and document intelligence.
Maintainers
Readme
Gemina TypeScript SDK
The official TypeScript/JavaScript client for the Gemina API — invoice OCR and document intelligence. Upload invoices and financial documents, get typed structured data back, then search, aggregate, and chat over everything you've processed. Works in Node.js (18+) and the browser, with zero runtime dependencies (native fetch).
Install
npm i @gemina/sdkRequires Node.js >= 18 (or any modern browser). Ships ESM and CommonJS builds with full type declarations.
Authenticate
Get an API key from the Gemina Console. The client sends it as the X-API-Key header on every request:
import { GeminaClient } from "@gemina/sdk";
const client = new GeminaClient(process.env.GEMINA_API_KEY!);Never ship the API key in browser or mobile code. For browser embedding, mint short-lived session tokens server-side (POST /v1/sessions/token) and use GeminaClient.withSessionToken(...) — see Session tokens below and the Document Intelligence guide in the docs.
Quickstart — process an invoice in one call
processDocument submits the document through the async endpoint, polls with exponential backoff until processing completes, and returns the final typed result — one call, no manual polling:
import { readFile } from "node:fs/promises";
import { GeminaClient } from "@gemina/sdk";
const client = new GeminaClient(process.env.GEMINA_API_KEY!);
// Node: wrap a Buffer in a Blob. In the browser, pass a File from an
// <input type="file"> directly.
const buf = await readFile("./invoice.png");
const result = await client.processDocument(
new Blob([buf], { type: "image/png" }),
["invoice_headers"],
);
const values = result.data?.extractions?.[0]?.values;
console.log("supplier:", values?.vendorName?.value);
console.log("total:", values?.totalAmount?.value);
console.log("date:", values?.invoiceDate?.value);Processing a document by URL works the same way — pass { url } instead of a file:
const result = await client.processDocument(
{ url: "https://example.com/invoice.pdf" },
["invoice_headers"],
);What you get back
processDocument resolves to a DocumentProcessingResultOutDTO:
status—"success" | "partial" | "empty" | "failed".partialandemptystill resolve (they carry usable data/meta);failedthrows aGeminaProcessingErrorinstead.data.extractions— one entry per requested extraction type. Each entry hasmeta.extractionType, its ownstatus, andvalues— the extracted payload. Forinvoice_headers,valuesfields (e.g.vendorName,invoiceNumber,invoiceDate,totalAmount,currency) are each{ value, coordinates, confidence }ornullwhen not found.meta.documentId— the stored document's ID (use it withclient.documents).meta.correlationId— the async processing correlation ID.
Extraction types (UploadExtractionTypeEnum) — note filetag is not one of
them: FileTag has its own endpoints (client.fileTag), and the upload endpoints
reject it.
| Type | What it extracts |
|------|------------------|
| ocr | Raw OCR text |
| invoice_headers | Invoice header fields (vendor, buyer, dates, amounts, taxes) |
| invoice_line_items | Invoice line items |
| document_details_hebrew | Hebrew document details |
| document_line_items_hebrew | Hebrew document line items |
| custom_template | Fields defined by your custom template (pass templateId) |
Search & aggregate your documents
Everything you process is indexed for retrieval. Use the retrieval group to query with natural language plus structured filters — results carry documentId / documentExtractionId citations back to the underlying documents:
const { items, meta } = await client.retrieval.retrievalQuery({
retrievalQueryInDTO: {
text: "cleaning services invoices from August",
filters: { totalAmountMin: 1000, currency: "ILS" },
limit: 10,
},
});
for (const item of items ?? []) {
console.log(item.vendorName, item.totalAmount, item.issueDate, item.documentId);
}
console.log(`${meta.count} matches (mode: ${meta.mode})`);Aggregate across your documents with metrics and group-by:
const { rows } = await client.retrieval.retrievalAggregate({
retrievalAggregateInDTO: {
metrics: [{ op: "sum", field: "total_amount" }, { op: "count" }],
groupBy: ["vendor_name"],
},
});
for (const row of rows ?? []) {
console.log(row.group, row.values);
}Check how many of your documents are indexed with client.retrieval.retrievalStatus() (returns { indexedDocuments }).
Advanced filters & match highlights. Beyond the promoted filters, filter on any structured field a document has with structuredFilters (op is one of eq / neq / gt / lt / contains / exists, max 8), and read back the line-item snippet that made a document match via matchedChunks:
const { items } = await client.retrieval.retrievalQuery({
retrievalQueryInDTO: {
mode: "hybrid",
text: "27-inch monitors",
structuredFilters: [{ path: "position", op: "contains", value: "engineer" }],
},
});
for (const item of items ?? []) {
for (const chunk of item.matchedChunks ?? []) {
console.log(item.documentId, "matched on:", chunk.text);
}
}Discover which fields you can filter on with client.retrieval.retrievalFields() — it returns the structured field names per document type (names only, never values), so you can build a field picker from real data:
const { fields } = await client.retrieval.retrievalFields();
// -> [{ documentType: "invoice", field: "vendor_name", count: 42 }, ...]What did an extraction cost?
Credits are charged after the result is delivered, so cost is a separate lookup rather than a field on the extraction response. Ask for one extraction, or up to 100 at a time:
const { data } = await client.documents.getExtractionCost({
documentExtractionId: extractionId,
});
console.log(data.state, data.creditsConsumed); // "settled" "2.5"
const bulk = await client.documents.getExtractionCosts({
ids: [extractionId, otherExtractionId],
});
for (const cost of bulk.data.costs) {
console.log(cost.extractionId, cost.state, cost.creditsConsumed);
}state tells you whether the number is final:
settled— the charge is on record; this is the authoritative number.pending— billing has not run yet. Retry.not_charged— billing finished without a charge. This never resolves, so don't poll it.
Enterprise accounts are billed in contract dollars: creditsConsumed is null
and costCents carries the amount. The bulk call silently omits ids you don't
own, so key the response by extractionId rather than assuming input order.
Chat with your documents
Ask free-form questions over everything you've processed. Answers come back with a confidence signal and citations to the source documents:
const reply = await client.chat.chatQuery({
chatQueryInDTO: { message: "How much did we spend on cleaning in 2020?" },
});
console.log(reply.answer);
console.log("confident:", reply.confident);
console.log("citations:", reply.citations);Chat requires a plan with Document Intelligence enabled — see pricing. Without it the API responds 402/403.
Multi-turn conversations (memory). For a back-and-forth where follow-ups keep context, use a conversation — it threads the server-issued sessionId for you:
const chat = client.conversation();
await chat.send("How much did we spend on cleaning in 2020?");
const follow = await chat.send("And which vendor was most expensive?"); // remembers 2020 / cleaning
console.log(follow.answer, "· session:", chat.sessionId);
await chat.delete(); // end it server-side (or chat.reset() to just forget it locally)A conversation's live context expires after roughly 24h of inactivity; the next send then throws the API's 404 CHAT_SESSION_NOT_FOUND — call chat.reset() and resend to continue in a fresh one. The transcript itself is not lost: it stays available in chat history (below) until your data-retention window — or an explicit purge — removes it. One-shot client.chat.chatQuery({ chatQueryInDTO: { message, sessionId } }) is still available if you'd rather hold the id yourself; every response returns a sessionId.
Chat history
Past conversations are kept as sessions you can list, reread, and purge:
const listing = await client.chat.listChatSessions({ limit: 20 });
for (const session of listing.sessions) {
console.log(session.title, "·", session.turnCount, "turns");
}
const transcript = await client.chat.getChatSession({ sessionId: listing.sessions[0].id });
for (const msg of transcript.messages) {
console.log(`[${msg.role}] ${msg.content}`);
}
await client.chat.purgeChatSession({ sessionId: listing.sessions[0].id });purgeChatSession permanently deletes the transcript and the server-side copy of its content — it cannot be undone. Purged sessions vanish from the list; pass withPurged: true to see their content-free stubs — title cleared, purgedAt/purgeReason set; timestamps, turnCount, and endUserId survive. Transcripts also age out automatically under your account's data-retention setting (each session's purgeAt tells you when). Purging requires an API key or a console sign-in — browser session tokens can list and read history, but never purge.
Session tokens (browser embedding)
To embed search or chat in your own frontend, mint a short-lived session token server-side and hand that to the browser — never the API key:
// Server-side (holds the API key)
const session = await client.sessions.mintRetrievalToken({
sessionTokenInDTO: { endUserId: "user-42", ttlSeconds: 900 },
});
// -> { token, expiresAt, expiresIn, tokenType }
// Browser (token only)
import { GeminaClient } from "@gemina/sdk";
const browserClient = GeminaClient.withSessionToken(session.token);
const results = await browserClient.retrieval.retrievalQuery({
retrievalQueryInDTO: { text: "last month's invoices" },
});For a drop-in chat UI, see @gemina/elements on npm.
Human verification in the browser
@gemina/elements also ships <GeminaVerification>: a drop-in review step that puts the document next to every extracted field, lets a person correct what's wrong, and sends the corrections back to Gemina for accuracy scoring.
Mint the token scoped to the extraction being reviewed — an unscoped token that reaches a browser can read every extraction in your account:
// Server-side (holds the API key)
const session = await client.sessions.mintRetrievalToken({
sessionTokenInDTO: {
extractionIds: [extractionId], // up to 10; pins the token to these
ttlSeconds: 900,
},
});An empty list is rejected rather than quietly minting a tenant-wide token. Your endpoint must check that the requesting end-user is allowed to see those extractions: Gemina enforces the claim, you decide who gets it.
Upload with evaluation: true to give the reviewer per-field confidence scores and the "hide everything already scored high" filter.
Reading the result back is one call, and it's the source of truth — the widget's browser callback is best-effort, this isn't:
const view = await client.documents.getDocumentExtraction({
documentExtractionId: extractionId,
});
view.meta.validated; // has a human verified this yet?
view.values; // what the model extracted
view.verifiedValues; // same shape, corrections merged in — null until verified
view.verifiedDiff; // what the reviewer changedverifiedValues is deliberately the same shape as values, so moving your pipeline from raw to human-verified data is a one-key change. Each verifiedDiff entry is field, pointer, original, verified and a status of corrected, added or removed; the pointer resolves against both payloads, so you can show before and after. All three fields also appear on the results-poll and list surfaces, so a batch job can sweep for meta.validated without fetching extractions one by one.
Verification is one-shot per extraction — a second submission is rejected with 409. To submit a review from your own UI instead of the widget:
const summary = await client.documents.validateDocumentExtraction({
targetDocumentExtractionId: extractionId,
extractionValidationInDTO: { data: correctedValues },
});
// summary.data -> per-field comparison against what was extractedGoing deeper
Full API surface. The convenience layer sits on top of a complete generated client. Every API group is available on the client as a lazy accessor: documents, retrieval, chat, templates, files, fileTag, sessions, subscriptions, billing. For example, list your stored documents:
const view = await client.documents.findDocuments({ limit: 10 });
for (const doc of view.data?.documents ?? []) {
console.log(doc.meta.documentId, doc.meta.filename, doc.meta.createdAt);
}Polling knobs. processDocument accepts timeoutSeconds (default 300), initialIntervalSeconds (default 2, grows ×1.5 per attempt), and maxIntervalSeconds (default 15). Transient poll errors (connection blips, 5xx) are retried automatically on the same schedule — up to 3 in a row — since the document is already submitted. On timeout it throws GeminaTimeoutError carrying the correlationId, so you can resume polling yourself:
import { GeminaTimeoutError } from "@gemina/sdk";
try {
await client.processDocument(file, ["invoice_headers"], { timeoutSeconds: 60 });
} catch (err) {
if (err instanceof GeminaTimeoutError) {
// Still processing — poll later with the correlation ID:
const result = await client.documents.getDocumentProcessingResultByCorrelationId({
correlationId: err.correlationId,
});
}
}Error handling. A terminal failed status throws GeminaProcessingError — its result.errors lists the failure details. Transport/HTTP errors from the generated client (ResponseError, FetchError) pass through unwrapped:
import { GeminaProcessingError, ResponseError } from "@gemina/sdk";
try {
const result = await client.processDocument(file, ["invoice_headers"]);
} catch (err) {
if (err instanceof GeminaProcessingError) {
console.error("processing failed:", err.result.errors);
} else if (err instanceof ResponseError) {
console.error("HTTP error:", err.response.status);
} else {
throw err;
}
}Custom base URL. Point the client at a staging or self-hosted deployment via the second constructor argument:
const staging = new GeminaClient(apiKey, "https://api.staging.gemina.co");Requirements & support
- Node.js >= 18, or any browser with
fetch(the SDK has zero runtime dependencies). - Docs: https://console.gemina.co/docs
- Issues: https://github.com/tommyil/gemina-sdk/issues
- Email: [email protected]
