Getting Started#

Extract structured data from documents programmatically. Responses are structured JSON with extracted fields, coordinates, and confidence scores.

There are four ways in. An official SDK is the recommended path — install it and process a document in a single call, then search and chat over your results.

Raw HTTP calls the same REST API from any language. A drop-in chat UI puts the whole experience inside your own app. And an AI agent can connect over MCP — see the MCP server reference.

Base URLhttps://api.gemina.co
AuthenticationX-API-Key: your-api-key

Official SDKs#

Recommended

Install one package, then process, search & chat in native code — Document Intelligence included.The fastest path for most integrations.

Install#

Add the official Gemina SDK to your project:

npm i @gemina/sdk

Process a document in one call#

Authenticate with your API key and extract structured data — the SDK submits the document and polls for the result for you, so there's no upload-then-poll loop to write:

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);

Add extraction types to a stored document#

Run more extraction types on a document Gemina already stores — no re-upload. The convenience helper submits the request, polls to a terminal result, and returns the whole document with its existing and new extractions. The generated submit-only API accepts anAddExtractionsInDTO, returns aDocumentAddExtractionsOutDTO, and exposes the poll correlation ID when you want to control polling:

// Reuse a documentId from processDocument, FileTag, or a document lookup.
const updated = await client.addExtractionsAndWait(
  documentId,
  ["invoice_line_items"],
  { includeCoordinates: true },
);

console.log(updated.data?.extractions);

// Submit-only generated surface (poll pollCorrelationId yourself):
// client.documents.addDocumentExtractions({
//   documentId,
//   addExtractionsInDTO: { extractionTypes: ["invoice_line_items"] },
// }) -> Promise<DocumentAddExtractionsOutDTO>

Each new type is billed like an upload and must allow the stored document's page count. A type already present is rejected; a purged source must be uploaded again. Adding a type also resets the shared retention date for the whole document to the account's current retention setting.

