Minerva API

Quickstart

From an API key to a completed extraction and a delivered webhook.

This walks the whole round trip: create a project, upload and process a document, run an extraction to completion, then register a webhook so the next run tells you when it finishes. Every sample below is the exact file CI executes on each change, so what you copy is what was tested.

You need an account in an organization with API access. The site itself needs no login; creating a key does.

1. Create an API key

Create a key in Settings → API Keys in the product. It is shown once — store it as a secret, never in source control. Every request sends it as a bearer token.

client.ts
// Keys are minted once: in Settings -> API keys, or by an existing admin key
// via POST /api-keys. Either way the plaintext is shown exactly once, so store
// it in your secret manager before you leave the page. Nothing below calls
// that endpoint; the quickstart reads the key from the environment.
export function apiKeyFromEnv(): string {
  const apiKey = process.env.MINERVA_API_KEY
  if (!apiKey) throw new Error('Set MINERVA_API_KEY to an API key from Settings -> API keys')
  return apiKey
}
client.ts
export const BASE_URL = 'https://api.minerva-platform.ai/v1'

// Every request carries the key as a bearer token.
export function authHeaders(apiKey: string, contentType?: string): Record<string, string> {
  const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` }
  if (contentType) headers['Content-Type'] = contentType
  return headers
}

// Every error response is { error: { code, message } }; keep both so a 402
// (out of credits) and a 422 (a document failed to parse) stay distinguishable.
export class MinervaApiError extends Error {
  readonly status: number
  readonly code: string

  constructor(status: number, code: string, message: string) {
    super(`${message} (${status} ${code})`)
    this.name = 'MinervaApiError'
    this.status = status
    this.code = code
  }
}

export async function raiseForStatus(res: Response): Promise<void> {
  if (res.ok) return
  const body = await res.json().catch(() => ({}))
  const error = body.error ?? { code: 'UNKNOWN', message: res.statusText }
  throw new MinervaApiError(res.status, error.code, error.message)
}

export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

A key carries one role. admin can do everything below; an editor key can do everything except manage keys and webhooks. See Permissions.

2. Create a project

A project is the container for documents and their results. Created through the API it auto-scaffolds a model you can edit later. See Extractions.

create-project.ts
// A quick project scaffolds its own model and data group. The id it returns
// is the only handle the later steps need.
export async function createProject(
  apiKey: string,
  title: string,
  description: string
): Promise<string> {
  const res = await fetch(`${BASE_URL}/extractions`, {
    method: 'POST',
    headers: authHeaders(apiKey, 'application/json'),
    body: JSON.stringify({ title, description, dataGroupName: 'Key facts' }),
  })
  await raiseForStatus(res)
  const { data } = await res.json()
  return data.id
}

3. Upload a document

Uploading stores the file and nothing more — the document comes back uploaded, not yet extractable.

upload-document.ts
// Upload only stores the file: the response says "uploaded" and parsing is a
// separate, billable step. Sending projectId is what attaches the document to
// the project. For a file on disk, `openAsBlob` from node:fs gives you the Blob.
export async function uploadDocument(
  apiKey: string,
  projectId: string,
  file: Blob,
  fileName: string
): Promise<string> {
  const form = new FormData()
  form.append('file', file, fileName)
  form.append('projectId', projectId)
  const res = await fetch(`${BASE_URL}/documents`, {
    method: 'POST',
    headers: authHeaders(apiKey),
    body: form,
  })
  await raiseForStatus(res)
  const { data } = await res.json()
  return data.documentId
}

4. Process it

Processing parses the document. It is a separate call so you can batch up to 50 documents and decide when to spend the time. See Documents.

process-documents.ts
// One call parses up to 50 uploaded documents. The jobId covers the whole
// batch and is pollable at GET /jobs/{jobId}; the document.processed webhook
// carries the same id. It is null only when none of the ids were found.
export async function processDocuments(
  apiKey: string,
  documentIds: string[]
): Promise<string | null> {
  const res = await fetch(`${BASE_URL}/documents/process`, {
    method: 'POST',
    headers: authHeaders(apiKey, 'application/json'),
    body: JSON.stringify({ documentIds }),
  })
  await raiseForStatus(res)
  const { data } = await res.json()
  return data.jobId
}

5. Run an extraction

An extraction session runs the project's schema over its documents. If a document is still processing, the call answers 202 and does not start — the sample re-posts until it does.

start-extraction.ts
export interface ExtractionSchema {
  promptText: string
  fields: Array<{ name: string; fieldType: string; description?: string }>
}

// Documents must finish parsing before a run can start. Until they do, the API
// answers 202 { status: "pending", documentsReady, documentsTotal } and starts
// nothing, so re-POST on an interval. A 201 means the run is under way; its
// jobId is the handle for the next step.
export async function startExtraction(
  apiKey: string,
  projectId: string,
  schema: ExtractionSchema,
  { intervalMs = 5000, maxAttempts = 120 } = {}
): Promise<{ sessionId: string; jobId: string }> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = await fetch(`${BASE_URL}/extraction-sessions`, {
      method: 'POST',
      headers: authHeaders(apiKey, 'application/json'),
      body: JSON.stringify({ projectId, ...schema }),
    })
    if (res.status !== 202) {
      await raiseForStatus(res)
      const { data } = await res.json()
      return { sessionId: data.extractionSession.id, jobId: data.jobId }
    }
    await sleep(intervalMs)
  }
  throw new Error(`Documents were still parsing after ${maxAttempts} attempts`)
}

6. Wait for the result

Every asynchronous call returns a job handle, and GET /jobs/{jobId} is the one place to read its status. See Jobs.

poll-job.ts
export type JobStatus =
  'queued' | 'in_progress' | 'completed' | 'completed_with_failures' | 'failed' | 'cancelled'

export interface Job {
  id: string
  type: 'preprocess' | 'extraction'
  status: JobStatus
  progress?: { done: number; total: number; failed: number }
  result?: Record<string, unknown>
  error?: string
}

const TERMINAL: JobStatus[] = ['completed', 'completed_with_failures', 'failed', 'cancelled']

// GET /jobs/{jobId} is the one status surface for every async job. Poll it
// until the status is terminal, then read the results exactly once.
// "completed_with_failures" means the run finished but some documents did not;
// check progress.failed before trusting the output.
export async function waitForJob(
  apiKey: string,
  jobId: string,
  { intervalMs = 5000, maxAttempts = 120 } = {}
): Promise<Job> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = await fetch(`${BASE_URL}/jobs/${encodeURIComponent(jobId)}`, {
      headers: authHeaders(apiKey),
    })
    await raiseForStatus(res)
    const { data } = await res.json()
    if (TERMINAL.includes(data.status)) return data
    await sleep(intervalMs)
  }
  throw new Error(`Job was still running after ${maxAttempts} polls`)
}

7. Get notified instead of polling

Register a webhook endpoint and store the signing secret it returns — shown once. Then send a test event: a signed webhook.test delivery to that endpoint alone, which proves your receiver verifies and answers 2xx before a real run depends on it. See Webhooks for verification code.

register-webhook.ts
// Instead of polling, let Minerva call you. The signing secret comes back
// exactly once; store it next to the endpoint id, because every delivery is
// signed with it (see Webhooks for the verification code).
export async function registerWebhook(
  apiKey: string,
  url: string,
  subscribedEventTypes: string[]
): Promise<{ endpointId: string; secret: string }> {
  const res = await fetch(`${BASE_URL}/webhook-endpoints`, {
    method: 'POST',
    headers: authHeaders(apiKey, 'application/json'),
    body: JSON.stringify({ url, name: 'Quickstart', subscribedEventTypes }),
  })
  await raiseForStatus(res)
  const { data } = await res.json()
  return { endpointId: data.endpoint.id, secret: data.secret }
}
send-test-event.ts
// Delivery is asynchronous: the response only says the event is enqueued. The
// eventId is how you find it in the endpoint's delivery log if nothing arrives.
export async function sendTestEvent(apiKey: string, endpointId: string): Promise<string> {
  const res = await fetch(`${BASE_URL}/webhook-endpoints/${encodeURIComponent(endpointId)}/test`, {
    method: 'POST',
    headers: authHeaders(apiKey),
  })
  await raiseForStatus(res)
  const { data } = await res.json()
  return data.eventId
}

The whole thing

run.ts
export interface QuickstartResult {
  projectId: string
  documentId: string
  job: Job
  endpointId: string
  secret: string
  eventId: string
}

// The whole round trip in order: a project, a document, one parse, one
// extraction run polled to completion, then a webhook endpoint so the next
// run notifies you instead. Each step is a function above; this is how they fit.
export async function runQuickstart(
  apiKey: string,
  file: Blob,
  fileName: string,
  webhookUrl: string
): Promise<QuickstartResult> {
  const projectId = await createProject(apiKey, 'Fund factsheets', 'Q3 batch')
  const documentId = await uploadDocument(apiKey, projectId, file, fileName)
  await processDocuments(apiKey, [documentId])

  const { jobId } = await startExtraction(apiKey, projectId, {
    promptText: 'Extract the fund factsheet key facts.',
    fields: [
      { name: 'fund_name', fieldType: 'string' },
      { name: 'isin', fieldType: 'string' },
    ],
  })
  const job = await waitForJob(apiKey, jobId)

  const { endpointId, secret } = await registerWebhook(apiKey, webhookUrl, [
    'extraction.completed',
    'extraction.failed',
  ])
  const eventId = await sendTestEvent(apiKey, endpointId)

  return { projectId, documentId, job, endpointId, secret, eventId }
}

Next: read results with GET /extraction-sessions/{id}/results, correct them on the validation surface, and promote them to the data mart. See Validation and the data mart.

On this page