Query everything you've processed with structured filters, semantic search, or both — and compute exact, database-backed aggregations without re-reading a document:

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})`);

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);
}

Chat with your documents#

Ask in natural language and get grounded answers with citations. Follow-up questions keep their conversation context. A conversation's live context expires after roughly 24h of inactivity — reset and resend to continue in a fresh one. The transcript itself is not lost; it stays readable in chat history below:

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);

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)

Chat history#

Past conversations are kept as sessions you can list, reread, and purge. Each one carries an auto-generated title, its turn count, the end-user it was scoped to, and the date your retention window will delete it:

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 });

Purging permanently deletes the transcript and the server-side copy of its content — it cannot be undone. Purged sessions vanish from the list; ask for purged records to see their content-free stubs (title cleared, purge timestamp and reason set; timestamps, turn count, and end-user id survive). Transcripts also age out automatically under your account's data-retention setting.

Browser-safe session tokens#

Exchange your API key server-side for a short-lived, scoped token so a browser can search and chat without ever seeing the key. Tokens are read-and-chat scoped: they can query, aggregate, chat, read history, and end a conversation — but they can never purge one, which takes an API key or a console sign-in:

// 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" },
});

Human verification#

Put a person in front of an extraction before it reaches your workflow. Mint a token scoped to that one extraction, render the drop-in reviewer (see Embed in your app), and read the result back afterwards. The corrections arrive as verifiedValues — the same shape as values, so switching your pipeline to human-verified data is a one-name change — alongside verifiedDiff, the list of what changed:

// Server-side. Authorize the end-user against this id FIRST — Gemina enforces
// the claim in the signed token, you decide who gets it.
const session = await client.sessions.mintRetrievalToken({
  sessionTokenInDTO: {
    extractionIds: [extractionId], // pins the token — up to 10
    ttlSeconds: 900,
  },
});
// Ship session.token to the browser and render <GeminaVerification />.

// The source of truth. The widget's browser callback is best-effort.
const view = await client.documents.getDocumentExtraction({
  documentExtractionId: extractionId,
});

if (view.meta.validated) {
  for (const change of view.verifiedDiff ?? []) {
    // status: "corrected" | "added" | "removed"
    console.log(change.status, change.field, change.original, "->", change.verified);
  }
  useThis(view.verifiedValues); // same shape as values, corrections merged in
} else {
  useThis(view.values); // nobody has reviewed it yet
}

// Only if you build your own review UI. One-shot: a second call is a 409.
const summary = await client.documents.validateDocumentExtraction({
  targetDocumentExtractionId: extractionId,
  extractionValidationInDTO: { data: correctedValues },
});

Verification is one-shot per extraction; a second submission is rejected. Reading the extraction back is the source of truth — the widget's browser callback is best-effort, so if the network drops the response the verification is still recorded but the callback never fires.

Error handling#

Typed errors separate a terminal processing failure from a still-processing timeout you can resume:

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;
  }
}

Raw HTTP API#

No SDK, or on an unsupported language? Every endpoint is a plain REST call. Search, aggregate, chat & chat history over your processed documents is documented right here in curl; for the upload-and-extract quick start in your language, open the picker below. Response shapes are in Response Fields; what a failure looks like is in Errors.

With Document Intelligence (opt-in per account), every successful extraction is indexed into a searchable layer — query your whole collection with exact filters, natural language, or both, and compute exact totals without re-reading a single document. Every SDK wraps this — see the “Search & analyze”, “Chat”, “Chat history”, and “Session tokens” tabs above; the requests below are the underlying REST API.

One endpoint, three modes: structured (exact filters), semantic (meaning), and hybrid (both, fused — the best default for free text):

# Hybrid search: keywords + meaning (best default for free text)
curl -X POST https://api.gemina.co/api/v1/retrieval/query \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "hybrid",
    "text": "the credit note about the server outage",
    "topK": 10,
    "filters": { "issueDateFrom": "2026-01-01" }
  }'

Exact aggregations#

Sums, averages and counts are computed in the database — never estimated by an AI model. Amounts in different currencies are never mixed into one total:

# Exact totals per vendor, computed in the database (never estimated by AI)
curl -X POST https://api.gemina.co/api/v1/retrieval/aggregate \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "metrics": [{ "op": "sum", "field": "total_amount" }, { "op": "count" }],
    "groupBy": ["vendor_name", "currency"],
    "filters": { "issueDateFrom": "2026-01-01", "issueDateTo": "2026-03-31" }
  }'

Chat with your documents#

Ask in natural language; Gemina routes the question to the right engine and answers grounded in your documents, with citations:

# Grounded natural-language Q&A over your document collection
curl -X POST https://api.gemina.co/api/v1/chat/query \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "message": "How much did we spend at Acme Catering this quarter?" }'
{
  "answer": "You spent a total of 18,450 ILS at Acme Catering this quarter, across 12 invoices.",
  "citations": ["5f2b7c1e-2b6b-4f0e-9a1c-4d3f2e1a0b9c"],
  "intent": "aggregation",
  "confident": true
}

Chat history#

Past conversations are kept as sessions you can list, reread, and purge. Each session carries a short auto-generated title, its turnCount, its endUserId (null = a tenant-wide chat over all documents), and purgeAt — when your data-retention window will delete it (null = kept always):

# List past sessions (newest activity first by default)
curl https://api.gemina.co/api/v1/chat/sessions?limit=20 \
  -H "X-API-Key: your-api-key"
# -> { "count": 3, "sessions": [ { "id": "c1a4e9d2-...", "title": "Cleaning spend 2020",
#      "turnCount": 4, "lastActivityAt": "...", "purgeAt": "...", "purgedAt": null, ... } ] }

# Read one session's transcript (paged with skip/limit)
curl https://api.gemina.co/api/v1/chat/sessions/c1a4e9d2-6b3f-4a71-9e02-... \
  -H "X-API-Key: your-api-key"
# -> { "session": {...}, "count": 8, "messages": [ { "role": "user", "content": "...",
#      "turnIndex": 0, ... }, { "role": "assistant", "content": "...", "confident": true,
#      "citations": [...], ... } ] }

# Permanently purge a session — transcript and server-side content, 204 on success
curl -X DELETE https://api.gemina.co/api/v1/chat/sessions/c1a4e9d2-6b3f-4a71-9e02-.../purge \
  -H "X-API-Key: your-api-key"

Purge is not “New chat”. DELETE /chat/sessions/{sessionId} only ends the live context — the transcript stays in history; DELETE /chat/sessions/{sessionId}/purge permanently deletes the transcript and the server-side copy of its content, and cannot be undone. Purged sessions vanish from the list; pass with_purged=true to see their content-free stubs (title cleared, purgedAt/purgeReason set). Session tokens can list and read history within their pinned scope, but can never purge — that takes an API key, or a console sign-in on the /purge/user variant.

For end-user-facing apps, exchange your API key server-side for a short-lived, scoped session token (POST /api/v1/sessions/token) — the browser can then query and chat safely without ever seeing your API key. The same capabilities are available to AI agents as MCP tools (query_documents, aggregate_documents, index_document).

Errors#

An error uses the same envelope as a success: status is failed, data is null, and the machine-readable detail is one entry in errors. Below is a real response — an unrecognized API key:

{
  "status": "failed",
  "meta": { "externalId": null, "correlationId": null, "userId": null },
  "data": null,
  "errors": [
    {
      "error_code": "ACCESS_DENIED_ERROR",
      "description": "Access Denied: API Key not found"
    }
  ],
  "createdAt": null,
  "createdAtTimestamp": null,
  "servedAt": "2026-08-11T17:01:07.138498",
  "servedAtTimestamp": 1786467667.138507
}

Branch on errors[0].error_code, not on description: the code is stable, the description is written for humans and changes freely. The code keeps its snake_case key even though the envelope around it is camelCase. On the error path meta is null, or carries only the identifiers your request supplied — the richer success meta is not available.

  • 401 UNAUTHORIZED_ERROR — No credential was sent, or a session token is invalid or expired. Mint a new token.
  • 403 ACCESS_DENIED_ERROR — The API key is unknown, revoked or expired, or the account is inactive.
  • 403 DOCUMENT_INTELLIGENCE_NOT_IN_PLAN — Search, aggregation and chat are not enabled on this plan.
  • 404 CHAT_SESSION_NOT_FOUND — The session is unknown, expired after 24h idle, or belongs to someone else. Retry without a sessionId to start a new conversation.
  • 413 REQUEST_ENTITY_TOO_LARGE_ERROR — The file is over 10 MB.
  • 415 UNSUPPORTED_MEDIA_TYPE_ERROR — The format is not one we accept.
  • 422 UNPROCESSABLE_ERROR — The request is malformed: an incompatible option combination, custom_template without a template_id, a file under 1 KB, a semantic query with no text, or a question chat could not process.
  • 422 DOCUMENT_MAX_PAGES_EXCEEDED_ERROR — The PDF is longer than the page limit for the extraction types you asked for.
  • 429 — You hit a rate limit or ran out of credit, and error_code says which. Back off on the Retry-After response header when one is sent. Burst limits (RETRIEVAL_RATE_LIMIT_EXCEEDED, FILETAG_RATE_LIMIT_EXCEEDED) clear in a second; quota and credit exhaustion (QUOTA_EXCEEDED, SPEND_LIMIT_EXCEEDED, CREDIT_EXHAUSTED, INSUFFICIENT_CREDITS, CHAT_QUOTA_EXCEEDED, FILETAG_QUOTA_EXHAUSTED) will not clear by retrying.
  • 502 BAD_GATEWAY_ERROR — The chat backend was unreachable. Retry shortly.

Authentication fails in two different ways. Sending no credential is a 401; sending an API key we don't recognize is a 403, not a 401. Only the session-token path answers 401 for a bad credential — so a client that refreshes on 401 alone will loop forever against a revoked API key.

Size & page limits#

Documents are 1 KB to 10 MB. The page ceiling is per extraction type — ask for several types in one request and the most permissive ceiling applies:

  • custom_template — 30 pages
  • ocr — 15 pages
  • invoice_headers, invoice_line_items — 10 pages
  • document_details_hebrew, document_line_items_hebrew — 8 pages

Format, size and page checks all run before processing starts, so a rejected upload costs no credits. Single images aren't paged, so they're measured instead: every upload is normalized to 1240 px wide and each 2420 px of height counts as one page equivalent. A long stitched screenshot can exceed the ceiling that way — unlike a PDF it's caught during processing, so it comes back as a failed extraction rather than a rejected upload.

Embed in your app#

Ship two experiences without building either. @gemina/elements gives you a drop-in React chat component (<GeminaChat>) and a human review-and-correct step (<GeminaVerification>), both behind a security-hardened token manager — citations, confidence handling, and RTL support included, without ever exposing your API key to the browser.

Install#

npm i @gemina/elements @gemina/sdk react

Mint session tokens (Next.js App Router)#

The API key stays on your server. This route mints a short-lived, scoped token for the browser — drop it in as-is:

// Next.js (App Router) — app/api/gemina-session/route.ts
import { NextResponse } from "next/server";
import { GeminaClient } from "@gemina/sdk";

const gemina = new GeminaClient(process.env.GEMINA_API_KEY!);

export async function POST(request: Request) {
  const user = await requireYourAppAuth(request); // your session check
  const minted = await gemina.sessions.mintRetrievalToken({
    sessionTokenInDTO: { endUserId: user.id, ttlSeconds: 900 },
  });
  return NextResponse.json({ token: minted.token, expiresIn: minted.expiresIn });
}

Render the chat#

Point the token manager at that route and render the component — conversation memory and the session token are managed for you. Give it your own voice with title (header text — your brand or assistant persona; tint the bar with --gemina-chat-header-bg) and intro (what the assistant can see, shown in the empty conversation until the first message):

import { GeminaTokenManager } from "@gemina/elements/token-manager";

const tokenManager = new GeminaTokenManager({
  // Points at YOUR backend — see the mint endpoint below.
  fetchToken: async () => {
    const res = await fetch("/api/gemina-session", { method: "POST" });
    if (!res.ok) throw new Error("Failed to mint Gemina session token");
    return res.json(); // { token, expiresIn }
  },
  // Optional: seconds before expiry to refresh (default 60).
  refreshSkewSeconds: 60,
});

import { GeminaChat } from "@gemina/elements";

<GeminaChat
  tokenManager={tokenManager}
  // Header text — your brand or assistant persona. When set, the header
  // is always visible; tint the bar with --gemina-chat-header-bg.
  title="Acme Invoices"
  // Centered in the empty conversation until the first message. Newlines
  // split it into separately spaced paragraphs.
  intro="Answers come from your indexed document data."
  onCitationClick={(documentId) => openDocumentViewer(documentId)}
/>;

Mint an extraction-scoped token#

Verification gets its own mint route. The token is pinned to a single extraction, so a curious end-user with developer tools can't read anything else in your account. Gemina enforces the pin; your endpoint decides who is allowed to ask for it — the extraction id arrives from the browser, so authorize it against your own user before minting:

// Next.js (App Router) — app/api/gemina-verify-session/route.ts
import { NextResponse } from "next/server";
import { GeminaClient } from "@gemina/sdk";

const gemina = new GeminaClient(process.env.GEMINA_API_KEY!);

export async function POST(request: Request) {
  const user = await requireYourAppAuth(request); // your session check
  const { extractionId } = await request.json();

  // YOU decide who may see this extraction. Gemina enforces the claim in the
  // signed token; it cannot know whether this user is entitled to that id.
  if (!(await userMayVerify(user, extractionId))) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const minted = await gemina.sessions.mintRetrievalToken({
    sessionTokenInDTO: {
      extractionIds: [extractionId], // pins the token — up to 10
      ttlSeconds: 900,
    },
  });
  return NextResponse.json({ token: minted.token, expiresIn: minted.expiresIn });
}

Render the reviewer#

The document sits next to every extracted field as an editable input. The reviewer corrects what's wrong and submits once. Run the extraction with evaluation enabled and each field also carries a confidence score, with a switch that hides everything already scored high — on a 169-row invoice that is 169 rows down to 7:

import { useMemo } from "react";
import { GeminaVerification } from "@gemina/elements/verification";
import { GeminaTokenManager } from "@gemina/elements/token-manager";

function VerifyStep({ extractionId }: { extractionId: string }) {
  // Stable per extraction — never construct the manager inline in JSX.
  const tokenManager = useMemo(
    () =>
      new GeminaTokenManager({
        fetchToken: async () => {
          const res = await fetch("/api/gemina-verify-session", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ extractionId }),
          });
          if (!res.ok) throw new Error("Failed to mint Gemina session token");
          return res.json(); // { token, expiresIn }
        },
      }),
    [extractionId]
  );

  return (
    <GeminaVerification
      extractionId={extractionId}
      tokenManager={tokenManager}
      // Fires the moment the reviewer submits — good for closing the step.
      // Best-effort: if the network drops the response the verification is
      // still recorded, so read the extraction back for anything that matters.
      onComplete={({ correctedValues, summary }) => {
        closeReviewStep();
      }}
      onError={(reason) => reportToYourMonitoring(reason)}
    />
  );
}

Then read the extraction back for the corrections — verifiedValues and verifiedDiff, shown per language under Human verification in the SDK guides. Submission is final: an extraction can be verified once.

Response Fields#

The response includes structured extraction values keyed by extraction type. Unpopulated fields are null.

invoice_headers fields#

Each header field uses an envelope shape: { value, coordinates, confidence }. When the invoice doesn't print the value, the whole envelope is null — a defensive client can safely guard with if (response.discountAmount) { … }.

  • grossSubtotalAmount — Sum of line items before any header-level discount or rounding.
  • discountAmount — Header-level discount in the document's currency. Sign is verbatim from the invoice: some templates print positives (229.91), others negatives (-229.91) or parenthesized values. Clients that subtract on their side must handle both.
  • discountPercentage — Header-level discount as a percentage (e.g. 3.0 means 3%). Only populated when the invoice prints it.
  • roundingAmount — Rounding adjustment (e.g. "round off", agorot rounding). Signed as printed; magnitude typically < 1.0 in document currency.
  • subtotalAmount — The tax base: the value the invoice's VAT/tax percentage is calculated against, after any header-level discount and rounding, before tax.

The reconciliation identity (modulo printing artifacts):

subtotalAmount + Σ taxes[].amount ≈ totalAmount

invoice_line_items fields#

Each item in the line_items array is a flat object (no envelope). Unpopulated fields are null.

  • listPrice — Gross/catalog unit price before any line-level discount. Populated only when the invoice prints a dedicated "list price" / "catalog price" / "MSRP" column. Documentation-only — do not use it in line-total math.
  • unitPrice — NET price per unit, after any line-level discount. The lineTotal math uses this value, so the per-line discountAmount and discountPercentage should not be subtracted again. For the gross/catalog price, use listPrice when populated.
  • packagingAmount — Additive packaging charge (crate fee, palletizing fee). Positive. Contributes to lineTotal.
  • depositAmount — Additive deposit/refund charge (bottle deposit, container deposit). Positive. Contributes to lineTotal.
  • unitsPerPackage — Structural pack size: whole-number count of units per package (e.g. 24 cans per case). Informational; never a volume or weight.
  • packageQuantity — Order quantity in package units; may be fractional (e.g. 2.1 cartons). Informational. Most invoices print only one of unitsPerPackage or packageQuantity — both can be null independently.

Line-total math contract:

lineTotal ≈ quantity × unitPrice
          + taxAmount        (if present)
          + packagingAmount  (if present)
          + depositAmount    (if present)

When both pack-size fields are present, quantity ≈ packageQuantity × unitsPerPackage — the relationship is approximate, not enforced.

API Reference#

Official SDKs

  • @gemina/sdk - TypeScript / Node.js (npm)
  • gemina - Python (PyPI)
  • Gemina.Sdk - C# (NuGet)
  • co.gemina:gemina-sdk - Java (Maven)
  • gemina/sdk - PHP (Packagist)
  • @gemina/elements - React chat UI (npm)

Extraction Types

  • invoice_headers - Invoice header fields (field list →)
  • invoice_line_items - Line item details (field list →)
  • ocr - Full text extraction
  • document_details_hebrew - Hebrew documents

Model Types

  • velox - Fast processing
  • praetorian - Balanced accuracy
  • invictus - Highest accuracy

Endpoints

  • POST /api/v1/documents/uploads
  • POST /api/v1/documents/uploads/web
  • GET /api/v1/documents/{id}
  • GET /api/v1/documents/results/{id}
  • GET /api/v1/documents/extractions/{id}
  • PUT /api/v1/documents/extractions/{id}/feedback

Response Statuses

  • success - Extraction completed
  • pending - Job queued
  • in_process - Processing
  • failed - Error occurred

Document Intelligence

  • POST /api/v1/retrieval/query
  • POST /api/v1/retrieval/aggregate
  • POST /api/v1/chat/query
  • DELETE /api/v1/chat/sessions/{id}
  • GET /api/v1/chat/sessions
  • GET /api/v1/chat/sessions/{id}
  • DELETE /api/v1/chat/sessions/{id}/purge
  • POST /api/v1/sessions/token

Errors

  • Branch on errors[0].error_code
  • 401 - No credential / bad session token
  • 403 - Bad or revoked API key
  • 422 - Malformed request, or over the page limit
  • 429 - Back off on Retry-After
  • Error reference

MCP server

FileTag API

Ready to Get Started?

Sign up for a free trial and start extracting data from your documents today.