# CLI (/guide/cli) > For the complete documentation index, see [llms.txt](/llms.txt). The bem CLI gives you a `bem` command for every resource in the API — functions, workflows, calls, outputs, errors, and schema inference. It's the fastest way to script ad-hoc work, drop into CI, or test an API change without writing SDK code. Installation [#installation] ```bash brew install bem-team/tools/bem ``` Requires [Go](https://go.dev/doc/install) 1.22 or later. ```bash go install github.com/bem-team/bem-cli/cmd/bem@latest ``` The binary lands in `$(go env GOPATH)/bin` (default `$HOME/go/bin`). If `bem` isn't on your `$PATH` after install, add the Go bin directory to your shell profile: ```bash export PATH="$PATH:$(go env GOPATH)/bin" ``` Clone the repo and run the bundled wrapper: ```bash git clone https://github.com/bem-team/bem-cli.git cd bem-cli ./scripts/run --help ``` Verify the install: ```bash bem --version ``` Authentication [#authentication] The CLI reads `BEM_API_KEY` from the environment by default. Generate a key from **Settings → API Keys** in the [bem dashboard](https://app.bem.ai), then export it: ```bash export BEM_API_KEY='your-api-key-here' ``` Persist the line in `~/.zshrc` or `~/.bashrc` to keep it across shell sessions. You can also pass `--api-key` per command — useful for CI runners and scripts that target multiple environments: ```bash bem functions list --api-key "$BEM_PROD_KEY" ``` The flag overrides the environment variable when both are set. Functions [#functions] Manage functions — extract, classify, split, join, enrich, payload\_shaping, and send. ```bash bem functions list bem functions retrieve --function-name invoice-extractor bem functions create \ --function-name invoice-extractor \ --type extract \ --display-name "Invoice Extractor" \ --output-schema-name Invoice \ --output-schema '{"type":"object","required":["invoiceNumber","totalAmount"],"properties":{"invoiceNumber":{"type":"string"},"totalAmount":{"type":"number"}}}' bem functions update --function-name invoice-extractor --display-name "Invoice Extractor v2" bem functions delete --function-name invoice-extractor ``` Copy a function (creates a new function with a fresh name from an existing one): ```bash bem functions:copy create --function-name invoice-extractor --new-function-name invoice-extractor-staging ``` Function Versions [#function-versions] Every change to a function creates a new version. List or retrieve them with `functions:versions`: ```bash bem functions:versions list --function-name invoice-extractor bem functions:versions retrieve --function-name invoice-extractor --version-num 3 ``` Workflows [#workflows] Workflows orchestrate functions into a DAG and are the entry point for calls. ```bash bem workflows list bem workflows retrieve --workflow-name invoice-intake bem workflows create \ --workflow-name invoice-intake \ --display-name "Invoice Intake" \ --main-node-name extract \ --node '{"name":"extract","function":{"name":"invoice-extractor"}}' bem workflows update --workflow-name invoice-intake --display-name "Invoice Intake v2" bem workflows copy --workflow-name invoice-intake --new-workflow-name invoice-intake-staging bem workflows delete --workflow-name invoice-intake ``` Invoking a Workflow [#invoking-a-workflow] `bem workflows call` runs a workflow against a file or batch of files. Use `@path/to/file` inside JSON values to attach files inline: ```bash bem workflows call \ --workflow-name invoice-intake \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' \ --wait ``` `--wait` blocks until the call completes (up to 30 seconds) and returns the finished call object. Omit it to fire-and-forget; the call ID comes back immediately and you can poll with `bem calls retrieve`. Workflow Versions [#workflow-versions] ```bash bem workflows:versions list --workflow-name invoice-intake bem workflows:versions retrieve --workflow-name invoice-intake --version-num 2 ``` Calls [#calls] A **call** is one execution of a workflow. Use `calls` to inspect history and check status: ```bash bem calls list --workflow-name invoice-intake --limit 20 bem calls retrieve --call-id call_abc123 ``` Filter by status, reference ID, or date range — see `bem calls list --help` for the full set of flags. Outputs [#outputs] `outputs` returns the terminal non-error events emitted by completed function executions — the structured payloads your downstream systems consume. ```bash bem outputs list --function-name invoice-extractor --limit 50 bem outputs retrieve --output-id evt_abc123 ``` Errors [#errors] `errors` mirrors `outputs` for terminal error events. Useful when a webhook subscription failed or you want to triage call failures: ```bash bem errors list --workflow-name invoice-intake --limit 50 bem errors retrieve --error-id err_abc123 ``` Schema Inference [#schema-inference] `infer-schema` analyzes a sample file and returns a JSON Schema you can paste straight into a function's `output_schema`. Handy for bootstrapping a new extractor: ```bash bem infer-schema create --file @sample-invoice.pdf ``` Passing files as arguments [#passing-files-as-arguments] Anywhere a command takes a file value, prefix the path with `@` to read it inline: ```bash bem workflows call \ --workflow-name invoice-intake \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' ``` The CLI sniffs the file type and sends plain text as a string or binary as base64 automatically. Override the encoding when you need to: ```bash # Force base64 encoding bem --arg @data://payload.bin # Force string encoding bem --arg @file://notes.txt ``` To pass a literal `@` at the start of a value (e.g. an email handle), escape it with a backslash: ```bash bem --user '\@alice' ``` Global options [#global-options] These flags work on every command: | Flag | Description | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `--api-key ` | API key, overrides `$BEM_API_KEY`. | | `--base-url ` | Point at a non-default API host. | | `--format ` | Output format. Defaults to `auto`. | | `--format-error ` | Error output format. | | `--transform ` | Reshape successful output with a [GJSON](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) expression. | | `--transform-error ` | Reshape error output with a GJSON expression. | | `--debug` | Verbose logging including HTTP request and response details. | | `--help`, `-h` | Show command-specific help. | | `--version`, `-v` | Print the CLI version. | Output formatting [#output-formatting] `--format auto` (the default) prints a human-friendly table when stdout is a TTY and switches to JSON when piped, so the same command works interactively and in scripts: ```bash # Pretty-printed table bem functions list # Raw JSON for jq bem functions list | jq '.data[] | .functionName' ``` Pin a format explicitly when you need consistent output: ```bash bem functions list --format json bem functions list --format yaml bem functions list --format jsonl # one JSON object per line, ideal for streaming ``` `--transform` lets you reshape responses without piping through `jq`: ```bash bem functions list --transform 'data.#.functionName' ``` CI/CD [#cicd] For pipelines, set `BEM_API_KEY` as a secret and call commands directly. The CLI exits non-zero on API errors, so the build will fail when something goes wrong: ```yaml # GitHub Actions example - name: Promote workflow env: BEM_API_KEY: ${{ secrets.BEM_API_KEY }} run: | bem workflows retrieve --workflow-name invoice-intake --format json > current.json bem workflows update --workflow-name invoice-intake --display-name "Invoice Intake $GITHUB_SHA" ``` Use `--format json` (or `jsonl`) in CI to keep output stable across CLI versions. Configuration [#configuration] | Source | Purpose | | ---------------------- | ---------------------------------------------------------------------------- | | `BEM_API_KEY` | Auth. Required unless `--api-key` is passed on every call. | | `--base-url` | Optional API host override (mostly for staging or self-hosted environments). | | `$(go env GOPATH)/bin` | Where the binary lives when installed via `go install`. | The CLI is stateless — it doesn't write a config file or cache credentials, so machine-to-machine use is just `BEM_API_KEY=...` plus the command. Reference [#reference] Source, releases, and issue tracker. Side-by-side examples covering CLI, SDKs, and `curl`. How functions, workflows, and calls fit together. Hand the same surface to an agent instead of running it yourself. # Entity Curation (/guide/entity-curation) > For the complete documentation index, see [llms.txt](/llms.txt). bem builds an entity memory automatically as it parses documents — the nodes and edges you read through the [Knowledge Graph API](/guide/knowledge-graph), shaped by the types and synonyms you define in the [Customer Ontology](/guide/ontology). **Curation** is the human-in-the-loop layer over that memory: a reviewer works through the entities Parse extracted, approves the ones that are right, rejects the noise, and fixes types and synonyms along the way. ``` PATCH /v3/entities/{id} POST /v3/entities/bulk-validate x-api-key: ``` Concepts [#concepts] The entity lifecycle [#the-entity-lifecycle] Every entity carries a curation `status`. It begins **pre-terminal** and a reviewer moves it to a **terminal** state: | `status` | Meaning | | ----------- | ------------------------------------------------------------------ | | `extracted` | bem inferred the entity while parsing a document. Awaiting review. | | `proposed` | Queued for a reviewer's attention. Awaiting review. | | `approved` | A reviewer confirmed the entity. Terminal. | | `rejected` | A reviewer discarded the entity. Terminal. | Approving or rejecting is only allowed **from** `extracted` or `proposed`. Any other transition — re-approving a terminal entity, for example — is rejected with **`409`**. Once an entity is validated, `validatedAt` and `validatedByUserID` record who closed it out and when. Reviewers are a dashboard concept, not an API-key one [#reviewers-are-a-dashboard-concept-not-an-api-key-one] Curation is scoped by **entity type**, and someone with dashboard access can assign reviewers per type. That assignment, and the lookup of "every type a given user reviews," both live on the session-authenticated dashboard surface — `/v3/entity-types/{typeID}/reviewers` and `/v3/users/{userID}/reviewer-assignments` accept a dashboard session (JWT) only, never an `x-api-key`. An API-key integration can't manage or read reviewer assignments; if you need "who reviews what" in your own system, track it yourself. There is currently no API-key-reachable way to list entities awaiting review. The read surface this page used to document (`GET /v3/review-queue`) doesn't exist on any surface — not the API, not the dashboard — so it isn't a scoping issue you can work around with a different auth mode. If your integration needs to discover pending entities, you need your own bookkeeping (for example, tracking entity IDs off webhooks and other events you already receive) until this gap closes. Flagging it rather than guessing at a substitute. Alias resolution [#alias-resolution] Curation endpoints honor merges. If you hold an entity id that was later merged away, `PATCH /v3/entities/{id}` resolves it to the surviving canonical entity and operates on that — you never act on a dead id by accident. Curating entities [#curating-entities] Approve, reject, or correct one entity [#approve-reject-or-correct-one-entity] `PATCH /v3/entities/{id}` updates a single entity. Every field is optional, but **at least one** must be present. | Field | Notes | | ------------------ | -------------------------------------------------------------------------------------------------------------------- | | `status` | `approved` or `rejected` — only from `extracted` / `proposed`, else `409`. | | `assignedTypeID` | `ety_…` to override the inferred type. The **empty string** clears the assignment. | | `canonical` | Replace the canonical surface form (re-derives its normalized form). | | `addSynonyms` | `string[]` — surface forms to attach as `customer_defined` synonyms. | | `removeSynonymIDs` | `esn_…` IDs to soft-delete. Only `customer_defined` / `sme_approved` synonyms; removing an `extracted` one is `409`. | | `locale` | Optional BCP 47 tag stamped on any added synonyms. | An optional `bucket` query param (`bkt_…`) scopes the lookup to one bucket; omit it for the default bucket. ```bash # approve an entity and pin its type in one call curl -X PATCH "https://api.bem.ai/v3/entities/ent_acme" \ -H "x-api-key: $BEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "approved", "assignedTypeID": "ety_manufacturer", "addSynonyms": ["Acme Inc."] }' ``` Approving emits an `entity_validated` webhook; rejecting emits `entity_rejected`. The response is the full updated entity record, including its new `status`, `validatedAt`, and `validatedByUserID`. Validate in bulk [#validate-in-bulk] `POST /v3/entities/bulk-validate` applies one terminal `status` to many entities at once — the workhorse behind "approve all" in a curation session. It takes the same optional `bucket` query param as `PATCH`, above. ```bash curl -X POST "https://api.bem.ai/v3/entities/bulk-validate" \ -H "x-api-key: $BEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "entityIDs": ["ent_acme", "ent_globex"], "status": "approved" }' ``` The response reports a per-row `outcome` (in request order) plus an aggregate summary, so a partially-valid batch still tells you exactly what happened: ```json { "results": [ { "entityID": "ent_acme", "outcome": "validated" }, { "entityID": "ent_globex", "outcome": "rejected-row", "reason": "already terminal" }, { "entityID": "ent_missing", "outcome": "skipped", "reason": "not found" } ], "summary": { "validated": 1, "skipped": 1, "rejectedRow": 1 } } ``` | `outcome` | Meaning | | -------------- | ---------------------------------------------------------- | | `validated` | The transition was applied. | | `skipped` | Entity not found, or not authorized for the caller. | | `rejected-row` | The transition itself was illegal (e.g. already terminal). | See also [#see-also] Read back the entities and relations curation operates over. Define the entity types and synonyms that curation operates over. The `PATCH` and `bulk-validate` endpoints in full. # Errors and Status Codes (/guide/errors) > For the complete documentation index, see [llms.txt](/llms.txt). bem distinguishes two kinds of failure: **request errors**, returned synchronously when the API can't accept your call, and **execution errors**, which appear inside otherwise-successful workflow calls when one or more nodes fail. Both shapes are stable — your error-handling code only needs to know two things. HTTP status codes [#http-status-codes] | Code | Meaning | When you'll see it | | --------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `200 OK` | Success, body returned | Most reads; `wait=true` calls that completed; `DELETE /v3/workflows/{workflowName}` (body reports per-connector teardown errors — read it) | | `202 Accepted` | Accepted for async processing | Workflow calls without `wait=true`; eval queueing; collection item ingest | | `204 No Content` | Success, no body | Most deletes | | `207 Multi-Status` | Partial success | Batch updates where some items succeeded and some failed (e.g. `update-transformation`) | | `400 Bad Request` | Invalid request | Schema violation, unsupported `inputType`, malformed `outputSchema`, contradictory filters | | `401 Unauthorized` | Missing or invalid API key | The `x-api-key` header is missing, malformed, or revoked | | `404 Not Found` | Resource doesn't exist | Wrong `functionName`/`workflowName`/`callID`, or you're targeting a different environment | | `422 Unprocessable Entity` | Request shape valid, semantics not | Body parses but violates a model constraint (e.g. workflow `mainNodeName` not in `nodes`) | | `429 Too Many Requests` | Rate limit exceeded | Back off and retry | | `500 Internal Server Error` | Server-side failure | Treat as retryable with backoff; if persistent, contact support with the response body | Request-error shape [#request-error-shape] Every non-2xx response uses the same body schema: ```json { "message": "human-readable description", "code": 400, "details": { "field": "outputSchema", "reason": "..." } } ``` | Field | Type | Notes | | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | | `message` | string | Always present. Safe to surface to operators; not necessarily safe to surface to end users (may include resource names). | | `code` | integer | Optional. Mirrors the HTTP status when present. | | `details` | object | Optional, free-form. Carries field-level context for `400`/`422` responses; absent for most `500`s. | Treat the body as advisory and the HTTP status as authoritative. Don't switch on `details.field` strings — they're meant for humans, not parsers. Execution errors inside workflow calls [#execution-errors-inside-workflow-calls] When a workflow call partially or fully fails, the HTTP response is still `200` (or `202` if you didn't pass `wait=true`). The failure information lives inside the call object: ```json { "call": { "callID": "wc_abc123", "status": "failed", "outputs": [ { "eventID": "ev_001", "eventType": "transform", "transformation": { ... } } ], "errors": [ { "eventID": "ev_002", "functionCallID": "fc_abc", "workflowNodeName": "invoice-extractor", "errorMessage": "outputSchema constraint violation: field 'totalAmount' is required", "createdAt": "2024-04-25T19:14:02Z" } ], "url": "/v3/calls/wc_abc123", "traceUrl": "/v3/calls/wc_abc123/trace" } } ``` Two things to note: 1. **`outputs` and `errors` are not mutually exclusive.** A workflow with three nodes can emit two terminal outputs and one error. Always check both. 2. **`call.status` reflects the worst outcome.** It's `completed` only if every terminal node produced a non-error event. Any error event flips it to `failed`. For per-node detail, fetch the trace at `traceUrl`. Retry guidance [#retry-guidance] | Status | Retry? | How | | ----------------------- | --------- | ---------------------------------------------------------------------------------------------------- | | `429` | Yes | Honour `Retry-After` if present, otherwise exponential backoff starting around 1s, capping near 10s. | | `500`/`502`/`503`/`504` | Yes | Exponential backoff with jitter, max 5 attempts. | | `408` | Yes | Same as 5xx. | | `400`/`401`/`404`/`422` | No | The request itself needs to change; retrying as-is won't help. | | `409` | Sometimes | When returned on a workflow create with a name collision, treat as terminal. | Idempotency [#idempotency] bem uses `callReferenceID` (on workflow calls) as your deduplication key. Submitting the same `callReferenceID` for the same workflow within a short window returns the existing call instead of creating a new one — safe to retry network failures without producing duplicates. For all other operations (function/workflow create + update), use the response's stable IDs (`functionID`, `workflowID`) to detect whether your retry succeeded after a network blip. Common errors and what they mean [#common-errors-and-what-they-mean] | Message fragment | What it means | Fix | | --------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `outputSchema constraint violation` | Extracted JSON doesn't satisfy your `outputSchema` (usually a missing required field) | Surface as a workflow output error, not a 4xx — see `call.errors[].errorMessage`. Tighten upstream prompts or relax the requirement. | | `unsupported inputType` | The `inputType` you sent isn't in the supported list | See [Supported file types](/guide/file-types). | | `function name already exists` | You called create with a name that's already in use in this environment | Either update the existing function or pick a new name. | | `workflow mainNodeName must be one of nodes[].name` | Topology mismatch | Fix the request body. | | `BadRequestError: model state limit exceeded` | The `outputSchema` is too large/complex for the underlying model | See [Known limitations](/guide/known-limitations). Split the schema or reduce nesting. | Observing errors after the fact [#observing-errors-after-the-fact] The `/v3/errors` endpoint lists terminal error events across calls and is the right place to power monitoring dashboards or alerts. Filter by `workflowNames`, `functionNames`, or `callIDs` to scope to a specific surface. `wait=true` semantics, polling cadence, and idempotency. Programmatically query terminal error events. Schema-size and model-state limits that surface as errors. # File System API (/guide/file-system) > For the complete documentation index, see [llms.txt](/llms.txt). The File System API lets an LLM agent (or any programmatic client) navigate parsed documents the way it would navigate a filesystem — `ls` to list, `grep` to search, `cat` to read, `head` for a quick peek, `stat` for metadata, and `find` / `open` / `xref` for the cross-document entity memory layer. ``` POST /v3/fs Content-Type: application/json x-api-key: ``` Every request sends `{"op": "", ...}`. Every response returns `{"op", "data", "hasMore?", "nextCursor?", "count?", "hint?"}`. Operations at a glance [#operations-at-a-glance] | Op | `path` | Other fields | What it does | | ------ | ----------------------------- | ------------------------------- | ----------------------------------------- | | `ls` | — | `filter`, `limit`, `cursor` | List parsed documents | | `grep` | referenceID *(optional)* | `pattern`, `scope`, `countOnly` | Search across documents | | `cat` | referenceID | `range`, `select` | Read a document's parsed content | | `head` | referenceID | `n` | First N sections (default 10) | | `stat` | referenceID *or* entityID | — | Metadata only | | `find` | — | `filter`, `limit`, `cursor` | List canonical entities | | `open` | entityID | — | Entity detail + all mentions | | `xref` | entityID | `limit`, `cursor` | Sections across docs mentioning an entity | The `path` field [#the-path-field] `path` is the positional identifier — what it refers to depends on the op: * **Doc ops** (`cat`, `head`, `stat`): pass the document's `referenceID` from `ls`. * **Entity ops** (`open`, `xref`): pass the `entityID` from `find`. * **`grep`**: optionally pass a `referenceID` to scope search to one document. * **`ls`** and **`find`** do not use `path`. Doc-level operations [#doc-level-operations] ls — list documents [#ls--list-documents] ```json { "op": "ls" } ``` Returns an array of parsed documents with metadata: ```json [ { "referenceID": "my-doc-001", "transformationID": "tr_abc123", "functionName": "doc-parser", "parsedAt": "2025-01-15T10:30:00Z", "pageCount": 23, "sectionCount": 254, "entityCount": 66, "previewEntities": ["Acme Corp", "John Smith"] } ] ``` Filter by function name or referenceID substring: ```json { "op": "ls", "filter": { "search": "invoice", "functionName": "doc-parser" } } ``` grep — search documents [#grep--search-documents] ```json { "op": "grep", "pattern": "holiday", "scope": "sections" } ``` Returns matching sections with page numbers and text snippets: ```json [ { "referenceID": "my-doc-001", "page": 7, "sectionLabel": "Article 8 Header", "snippet": "ARTICLE 8 - HOLIDAYS", "scope": "section" } ] ``` Scope to one document with `path`: ```json { "op": "grep", "path": "my-doc-001", "pattern": "holiday", "scope": "sections" } ``` Count only (no snippets): ```json { "op": "grep", "pattern": "holiday", "countOnly": true } ``` `scope` values: `"sections"`, `"entities"`, `"relationships"`, `"all"` (default). cat — read a document [#cat--read-a-document] ```json { "op": "cat", "path": "my-doc-001", "range": { "page": 7 } } ``` Returns the document's parsed content — sections, entities, and relationships. `range` is an **object** with optional keys: ```json {"op": "cat", "path": "my-doc-001", "range": {"page": 7}} {"op": "cat", "path": "my-doc-001", "range": {"pageRange": [5, 10]}} {"op": "cat", "path": "my-doc-001", "range": {"sectionTypes": ["table", "heading"]}} ``` `select` projects to specific fields (an **array of strings**): ```json { "op": "cat", "path": "my-doc-001", "select": ["sections.label", "sections.page", "sections.type"] } ``` Without `range` or `select`, returns the entire document. Use `range` to keep responses small. head — preview first sections [#head--preview-first-sections] ```json { "op": "head", "path": "my-doc-001", "n": 10 } ``` Returns `{"sections": [{content, label, page, type}, ...]}` for the first N sections. Default N is 10. stat — document or entity metadata [#stat--document-or-entity-metadata] ```json { "op": "stat", "path": "my-doc-001" } ``` Returns `{kind, path, referenceID, pageCount, sectionCount, entityCount, parsedAt}`. Also works for entities: ```json { "op": "stat", "path": "ent_abc123" } ``` Entity memory operations [#entity-memory-operations] These require `linkAcrossDocuments: true` on the parse function. Without it, they return empty data with a `hint` explaining how to enable it. **Workflow:** call `find` first to discover entities and their IDs, then pass an `entityID` as `path` to `open` or `xref`. **See also:** these ops walk entities one at a time. To read the entity graph in bulk — every relation for an entity, or a whole slice of nodes and edges — use the [Knowledge Graph API](/guide/knowledge-graph). find — list entities [#find--list-entities] ```json {"op": "find"} {"op": "find", "filter": {"type": "person"}} {"op": "find", "filter": {"search": "ryder"}} ``` Returns `[{entityID, canonical, type, description, mentionCount, surfaceForms}]`. open — entity detail + mentions [#open--entity-detail--mentions] ```json { "op": "open", "path": "ent_abc123" } ``` `path` must be an `entityID` from `find` (not an entity name). Returns the entity record plus every mention across documents. xref — cross-document sections for an entity [#xref--cross-document-sections-for-an-entity] ```json { "op": "xref", "path": "ent_abc123" } ``` Returns the actual section text from every document that mentions this entity — the "where exactly is X discussed?" query in one call. ```json [ { "referenceID": "my-doc-001", "page": 20, "sectionLabel": "Signatures", "sectionContent": "Signed this 7th day of September 2024...", "surface": "TEAMSTERS LOCAL 222" } ] ``` Pagination [#pagination] `ls` and `find` paginate by cursor. Pass the `nextCursor` from one response as `cursor` in the next request. `hasMore: false` means last page. ```json { "op": "ls", "cursor": "tr_abc123", "limit": 10 } ``` # Supported File Types (/guide/file-types) > For the complete documentation index, see [llms.txt](/llms.txt). Every workflow call carries an `inputType` value that tells bem how to handle the file. The full set is below, grouped by category. Use the exact `inputType` string when constructing requests — values are lowercase and short (`pdf`, not `PDF`, not `application/pdf`). A few types have aliases: `jpg` and `jfif` are both accepted and normalized to `jpeg`. Where a value is normalized, the type reported back to you is the canonical one, not the alias you sent. Documents [#documents] | `inputType` | MIME type(s) | Notes | | ----------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pdf` | `application/pdf` | | | `docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | Modern Word documents. Legacy `.doc` is not supported — convert to `.docx` first. | | `email` | `message/rfc822` (`.eml`) | Body is parsed as the primary content. Attachments are unwrapped and processed alongside the body if their type is in this table. Attachments whose type isn't supported are ignored. | | `text` | `text/plain` | UTF-8 expected. | Images [#images] | `inputType` | MIME type(s) | Notes | | ----------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `jpeg` | `image/jpeg` | Also accepted as `jpg`. | | `jfif` | `image/jpeg` | JFIF is JPEG under a different extension — Windows tooling and older browsers save `.jfif`, `.jif` or `.jpe`. Normalized to `jpeg`, so the type reported back to you is `jpeg`, not `jfif`. | | `png` | `image/png` | | | `webp` | `image/webp` | | | `heic` | `image/heic` | iOS-format photos. Decoded server-side. | | `heif` | `image/heif` | | Spreadsheets [#spreadsheets] | `inputType` | MIME type(s) | Notes | | ----------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `csv` | `text/csv` | UTF-8 expected. | | `xls` | `application/vnd.ms-excel` | Legacy Excel format. | | `xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | Modern Excel format. Multi-sheet workbooks are processed sheet-by-sheet. | Structured data [#structured-data] | `inputType` | MIME type(s) | Notes | | ----------- | ----------------------------- | ----- | | `json` | `application/json` | | | `xml` | `application/xml`, `text/xml` | | | `html` | `text/html` | | Audio [#audio] | `inputType` | MIME type(s) | Notes | | ----------- | -------------------------------------- | ---------------------------------------- | | `mp3` | `audio/mpeg` | Speech is transcribed before extraction. | | `wav` | `audio/wav` | | | `m4a` | `audio/mp4` (audio-only MP4 container) | | Video [#video] | `inputType` | MIME type(s) | Notes | | ----------- | ------------ | ------------------------------------------------------ | | `mp4` | `video/mp4` | Frames are sampled and the audio track is transcribed. | Encoding rules [#encoding-rules] There are two ways to send a file in a workflow call: **Multipart form (`multipart/form-data`)** — preferred for large files. Attach the binary directly as the `file` (or `files` for join workflows) field; no encoding required. **JSON (base64)** — embed the file content as a base64-encoded string in `input.singleFile.inputContent`. Standard base64 (RFC 4648) — padding is required, line breaks are not. Don't include a `data:` URI prefix; pass only the base64 payload. The Bem CLI hides the encoding: write `--input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}'` and the CLI base64-encodes binary files automatically (text files are embedded as strings). Size limits [#size-limits] The schema-inference endpoint (`POST /v3/infer-schema`) caps uploads at **20 MB**. Workflow calls accept larger files; if you hit a size error in practice, contact support — the limit varies by plan. When the input doesn't match [#when-the-input-doesnt-match] Sending an `inputType` that doesn't match the actual file format (for example, `inputType: "pdf"` with a JPEG body) returns `400 Bad Request` with a message that names the mismatched type. Set `inputType` from the source-of-truth extension or MIME type, not from a guess — but map it to a value in the tables above rather than sending the extension verbatim. `.jfif`, `.jif` and `.jpe` all mean `jpeg`. `multipart/form-data` uploads carry no `inputType` field — bem derives it, reading the part's `Content-Type` header first, then falling back to the filename extension, then to the file's own signature bytes. That is why `curl -F 'file=@photo.jfif'` works even though curl labels the part `application/octet-stream`, having no MIME entry for `.jfif`. For unsupported file types — anything not in the tables above — there is no automatic conversion. Convert client-side first (e.g. `.doc` → `.docx`), or split the workload into a pre-processing step that produces a supported format. # Introduction (/guide/introduction) > For the complete documentation index, see [llms.txt](/llms.txt). bem turns documents — PDFs, emails, spreadsheets, images, audio, video — into structured JSON, against a schema you define. You compose small typed functions into a workflow, call the workflow with a file, and bem returns extracted data ready to write to your systems. How it fits together [#how-it-fits-together] | Primitive | What it is | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | **Function** | A single processing step. One of `extract`, `classify`, `split`, `join`, `enrich`, `parse`, `payload_shaping`, `render`, or `send`. Versioned. | | **Workflow** | A directed graph of functions with one entry point. Versioned. | | **Call** | A single execution of a workflow against a specific input. | | **Event** | The output of a function within a call. Successful events carry a transformation; failed events carry an error. | | **Subscription** | A binding from a function to a webhook URL. | The function types [#the-function-types] | Function | What it does | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Extract** | Pulls structured JSON from any supported file against your `outputSchema`. | | **Classify** | Sends the input down one of several labeled paths based on content. | | **Split** | Breaks a multi-document file into individual pieces for downstream processing. | | **Join** | Combines the outputs of upstream nodes into a single payload. | | **Enrich** | Augments extracted data with context from a collection via semantic search. | | **Parse** | Renders documents into a navigable structure — sections, entities, relationships — for an LLM agent to walk via the [File System API](/api/v3/file-system). | | **Payload Shaping** | Reshapes JSON with JMESPath for ingestion into downstream systems. | | **Render** | Merges structured JSON into a Word template and produces a finished `.docx`. | | **Send** | Delivers workflow outputs to a webhook, S3 bucket, or Google Drive folder. | bem is SOC 2 Type 2, HIPAA and GDPR compliant. Outputs are validated against the schema you provide, and low-confidence transformations can be routed to human review automatically. Get started [#get-started] Your first synchronous workflow call end-to-end — pick a language and ship in **five minutes**. The full data model: functions, workflows, calls, events, subscriptions. Compose functions into branching, splitting, and aggregating graphs. Authentication, endpoints, request and response shapes. SDKs and tools [#sdks-and-tools] Official client libraries cover TypeScript, Python, Go, and C#. There's also a CLI, an MCP server for agent-driven access, and a Terraform provider for declarative configuration. All read `BEM_API_KEY` from the environment by default. See [SDKs](/guide/sdks) for the install commands and links, or jump to the [Quickstart](/guide/quickstart) for side-by-side examples. V3 and the legacy API [#v3-and-the-legacy-api] If you have an existing integration on V1 or V2, see [V3 migration](/guide/v3-migration) for the rename map and endpoint changes. Legacy types (`transform`, `analyze`, `route`) remain readable and callable — no migration is required for deployed pipelines. # Knowledge Graph API (/guide/knowledge-graph) > For the complete documentation index, see [llms.txt](/llms.txt). The Knowledge Graph API exposes the entity memory that bem builds as it parses documents. Where the [File System API](/guide/file-system)'s `find` / `open` / `xref` ops let an agent walk entities one at a time, the Knowledge Graph endpoints let you read the graph itself — the entities (nodes) and the relations between them (edges) — either for a single entity or as a slice across a whole bucket. ``` GET /v3/entities/{id}/relations GET /v3/knowledge-graph x-api-key: ``` Concepts [#concepts] * **Entity** — a canonical thing bem has recognized across documents: an organization, a person, a product, a location. Each entity has an `id` (`ent_…`), a `canonical` name, and a `type`. * **Edge (relation)** — a directed link between two entities, labeled with a `relationType` such as `employs` or `acquired by`. Every edge carries a `mentionCount` (how many parsed mentions support it) and a `firstSeenAt` timestamp. * **Entity type** — the category of an entity (`organization`, `person`, `product`, `location`, …). Used to filter the graph. * **Bucket** — a knowledge graph is scoped to a bucket (`bkt_…`). There is one knowledge graph per bucket. Pass `bucket` to read a single bucket's graph; omit it to read across **all** buckets in the account + environment. These endpoints are the bulk-read counterpart to the File System memory ops: use `find` / `open` / `xref` when an agent is exploring one entity at a time, and the Knowledge Graph endpoints when you want the edges themselves — for example to render a graph or to walk relations programmatically. `GET /v3/entities/{id}/relations` [#get-v3entitiesidrelations] Returns the inbound and outbound edges for a single entity. | Param | Default | Notes | | -------------- | ----------- | ---------------------------------------------------------- | | `direction` | `both` | `inbound`, `outbound`, or `both` | | `relationType` | — | Exact-match filter on the relation label | | `bucket` | all buckets | `bkt_…`; absent → all buckets in the account + environment | | `limit` | 50 | Max 200 | | `cursor` | — | `nextCursor` from a previous response | ```bash curl "https://api.bem.ai/v3/entities/ent_acme/relations?direction=both&limit=50" \ -H "x-api-key: $BEM_API_KEY" ``` ```json { "inbound": [ { "relationType": "owns", "sourceEntity": { "id": "ent_globex", "canonical": "Globex Holdings", "type": "organization" }, "mentionCount": 3, "firstSeenAt": "2026-05-20T12:00:00Z" } ], "outbound": [ { "relationType": "employs", "targetEntity": { "id": "ent_jane_doe", "canonical": "Jane Doe", "type": "person" }, "mentionCount": 1, "firstSeenAt": "2026-05-21T08:30:00Z" } ], "nextCursor": "erl_1f3a9c" } ``` Inbound edges point **at** the entity (the far end is `sourceEntity`); outbound edges point **from** it (the far end is `targetEntity`). Use `direction` to fetch one side only — `direction=outbound` omits the `inbound` array — and `relationType` to keep only edges with an exact label, e.g. `?relationType=employs`. **Alias resolution.** If `{id}` is an entity that was later merged away, the endpoint transparently resolves it to the surviving entity and returns that entity's edges. You never get an empty result just because you held onto an older, merged id. `GET /v3/knowledge-graph` [#get-v3knowledge-graph] Returns a slice of the graph — a set of nodes and the edges between them. Pagination is over **edges** (see below). | Param | Default | Notes | | -------- | ----------- | ---------------------------------------------------------- | | `type` | all types | Repeatable: `?type=organization&type=person` | | `search` | — | Substring match on entity `canonical` name | | `since` | — | RFC3339; edges first seen on or after this time | | `bucket` | all buckets | `bkt_…`; absent → all buckets in the account + environment | | `limit` | 50 | Max 200 | | `cursor` | — | `nextCursor` from a previous response | ```bash curl "https://api.bem.ai/v3/knowledge-graph?type=organization&type=person&search=acme&limit=50" \ -H "x-api-key: $BEM_API_KEY" ``` ```json { "nodes": [ { "id": "ent_acme", "canonical": "Acme Corporation", "type": "organization", "mentionCount": 12 } ], "edges": [ { "sourceId": "ent_acme", "targetId": "ent_jane_doe", "relationType": "employs", "mentionCount": 4 } ], "nextCursor": "erl_8b2d0e" } ``` Filter semantics [#filter-semantics] `type[]` and `search` filter **entities**, not edges directly. An edge appears in the response only when **both** of its endpoints survive the filter. So `?type=organization&type=person` returns organization↔person, organization↔organization, and person↔person edges, but drops any edge with an endpoint that is neither an organization nor a person. `search` works the same way: both endpoints' canonical names are tested against the substring. By design in Phase 1, an entity that has no edges does not appear as a node — the graph is built up from edges. (Use the File System API's `find` op to enumerate entities regardless of whether they participate in a relation.) Edge pagination and node inclusion [#edge-pagination-and-node-inclusion] Pagination is over edges. Each page returns up to `limit` edges, and the `nodes` array contains **both endpoints of every edge on that page** — even if the far endpoint's *other* edges fall on a later page. So a single node can appear across multiple pages whenever its edges span a page boundary; de-duplicate nodes by `id` if you are assembling the full graph client-side. Pagination patterns [#pagination-patterns] Both endpoints paginate by cursor. Read the first page, then pass the `nextCursor` you got back as the `cursor` of the next request: ```bash # page 1 curl "https://api.bem.ai/v3/knowledge-graph?type=organization&limit=200" \ -H "x-api-key: $BEM_API_KEY" # page 2 — feed nextCursor from page 1 back in as cursor curl "https://api.bem.ai/v3/knowledge-graph?type=organization&limit=200&cursor=erl_8b2d0e" \ -H "x-api-key: $BEM_API_KEY" ``` A missing or empty `nextCursor` means you have reached the last page. `limit` defaults to **50** and is capped at **200**; values above 200 are clamped. The "find every document that mentions X" pattern — start from an entity, walk its relations, and follow the surviving endpoints — is already useful today via `relations` plus the File System API's `xref`. It becomes considerably more powerful once synonym resolution lands in **Phase 4**, when distinct surface forms of the same real-world thing collapse into a single entity and its edges. See also [#see-also] Define your own entity types and synonyms, and seed them in bulk. Per-entity memory ops — `find`, `open`, `xref` — for exploring one entity at a time. Cursor conventions shared across the V3 API. # Known Limitations (/guide/known-limitations) > For the complete documentation index, see [llms.txt](/llms.txt). Schema Complexity Limits [#schema-complexity-limits] Extremely large and complex schemas (deep nesting, long descriptions, numerous fields) can exceed the underlying model's "state" limit, causing a `BadRequestError` when the function is created or called. This is particularly common with schemas that have: * More than 20+ fields at the root level * Deep nesting beyond 3-4 levels * Very long field descriptions (1000+ characters per field) * Complex conditional logic using `anyOf`/`oneOf` statements Limits on In-place Enums [#limits-on-in-place-enums] Schemas with very long enum lists (64+ items) can hit the context window limit of the underlying LLM models, leading to failures or degraded performance. Consider leveraging our [Enrich](/guide/function-types/enrich) primitive for deeper semantic search capabilities, or by breaking large enums into smaller, more focused schemas or using pattern matching instead. Positional Schema Fragility [#positional-schema-fragility] Schemas that rely on array indices to convey meaning (e.g., `rates[1]` corresponds to a weight of 1 lb) are brittle and often lead to poor performance, as LLMs work best with semantic relationships. Use descriptive field names and object structures instead of positional arrays. Only Object Type Schemas at Root Level [#only-object-type-schemas-at-root-level] * Only object-type schemas are supported at the root level * External `$schema` references are not supported * Schemas must have at least one property defined # MCP Server (/guide/mcp-server) > For the complete documentation index, see [llms.txt](/llms.txt). The bem MCP Server lets agents — Claude Desktop, Claude Code, Cursor, VS Code, and any other [Model Context Protocol](https://modelcontextprotocol.io/) client — call the bem API on your behalf. It's published as `bem-ai-sdk-mcp` on npm. What can bem's MCP Server do? [#what-can-bems-mcp-server-do] The bem MCP server runs in **Code Mode**: instead of exposing one tool per API endpoint, it exposes two general-purpose tools that together give the agent the full surface of the bem TypeScript SDK. | Tool | What it does | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `docs` | Searches bem's API and SDK documentation. The agent uses this to discover what methods exist and how to call them. | | `code` | Executes TypeScript against the bem SDK in a sandbox with no network or filesystem access. Anything the code returns or prints comes back to the agent as the tool result. | Because the agent writes real SDK code, it can do anything the SDK can do — manage functions and workflows, kick off calls, list events, configure subscriptions, query collections, manage API keys and environments — and chain those operations together in a single tool call. Code Mode trades the discoverability of one-tool-per-endpoint for far greater capability and lower token cost. The agent learns the API by searching docs, then writes a single block of code instead of orchestrating dozens of tool calls. Prerequisites [#prerequisites] * A [bem account](https://app.bem.ai) and an API key generated from **Settings → API Keys**. * Node.js 18 or later (the server runs via `npx`). * An MCP-compatible client — Claude Desktop, Claude Code, Cursor, VS Code, or any other [client](https://modelcontextprotocol.io/clients). How to use the MCP Server [#how-to-use-the-mcp-server] The server supports two transports. **Stdio** is the default and the right choice for local clients on the same machine; **HTTP** is for hosting the server remotely or sharing it across multiple users. Stdio Transport (Default) [#stdio-transport-default] Pick your client below. In every case the server is launched on demand by the client — no long-running process to manage. Run the following in your terminal: ```bash claude mcp add bem_ai_sdk_mcp_api \ --env BEM_API_KEY="your-api-key-here" \ -- npx -y bem-ai-sdk-mcp ``` Claude Code writes the server entry to `~/.claude.json`. Restart Claude Code to pick up the change. Add the server to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows): ```json { "mcpServers": { "bem_ai_sdk_api": { "command": "npx", "args": ["-y", "bem-ai-sdk-mcp"], "env": { "BEM_API_KEY": "your-api-key-here" } } } } ``` Restart Claude Desktop after saving. Open **Cursor Settings → Tools & MCP → New MCP Server** and add the following to `mcp.json`: ```json { "mcpServers": { "bem_ai_sdk_api": { "command": "npx", "args": ["-y", "bem-ai-sdk-mcp"], "env": { "BEM_API_KEY": "your-api-key-here" } } } } ``` Open the Command Palette and run **MCP: Open User Configuration**, then add: ```json { "servers": { "bem_ai_sdk_api": { "command": "npx", "args": ["-y", "bem-ai-sdk-mcp"], "env": { "BEM_API_KEY": "your-api-key-here" } } } } ``` Most other MCP clients accept the same shape. Drop the snippet into whatever JSON config the client expects: ```json { "mcpServers": { "bem_ai_sdk_api": { "command": "npx", "args": ["-y", "bem-ai-sdk-mcp"], "env": { "BEM_API_KEY": "your-api-key-here" } } } } ``` Refer to your client's documentation for the exact file path. You can also invoke the server directly without a client, useful for sanity-checking your setup: ```bash export BEM_API_KEY="your-api-key-here" npx -y bem-ai-sdk-mcp@latest ``` HTTP Transport [#http-transport] For remote hosting, start the server in HTTP mode. Authorization is sent per-request as the `x-api-key` header, so a single hosted instance can serve multiple users: ```bash npx -y bem-ai-sdk-mcp --transport=http --port=3000 ``` Use `--socket /path/to/socket` instead of `--port` to listen on a Unix socket. Point an MCP client at the running server: ```json { "mcpServers": { "bem_ai_sdk_api": { "url": "http://localhost:3000", "headers": { "x-api-key": "your-api-key-here" } } } } ``` Options [#options] The server accepts the following flags. Every flag also has a matching environment variable prefixed with `MCP_SERVER_` (for example, `MCP_SERVER_TRANSPORT=http`). | Flag | Description | | -------------------------------------------------- | -------------------------------------------------------------------------- | | `--transport ` | Transport to use. Defaults to `stdio`. | | `--port ` | Port to bind when using HTTP transport. Defaults to `3000`. | | `--socket ` | Unix socket to bind when using HTTP transport. | | `--tools ` | Explicitly enable a tool. Repeat to enable multiple. | | `--no-tools ` | Explicitly disable a tool. Repeat to disable multiple. | | `--code-execution-mode ` | Where the `code` tool runs. Defaults to `stainless-sandbox`. | | `--code-allow-http-gets` | Allow all SDK methods that map to HTTP GET operations. | | `--code-allowed-methods ` | Allowlist for SDK methods the `code` tool may call. | | `--code-blocked-methods ` | Blocklist for SDK methods the `code` tool may call. | | `--docs-search-mode ` | Where docs search runs. Defaults to `stainless-api`. | | `--docs-dir ` | Directory of additional markdown/JSON docs to include in local search. | | `--custom-instructions-path ` | Path to a file with custom instructions injected into the server's prompt. | | `--log-format ` | Log output format. Defaults to `json` (or `pretty` when stderr is a TTY). | | `--debug` | Enable debug logging. | The required environment variable is `BEM_API_KEY`. When using the HTTP transport, the per-request `x-api-key` header takes precedence. Local Development [#local-development] To hack on the server itself, clone the TypeScript SDK monorepo and build the package: ```bash git clone https://github.com/bem-team/bem-typescript-sdk.git cd bem-typescript-sdk/packages/mcp-server pnpm install pnpm build ``` Run a local stdio server: ```bash export BEM_API_KEY="your-api-key-here" node dist/index.js ``` Or run it over HTTP: ```bash node dist/index.js --transport=http --port=3000 ``` Inspect tool calls and responses live with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): ```bash npx @modelcontextprotocol/inspector node dist/index.js ``` The inspector opens a browser UI where you can list tools, fire calls by hand, and watch the JSON-RPC traffic — the fastest way to verify the server is wired up before pointing a real client at it. Reference [#reference] Source, build scripts, and issue tracker. `bem-ai-sdk-mcp` — releases and version history. Protocol specification and a directory of MCP clients. Direct API and SDK usage if you'd rather not go through an agent. # Comparing Functions on a Dataset (/guide/model-comparison) > For the complete documentation index, see [llms.txt](/llms.txt). Model comparison grades several functions (or versions) against the **same** dataset of known-correct answers, so you can see which one extracts most accurately — and how each differs from a baseline. It reuses the eval-score grading engine per entry and reports precision / recall / F1, latency, and **lift** against the baseline entry. Grading runs are internal and **not billed**. Everything is **API-key authenticated** (`x-api-key`; account + environment come from the key) and **asynchronous**: you `POST` to create a resource that returns immediately with an id and `pending` status, then poll a `GET` until it is `complete`. ```text select functions ──▶ dataset (gds_) ──▶ comparison (cmp_) ──▶ metrics + lift ──▶ mislabel check ``` 1\. Build a dataset [#1-build-a-dataset] A dataset holds your ground truth: input files paired with the expected JSON. The most direct way is to build one from the **corrected transformations of functions already in your account**. ```http POST /v3/datasets/from-function-outputs ``` ```json { "name": "invoices golden", "query": { "functionNames": ["invoices", "invoices-experiment"], "isLabelled": true } } ``` * `isLabelled: true` keeps only reviewed/corrected rows — the ground truth a comparison scores against. The response contains the new dataset: `{ "dataset": { "id": "gds_…", … } }`. Choosing which outputs become rows [#choosing-which-outputs-become-rows] `query` selects the rows, from coarse to fine. Combine as needed: * **By function** — `functionNames` (or `functionIDs`) pulls every corrected transformation of those functions, across all versions. * **By (function, version) pair** — `functionVersions` pins exact versions. Pairs union rather than cross-product: `[{ "functionName": "invoices", "versionNum": 2 }, { "functionName": "receipts", "versionNum": 3 }]` yields exactly invoices\@v2 + receipts\@v3. Omit `versionNum` to take all versions of a function. * **Individually** — `transformationIDs`, `eventIDs`, or `referenceIDs` hand-pick specific rows. ```json { "query": { "functionVersions": [ { "functionName": "invoices", "versionNum": 2 }, { "functionName": "invoices", "versionNum": 3 } ], "isLabelled": true } } ``` Each row automatically carries the **schema** its corrected answer was produced with, so scoring follows that per-row **ground-truth schema** rather than the scored function's own schema — a comparison stays fair as functions and schemas evolve over time. 2\. (Optional) Score a single function first [#2-optional-score-a-single-function-first] Before comparing, you can grade one function against the dataset with the eval-score endpoint — handy for a quick sanity check. ```http POST /v3/eval/score ``` ```json { "functionName": "invoices", "functionVersionNum": 3, "datasetID": "gds_…" } ``` Returns `202 { "scoreRunID": "evalrun_…", "status": "pending" }`. Poll `GET /v3/eval/score/{scoreRunID}` for the aggregate score and per-row, per-field diffs. The run itself only extracts; the comparison against your expected values is redone on every read, so the numbers always reflect your dataset as it stands now. `datasetID` and `pairs` are mutually exclusive — pass `pairs: [{ input, expected }]` to score inline examples instead of a saved dataset. A dataset's input / corrected / schema columns are resolved by role, so no column names are ever needed. 3\. Create a comparison [#3-create-a-comparison] ```http POST /v3/model-comparisons ``` ```json { "name": "invoices v3 vs v2 vs experiment", "datasetID": "gds_…", "entries": [ { "functionName": "invoices", "functionVersionNum": 2, "label": "v2" }, { "functionName": "invoices", "functionVersionNum": 3, "label": "v3" }, { "functionName": "invoices-experiment", "label": "candidate" } ] } ``` * Each entry is a `(functionName, functionVersionNum)` pair — one function version, i.e. one configuration to grade. Omit `functionVersionNum` to use the function's current version. * Provide **2–3 distinct entries** (the same `(function, version)` twice is rejected). They may span **different functions** as well as different versions. The dataset is capped at 1000 rows. * The **first entry is the baseline**; every other entry's lift is measured against it. * Columns (input / corrected / schema) resolve by role, so no column names are needed. Because the dataset carries a per-row `schema`, every entry is scored against that **ground-truth schema** — so the comparison stays fair as functions and schemas evolve. Returns `202 { "comparisonID": "cmp_…", "status": "pending" }`. 4\. Read the results [#4-read-the-results] ```http GET /v3/model-comparisons/{comparisonID}?matchMode=normalized&orderMatching=false ``` `matchMode` (`strict` | `normalized` | `fuzzy`, anything else a `400`) controls how leaf values are judged equal, and `orderMatching` whether array elements are compared in order. Grading is recomputed on read, so you can change these without re-running the functions. ```json { "comparisonID": "cmp_…", "status": "complete", "entries": [ { "label": "v2", "functionName": "invoices", "functionVersionNum": 2, "scoreRunID": "evalrun_…", "status": "complete", "isBaseline": true, "coverage": { "completed": 50, "total": 50 }, "metrics": { "aggregateMetrics": { "precision": 0.88, "recall": 0.85, "f1Score": 0.86, "accuracy": 0.91, "tp": 120, "fp": 16, "fn": 21, "tn": 300 }, "fieldMetrics": [] }, "latencyPercentiles": { "latencyP50": 2.1, "latencyP90": 3.4, "latencyP95": 3.9 } }, { "label": "v3", "isBaseline": false, "metrics": { "aggregateMetrics": { "f1Score": 0.91, "…": "…" } }, "lift": { "f1Score": { "baselineValue": 0.86, "comparisonValue": 0.91, "difference": 0.05, "liftPercent": 5.8 }, "precision": { "…": "…" }, "recall": { "…": "…" }, "accuracy": { "…": "…" } } } ] } ``` Each entry carries its metric bundle (aggregate + per-field accuracy, latency, and a dataset baseline when present). The baseline entry has no `lift`; every other entry's `lift` gives the difference and percent change against it. `GET /v3/model-comparisons` lists comparisons newest-first, paginated with `limit` (default 50, max 100) and a `startingAfter` comparison-ID cursor. 5\. Surface likely-mislabeled data [#5-surface-likely-mislabeled-data] Because a comparison grades several **independent** functions against the same labels, it is a strong label-error detector: when several entries **agree with each other** on a value but **disagree with the label**, the label is the likely error — not the models. There is no dedicated endpoint; it is a read over the comparison itself. Fetch it with `?includeRowResults=true` (and your chosen `matchMode`): ```http GET /v3/model-comparisons/{comparisonID}?includeRowResults=true&matchMode=normalized ``` Each entry then carries `rowResults: [{ rowKey, fields: [{ path, category, expected, actual }] }]`, where `category` is `match` / `mismatch` / `missing` / `extra` — **re-matched under the same `matchMode` as the metrics**, so a surface-form difference you've told the comparison to treat as equal won't show up as a false mislabel. Group by `(rowKey, path)`: where the entries whose `category` is `mismatch` converge on the same `actual`, flag that row for review. Reference [#reference] | Concept | Id prefix | Meaning | | ---------- | ---------- | ---------------------------------------------------- | | Dataset | `gds_` | Inputs + expected JSON (your ground truth) | | Comparison | `cmp_` | One dataset × several entries | | Entry | — | One function version graded; entry 0 is the baseline | | Score run | `evalrun_` | The per-entry grading run + per-field detail | **Comparison status** (rolled up from the entries' score runs): `pending` → `running` → `complete` when all entries succeed, `partial` if some entries errored but at least one succeeded (the successful results are still usable), `error` if all failed, or `cancelled` if you cancel it. **Cancel** a running comparison with `POST /v3/model-comparisons/{comparisonID}/cancel` — it stops the still-running entries; already-finished entries keep their results. **Scoring is a read-time decision.** Creating a run or a comparison only dispatches the extractions; nothing about how the output is judged is fixed at that point. Change the settings on the GET and the numbers change, with no re-run: | Endpoint | Structure | Strictness | | ------------------------------------------ | --------------- | ----------------------- | | `GET /v3/eval/score/{scoreRunID}` | — | exact, not configurable | | `GET /v3/model-comparisons/{comparisonID}` | `orderMatching` | `matchMode` | There are deliberately no tunable thresholds and no way to exclude fields: `matchMode=normalized` already compares numbers numerically and dates by calendar value rather than by spelling, and `fuzzy` adds a fixed similarity pass over free text. A percentage band, a similarity cutoff, or a list of fields to skip would each do the same thing — quietly stop counting something that was wrong. Each score run's `fieldResults` carry the evidence behind every verdict — `delta` for numeric pairs, `similarity` (Levenshtein ratio) for string pairs. They tell you how close a wrong value was, which is worth knowing and never makes it right. # Customer Ontology (/guide/ontology) > For the complete documentation index, see [llms.txt](/llms.txt). bem builds an entity memory automatically as it parses documents — the nodes and edges you read through the [Knowledge Graph API](/guide/knowledge-graph) and the [File System API](/guide/file-system)'s `find` / `open` / `xref` ops. The **customer ontology** endpoints let you shape that memory ahead of time: define your own entity **types**, attach **synonyms** to entities, and **seed** a whole catalog in one request so the matcher recognizes your vocabulary from the first document it sees. ``` POST /v3/entity-types POST /v3/entities/bulk POST /v3/entities/{id}/synonyms x-api-key: ``` Concepts [#concepts] Entity types [#entity-types] An **entity type** is a category in your taxonomy — `organization`, `person`, `product`, `manufacturer`, `location`. bem ships with a small default set, but you can define your own and arrange them into a hierarchy. * **Custom taxonomy.** Create the types your domain actually uses. A catalog might define `equipment`, `manufacturer`, and `accessory` rather than leaning on the generic `product`. * **Parent types.** A type can declare a `parentTypeId`, so `accessory` can sit under `equipment`. The matcher uses the hierarchy to resolve and roll up entities; a query for the parent type also covers its children. * **Attribute schema.** A type can carry an `attributeSchema` — a JSON Schema describing the structured fields entities of that type may hold (e.g. `weightKg`, `voltage`, `sku`). Seeded entities validate their `attributes` against it. Synonyms [#synonyms] A **synonym** is an alternate surface form for an entity — "Acme Corp", "Acme Corporation", and "ACME" all pointing at one canonical entity. Synonyms are first-class records, not free text, and each one carries a `source` that records its provenance: | `source` | Where it comes from | | ------------------ | ------------------------------------------------------ | | `extracted` | bem inferred the surface form while parsing a document | | `customer_defined` | You added it via the API or a CSV seed | | `sme_approved` | A subject-matter expert reviewed and confirmed it | When the matcher reads a new document, every synonym — whatever its source — is a candidate surface form for resolving a mention back to the canonical entity. Seeding `customer_defined` synonyms up front is how you teach the matcher the spellings, abbreviations, and trade names your documents actually use before it has seen them in context. These three surfaces work together: **entity types** shape the taxonomy, **synonyms** widen what resolves to each entity, and the [Knowledge Graph](/guide/knowledge-graph) and [File System](/guide/file-system) APIs read the result back out. Seeding via the API [#seeding-via-the-api] `POST /v3/entities/bulk` creates or merges many entities in one call. A seeded `type` resolves to an existing entity type or creates one; each string in `synonyms` is inserted as a `customer_defined` synonym. | Field | Notes | | ------------ | ------------------------------------------------------------------------------------------------- | | `bucket` | `bkt_…`; absent → the default bucket for the account + environment | | `entities[]` | Each: `canonical`, `type`, optional `description`, `synonyms` (`string[]`), `attributes` (object) | | `onConflict` | `"merge"` — see [merge semantics](#onconflictmerge-semantics) | ```bash curl -X POST "https://api.bem.ai/v3/entities/bulk" \ -H "x-api-key: $BEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "bucket": "bkt_catalog", "onConflict": "merge", "entities": [ { "canonical": "Acme Corporation", "type": "manufacturer", "description": "Industrial equipment maker", "synonyms": ["Acme Corp", "ACME"] }, { "canonical": "Acme Forklift X200", "type": "equipment", "synonyms": ["X200", "Acme X200"], "attributes": { "manufacturer": "Acme Corporation", "weightKg": 3200 } } ] }' ``` Sync vs async [#sync-vs-async] The request size decides the response shape: * **Fewer than 100 entities → `200`, processed inline.** You get the per-row outcomes back in the same response. ```json { "results": [ { "canonical": "Acme Corporation", "outcome": "created", "entityID": "ent_acme" }, { "canonical": "Acme Forklift X200", "outcome": "merged-with", "entityID": "ent_x200" }, { "canonical": "", "outcome": "rejected", "reason": "canonical is required" } ], "summary": { "created": 1, "merged": 1, "rejected": 1 } } ``` * **100 or more entities → `202`, processed as a background job.** You get a `seedJobID` and a `statusURL` to poll. ```json { "seedJobID": "seed_7f3a9c", "status": "pending", "statusURL": "/v3/entities/seed/seed_7f3a9c" } ``` Polling a seed job [#polling-a-seed-job] Poll the `statusURL` (`GET /v3/entities/seed/{id}`) until `status` is terminal. While running, only the counts are populated; the full `results` array appears once the job finishes. ```bash curl "https://api.bem.ai/v3/entities/seed/seed_7f3a9c" \ -H "x-api-key: $BEM_API_KEY" ``` ```json { "status": "completed", "totalRows": 240, "createdCount": 198, "mergedCount": 40, "rejectedCount": 2, "results": [ { "canonical": "Acme Corporation", "outcome": "created", "entityID": "ent_acme" } ], "error": null } ``` A non-null `error` means the job itself failed; per-row problems show up as `rejected` rows with a `reason`, not as a job-level error. `onConflict=merge` semantics [#onconflictmerge-semantics] A seeded entity conflicts with an existing one when both the `canonical` name **and** the `type` match. With `onConflict: "merge"`, the existing entity is updated in place rather than duplicated: * **Synonyms** merge **additively** — seeded synonyms are added as `customer_defined`; existing synonyms (including `extracted` ones) are kept. * **Description** is updated to the seeded value when one is provided. * **Attributes** merge — seeded keys overwrite, untouched keys remain. The row's `outcome` comes back as `merged-with` (with the surviving `entityID`) so you can tell merges from fresh `created` rows. Seeding via CSV upload [#seeding-via-csv-upload] For non-engineers, the dashboard route **`/memory/seed`** wraps the same bulk endpoint in a drag-and-drop CSV flow. **Columns** * **`canonical`** *(required)* — the entity's canonical name. * **`type`** *(required)* — the entity type; resolves or creates it. * **`description`** *(optional)* — free-text description. * **`synonyms`** *(optional)* — a **semicolon-separated** list of surface forms, each inserted as `customer_defined`. * **Any other column** becomes a per-entity **attribute**, keyed by the column header. ```csv canonical,type,description,synonyms,manufacturer,weightKg Acme Corporation,manufacturer,Industrial equipment maker,Acme Corp;ACME,, Acme Forklift X200,equipment,,X200;Acme X200,Acme Corporation,3200 ``` **The flow** 1. **Preview.** After you drop the file, the dashboard parses it and shows a preview of the rows it will submit so you can confirm the column mapping before anything is written. 2. **Submit and watch progress.** On submit the rows run through the bulk endpoint with live progress, using the same sync/async split as the API (large files become a background seed job). 3. **Rejected-rows CSV.** When the run finishes, any rejected rows are offered as a **downloadable CSV** — the original columns plus the `reason` — so you can fix and re-upload just those. Day-2 management [#day-2-management] The seed is a starting point; you keep the ontology current with the synonym and entity-type endpoints. Adding and removing synonyms [#adding-and-removing-synonyms] ```bash # add a customer-defined synonym (upgrades an existing extracted one in place) curl -X POST "https://api.bem.ai/v3/entities/ent_acme/synonyms" \ -H "x-api-key: $BEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Acme Inc.", "locale": "en-US" }' # delete a synonym curl -X DELETE "https://api.bem.ai/v3/entities/ent_acme/synonyms/syn_123" \ -H "x-api-key: $BEM_API_KEY" ``` `POST` adds the synonym with `source: customer_defined`. If the same text already exists as an `extracted` synonym, it is **upgraded** in place to `customer_defined` rather than duplicated. `locale` is optional. `DELETE` only removes `customer_defined` or `sme_approved` synonyms. Trying to delete an `extracted` synonym returns **`409`** — bem learned it from a document, so you can't hand-delete it; it goes away when the mentions do. Synonym changes honor **alias resolution**: if `{id}` points at an entity that was later merged away, the request resolves to the surviving entity and operates on its synonym set. Managing entity types [#managing-entity-types] `GET` / `POST` / `PATCH` / `DELETE /v3/entity-types` manage the taxonomy. The body is `{ name, description?, parentTypeId?, attributeSchema? }`. `GET` takes an optional `name` query param — a case-insensitive substring match — to search the taxonomy instead of listing all of it. Two rules to know: * **`name` is immutable.** `PATCH` can change `description`, `parentTypeId`, or `attributeSchema`, but not `name` — entities reference the type by name, so renaming is not allowed. * **`DELETE` is blocked when the type is in use.** If any entity uses the type, or the type has child types, `DELETE` returns **`409`**. Reassign or remove the dependents first. Worked example: seeding a small catalog [#worked-example-seeding-a-small-catalog] A realistic seed is wide but shallow — on the order of **\~20 types × \~25 synonyms each** for an equipment catalog. You don't send 500 lines of JSON; you send the *shape* below and let the row count grow. First, the types (define the taxonomy and its hierarchy once): ```bash curl -X POST "https://api.bem.ai/v3/entity-types" \ -H "x-api-key: $BEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "equipment", "description": "Physical machines in the catalog", "attributeSchema": { "type": "object", "properties": { "manufacturer": { "type": "string" }, "weightKg": { "type": "number" } } } }' # a child type under "equipment" curl -X POST "https://api.bem.ai/v3/entity-types" \ -H "x-api-key: $BEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "accessory", "parentTypeId": "etype_equipment" }' ``` Then seed the entities for those types in one bulk call (truncated — the real body repeats this row shape across all \~20 types): ```json { "bucket": "bkt_catalog", "onConflict": "merge", "entities": [ { "canonical": "Acme Forklift X200", "type": "equipment", "synonyms": ["X200", "Acme X200", "Forklift X200", "AX-200"], "attributes": { "manufacturer": "Acme Corporation", "weightKg": 3200 } }, { "canonical": "Globex Pallet Jack PJ-5", "type": "equipment", "synonyms": ["PJ-5", "Globex PJ5", "Pallet Jack 5"], "attributes": { "manufacturer": "Globex Holdings", "weightKg": 95 } } ] } ``` With \~25 synonyms behind each entity, the matcher resolves the abbreviations, SKUs, and trade names in your documents back to the right canonical entity from the first parse — no warm-up period. See also [#see-also] Read back the entities and relations your ontology shapes. Per-entity memory ops — `find`, `open`, `xref` — over the same entities. # Polling and Retries (/guide/polling-and-retries) > For the complete documentation index, see [llms.txt](/llms.txt). Workflow calls run asynchronously by default. There are three ways to find out when one finishes: 1. **Synchronous wait** — pass `wait=true` and bem holds the response open for up to 30 seconds. 2. **Polling** — call `GET /v3/calls/{callID}` until `status` is terminal. 3. **Webhooks** — subscribe a function to a URL and receive event deliveries (see [Webhooks](/guide/webhooks)). Use whichever fits your workload. Synchronous waits are the simplest for interactive flows; webhooks are the right choice for high-volume backends; polling is the universal fallback. Synchronous waits (`wait=true`) [#synchronous-waits-waittrue] `wait=true` on `POST /v3/workflows/{workflowName}/call` holds the response open for up to 30 seconds. If the call finishes inside that window you get `200 OK` (or `500` on failure) with the final result; if it doesn't, you get `202 Accepted` with the in-progress call object and you fall back to polling or wait for the webhook. For the full contract — request shape, latency expectations, language-by-language access patterns, HTTP-client timeout configuration, and the production patterns that combine sync mode with webhooks — see [Synchronous Mode](/guide/synchronous-mode). Polling [#polling] `GET /v3/calls/{callID}` returns the current state of any call. The `status` field is the one to switch on: | Status | Terminal? | What it means | | ----------- | --------- | --------------------------------------------------- | | `pending` | No | Queued, not yet picked up by a worker | | `running` | No | At least one node is executing | | `completed` | Yes | Every terminal node finished without an error event | | `failed` | Yes | One or more terminal nodes produced an error event | Recommended cadence: * **Initial wait**: 500ms–1s. Most simple workflows finish well under 5s. * **Backoff**: double after each unsuccessful poll, with jitter, capping at \~10s. A capped exponential of `0.5, 1, 2, 4, 8, 10, 10, 10, …` is a reasonable default. * **Deadline**: pick one based on your workflow's expected runtime. Multi-step workflows with split/extract chains can run for tens of seconds; OCR-heavy pages can take minutes. If you don't have a deadline, fall back to webhooks. ```python import time from bem import Bem client = Bem() call_id = "wc_abc123" delay = 0.5 deadline = time.time() + 120 # 2-minute deadline while True: call = client.calls.retrieve(call_id).call if call.status in ("completed", "failed"): break if time.time() > deadline: raise TimeoutError(f"call {call_id} did not finish in time") time.sleep(delay) delay = min(delay * 2, 10) ``` For per-node visibility (which node ran, which event it emitted, why a particular node failed), fetch the trace at `GET /v3/calls/{callID}/trace`. The trace is incremental — it grows as the call progresses, so it's also pollable mid-execution. Idempotency via `callReferenceID` [#idempotency-via-callreferenceid] `callReferenceID` is your client-side deduplication key. When you submit the same `callReferenceID` against the same workflow within a short retention window, bem returns the existing call instead of creating a new one — safe to retry network failures without producing duplicates. ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); // Retrying this exact request with the same callReferenceID is safe. const { call } = await client.workflows.call("invoice-processing", { callReferenceID: `invoice:${invoiceID}`, input: { singleFile: { inputContent, inputType: "pdf" } }, wait: true, }); ``` Pick a `callReferenceID` that's deterministic from your domain — the invoice ID, the document UUID, the user-and-email-and-timestamp tuple — not a random string. Random IDs defeat the deduplication. If you don't pass a `callReferenceID`, every retry creates a new call. The call objects are cheap, but you'll process the same input multiple times and your downstream systems will see duplicate events. Your `callReferenceID` also comes back inside the extracted output — see [`_metadata` echoes back what you attached to the call](/guide/reading-workflow-call-outputs#_metadata-echoes-back-what-you-attached-to-the-call). Network and server-side retries [#network-and-server-side-retries] | Status | Retry? | How | | ----------------------- | ------ | ----------------------------------------------------------- | | `429 Too Many Requests` | Yes | Honour `Retry-After` if set; otherwise exponential backoff. | | `500`/`502`/`503`/`504` | Yes | Exponential backoff with jitter, max 5 attempts. | | `408 Request Timeout` | Yes | Same as 5xx. | | `400`/`401`/`404`/`422` | No | The request itself needs to change. | The official SDKs implement these defaults — you only need to add explicit retry logic if you're using `fetch` or `requests` directly. See [Errors and status codes](/guide/errors) for the full breakdown. When to use which [#when-to-use-which] | Pattern | Use when | Watch out for | | ----------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `wait=true` | Interactive UIs, scripts, single-shot extracts that finish in seconds | The 30s ceiling — fall back to polling on 202 | | Polling | Batch jobs, CI workflows, simple long-running scripts | Don't poll faster than \~2 calls/sec; honour rate limits | | Webhooks | Production backends, multi-tenant systems, anything where you'd otherwise burn polling traffic | Set up signature verification before going live (see [Webhooks](/guide/webhooks)) | What `failed` looks like, and which codes to retry. Replace polling with push delivery. The polling endpoint reference. # Quickstart (/guide/quickstart) > For the complete documentation index, see [llms.txt](/llms.txt). This guide walks you through running your first bem workflow end to end. By the end you'll have: 1. An extract function with a JSON schema describing the structure you want to pull out 2. A workflow that wires that function into a reusable entry point 3. A synchronous call that returns your structured data in one request (`wait=true`) Pick a language from the tabs in each step — the flow is identical across cURL, the SDKs, and the CLI. Prerequisites [#prerequisites] * A [bem account](https://app.bem.ai) (free to sign up) * An API key generated from **Settings → API Keys** in the bem UI * A document to run through the workflow — grab the sample invoice below Sample invoice [#sample-invoice] Every example on this page runs against the same one-page PDF: a rental invoice from Appleseed Paint Co. Download it into your working directory so the `invoice.pdf` references below resolve: ```bash curl -O https://docs.bem.ai/sample/invoice.pdf ``` Download invoice.pdf directly if you'd rather grab it from the browser. The extracted values shown throughout this guide come from that file, so you can compare your output field by field. Bring your own invoice instead if you prefer — the schema in Step 3 is a starting point, not a fixed contract. Step 1: Set your API key [#step-1-set-your-api-key] Export your API key so the SDKs, CLI, and `curl` examples can pick it up from the environment: ```bash export BEM_API_KEY='your-api-key-here' ``` To persist the key across shell sessions, add that line to your shell profile (such as `~/.zshrc` or `~/.bashrc`). Step 2: Install the SDK [#step-2-install-the-sdk] Skip this step if you're using `curl`. `curl` ships with macOS and most Linux distributions — no install required. ```bash npm install bem-ai-sdk ``` ```bash pip install bem-sdk ``` ```bash go get github.com/bem-team/bem-go-sdk ``` ```bash dotnet add package Bem ``` ```bash brew install bem-team/tools/bem ``` Or, with Go: ```bash go install github.com/bem-team/bem-cli/cmd/bem@latest ``` Step 3: Create an extract function [#step-3-create-an-extract-function] Extract functions pull structured JSON out of unstructured files. The `outputSchema` below describes the fields we want to pull off the sample invoice — header details, the rental line items, and the totals. Customize it to match any document type. The sample invoice states payment terms (`NET 30`) rather than an explicit due date, and prints an address for the bill-to party only, so the schema models those as `paymentTerms` and `billTo` instead of `dueDate` and `vendor.address`. Ask for fields your document actually carries: a field with nothing behind it comes back `null`. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "invoice-extractor", "type": "extract", "displayName": "Invoice Extractor", "outputSchemaName": "Invoice", "outputSchema": { "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": { "type": "string", "description": "Unique invoice identifier" }, "invoiceDate": { "type": "string", "description": "Invoice date (YYYY-MM-DD)" }, "paymentTerms": { "type": "string", "description": "Payment terms as printed, e.g. NET 30" }, "vendor": { "type": "object", "properties": { "name": { "type": "string", "description": "Company issuing the invoice" } } }, "billTo": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number", "description": "Rate charged per unit" }, "rateUnit": { "type": "string", "description": "Unit the rate is charged against, e.g. day, week, panel" }, "amount": { "type": "number" } } } }, "subtotal": { "type": "number" }, "taxAmount": { "type": "number" }, "totalAmount": { "type": "number" } } } }' ``` Save as `create-function.ts`: ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { function: fn } = await client.functions.create({ functionName: "invoice-extractor", type: "extract", displayName: "Invoice Extractor", outputSchemaName: "Invoice", outputSchema: { type: "object", required: ["invoiceNumber", "vendor", "totalAmount"], properties: { invoiceNumber: { type: "string", description: "Unique invoice identifier" }, invoiceDate: { type: "string", description: "Invoice date (YYYY-MM-DD)" }, paymentTerms: { type: "string", description: "Payment terms as printed, e.g. NET 30" }, vendor: { type: "object", properties: { name: { type: "string", description: "Company issuing the invoice" }, }, }, billTo: { type: "object", properties: { name: { type: "string" }, address: { type: "string" }, }, }, lineItems: { type: "array", items: { type: "object", properties: { description: { type: "string" }, quantity: { type: "number" }, unitPrice: { type: "number", description: "Rate charged per unit" }, rateUnit: { type: "string", description: "Unit the rate is charged against, e.g. day, week, panel" }, amount: { type: "number" }, }, }, }, subtotal: { type: "number" }, taxAmount: { type: "number" }, totalAmount: { type: "number" }, }, }, }); console.log(fn); ``` Run it with `npx tsx create-function.ts`. Save as `create_function.py`: ```python from bem import Bem client = Bem() response = client.functions.create( function_name="invoice-extractor", type="extract", display_name="Invoice Extractor", output_schema_name="Invoice", output_schema={ "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": {"type": "string", "description": "Unique invoice identifier"}, "invoiceDate": {"type": "string", "description": "Invoice date (YYYY-MM-DD)"}, "paymentTerms": {"type": "string", "description": "Payment terms as printed, e.g. NET 30"}, "vendor": { "type": "object", "properties": { "name": {"type": "string", "description": "Company issuing the invoice"}, }, }, "billTo": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, }, }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": {"type": "string"}, "quantity": {"type": "number"}, "unitPrice": {"type": "number", "description": "Rate charged per unit"}, "rateUnit": {"type": "string", "description": "Unit the rate is charged against, e.g. day, week, panel"}, "amount": {"type": "number"}, }, }, }, "subtotal": {"type": "number"}, "taxAmount": {"type": "number"}, "totalAmount": {"type": "number"}, }, }, ) print(response.function) ``` Run it with `python create_function.py`. Save as `create_function.go`: ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() schema := map[string]any{ "type": "object", "required": []string{"invoiceNumber", "vendor", "totalAmount"}, "properties": map[string]any{ "invoiceNumber": map[string]any{"type": "string"}, "invoiceDate": map[string]any{"type": "string"}, "paymentTerms": map[string]any{"type": "string"}, "vendor": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, }, }, "billTo": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, "address": map[string]any{"type": "string"}, }, }, "lineItems": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "properties": map[string]any{ "description": map[string]any{"type": "string"}, "quantity": map[string]any{"type": "number"}, "unitPrice": map[string]any{"type": "number"}, "rateUnit": map[string]any{"type": "string"}, "amount": map[string]any{"type": "number"}, }, }, }, "subtotal": map[string]any{"type": "number"}, "taxAmount": map[string]any{"type": "number"}, "totalAmount": map[string]any{"type": "number"}, }, } resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfExtract: &bem.CreateFunctionExtractParam{ FunctionName: "invoice-extractor", DisplayName: bem.String("Invoice Extractor"), OutputSchemaName: bem.String("Invoice"), OutputSchema: schema, }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Function) } ``` Run it with `go run create_function.go`. Save as `CreateFunction.cs`: ```csharp using System.Text.Json; using Bem; using Bem.Models.Functions; BemClient client = new(); var schemaJson = """ { "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": { "type": "string", "description": "Unique invoice identifier" }, "invoiceDate": { "type": "string", "description": "Invoice date (YYYY-MM-DD)" }, "paymentTerms": { "type": "string", "description": "Payment terms as printed, e.g. NET 30" }, "vendor": { "type": "object", "properties": { "name": { "type": "string", "description": "Company issuing the invoice" } } }, "billTo": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number", "description": "Rate charged per unit" }, "rateUnit": { "type": "string", "description": "Unit the rate is charged against, e.g. day, week, panel" }, "amount": { "type": "number" } } } }, "subtotal": { "type": "number" }, "taxAmount": { "type": "number" }, "totalAmount": { "type": "number" } } } """; var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Extract { FunctionName = "invoice-extractor", DisplayName = "Invoice Extractor", OutputSchemaName = "Invoice", OutputSchema = JsonSerializer.Deserialize(schemaJson), }, }); Console.WriteLine(response.Function); ``` Run it from the project directory with `dotnet run`. Put the schema in `invoice-schema.json`: ```json { "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": { "type": "string" }, "invoiceDate": { "type": "string" }, "paymentTerms": { "type": "string" }, "vendor": { "type": "object", "properties": { "name": { "type": "string" } } }, "billTo": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number" }, "rateUnit": { "type": "string" }, "amount": { "type": "number" } } } }, "subtotal": { "type": "number" }, "taxAmount": { "type": "number" }, "totalAmount": { "type": "number" } } } ``` Then create the function: ```bash bem functions create \ --function-name invoice-extractor \ --type extract \ --display-name "Invoice Extractor" \ --output-schema-name Invoice \ --output-schema @invoice-schema.json ``` **Response:** ```json { "function": { "functionID": "fn_2abc123xyz", "functionName": "invoice-extractor", "displayName": "Invoice Extractor", "type": "extract", "currentVersionNum": 1 } } ``` ```typescript { functionID: 'fn_2abc123xyz', functionName: 'invoice-extractor', displayName: 'Invoice Extractor', type: 'extract', currentVersionNum: 1 } ``` ```python Function( function_id='fn_2abc123xyz', function_name='invoice-extractor', display_name='Invoice Extractor', type='extract', current_version_num=1, ) ``` ```go bem.Function{ FunctionID: "fn_2abc123xyz", FunctionName: "invoice-extractor", DisplayName: "Invoice Extractor", Type: "extract", CurrentVersionNum: 1, } ``` ```csharp Bem.Models.Functions.Function { FunctionID = "fn_2abc123xyz", FunctionName = "invoice-extractor", DisplayName = "Invoice Extractor", Type = "extract", CurrentVersionNum = 1 } ``` ```json { "function": { "functionID": "fn_2abc123xyz", "functionName": "invoice-extractor", "displayName": "Invoice Extractor", "type": "extract", "currentVersionNum": 1 } } ``` Step 4: Create a workflow [#step-4-create-a-workflow] A workflow wires one or more functions into a reusable entry point. For a single-function pipeline you only need one node and no edges. ```bash curl -X POST https://api.bem.ai/v3/workflows \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "name": "invoice-processing", "displayName": "Invoice Processing Workflow", "tags": ["invoices", "financial-data"], "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } } ] }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { workflow } = await client.workflows.create({ name: "invoice-processing", displayName: "Invoice Processing Workflow", tags: ["invoices", "financial-data"], mainNodeName: "invoice-extractor", nodes: [ { name: "invoice-extractor", function: { name: "invoice-extractor" }, }, ], }); console.log(workflow); ``` ```python from bem import Bem client = Bem() response = client.workflows.create( name="invoice-processing", display_name="Invoice Processing Workflow", tags=["invoices", "financial-data"], main_node_name="invoice-extractor", nodes=[ { "name": "invoice-extractor", "function": {"name": "invoice-extractor"}, } ], ) print(response.workflow) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() resp, err := client.Workflows.New(context.TODO(), bem.WorkflowNewParams{ Name: "invoice-processing", DisplayName: bem.String("Invoice Processing Workflow"), Tags: []string{"invoices", "financial-data"}, MainNodeName: "invoice-extractor", Nodes: []bem.WorkflowNewParamsNode{ { Name: bem.String("invoice-extractor"), Function: bem.FunctionVersionIdentifierParam{ Name: bem.String("invoice-extractor"), }, }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Workflow) } ``` ```csharp using Bem; using Bem.Models.Workflows; BemClient client = new(); var response = await client.Workflows.Create(new WorkflowCreateParams { Name = "invoice-processing", DisplayName = "Invoice Processing Workflow", Tags = new List { "invoices", "financial-data" }, MainNodeName = "invoice-extractor", Nodes = new List { new Node { Function = new FunctionVersionIdentifier { Name = "invoice-extractor" }, Name = "invoice-extractor", }, }, }); Console.WriteLine(response.Workflow); ``` ```bash bem workflows create \ --name invoice-processing \ --display-name "Invoice Processing Workflow" \ --tags '["invoices", "financial-data"]' \ --main-node-name invoice-extractor \ --node '{name: invoice-extractor, function: {name: invoice-extractor}}' ``` **Response:** ```json { "workflow": { "id": "wf_2def456abc", "name": "invoice-processing", "displayName": "Invoice Processing Workflow", "versionNum": 1, "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor", "versionNum": 1 } } ], "edges": [] } } ``` ```typescript { id: 'wf_2def456abc', name: 'invoice-processing', displayName: 'Invoice Processing Workflow', versionNum: 1, mainNodeName: 'invoice-extractor', nodes: [ { name: 'invoice-extractor', function: { name: 'invoice-extractor', versionNum: 1 } } ], edges: [] } ``` ```python Workflow( id='wf_2def456abc', name='invoice-processing', display_name='Invoice Processing Workflow', version_num=1, main_node_name='invoice-extractor', nodes=[ WorkflowNodeResponse( name='invoice-extractor', function=FunctionVersionIdentifier(name='invoice-extractor', version_num=1), ) ], edges=[], ) ``` ```go bem.Workflow{ ID: "wf_2def456abc", Name: "invoice-processing", DisplayName: "Invoice Processing Workflow", VersionNum: 1, MainNodeName: "invoice-extractor", Nodes: []bem.WorkflowNodeResponse{ { Name: "invoice-extractor", Function: bem.FunctionVersionIdentifier{ Name: "invoice-extractor", VersionNum: 1, }, }, }, Edges: []bem.WorkflowEdgeResponse{}, } ``` ```csharp Bem.Models.Workflows.Workflow { ID = "wf_2def456abc", Name = "invoice-processing", DisplayName = "Invoice Processing Workflow", VersionNum = 1, MainNodeName = "invoice-extractor", Nodes = [ WorkflowNodeResponse { Name = "invoice-extractor", Function = FunctionVersionIdentifier { Name = "invoice-extractor", VersionNum = 1 } } ], Edges = [] } ``` ```json { "workflow": { "id": "wf_2def456abc", "name": "invoice-processing", "displayName": "Invoice Processing Workflow", "versionNum": 1, "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor", "versionNum": 1 } } ], "edges": [] } } ``` Step 5: Call the workflow synchronously [#step-5-call-the-workflow-synchronously] Pass `wait=true` (query param for JSON, form field for multipart) to block until the call finishes and return the completed result in the same response. The endpoint waits up to 30 seconds — longer-running calls still return a `pending` call object that you can poll or subscribe to. JSON body with base64-encoded file: ```bash curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "callReferenceID": "invoice-001", "input": { "singleFile": { "inputType": "pdf", "inputContent": "'"$(base64 -i invoice.pdf)"'" } } }' ``` Or, upload the file as multipart form data: ```bash curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call" \ -H "x-api-key: $BEM_API_KEY" \ -F "wait=true" \ -F "callReferenceID=invoice-001" \ -F "file=@invoice.pdf" ``` ```typescript import fs from "node:fs"; import Bem from "bem-ai-sdk"; const client = new Bem(); const inputContent = fs.readFileSync("invoice.pdf").toString("base64"); const { call } = await client.workflows.call("invoice-processing", { wait: true, callReferenceID: "invoice-001", input: { singleFile: { inputType: "pdf", inputContent, }, }, }); console.log(call?.status); console.log(call?.outputs); ``` ```python import base64 from bem import Bem client = Bem() with open("invoice.pdf", "rb") as f: input_content = base64.b64encode(f.read()).decode() response = client.workflows.call( "invoice-processing", wait=True, call_reference_id="invoice-001", input={ "single_file": { "input_type": "pdf", "input_content": input_content, } }, ) print(response.call.status) print(response.call.outputs) ``` ```go package main import ( "context" "encoding/base64" "fmt" "os" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() data, err := os.ReadFile("invoice.pdf") if err != nil { panic(err) } encoded := base64.StdEncoding.EncodeToString(data) resp, err := client.Workflows.Call(context.TODO(), "invoice-processing", bem.WorkflowCallParams{ Wait: bem.Bool(true), CallReferenceID: bem.String("invoice-001"), Input: bem.WorkflowCallParamsInput{ SingleFile: &bem.WorkflowCallParamsInputSingleFile{ InputType: "pdf", InputContent: encoded, }, }, }) if err != nil { panic(err) } fmt.Printf("status=%s outputs=%d\n", resp.Call.Status, len(resp.Call.Outputs)) } ``` ```csharp using Bem; using Bem.Models.Workflows; BemClient client = new(); var fileBytes = await File.ReadAllBytesAsync("invoice.pdf"); var inputContent = Convert.ToBase64String(fileBytes); var response = await client.Workflows.Call("invoice-processing", new WorkflowCallParams { Wait = true, CallReferenceID = "invoice-001", Input = new Input { SingleFile = new FileInput { InputType = InputType.Pdf, InputContent = inputContent, }, }, }); Console.WriteLine($"status={response.Call.Status} outputs={response.Call.Outputs.Count}"); ``` The CLI's `@path/to/file` syntax reads and base64-encodes the file for you. ```bash bem workflows call \ --workflow-name invoice-processing \ --call-reference-id invoice-001 \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' \ --wait ``` **Important:** `--wait` is a boolean flag. Use `--wait` or `--wait=true` — not `--wait true` with a space. **Response when the call finishes in time (HTTP 200):** ```json { "call": { "callID": "wc_2ghi789def", "status": "completed", "workflowName": "invoice-processing", "workflowVersionNum": 1, "callReferenceID": "invoice-001", "createdAt": "2024-01-15T10:30:00Z", "finishedAt": "2024-01-15T10:30:12Z", "outputs": [ { "eventID": "evt_3AlB5PRZFCwAxrwHziH9PQkbkav", "eventType": "transform", "transformedContent": { "invoiceNumber": "FE-2421 C", "invoiceDate": "2025-07-14", "paymentTerms": "NET 30", "vendor": { "name": "Appleseed Paint Co." }, "billTo": { "name": "Pacific Stageworks", "address": "1129 Manna Blvd, San Diego, CA 92101" }, "lineItems": [ { "description": "Temporary Fence Panel, 6ft x 12ft", "quantity": 40, "unitPrice": 22.0, "rateUnit": "panel", "amount": 880.0 }, { "description": "Paint Sprayer Kit, HVLP Pro", "quantity": 3, "unitPrice": 95.0, "rateUnit": "day", "amount": 285.0 }, { "description": "Extension Ladder, 24ft Aluminum", "quantity": 6, "unitPrice": 18.0, "rateUnit": "day", "amount": 108.0 }, { "description": "Portable Generator, 7000W Quiet Series", "quantity": 2, "unitPrice": 12.0, "rateUnit": "day", "amount": 240.0 }, { "description": "High-Visibility Job Site Lighting Kit", "quantity": 4, "unitPrice": 75.0, "rateUnit": "day", "amount": 300.0 }, { "description": "Paint Containment Tent, Mobile", "quantity": 1, "unitPrice": 65.0, "rateUnit": "week", "amount": 650.0 } ], "subtotal": 2463.0, "taxAmount": 215.51, "totalAmount": 2678.51 } } ], "errors": [], "url": "/v3/calls/wc_2ghi789def", "traceUrl": "/v3/calls/wc_2ghi789def/trace" } } ``` ```typescript { callID: 'wc_2ghi789def', status: 'completed', workflowName: 'invoice-processing', workflowVersionNum: 1, callReferenceID: 'invoice-001', createdAt: '2024-01-15T10:30:00Z', finishedAt: '2024-01-15T10:30:12Z', outputs: [ { eventID: 'evt_3AlB5PRZFCwAxrwHziH9PQkbkav', eventType: 'transform', transformedContent: { invoiceNumber: 'FE-2421 C', invoiceDate: '2025-07-14', paymentTerms: 'NET 30', vendor: { name: 'Appleseed Paint Co.' }, billTo: { name: 'Pacific Stageworks', address: '1129 Manna Blvd, San Diego, CA 92101' }, lineItems: [ { description: 'Temporary Fence Panel, 6ft x 12ft', quantity: 40, unitPrice: 22, rateUnit: 'panel', amount: 880 }, { description: 'Paint Sprayer Kit, HVLP Pro', quantity: 3, unitPrice: 95, rateUnit: 'day', amount: 285 }, { description: 'Extension Ladder, 24ft Aluminum', quantity: 6, unitPrice: 18, rateUnit: 'day', amount: 108 }, { description: 'Portable Generator, 7000W Quiet Series', quantity: 2, unitPrice: 12, rateUnit: 'day', amount: 240 }, { description: 'High-Visibility Job Site Lighting Kit', quantity: 4, unitPrice: 75, rateUnit: 'day', amount: 300 }, { description: 'Paint Containment Tent, Mobile', quantity: 1, unitPrice: 65, rateUnit: 'week', amount: 650 } ], subtotal: 2463, taxAmount: 215.51, totalAmount: 2678.51 } } ], errors: [], url: '/v3/calls/wc_2ghi789def', traceUrl: '/v3/calls/wc_2ghi789def/trace' } ``` ```python CallV3( call_id='wc_2ghi789def', status='completed', workflow_name='invoice-processing', workflow_version_num=1, call_reference_id='invoice-001', created_at=datetime.datetime(2024, 1, 15, 10, 30, tzinfo=tzutc()), finished_at=datetime.datetime(2024, 1, 15, 10, 30, 12, tzinfo=tzutc()), outputs=[ Event( event_id='evt_3AlB5PRZFCwAxrwHziH9PQkbkav', event_type='transform', transformed_content={ 'invoiceNumber': 'FE-2421 C', 'invoiceDate': '2025-07-14', 'paymentTerms': 'NET 30', 'vendor': {'name': 'Appleseed Paint Co.'}, 'billTo': {'name': 'Pacific Stageworks', 'address': '...'}, 'lineItems': [...], 'subtotal': 2463.0, 'taxAmount': 215.51, 'totalAmount': 2678.51, }, ) ], errors=[], url='/v3/calls/wc_2ghi789def', trace_url='/v3/calls/wc_2ghi789def/trace', ) ``` ```go bem.CallV3{ CallID: "wc_2ghi789def", Status: "completed", WorkflowName: "invoice-processing", WorkflowVersionNum: 1, CallReferenceID: "invoice-001", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), FinishedAt: time.Date(2024, 1, 15, 10, 30, 12, 0, time.UTC), Outputs: []bem.Event{ { EventID: "evt_3AlB5PRZFCwAxrwHziH9PQkbkav", EventType: "transform", TransformedContent: map[string]any{ "invoiceNumber": "FE-2421 C", "invoiceDate": "2025-07-14", "paymentTerms": "NET 30", "vendor": map[string]any{ "name": "Appleseed Paint Co.", }, "billTo": map[string]any{ "name": "Pacific Stageworks", "address": "1129 Manna Blvd, San Diego, CA 92101", }, "lineItems": []any{ map[string]any{"description": "Temporary Fence Panel, 6ft x 12ft", "quantity": 40, "unitPrice": 22.0, "rateUnit": "panel", "amount": 880.0}, map[string]any{"description": "Paint Sprayer Kit, HVLP Pro", "quantity": 3, "unitPrice": 95.0, "rateUnit": "day", "amount": 285.0}, map[string]any{"description": "Extension Ladder, 24ft Aluminum", "quantity": 6, "unitPrice": 18.0, "rateUnit": "day", "amount": 108.0}, map[string]any{"description": "Portable Generator, 7000W Quiet Series", "quantity": 2, "unitPrice": 12.0, "rateUnit": "day", "amount": 240.0}, map[string]any{"description": "High-Visibility Job Site Lighting Kit", "quantity": 4, "unitPrice": 75.0, "rateUnit": "day", "amount": 300.0}, map[string]any{"description": "Paint Containment Tent, Mobile", "quantity": 1, "unitPrice": 65.0, "rateUnit": "week", "amount": 650.0}, }, "subtotal": 2463.0, "taxAmount": 215.51, "totalAmount": 2678.51, }, }, }, Errors: []bem.ErrorEvent{}, URL: "/v3/calls/wc_2ghi789def", TraceURL: "/v3/calls/wc_2ghi789def/trace", } ``` ```csharp Bem.Models.Calls.CallV3 { CallID = "wc_2ghi789def", Status = "completed", WorkflowName = "invoice-processing", WorkflowVersionNum = 1, CallReferenceID = "invoice-001", CreatedAt = 2024-01-15T10:30:00Z, FinishedAt = 2024-01-15T10:30:12Z, Outputs = [ Event { EventID = "evt_3AlB5PRZFCwAxrwHziH9PQkbkav", EventType = "transform", TransformedContent = { "invoiceNumber": "FE-2421 C", "invoiceDate": "2025-07-14", "paymentTerms": "NET 30", "vendor": { "name": "Appleseed Paint Co." }, "billTo": { "name": "Pacific Stageworks", "address": "..." }, "lineItems": [ /* ... */ ], "subtotal": 2463.0, "taxAmount": 215.51, "totalAmount": 2678.51 } } ], Errors = [], Url = "/v3/calls/wc_2ghi789def", TraceUrl = "/v3/calls/wc_2ghi789def/trace" } ``` ```json { "call": { "callID": "wc_2ghi789def", "status": "completed", "workflowName": "invoice-processing", "workflowVersionNum": 1, "callReferenceID": "invoice-001", "createdAt": "2024-01-15T10:30:00Z", "finishedAt": "2024-01-15T10:30:12Z", "outputs": [ { "eventID": "evt_3AlB5PRZFCwAxrwHziH9PQkbkav", "eventType": "extract", "transformedContent": { "invoiceNumber": "FE-2421 C", "invoiceDate": "2025-07-14", "paymentTerms": "NET 30", "vendor": { "name": "Appleseed Paint Co." }, "billTo": { "name": "Pacific Stageworks", "address": "1129 Manna Blvd, San Diego, CA 92101" }, "lineItems": [ { "description": "Temporary Fence Panel, 6ft x 12ft", "quantity": 40, "unitPrice": 22.0, "rateUnit": "panel", "amount": 880.0 }, { "description": "Paint Sprayer Kit, HVLP Pro", "quantity": 3, "unitPrice": 95.0, "rateUnit": "day", "amount": 285.0 }, { "description": "Extension Ladder, 24ft Aluminum", "quantity": 6, "unitPrice": 18.0, "rateUnit": "day", "amount": 108.0 }, { "description": "Portable Generator, 7000W Quiet Series", "quantity": 2, "unitPrice": 12.0, "rateUnit": "day", "amount": 240.0 }, { "description": "High-Visibility Job Site Lighting Kit", "quantity": 4, "unitPrice": 75.0, "rateUnit": "day", "amount": 300.0 }, { "description": "Paint Containment Tent, Mobile", "quantity": 1, "unitPrice": 65.0, "rateUnit": "week", "amount": 650.0 } ], "subtotal": 2463.0, "taxAmount": 215.51, "totalAmount": 2678.51 } } ], "errors": [], "url": "/v3/calls/wc_2ghi789def", "traceUrl": "/v3/calls/wc_2ghi789def/trace" } } ``` Tip: pipe through `jq '.call.outputs[0].transformedContent'` to extract just the extracted fields. Reading the response [#reading-the-response] The extracted data lives at **`call.outputs[0].transformedContent`** — note the plural `outputs[]` (it's always an array, even with one terminal node) and the top-level `transformedContent` (the payload is on the event itself, not nested under a `transformation` field). For workflows where the terminal node is an Enrich function, the payload is at `enrichedContent` instead; the field name is keyed off `eventType`. ```bash # With jq curl ... | jq '.call.outputs[0].transformedContent' ``` ```typescript const { call } = await client.workflows.call("invoice-processing", { wait: true, /* ... */ }); const data = call?.outputs?.[0]?.transformedContent; console.log(data?.invoiceNumber, data?.totalAmount); ``` ```python response = client.workflows.call("invoice-processing", wait=True, ...) data = response.call.outputs[0].transformed_content print(data["invoiceNumber"], data["totalAmount"]) ``` ```go resp, err := client.Workflows.Call(ctx, "invoice-processing", bem.WorkflowCallParams{Wait: bem.Bool(true) /* ... */}) if err != nil { panic(err) } data := resp.Call.Outputs[0].TransformedContent fmt.Printf("%+v\n", data) ``` ```csharp var response = await client.Workflows.Call("invoice-processing", new WorkflowCallParams { Wait = true /* ... */ }); var data = response.Call.Outputs[0].TransformedContent; Console.WriteLine(data); ``` ```bash bem workflows call --workflow-name invoice-processing --wait \ --call-reference-id invoice-001 \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' \ --transform 'call.outputs.0.transformedContent' ``` The `--transform` flag uses [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) so the CLI prints just the extracted JSON. For the full per-event-type field map, the `outputs[]` array semantics, and accessor patterns when a workflow has multiple terminal nodes (branching/splitting), see [Reading workflow call outputs](/guide/reading-workflow-call-outputs). If the call takes longer than 30 seconds, the endpoint returns **HTTP 202** with `status: "pending"` or `"running"`. When that happens, either poll `GET /v3/calls/{callID}` or configure a [webhook subscription](#webhook-delivery-optional) to receive the result asynchronously. Expanding your workflow [#expanding-your-workflow] Workflows are easy to iterate on — you can keep layering functions into the DAG as your pipeline grows. To show how that works, let's add an **Enrich** step that matches each extracted line-item description to a SKU in a product catalog. We'll: 1. Create a **collection** to hold the catalog 2. Populate it with a handful of products 3. Create an **enrich** function that does a semantic lookup against the collection 4. Wire the enrich function into `invoice-processing` so it runs after `invoice-extractor` Step 6a: Create a product catalog collection [#step-6a-create-a-product-catalog-collection] ```bash curl -X POST https://api.bem.ai/v3/collections \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{"collectionName": "product_catalog"}' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const collection = await client.collections.create({ collectionName: "product_catalog", }); console.log(collection); ``` ```python from bem import Bem client = Bem() collection = client.collections.create( collection_name="product_catalog", ) print(collection) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() collection, err := client.Collections.New(context.TODO(), bem.CollectionNewParams{ CollectionName: "product_catalog", }) if err != nil { panic(err) } fmt.Printf("%+v\n", collection) } ``` ```csharp using Bem; using Bem.Models.Collections; BemClient client = new(); var collection = await client.Collections.Create(new CollectionCreateParams { CollectionName = "product_catalog", }); Console.WriteLine(collection); ``` ```bash bem collections create --collection-name product_catalog ``` **Response:** ```json { "collectionID": "cl_2N6gH8ZKCmvb6BnFcGqhKJ98VzP", "collectionName": "product_catalog", "itemCount": 0, "createdAt": "2026-04-22T15:30:00Z", "updatedAt": "2026-04-22T15:30:00Z" } ``` ```typescript { collectionID: 'cl_2N6gH8ZKCmvb6BnFcGqhKJ98VzP', collectionName: 'product_catalog', itemCount: 0, createdAt: '2026-04-22T15:30:00Z', updatedAt: '2026-04-22T15:30:00Z' } ``` ```python CollectionCreateResponse( collection_id='cl_2N6gH8ZKCmvb6BnFcGqhKJ98VzP', collection_name='product_catalog', item_count=0, created_at=datetime.datetime(2026, 4, 22, 15, 30, tzinfo=tzutc()), updated_at=datetime.datetime(2026, 4, 22, 15, 30, tzinfo=tzutc()), ) ``` ```go &bem.CollectionNewResponse{ CollectionID: "cl_2N6gH8ZKCmvb6BnFcGqhKJ98VzP", CollectionName: "product_catalog", ItemCount: 0, CreatedAt: time.Date(2026, 4, 22, 15, 30, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 4, 22, 15, 30, 0, 0, time.UTC), } ``` ```csharp Bem.Models.Collections.CollectionCreateResponse { CollectionID = "cl_2N6gH8ZKCmvb6BnFcGqhKJ98VzP", CollectionName = "product_catalog", ItemCount = 0, CreatedAt = 2026-04-22T15:30:00Z, UpdatedAt = 2026-04-22T15:30:00Z } ``` ```json { "collectionID": "cl_2N6gH8ZKCmvb6BnFcGqhKJ98VzP", "collectionName": "product_catalog", "itemCount": 0, "createdAt": "2026-04-22T15:30:00Z", "updatedAt": "2026-04-22T15:30:00Z" } ``` Step 6b: Add products to the collection [#step-6b-add-products-to-the-collection] Each item's `data` can be a plain string or a JSON object. Objects let you retrieve structured fields (SKU, unit cost, category) when a match is found. ```bash curl -X POST https://api.bem.ai/v3/collections/items \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "collectionName": "product_catalog", "items": [ { "data": { "sku": "FENCE-6X12-TMP", "name": "Temporary Fence Panel 6ft x 12ft", "category": "Site Safety", "unitCost": 24.00 } }, { "data": { "sku": "SPRAY-HVLP-PRO", "name": "HVLP Pro Paint Sprayer Kit", "category": "Paint Equipment", "unitCost": 110.00 } }, { "data": { "sku": "LADDER-EXT-24AL", "name": "Extension Ladder 24ft Aluminum", "category": "Access", "unitCost": 20.00 } }, { "data": { "sku": "GEN-7000W-QS", "name": "Portable Generator 7000W Quiet Series","category": "Power", "unitCost": 135.00 } }, { "data": { "sku": "LIGHT-JOBSITE-HV", "name": "High-Visibility Job Site Lighting Kit","category": "Lighting", "unitCost": 85.00 } }, { "data": { "sku": "TENT-CONTAIN-MOB", "name": "Mobile Paint Containment Tent", "category": "Paint Equipment", "unitCost": 700.00 } } ] }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const response = await client.collections.items.add({ collectionName: "product_catalog", items: [ { data: { sku: "FENCE-6X12-TMP", name: "Temporary Fence Panel 6ft x 12ft", category: "Site Safety", unitCost: 24.00 } }, { data: { sku: "SPRAY-HVLP-PRO", name: "HVLP Pro Paint Sprayer Kit", category: "Paint Equipment", unitCost: 110.00 } }, { data: { sku: "LADDER-EXT-24AL", name: "Extension Ladder 24ft Aluminum", category: "Access", unitCost: 20.00 } }, { data: { sku: "GEN-7000W-QS", name: "Portable Generator 7000W Quiet Series", category: "Power", unitCost: 135.00 } }, { data: { sku: "LIGHT-JOBSITE-HV", name: "High-Visibility Job Site Lighting Kit", category: "Lighting", unitCost: 85.00 } }, { data: { sku: "TENT-CONTAIN-MOB", name: "Mobile Paint Containment Tent", category: "Paint Equipment", unitCost: 700.00 } }, ], }); console.log(response); ``` ```python from bem import Bem client = Bem() response = client.collections.items.add( collection_name="product_catalog", items=[ {"data": {"sku": "FENCE-6X12-TMP", "name": "Temporary Fence Panel 6ft x 12ft", "category": "Site Safety", "unitCost": 24.00}}, {"data": {"sku": "SPRAY-HVLP-PRO", "name": "HVLP Pro Paint Sprayer Kit", "category": "Paint Equipment", "unitCost": 110.00}}, {"data": {"sku": "LADDER-EXT-24AL", "name": "Extension Ladder 24ft Aluminum", "category": "Access", "unitCost": 20.00}}, {"data": {"sku": "GEN-7000W-QS", "name": "Portable Generator 7000W Quiet Series", "category": "Power", "unitCost": 135.00}}, {"data": {"sku": "LIGHT-JOBSITE-HV", "name": "High-Visibility Job Site Lighting Kit", "category": "Lighting", "unitCost": 85.00}}, {"data": {"sku": "TENT-CONTAIN-MOB", "name": "Mobile Paint Containment Tent", "category": "Paint Equipment", "unitCost": 700.00}}, ], ) print(response) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() response, err := client.Collections.Items.Add(context.TODO(), bem.CollectionItemAddParams{ CollectionName: "product_catalog", Items: []bem.CollectionItemAddParamsItem{ {Data: map[string]any{"sku": "FENCE-6X12-TMP", "name": "Temporary Fence Panel 6ft x 12ft", "category": "Site Safety", "unitCost": 24.00}}, {Data: map[string]any{"sku": "SPRAY-HVLP-PRO", "name": "HVLP Pro Paint Sprayer Kit", "category": "Paint Equipment", "unitCost": 110.00}}, {Data: map[string]any{"sku": "LADDER-EXT-24AL", "name": "Extension Ladder 24ft Aluminum", "category": "Access", "unitCost": 20.00}}, {Data: map[string]any{"sku": "GEN-7000W-QS", "name": "Portable Generator 7000W Quiet Series", "category": "Power", "unitCost": 135.00}}, {Data: map[string]any{"sku": "LIGHT-JOBSITE-HV", "name": "High-Visibility Job Site Lighting Kit", "category": "Lighting", "unitCost": 85.00}}, {Data: map[string]any{"sku": "TENT-CONTAIN-MOB", "name": "Mobile Paint Containment Tent", "category": "Paint Equipment", "unitCost": 700.00}}, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", response) } ``` ```csharp using System.Text.Json; using Bem; using Bem.Models.Collections.Items; BemClient client = new(); var response = await client.Collections.Items.Add(new ItemAddParams { CollectionName = "product_catalog", Items = new List { new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "FENCE-6X12-TMP", "name": "Temporary Fence Panel 6ft x 12ft", "category": "Site Safety", "unitCost": 24.00} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "SPRAY-HVLP-PRO", "name": "HVLP Pro Paint Sprayer Kit", "category": "Paint Equipment", "unitCost": 110.00} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "LADDER-EXT-24AL", "name": "Extension Ladder 24ft Aluminum", "category": "Access", "unitCost": 20.00} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "GEN-7000W-QS", "name": "Portable Generator 7000W Quiet Series", "category": "Power", "unitCost": 135.00} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "LIGHT-JOBSITE-HV", "name": "High-Visibility Job Site Lighting Kit", "category": "Lighting", "unitCost": 85.00} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "TENT-CONTAIN-MOB", "name": "Mobile Paint Containment Tent", "category": "Paint Equipment", "unitCost": 700.00} """))), }, }); Console.WriteLine(response); ``` ```bash bem collections:items add \ --collection-name product_catalog \ --item '{data: {sku: FENCE-6X12-TMP, name: "Temporary Fence Panel 6ft x 12ft", category: "Site Safety", unitCost: 24.00}}' \ --item '{data: {sku: SPRAY-HVLP-PRO, name: "HVLP Pro Paint Sprayer Kit", category: "Paint Equipment", unitCost: 110.00}}' \ --item '{data: {sku: LADDER-EXT-24AL, name: "Extension Ladder 24ft Aluminum", category: Access, unitCost: 20.00}}' \ --item '{data: {sku: GEN-7000W-QS, name: "Portable Generator 7000W Quiet Series", category: Power, unitCost: 135.00}}' \ --item '{data: {sku: LIGHT-JOBSITE-HV, name: "High-Visibility Job Site Lighting Kit", category: Lighting, unitCost: 85.00}}' \ --item '{data: {sku: TENT-CONTAIN-MOB, name: "Mobile Paint Containment Tent", category: "Paint Equipment", unitCost: 700.00}}' ``` **Response:** ```json { "status": "pending", "message": "Collection items are being processed asynchronously", "eventID": "evt_2N6gH8ZKCmvb6BnFcGqhKJ98VzP" } ``` ```typescript { status: 'pending', message: 'Collection items are being processed asynchronously', eventID: 'evt_2N6gH8ZKCmvb6BnFcGqhKJ98VzP' } ``` ```python ItemAddResponse( status='pending', message='Collection items are being processed asynchronously', event_id='evt_2N6gH8ZKCmvb6BnFcGqhKJ98VzP', ) ``` ```go &bem.CollectionItemAddResponse{ Status: "pending", Message: "Collection items are being processed asynchronously", EventID: "evt_2N6gH8ZKCmvb6BnFcGqhKJ98VzP", } ``` ```csharp Bem.Models.Collections.Items.ItemAddResponse { Status = "pending", Message = "Collection items are being processed asynchronously", EventID = "evt_2N6gH8ZKCmvb6BnFcGqhKJ98VzP" } ``` ```json { "status": "pending", "message": "Collection items are being processed asynchronously", "eventID": "evt_2N6gH8ZKCmvb6BnFcGqhKJ98VzP" } ``` Items are embedded asynchronously. For a catalog this small it finishes within a few seconds — call `client.collections.items.retrieve({ collectionName: "product_catalog" })` (or the equivalent in your SDK) if you want to confirm before continuing. Step 6c: Create an enrich function [#step-6c-create-an-enrich-function] The enrich function pulls every line item description out of the upstream payload, runs a semantic search against `product_catalog`, and writes the top match back inline to each line item at `lineItems[*].matchedProduct`. When the source uses `[*]`, the target must use the same array notation so each match lands on its own line item rather than in a parallel top-level array. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "sku-matcher", "type": "enrich", "displayName": "SKU Matcher", "tags": ["products", "sku-lookup"], "config": { "steps": [ { "sourceField": "lineItems[*].description", "collectionName": "product_catalog", "targetField": "lineItems[*].matchedProduct", "topK": 1, "searchMode": "semantic" } ] } }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { function: fn } = await client.functions.create({ functionName: "sku-matcher", type: "enrich", displayName: "SKU Matcher", tags: ["products", "sku-lookup"], config: { steps: [ { sourceField: "lineItems[*].description", collectionName: "product_catalog", targetField: "lineItems[*].matchedProduct", topK: 1, searchMode: "semantic", }, ], }, }); console.log(fn); ``` ```python from bem import Bem client = Bem() response = client.functions.create( function_name="sku-matcher", type="enrich", display_name="SKU Matcher", tags=["products", "sku-lookup"], config={ "steps": [ { "sourceField": "lineItems[*].description", "collectionName": "product_catalog", "targetField": "lineItems[*].matchedProduct", "topK": 1, "searchMode": "semantic", } ], }, ) print(response.function) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfEnrich: &bem.CreateFunctionEnrichParam{ FunctionName: "sku-matcher", DisplayName: bem.String("SKU Matcher"), Tags: []string{"products", "sku-lookup"}, Config: bem.EnrichConfigParam{ Steps: []bem.EnrichStepParam{ { SourceField: "lineItems[*].description", CollectionName: "product_catalog", TargetField: "lineItems[*].matchedProduct", TopK: bem.Int(1), SearchMode: bem.EnrichStepSearchModeSemantic, }, }, }, }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Function) } ``` ```csharp using Bem; using Bem.Models.Functions; BemClient client = new(); var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Enrich { FunctionName = "sku-matcher", DisplayName = "SKU Matcher", Tags = new List { "products", "sku-lookup" }, Config = new EnrichConfig { Steps = new List { new EnrichStep { SourceField = "lineItems[*].description", CollectionName = "product_catalog", TargetField = "lineItems[*].matchedProduct", TopK = 1, SearchMode = EnrichStepSearchMode.Semantic, }, }, }, }, }); Console.WriteLine(response.Function); ``` ```bash bem functions create \ --function-name sku-matcher \ --type enrich \ --display-name "SKU Matcher" \ --tag products --tag sku-lookup \ --config '{steps: [{sourceField: "lineItems[*].description", collectionName: product_catalog, targetField: "lineItems[*].matchedProduct", topK: 1, searchMode: semantic}]}' ``` **Response:** ```json { "function": { "functionID": "fn_7ghi789jkl", "functionName": "sku-matcher", "displayName": "SKU Matcher", "type": "enrich", "currentVersionNum": 1 } } ``` ```typescript { functionID: 'fn_7ghi789jkl', functionName: 'sku-matcher', displayName: 'SKU Matcher', type: 'enrich', currentVersionNum: 1 } ``` ```python Function( function_id='fn_7ghi789jkl', function_name='sku-matcher', display_name='SKU Matcher', type='enrich', current_version_num=1, ) ``` ```go bem.Function{ FunctionID: "fn_7ghi789jkl", FunctionName: "sku-matcher", DisplayName: "SKU Matcher", Type: "enrich", CurrentVersionNum: 1, } ``` ```csharp Bem.Models.Functions.Function { FunctionID = "fn_7ghi789jkl", FunctionName = "sku-matcher", DisplayName = "SKU Matcher", Type = "enrich", CurrentVersionNum = 1 } ``` ```json { "function": { "functionID": "fn_7ghi789jkl", "functionName": "sku-matcher", "displayName": "SKU Matcher", "type": "enrich", "currentVersionNum": 1 } } ``` Step 6d: Chain the enrich function into the workflow [#step-6d-chain-the-enrich-function-into-the-workflow] `PATCH /v3/workflows/{workflowName}` takes the full desired node/edge topology and creates a new workflow version. Add `sku-matcher` as a second node, then connect `invoice-extractor → sku-matcher` with an edge. ```bash curl -X PATCH https://api.bem.ai/v3/workflows/invoice-processing \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "sku-matcher", "function": { "name": "sku-matcher" } } ], "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "sku-matcher" } ] }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { workflow } = await client.workflows.update("invoice-processing", { mainNodeName: "invoice-extractor", nodes: [ { name: "invoice-extractor", function: { name: "invoice-extractor" } }, { name: "sku-matcher", function: { name: "sku-matcher" } }, ], edges: [ { sourceNodeName: "invoice-extractor", destinationNodeName: "sku-matcher" }, ], }); console.log(workflow); ``` ```python from bem import Bem client = Bem() response = client.workflows.update( "invoice-processing", main_node_name="invoice-extractor", nodes=[ {"name": "invoice-extractor", "function": {"name": "invoice-extractor"}}, {"name": "sku-matcher", "function": {"name": "sku-matcher"}}, ], edges=[ {"source_node_name": "invoice-extractor", "destination_node_name": "sku-matcher"}, ], ) print(response.workflow) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() resp, err := client.Workflows.Update(context.TODO(), "invoice-processing", bem.WorkflowUpdateParams{ MainNodeName: bem.String("invoice-extractor"), Nodes: []bem.WorkflowUpdateParamsNode{ { Name: bem.String("invoice-extractor"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("invoice-extractor")}, }, { Name: bem.String("sku-matcher"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("sku-matcher")}, }, }, Edges: []bem.WorkflowUpdateParamsEdge{ { SourceNodeName: "invoice-extractor", DestinationNodeName: "sku-matcher", }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Workflow) } ``` ```csharp using Bem; using Bem.Models.Workflows; BemClient client = new(); var response = await client.Workflows.Update("invoice-processing", new WorkflowUpdateParams { MainNodeName = "invoice-extractor", Nodes = new List { new WorkflowUpdateParamsNode { Function = new FunctionVersionIdentifier { Name = "invoice-extractor" }, Name = "invoice-extractor" }, new WorkflowUpdateParamsNode { Function = new FunctionVersionIdentifier { Name = "sku-matcher" }, Name = "sku-matcher" }, }, Edges = new List { new WorkflowUpdateParamsEdge { SourceNodeName = "invoice-extractor", DestinationNodeName = "sku-matcher" }, }, }); Console.WriteLine(response.Workflow); ``` ```bash bem workflows update \ --workflow-name invoice-processing \ --main-node-name invoice-extractor \ --node '{name: invoice-extractor, function: {name: invoice-extractor}}' \ --node '{name: sku-matcher, function: {name: sku-matcher}}' \ --edge '{sourceNodeName: invoice-extractor, destinationNodeName: sku-matcher}' ``` **Response:** ```json { "workflow": { "id": "wf_2def456abc", "name": "invoice-processing", "displayName": "Invoice Processing Workflow", "versionNum": 2, "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor", "versionNum": 1 } }, { "name": "sku-matcher", "function": { "name": "sku-matcher", "versionNum": 1 } } ], "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "sku-matcher" } ] } } ``` ```typescript { id: 'wf_2def456abc', name: 'invoice-processing', displayName: 'Invoice Processing Workflow', versionNum: 2, mainNodeName: 'invoice-extractor', nodes: [ { name: 'invoice-extractor', function: { name: 'invoice-extractor', versionNum: 1 } }, { name: 'sku-matcher', function: { name: 'sku-matcher', versionNum: 1 } } ], edges: [ { sourceNodeName: 'invoice-extractor', destinationNodeName: 'sku-matcher' } ] } ``` ```python Workflow( id='wf_2def456abc', name='invoice-processing', display_name='Invoice Processing Workflow', version_num=2, main_node_name='invoice-extractor', nodes=[ WorkflowNodeResponse(name='invoice-extractor', function=FunctionVersionIdentifier(name='invoice-extractor', version_num=1)), WorkflowNodeResponse(name='sku-matcher', function=FunctionVersionIdentifier(name='sku-matcher', version_num=1)), ], edges=[ WorkflowEdgeResponse(source_node_name='invoice-extractor', destination_node_name='sku-matcher'), ], ) ``` ```go bem.Workflow{ ID: "wf_2def456abc", Name: "invoice-processing", DisplayName: "Invoice Processing Workflow", VersionNum: 2, MainNodeName: "invoice-extractor", Nodes: []bem.WorkflowNodeResponse{ {Name: "invoice-extractor", Function: bem.FunctionVersionIdentifier{Name: "invoice-extractor", VersionNum: 1}}, {Name: "sku-matcher", Function: bem.FunctionVersionIdentifier{Name: "sku-matcher", VersionNum: 1}}, }, Edges: []bem.WorkflowEdgeResponse{ {SourceNodeName: "invoice-extractor", DestinationNodeName: "sku-matcher"}, }, } ``` ```csharp Bem.Models.Workflows.Workflow { ID = "wf_2def456abc", Name = "invoice-processing", DisplayName = "Invoice Processing Workflow", VersionNum = 2, MainNodeName = "invoice-extractor", Nodes = [ WorkflowNodeResponse { Name = "invoice-extractor", Function = FunctionVersionIdentifier { Name = "invoice-extractor", VersionNum = 1 } }, WorkflowNodeResponse { Name = "sku-matcher", Function = FunctionVersionIdentifier { Name = "sku-matcher", VersionNum = 1 } } ], Edges = [ WorkflowEdgeResponse { SourceNodeName = "invoice-extractor", DestinationNodeName = "sku-matcher" } ] } ``` ```json { "workflow": { "id": "wf_2def456abc", "name": "invoice-processing", "displayName": "Invoice Processing Workflow", "versionNum": 2, "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor", "versionNum": 1 } }, { "name": "sku-matcher", "function": { "name": "sku-matcher", "versionNum": 1 } } ], "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "sku-matcher" } ] } } ``` Call the enriched workflow [#call-the-enriched-workflow] Invoke the workflow the same way as [Step 5](#step-5-call-the-workflow-synchronously) — no client changes required. Because `sku-matcher` is now the terminal node, the call's `outputs` array contains an **enrich event** whose `enrichedContent` carries the extracted invoice with each line item's best catalog match attached inline at `lineItems[i].matchedProduct`. All six line items on the sample invoice come back with a `matchedProduct`; the responses below show the first two. `cosineDistance` is the semantic distance to the match — lower is closer — so you can threshold on it to reject weak matches. ```json { "call": { "callID": "wc_8mno012pqr", "status": "completed", "workflowName": "invoice-processing", "workflowVersionNum": 2, "outputs": [ { "eventID": "evt_5abc678def", "eventType": "enrich", "functionName": "sku-matcher", "enrichedContent": { "invoiceNumber": "FE-2421 C", "lineItems": [ { "description": "Temporary Fence Panel, 6ft x 12ft", "quantity": 40, "unitPrice": 22.0, "rateUnit": "panel", "amount": 880.0, "matchedProduct": { "data": { "sku": "FENCE-6X12-TMP", "name": "Temporary Fence Panel 6ft x 12ft", "category": "Site Safety", "unitCost": 24.00 }, "cosineDistance": 0.0412 } }, { "description": "Paint Sprayer Kit, HVLP Pro", "quantity": 3, "unitPrice": 95.0, "rateUnit": "day", "amount": 285.0, "matchedProduct": { "data": { "sku": "SPRAY-HVLP-PRO", "name": "HVLP Pro Paint Sprayer Kit", "category": "Paint Equipment", "unitCost": 110.00 }, "cosineDistance": 0.0687 } } ] } } ], "errors": [], "url": "/v3/calls/wc_8mno012pqr", "traceUrl": "/v3/calls/wc_8mno012pqr/trace" } } ``` ```typescript { callID: 'wc_8mno012pqr', status: 'completed', workflowName: 'invoice-processing', workflowVersionNum: 2, outputs: [ { eventID: 'evt_5abc678def', eventType: 'enrich', functionName: 'sku-matcher', enrichedContent: { invoiceNumber: 'FE-2421 C', lineItems: [ { description: 'Temporary Fence Panel, 6ft x 12ft', quantity: 40, unitPrice: 22, rateUnit: 'panel', amount: 880, matchedProduct: { data: { sku: 'FENCE-6X12-TMP', name: 'Temporary Fence Panel 6ft x 12ft', category: 'Site Safety', unitCost: 24 }, cosineDistance: 0.0412 } }, { description: 'Paint Sprayer Kit, HVLP Pro', quantity: 3, unitPrice: 95, rateUnit: 'day', amount: 285, matchedProduct: { data: { sku: 'SPRAY-HVLP-PRO', name: 'HVLP Pro Paint Sprayer Kit', category: 'Paint Equipment', unitCost: 110 }, cosineDistance: 0.0687 } } ] } } ], errors: [], url: '/v3/calls/wc_8mno012pqr', traceUrl: '/v3/calls/wc_8mno012pqr/trace' } ``` ```python CallV3( call_id='wc_8mno012pqr', status='completed', workflow_name='invoice-processing', workflow_version_num=2, outputs=[ EnrichEvent( event_id='evt_5abc678def', event_type='enrich', function_name='sku-matcher', enriched_content={ 'invoiceNumber': 'FE-2421 C', 'lineItems': [ { 'description': 'Temporary Fence Panel, 6ft x 12ft', 'quantity': 10, 'unitPrice': 25.0, 'amount': 250.0, 'matchedProduct': {'data': {'sku': 'FENCE-6X12-TMP', 'name': 'Temporary Fence Panel 6ft x 12ft', 'category': 'Site Safety', 'unitCost': 24.00}, 'cosineDistance': 0.0412}, }, { 'description': 'Paint Sprayer Kit, HVLP Pro', 'quantity': 5, 'unitPrice': 15.0, 'amount': 75.0, 'matchedProduct': {'data': {'sku': 'SPRAY-HVLP-PRO', 'name': 'HVLP Pro Paint Sprayer Kit', 'category': 'Paint Equipment', 'unitCost': 110.00}, 'cosineDistance': 0.0687}, }, ], }, ) ], errors=[], url='/v3/calls/wc_8mno012pqr', trace_url='/v3/calls/wc_8mno012pqr/trace', ) ``` ```go bem.CallV3{ CallID: "wc_8mno012pqr", Status: "completed", WorkflowName: "invoice-processing", WorkflowVersionNum: 2, Outputs: []bem.Event{ { EventID: "evt_5abc678def", EventType: "enrich", FunctionName: "sku-matcher", EnrichedContent: map[string]any{ "invoiceNumber": "FE-2421 C", "lineItems": []any{ map[string]any{ "description": "Temporary Fence Panel, 6ft x 12ft", "quantity": 10, "unitPrice": 25.0, "amount": 250.0, "matchedProduct": map[string]any{ "data": map[string]any{"sku": "FENCE-6X12-TMP", "name": "Temporary Fence Panel 6ft x 12ft", "category": "Site Safety", "unitCost": 24.00}, "cosineDistance": 0.0823, }, }, map[string]any{ "description": "Paint Sprayer Kit, HVLP Pro", "quantity": 5, "unitPrice": 15.0, "amount": 75.0, "matchedProduct": map[string]any{ "data": map[string]any{"sku": "SPRAY-HVLP-PRO", "name": "HVLP Pro Paint Sprayer Kit", "category": "Paint Equipment", "unitCost": 110.00}, "cosineDistance": 0.1104, }, }, }, }, }, }, Errors: []bem.ErrorEvent{}, URL: "/v3/calls/wc_8mno012pqr", TraceURL: "/v3/calls/wc_8mno012pqr/trace", } ``` ```csharp Bem.Models.Calls.CallV3 { CallID = "wc_8mno012pqr", Status = "completed", WorkflowName = "invoice-processing", WorkflowVersionNum = 2, Outputs = [ EnrichEvent { EventID = "evt_5abc678def", EventType = "enrich", FunctionName = "sku-matcher", EnrichedContent = { "invoiceNumber": "FE-2421 C", "lineItems": [ { "description": "Temporary Fence Panel, 6ft x 12ft", "quantity": 10, "unitPrice": 25.0, "amount": 250.0, "matchedProduct": { "data": { "sku": "FENCE-6X12-TMP", ... }, "cosineDistance": 0.0412 } }, { "description": "Paint Sprayer Kit, HVLP Pro", "quantity": 5, "unitPrice": 15.0, "amount": 75.0, "matchedProduct": { "data": { "sku": "SPRAY-HVLP-PRO", ... }, "cosineDistance": 0.0687 } } ] } } ], Errors = [], Url = "/v3/calls/wc_8mno012pqr", TraceUrl = "/v3/calls/wc_8mno012pqr/trace" } ``` ```json { "call": { "callID": "wc_8mno012pqr", "status": "completed", "workflowName": "invoice-processing", "workflowVersionNum": 2, "outputs": [ { "eventID": "evt_5abc678def", "eventType": "enrich", "functionName": "sku-matcher", "enrichedContent": { "invoiceNumber": "FE-2421 C", "lineItems": [ { "description": "Temporary Fence Panel, 6ft x 12ft", "quantity": 40, "unitPrice": 22.0, "rateUnit": "panel", "amount": 880.0, "matchedProduct": { "data": { "sku": "FENCE-6X12-TMP", "name": "Temporary Fence Panel 6ft x 12ft", "category": "Site Safety", "unitCost": 24.00 }, "cosineDistance": 0.0412 } }, { "description": "Paint Sprayer Kit, HVLP Pro", "quantity": 3, "unitPrice": 95.0, "rateUnit": "day", "amount": 285.0, "matchedProduct": { "data": { "sku": "SPRAY-HVLP-PRO", "name": "HVLP Pro Paint Sprayer Kit", "category": "Paint Equipment", "unitCost": 110.00 }, "cosineDistance": 0.0687 } } ] } } ], "errors": [], "url": "/v3/calls/wc_8mno012pqr", "traceUrl": "/v3/calls/wc_8mno012pqr/trace" } } ``` The intermediate extract event is still available via `GET /v3/calls/{callID}/trace` if you need to inspect each function call in the DAG. Webhook delivery (optional) [#webhook-delivery-optional] For production pipelines that may exceed the 30-second `wait` window, subscribe a webhook so bem pushes results to your server the moment processing completes. ```bash curl -X POST https://api.bem.ai/v1-alpha/subscriptions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "name": "invoice-results", "type": "transform", "functionName": "invoice-extractor", "webhookURL": "https://your-server.com/webhooks/bem" }' ``` Verify incoming requests with the `bem-signature` header — see [Webhook Authentication](/api/authentication#webhook-authentication) for the HMAC-SHA256 recipe. Next Steps [#next-steps] Learn more about extract function configuration Add context from collections using semantic search Best practices for designing output schemas Chain functions into multi-step DAGs # Reading Workflow Call Outputs (/guide/reading-workflow-call-outputs) > For the complete documentation index, see [llms.txt](/llms.txt). When you call a workflow with `wait=true` (or fetch a completed call via `GET /v3/calls/{callID}`), the response is the same shape — a `call` object whose terminal events are in `call.outputs`. The extracted data lives **on each event**, not under a nested `transformation` field. ```text result (or response object) └─ call ├─ callID ├─ status ("completed" | "pending" | "running" | "failed") ├─ outputs[] array of terminal events │ ├─ eventID │ ├─ eventType discriminator → tells you which payload field to read │ ├─ functionName │ └─ transformedContent | enrichedContent | choice | … ├─ errors[] ├─ url /v3/calls/{callID} └─ traceUrl /v3/calls/{callID}/trace (full per-node execution graph) ``` `callType` tells you what kind of call you're looking at [#calltype-tells-you-what-kind-of-call-youre-looking-at] `GET /v3/calls` and `GET /v3/calls/{callID}` don't only return workflow calls — they also surface `direct_function` and `adhoc_function` calls made outside V3. Every call object carries `callType` (`workflow` | `direct_function` | `adhoc_function`); workflow calls populate `workflowID` / `workflowName` / `workflowVersionNum`, and function calls populate `functionID` / `functionName` / `functionType` / `functionVersionNum` instead. This page covers the workflow-call shape — `outputs[]` and `errors[]` work the same regardless of `callType`. `GET /v3/calls` filters on `callType` with `callTypes`, and on the function-scoped fields with `functionIDs` / `functionNames`. To narrow a call log down to failed ad-hoc function calls for one function: ```bash curl -G "https://api.bem.ai/v3/calls" \ -H "x-api-key: $BEM_API_KEY" \ -d callTypes=adhoc_function \ -d statuses=failed \ -d functionNames=invoice-extractor ``` `GET /v3/calls` takes several more filters — see [List Calls](/api/v3/calls/v3-list-calls) for the complete parameter list. `outputs` is always an array [#outputs-is-always-an-array] The terminal nodes of a workflow are whatever has no outgoing edge. A single-step workflow has exactly one terminal event; a branching workflow (Classify) or a fan-out workflow (Split) can have more. Even with one terminal node, the field is plural — you index into it. ```js result.call.outputs[0].transformedContent; // ✓ result.call.output; // ✗ does not exist result.call.outputs.transformedContent; // ✗ outputs is an array ``` Where the data lives, by `eventType` [#where-the-data-lives-by-eventtype] The payload field name depends on the event variant. Switch on `eventType` (or use the SDK's variant accessor) to get the right field. | `eventType` | Content field | Produced by | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `extract` | `transformedContent` | [Extract](/guide/function-types/extract) function | | `enrich` | `enrichedContent` (carries upstream content + matched data) | [Enrich](/guide/function-types/enrich) function | | `join` | `transformedContent` | [Join](/guide/function-types/join) function | | `classify` | `choice` (the picked classification label) — the extracted JSON flows from the routed-to extractor downstream | [Classify](/guide/function-types/classify) function | | `payload_shaping` | `transformedContent` (the reshaped JSON from the JMESPath schema) | [Payload Shaping](/guide/function-types/payload-shaping) function | | `transform`, `analyze` | `transformedContent` | Legacy V1/V2 functions (still readable from V3) | | `route` | route metadata | Legacy V1/V2 function | | `send` | delivery status (no content payload) | [Send](/guide/function-types/send) function | | `parse` | `transformedContent` (sections/entities/relationships JSON, same field as extract) | [Parse](/guide/function-types/parse) function | | `error` | `errorMessage`, `errorCode`, `errorDetails` | Any failed function call | So an Extract-only workflow lands at `outputs[0].transformedContent`. An Extract → Enrich workflow lands at `outputs[0].enrichedContent` (because Enrich is the terminal node). A Classify-only workflow lands at `outputs[0].choice`. A Parse function fills `outputs[0].transformedContent` with its sections/entities/relationships JSON like any other terminal function. [`POST /v3/fs`](/api/v3/file-system) is an additional navigation layer for querying parsed corpora, not a substitute for reading the parse result off the call output. Accessor patterns [#accessor-patterns] The same path in every language. The SDKs flatten the event variants into a single discriminated union — you can either read the field directly (it'll be `null`/missing on the wrong variant) or use the SDK's variant accessor for type safety. ```bash # Extract-only workflow jq '.call.outputs[0].transformedContent' < response.json # Extract → Enrich workflow jq '.call.outputs[0].enrichedContent' < response.json # Switch on eventType jq '.call.outputs[] | if .eventType=="enrich" then .enrichedContent else .transformedContent end' < response.json ``` ```typescript const { call } = await client.workflows.call("my-workflow", { wait: true, /* ... */ }); // Single terminal node (Extract) const data = call?.outputs?.[0]?.transformedContent; // Single terminal node (Enrich) const enriched = call?.outputs?.[0]?.enrichedContent; // Generic — switch on eventType for (const event of call?.outputs ?? []) { switch (event.eventType) { case "extract": case "transform": case "join": console.log(event.transformedContent); break; case "enrich": console.log(event.enrichedContent); break; case "classify": console.log(event.choice); break; } } ``` ```python response = client.workflows.call("my-workflow", wait=True, ...) call = response.call # Single terminal node (Extract) data = call.outputs[0].transformed_content # Single terminal node (Enrich) enriched = call.outputs[0].enriched_content # Generic — switch on event_type for event in call.outputs: if event.event_type in ("extract", "transform", "join"): print(event.transformed_content) elif event.event_type == "enrich": print(event.enriched_content) elif event.event_type == "classify": print(event.choice) ``` ```go resp, err := client.Workflows.Call(ctx, "my-workflow", bem.WorkflowCallParams{Wait: bem.Bool(true), /* ... */}) if err != nil { panic(err) } // Direct field access (works because the union flattens all variant fields) data := resp.Call.Outputs[0].TransformedContent // for extract / transform / join enriched := resp.Call.Outputs[0].EnrichedContent // for enrich // Type-safe variant switch for _, event := range resp.Call.Outputs { switch v := event.AsAny().(type) { case bem.EventExtract: fmt.Println(v.TransformedContent) case bem.EventEnrich: fmt.Println(v.EnrichedContent) case bem.EventClassify: fmt.Println(v.Choice) } } ``` ```csharp var response = await client.Workflows.Call("my-workflow", new WorkflowCallParams { Wait = true, /* ... */ }); var call = response.Call; // Single terminal node (Extract) var data = call.Outputs[0].TransformedContent; // Single terminal node (Enrich) var enriched = call.Outputs[0].EnrichedContent; // Generic — pattern match on event variant foreach (var ev in call.Outputs) { switch (ev) { case ExtractEvent ex: Console.WriteLine(ex.TransformedContent); break; case EnrichEvent en: Console.WriteLine(en.EnrichedContent); break; case ClassifyEvent cl: Console.WriteLine(cl.Choice); break; } } ``` ```bash bem workflows call --workflow-name my-workflow --wait --call-reference-id my-ref \ --input.single-file '{"inputContent": "@my-file.pdf", "inputType": "pdf"}' \ --transform 'call.outputs.0.transformedContent' ``` The `--transform` flag uses [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) and projects to the path you ask for, so the CLI prints just the extracted JSON. Replace `transformedContent` with `enrichedContent` for an Enrich-terminal workflow. `_metadata` echoes back what you attached to the call [#_metadata-echoes-back-what-you-attached-to-the-call] Attach an arbitrary JSON object to a call and bem writes it back into the extracted output, so you can correlate a result with your own systems — a customer ID, a batch ID, a trace span — without a second lookup. Attaching it [#attaching-it] Pass `metadata` alongside `input` on [`POST /v3/workflows/{workflowName}/call`](/api/v3/calls/v3-call-workflow) (or a direct call create). Both the JSON and multipart request bodies accept it, but the shape differs: * **JSON body** — `metadata` is a normal JSON object. * **multipart/form-data** — `metadata` is that same object serialized to a string, because multipart form fields are always strings. Both forms are validated identically: `metadata` must be a JSON object — not an array, string, number, or boolean — and at most 4 KB serialized. Either violation returns `400 Bad Request` with `invalid metadata: `. ```bash # JSON body curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call" \ -H "x-api-key: $BEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "callReferenceID": "invoice-001", "metadata": { "customerID": "cust-42", "region": "us-east-1" }, "input": { "singleFile": { "inputContent": "@invoice.pdf", "inputType": "pdf" } } }' # multipart/form-data — metadata is the same object, JSON-encoded into a string field curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call" \ -H "x-api-key: $BEM_API_KEY" \ -F 'callReferenceID=invoice-001' \ -F 'metadata={"customerID":"cust-42","region":"us-east-1"}' \ -F 'file=@invoice.pdf' ``` Where it comes back [#where-it-comes-back] bem writes your metadata into a reserved `_metadata` key inside `transformedContent`, next to a `referenceID` field that mirrors the `callReferenceID` you passed on the call (see [Idempotency via `callReferenceID`](/guide/polling-and-retries#idempotency-via-callreferenceid)). **Without metadata on the call:** ```json { "invoiceNumber": "FE-2421 C", "vendor": { "name": "Appleseed Paint Co." }, "totalAmount": 2678.51 } ``` **With `metadata: { customerID: 'cust-42', region: 'us-east-1' }` on the call:** ```json { "invoiceNumber": "FE-2421 C", "vendor": { "name": "Appleseed Paint Co." }, "totalAmount": 2678.51, "_metadata": { "customerID": "cust-42", "region": "us-east-1", "referenceID": "invoice-001" } } ``` `_metadata` only appears when the call carried at least one non-reserved metadata key. No metadata on the call — or metadata made up entirely of reserved keys — means no `_metadata` key at all, not an empty object. Guard the read: `output.transformedContent._metadata?.customerID`, not `output.transformedContent._metadata.customerID`. Which event types echo it [#which-event-types-echo-it] Only event types whose payload comes from a transformation carry `_metadata`. The rest have no `ExtractedJSON` for the platform to inject into. | Echoes `_metadata` | Doesn't | | -------------------------------------------------- | ----------------------------------------------------------------- | | `extract`, `transform`, `parse`, `analyze`, `join` | `enrich`, `classify`, `payload_shaping`, `route`, `send`, `error` | Reserved keys aren't echoed [#reserved-keys-arent-echoed] Metadata keys starting with `_` are reserved for the platform and are stripped before `_metadata` is built — they never reach the response, however you send them. The same `_`-prefix convention shows up elsewhere, e.g. [Render's reserved underscore prefix](/guide/function-types/render#reserved-underscore-prefix) for template placeholders. Don't rely on an underscore-prefixed key of your own surviving the round trip. Anti-patterns [#anti-patterns] * **`call.output` (singular)** — does not exist. The field is always `outputs[]`. * **`outputs[0].transformation.extractedJSON`** — that's the legacy V1/V2 [Transformation record](/guide/system-overview#transformations) shape returned by `GET /v1-beta/transformations`. V3 workflow calls return *events* whose payload sits at the top of each event object as `transformedContent` / `enrichedContent` / `choice` / etc. * **Skipping the `outputs` index** — `outputs` is an array even when there's a single terminal event. * **Reading `transformedContent` on an enrich event** — empty or missing. Enrich's payload is at `enrichedContent`. Switch on `eventType`. * **`outputs[0].transformedContent._metadata.customerID` unguarded** — `_metadata` is only present when the call carried non-reserved metadata. See [`_metadata` echoes back what you attached to the call](#_metadata-echoes-back-what-you-attached-to-the-call). For per-node execution detail (every intermediate function call, not just the terminal nodes), fetch [`GET /v3/calls/{callID}/trace`](/api/v3/calls/v3-get-call-trace). Related [#related] Functions, workflows, calls, events, transformations, subscriptions, views — and how they fit together Sync waits, polling, idempotency via `callReferenceID` Subscribe an endpoint and verify signed deliveries `POST /v3/workflows/{workflowName}/call` — every parameter `GET /v3/calls` — every filter and pagination parameter # Schema Building Guide (/guide/schema-building) > For the complete documentation index, see [llms.txt](/llms.txt). The `outputSchema` you give to an Extract or Join function is what bem uses to normalize many different inputs into one consistent shape. The recommendations below come from running these schemas in production — they meaningfully improve accuracy and reduce the volume of follow-up corrections you have to ship. Provide descriptions for every field [#provide-descriptions-for-every-field] The single highest-leverage thing you can do. bem only knows what you tell it before you start providing feedback, so a one-line natural-language description — the field's purpose, how it might appear in source documents, and an example value — measurably improves extraction quality. Treat field descriptions as prompts, not as JavaDoc. Set a type on every field [#set-a-type-on-every-field] Typing helps bem ground its extraction. It also gives you reliably structured output downstream and surfaces bad source data: any field that has a type but couldn't be populated comes back as an "invalid property" that you can build error handling around. Mark genuinely required fields as `required` [#mark-genuinely-required-fields-as-required] `required` tells bem which fields to anchor on. Required fields that can't be populated from the input also surface as "invalid properties," so you can fail fast on documents that are missing critical data instead of silently extracting partial records. Provide formatting hints for fields if necessary [#provide-formatting-hints-for-fields-if-necessary] If a field you want to populate has a fixed format, you can either specify the format as a regular expression in the conventional [JSON Schema `pattern`](https://json-schema.org/understanding-json-schema/reference/regular_expressions) field. We've also seen great results from specifying a pattern in natural language in description fields, but if you have more stringent formatting expectations we'd recommend setting a regex `pattern`. For date strings, we only support formatting in the ISO 8601 standard and do not support regex patterns at the moment. As an example, the time '1/01/2024' in a given input will be formatted as '2024-01-01'. Set enums for fields you know have a certain set of possible values [#set-enums-for-fields-you-know-have-a-certain-set-of-possible-values] Enum values help constrain the set of valid values that bem transforms into a given property. At the moment, this isn't a “strict” constraint but generally helps bem understand the intent behind the desired transformation. Think of it as a stronger way to indicate desired output than providing example values in a description. Example Schema [#example-schema] Below is an example schema showcasing the above best practices that can be used to normalize inputs from a variety of commercial vehicle electronic logging device (ELD) providers. ```json { "type": "object", "title": "Fleet Trip Summary", "required": ["fleetId", "tripSummary", "compliance", "operationalEfficiency"], "properties": { "fleetId": { "type": "string", "description": "Unique identifier for the fleet." }, "compliance": { "type": "object", "required": ["hoursOfServiceCompliance", "notes"], "properties": { "notes": { "type": "string", "description": "Additional notes on compliance." }, "hoursOfServiceCompliance": { "type": "string", "description": "Compliance status with hours of service." } } }, "tripSummary": { "type": "object", "required": [ "tripId", "vehicle", "driver", "start", "end", "distanceCovered", "fuelUsage", "incidents" ], "properties": { "end": { "type": "object", "required": ["time", "location", "odometerEnd"], "properties": { "time": { "type": "string", "format": "date-time", "description": "End time of the trip." }, "location": { "type": "string", "description": "End location of the trip." }, "odometerEnd": { "type": "integer", "description": "Odometer reading at the end of the trip." } } }, "start": { "type": "object", "required": ["time", "location", "odometerStart"], "properties": { "time": { "type": "string", "format": "date-time", "description": "Start time of the trip." }, "location": { "type": "string", "description": "Start location of the trip." }, "odometerStart": { "type": "integer", "description": "Odometer reading at the start of the trip." } } }, "driver": { "type": "object", "required": ["id", "name"], "properties": { "id": { "type": "string", "description": "Unique identifier for the driver." }, "name": { "type": "string", "description": "Name of the driver." } } }, "tripId": { "type": "string", "description": "Unique identifier for the trip." }, "vehicle": { "type": "object", "required": ["id", "details"], "properties": { "id": { "type": "string", "description": "Unique identifier for the vehicle." }, "details": { "type": "string", "description": "Description of the vehicle including make, model, and year." } } }, "fuelUsage": { "type": "object", "required": ["totalGallons", "averagePricePerGallon", "totalCost"], "properties": { "totalCost": { "type": "string", "description": "Total cost of fuel." }, "totalGallons": { "type": "number", "description": "Total gallons of fuel used." }, "averagePricePerGallon": { "type": "string", "description": "Average price per gallon of fuel." } } }, "incidents": { "type": "array", "items": { "type": "object", "required": ["type", "time", "location", "details"], "properties": { "time": { "type": "string", "format": "date-time", "description": "Time of the incident." }, "type": { "type": "string", "description": "Type of incident." }, "details": { "type": "string", "description": "Detailed description of the incident." }, "location": { "type": "string", "description": "Location of the incident." } } } }, "distanceCovered": { "type": "string", "description": "Total distance covered during the trip." } } }, "operationalEfficiency": { "type": "object", "required": ["totalEngineHours", "idleTime", "efficiencyRating"], "properties": { "idleTime": { "type": "string", "description": "Total idle time during the trip." }, "efficiencyRating": { "type": "string", "description": "Efficiency rating of the trip." }, "totalEngineHours": { "type": "string", "description": "Total engine hours for the trip." } } } } } ``` Avoid positional schemas [#avoid-positional-schemas] Positional schemas rely on array indices to carry meaning — for example, treating `rates[0]` as "the rate that goes with the first weight." These are brittle: * The model has no semantic anchor — it works best when relationships are explicit * Small changes in input layout (one extra row, one missing entry) silently corrupt every downstream pairing * The intent isn't readable in the schema itself, so future maintainers can't see what's correlated ```json { "type": "object", "properties": { "rates": { "type": "array", "description": "Shipping rates", "items": { "type": "number" } }, "weights": { "type": "array", "description": "Weights for each shipping rate", "items": { "type": "number" } } } } ``` Note how `rates` and `weights` are both arrays. This schema attempts to correlate them by position. This is brittle and not recommended. Preferred: Semantic Object Pattern [#preferred-semantic-object-pattern] ```json { "type": "object", "properties": { "shippingRates": { "type": "array", "description": "Shipping rates with weights", "items": { "type": "object", "properties": { "rate": { "type": "number", "description": "Shipping rate" }, "weight": { "type": "number", "description": "Weight" } } } } } } ``` Note that `rate` and `weight` are directly associated by being in the same object. Defaults [#defaults] Defaults may be provided using the `default` key. The default value will be inserted if no value was able to be extracted from the input. ```json { "type": "object", "properties": { "currency": { "type": "string", "enum": ["USD", "EUR", "GBP"], "default": "USD" } } } ``` # SDKs (/guide/sdks) > For the complete documentation index, see [llms.txt](/llms.txt). Open-source client libraries for your favorite platforms. Every SDK shares the same resource model — functions, workflows, calls — and reads `BEM_API_KEY` from the environment by default. Official SDKs [#official-sdks] github.com/bem-team/bem-typescript-sdk github.com/bem-team/bem-python-sdk github.com/bem-team/bem-go-sdk github.com/bem-team/bem-csharp-sdk See the [Quickstart](/guide/quickstart) for side-by-side examples covering every SDK and the REST API. # Synchronous Mode (/guide/synchronous-mode) > For the complete documentation index, see [llms.txt](/llms.txt). Workflow calls run asynchronously by default — `POST /v3/workflows/{name}/call` returns immediately with `status: "pending"` and bem processes the call in the background. **Synchronous mode** flips that: pass `wait=true` and bem holds the response open until the call finishes, up to a 30-second ceiling. When it finishes inside that window the response carries the final result; when it doesn't, the response carries the in-progress status and you fall back to polling or a webhook. Synchronous mode is the right choice for interactive flows — a user uploading a document and waiting for the result, a script processing a single file, a CLI command — where the caller can sit on the connection. For batch ingestion, scheduled jobs, or anything that runs at sustained volume, asynchronous mode plus webhooks is the better fit. This page covers both sides: how `wait=true` actually behaves, and the patterns to use it without surprises in production. How it works [#how-it-works] Pass `wait=true` on the call endpoint. The flag is read from either the query string (for JSON bodies) or as a form field (for multipart uploads), so both content types are supported. ```bash # Query param (JSON body) curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "callReferenceID": "inv-12345", "input": { "singleFile": { ... } } }' # Form field (multipart upload) curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call" \ -H "x-api-key: $BEM_API_KEY" \ -F "wait=true" \ -F "callReferenceID=inv-12345" \ -F "file=@invoice.pdf" ``` The response shape is always the same — a `call` object. What varies is the HTTP status code and the call's `status` field, keyed off whether the call finished within 30 seconds: | Outcome | HTTP status | `call.status` | Body | | -------------------- | --------------------------- | ---------------------- | ----------------------------------------------------------- | | Completed within 30s | `200 OK` | `completed` | Final call object with `outputs[]` populated | | Failed within 30s | `500 Internal Server Error` | `failed` | Call object with `errors[]` populated | | Still running at 30s | `202 Accepted` | `pending` or `running` | Call object with `outputs[]` empty (no terminal events yet) | `wait=true` doesn't cancel the call [#waittrue-doesnt-cancel-the-call] This is the most important thing to internalize: **the 30-second window is a wait budget, not a deadline.** When the budget elapses without the call finishing, bem stops holding the connection open and returns `202` — but the call keeps running on the server. You can fetch its eventual result via `GET /v3/calls/{callID}` or receive it via webhook subscription. There is no way to cancel an in-flight call; once submitted, it runs to terminal status (`completed` or `failed`). This means a 202 is **never an error** — it's just "not done yet, please come back." Treat it as a different code path, not as a retry signal. Latency expectations [#latency-expectations] Most calls finish well inside 30 seconds. As a rough order-of-magnitude guide: | Workflow shape | Typical latency | Likely outcome | | ------------------------------------------------------ | -------------------------- | -------------------------- | | Single Extract on a short PDF (1–3 pages) | a few seconds | 200 | | Extract → Enrich (single PDF, semantic catalog match) | \~10s | 200 | | Classify → Extract (single PDF) | \~10s | 200 | | Single Extract on a long or scan-heavy PDF (15+ pages) | 15–30s | usually 200, sometimes 202 | | Split + per-piece Extract on a multi-document file | tens of seconds | often 202 | | OCR-heavy or large multi-modal inputs | tens of seconds to minutes | usually 202 | If your typical workflow's p95 is comfortably under 30s, sync mode is the simplest pattern. If it's near or above the line, design for 202 from the start — see [Production patterns](#production-patterns) below. The access pattern [#the-access-pattern] The canonical client shape is **call → branch on status**. On a successful synchronous return read the outputs; on 202, fall back to polling or wait for the webhook. SDKs treat both 200 and 202 as success (they're both `2xx`), so the discriminator is the `call.status` field, not an HTTP error. ```bash HTTP_STATUS=$(curl -s -o response.json -w "%{http_code}" \ -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "callReferenceID": "inv-12345", "input": { "singleFile": { "inputType": "pdf", "inputContent": "'"$(base64 -i invoice.pdf)"'" } } }') case "$HTTP_STATUS" in 200) # Completed — read the outputs jq '.call.outputs[0].transformedContent' response.json ;; 202) # Still running — poll GET /v3/calls/{callID} until done CALL_ID=$(jq -r '.call.callID' response.json) echo "Call $CALL_ID still running; switch to polling." ;; 500) # Failed within 30s jq '.call.errors' response.json exit 1 ;; esac ``` ```typescript import Bem from "bem-ai-sdk"; import fs from "node:fs"; const client = new Bem(); const inputContent = fs.readFileSync("invoice.pdf").toString("base64"); const { call } = await client.workflows.call("invoice-processing", { wait: true, callReferenceID: "inv-12345", input: { singleFile: { inputType: "pdf", inputContent } }, }); if (!call) throw new Error("no call object returned"); switch (call.status) { case "completed": // 200 — terminal events in call.outputs return call.outputs?.[0]?.transformedContent; case "pending": case "running": // 202 — fall back to polling or wait for the webhook return await pollUntilDone(client, call.callID); case "failed": // 500 — terminal error throw new Error(call.errors?.[0]?.errorMessage ?? "call failed"); } ``` ```python import base64 from bem import Bem client = Bem() with open("invoice.pdf", "rb") as f: input_content = base64.b64encode(f.read()).decode() response = client.workflows.call( "invoice-processing", wait=True, call_reference_id="inv-12345", input={"single_file": {"input_type": "pdf", "input_content": input_content}}, ) call = response.call if call.status == "completed": # 200 — terminal events in call.outputs return call.outputs[0].transformed_content elif call.status in ("pending", "running"): # 202 — fall back to polling or wait for the webhook return poll_until_done(client, call.call_id) elif call.status == "failed": # 500 — terminal error raise RuntimeError(call.errors[0].error_message) ``` ```go data, err := os.ReadFile("invoice.pdf") if err != nil { panic(err) } encoded := base64.StdEncoding.EncodeToString(data) resp, err := client.Workflows.Call(ctx, "invoice-processing", bem.WorkflowCallParams{ Wait: bem.Bool(true), CallReferenceID: bem.String("inv-12345"), Input: bem.WorkflowCallParamsInput{ SingleFile: &bem.WorkflowCallParamsInputSingleFile{ InputType: "pdf", InputContent: encoded, }, }, }) if err != nil { panic(err) } // SDK error = transport / 5xx switch resp.Call.Status { case "completed": // 200 — terminal events in resp.Call.Outputs fmt.Printf("%+v\n", resp.Call.Outputs[0].TransformedContent) case "pending", "running": // 202 — fall back to polling or wait for the webhook pollUntilDone(ctx, client, resp.Call.CallID) case "failed": // 500 — terminal error log.Fatalf("call failed: %v", resp.Call.Errors) } ``` ```csharp var bytes = File.ReadAllBytes("invoice.pdf"); var encoded = Convert.ToBase64String(bytes); var response = await client.Workflows.Call("invoice-processing", new WorkflowCallParams { Wait = true, CallReferenceID = "inv-12345", Input = new Input { SingleFile = new FileInput { InputType = InputType.Pdf, InputContent = encoded }, }, }); var call = response.Call; switch (call.Status) { case "completed": // 200 — terminal events in call.Outputs Console.WriteLine(call.Outputs[0].TransformedContent); break; case "pending": case "running": // 202 — fall back to polling or wait for the webhook await PollUntilDone(client, call.CallID); break; case "failed": // 500 — terminal error throw new Exception(call.Errors[0].ErrorMessage); } ``` ```bash bem workflows call \ --workflow-name invoice-processing \ --wait \ --call-reference-id inv-12345 \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' ``` The CLI exits non-zero on `5xx` responses and prints the response body on `2xx`. Pipe through `--transform 'call.status'` to branch on the status from a script: ```bash STATUS=$(bem workflows call --workflow-name invoice-processing --wait \ --call-reference-id inv-12345 \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' \ --transform 'call.status' --format raw) if [ "$STATUS" = "completed" ]; then # 200 path echo "done" elif [ "$STATUS" = "pending" ] || [ "$STATUS" = "running" ]; then # 202 path — fall back to polling echo "still running" fi ``` For the polling-fallback implementation (cadence, backoff, deadline), see [Polling and retries](/guide/polling-and-retries#polling). For the response payload shape — where `transformedContent` vs `enrichedContent` etc. lives — see [Reading workflow call outputs](/guide/reading-workflow-call-outputs). Configuring your HTTP client [#configuring-your-http-client] Your client's request timeout must exceed the server's wait window or you'll abort connections that would otherwise return successful results. **Set the client timeout to at least 35 seconds** when calling with `wait=true` — a 5-second buffer beyond the 30-second ceiling covers TLS handshake, network jitter, and the response body itself. The official SDKs default to a longer timeout out of the box, but if you've overridden it (or you're hand-rolling the HTTP call), confirm it's wide enough. ```bash curl --max-time 35 -X POST "https://api.bem.ai/v3/workflows/.../call?wait=true" ... ``` ```typescript const client = new Bem({ timeout: 35_000 }); // ms; default is 60s ``` ```python client = Bem(timeout=35.0) # seconds; default is 60s ``` ```go import "github.com/bem-team/bem-go-sdk/option" client := bem.NewClient(option.WithRequestTimeout(35 * time.Second)) ``` ```csharp var client = new BemClient(new ClientOptions { TimeoutInSeconds = 35 }); ``` If your load balancer, reverse proxy, or serverless runtime enforces its own timeout (AWS API Gateway is 29s, Cloudflare Workers is 30s by default, Vercel serverless functions vary), make sure that ceiling is also above 30s — otherwise the proxy will return `504 Gateway Timeout` even when bem would have responded successfully. Sync vs async: which to use [#sync-vs-async-which-to-use] | You're building | Use | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | A user-facing flow where someone is waiting on the result (web upload, chat agent, dashboard) | **Sync** (`wait=true`) — fall back to polling on 202 | | A CLI tool, dev script, or one-shot job from a developer's machine | **Sync** — simplest code path | | A backend RPC where the caller has its own request budget under 30s | **Sync** — your timeout becomes the caller's deadline | | A scheduled batch (nightly invoice ingest, end-of-day reconciliation) | **Async + webhooks** — no reason to hold connections | | A high-volume ingestion pipeline (S3 drops, email forwarding, Kafka tail) | **Async + webhooks** — scale better without sync connection counts | | A long-running workflow you know runs over 30s (multi-page splits, OCR-heavy stacks) | **Async + webhooks** — sync mode would 202 every time | | Anything where the caller can't afford to block (mobile background tasks, lambdas with 15s budget) | **Async + webhooks** — fire-and-forget | A reasonable hybrid for mixed workloads: **call with `wait=true`, branch on the `status` field, fall back to a webhook subscription on 202**. The same `callReferenceID` makes the optimistic fast path and the eventual webhook delivery point at the same call object — no duplication, no extra bookkeeping. Production patterns [#production-patterns] Always pass a `callReferenceID` [#always-pass-a-callreferenceid] Use a deterministic key from your domain — the invoice ID, the document UUID, the `(buyer, PO, timestamp)` tuple. When the same `callReferenceID` is submitted against the same workflow twice within bem's retention window, the second request returns the existing call instead of creating a new one. This is what makes network-failure retries safe with `wait=true`: if you don't know whether the server received your first request, you can resubmit with the same key and either get the original call back or create it for the first time. Without a `callReferenceID`, every retry creates a new call and you'll process the same input multiple times. See [Idempotency via `callReferenceID`](/guide/polling-and-retries#idempotency-via-callreferenceid) for the full semantics. Don't retry a 202 — poll instead [#dont-retry-a-202--poll-instead] A `202 Accepted` is not a retryable failure. Retrying with the same `callReferenceID` returns the existing in-progress call (no harm done, but no progress either). Retrying *without* a `callReferenceID` creates a duplicate call — same input, same work, twice. Either way, the right move is to **switch to polling** `GET /v3/calls/{callID}` until you see a terminal status, or wait for the webhook delivery if you've subscribed. Subscribe a webhook before the call, not after [#subscribe-a-webhook-before-the-call-not-after] For workloads that mix sync and fallback, set up a webhook subscription on the workflow before you start sending calls. That way every call has two completion paths: the fast sync return on 200, and the eventual webhook on completion regardless of the wait outcome. Your handler treats both as the same final state — see the call's `callID` to dedupe. ```text client bem │ │ │── POST /v3/workflows/.../call?wait=true ────▶│ │ │── starts processing ───▶ workers │ │ │ ◀── (case A: 200, status=completed) ────────│ done within 30s │ │ │ ◀── (case B: 202, status=running) ──────────│ not done at 30s — keeps running │ │ │ │── eventually completes ──▶ webhook delivered │ ◀────────────────────────── webhook POST ───│ ``` This belt-and-braces pattern is the production-friendly default: low-latency results when the workflow finishes quickly, durable delivery when it doesn't. See [Webhooks](/guide/webhooks) for the subscription setup and signature verification. Concurrency budgeting [#concurrency-budgeting] Synchronous mode holds an HTTP connection per in-flight call. If you push a high request rate through sync mode, you can hit your platform's connection or thread limits before bem's. As a rule of thumb, sync mode is comfortable up to a few hundred concurrent in-flight calls per client. Above that, switch to async + webhooks: each call costs you a single round-trip to enqueue, not a held connection for tens of seconds. Common pitfalls [#common-pitfalls] * **Treating 202 as a failure.** It's a code path: the call is alive and will finish — poll or wait for the webhook. * **Retrying a 202 without `callReferenceID`.** Creates a duplicate call. Always submit calls with a deterministic `callReferenceID` so retries are no-ops. * **Client timeout shorter than 30s.** The connection will abort just before bem returns. Set client timeout ≥ 35s. * **Proxy / gateway timeouts shorter than 30s.** API Gateway, Cloudflare, function runtimes — check the layer between you and bem and confirm it's above 30s, or you'll get a 504 even on successful workflows. * **Using sync mode for batch / scheduled work.** It works, but you're holding connections for no reason — async + webhook scales better and frees up your callers. * **Forgetting that the wait is read from form OR query.** For multipart uploads, use `-F "wait=true"`. For JSON bodies, use the `?wait=true` query param. Mixing them silently no-ops. * **Looking for `call.output` (singular) on a 200.** It's `call.outputs[]` (plural array). See [Reading workflow call outputs](/guide/reading-workflow-call-outputs). Related [#related] Polling cadence, idempotency, and retry semantics for the 202 fallback path Subscribe an endpoint and verify signed deliveries Where the extracted data lives in the response, and how to access it in every SDK `POST /v3/workflows/{workflowName}/call` — every parameter # System Overview (/guide/system-overview) > For the complete documentation index, see [llms.txt](/llms.txt). This page covers bem's core primitives — functions, workflows, calls, events, transformations, subscriptions, views — and how they fit together. If you've already done the [Quickstart](/guide/quickstart) and want a model of what's actually happening under the API, this is the page. The Big Picture [#the-big-picture] ```text +----------------+ +------------------+ +-----------------+ | | | | | | | Your Input | --> | Workflow | --> | Structured | | (PDF, email, | | (orchestrates | | Output (JSON) | | image, etc.) | | Functions) | | | | | | | | | +----------------+ +------------------+ +-----------------+ | v +--------------------+ | Subscriptions | | (webhooks to | | your systems) | +--------------------+ ``` You send documents to bem, workflows orchestrate processing using functions, and you receive structured JSON via polling or webhooks. Core Primitives [#core-primitives] Functions [#functions] A **function** is a single, reusable processing operation. Functions are the atomic building blocks of bem: | Type | Description | Use case | | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `extract` | 1:1 extraction of structured JSON from documents, images, and media | Invoices, forms, receipts, visual analysis | | `classify` | Classifies inputs and directs them down labeled paths | Document type classification, branching workflows | | `split` | 1:N breakdown of multi-document files | Processing bundled PDFs | | `join` | N:1 combination of multiple inputs | Merging related documents | | `enrich` | Augments data via semantic search against collections | SKU matching, catalog lookup | | `parse` | Renders documents into a navigable structure of sections, entities, and relationships | LLM-agent retrieval over a corpus, cross-document memory | | `payload_shaping` | Transforms JSON structure using JMESPath | Formatting for downstream APIs | | `render` | Merges structured JSON into a Word template to produce a finished `.docx` | Contracts, reports, letters from extracted data | | `send` | Delivers workflow outputs to an external destination | Webhooks, S3 sync, Google Drive | Functions are **versioned** — each configuration change creates a new version. Workflow nodes that pin a `versionNum` continue to use that version even after the function is updated. If you're coming from V1/V2, see [V3 migration](/guide/v3-migration) for the rename map. Workflows [#workflows] A **workflow** orchestrates multiple functions into a unified processing pipeline. Workflows are configured as a directed graph: ```text Workflow +-------------------------------------------------------------+ | | | +-----------+ +----------+ +----------------+ | | | Extract | ---> | Enrich | ---> | Payload Shaping| | | +-----------+ +----------+ +----------------+ | | | +-------------------------------------------------------------+ ^ | Single Entry Point ``` * **Main Function**: The entry point that receives input * **Relationships**: Define how data flows between functions * **Versioned**: Update workflows safely without disrupting production Calls [#calls] A **call** is an execution request. When you send data to bem, you create a call: ```text POST /v2/calls | v +------------------+ | WorkflowCall | <-- Your execution request +------------------+ | | spawns one per function v +------------------+ +------------------+ | FunctionCall 1 | ---> | FunctionCall 2 | +------------------+ +------------------+ ``` * **Workflow Call**: Executes an entire workflow * **Ad-hoc Function Call**: Executes a single function directly Calls progress through statuses: `pending` → `running` → `completed` (or `failed`). Events [#events] An **event** is the output notification from a function execution. When a function completes, it produces an event containing the results. ```text FunctionCall completes | v +------------------+ | Event | +------------------+ | +-- eventType ("extract" | "enrich" | "transform" | "classify" | ...) | +-- Content field (payload, varies by eventType — see below) | +-- Triggers Subscriptions (webhooks) ``` Events are what subscriptions listen to — when created, bem delivers them to your configured webhook endpoints. Events are also what a workflow call returns synchronously: a `call` object whose terminal events are in `call.outputs`, with the extracted data on each event at `transformedContent` / `enrichedContent` / `choice` / etc. depending on `eventType`. See [Reading workflow call outputs](/guide/reading-workflow-call-outputs) for the full path map and accessor patterns in every SDK. Transformations [#transformations] A **Transformation** is a persisted record of one function's structured output, stored in bem and queryable via the legacy [`/v1-beta/transformations`](/api/legacy/transformations) endpoints. The shape is: ```json { "transformID": "tr_abc123", "extractedJSON": { "invoiceNumber": "INV-2024-001", "vendor": "Acme Corp", "totalAmount": 1250.0 }, "referenceID": "your-tracking-id" } ``` Transformations adhere to the `outputSchema` defined in the function configuration. > **V3 workflow callers, take note:** `POST /v3/workflows/{name}/call` does **not** return Transformation records. It returns Events whose extracted JSON is at `outputs[].transformedContent` (or `enrichedContent` etc., per the [field map above](#where-the-data-lives-by-eventtype)). The legacy Transformation record shape only shows up if you read it through the V1/V2 endpoints. Subscriptions [#subscriptions] A **subscription** configures webhook delivery for events, connecting function outputs to your systems: ```text Function completes --> Event created --> Subscription triggers --> Webhook sent ``` Subscribe to specific functions to receive notifications when they complete. Views [#views] A **view** provides insight into transformation outputs. Views can include columns, filters, and aggregations—useful for monitoring and analyzing results across many function executions. How Everything Connects [#how-everything-connects] ```text 1. SETUP (once) +-- Create Functions (define extraction logic) +-- Create Workflow (chain functions together) +-- Create Subscriptions (configure webhooks) 2. EXECUTE (per document) POST /v2/calls | v WorkflowCall created (status: pending) | v FunctionCalls execute in sequence | v Events produced with Transformations | v Subscriptions trigger webhooks to your systems 3. RETRIEVE GET /v2/calls/{id} --> Full results with all function outputs ``` Data Model Summary [#data-model-summary] | Concept | What It Is | Contains | | ------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | **Function** | Reusable processing unit | Configuration, output schema | | **Workflow** | Orchestration layer | Main function, relationships | | **Call** | Execution request | Input data, reference ID, terminal `outputs[]` | | **Function Call** | Single function execution | Status, attempt info | | **Event** | Output notification (one per function execution) | `eventType`, `functionName`, content payload (`transformedContent` / `enrichedContent` / `choice` / …) | | **Transformation** | Persisted record of a function's output (legacy V1/V2) | `transformID`, `extractedJSON`, `referenceID` | | **Subscription** | Webhook config | Function ID, webhook URL | Next Steps [#next-steps] Build your first workflow step-by-step Deep dive into workflow orchestration Explore all available function types API reference for executing workflows # Terraform (/guide/terraform) > For the complete documentation index, see [llms.txt](/llms.txt). The bem Terraform provider lets you declare functions and workflows as code, version them in your repo, and apply changes through your existing infrastructure pipeline. It calls the same V3 API as the SDKs and the dashboard, so anything you can build in the UI you can manage with Terraform. Capabilities [#capabilities] The provider exposes the two primitives at the heart of bem and read-only data sources for everything you'd query. | Resource | Purpose | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bem_function` | Create and update functions of any type — `extract`, `classify`, `split`, `join`, `enrich`, `payload_shaping`, `send`. Owns the function's output schema, configuration, and tags. | | `bem_workflow` | Wire functions into a DAG. Owns nodes, edges, the entry-point node, connectors (e.g. Box, Dropbox, S3), and tags. | | Data source | Returns | | --------------- | --------------------------------------- | | `bem_function` | A single function by name. | | `bem_functions` | A list of functions in the environment. | | `bem_workflow` | A single workflow by name. | | `bem_workflows` | A list of workflows in the environment. | Authentication uses an API key, picked up from the `BEM_API_KEY` environment variable or set explicitly in the `provider` block. Functions and workflows are versioned on the bem side. Each `terraform apply` that changes a resource creates a new version — older versions remain readable and existing calls keep working. Prerequisites [#prerequisites] * A [bem account](https://app.bem.ai) and an API key generated from **Settings → API Keys**. * [Terraform CLI](https://developer.hashicorp.com/terraform/install) 1.0 or later. Step 1: Set up and initialize the provider [#step-1-set-up-and-initialize-the-provider] Create a `main.tf` and declare the provider: ```hcl terraform { required_providers { bem = { source = "bem-team/bem" version = "~> 0.1" } } } provider "bem" { # Reads BEM_API_KEY from the environment when omitted. } ``` Export your API key and initialize the working directory: ```bash export BEM_API_KEY='your-api-key-here' terraform init ``` `terraform init` downloads the provider from the Terraform Registry and writes a lock file. You're ready to declare resources. Step 2: Create a function and a workflow [#step-2-create-a-function-and-a-workflow] Add an extract function and a single-node workflow that uses it. The `output_schema` is a standard JSON Schema, passed as a string via `jsonencode` so HCL stays readable: ```hcl resource "bem_function" "invoice_extractor" { function_name = "invoice-extractor" type = "extract" display_name = "Invoice Extractor" output_schema_name = "Invoice" output_schema = jsonencode({ type = "object" required = ["invoiceNumber", "vendor", "totalAmount"] properties = { invoiceNumber = { type = "string", description = "Unique invoice identifier" } invoiceDate = { type = "string", description = "Invoice date (YYYY-MM-DD)" } vendor = { type = "object" properties = { name = { type = "string" } address = { type = "string" } } } totalAmount = { type = "number" } } }) tags = ["finance", "managed-by-terraform"] } resource "bem_workflow" "invoice_intake" { name = "invoice-intake" display_name = "Invoice Intake" main_node_name = "extract" nodes = [{ name = "extract" function = { name = bem_function.invoice_extractor.function_name version_num = bem_function.invoice_extractor.function.version_num } }] tags = ["finance"] } ``` The workflow references the function by name, and pins to its current `version_num` — Terraform will roll the workflow forward whenever the function version changes. Because there's only one node, `edges` can be omitted. Apply the configuration: ```bash terraform plan terraform apply ``` The `apply` creates the function first, then the workflow that depends on it. Once it finishes, you can call the workflow exactly like any workflow created through the UI or SDKs. Step 3: Update the function and workflow [#step-3-update-the-function-and-workflow] Iterating is the same pattern as any Terraform resource: edit the configuration, plan, apply. Suppose the schema needs a `lineItems` array and you want to add a payload-shaping step that reformats the output before delivery. Update the function and add a second node plus an edge to the workflow: ```hcl resource "bem_function" "invoice_extractor" { function_name = "invoice-extractor" type = "extract" display_name = "Invoice Extractor" output_schema_name = "Invoice" output_schema = jsonencode({ type = "object" required = ["invoiceNumber", "vendor", "totalAmount"] properties = { invoiceNumber = { type = "string", description = "Unique invoice identifier" } invoiceDate = { type = "string", description = "Invoice date (YYYY-MM-DD)" } vendor = { type = "object" properties = { name = { type = "string" } address = { type = "string" } } } lineItems = { type = "array" items = { type = "object" properties = { description = { type = "string" } quantity = { type = "number" } unitPrice = { type = "number" } amount = { type = "number" } } } } totalAmount = { type = "number" } } }) tags = ["finance", "managed-by-terraform"] } resource "bem_function" "invoice_shaper" { function_name = "invoice-shaper" type = "payload_shaping" display_name = "Invoice Shaper" shaping_schema = jsonencode({ invoice_id = "invoiceNumber" vendor = "vendor.name" total = "totalAmount" items = "lineItems" }) } resource "bem_workflow" "invoice_intake" { name = "invoice-intake" display_name = "Invoice Intake" main_node_name = "extract" nodes = [ { name = "extract" function = { name = bem_function.invoice_extractor.function_name version_num = bem_function.invoice_extractor.function.version_num } }, { name = "shape" function = { name = bem_function.invoice_shaper.function_name version_num = bem_function.invoice_shaper.function.version_num } }, ] edges = [{ source_node_name = "extract" destination_node_name = "shape" }] tags = ["finance"] } ``` Plan and apply: ```bash terraform plan terraform apply ``` Terraform issues an in-place update to the function (creating a new function version on bem's side) and updates the workflow to reference both functions and the new edge. Existing calls continue against their pinned version; new calls use the new one. To remove the workflow, run `terraform destroy` — the workflow is deleted first, then the functions it referenced. Reference [#reference] Full schema for every resource and data source. Source, examples, releases, and issue tracker. How functions, workflows, and calls fit together. Every function type the provider can manage. # V3 Migration (/guide/v3-migration) > For the complete documentation index, see [llms.txt](/llms.txt). V3 cleans up the function and routing primitives that grew up across V1 and V2. The mental model didn't change — bem still runs functions inside workflows — but a handful of names and endpoints did. **Existing integrations keep working.** Legacy types remain readable and callable; you only need to migrate when you create a new function or add a new endpoint. What renamed [#what-renamed] | Concept | Legacy | V3 | | ------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Pull structured data out of a file (text-first) | `transform` function | `extract` function | | Pull structured data out of a file (visual-first) | `analyze` function | `extract` function (same primitive — input drives the strategy) | | Branch on content type | `route` function | `classify` function | | The list of branches on a `route`/`classify` | `routes` | `classifications` | | Send a request to bem | `pipeline` (V1) / `function-call` (V2) | `workflow` + `call` | | The output payload of a successful function | `transformation` | `transformation` (still the same name on the wire — `extract` events emit `eventType: "transform"` for backward compatibility) | What's the same [#whats-the-same] * Functions are still versioned. Updates create new versions; old versions remain immutable and addressable. * `outputSchema` still uses standard JSON Schema. * Authentication is still an `x-api-key` header. * Webhooks still use the `bem-signature` header (`t={timestamp},v1={hex_hmac_sha256}`). * Cursor pagination still uses `startingAfter` and `endingBefore`. * File `inputType` values (csv, docx, email, heic, heif, html, jfif, jpeg, json, m4a, mov, mp3, mp4, pdf, png, pptx, text, wav, webp, xls, xlsx, xml) carry over unchanged. Endpoint mapping [#endpoint-mapping] The `/v3` surface replaces a few legacy entry points. Where you used to call the legacy URL on the left, call the V3 URL on the right. | Legacy | V3 | Notes | | ---------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `POST /v2/functions/{functionName}/call` | `POST /v3/workflows/{workflowName}/call` | V3 always invokes through a workflow. For a single-function pipeline, wrap the function in a one-node workflow with no edges. | | `POST /v2/calls` | `POST /v3/workflows/{workflowName}/call` | Per-workflow URL replaces the unified `/calls` body. | | `GET /v2/calls/{id}` | `GET /v3/calls/{callID}` | Same shape, V3 wraps the call object consistently. | | `POST /v1-beta/transformations` | `POST /v3/workflows/{workflowName}/call` | Transformations are now produced by workflow calls. | | `POST /v2/connectors` | inline `connectors` on `POST /v3/workflows` | Connectors are workflow configuration, not a separate resource. | | `POST /v2/collections` (Bearer auth) | `POST /v3/collections` (`x-api-key`) | Collections moved under V3. The item body field is now `data` (string or object), not `content` + `metadata`. | Subscriptions are available at `/v3/subscriptions`. The legacy `/v1-alpha/subscriptions` endpoint still works for backward compatibility and routes to the same handler. See [Webhooks](/guide/webhooks) for the end-to-end flow. Migration checklist [#migration-checklist] 1. **Create new functions as `extract` or `classify`.** Don't reach for `transform`/`analyze`/`route` — those types are read-only on V3. 2. **Wrap existing functions in workflows** if you're calling them directly. A one-node workflow has no edges and behaves identically: ```json { "name": "invoice-extractor-wrapper", "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } } ] } ``` Adding nodes later produces a new workflow version without breaking existing callers. 3. **Update branching keys.** When you build a workflow off a Classify function, the edge field is `destinationName` and it matches `classifications[].name`. (In legacy, this was `routes[].name` on `route` functions.) 4. **Re-point your polling URL** if you read call status: legacy `GET /v2/calls/{id}` → V3 `GET /v3/calls/{callID}`. 5. **Switch collections endpoints** from `/v2/collections` (with `Authorization: Bearer`) to `/v3/collections` (with `x-api-key`), and update item bodies to use `data` instead of `content` + `metadata`. You don't need to migrate a function just to use the new APIs around it: V3 endpoints accept legacy function types for reads and (for `extract`-equivalent operations) calls. The only hard line is creates and updates — those are V3 types only. Scoring: eval runs and comparisons [#scoring-eval-runs-and-comparisons] The scoring endpoints are new in V3, so there is nothing to migrate — but they work differently enough from the legacy accuracy tooling to be worth reading before you wire them up. **A run is the extraction; the score is a read.** Creating an eval run or a comparison dispatches function calls and nothing more. Comparing that output against your expected values is a pure function of two values bem already stores, so it happens on every read instead of being fixed when the run executed. Re-reading a finished run costs no model calls, and its numbers always reflect your dataset as it stands now. That means comparison settings are query parameters, not request-body fields: ```http GET /v3/model-comparisons/{comparisonID}?matchMode=normalized&orderMatching=false ``` * **`matchMode`** — `strict` (default), `normalized`, or `fuzzy`. Anything else is a `400`; an unrecognized mode is rejected rather than scored at some other strictness. This is the whole of strictness. `normalized` compares numbers as numbers and dates by calendar value rather than by spelling; `fuzzy` adds a similarity pass over free text. There are deliberately no tolerance or similarity dials: a threshold you can widen until the numbers look acceptable is not measuring anything. * **`orderMatching`** — score array elements in order instead of as sets. * **`includeRowResults`** — include per-row, per-field detail. Off by default; it can be large. `GET /v3/eval/score/{scoreRunID}` takes no parameters at all. Its comparison is exact: a value matches the expected one or it does not. Reading a field result [#reading-a-field-result] Both endpoints classify each leaf the same way: | Category | Meaning | | ---------- | -------------------------------------------- | | `match` | both present and equal | | `mismatch` | both present, different | | `missing` | expected a value, none was produced | | `extra` | a value was produced where none was expected | Only `match` counts toward `precision`, `recall` and `f1`. A field result also carries `delta` for every non-identical numeric pair and `similarity` (a Levenshtein ratio) for every non-identical string pair — they tell you how close a wrong value was, which never makes it right. For the full workflow — building a dataset, comparing versions, reading lift, and spotting mislabeled rows — see [Comparing Functions on a Dataset](/guide/model-comparison). What's still legacy [#whats-still-legacy] Legacy reference pages remain available under [API Reference → Legacy](/api/legacy/authentication) for endpoints that don't have a V3 successor yet. Most `function-calls`, `events`, `pipelines`, `connectors`, `actions`, and `subscriptions` endpoints are accessible there. Each legacy page that does have a V3 successor carries a deprecation callout linking to it. The V3 orchestration model end-to-end. Extract, Classify, Split, Join, Enrich, Payload Shaping. Subscribe, receive, and verify event deliveries. # Webhooks (/guide/webhooks) > For the complete documentation index, see [llms.txt](/llms.txt). bem can deliver every terminal event from a function — a successful transformation, an extraction error, a classification — to an HTTPS endpoint you control. This page walks through the full flow: enabling signatures, creating a subscription, and verifying deliveries in your receiver. Concepts [#concepts] There are two ways to receive webhooks from bem: 1. A **subscription** binds a function to a webhook URL. Whenever that function produces a terminal event, bem POSTs the event JSON to the URL. The HTTP body is the event itself — no envelope. This page walks through subscriptions end-to-end. 2. A **[Send function](/guide/function-types/send)** is a workflow node whose job is to deliver the upstream payload to a destination — including a webhook URL. Use Send functions when delivery should be part of the workflow graph (mid-pipeline, behind a Classify branch, fanned out to multiple destinations, or with a reshaped payload). The wire format and signature verification are identical to subscription deliveries; the rest of this page applies to both. When a **webhook signing secret** is active, every delivery — subscription or Send — includes a `bem-signature` header you can use to confirm the request really came from bem (and that the body wasn't modified in transit). You should always have one active. Step 1: Generate a signing secret [#step-1-generate-a-signing-secret] ```bash curl -X POST https://api.bem.ai/v3/webhook-secret \ -H "x-api-key: $BEM_API_KEY" ``` The response contains the secret in plaintext. **This is the only time it's shown** — store it in your secrets manager immediately. To rotate later, call the same endpoint again. To avoid downtime, update your verification logic to accept either the old or the new secret for a minute or two before revoking the old one. Step 2: Subscribe a function to a URL [#step-2-subscribe-a-function-to-a-url] Create a subscription on the V3 surface. A subscription needs a `name`, a `type` (the function's output type, e.g. `transform`), the `functionName` to listen to, and a `webhookURL` to deliver to: ```bash curl -X POST https://api.bem.ai/v3/subscriptions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "name": "invoice-results", "type": "transform", "functionName": "invoice-extractor", "webhookURL": "https://your-app.example.com/webhooks/bem" }' ``` The legacy `POST /v1-alpha/subscriptions` endpoint still works for backward compatibility and routes to the same handler. You can subscribe to any number of functions, and a single function can fan out to multiple URLs. Subscriptions trigger on **terminal events only** — intermediate function calls within a workflow don't fire on their own; the workflow's terminal nodes do. Step 3: Build the receiver [#step-3-build-the-receiver] The header looks like this: ``` bem-signature: t=1492774577,v1=0734be64d748aa8e8ee9dfe87407665541f2c33f9b0ebf19dfd0dd80f08f504c ``` `t` is a Unix timestamp. `v1` is the hex-encoded HMAC-SHA256 of `{t}.{raw_request_body}` using your signing secret as the key. To verify: 1. Read the **raw** request body — not a re-serialized JSON object. Re-serialization can reorder keys or change spacing and break the signature. 2. Parse `bem-signature` into `t` and `v1`. 3. Compute `HMAC-SHA256("{t}.{rawBody}", secret)` and hex-encode it. 4. Compare against `v1` with a constant-time comparison. 5. Reject if `t` is more than \~5 minutes old (replay protection). Node.js (Express) [#nodejs-express] ```js import crypto from "node:crypto"; import express from "express"; const app = express(); const SECRET = process.env.BEM_WEBHOOK_SECRET; const TOLERANCE_SECONDS = 5 * 60; // Capture the raw body — Express's json() parser would mutate it. app.post( "/webhooks/bem", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header("bem-signature") ?? ""; const parts = Object.fromEntries( sig.split(",").map((p) => p.split("=", 2)) ); const { t, v1 } = parts; if (!t || !v1) return res.status(400).send("missing signature"); const ageSeconds = Math.floor(Date.now() / 1000) - Number(t); if (Math.abs(ageSeconds) > TOLERANCE_SECONDS) { return res.status(400).send("timestamp out of tolerance"); } const expected = crypto .createHmac("sha256", SECRET) .update(`${t}.${req.body.toString("utf8")}`) .digest("hex"); if ( expected.length !== v1.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) ) { return res.status(401).send("invalid signature"); } const event = JSON.parse(req.body.toString("utf8")); // ack quickly, do real work async res.status(204).end(); handleEvent(event).catch(console.error); } ); ``` Python (FastAPI) [#python-fastapi] ```python import hmac, hashlib, os, time from fastapi import FastAPI, Header, HTTPException, Request app = FastAPI() SECRET = os.environ["BEM_WEBHOOK_SECRET"] TOLERANCE_SECONDS = 5 * 60 @app.post("/webhooks/bem") async def bem_webhook(request: Request, bem_signature: str = Header(...)): raw = await request.body() parts = dict(p.split("=", 1) for p in bem_signature.split(",")) t = parts.get("t") v1 = parts.get("v1") if not t or not v1: raise HTTPException(400, "missing signature") if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS: raise HTTPException(400, "timestamp out of tolerance") signed = f"{t}.{raw.decode('utf-8')}".encode() expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, v1): raise HTTPException(401, "invalid signature") event = await request.json() # ack quickly, schedule real work return {"ok": True} ``` Go (net/http) [#go-nethttp] ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time" ) const toleranceSeconds = 5 * 60 func bemWebhook(secret string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read failed", http.StatusBadRequest) return } parts := map[string]string{} for _, p := range strings.Split(r.Header.Get("bem-signature"), ",") { kv := strings.SplitN(p, "=", 2) if len(kv) == 2 { parts[kv[0]] = kv[1] } } t, v1 := parts["t"], parts["v1"] if t == "" || v1 == "" { http.Error(w, "missing signature", http.StatusBadRequest) return } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || abs64(time.Now().Unix()-ts) > toleranceSeconds { http.Error(w, "timestamp out of tolerance", http.StatusBadRequest) return } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + "." + string(body))) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(v1)) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } // ack quickly, do real work async w.WriteHeader(http.StatusNoContent) go handleEvent(body) } } func abs64(x int64) int64 { if x < 0 { return -x } return x } func main() { http.HandleFunc("/webhooks/bem", bemWebhook(os.Getenv("BEM_WEBHOOK_SECRET"))) http.ListenAndServe(":8080", nil) } ``` Best practices [#best-practices] * **Acknowledge fast.** Return a 2xx within a few seconds. Do the real work after the response is sent. * **Be idempotent.** Dedupe by `eventID` — the same event can be redelivered after a network failure. * **Accept both old and new during rotation.** Store two valid secrets briefly, then revoke the old one once you've seen at least one delivery signed with the new one. * **Reject anything older than your tolerance window.** Replay attacks become easier the longer you accept stale timestamps. * **Don't trust the body until verified.** Parse `JSON.parse(rawBody)` only after the signature check passes. What's in the body [#whats-in-the-body] The body is the event itself — same shape you'd get from `GET /v3/outputs/{eventID}` or `GET /v3/errors/{eventID}`. Inspect `eventType` to discriminate (`transform`, `route`/`classify`, `split`, `join`, etc.) and read the polymorphic payload accordingly. Delivering from inside a workflow [#delivering-from-inside-a-workflow] If you'd rather make delivery part of the workflow graph, drop a [Send function](/guide/function-types/send) into the workflow as a node. A Send function configured with `destinationType: "webhook"` POSTs to its `webhookUrl` whenever an upstream node feeds it a payload — the wire format and `bem-signature` header are identical to a subscription delivery, so the receivers above work unchanged. Reach for Send functions when: * Delivery should depend on the workflow shape (e.g. one webhook for invoices, another for receipts, branched off a Classify). * You need to fan out the same payload to several destinations. * You want to reshape the payload (chain a Payload Shaping node before the Send) or route it via S3/Google Drive instead of a webhook. Reach for subscriptions when: * The rule is "every event from this function goes to this URL," with no workflow context to express. * You want delivery to happen automatically without modifying the workflow graph. Both can coexist on the same function — a Send node inside a workflow and a subscription on the same function will both fire. Webhook, S3, and Google Drive destinations as workflow nodes. Failure shapes inside event payloads. Manage the signing secret programmatically. The pull alternative to webhooks. # Workflows Explained (/guide/workflows-explained) > For the complete documentation index, see [llms.txt](/llms.txt). A workflow is a directed graph of bem functions, called as a single endpoint. You define the graph once; bem runs it on every call, managing state and data flow between nodes. This page covers the structure, the common shapes (sequential, branching, splitting, joining), and how to create and update workflows from the API. What is a Workflow? [#what-is-a-workflow] A **workflow** is a versioned, reusable orchestration layer that wraps one or more functions into a cohesive processing unit. Think of it as a directed graph where: * **Nodes** are named call sites that point at a function (Extract, Classify, Split, Join, Enrich, Render, Send, etc.) * **Edges** are directed connections that define how data flows between nodes ```text Workflow +--------------------------------------------------------+ | | | +-----------+ +-----------+ | | | Extract | ----> | Enrich | | | | (main) | | | | | +-----------+ +-----------+ | | | +--------------------------------------------------------+ ^ | Input (PDF, email, JSON, etc.) ``` When you call a workflow, bem automatically executes all functions in the correct order, managing state and data flow between them. Why Use Workflows? [#why-use-workflows] Single Entry Point [#single-entry-point] Instead of managing multiple function calls and tracking their dependencies yourself, workflows provide a single API endpoint. Call the workflow once, and bem handles the orchestration: ```bash curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "input": { "singleFile": { "inputType": "pdf", "inputContent": "..." } } }' ``` Versioned Configuration [#versioned-configuration] Workflows are versioned independently from the functions they contain. Each time you update a workflow's structure (changing nodes or edges), a new version is created. This means: * Existing integrations continue working on their version * You can test new workflow configurations without disrupting production * Rollback is straightforward—just point to a previous version Unified Monitoring [#unified-monitoring] Track the entire processing pipeline through a single workflow call. The response surfaces the terminal outputs and errors produced by the workflow; fetch the full per-node execution graph via `GET /v3/calls/{callID}/trace`. ```json { "call": { "callID": "wc_abc123", "status": "completed", "workflowName": "invoice-processing", "workflowVersionNum": 1, "outputs": [ { "eventID": "ev_abc", "eventType": "transform", "transformation": { ... } } ], "errors": [], "url": "/v3/calls/wc_abc123", "traceUrl": "/v3/calls/wc_abc123/trace" } } ``` Workflow Structure [#workflow-structure] Every workflow has three key components: 1\. Nodes [#1-nodes] **Nodes** are named call sites in the workflow's DAG. Each node points at a function (optionally pinned to a version). At least one node is required: ```json { "name": "invoice-processing", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } } ] } ``` A node's `name` is unique within the workflow version and is referenced by `mainNodeName` and by edges. If omitted, it defaults to the function's own name. 2\. Main Node [#2-main-node] The **main node** is the entry point—the node that receives input when the workflow is called. Set `mainNodeName` to the `name` of one of the nodes declared above: ```json { "mainNodeName": "invoice-extractor" } ``` The main node must not be the destination of any edge. 3\. Edges [#3-edges] **Edges** define how data flows between nodes. Each edge specifies a source node and a destination node, creating the processing graph: ```json { "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "sku-matcher" } ] } ``` The output of the source node becomes the input for the destination node. Edges are optional — a single-node workflow has no edges. Common Workflow Patterns [#common-workflow-patterns] Sequential Pipeline [#sequential-pipeline] Chain functions in sequence for multi-step processing: ```text Input --> Extract --> Enrich --> Payload Shaping ``` **Example:** Extract invoice data, match to product catalog, then format for ERP system. ```json { "name": "invoice-to-erp", "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "product-matcher", "function": { "name": "product-matcher" } }, { "name": "erp-formatter", "function": { "name": "erp-formatter" } } ], "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "product-matcher" }, { "sourceNodeName": "product-matcher", "destinationNodeName": "erp-formatter" } ] } ``` Branching with Classify [#branching-with-classify] Use Classify functions to direct data down different paths based on content. The **destinationName** on each edge matches a `classifications[].name` from the Classify function. ```text +--> Invoice Extract | Input --> Classify ----+--> Receipt Extract | +--> PO Extract ``` **Example:** Classify incoming documents and process each type differently. ```json { "name": "document-processor", "mainNodeName": "document-classifier", "nodes": [ { "name": "document-classifier", "function": { "name": "document-classifier" } }, { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "receipt-extractor", "function": { "name": "receipt-extractor" } }, { "name": "po-extractor", "function": { "name": "po-extractor" } } ], "edges": [ { "sourceNodeName": "document-classifier", "destinationName": "invoice", "destinationNodeName": "invoice-extractor" }, { "sourceNodeName": "document-classifier", "destinationName": "receipt", "destinationNodeName": "receipt-extractor" }, { "sourceNodeName": "document-classifier", "destinationName": "purchase_order", "destinationNodeName": "po-extractor" } ] } ``` The `destinationName` field on an edge maps to the `classifications[].name` values defined in your Classify function configuration. Split and Process [#split-and-process] Handle multi-document files by splitting and processing each piece: ```text +--> Doc 1 --> Extract A | PDF --> Split-+--> Doc 2 --> Extract B | +--> Doc 3 --> Extract C ``` **Example:** A PDF containing multiple shipment documents, each needing extraction. ```json { "name": "shipment-bundle-processor", "mainNodeName": "shipment-splitter", "nodes": [ { "name": "shipment-splitter", "function": { "name": "shipment-splitter" } }, { "name": "bol-extractor", "function": { "name": "bol-extractor" } }, { "name": "commercial-invoice-extractor", "function": { "name": "commercial-invoice-extractor" } }, { "name": "packing-list-extractor", "function": { "name": "packing-list-extractor" } } ], "edges": [ { "sourceNodeName": "shipment-splitter", "destinationName": "bill_of_lading", "destinationNodeName": "bol-extractor" }, { "sourceNodeName": "shipment-splitter", "destinationName": "commercial_invoice", "destinationNodeName": "commercial-invoice-extractor" }, { "sourceNodeName": "shipment-splitter", "destinationName": "packing_list", "destinationNodeName": "packing-list-extractor" } ] } ``` Aggregation with Join [#aggregation-with-join] Combine outputs from multiple sources into a unified result: ```text Source A --+ | Source B --+--> Join --> Unified Extract Output | Source C --+ ``` **Example:** Merge data from multiple related documents into a comprehensive record. Function Reference Options [#function-reference-options] A node's `function` field accepts a `FunctionVersionIdentifier`. Provide either `id` or `name` (not both), and optionally a `versionNum` to pin to a specific version: | Reference Style | Example | Description | | ------------------------ | -------------------------------------------------------------- | ------------------------ | | By name (latest version) | `"function": { "name": "invoice-extractor" }` | Uses the current version | | By ID (latest version) | `"function": { "id": "f_abc123" }` | Uses the current version | | By name with version | `"function": { "name": "invoice-extractor", "versionNum": 2 }` | Pins to specific version | | By ID with version | `"function": { "id": "f_abc123", "versionNum": 2 }` | Pins to specific version | Pinning to specific versions is useful when you need deterministic behavior and want to control when updates propagate. Creating a Workflow [#creating-a-workflow] Create a workflow with `POST /v3/workflows`: ```bash curl -X POST https://api.bem.ai/v3/workflows \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "name": "invoice-processing", "displayName": "Invoice Processing Pipeline", "tags": ["finance", "invoices"], "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "sku-matcher", "function": { "name": "sku-matcher" } } ], "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "sku-matcher" } ] }' ``` Configuration Fields [#configuration-fields] | Field | Type | Required | Description | | -------------- | --------- | -------- | ------------------------------------------------------ | | `name` | string | Yes | Unique identifier (alphanumeric, hyphens, underscores) | | `displayName` | string | No | Human-readable name for the UI | | `tags` | string\[] | No | Tags for organizing workflows | | `mainNodeName` | string | Yes | Name of the entry-point node | | `nodes` | array | Yes | Call-site nodes in the DAG (at least one) | | `edges` | array | No | Directed edges between nodes | Updating a Workflow [#updating-a-workflow] Updates create a new version, preserving the previous configuration. Use `PATCH /v3/workflows/{workflowName}`: ```bash curl -X PATCH https://api.bem.ai/v3/workflows/invoice-processing \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "sku-matcher", "function": { "name": "sku-matcher" } }, { "name": "erp-formatter", "function": { "name": "erp-formatter" } } ], "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "sku-matcher" }, { "sourceNodeName": "sku-matcher", "destinationNodeName": "erp-formatter" } ] }' ``` When updating structure, you must provide `mainNodeName`, `nodes`, and `edges` together. Omit all three to keep the topology unchanged from the current version while updating `displayName`, `tags`, or `name`. Deleting a Workflow [#deleting-a-workflow] `DELETE /v3/workflows/{workflowName}` returns `200` with a body, not an empty `204` — read it before you discard it: ```bash curl -X DELETE https://api.bem.ai/v3/workflows/invoice-processing \ -H "x-api-key: YOUR_API_KEY" ``` ```json { "workflow": { "workflowID": "wf_abc123", "name": "invoice-processing", "versionNum": 3 }, "connectorErrors": [ { "connectorID": "cnr_paragon_xyz", "operation": "delete", "message": "..." } ] } ``` Connector teardown is best-effort and doesn't block the deletion. A non-empty `connectorErrors` means the workflow itself is gone but one or more of its Paragon-backed connectors may still need manual cleanup — check that array on every delete rather than assuming an empty response. Workflow Versions [#workflow-versions] List all versions of a workflow with `GET /v3/workflows/{workflowName}/versions`: ```bash curl https://api.bem.ai/v3/workflows/invoice-processing/versions \ -H "x-api-key: YOUR_API_KEY" ``` Get a specific version with `GET /v3/workflows/{workflowName}/versions/{versionNum}`: ```bash curl https://api.bem.ai/v3/workflows/invoice-processing/versions/2 \ -H "x-api-key: YOUR_API_KEY" ``` Executing Workflows [#executing-workflows] Call a workflow with `POST /v3/workflows/{workflowName}/call`. The workflow name is derived from the URL path: ```bash curl -X POST "https://api.bem.ai/v3/workflows/invoice-processing/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "callReferenceID": "my-tracking-id-001", "input": { "singleFile": { "inputType": "pdf", "inputContent": "JVBERi0xLjQK..." } } }' ``` You can also upload files as `multipart/form-data` against the same endpoint — see [Call a Workflow](/api/v3/calls/v3-call-workflow). The `callReferenceID` is your custom identifier for tracking this execution in your systems. Pass `wait=true` to have the endpoint wait up to 30 seconds for the call to complete; if it's still running when the timeout elapses, the response returns `status: "pending"` or `"running"` and you can poll `GET /v3/calls/{callID}` or configure a webhook subscription. Ad-hoc Function Calls [#ad-hoc-function-calls] V3 executes every call through a workflow — there is no standalone "call a function" endpoint. For a single-function pipeline, wrap the function in a one-node workflow with no edges: ```bash curl -X POST https://api.bem.ai/v3/workflows \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "name": "invoice-extractor-wrapper", "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } } ] }' ``` Then call it like any other workflow via `POST /v3/workflows/invoice-extractor-wrapper/call`. Adding more nodes and edges later produces a new workflow version without breaking existing callers. Best Practices [#best-practices] Start Simple, Extend Incrementally [#start-simple-extend-incrementally] Begin with a single-node workflow. As requirements grow, add nodes and edges: 1. Create workflow with just an Extract function 2. Add Enrich to augment data with your catalogs 3. Add Payload Shaping to format for downstream systems Use Meaningful Names [#use-meaningful-names] Workflow and function names should describe their purpose: * `invoice-processing` (workflow) * `invoice-extractor` (extract function) * `product-catalog-matcher` (enrich function) Leverage Tags for Organization [#leverage-tags-for-organization] Use tags to categorize workflows by domain, team, or use case: ```json { "tags": ["finance", "ap-automation", "production"] } ``` Pin Versions for Stability [#pin-versions-for-stability] In production, consider pinning function versions to prevent unexpected changes: ```json { "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor", "versionNum": 3 } } ] } ``` Finding Workflows Pinned to a Function Version [#finding-workflows-pinned-to-a-function-version] Before changing or deleting a function version, check which workflows still reference it. `GET /v3/workflows` filters by `functionNames` / `functionIDs` for any reference to the function, or by `functionNameVersionNums` / `functionIDVersionNums` to narrow to a specific pinned version: ```bash curl -G "https://api.bem.ai/v3/workflows" \ -H "x-api-key: $BEM_API_KEY" \ -d functionNameVersionNums=invoice-extractor.3 ``` `GET /v3/workflows` supports several more filters — see [List Workflows](/api/v3/workflows/v3-list-workflows) for the complete parameter list. Next Steps [#next-steps] Build your first workflow step-by-step Explore all available function types V3 API reference for creating workflows V3 API reference for executing workflows V3 API reference for listing and filtering workflows # Match Buyer Orders to Catalog SKUs (/guide/cookbooks/match-buyer-orders-to-catalog-skus) > For the complete documentation index, see [llms.txt](/llms.txt). If you're a produce supplier (or any wholesaler) ingesting buyer orders, the bottleneck isn't the API to your ERP — it's everything *before* the API. Buyers email POs in their own words: "10 cases organic gala apples, 88 ct" instead of `APL-GALA-ORG-CASE × 10`. Today, somebody types those lines into your order-entry screen by hand, looking up SKUs in a catalog tab, and your warehouse waits. This cookbook builds the small bem workflow that replaces that step. You'll wire two function types into a single workflow: 1. An [Extract](/guide/function-types/extract) function that pulls a clean order shape — buyer, delivery date, line items — out of whatever the buyer sent (PDF, email, scan). 2. An [Enrich](/guide/function-types/enrich) function that semantically matches each line item's free-text description to the right SKU in your **Collection** (your product catalog, indexed by bem). The result is a single API call that turns *"need 10 cases organic gala apples, 88 ct, Tuesday delivery"* into: ```json { "description": "organic gala apples, 88 ct", "quantity": 10, "unit": "case", "matchedProduct": { "data": { "sku": "APL-GALA-ORG-CASE", "name": "Organic Gala Apples", "packSize": "88-count tray", "category": "Apples", "unitCost": 42.0 }, "cosineDistance": 0.0612 } } ``` …ready to drop straight into the ERP or WMS. No string-matching, no fuzzy logic, no maintenance of a separate normalization table — the catalog *is* the index. Pick a language from the tabs in each step. The flow is identical across cURL, the SDKs, and the CLI. If you don't have an SDK installed yet, see [Step 2 of the Quickstart](/guide/quickstart#step-2-install-the-sdk). Prerequisites [#prerequisites] * A [bem account](https://app.bem.ai) and an API key from **Settings → API Keys** * `BEM_API_KEY` exported in your shell: ```bash export BEM_API_KEY='your-api-key-here' ``` * A sample order PDF on disk at `order-request.pdf`. Any buyer's PO/email/order form will do — bem renders the file before extracting. Step 1: Create the order Extract function [#step-1-create-the-order-extract-function] The schema below is intentionally produce-shaped. The `lineItems[].description` field stays as free text — that's deliberate, because the Enrich step in Step 4 is going to do the SKU lookup against that exact text. Don't over-engineer the schema by trying to pre-normalize the description; let the catalog do that work. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "order-extractor", "type": "extract", "displayName": "Buyer Order Extractor", "tags": ["produce", "orders"], "outputSchemaName": "BuyerOrder", "outputSchema": { "type": "object", "required": ["orderNumber", "buyer", "lineItems"], "properties": { "orderNumber": { "type": "string", "description": "Buyer'"'"'s PO number, or a supplier-assigned ID if missing" }, "orderDate": { "type": "string", "description": "Date the order was placed (YYYY-MM-DD)" }, "requestedDeliveryDate": { "type": "string", "description": "Date the buyer wants delivery (YYYY-MM-DD)" }, "buyer": { "type": "object", "properties": { "name": { "type": "string", "description": "Buyer'"'"'s company name" }, "accountNumber": { "type": "string", "description": "Buyer'"'"'s account on the supplier'"'"'s books, if present" }, "contact": { "type": "string", "description": "Buyer'"'"'s contact email or phone, if present" } } }, "deliveryAddress": { "type": "string", "description": "Full delivery address as a single string" }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string", "description": "Line item as the buyer wrote it — e.g. '"'"'organic gala apples, 88 count tray'"'"'. Keep verbatim; downstream enrichment matches against this text." }, "quantity": { "type": "number", "description": "Number of units requested" }, "unit": { "type": "string", "description": "Unit of measure as written: case, lb, dozen, flat, clamshell, etc." }, "notes": { "type": "string", "description": "Buyer-supplied notes on this line — substitutions allowed, brand requests, etc." } } } }, "notes": { "type": "string", "description": "Order-level notes — '"'"'leave at dock 4, call on arrival'"'"', etc." } } } }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { function: fn } = await client.functions.create({ functionName: "order-extractor", type: "extract", displayName: "Buyer Order Extractor", tags: ["produce", "orders"], outputSchemaName: "BuyerOrder", outputSchema: { type: "object", required: ["orderNumber", "buyer", "lineItems"], properties: { orderNumber: { type: "string", description: "Buyer's PO number, or a supplier-assigned ID if missing" }, orderDate: { type: "string", description: "Date the order was placed (YYYY-MM-DD)" }, requestedDeliveryDate: { type: "string", description: "Date the buyer wants delivery (YYYY-MM-DD)" }, buyer: { type: "object", properties: { name: { type: "string", description: "Buyer's company name" }, accountNumber: { type: "string", description: "Buyer's account on the supplier's books, if present" }, contact: { type: "string", description: "Buyer's contact email or phone, if present" }, }, }, deliveryAddress: { type: "string", description: "Full delivery address as a single string" }, lineItems: { type: "array", items: { type: "object", properties: { description: { type: "string", description: "Line item as the buyer wrote it. Keep verbatim; downstream enrichment matches against this text." }, quantity: { type: "number", description: "Number of units requested" }, unit: { type: "string", description: "Unit of measure as written: case, lb, dozen, flat, clamshell, etc." }, notes: { type: "string", description: "Buyer-supplied notes on this line — substitutions allowed, brand requests, etc." }, }, }, }, notes: { type: "string", description: "Order-level notes — 'leave at dock 4, call on arrival', etc." }, }, }, }); console.log(fn); ``` ```python from bem import Bem client = Bem() response = client.functions.create( function_name="order-extractor", type="extract", display_name="Buyer Order Extractor", tags=["produce", "orders"], output_schema_name="BuyerOrder", output_schema={ "type": "object", "required": ["orderNumber", "buyer", "lineItems"], "properties": { "orderNumber": {"type": "string", "description": "Buyer's PO number, or a supplier-assigned ID if missing"}, "orderDate": {"type": "string", "description": "Date the order was placed (YYYY-MM-DD)"}, "requestedDeliveryDate": {"type": "string", "description": "Date the buyer wants delivery (YYYY-MM-DD)"}, "buyer": { "type": "object", "properties": { "name": {"type": "string", "description": "Buyer's company name"}, "accountNumber": {"type": "string", "description": "Buyer's account on the supplier's books, if present"}, "contact": {"type": "string", "description": "Buyer's contact email or phone, if present"}, }, }, "deliveryAddress": {"type": "string", "description": "Full delivery address as a single string"}, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": {"type": "string", "description": "Line item as the buyer wrote it. Keep verbatim; downstream enrichment matches against this text."}, "quantity": {"type": "number", "description": "Number of units requested"}, "unit": {"type": "string", "description": "Unit of measure as written: case, lb, dozen, flat, clamshell, etc."}, "notes": {"type": "string", "description": "Buyer-supplied notes on this line — substitutions allowed, brand requests, etc."}, }, }, }, "notes": {"type": "string", "description": "Order-level notes — 'leave at dock 4, call on arrival', etc."}, }, }, ) print(response.function) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() schema := map[string]any{ "type": "object", "required": []string{"orderNumber", "buyer", "lineItems"}, "properties": map[string]any{ "orderNumber": map[string]any{"type": "string", "description": "Buyer's PO number, or a supplier-assigned ID if missing"}, "orderDate": map[string]any{"type": "string", "description": "Date the order was placed (YYYY-MM-DD)"}, "requestedDeliveryDate": map[string]any{"type": "string", "description": "Date the buyer wants delivery (YYYY-MM-DD)"}, "buyer": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, "accountNumber": map[string]any{"type": "string"}, "contact": map[string]any{"type": "string"}, }, }, "deliveryAddress": map[string]any{"type": "string"}, "lineItems": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "properties": map[string]any{ "description": map[string]any{"type": "string", "description": "Keep verbatim; downstream enrichment matches against this text."}, "quantity": map[string]any{"type": "number"}, "unit": map[string]any{"type": "string"}, "notes": map[string]any{"type": "string"}, }, }, }, "notes": map[string]any{"type": "string"}, }, } resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfExtract: &bem.CreateFunctionExtractParam{ FunctionName: "order-extractor", DisplayName: bem.String("Buyer Order Extractor"), Tags: []string{"produce", "orders"}, OutputSchemaName: bem.String("BuyerOrder"), OutputSchema: schema, }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Function) } ``` ```csharp using System.Text.Json; using Bem; using Bem.Models.Functions; BemClient client = new(); var schemaJson = """ { "type": "object", "required": ["orderNumber", "buyer", "lineItems"], "properties": { "orderNumber": { "type": "string", "description": "Buyer's PO number, or a supplier-assigned ID if missing" }, "orderDate": { "type": "string", "description": "Date the order was placed (YYYY-MM-DD)" }, "requestedDeliveryDate": { "type": "string", "description": "Date the buyer wants delivery (YYYY-MM-DD)" }, "buyer": { "type": "object", "properties": { "name": { "type": "string" }, "accountNumber": { "type": "string" }, "contact": { "type": "string" } } }, "deliveryAddress": { "type": "string" }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string", "description": "Keep verbatim; downstream enrichment matches against this text." }, "quantity": { "type": "number" }, "unit": { "type": "string" }, "notes": { "type": "string" } } } }, "notes": { "type": "string" } } } """; var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Extract { FunctionName = "order-extractor", DisplayName = "Buyer Order Extractor", Tags = new List { "produce", "orders" }, OutputSchemaName = "BuyerOrder", OutputSchema = JsonSerializer.Deserialize(schemaJson), }, }); Console.WriteLine(response.Function); ``` ```bash bem functions create \ --function-name order-extractor \ --type extract \ --display-name "Buyer Order Extractor" \ --tag produce --tag orders \ --output-schema-name BuyerOrder \ --output-schema '{ "type": "object", "required": ["orderNumber", "buyer", "lineItems"], "properties": { "orderNumber": { "type": "string" }, "orderDate": { "type": "string" }, "requestedDeliveryDate": { "type": "string" }, "buyer": { "type": "object", "properties": { "name": { "type": "string" }, "accountNumber": { "type": "string" }, "contact": { "type": "string" } } }, "deliveryAddress": { "type": "string" }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unit": { "type": "string" }, "notes": { "type": "string" } } } }, "notes": { "type": "string" } } }' ``` Step 2: Create a product catalog Collection [#step-2-create-a-product-catalog-collection] A **Collection** is bem's hosted, semantic-search index. You give it items (each with a `data` payload — string or object), and bem handles embedding, storage, and retrieval. You don't deploy a vector store, you don't tune a chunker, and you don't think about embeddings; the platform owns that surface so the rest of your team can think about catalog data, not infrastructure. ```bash curl -X POST https://api.bem.ai/v3/collections \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{"collectionName": "produce_catalog"}' ``` ```typescript const collection = await client.collections.create({ collectionName: "produce_catalog", }); console.log(collection); ``` ```python collection = client.collections.create( collection_name="produce_catalog", ) print(collection) ``` ```go collection, err := client.Collections.New(context.TODO(), bem.CollectionNewParams{ CollectionName: "produce_catalog", }) ``` ```csharp using Bem.Models.Collections; var collection = await client.Collections.Create(new CollectionCreateParams { CollectionName = "produce_catalog", }); ``` ```bash bem collections create --collection-name produce_catalog ``` Step 3: Add SKUs to the collection [#step-3-add-skus-to-the-collection] This is your product catalog. In production you'll sync it from your ERP or PIM nightly; for the cookbook, six representative SKUs are enough to demonstrate the matching behavior. Notice that each item's `data` is a **structured object**, not just a string — when bem matches a description, it returns the whole object, so the SKU, pack size, category, and unit cost all flow through to your downstream system on the same hop. ```bash curl -X POST https://api.bem.ai/v3/collections/items \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "collectionName": "produce_catalog", "items": [ { "data": { "sku": "APL-GALA-ORG-CASE", "name": "Organic Gala Apples", "packSize": "88-count tray", "category": "Apples", "unitCost": 42.00 } }, { "data": { "sku": "AVO-HASS-CASE-48", "name": "Hass Avocados", "packSize": "48-count case", "category": "Avocados", "unitCost": 38.50 } }, { "data": { "sku": "SPN-BABY-CLAM-1LB", "name": "Baby Spinach", "packSize": "1 lb clamshell", "category": "Greens", "unitCost": 4.25 } }, { "data": { "sku": "LEM-EUR-CASE-95", "name": "European Lemons", "packSize": "95-count case", "category": "Citrus", "unitCost": 28.00 } }, { "data": { "sku": "LET-ROM-CASE-24", "name": "Romaine Hearts", "packSize": "24-count case", "category": "Greens", "unitCost": 22.50 } }, { "data": { "sku": "STR-CON-FLAT-8", "name": "Conventional Strawberries", "packSize": "8x1 lb flat", "category": "Berries", "unitCost": 18.00 } } ] }' ``` ```typescript const response = await client.collections.items.add({ collectionName: "produce_catalog", items: [ { data: { sku: "APL-GALA-ORG-CASE", name: "Organic Gala Apples", packSize: "88-count tray", category: "Apples", unitCost: 42.00 } }, { data: { sku: "AVO-HASS-CASE-48", name: "Hass Avocados", packSize: "48-count case", category: "Avocados", unitCost: 38.50 } }, { data: { sku: "SPN-BABY-CLAM-1LB", name: "Baby Spinach", packSize: "1 lb clamshell", category: "Greens", unitCost: 4.25 } }, { data: { sku: "LEM-EUR-CASE-95", name: "European Lemons", packSize: "95-count case", category: "Citrus", unitCost: 28.00 } }, { data: { sku: "LET-ROM-CASE-24", name: "Romaine Hearts", packSize: "24-count case", category: "Greens", unitCost: 22.50 } }, { data: { sku: "STR-CON-FLAT-8", name: "Conventional Strawberries", packSize: "8x1 lb flat", category: "Berries", unitCost: 18.00 } }, ], }); ``` ```python response = client.collections.items.add( collection_name="produce_catalog", items=[ {"data": {"sku": "APL-GALA-ORG-CASE", "name": "Organic Gala Apples", "packSize": "88-count tray", "category": "Apples", "unitCost": 42.00}}, {"data": {"sku": "AVO-HASS-CASE-48", "name": "Hass Avocados", "packSize": "48-count case", "category": "Avocados", "unitCost": 38.50}}, {"data": {"sku": "SPN-BABY-CLAM-1LB", "name": "Baby Spinach", "packSize": "1 lb clamshell", "category": "Greens", "unitCost": 4.25}}, {"data": {"sku": "LEM-EUR-CASE-95", "name": "European Lemons", "packSize": "95-count case", "category": "Citrus", "unitCost": 28.00}}, {"data": {"sku": "LET-ROM-CASE-24", "name": "Romaine Hearts", "packSize": "24-count case", "category": "Greens", "unitCost": 22.50}}, {"data": {"sku": "STR-CON-FLAT-8", "name": "Conventional Strawberries", "packSize": "8x1 lb flat", "category": "Berries", "unitCost": 18.00}}, ], ) ``` ```go response, err := client.Collections.Items.Add(context.TODO(), bem.CollectionItemAddParams{ CollectionName: "produce_catalog", Items: []bem.CollectionItemAddParamsItem{ {Data: map[string]any{"sku": "APL-GALA-ORG-CASE", "name": "Organic Gala Apples", "packSize": "88-count tray", "category": "Apples", "unitCost": 42.00}}, {Data: map[string]any{"sku": "AVO-HASS-CASE-48", "name": "Hass Avocados", "packSize": "48-count case", "category": "Avocados", "unitCost": 38.50}}, {Data: map[string]any{"sku": "SPN-BABY-CLAM-1LB", "name": "Baby Spinach", "packSize": "1 lb clamshell", "category": "Greens", "unitCost": 4.25}}, {Data: map[string]any{"sku": "LEM-EUR-CASE-95", "name": "European Lemons", "packSize": "95-count case", "category": "Citrus", "unitCost": 28.00}}, {Data: map[string]any{"sku": "LET-ROM-CASE-24", "name": "Romaine Hearts", "packSize": "24-count case", "category": "Greens", "unitCost": 22.50}}, {Data: map[string]any{"sku": "STR-CON-FLAT-8", "name": "Conventional Strawberries", "packSize": "8x1 lb flat", "category": "Berries", "unitCost": 18.00}}, }, }) ``` ```csharp using System.Text.Json; using Bem.Models.Collections.Items; var response = await client.Collections.Items.Add(new ItemAddParams { CollectionName = "produce_catalog", Items = new List { new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "APL-GALA-ORG-CASE", "name": "Organic Gala Apples", "packSize": "88-count tray", "category": "Apples", "unitCost": 42.00} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "AVO-HASS-CASE-48", "name": "Hass Avocados", "packSize": "48-count case", "category": "Avocados", "unitCost": 38.50} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "SPN-BABY-CLAM-1LB", "name": "Baby Spinach", "packSize": "1 lb clamshell", "category": "Greens", "unitCost": 4.25} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "LEM-EUR-CASE-95", "name": "European Lemons", "packSize": "95-count case", "category": "Citrus", "unitCost": 28.00} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "LET-ROM-CASE-24", "name": "Romaine Hearts", "packSize": "24-count case", "category": "Greens", "unitCost": 22.50} """))), new(new ItemAddParamsItemData(JsonSerializer.Deserialize(""" {"sku": "STR-CON-FLAT-8", "name": "Conventional Strawberries", "packSize": "8x1 lb flat", "category": "Berries", "unitCost": 18.00} """))), }, }); ``` ```bash bem collections:items add \ --collection-name produce_catalog \ --item '{data: {sku: APL-GALA-ORG-CASE, name: "Organic Gala Apples", packSize: "88-count tray", category: Apples, unitCost: 42.00}}' \ --item '{data: {sku: AVO-HASS-CASE-48, name: "Hass Avocados", packSize: "48-count case", category: Avocados, unitCost: 38.50}}' \ --item '{data: {sku: SPN-BABY-CLAM-1LB, name: "Baby Spinach", packSize: "1 lb clamshell", category: Greens, unitCost: 4.25}}' \ --item '{data: {sku: LEM-EUR-CASE-95, name: "European Lemons", packSize: "95-count case", category: Citrus, unitCost: 28.00}}' \ --item '{data: {sku: LET-ROM-CASE-24, name: "Romaine Hearts", packSize: "24-count case", category: Greens, unitCost: 22.50}}' \ --item '{data: {sku: STR-CON-FLAT-8, name: "Conventional Strawberries", packSize: "8x1 lb flat", category: Berries, unitCost: 18.00}}' ``` The response is an async `pending` ack — bem embeds the items in the background. For a six-item catalog that finishes in seconds; for a 50,000-SKU production catalog allow a few minutes on the first sync. Subsequent updates are incremental. Step 4: Create the SKU-matching Enrich function [#step-4-create-the-sku-matching-enrich-function] The Enrich function is configured by where to look (`sourceField`), what catalog to search (`collectionName`), and where to put the result (`targetField`). The expression `lineItems[*].description` says "for every line item, take its `description` and search the catalog with it" — so the function iterates the order automatically. When the source uses `[*]`, the target must use the same array notation: `lineItems[*].matchedProduct` writes the match back inline onto each line item, so each entry ends up carrying its own `matchedProduct` next to its `description`. `topK: 1` returns the single best match per line item. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "sku-resolver", "type": "enrich", "displayName": "Order Line Item → SKU Resolver", "tags": ["produce", "sku-lookup"], "config": { "steps": [ { "sourceField": "lineItems[*].description", "collectionName": "produce_catalog", "targetField": "lineItems[*].matchedProduct", "topK": 1, "searchMode": "semantic" } ] } }' ``` ```typescript const { function: fn } = await client.functions.create({ functionName: "sku-resolver", type: "enrich", displayName: "Order Line Item → SKU Resolver", tags: ["produce", "sku-lookup"], config: { steps: [ { sourceField: "lineItems[*].description", collectionName: "produce_catalog", targetField: "lineItems[*].matchedProduct", topK: 1, searchMode: "semantic", }, ], }, }); ``` ```python response = client.functions.create( function_name="sku-resolver", type="enrich", display_name="Order Line Item → SKU Resolver", tags=["produce", "sku-lookup"], config={ "steps": [ { "sourceField": "lineItems[*].description", "collectionName": "produce_catalog", "targetField": "lineItems[*].matchedProduct", "topK": 1, "searchMode": "semantic", } ], }, ) ``` ```go resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfEnrich: &bem.CreateFunctionEnrichParam{ FunctionName: "sku-resolver", DisplayName: bem.String("Order Line Item → SKU Resolver"), Tags: []string{"produce", "sku-lookup"}, Config: bem.EnrichConfigParam{ Steps: []bem.EnrichStepParam{ { SourceField: "lineItems[*].description", CollectionName: "produce_catalog", TargetField: "lineItems[*].matchedProduct", TopK: bem.Int(1), SearchMode: bem.EnrichStepSearchModeSemantic, }, }, }, }, }, }) ``` ```csharp var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Enrich { FunctionName = "sku-resolver", DisplayName = "Order Line Item → SKU Resolver", Tags = new List { "produce", "sku-lookup" }, Config = new EnrichConfig { Steps = new List { new EnrichStep { SourceField = "lineItems[*].description", CollectionName = "produce_catalog", TargetField = "lineItems[*].matchedProduct", TopK = 1, SearchMode = EnrichStepSearchMode.Semantic, }, }, }, }, }); ``` ```bash bem functions create \ --function-name sku-resolver \ --type enrich \ --display-name "Order Line Item → SKU Resolver" \ --tag produce --tag sku-lookup \ --config '{steps: [{sourceField: "lineItems[*].description", collectionName: produce_catalog, targetField: "lineItems[*].matchedProduct", topK: 1, searchMode: semantic}]}' ``` Step 5: Create the workflow [#step-5-create-the-workflow] Wire `order-extractor` and `sku-resolver` into a two-node DAG. The extractor is the entry point; the enricher reads its output and is the terminal node, so its `enrichedContent` is what callers receive. ```text Workflow: order-intake +-----------------------------------------------------------------+ | | | +-------------------+ +-----------------------+ | | | order-extractor | ---------> | sku-resolver | | | | (main) | | (enrich → catalog) | | | +-------------------+ +-----------------------+ | | | +-----------------------------------------------------------------+ ^ | Buyer's PO (PDF, email, scan) ``` ```bash curl -X POST https://api.bem.ai/v3/workflows \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "name": "order-intake", "displayName": "Buyer Order Intake", "tags": ["produce", "orders"], "mainNodeName": "order-extractor", "nodes": [ { "name": "order-extractor", "function": { "name": "order-extractor" } }, { "name": "sku-resolver", "function": { "name": "sku-resolver" } } ], "edges": [ { "sourceNodeName": "order-extractor", "destinationNodeName": "sku-resolver" } ] }' ``` ```typescript const { workflow } = await client.workflows.create({ name: "order-intake", displayName: "Buyer Order Intake", tags: ["produce", "orders"], mainNodeName: "order-extractor", nodes: [ { name: "order-extractor", function: { name: "order-extractor" } }, { name: "sku-resolver", function: { name: "sku-resolver" } }, ], edges: [ { sourceNodeName: "order-extractor", destinationNodeName: "sku-resolver" }, ], }); ``` ```python response = client.workflows.create( name="order-intake", display_name="Buyer Order Intake", tags=["produce", "orders"], main_node_name="order-extractor", nodes=[ {"name": "order-extractor", "function": {"name": "order-extractor"}}, {"name": "sku-resolver", "function": {"name": "sku-resolver"}}, ], edges=[ {"source_node_name": "order-extractor", "destination_node_name": "sku-resolver"}, ], ) ``` ```go resp, err := client.Workflows.New(context.TODO(), bem.WorkflowNewParams{ Name: "order-intake", DisplayName: bem.String("Buyer Order Intake"), Tags: []string{"produce", "orders"}, MainNodeName: "order-extractor", Nodes: []bem.WorkflowNewParamsNode{ {Name: bem.String("order-extractor"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("order-extractor")}}, {Name: bem.String("sku-resolver"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("sku-resolver")}}, }, Edges: []bem.WorkflowNewParamsEdge{ {SourceNodeName: "order-extractor", DestinationNodeName: "sku-resolver"}, }, }) ``` ```csharp using Bem.Models.Workflows; var response = await client.Workflows.Create(new WorkflowCreateParams { Name = "order-intake", DisplayName = "Buyer Order Intake", Tags = new List { "produce", "orders" }, MainNodeName = "order-extractor", Nodes = new List { new Node { Name = "order-extractor", Function = new FunctionVersionIdentifier { Name = "order-extractor" } }, new Node { Name = "sku-resolver", Function = new FunctionVersionIdentifier { Name = "sku-resolver" } }, }, Edges = new List { new Edge { SourceNodeName = "order-extractor", DestinationNodeName = "sku-resolver" }, }, }); ``` ```bash bem workflows create \ --name order-intake \ --display-name "Buyer Order Intake" \ --tag produce --tag orders \ --main-node-name order-extractor \ --node '{name: order-extractor, function: {name: order-extractor}}' \ --node '{name: sku-resolver, function: {name: sku-resolver}}' \ --edge '{sourceNodeName: order-extractor, destinationNodeName: sku-resolver}' ``` Step 6: Call the workflow with a buyer's order [#step-6-call-the-workflow-with-a-buyers-order] Send `order-request.pdf` through the workflow with `wait=true` to get the SKU-resolved order back synchronously. Multipart form data (recommended for files): ```bash curl -X POST "https://api.bem.ai/v3/workflows/order-intake/call" \ -H "x-api-key: $BEM_API_KEY" \ -F "wait=true" \ -F "callReferenceID=PO-78421" \ -F "file=@order-request.pdf" ``` Or, JSON body with base64-encoded file: ```bash curl -X POST "https://api.bem.ai/v3/workflows/order-intake/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "callReferenceID": "PO-78421", "input": { "singleFile": { "inputType": "pdf", "inputContent": "'"$(base64 -i order-request.pdf)"'" } } }' ``` ```typescript import fs from "node:fs"; const inputContent = fs.readFileSync("order-request.pdf").toString("base64"); const { call } = await client.workflows.call("order-intake", { wait: true, callReferenceID: "PO-78421", input: { singleFile: { inputType: "pdf", inputContent, }, }, }); console.log(call?.status, call?.outputs); ``` ```python import base64 with open("order-request.pdf", "rb") as f: input_content = base64.b64encode(f.read()).decode() response = client.workflows.call( "order-intake", wait=True, call_reference_id="PO-78421", input={ "single_file": { "input_type": "pdf", "input_content": input_content, } }, ) print(response.call.status, response.call.outputs) ``` ```go data, err := os.ReadFile("order-request.pdf") if err != nil { panic(err) } encoded := base64.StdEncoding.EncodeToString(data) resp, err := client.Workflows.Call(context.TODO(), "order-intake", bem.WorkflowCallParams{ Wait: bem.Bool(true), CallReferenceID: bem.String("PO-78421"), Input: bem.WorkflowCallParamsInput{ SingleFile: &bem.WorkflowCallParamsInputSingleFile{ InputType: "pdf", InputContent: encoded, }, }, }) if err != nil { panic(err) } fmt.Printf("status=%s outputs=%d\n", resp.Call.Status, len(resp.Call.Outputs)) ``` ```csharp var bytes = File.ReadAllBytes("order-request.pdf"); var encoded = Convert.ToBase64String(bytes); var response = await client.Workflows.Call("order-intake", new WorkflowCallParams { Wait = true, CallReferenceID = "PO-78421", Input = new Input { SingleFile = new FileInput { InputType = InputType.Pdf, InputContent = encoded, }, }, }); Console.WriteLine(response.Call.Status); ``` ```bash bem workflows call \ --workflow-name order-intake \ --wait \ --call-reference-id PO-78421 \ --input.single-file '{"inputContent": "@order-request.pdf", "inputType": "pdf"}' ``` **Response:** ```json { "call": { "callID": "wc_5gh789ijk", "callReferenceID": "PO-78421", "status": "completed", "workflowName": "order-intake", "workflowVersionNum": 1, "outputs": [ { "eventID": "ev_…", "eventType": "enrich", "functionName": "sku-resolver", "enrichedContent": { "orderNumber": "78421", "orderDate": "2026-04-28", "requestedDeliveryDate": "2026-04-30", "buyer": { "name": "Bayside Co-op", "accountNumber": "BAYS-241", "contact": "ordering@baysidecoop.com" }, "deliveryAddress": "1100 Marina Way, Oakland, CA 94607", "lineItems": [ { "description": "organic gala apples, 88 ct", "quantity": 10, "unit": "case", "matchedProduct": { "data": { "sku": "APL-GALA-ORG-CASE", "name": "Organic Gala Apples", "packSize": "88-count tray", "category": "Apples", "unitCost": 42.0 }, "cosineDistance": 0.0612 } }, { "description": "Hass avocados, 48s", "quantity": 6, "unit": "case", "matchedProduct": { "data": { "sku": "AVO-HASS-CASE-48", "name": "Hass Avocados", "packSize": "48-count case", "category": "Avocados", "unitCost": 38.5 }, "cosineDistance": 0.0784 } }, { "description": "baby spinach", "quantity": 12, "unit": "lb", "matchedProduct": { "data": { "sku": "SPN-BABY-CLAM-1LB", "name": "Baby Spinach", "packSize": "1 lb clamshell", "category": "Greens", "unitCost": 4.25 }, "cosineDistance": 0.0931 } }, { "description": "European lemons, 95 ct", "quantity": 4, "unit": "case", "matchedProduct": { "data": { "sku": "LEM-EUR-CASE-95", "name": "European Lemons", "packSize": "95-count case", "category": "Citrus", "unitCost": 28.0 }, "cosineDistance": 0.0593 } }, { "description": "romaine hearts", "quantity": 8, "unit": "case", "matchedProduct": { "data": { "sku": "LET-ROM-CASE-24", "name": "Romaine Hearts", "packSize": "24-count case", "category": "Greens", "unitCost": 22.5 }, "cosineDistance": 0.1207 } }, { "description": "strawberries, 8x1 lb flat", "quantity": 6, "unit": "flat", "matchedProduct": { "data": { "sku": "STR-CON-FLAT-8", "name": "Conventional Strawberries", "packSize": "8x1 lb flat", "category": "Berries", "unitCost": 18.0 }, "cosineDistance": 0.0455 } } ] } } ], "errors": [], "url": "/v3/calls/wc_5gh789ijk", "traceUrl": "/v3/calls/wc_5gh789ijk/trace" } } ``` Each line item now carries its own `matchedProduct` inline — no zipping required. `lineItems[i].matchedProduct.data` is the full object you stored on the catalog item, and `lineItems[i].matchedProduct.cosineDistance` is a soft confidence score (smaller = closer match). For tighter quality control, raise an exception flag below a threshold (e.g. anything ≥ `0.20`) and route those orders to a human reviewer. Because the terminal node here is an Enrich function, the SKU-resolved order lives at `call.outputs[0].enrichedContent` (not `transformedContent` — that's the field name for Extract/Transform/Join terminals). For the per-event-type field map and accessor patterns in every SDK, see [Reading workflow call outputs](/guide/reading-workflow-call-outputs). What just happened [#what-just-happened] It's worth slowing down on the moving parts here, because the same shape works for every variation of "natural-language order intake → SKU resolution" that suppliers in produce, building materials, foodservice, pharma distribution, and parts run into. * **The Extract function did not need to be told what an order *is*.** Every field's `description` tells the LLM what to look for. The schema acts as both contract and prompt — you don't maintain a separate prompt file. * **The Enrich function did not chunk, embed, or index anything itself.** When you added items to `produce_catalog`, bem embedded each `data` payload server-side with a managed embedding model. The function just submits the line item description and gets the top match back. * **The DAG ran in the right order automatically.** `extract → enrich` because the edge says so. Workflow versions are immutable, so adding a third step later (say, a `payload_shaping` node that reformats the result for your ERP) creates a v2 without disrupting any caller still on v1. * **The same `wait=true` path that returned this result is also what you'd subscribe to with a webhook** — so the dev-loop call and the production hand-off use the same shape. Wiring this into your ERP or WMS [#wiring-this-into-your-erp-or-wms] The terminal `enrichedContent` is the payload you send downstream. Two common patterns: * **Webhook subscription.** Subscribe a URL in your service (NetSuite middleware, SAP integration layer, a custom Lambda) to the workflow's terminal events. bem POSTs the same JSON shape you saw above the moment a call finishes, signed with HMAC-SHA256 so you can verify it. See [Webhooks](/guide/webhooks). * **Add a [Send](/guide/function-types/send) node** to push results directly to a webhook URL, S3 bucket, or Google Drive folder — useful when the receiving system is fine with file-drop semantics (S3 → SFTP-out → WMS, for example). For ERPs specifically, the typical line-of-business shape is: | Field needed by the ERP | Where it comes from | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Buyer account ID | `enrichedContent.buyer.accountNumber` (Extract) | | Requested ship date | `enrichedContent.requestedDeliveryDate` (Extract) | | `(SKU, quantity, unit cost)` per line | `lineItems[i].quantity` with `lineItems[i].matchedProduct.data.sku` and `lineItems[i].matchedProduct.data.unitCost` | | Confidence flag | `lineItems[i].matchedProduct.cosineDistance` (route to human review if above threshold) | If your ERP needs a particular wire format (cXML, EDI 850, a custom JSON shape), drop a [Payload Shaping](/guide/function-types/payload-shaping) node at the end of the workflow and reshape with JMESPath. No code, no separate ETL job. Next steps [#next-steps] Full reference: search modes, multi-step enrichment, multiple collections per function Schema design, tabular chunking, visual vs text-first inputs Subscribe an endpoint and verify signed deliveries Designing `outputSchema` for reliable extraction # Overview (/guide/cookbooks/overview) > For the complete documentation index, see [llms.txt](/llms.txt). Cookbooks are self-contained, working walkthroughs of patterns that span more than one primitive. Each one is meant to be runnable in a single sitting against your own bem account. Parse a contract into a navigable structure of sections, parties, dollar amounts, and statutes — then drive an LLM-agent review loop with the File System API Build a Classify → Extract pipeline that fans inbound invoices, bills of lading, and packing slips out to type-specific schemas Extract → Enrich pipeline for produce or wholesale suppliers — turn natural-language POs into ERP/WMS-ready SKUs in one workflow call # Parse and Search over Contracts (/guide/cookbooks/parse-and-search-over-contracts) > For the complete documentation index, see [llms.txt](/llms.txt). This cookbook walks the [Parse](/guide/function-types/parse) primitive end to end against a contract. By the end you will have: 1. A Parse function with entity extraction and cross-document memory enabled 2. A workflow that calls that Parse function 3. Your first parsed contract — page-aware sections (DUTIES, COMPENSATION, INDEMNIFICATION, INSURANCE, …), entities (parties, dollar amounts, jurisdictions, named individuals), and the relationships between them 4. A working set of [File System](/api/v3/file-system) ops (`ls`, `cat`, `grep`, `find`, `xref`) you can wire straight into an agent's tool surface for contract review The example uses a single Professional Services Agreement template at `contract.pdf`. The same shape works for MSAs, NDAs, vendor agreements, employment contracts — any structured legal document. Once several contracts are parsed in the same environment, the cross-document ops collapse the same party, statute, or coverage type into one canonical record across all of them. Pick a language from the tabs in each step — the flow is identical across cURL, the SDKs, and the CLI. If you don't have an SDK installed yet, see [Step 2 of the Quickstart](/guide/quickstart#step-2-install-the-sdk). Prerequisites [#prerequisites] * A [bem account](https://app.bem.ai) and an API key from **Settings → API Keys** * `BEM_API_KEY` exported in your shell: ```bash export BEM_API_KEY='your-api-key-here' ``` * A contract PDF on disk at `contract.pdf`. The walkthrough's example responses are based on a 10-page Professional Services Agreement; substitute any contract you have. Step 1: Create a Parse function [#step-1-create-a-parse-function] A Parse function has no `outputSchema` — it's configured by what you want extracted *about* each contract, not by the fields you need. The two `parseConfig` toggles default to `true` and we want both on: * `extractEntities=true` makes `entities[]` and `relationships[]` show up alongside `sections[]` in the parse output (parties, signers, dollar amounts, statutes, …). * `linkAcrossDocuments=true` runs a cross-document resolver after each parse, so the same party or statute resolves to one canonical record across every contract you parse. This is what unlocks the memory-level File System ops (`find`, `open`, `xref`). ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "contract-parser", "type": "parse", "displayName": "Contract Parser", "tags": ["contracts", "legal"], "parseConfig": { "extractEntities": true, "linkAcrossDocuments": true } }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { function: fn } = await client.functions.create({ functionName: "contract-parser", type: "parse", displayName: "Contract Parser", tags: ["contracts", "legal"], parseConfig: { extractEntities: true, linkAcrossDocuments: true, }, }); console.log(fn); ``` ```python from bem import Bem client = Bem() response = client.functions.create( function_name="contract-parser", type="parse", display_name="Contract Parser", tags=["contracts", "legal"], parse_config={ "extract_entities": True, "link_across_documents": True, }, ) print(response.function) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfParse: &bem.CreateFunctionParseParam{ FunctionName: "contract-parser", DisplayName: bem.String("Contract Parser"), Tags: []string{"contracts", "legal"}, ParseConfig: bem.CreateFunctionParseParseConfigParam{ ExtractEntities: bem.Bool(true), LinkAcrossDocuments: bem.Bool(true), }, }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Function) } ``` ```csharp using Bem; using Bem.Models.Functions; BemClient client = new(); var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Parse { FunctionName = "contract-parser", DisplayName = "Contract Parser", Tags = new List { "contracts", "legal" }, ParseConfig = new ParseConfig { ExtractEntities = true, LinkAcrossDocuments = true, }, }, }); Console.WriteLine(response.Function); ``` ```bash bem functions create \ --function-name contract-parser \ --type parse \ --display-name "Contract Parser" \ --tags '["contracts", "legal"]' \ --parse-config.extract-entities \ --parse-config.link-across-documents ``` **Response:** ```json { "function": { "functionID": "fn_2abc123", "functionName": "contract-parser", "type": "parse", "displayName": "Contract Parser", "tags": ["contracts", "legal"], "versionNum": 1, "parseConfig": { "extractEntities": true, "linkAcrossDocuments": true } } } ``` Step 2: Create a workflow [#step-2-create-a-workflow] A workflow gives the Parse function a callable entry point. For a single-step parse pipeline you only need one node and no edges. ```bash curl -X POST https://api.bem.ai/v3/workflows \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "name": "contract-parse", "displayName": "Contract Parse", "tags": ["contracts", "legal"], "mainNodeName": "contract-parser", "nodes": [ { "name": "contract-parser", "function": { "name": "contract-parser" } } ] }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { workflow } = await client.workflows.create({ name: "contract-parse", displayName: "Contract Parse", tags: ["contracts", "legal"], mainNodeName: "contract-parser", nodes: [ { name: "contract-parser", function: { name: "contract-parser" }, }, ], }); console.log(workflow); ``` ```python from bem import Bem client = Bem() response = client.workflows.create( name="contract-parse", display_name="Contract Parse", tags=["contracts", "legal"], main_node_name="contract-parser", nodes=[ { "name": "contract-parser", "function": {"name": "contract-parser"}, } ], ) print(response.workflow) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() resp, err := client.Workflows.New(context.TODO(), bem.WorkflowNewParams{ Name: "contract-parse", DisplayName: bem.String("Contract Parse"), Tags: []string{"contracts", "legal"}, MainNodeName: "contract-parser", Nodes: []bem.WorkflowNewParamsNode{ { Name: bem.String("contract-parser"), Function: bem.FunctionVersionIdentifierParam{ Name: bem.String("contract-parser"), }, }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Workflow) } ``` ```csharp using Bem; using Bem.Models.Workflows; BemClient client = new(); var response = await client.Workflows.Create(new WorkflowCreateParams { Name = "contract-parse", DisplayName = "Contract Parse", Tags = new List { "contracts", "legal" }, MainNodeName = "contract-parser", Nodes = new List { new Node { Function = new FunctionVersionIdentifier { Name = "contract-parser" }, Name = "contract-parser", }, }, }); Console.WriteLine(response.Workflow); ``` ```bash bem workflows create \ --name contract-parse \ --display-name "Contract Parse" \ --tags '["contracts", "legal"]' \ --main-node-name contract-parser \ --node '{name: contract-parser, function: {name: contract-parser}}' ``` Step 3: Call the workflow with your contract [#step-3-call-the-workflow-with-your-contract] Send `contract.pdf` through the workflow. We pass `wait=true` to block for up to 30 seconds — Parse on a typical contract finishes well inside that window. The `callReferenceID` is the handle we'll address the parsed doc by from `/v3/fs` later. Upload as multipart form data (recommended for files): ```bash curl -X POST "https://api.bem.ai/v3/workflows/contract-parse/call" \ -H "x-api-key: $BEM_API_KEY" \ -F "wait=true" \ -F "callReferenceID=sample-contract" \ -F "file=@contract.pdf" ``` Or, JSON body with base64-encoded file: ```bash curl -X POST "https://api.bem.ai/v3/workflows/contract-parse/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "callReferenceID": "sample-contract", "input": { "singleFile": { "inputType": "pdf", "inputContent": "'"$(base64 -i contract.pdf)"'" } } }' ``` ```typescript import fs from "node:fs"; import Bem from "bem-ai-sdk"; const client = new Bem(); const inputContent = fs.readFileSync("contract.pdf").toString("base64"); const { call } = await client.workflows.call("contract-parse", { wait: true, callReferenceID: "sample-contract", input: { singleFile: { inputType: "pdf", inputContent, }, }, }); console.log(call?.status); ``` ```python import base64 from bem import Bem client = Bem() with open("contract.pdf", "rb") as f: input_content = base64.b64encode(f.read()).decode() response = client.workflows.call( "contract-parse", wait=True, call_reference_id="sample-contract", input={ "single_file": { "input_type": "pdf", "input_content": input_content, } }, ) print(response.call.status) ``` ```go package main import ( "context" "encoding/base64" "fmt" "os" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() data, err := os.ReadFile("contract.pdf") if err != nil { panic(err) } encoded := base64.StdEncoding.EncodeToString(data) resp, err := client.Workflows.Call(context.TODO(), "contract-parse", bem.WorkflowCallParams{ Wait: bem.Bool(true), CallReferenceID: bem.String("sample-contract"), Input: bem.WorkflowCallParamsInput{ SingleFile: &bem.WorkflowCallParamsInputSingleFile{ InputType: "pdf", InputContent: encoded, }, }, }) if err != nil { panic(err) } fmt.Printf("status=%s\n", resp.Call.Status) } ``` ```csharp using Bem; using Bem.Models.Workflows; BemClient client = new(); var bytes = File.ReadAllBytes("contract.pdf"); var encoded = Convert.ToBase64String(bytes); var response = await client.Workflows.Call("contract-parse", new WorkflowCallParams { Wait = true, CallReferenceID = "sample-contract", Input = new Input { SingleFile = new FileInput { InputType = InputType.Pdf, InputContent = encoded, }, }, }); Console.WriteLine(response.Call.Status); ``` ```bash bem workflows call \ --workflow-name contract-parse \ --wait \ --call-reference-id sample-contract \ --input.single-file '{"inputContent": "@contract.pdf", "inputType": "pdf"}' ``` The `@contract.pdf` syntax tells the CLI to read and base64-encode the file inline. A few notes: * `wait=true` blocks for up to 30 seconds. Larger or scan-heavy contracts may run longer and return a `pending` call you can poll with [`GET /v3/calls/{callID}`](/api/v3/calls/v3-get-call) or subscribe to via webhook. * The cross-document resolver runs **after** each parse event is emitted, so the entity graph is briefly eventually-consistent — a few seconds — before `find` / `xref` see new entities. If you're scripting against `/v3/fs` immediately after a parse, build in a short delay. Step 4: List parsed contracts [#step-4-list-parsed-contracts] `ls` returns one row per parsed document with the metadata an agent needs to navigate. ```bash curl -X POST https://api.bem.ai/v3/fs \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "op": "ls", "limit": 25 }' ``` ```typescript const { data } = await client.fs.navigate({ op: "ls", limit: 25 }); console.log(data); ``` ```python response = client.fs.navigate(op="ls", limit=25) print(response.data) ``` ```go resp, err := client.Fs.Navigate(context.TODO(), bem.FNavigateParams{ Op: bem.FNavigateParamsOpLs, Limit: bem.Int(25), }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Data) ``` ```csharp using Bem.Models.Fs; var response = await client.Fs.Navigate(new FNavigateParams { Op = "ls", Limit = 25, }); Console.WriteLine(response.Data); ``` ```bash bem fs navigate --op ls --limit 25 ``` **Response:** ```json { "op": "ls", "data": [ { "referenceID": "sample-contract", "transformationID": "tr_…", "functionName": "contract-parser", "parsedAt": "2026-04-28T16:00:00Z", "pageCount": 10, "sectionCount": 47, "entityCount": 31, "previewEntities": [ "Santa Cruz County Regional Transportation Commission", "CONSULTANT", "Yesenia Parra", "Luis Mendez", "California Labor Code", "$1,000,000" ] } ], "hasMore": false } ``` `previewEntities` is up to \~6 canonical names sampled from the document — enough for a sanity check that parsing landed on the right things. Step 5: Map a contract's structure cheaply [#step-5-map-a-contracts-structure-cheaply] Before reading content, let an agent map structure. `cat` with `select` projects the parse output to specific dotted paths — labels, types, pages — so the agent can scan the doc's skeleton for \~1 KB instead of pulling the full \~150 KB content. ```bash curl -X POST https://api.bem.ai/v3/fs \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "op": "cat", "path": "sample-contract", "select": ["sections.label", "sections.type", "sections.page"] }' ``` ```typescript const { data } = await client.fs.navigate({ op: "cat", path: "sample-contract", select: ["sections.label", "sections.type", "sections.page"], }); console.log(data); ``` ```python response = client.fs.navigate( op="cat", path="sample-contract", select=["sections.label", "sections.type", "sections.page"], ) print(response.data) ``` ```go resp, err := client.Fs.Navigate(context.TODO(), bem.FNavigateParams{ Op: bem.FNavigateParamsOpCat, Path: bem.String("sample-contract"), Select: []string{"sections.label", "sections.type", "sections.page"}, }) ``` ```csharp var response = await client.Fs.Navigate(new FNavigateParams { Op = "cat", Path = "sample-contract", Select = new List { "sections.label", "sections.type", "sections.page" }, }); ``` ```bash bem fs navigate \ --op cat \ --path sample-contract \ --select '["sections.label", "sections.type", "sections.page"]' ``` The agent gets back the contract's clause map — `"DUTIES"`, `"COMPENSATION"`, `"TERM"`, `"EARLY TERMINATION"`, `"INDEMNIFICATION FOR DAMAGES, TAXES AND CONTRIBUTIONS"`, `"INSURANCE"`, `"FEDERAL, STATE AND LOCAL LAWS"`, … — with their types (`heading`, `paragraph`, `list`) and page numbers. From there it decides which sections to read in full. To read one page in full: ```bash curl -X POST https://api.bem.ai/v3/fs \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "op": "cat", "path": "sample-contract", "range": { "page": 4 } }' ``` ```typescript const { data } = await client.fs.navigate({ op: "cat", path: "sample-contract", range: { page: 4 }, }); ``` ```python response = client.fs.navigate( op="cat", path="sample-contract", range={"page": 4}, ) ``` ```go resp, err := client.Fs.Navigate(context.TODO(), bem.FNavigateParams{ Op: bem.FNavigateParamsOpCat, Path: bem.String("sample-contract"), Range: bem.FNavigateParamsRange{Page: bem.Int(4)}, }) ``` ```csharp var response = await client.Fs.Navigate(new FNavigateParams { Op = "cat", Path = "sample-contract", Range = new Range { Page = 4 }, }); ``` ```bash bem fs navigate --op cat --path sample-contract --range '{"page": 4}' ``` Step 6: Search across your contracts [#step-6-search-across-your-contracts] `grep` runs substring or regex search across every parsed document's output. `scope` narrows it to one part of the parse output (`sections`, `entities`, `relationships`, or `all`); `path` scopes to a single document; `countOnly` returns just the hit count. Search for everywhere indemnification is discussed: ```bash curl -X POST https://api.bem.ai/v3/fs \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "op": "grep", "pattern": "indemnif", "scope": "sections", "limit": 10 }' ``` ```typescript const { data } = await client.fs.navigate({ op: "grep", pattern: "indemnif", scope: "sections", limit: 10, }); ``` ```python response = client.fs.navigate( op="grep", pattern="indemnif", scope="sections", limit=10, ) ``` ```go resp, err := client.Fs.Navigate(context.TODO(), bem.FNavigateParams{ Op: bem.FNavigateParamsOpGrep, Pattern: bem.String("indemnif"), Scope: bem.String("sections"), Limit: bem.Int(10), }) ``` ```csharp var response = await client.Fs.Navigate(new FNavigateParams { Op = "grep", Pattern = "indemnif", Scope = "sections", Limit = 10, }); ``` ```bash bem fs navigate \ --op grep \ --pattern indemnif \ --scope sections \ --limit 10 ``` Returns hits with `referenceID`, `page`, `sectionLabel`, and a snippet around each match — section 5 (`INDEMNIFICATION FOR DAMAGES, TAXES AND CONTRIBUTIONS`) on page 3, plus several insurance subclauses on page 4 that touch indemnity. For a cheap "is it worth reading?" check, use `countOnly`: ```bash curl -X POST https://api.bem.ai/v3/fs \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "op": "grep", "pattern": "Workers. Compensation", "regex": true, "countOnly": true }' ``` ```typescript const { count } = await client.fs.navigate({ op: "grep", pattern: "Workers. Compensation", regex: true, countOnly: true, }); ``` ```python response = client.fs.navigate( op="grep", pattern="Workers. Compensation", regex=True, count_only=True, ) ``` ```go resp, err := client.Fs.Navigate(context.TODO(), bem.FNavigateParams{ Op: bem.FNavigateParamsOpGrep, Pattern: bem.String("Workers. Compensation"), Regex: bem.Bool(true), CountOnly: bem.Bool(true), }) ``` ```csharp var response = await client.Fs.Navigate(new FNavigateParams { Op = "grep", Pattern = "Workers. Compensation", Regex = true, CountOnly = true, }); ``` ```bash bem fs navigate --op grep --pattern "Workers. Compensation" --regex --count-only ``` Returns `{ "count": 3 }` with no snippet payload — the agent decides whether to keep digging. Step 7: List entities in cross-document memory [#step-7-list-entities-in-cross-document-memory] `find` is the entry point to the cross-document entity graph populated by `linkAcrossDocuments=true`. Filter by entity type (`"organization"`, `"person"`, `"monetary_amount"`, `"legal_reference"`, …) or by a substring search on canonical names. List every organization mentioned across your contracts: ```bash curl -X POST https://api.bem.ai/v3/fs \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "op": "find", "filter": { "type": "organization" }, "limit": 20 }' ``` ```typescript const { data } = await client.fs.navigate({ op: "find", filter: { type: "organization" }, limit: 20, }); ``` ```python response = client.fs.navigate( op="find", filter={"type": "organization"}, limit=20, ) ``` ```go resp, err := client.Fs.Navigate(context.TODO(), bem.FNavigateParams{ Op: bem.FNavigateParamsOpFind, Filter: bem.FNavigateParamsFilter{Type: bem.String("organization")}, Limit: bem.Int(20), }) ``` ```csharp var response = await client.Fs.Navigate(new FNavigateParams { Op = "find", Filter = new Filter { Type = "organization" }, Limit = 20, }); ``` ```bash bem fs navigate \ --op find \ --filter.type organization \ --limit 20 ``` Returns one row per canonical entity with `entityID`, `canonical`, `type`, `mentionCount`, `surfaceForms`, and the document where it was first seen: ```json { "op": "find", "data": [ { "entityID": "ent_4xQ…", "canonical": "Santa Cruz County Regional Transportation Commission", "type": "organization", "mentionCount": 38, "surfaceForms": [ "SCCRTC", "COMMISSION", "Santa Cruz County Regional Transportation Commission" ], "firstSeenReferenceID": "sample-contract" } ], "hasMore": false } ``` Notice the surface-form collapse: `SCCRTC`, `COMMISSION`, and the long form all resolved to one canonical record. With multiple contracts in the same environment, this same entity would carry mentions from all of them. If your environment hasn't yet been populated with `linkAcrossDocuments=true` parses, `find` returns an empty list with a `hint` field pointing at the toggle. Step 8: Resolve an entity to every section that mentions it [#step-8-resolve-an-entity-to-every-section-that-mentions-it] `xref` is the killer "show me everywhere this entity is discussed, with full context" loop. Pass an `entityID` (from `find`); get back one row per mention with the section's full content. ```bash curl -X POST https://api.bem.ai/v3/fs \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "op": "xref", "path": "ent_4xQ…", "limit": 50 }' ``` ```typescript const { data } = await client.fs.navigate({ op: "xref", path: "ent_4xQ…", limit: 50, }); ``` ```python response = client.fs.navigate( op="xref", path="ent_4xQ…", limit=50, ) ``` ```go resp, err := client.Fs.Navigate(context.TODO(), bem.FNavigateParams{ Op: bem.FNavigateParamsOpXref, Path: bem.String("ent_4xQ…"), Limit: bem.Int(50), }) ``` ```csharp var response = await client.Fs.Navigate(new FNavigateParams { Op = "xref", Path = "ent_4xQ…", Limit = 50, }); ``` ```bash bem fs navigate --op xref --path ent_4xQ… --limit 50 ``` Each row carries `referenceID`, `page`, `sectionLabel`, `sectionType`, the surface form that matched (`"COMMISSION"`, `"SCCRTC"`, `"Santa Cruz County Regional Transportation Commission"`), and the full `sectionContent`. One call replaces the loop of "find docs mentioning X, then `cat` each one to read the surrounding paragraph." Putting it together: an agent loop [#putting-it-together-an-agent-loop] Here's a concrete contract-review loop in pseudocode. The agent does the comprehension; `/v3/fs` gives it eyes. ``` [user] What insurance minimums does this contract require? [agent → tool] {op:"grep", pattern:"insurance", scope:"sections", countOnly:true} [tool → agent] {count: 23} [agent → tool] {op:"grep", pattern:"\\$[0-9,]+", regex:true, scope:"sections", limit:10} [tool → agent] 10 hits — most cluster on page 4 ("$1,000,000 combined single limit"); page 1 has the placeholder "$_____ for time and materials" in section 2.A. [agent → tool] {op:"cat", path:"sample-contract", range:{page:4}} [tool → agent] Full text of page 4 — section 6.A enumerates four insurance types with their minimum limits. [agent → user] "The contract requires four insurance coverages from CONSULTANT: Workers' Compensation (statutory minimum), Automobile Liability ($1,000,000 combined single limit), Comprehensive General Liability ($1,000,000 CSL — bodily injury, personal injury, broad form property damage, contractual liability, cross-liability), and Professional Liability ($1,000,000 CSL, only when both parties initial subparagraph 6.A.4). Source: Section 6.A on page 4." ``` Three tool roundtrips, no embeddings, no chunker tuning, no top-K window. With multiple contracts parsed under the same environment, swapping `path:"sample-contract"` for an `xref` against an entity like *Santa Cruz County Regional Transportation Commission* lights up every section across every contract that names that party — the same answer pattern, just broadened. Pagination [#pagination] `ls` and `find` paginate by cursor. Pass the previous response's `nextCursor` back as `cursor` to fetch the next page; `hasMore: false` means you've hit the end. Same idiom as [`/v3/calls`](/api/v3/calls/v3-list-calls) and [`/v3/outputs`](/api/v3/outputs/v3-list-outputs). Next steps [#next-steps] The full reference for the Parse primitive — toggles, output structure, when to use `POST /v3/fs` — every op, every flag, every parameter Wrap `/v3/fs` for Claude, ChatGPT, and other MCP-aware agents Chain a Parse function with downstream nodes for richer pipelines # Triage and Extract Logistics Documents (/guide/cookbooks/triage-and-extract-logistics-documents) > For the complete documentation index, see [llms.txt](/llms.txt). This cookbook builds an end-to-end logistics-document triage pipeline. By the end you will have: 1. Three [Extract](/guide/function-types/extract) functions — one each for invoices, bills of lading, and packing slips — with sensible schemas that capture the canonical fields of each document type 2. A [Classify](/guide/function-types/classify) function that decides which document type an inbound file is 3. A workflow that wires them into a branching DAG: one entry point, three terminal outputs 4. A working call that demonstrates the routing in action The example uses logistics — invoices, bills of lading (BOLs), packing slips — but the same shape (classify → extract per type) works for any inbound document mix: claims vs benefit summaries vs prescriptions, contracts vs SOWs vs purchase orders, and so on. Pick a language from the tabs in each step — the flow is identical across cURL, the SDKs, and the CLI. If you don't have an SDK installed yet, see [Step 2 of the Quickstart](/guide/quickstart#step-2-install-the-sdk). Prerequisites [#prerequisites] * A [bem account](https://app.bem.ai) and an API key from **Settings → API Keys** * `BEM_API_KEY` exported in your shell: ```bash export BEM_API_KEY='your-api-key-here' ``` * A few sample logistics PDFs on disk to call the workflow against: `invoice.pdf`, `bill-of-lading.pdf`, `packing-slip.pdf`. Any PDF or image of each type works — bem renders the file before classifying. Step 1: Create the invoice Extract function [#step-1-create-the-invoice-extract-function] Each document type gets its own Extract function so the schema reflects what's actually in that kind of document. Start with the invoice extractor. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "invoice-extractor", "type": "extract", "displayName": "Invoice Extractor", "tags": ["logistics", "invoices"], "outputSchemaName": "Invoice", "outputSchema": { "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": { "type": "string", "description": "Unique invoice identifier" }, "invoiceDate": { "type": "string", "description": "Invoice date (YYYY-MM-DD)" }, "dueDate": { "type": "string", "description": "Payment due date (YYYY-MM-DD)" }, "vendor": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number" }, "amount": { "type": "number" } } } }, "subtotal": { "type": "number" }, "totalAmount": { "type": "number" } } } }' ``` ```typescript import Bem from "bem-ai-sdk"; const client = new Bem(); const { function: fn } = await client.functions.create({ functionName: "invoice-extractor", type: "extract", displayName: "Invoice Extractor", tags: ["logistics", "invoices"], outputSchemaName: "Invoice", outputSchema: { type: "object", required: ["invoiceNumber", "vendor", "totalAmount"], properties: { invoiceNumber: { type: "string", description: "Unique invoice identifier" }, invoiceDate: { type: "string", description: "Invoice date (YYYY-MM-DD)" }, dueDate: { type: "string", description: "Payment due date (YYYY-MM-DD)" }, vendor: { type: "object", properties: { name: { type: "string" }, address: { type: "string" }, }, }, lineItems: { type: "array", items: { type: "object", properties: { description: { type: "string" }, quantity: { type: "number" }, unitPrice: { type: "number" }, amount: { type: "number" }, }, }, }, subtotal: { type: "number" }, totalAmount: { type: "number" }, }, }, }); console.log(fn); ``` ```python from bem import Bem client = Bem() response = client.functions.create( function_name="invoice-extractor", type="extract", display_name="Invoice Extractor", tags=["logistics", "invoices"], output_schema_name="Invoice", output_schema={ "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": {"type": "string", "description": "Unique invoice identifier"}, "invoiceDate": {"type": "string", "description": "Invoice date (YYYY-MM-DD)"}, "dueDate": {"type": "string", "description": "Payment due date (YYYY-MM-DD)"}, "vendor": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, }, }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": {"type": "string"}, "quantity": {"type": "number"}, "unitPrice": {"type": "number"}, "amount": {"type": "number"}, }, }, }, "subtotal": {"type": "number"}, "totalAmount": {"type": "number"}, }, }, ) print(response.function) ``` ```go package main import ( "context" "fmt" bem "github.com/bem-team/bem-go-sdk" ) func main() { client := bem.NewClient() schema := map[string]any{ "type": "object", "required": []string{"invoiceNumber", "vendor", "totalAmount"}, "properties": map[string]any{ "invoiceNumber": map[string]any{"type": "string"}, "invoiceDate": map[string]any{"type": "string"}, "dueDate": map[string]any{"type": "string"}, "vendor": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, "address": map[string]any{"type": "string"}, }, }, "lineItems": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "properties": map[string]any{ "description": map[string]any{"type": "string"}, "quantity": map[string]any{"type": "number"}, "unitPrice": map[string]any{"type": "number"}, "amount": map[string]any{"type": "number"}, }, }, }, "subtotal": map[string]any{"type": "number"}, "totalAmount": map[string]any{"type": "number"}, }, } resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfExtract: &bem.CreateFunctionExtractParam{ FunctionName: "invoice-extractor", DisplayName: bem.String("Invoice Extractor"), Tags: []string{"logistics", "invoices"}, OutputSchemaName: bem.String("Invoice"), OutputSchema: schema, }, }, }) if err != nil { panic(err) } fmt.Printf("%+v\n", resp.Function) } ``` ```csharp using System.Text.Json; using Bem; using Bem.Models.Functions; BemClient client = new(); var schemaJson = """ { "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": { "type": "string", "description": "Unique invoice identifier" }, "invoiceDate": { "type": "string", "description": "Invoice date (YYYY-MM-DD)" }, "dueDate": { "type": "string", "description": "Payment due date (YYYY-MM-DD)" }, "vendor": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number" }, "amount": { "type": "number" } } } }, "subtotal": { "type": "number" }, "totalAmount": { "type": "number" } } } """; var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Extract { FunctionName = "invoice-extractor", DisplayName = "Invoice Extractor", Tags = ["logistics", "invoices"], OutputSchemaName = "Invoice", OutputSchema = JsonSerializer.Deserialize(schemaJson), }, }); Console.WriteLine(response.Function); ``` ```bash bem functions create \ --function-name invoice-extractor \ --type extract \ --display-name "Invoice Extractor" \ --tags '["logistics", "invoices"]' \ --output-schema-name Invoice \ --output-schema '{ "type": "object", "required": ["invoiceNumber", "vendor", "totalAmount"], "properties": { "invoiceNumber": { "type": "string" }, "invoiceDate": { "type": "string" }, "dueDate": { "type": "string" }, "vendor": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number" }, "amount": { "type": "number" } } } }, "subtotal": { "type": "number" }, "totalAmount": { "type": "number" } } }' ``` Step 2: Create the bill of lading Extract function [#step-2-create-the-bill-of-lading-extract-function] Bills of lading carry shipper, consignee, carrier, and per-line freight detail. Drop the invoice-shaped fields and add the freight ones. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "bol-extractor", "type": "extract", "displayName": "Bill of Lading Extractor", "tags": ["logistics", "freight"], "outputSchemaName": "BillOfLading", "outputSchema": { "type": "object", "required": ["bolNumber", "shipper", "consignee"], "properties": { "bolNumber": { "type": "string", "description": "Bill of lading number (sometimes called the pro number)" }, "shipDate": { "type": "string", "description": "Ship date (YYYY-MM-DD)" }, "carrier": { "type": "string", "description": "Carrier name or SCAC code" }, "shipper": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "consignee": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "items": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "weightLbs": { "type": "number" }, "freightClass": { "type": "string" } } } }, "totalWeightLbs": { "type": "number" }, "freightCharges": { "type": "number" } } } }' ``` ```typescript const { function: fn } = await client.functions.create({ functionName: "bol-extractor", type: "extract", displayName: "Bill of Lading Extractor", tags: ["logistics", "freight"], outputSchemaName: "BillOfLading", outputSchema: { type: "object", required: ["bolNumber", "shipper", "consignee"], properties: { bolNumber: { type: "string", description: "Bill of lading number (sometimes called the pro number)" }, shipDate: { type: "string", description: "Ship date (YYYY-MM-DD)" }, carrier: { type: "string", description: "Carrier name or SCAC code" }, shipper: { type: "object", properties: { name: { type: "string" }, address: { type: "string" }, }, }, consignee: { type: "object", properties: { name: { type: "string" }, address: { type: "string" }, }, }, items: { type: "array", items: { type: "object", properties: { description: { type: "string" }, quantity: { type: "number" }, weightLbs: { type: "number" }, freightClass: { type: "string" }, }, }, }, totalWeightLbs: { type: "number" }, freightCharges: { type: "number" }, }, }, }); ``` ```python response = client.functions.create( function_name="bol-extractor", type="extract", display_name="Bill of Lading Extractor", tags=["logistics", "freight"], output_schema_name="BillOfLading", output_schema={ "type": "object", "required": ["bolNumber", "shipper", "consignee"], "properties": { "bolNumber": {"type": "string", "description": "Bill of lading number (sometimes called the pro number)"}, "shipDate": {"type": "string", "description": "Ship date (YYYY-MM-DD)"}, "carrier": {"type": "string", "description": "Carrier name or SCAC code"}, "shipper": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, }, }, "consignee": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, }, }, "items": { "type": "array", "items": { "type": "object", "properties": { "description": {"type": "string"}, "quantity": {"type": "number"}, "weightLbs": {"type": "number"}, "freightClass": {"type": "string"}, }, }, }, "totalWeightLbs": {"type": "number"}, "freightCharges": {"type": "number"}, }, }, ) ``` ```go schema := map[string]any{ "type": "object", "required": []string{"bolNumber", "shipper", "consignee"}, "properties": map[string]any{ "bolNumber": map[string]any{"type": "string"}, "shipDate": map[string]any{"type": "string"}, "carrier": map[string]any{"type": "string"}, "shipper": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, "address": map[string]any{"type": "string"}, }, }, "consignee": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, "address": map[string]any{"type": "string"}, }, }, "items": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "properties": map[string]any{ "description": map[string]any{"type": "string"}, "quantity": map[string]any{"type": "number"}, "weightLbs": map[string]any{"type": "number"}, "freightClass": map[string]any{"type": "string"}, }, }, }, "totalWeightLbs": map[string]any{"type": "number"}, "freightCharges": map[string]any{"type": "number"}, }, } resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfExtract: &bem.CreateFunctionExtractParam{ FunctionName: "bol-extractor", DisplayName: bem.String("Bill of Lading Extractor"), Tags: []string{"logistics", "freight"}, OutputSchemaName: bem.String("BillOfLading"), OutputSchema: schema, }, }, }) ``` ```csharp var schemaJson = """ { "type": "object", "required": ["bolNumber", "shipper", "consignee"], "properties": { "bolNumber": { "type": "string", "description": "Bill of lading number (sometimes called the pro number)" }, "shipDate": { "type": "string", "description": "Ship date (YYYY-MM-DD)" }, "carrier": { "type": "string", "description": "Carrier name or SCAC code" }, "shipper": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "consignee": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "items": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "weightLbs": { "type": "number" }, "freightClass": { "type": "string" } } } }, "totalWeightLbs": { "type": "number" }, "freightCharges": { "type": "number" } } } """; var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Extract { FunctionName = "bol-extractor", DisplayName = "Bill of Lading Extractor", Tags = ["logistics", "freight"], OutputSchemaName = "BillOfLading", OutputSchema = JsonSerializer.Deserialize(schemaJson), }, }); ``` ```bash bem functions create \ --function-name bol-extractor \ --type extract \ --display-name "Bill of Lading Extractor" \ --tags '["logistics", "freight"]' \ --output-schema-name BillOfLading \ --output-schema '{ "type": "object", "required": ["bolNumber", "shipper", "consignee"], "properties": { "bolNumber": { "type": "string" }, "shipDate": { "type": "string" }, "carrier": { "type": "string" }, "shipper": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "consignee": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "items": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "weightLbs": { "type": "number" }, "freightClass": { "type": "string" } } } }, "totalWeightLbs": { "type": "number" }, "freightCharges": { "type": "number" } } }' ``` Step 3: Create the packing slip Extract function [#step-3-create-the-packing-slip-extract-function] Packing slips track what physically shipped — recipient, items, quantities ordered vs shipped — without the financial detail of an invoice or the freight detail of a BOL. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "packing-slip-extractor", "type": "extract", "displayName": "Packing Slip Extractor", "tags": ["logistics", "fulfillment"], "outputSchemaName": "PackingSlip", "outputSchema": { "type": "object", "required": ["packingSlipNumber", "recipient"], "properties": { "packingSlipNumber": { "type": "string" }, "orderNumber": { "type": "string", "description": "Related purchase order or sales order number" }, "shipDate": { "type": "string", "description": "Ship date (YYYY-MM-DD)" }, "trackingNumber": { "type": "string" }, "recipient": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "items": { "type": "array", "items": { "type": "object", "properties": { "sku": { "type": "string" }, "description": { "type": "string" }, "quantityOrdered": { "type": "number" }, "quantityShipped": { "type": "number" } } } } } } }' ``` ```typescript const { function: fn } = await client.functions.create({ functionName: "packing-slip-extractor", type: "extract", displayName: "Packing Slip Extractor", tags: ["logistics", "fulfillment"], outputSchemaName: "PackingSlip", outputSchema: { type: "object", required: ["packingSlipNumber", "recipient"], properties: { packingSlipNumber: { type: "string" }, orderNumber: { type: "string", description: "Related purchase order or sales order number" }, shipDate: { type: "string", description: "Ship date (YYYY-MM-DD)" }, trackingNumber: { type: "string" }, recipient: { type: "object", properties: { name: { type: "string" }, address: { type: "string" }, }, }, items: { type: "array", items: { type: "object", properties: { sku: { type: "string" }, description: { type: "string" }, quantityOrdered: { type: "number" }, quantityShipped: { type: "number" }, }, }, }, }, }, }); ``` ```python response = client.functions.create( function_name="packing-slip-extractor", type="extract", display_name="Packing Slip Extractor", tags=["logistics", "fulfillment"], output_schema_name="PackingSlip", output_schema={ "type": "object", "required": ["packingSlipNumber", "recipient"], "properties": { "packingSlipNumber": {"type": "string"}, "orderNumber": {"type": "string", "description": "Related purchase order or sales order number"}, "shipDate": {"type": "string", "description": "Ship date (YYYY-MM-DD)"}, "trackingNumber": {"type": "string"}, "recipient": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, }, }, "items": { "type": "array", "items": { "type": "object", "properties": { "sku": {"type": "string"}, "description": {"type": "string"}, "quantityOrdered": {"type": "number"}, "quantityShipped": {"type": "number"}, }, }, }, }, }, ) ``` ```go schema := map[string]any{ "type": "object", "required": []string{"packingSlipNumber", "recipient"}, "properties": map[string]any{ "packingSlipNumber": map[string]any{"type": "string"}, "orderNumber": map[string]any{"type": "string"}, "shipDate": map[string]any{"type": "string"}, "trackingNumber": map[string]any{"type": "string"}, "recipient": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, "address": map[string]any{"type": "string"}, }, }, "items": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "properties": map[string]any{ "sku": map[string]any{"type": "string"}, "description": map[string]any{"type": "string"}, "quantityOrdered": map[string]any{"type": "number"}, "quantityShipped": map[string]any{"type": "number"}, }, }, }, }, } resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfExtract: &bem.CreateFunctionExtractParam{ FunctionName: "packing-slip-extractor", DisplayName: bem.String("Packing Slip Extractor"), Tags: []string{"logistics", "fulfillment"}, OutputSchemaName: bem.String("PackingSlip"), OutputSchema: schema, }, }, }) ``` ```csharp var schemaJson = """ { "type": "object", "required": ["packingSlipNumber", "recipient"], "properties": { "packingSlipNumber": { "type": "string" }, "orderNumber": { "type": "string", "description": "Related purchase order or sales order number" }, "shipDate": { "type": "string", "description": "Ship date (YYYY-MM-DD)" }, "trackingNumber": { "type": "string" }, "recipient": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "items": { "type": "array", "items": { "type": "object", "properties": { "sku": { "type": "string" }, "description": { "type": "string" }, "quantityOrdered": { "type": "number" }, "quantityShipped": { "type": "number" } } } } } } """; var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Extract { FunctionName = "packing-slip-extractor", DisplayName = "Packing Slip Extractor", Tags = ["logistics", "fulfillment"], OutputSchemaName = "PackingSlip", OutputSchema = JsonSerializer.Deserialize(schemaJson), }, }); ``` ```bash bem functions create \ --function-name packing-slip-extractor \ --type extract \ --display-name "Packing Slip Extractor" \ --tags '["logistics", "fulfillment"]' \ --output-schema-name PackingSlip \ --output-schema '{ "type": "object", "required": ["packingSlipNumber", "recipient"], "properties": { "packingSlipNumber": { "type": "string" }, "orderNumber": { "type": "string" }, "shipDate": { "type": "string" }, "trackingNumber": { "type": "string" }, "recipient": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "items": { "type": "array", "items": { "type": "object", "properties": { "sku": { "type": "string" }, "description": { "type": "string" }, "quantityOrdered": { "type": "number" }, "quantityShipped": { "type": "number" } } } } } }' ``` Step 4: Create the Classify function [#step-4-create-the-classify-function] The Classify function decides which extractor to route to. Each `classifications[]` entry has a `name` (used by the workflow's edge `destinationName`), a `description` the model uses to make the call, and a `functionName` pointing at the destination. Write the descriptions like you'd write a routing rubric for an intern: include the *distinguishing* features, not the generic ones. "Has line items" is too weak — invoices, BOLs, and packing slips can all have line items. "Has unit prices and an amount due" is the discriminator for an invoice. ```bash curl -X POST https://api.bem.ai/v3/functions \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "functionName": "logistics-classifier", "type": "classify", "displayName": "Logistics Document Classifier", "tags": ["logistics"], "description": "Classifies inbound logistics documents and routes each to the right extractor. Inputs are typically PDFs or scans of a single document.", "classifications": [ { "name": "invoice", "functionName": "invoice-extractor", "description": "An invoice or bill from a vendor requesting payment for goods or services. Distinguishing features: line items with unit prices and an amount due, a payment due date, vendor billing address. Common headers include INVOICE, BILL, STATEMENT." }, { "name": "bill_of_lading", "functionName": "bol-extractor", "description": "A bill of lading (BOL) — the freight contract between shipper and carrier. Distinguishing features: a BOL or pro number, named carrier, freight class per line, weight in pounds. Common headers include BILL OF LADING, STRAIGHT BILL OF LADING." }, { "name": "packing_slip", "functionName": "packing-slip-extractor", "description": "A packing slip listing what was physically shipped to a recipient. Distinguishing features: recipient address, line items with quantity ordered and quantity shipped, no prices or freight classes. Common headers include PACKING SLIP, PACKING LIST, SHIPMENT NOTICE." } ] }' ``` ```typescript const { function: fn } = await client.functions.create({ functionName: "logistics-classifier", type: "classify", displayName: "Logistics Document Classifier", tags: ["logistics"], description: "Classifies inbound logistics documents and routes each to the right extractor. Inputs are typically PDFs or scans of a single document.", classifications: [ { name: "invoice", functionName: "invoice-extractor", description: "An invoice or bill from a vendor requesting payment for goods or services. Distinguishing features: line items with unit prices and an amount due, a payment due date, vendor billing address. Common headers include INVOICE, BILL, STATEMENT.", }, { name: "bill_of_lading", functionName: "bol-extractor", description: "A bill of lading (BOL) — the freight contract between shipper and carrier. Distinguishing features: a BOL or pro number, named carrier, freight class per line, weight in pounds. Common headers include BILL OF LADING, STRAIGHT BILL OF LADING.", }, { name: "packing_slip", functionName: "packing-slip-extractor", description: "A packing slip listing what was physically shipped to a recipient. Distinguishing features: recipient address, line items with quantity ordered and quantity shipped, no prices or freight classes. Common headers include PACKING SLIP, PACKING LIST, SHIPMENT NOTICE.", }, ], }); ``` ```python response = client.functions.create( function_name="logistics-classifier", type="classify", display_name="Logistics Document Classifier", tags=["logistics"], description="Classifies inbound logistics documents and routes each to the right extractor. Inputs are typically PDFs or scans of a single document.", classifications=[ { "name": "invoice", "function_name": "invoice-extractor", "description": "An invoice or bill from a vendor requesting payment for goods or services. Distinguishing features: line items with unit prices and an amount due, a payment due date, vendor billing address. Common headers include INVOICE, BILL, STATEMENT.", }, { "name": "bill_of_lading", "function_name": "bol-extractor", "description": "A bill of lading (BOL) — the freight contract between shipper and carrier. Distinguishing features: a BOL or pro number, named carrier, freight class per line, weight in pounds. Common headers include BILL OF LADING, STRAIGHT BILL OF LADING.", }, { "name": "packing_slip", "function_name": "packing-slip-extractor", "description": "A packing slip listing what was physically shipped to a recipient. Distinguishing features: recipient address, line items with quantity ordered and quantity shipped, no prices or freight classes. Common headers include PACKING SLIP, PACKING LIST, SHIPMENT NOTICE.", }, ], ) ``` ```go resp, err := client.Functions.New(context.TODO(), bem.FunctionNewParams{ CreateFunction: bem.CreateFunctionUnionParam{ OfClassify: &bem.CreateFunctionClassifyParam{ FunctionName: "logistics-classifier", DisplayName: bem.String("Logistics Document Classifier"), Tags: []string{"logistics"}, Description: bem.String("Classifies inbound logistics documents and routes each to the right extractor. Inputs are typically PDFs or scans of a single document."), Classifications: []bem.ClassificationListItemParam{ { Name: bem.String("invoice"), FunctionName: bem.String("invoice-extractor"), Description: bem.String("An invoice or bill from a vendor requesting payment for goods or services. Distinguishing features: line items with unit prices and an amount due, a payment due date, vendor billing address. Common headers include INVOICE, BILL, STATEMENT."), }, { Name: bem.String("bill_of_lading"), FunctionName: bem.String("bol-extractor"), Description: bem.String("A bill of lading (BOL) — the freight contract between shipper and carrier. Distinguishing features: a BOL or pro number, named carrier, freight class per line, weight in pounds. Common headers include BILL OF LADING, STRAIGHT BILL OF LADING."), }, { Name: bem.String("packing_slip"), FunctionName: bem.String("packing-slip-extractor"), Description: bem.String("A packing slip listing what was physically shipped to a recipient. Distinguishing features: recipient address, line items with quantity ordered and quantity shipped, no prices or freight classes. Common headers include PACKING SLIP, PACKING LIST, SHIPMENT NOTICE."), }, }, }, }, }) ``` ```csharp var response = await client.Functions.Create(new FunctionCreateParams { CreateFunction = new Classify { FunctionName = "logistics-classifier", DisplayName = "Logistics Document Classifier", Tags = ["logistics"], Description = "Classifies inbound logistics documents and routes each to the right extractor. Inputs are typically PDFs or scans of a single document.", Classifications = [ new ClassificationListItem { Name = "invoice", FunctionName = "invoice-extractor", Description = "An invoice or bill from a vendor requesting payment for goods or services. Distinguishing features: line items with unit prices and an amount due, a payment due date, vendor billing address. Common headers include INVOICE, BILL, STATEMENT.", }, new ClassificationListItem { Name = "bill_of_lading", FunctionName = "bol-extractor", Description = "A bill of lading (BOL) — the freight contract between shipper and carrier. Distinguishing features: a BOL or pro number, named carrier, freight class per line, weight in pounds. Common headers include BILL OF LADING, STRAIGHT BILL OF LADING.", }, new ClassificationListItem { Name = "packing_slip", FunctionName = "packing-slip-extractor", Description = "A packing slip listing what was physically shipped to a recipient. Distinguishing features: recipient address, line items with quantity ordered and quantity shipped, no prices or freight classes. Common headers include PACKING SLIP, PACKING LIST, SHIPMENT NOTICE.", }, ], }, }); ``` ```bash bem functions create \ --function-name logistics-classifier \ --type classify \ --display-name "Logistics Document Classifier" \ --tags '["logistics"]' \ --description "Classifies inbound logistics documents and routes each to the right extractor. Inputs are typically PDFs or scans of a single document." \ --classification '[ { "name": "invoice", "functionName": "invoice-extractor", "description": "An invoice or bill from a vendor requesting payment for goods or services. Distinguishing features: line items with unit prices and an amount due, a payment due date, vendor billing address." }, { "name": "bill_of_lading", "functionName": "bol-extractor", "description": "A bill of lading (BOL) — the freight contract between shipper and carrier. Distinguishing features: a BOL or pro number, named carrier, freight class per line, weight in pounds." }, { "name": "packing_slip", "functionName": "packing-slip-extractor", "description": "A packing slip listing what was physically shipped to a recipient. Distinguishing features: recipient address, line items with quantity ordered and quantity shipped, no prices or freight classes." } ]' ``` Step 5: Create the workflow [#step-5-create-the-workflow] Now wire all four functions into a single workflow. The classifier is the entry point (`mainNodeName`); each edge leaves the classifier with a `destinationName` matching one of the classifications and arrives at the matching extractor. ```text +--> invoice-extractor (destinationName: "invoice") | Input --> doc-classifier -------+--> bol-extractor (destinationName: "bill_of_lading") | +--> packing-slip-extractor (destinationName: "packing_slip") ``` ```bash curl -X POST https://api.bem.ai/v3/workflows \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "name": "logistics-triage", "displayName": "Logistics Document Triage", "tags": ["logistics"], "mainNodeName": "doc-classifier", "nodes": [ { "name": "doc-classifier", "function": { "name": "logistics-classifier" } }, { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "bol-extractor", "function": { "name": "bol-extractor" } }, { "name": "packing-slip-extractor", "function": { "name": "packing-slip-extractor" } } ], "edges": [ { "sourceNodeName": "doc-classifier", "destinationName": "invoice", "destinationNodeName": "invoice-extractor" }, { "sourceNodeName": "doc-classifier", "destinationName": "bill_of_lading", "destinationNodeName": "bol-extractor" }, { "sourceNodeName": "doc-classifier", "destinationName": "packing_slip", "destinationNodeName": "packing-slip-extractor" } ] }' ``` ```typescript const { workflow } = await client.workflows.create({ name: "logistics-triage", displayName: "Logistics Document Triage", tags: ["logistics"], mainNodeName: "doc-classifier", nodes: [ { name: "doc-classifier", function: { name: "logistics-classifier" } }, { name: "invoice-extractor", function: { name: "invoice-extractor" } }, { name: "bol-extractor", function: { name: "bol-extractor" } }, { name: "packing-slip-extractor", function: { name: "packing-slip-extractor" } }, ], edges: [ { sourceNodeName: "doc-classifier", destinationName: "invoice", destinationNodeName: "invoice-extractor" }, { sourceNodeName: "doc-classifier", destinationName: "bill_of_lading", destinationNodeName: "bol-extractor" }, { sourceNodeName: "doc-classifier", destinationName: "packing_slip", destinationNodeName: "packing-slip-extractor" }, ], }); console.log(workflow); ``` ```python response = client.workflows.create( name="logistics-triage", display_name="Logistics Document Triage", tags=["logistics"], main_node_name="doc-classifier", nodes=[ {"name": "doc-classifier", "function": {"name": "logistics-classifier"}}, {"name": "invoice-extractor", "function": {"name": "invoice-extractor"}}, {"name": "bol-extractor", "function": {"name": "bol-extractor"}}, {"name": "packing-slip-extractor", "function": {"name": "packing-slip-extractor"}}, ], edges=[ {"source_node_name": "doc-classifier", "destination_name": "invoice", "destination_node_name": "invoice-extractor"}, {"source_node_name": "doc-classifier", "destination_name": "bill_of_lading", "destination_node_name": "bol-extractor"}, {"source_node_name": "doc-classifier", "destination_name": "packing_slip", "destination_node_name": "packing-slip-extractor"}, ], ) print(response.workflow) ``` ```go resp, err := client.Workflows.New(context.TODO(), bem.WorkflowNewParams{ Name: "logistics-triage", DisplayName: bem.String("Logistics Document Triage"), Tags: []string{"logistics"}, MainNodeName: "doc-classifier", Nodes: []bem.WorkflowNewParamsNode{ {Name: bem.String("doc-classifier"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("logistics-classifier")}}, {Name: bem.String("invoice-extractor"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("invoice-extractor")}}, {Name: bem.String("bol-extractor"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("bol-extractor")}}, {Name: bem.String("packing-slip-extractor"), Function: bem.FunctionVersionIdentifierParam{Name: bem.String("packing-slip-extractor")}}, }, Edges: []bem.WorkflowNewParamsEdge{ {SourceNodeName: "doc-classifier", DestinationName: bem.String("invoice"), DestinationNodeName: "invoice-extractor"}, {SourceNodeName: "doc-classifier", DestinationName: bem.String("bill_of_lading"), DestinationNodeName: "bol-extractor"}, {SourceNodeName: "doc-classifier", DestinationName: bem.String("packing_slip"), DestinationNodeName: "packing-slip-extractor"}, }, }) ``` ```csharp using Bem.Models.Workflows; var response = await client.Workflows.Create(new WorkflowCreateParams { Name = "logistics-triage", DisplayName = "Logistics Document Triage", Tags = ["logistics"], MainNodeName = "doc-classifier", Nodes = [ new Node { Name = "doc-classifier", Function = new FunctionVersionIdentifier { Name = "logistics-classifier" } }, new Node { Name = "invoice-extractor", Function = new FunctionVersionIdentifier { Name = "invoice-extractor" } }, new Node { Name = "bol-extractor", Function = new FunctionVersionIdentifier { Name = "bol-extractor" } }, new Node { Name = "packing-slip-extractor", Function = new FunctionVersionIdentifier { Name = "packing-slip-extractor" } }, ], Edges = [ new Edge { SourceNodeName = "doc-classifier", DestinationName = "invoice", DestinationNodeName = "invoice-extractor" }, new Edge { SourceNodeName = "doc-classifier", DestinationName = "bill_of_lading", DestinationNodeName = "bol-extractor" }, new Edge { SourceNodeName = "doc-classifier", DestinationName = "packing_slip", DestinationNodeName = "packing-slip-extractor" }, ], }); ``` ```bash bem workflows create \ --name logistics-triage \ --display-name "Logistics Document Triage" \ --tags '["logistics"]' \ --main-node-name doc-classifier \ --nodes '[ { "name": "doc-classifier", "function": { "name": "logistics-classifier" } }, { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "bol-extractor", "function": { "name": "bol-extractor" } }, { "name": "packing-slip-extractor", "function": { "name": "packing-slip-extractor" } } ]' \ --edges '[ { "sourceNodeName": "doc-classifier", "destinationName": "invoice", "destinationNodeName": "invoice-extractor" }, { "sourceNodeName": "doc-classifier", "destinationName": "bill_of_lading", "destinationNodeName": "bol-extractor" }, { "sourceNodeName": "doc-classifier", "destinationName": "packing_slip", "destinationNodeName": "packing-slip-extractor" } ]' ``` Step 6: Call the workflow with a logistics document [#step-6-call-the-workflow-with-a-logistics-document] Send a document through the triage. The example uses an invoice; swap the file for `bill-of-lading.pdf` or `packing-slip.pdf` to confirm the other branches route correctly. Upload as multipart form data: ```bash curl -X POST "https://api.bem.ai/v3/workflows/logistics-triage/call" \ -H "x-api-key: $BEM_API_KEY" \ -F "wait=true" \ -F "callReferenceID=invoice-001" \ -F "file=@invoice.pdf" ``` Or, JSON body with base64-encoded file: ```bash curl -X POST "https://api.bem.ai/v3/workflows/logistics-triage/call?wait=true" \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "callReferenceID": "invoice-001", "input": { "singleFile": { "inputType": "pdf", "inputContent": "'"$(base64 -i invoice.pdf)"'" } } }' ``` ```typescript import fs from "node:fs"; const inputContent = fs.readFileSync("invoice.pdf").toString("base64"); const { call } = await client.workflows.call("logistics-triage", { wait: true, callReferenceID: "invoice-001", input: { singleFile: { inputType: "pdf", inputContent, }, }, }); console.log(call?.status, call?.outputs); ``` ```python import base64 with open("invoice.pdf", "rb") as f: input_content = base64.b64encode(f.read()).decode() response = client.workflows.call( "logistics-triage", wait=True, call_reference_id="invoice-001", input={ "single_file": { "input_type": "pdf", "input_content": input_content, } }, ) print(response.call.status, response.call.outputs) ``` ```go data, err := os.ReadFile("invoice.pdf") if err != nil { panic(err) } encoded := base64.StdEncoding.EncodeToString(data) resp, err := client.Workflows.Call(context.TODO(), "logistics-triage", bem.WorkflowCallParams{ Wait: bem.Bool(true), CallReferenceID: bem.String("invoice-001"), Input: bem.WorkflowCallParamsInput{ SingleFile: &bem.WorkflowCallParamsInputSingleFile{ InputType: "pdf", InputContent: encoded, }, }, }) if err != nil { panic(err) } fmt.Printf("status=%s outputs=%d\n", resp.Call.Status, len(resp.Call.Outputs)) ``` ```csharp var bytes = File.ReadAllBytes("invoice.pdf"); var encoded = Convert.ToBase64String(bytes); var response = await client.Workflows.Call("logistics-triage", new WorkflowCallParams { Wait = true, CallReferenceID = "invoice-001", Input = new Input { SingleFile = new FileInput { InputType = InputType.Pdf, InputContent = encoded, }, }, }); Console.WriteLine(response.Call.Status); ``` ```bash bem workflows call \ --workflow-name logistics-triage \ --wait \ --call-reference-id invoice-001 \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' ``` The `@invoice.pdf` syntax tells the CLI to read and base64-encode the file inline. **Response:** ```json { "call": { "callID": "wc_abc123", "callReferenceID": "invoice-001", "status": "completed", "workflowName": "logistics-triage", "workflowVersionNum": 1, "outputs": [ { "eventID": "ev_…", "eventType": "extract", "functionName": "invoice-extractor", "transformedContent": { "invoiceNumber": "INV-2026-04188", "invoiceDate": "2026-04-22", "dueDate": "2026-05-22", "vendor": { "name": "Acme Logistics, Inc.", "address": "1523 Pacific Ave, Santa Cruz, CA 95060" }, "lineItems": [ { "description": "LTL freight, San Jose → Phoenix", "quantity": 1, "unitPrice": 1248.0, "amount": 1248.0 }, { "description": "Liftgate service", "quantity": 1, "unitPrice": 75.0, "amount": 75.0 } ], "subtotal": 1323.0, "totalAmount": 1323.0 } } ], "errors": [], "url": "/v3/calls/wc_abc123", "traceUrl": "/v3/calls/wc_abc123/trace" } } ``` The terminal output is the *extractor's* output, not the classifier's. The classifier's decision is implicit in `outputs[].functionName` (`invoice-extractor` here, meaning the file was classified as an invoice). The extracted JSON lives at `outputs[0].transformedContent` — see [Reading workflow call outputs](/guide/reading-workflow-call-outputs) for the per-event-type field map and accessor patterns. For full per-node visibility, fetch [`GET /v3/calls/{callID}/trace`](/api/v3/calls/v3-get-call-trace), which returns the complete execution graph including the classifier's chosen label. Adding an error fallback [#adding-an-error-fallback] The classifier as built will fail a call when an inbound file matches none of the three classifications. To handle the long tail of mis-routed mail or off-template documents, add a fourth classification with `isErrorFallback: true` and route it to a generic extractor — usually one with a permissive schema: ```json { "name": "unknown", "functionName": "logistics-fallback-extractor", "isErrorFallback": true, "description": "Inbound document that doesn't match any of the known logistics types. Use as a catch-all for off-template forms or mis-routed mail." } ``` Then add the fallback extractor as a node in the workflow and an edge from `doc-classifier` with `destinationName: "unknown"`. The [Classify Functions](/guide/function-types/classify) reference covers the pattern in full. Next steps [#next-steps] The full reference for branching workflows: classifications, descriptions, error fallbacks Schema design, tabular chunking, and visual vs text-first inputs Sequential, branching, splitting, joining — every DAG shape Designing `outputSchema` for reliable extraction # Access Control (/guide/dashboard/access-control) > For the complete documentation index, see [llms.txt](/llms.txt). **Access control** in bem has two layers. Every member of an account carries one **account role** that governs what they can do everywhere. On top of that, any single workflow can be **restricted** — hidden, along with everything it processed, from members who have not been given access to it. Users settings Account roles (Settings → Users) [#account-roles-settings--users] Open the account menu in the top-left corner, choose **Settings**, then **Users**. Admins and owners can invite members, change a member's role from the dropdown in the **Role** column, and deactivate them. Every member holds exactly one account role. The right-hand column is what that role means on a workflow that has *not* been restricted: | Role | In the account | On an open workflow | | ------------ | ------------------------------------------------------------------------------------------------- | ----------------------------------------- | | **Owner** | Everything, including billing. One per account. | Full control, including access management | | **Admin** | Everything except owner-only billing settings. Manages members, API keys, and webhooks. | Full control, including access management | | **Editor** | Create, edit, and delete workflows and functions. Run workflows, retry calls, submit corrections. | Read, run, edit, delete | | **Operator** | The same workflow powers as an Editor. Cannot manage members, API keys, or webhooks. | Read, run, edit, delete | | **Viewer** | Read-only across the app: workflows, calls, outputs, errors, traces, functions. | Read | Operator is not a step between Viewer and Editor. Despite sitting lower in the list, an Operator has the same workflow powers as an Editor — they can create, edit, and delete workflows. To give someone the ability to run a workflow without the ability to change it, make them a **Viewer** at the account level, then hide that workflow and set their role on it to **Operator**. Workflow access (workflow toolbar) [#workflow-access-workflow-toolbar] Every workflow starts **open**: everyone in the account can see it, and their account role decides what they can do with it. Owners and admins can hide a workflow so that it is visible only to the people they name. Open a workflow from the [Workflows](./workflows) list and look at the toolbar next to the workflow name: * **Manage access** — opens the access panel. Blue while the workflow is open, yellow once it is restricted. * **Restricted** (yellow, with a lock) — you can see this restricted workflow, but you are not one of its managers, so you cannot change who else can. The panel asks one question, and the answer you pick is the whole setting: * **Anyone in the account** — each member's account role decides what they can do with it. * **Only people you add** — the workflow, and its calls, outputs, and errors, are hidden from everyone you have not added. Workflow access panel Hiding a workflow [#hiding-a-workflow] Choosing **Only people you add** opens a confirmation step where you name the members who keep access. Adding someone here is what gives them access — there is no separate step to grant it. Owners and admins are deliberately absent from the picker: they already have full access to every workflow and never need a grant. Hide a workflow from other members Nothing changes until you confirm, so you can back out of the picker without touching the workflow. * Only an owner or admin can put a workflow into the restricted state; an open workflow has no managers yet. Once it is restricted, any **Manager** on it can administer it. * Restriction is **per workflow, per environment**. Restricting `invoices` in Sandbox leaves `invoices` in Production open, and access does not carry between environments. * Switching back to **Anyone in the account** reverses it — but it *deletes* the member list rather than parking it, and the confirmation tells you how many members that is. Re-hiding later starts from an empty list, so nobody is silently re-granted access months later. * Copying a restricted workflow produces a workflow that is **also restricted**, with you added as its Manager. A restricted workflow cannot be laundered into an open copy. Workflow roles [#workflow-roles] Inside a restricted workflow the grant is the whole story: it overrides the account role in both directions. An account Viewer granted **Editor** can edit that workflow. An account Editor granted only **Viewer** is read-only there. Managing workflow access Each role includes everything above it: | Workflow role | Adds | | ------------- | ------------------------------------------------------------------------------------------------------- | | **Viewer** | See the workflow and read everything it produced — definition, versions, calls, outputs, errors, traces | | **Operator** | Run the workflow, retry its calls, submit corrections on its outputs | | **Editor** | Modify the workflow and its tags, save new versions, delete it | | **Manager** | Manage the member list — add, remove, and change roles — and turn restriction off | * A member you add starts on the role that matches what they could already do here while the workflow was open, so hiding a workflow does not quietly demote anyone. Change it from the dropdown at any time. * Because the two ladders order the same names differently, an account **Operator** starts as a workflow **Editor** — the account role already allows editing. That is expected, not a mistake. * Changes take effect on the member's **next request**. There is nothing to invalidate and no sign-out required. * The **Editor** tab of a restricted workflow needs an Editor grant. A Viewer or Operator grant reads the workflow's data through the [Calls](./workflow-calls) tab; the Editor tab shows an "Access Restricted" panel. * A Manager can remove or demote their own grant, including the last Manager's. Owners and admins always retain control, so a workflow can never be orphaned. * Access is granted to individual people. There are no groups or teams yet, so a long list is maintained member by member. What members without access see [#what-members-without-access-see] Nothing. A restricted workflow is absent from the workflows list rather than greyed out, and there is no hint that anything was filtered. Here is the same environment as an owner, then as an Operator with no grants — five workflows become three: Workflows list with restricted workflows Workflows list for a member without access For a member without a grant, a restricted workflow disappears from: * The **Workflows** list, and the workflow pickers that read from it. * The [Call Log](./call-log) and the workflow's [Calls](./workflow-calls), plus outputs, errors, events, and traces. * **Accuracy** review and dataset building — a function version shared with an open workflow does not leak the restricted workflow's outputs into either, and a function *only* the restricted workflow uses is absent from the function selector entirely. * Anything addressed directly by name or ID, which returns *not found* — the same response as a workflow that never existed. Deep links in the app bounce back to the workflows list. That last point is deliberate: a "forbidden" response would confirm the workflow exists, which is exactly what restricting it is meant to prevent. Functions follow the workflows that use them [#functions-follow-the-workflows-that-use-them] A function has no owning workflow. One function can serve many workflows and can be called on its own, so there is no single workflow whose access rules could govern it directly. Instead, a function is visible to you when **at least one workflow that uses it is visible to you** — and when no workflow uses it at all. In practice: * A function only a restricted workflow uses is **hidden** from members without access, along with its versions, output schema, and prompts. It disappears from the Functions list, the [Accuracy](./accuracy) function selector, and the API. * A function that an open workflow *also* uses stays **visible** — the open workflow needs it. Its **used in** list simply omits the restricted workflows you cannot see. * A function no workflow references yet stays visible to everyone, so a function you just created does not vanish before you can attach it to anything. Two things follow from functions being shared rather than owned: * An **Editor** grant on a restricted workflow lets you edit the functions that workflow uses — including functions other workflows also use. Editing creates a *new* function version and each workflow pins a version, so no other workflow's behavior changes until it is saved again. * A function cannot be deleted while any workflow's current version still references it. The error names the workflows that are blocking the delete, including restricted ones you cannot see. Learn more [#learn-more] Browse and manage every workflow in your environment. The `restricted` flag is returned on every workflow read programmatically. How workflows compose functions into processing pipelines. # Accuracy (/guide/dashboard/accuracy) > For the complete documentation index, see [llms.txt](/llms.txt). The **Accuracy** page is the hub for improving your functions. It shows every function in your environment with headline quality metrics, and exposes three tools per function — **Label**, **Review**, and **Regression Testing** — for different stages of the quality loop. Accuracy list Function list [#function-list] * **Filter by function name** — the search bar at the top matches on the function's `displayName` (substring, case-insensitive). * Each row shows aggregate quality counters for the function: * **Labeled Outputs** — how many outputs you've manually labeled or corrected. * **Total Outputs** — how many outputs the function has produced. * **False Negatives / False Positives / True Positives** — confusion-matrix style counts computed against your labels. * Hovering a row exposes three action buttons: **Label**, **Review**, and **Regression Testing** — clicking any of them jumps into the corresponding sub-tool for that function. Label [#label] Correction editor The Label tab is a three-pane correction editor for supplying ground-truth values: * **Transformations list** — dropdown of every transformation for the selected function. Use `⌘ ← / →` to move between adjacent ones. A filter button narrows the list by date, confidence, or status. * **Input preview** — renders the original input file (PDF, image, etc.) so you can cross-reference values while correcting. * **Correction editor** — the structured JSON output, with per-field confidence badges in the gutter. Fields with low confidence are highlighted so you know what to focus on. * **Order matching** toggle — when on, the editor snaps your edits to the canonical field order from the output schema; when off, it preserves the model's original ordering. * **Confirm Output** (`⌘ ↵`) — saves your corrections as a label, which then flows into the function's accuracy metrics and becomes available as regression-test data. Review [#review] Accuracy review The Review tab is a quality dashboard for the selected function: * **Margin of Error / Confidence Level** — statistical bounds for the metrics shown below. Raise the confidence level or shrink the margin for stricter estimates. * **Function Version / Evaluation Version** — control which function version is being evaluated and which LLM-judge version is doing the grading. * **Is Regression** toggle — filter to only the outputs that are part of a regression-testing run (see below). * **Dataset Overview** — counts of total, labeled, and unlabeled transformations, plus a labeling-progress bar. * **Model Performance** — headline PR-AUC (precision-recall area under curve) for the function, with an explanation of how to read it. * **Confidence Distribution** — breakdown of outputs by confidence bucket (High ≥ 80%, Medium 60–80%, Low \< 60%). * **Run Review** — kicks off a fresh judge pass over the dataset, which repopulates the metrics above. Regression Testing [#regression-testing] Regression testing Regression Testing lets you compare two versions of the same function against the same set of inputs, so you can see whether a configuration change improved or regressed quality before promoting it. * **Baseline Version / Comparison Version** — dropdowns to pick the two function versions you want to pit against each other. * **1. Run Regression Tests** — creates regression transformation samples by re-running the baseline version over historical labeled inputs. Click **Run Tests** to start. * **2. Apply Corrections** — once samples exist, apply corrections to them either **Automatically** (using the existing labels) or **Manually** (by labeling them yourself in the Label tab). * **3. Inspect Results** — summarizes **Baseline Transformations** vs **Comparison Transformations** so you can see exactly which fields improved or regressed. Use **Rerun Comparison** to refresh after labelling more samples. Reading Metrics Programmatically [#reading-metrics-programmatically] The same quality counters shown in the function list are available via `GET /v3/functions/metrics`. Filter by `tags` to pull metrics for every function carrying a given tag, or by `displayName` for a substring match on the function name: ```bash curl -G "https://api.bem.ai/v3/functions/metrics" \ -H "x-api-key: $BEM_API_KEY" \ -d tags=finance ``` `workflowIDs` / `workflowNames` (and their `*VersionNums` variants) narrow the result to functions referenced by a specific workflow, or workflow version. See [Get Function Metrics](/api/v3/function-accuracy/v3-get-function-metrics) for the complete parameter list. Learn more [#learn-more] Retrieve function configuration and stats programmatically. Pull accuracy, precision, recall, and F1 for a set of functions. # Call Log (/guide/dashboard/call-log) > For the complete documentation index, see [llms.txt](/llms.txt). The Call Log is a cross-workflow feed of every call that's hit your environment, sorted by timestamp. It's the fastest way to answer "did that webhook fire?" or "is anything failing right now?". Call Log Filters [#filters] * **All triggers** — dropdown to scope the feed to a single workflow. Defaults to every workflow in the environment. * **Status** — filter by execution status (`Completed`, `Failed`, `Running`, `Paused`). Useful for triaging error spikes. * **Filter by reference ID** — exact-match search on the client-supplied `referenceID`. Ideal for tracing a specific business document through the system. Columns [#columns] Every row is one workflow call: * **Call ID** — the internal `wc_…` identifier. Truncated in the table; click the row to open the full call in the workflow's [Calls](./workflow-calls) tab. * **Timestamp** — when the call was received. Click the header to toggle ascending or descending sort. * **Status** — colored pill showing the final state (`Completed` in green, `Failed` in red, etc.). * **Reference ID** — the `referenceID` you supplied when triggering the call (typically a filename, invoice number, or request ID). * **Trigger** — the workflow name and version that produced the call. Click the chip to jump to that workflow's editor. Clicking any row navigates to the workflow's Calls view with that specific call pre-selected. Learn more [#learn-more] List and retrieve calls across workflows programmatically. # Collections (/guide/dashboard/collections) > For the complete documentation index, see [llms.txt](/llms.txt). **Collections** are your managed reference-data tables. They hold structured records (for example a vendor catalog, a product SKU list, a list of known customers) that `Enrich` functions can look up against to annotate or validate the output of a transformation. Collections Empty state [#empty-state] When you open Collections for the first time you'll see a **Create your first Collection** prompt with a single action: * **New Collection** — opens the collection creator, where you define the schema (column names and types) and load the initial rows. Once created, the collection becomes selectable as a data source on any `Enrich` node in the [Workflow Editor](./workflow-editor). Collections you've created appear in the left side panel once the first one exists; clicking any row opens the row browser, where you can view, edit, and bulk-import records. A collection is just a keyed table of records — think of it as a lightweight, queryable database you can use from inside your workflow. Use it when a function needs to look up external context (SKU → product name, carrier code → carrier name) to complete its output. Learn more [#learn-more] Learn how the Enrich function consumes collections. # Dashboard (/guide/dashboard/dashboard) > For the complete documentation index, see [llms.txt](/llms.txt). The **Dashboard** is the executive summary for your environment. It aggregates call volume, field throughput, error rate, business-impact estimates, and latency percentiles into a single scrollable page so you can see how bem is performing end-to-end. Dashboard KPI strip [#kpi-strip] Five summary tiles along the top of the page give you the headline numbers for the selected range: * **Time Saved** — estimated human hours displaced by automated extraction. * **Extracted Fields** — total number of structured fields produced across all workflows. * **Function Calls** — total function invocations (one call may invoke several functions). * **Error Rate** — percentage of calls that ended in a `Failed` state. * **Corrections** — number of human corrections submitted via the [Accuracy](./accuracy) page. Function Calls chart [#function-calls-chart] A stacked line chart showing daily volume broken down by function type (Analyze, Evaluation, Transform) plus a separate Errors line. Use it to spot spikes, outages, or gradual shifts in workload composition. Estimated Business Impact [#estimated-business-impact] Aggregated dollar-value and speed estimates for the selected range: * **Labor Cost Avoided** — rough dollar value of the human labor necessary to process. * **R\&D Cost Avoided** — estimated spend you'd have incurred to build and maintain the equivalent extraction logic in-house. * **Processing Speed** — how many times faster bem is than a manual process, based on measured throughput. * **Straight-Through Rate** — share of transformations that required no human correction. 100% means nothing was routed to review. API Latency [#api-latency] A chart of the **P50 / P90 / P99** response-time percentiles for the API, with a dropdown to pick which function type's latency to render. The stacked tiles on the right show the most recent bucket for each percentile at a glance — use them to check whether recent changes have moved your latency budget. Learn more [#learn-more] Drill into individual calls behind these aggregate numbers. Investigate corrections and labeling progress per function. # Forge (/guide/dashboard/forge) > For the complete documentation index, see [llms.txt](/llms.txt). **Forge** turns a file and a natural-language request into a production-ready workflow. Drop the document you care about, describe the output you want, and Forge builds the DAG, infers the schema, and gives you an API endpoint — all in one pass. Forge Starting points (center) [#starting-points-center] Forge shows two tabs above the main area: * **Use an existing workflow** — pick one of your current workflows as a starting template. Forge will propose modifications layered on top of it. * **Try an example** — browse a library of sample workflows (invoice extraction, video analysis, etc.) that you can fork into your environment. Each row in the list shows the workflow's type, name, and a quick action to select it. Prompt bar (bottom) [#prompt-bar-bottom] The composer at the bottom of the page accepts: * **Plain-text prompts** — describe what you want bem to build. For example: *"Extract line items from this invoice and classify the vendor."* * **File attachments** — click the paperclip, or drag a file onto the bar. Forge analyzes the document to scaffold an output schema and suggest downstream steps. * **Submit** (arrow button) — runs Forge. When it finishes you land in the [Workflow Editor](./workflow-editor) with the generated workflow pre-populated and ready to test. Forge is the recommended entrypoint when you have a sample document but aren't sure which function types to combine. For deterministic, code-first workflow creation, use the [Workflows API](/api/v3/\(generated\)/workflows/workflow-create-v3) directly. Learn more [#learn-more] Learn how the building blocks Forge uses fit together. Reference for Transform, Analyze, Route, Split, Join, Enrich. # Overview (/guide/dashboard/overview) > For the complete documentation index, see [llms.txt](/llms.txt). The dashboard is your control plane for building, running, and monitoring bem workflows. You can author workflows visually, inspect individual calls, label outputs, and review operational metrics — all without leaving the browser. The dashboard is organized into three sections in the left sidebar: * **Settings and Environment Switcher** — dropdown at the top to access account settings, billing, and manage workflows across your sandbox and production environnments. * **Workspace** — list and edit your workflows, and inspect the full call log across every trigger. * **Build** — compose new workflows from natural language or files with Forge, and manage the data sources (collections) and dashboards that plug into them. * **Monitor** — drill into accuracy metrics and label data to improve your functions, or open the global Dashboard for a high-level operational view. Dashboard The rest of this section walks through each page and explains what every component does. # Workflow Calls (/guide/dashboard/workflow-calls) > For the complete documentation index, see [llms.txt](/llms.txt). The **Calls** tab inside a workflow shows the full execution history for that workflow, with a DAG view on the left and a detailed call inspector on the right. Workflow calls Call selector (top) [#call-selector-top] * **Workflow picker** (left) — switch between workflows without leaving the Calls view. * **Call picker** (center) — dropdown of every call for the current workflow, labelled by the call's `referenceID`. Use the `⌘ ← / →` shortcut to move between adjacent calls. * **Input Preview** (right) — opens the original input file (PDF, image, audio) that triggered the call in a side-by-side preview. * **Replay** — re-runs the call using the current workflow version. Useful for validating that a fix to the workflow resolves an error seen in production. Call summary (right, top) [#call-summary-right-top] For the selected call you'll see: * **Reference ID** — the client-supplied `referenceID` from the original `/v3/workflows/:name/call` request. * **Started / Finished / Duration** — wall-clock timestamps and elapsed time. * **Call ID** — the internal `wc_…` identifier that uniquely names this call. * **Function Calls / Events** — counts of how many function invocations and downstream events this call produced. * **Triggered by** — the workflow name and version responsible for this call. Function execution timeline (right, below summary) [#function-execution-timeline-right-below-summary] Every function the call visited is rendered as its own expandable card: * **Type and version** — for example `Transform: EDI 990 v1` with the function's internal `fn_…` ID. * **Time to First Event / Attempts / Output Events** — performance stats for that function call. * **Attempt #n** — expandable block showing each attempt's stages: **Preprocess Input → Linear Transform → Schematization → Persist Results**. Each stage reports pass/fail status and timing. * **Transform event payload** — the structured output produced by the attempt, with per-field confidence scores rendered as a percentage badge in the gutter (e.g. `99%`). Hover a score to see the LLM judge's reasoning. DAG view (left) [#dag-view-left] The DAG mirrors the workflow's topology, with each node annotated with: * A **green check** on the node once it's finished successfully. * **Timing** (e.g. `14s 629ms`) and **output count** (`▷ +1`) under the node. Use this view to spot slow or failing nodes at a glance before drilling into the per-stage details on the right. Learn more [#learn-more] Retrieve calls and their events programmatically. Trigger a workflow call via the API. # Workflow Editor (/guide/dashboard/workflow-editor) > For the complete documentation index, see [llms.txt](/llms.txt). The Editor is where you build and test a workflow. It combines a visual DAG canvas on the left with a configuration panel on the right. Workflow Editor DAG canvas [#dag-canvas] The canvas shows every node in the workflow as a connected DAG. * **Node** — each box is one function call-site. Click a node to select it and load its configuration on the right. * **Add child** — hover a node and click the **+** button that appears underneath to append a downstream node. Only Route and Split nodes support multiple labelled outlets. * **Remove** — hover a node and click **Remove** in its top-left corner to delete it from the workflow. * **Fullscreen** — the icon in the bottom-right corner expands the canvas to the full window. Configuration panel [#configuration-panel] The right panel has two tabs: * **Edit** — settings for the currently-selected node. The fields change based on the function type (Transform, Analyze, Route, Split, Join, Enrich, Payload Shaping, Subscription). For Transform nodes you edit the function name, the JSON output schema, and model settings. * **Tests** — upload up to 5 files and run them through the workflow. Results appear as cards with the output for each function in the DAG. Edit tab [#edit-tab] * **Select Function** — dropdown to attach an existing function to the selected node, or create a new one inline. * **Function Name** — human-readable name. The API-visible identifier is shown below with a copy button. * **Output schema** — JSON Schema describing the structured output. The **Infer Schema From File** button auto-generates a schema from an example document. * **Version History** — opens the list of previous versions for the attached function so you can roll back or diff. * **Save** — persists your changes as a new version of the workflow. Unsaved changes are indicated on the button. * **Close (✕)** — collapses the panel to give the canvas more room. New workflow [#new-workflow] New workflow picker When you open the editor with no workflow selected, you're prompted to bootstrap one: * **Drop a file** — drag any supported input (PDF, image, spreadsheet, audio, video) into the canvas and bem auto-generates a Transform function with an inferred output schema. * **Start from scratch** — pick a starting function type (Transform, Analyze, Join, Split, Route) and the editor creates an empty node for you to configure. The fastest way to create a workflow from a sample document is [Forge](./forge) — it uses natural language plus your file to scaffold a multi-node workflow end-to-end. Learn more [#learn-more] Learn what each function type does and when to use it. Best practices for writing output schemas. Create and update workflows programmatically. # Workflows (/guide/dashboard/workflows) > For the complete documentation index, see [llms.txt](/llms.txt). The Workflows page is the default landing page for the dashboard. It lists every workflow in the current environment with quick actions for editing, replaying, and promoting them. Workflows list Filter and search [#filter-and-search] * **Filter by workflow name** — use the search bar above the table to filter the list by `displayName`. Matching is substring-based and case-insensitive. * **Environment switcher** — the button in the top-left corner of the sidebar switches between environments (for example Sandbox and Production). Each environment has its own independent set of workflows. Row actions [#row-actions] Every row in the table corresponds to one workflow. Hovering a row exposes the following actions: * **Edit & Test** — opens the workflow in the [Editor](./workflow-editor) where you can modify its DAG, adjust function settings, and run test calls. * **See Calls** — opens the [Calls](./workflow-calls) view filtered to that workflow so you can inspect its execution history. * **Duplicate** — creates a copy of the workflow within the current environment. Useful for experimenting with configuration changes without touching the original. * **Copy to Production** — clones the workflow (and the functions it depends on) into your Production environment. Only available when you are viewing a non-production environment. * **Tags** — manage the free-form tag list on the workflow. Tags are returned by the API and help you organize workflows by domain, team, or lifecycle stage. Clicking the copy icon next to a workflow's name copies its API `name` to your clipboard so you can paste it into an API call. Creating a workflow [#creating-a-workflow] * **New workflow** — opens the [New Workflow](./workflow-editor#new-workflow) page where you can either drop a file for bem to auto-generate a workflow, or pick a starting function type. Learn more [#learn-more] Create and manage workflows programmatically. Learn how workflows compose functions into processing pipelines. # Analyze Functions (/guide/function-types/analyze) > For the complete documentation index, see [llms.txt](/llms.txt). **Legacy type.** V3 replaces `analyze` with the unified [`extract`](/guide/function-types/extract) type, which handles both text-first and visual-first inputs. Existing `analyze` functions remain readable and callable, but new functions should be created as `extract`. This page is retained for reference. Analyze functions perform visual analysis on images and videos. They're optimized for extracting information that requires understanding visual layout and appearance of content. When to Use [#when-to-use] Use an Analyze function when you need to: * Infer visual context from pictures * Analyze images for specific visual elements * Ask questions about visual aspects in inputs Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | ------------------ | ------ | --------------------------------------------- | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"analyze"` | | `outputSchemaName` | string | Human-readable name for your schema | | `outputSchema` | object | JSON Schema defining the structure to extract | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------- | --------- | ------- | --------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | Analyze vs Transform [#analyze-vs-transform] | Aspect | Analyze | Transform | | ----------------- | ----------------------- | -------------------------- | | **Optimized for** | Visual content, images | Documents, text | | **Best for** | Receipts, photos, scans | PDFs, emails, spreadsheets | | **Processing** | Visual-first approach | Text-first approach | Choose **Analyze** when the visual appearance of the document is critical for extraction (e.g. identifying cars in a photo of a parking lot, pulling events from a warehouse security video). Choose **Transform** for standard document processing where text extraction is the primary need. Example [#example] ```json { "functionName": "receipt-analyzer", "type": "analyze", "displayName": "Receipt Image Analyzer", "outputSchemaName": "Receipt Schema", "outputSchema": { "type": "object", "required": ["merchantName", "total"], "properties": { "merchantName": { "type": "string", "description": "Name of the merchant/store" }, "merchantAddress": { "type": "string", "description": "Store address if visible" }, "transactionDate": { "type": "string", "description": "Date of transaction" }, "total": { "type": "number", "description": "Total amount paid" }, "paymentMethod": { "type": "string", "description": "How the purchase was paid (cash, card, etc.)" }, "items": { "type": "array", "description": "Individual items purchased", "items": { "type": "object", "properties": { "name": { "type": "string" }, "price": { "type": "number" }, "quantity": { "type": "number" } } } } } }, "tags": ["expense", "receipts"] } ``` Email Integration [#email-integration] Like Transform functions, Analyze functions receive an email address for forwarding documents. The email address is returned in the function response as `emailAddress`. Related [#related] API reference for creating functions Compare with Transform functions See which file types can be processed # Classify Functions (/guide/function-types/classify) > For the complete documentation index, see [llms.txt](/llms.txt). Classify functions analyze incoming data and direct it down one of several labeled paths, enabling branching workflows driven by content rather than metadata. The model decides which classification an input belongs to based on the per-classification descriptions you provide. When to Use [#when-to-use] Use a Classify function when you need to: * Identify document types (invoices, receipts, contracts, etc.) and send each to a specialized Extract function * Build branching workflows where different content needs different processing * Handle mixed document batches from a shared inbox or upload channel * Apply a fallback path for inputs that don't match any known category Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | ----------------- | ------ | ----------------------------------------------------------- | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"classify"` | | `description` | string | Description of the classification logic and expected inputs | | `classifications` | array | Array of classification definitions | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------- | --------- | ------- | --------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | Classification Configuration [#classification-configuration] Each entry in the `classifications` array has these fields: | Field | Type | Required | Description | | ----------------- | ------- | ----------- | --------------------------------------------------------------------------------------- | | `name` | string | Yes | Unique name for this classification — referenced by `destinationName` in workflow edges | | `description` | string | No | Description of when this classification applies | | `functionName` | string | Conditional | Target function name | | `isErrorFallback` | boolean | No | If `true`, handles inputs that don't match other classifications | Example [#example] ```json { "functionName": "document-classifier", "type": "classify", "displayName": "Document Classifier", "description": "Classifies incoming documents and routes them to the appropriate extraction function. Handles invoices, receipts, purchase orders, and contracts.", "classifications": [ { "name": "invoices", "description": "Invoice documents including bills and payment requests", "functionName": "invoice-extractor" }, { "name": "receipts", "description": "Receipts from purchases and transactions", "functionName": "receipt-extractor" }, { "name": "purchase-orders", "description": "Purchase order documents", "functionName": "po-extractor" }, { "name": "contracts", "description": "Legal contracts and agreements", "functionName": "contract-extractor" }, { "name": "unknown", "description": "Fallback for documents that don't match other categories", "functionName": "generic-extractor", "isErrorFallback": true } ], "tags": ["classification", "workflow"] } ``` Writing Effective Descriptions [#writing-effective-descriptions] The `description` field on each classification is what the model uses to decide where an input belongs. Include: 1. **Clear criteria** — What makes an input belong to this classification? 2. **Examples** — Specific document types or characteristics 3. **Distinguishing features** — How to differentiate from similar classifications Good Description Example [#good-description-example] ```json { "name": "invoices", "description": "Invoice documents. These typically include: vendor information, invoice number, line items with quantities and prices, payment terms, and a total amount due. Includes bills, payment requests, and statements with amounts owed." } ``` Weak Description Example [#weak-description-example] ```json { "name": "invoices", "description": "For invoices" } ``` Error Fallback [#error-fallback] Always include a fallback classification with `isErrorFallback: true` to handle inputs that don't match any other category. This prevents unclassified inputs from failing the workflow. Wiring Into a Workflow [#wiring-into-a-workflow] In a workflow, edges leaving a Classify function use `destinationName` to match the `classifications[].name` on this function. See [Workflows Explained](/guide/workflows-explained) for the full branching pattern. Email Integration [#email-integration] Classify functions automatically receive an email address. Forward emails to this address to classify them and fan out to the appropriate downstream function. The email address is returned in the function response as `emailAddress` (e.g., `eml_xxx@actions.bem.ai`). Related [#related] Cookbook: build a Classify → Extract pipeline for invoices, bills of lading, and packing slips API reference for creating functions Common destination for Classify edges How `destinationName` wires Classify outputs to downstream functions # Enrich Functions (/guide/function-types/enrich) > For the complete documentation index, see [llms.txt](/llms.txt). Enrich functions perform semantic search against collections to add contextual information to your data. They're useful for looking up reference data, adding context from knowledge bases, or matching against existing records. When to Use [#when-to-use] Use an Enrich function when you need to: * Look up vendor information from a master list * Match extracted data against existing records * Add context from a knowledge base or documentation * Find similar items in a collection * Validate data against reference sources Prerequisites [#prerequisites] Before creating an Enrich function, you need: 1. A **Collection** with your reference data 2. Items added to the collection with searchable content See the [Collections API](/api/v3/collections/v3-add-collection-items) for creating and populating collections. Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | -------------- | ------ | ---------------------------------- | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"enrich"` | | `config` | object | Enrichment configuration | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------- | --------- | ------- | --------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | Enrich Config [#enrich-config] The `config` object holds a `steps` array. Each step reads a value from the input, searches a collection, and writes the result back onto the data. | Field | Type | Description | | ------- | --------- | --------------------------------------- | | `steps` | object\[] | One or more enrichment steps (required) | Each entry in `steps` carries: | Field | Type | Default | Description | | ----------------------- | ------- | ---------- | ------------------------------------------------------------------------ | | `sourceField` | string | - | JMESPath to the value to search with (required) | | `collectionName` | string | - | Name of the collection to search (required) | | `targetField` | string | - | JMESPath where the match is written (required) | | `topK` | number | - | Number of matches to return per value (0–100) | | `searchMode` | string | `semantic` | One of `semantic`, `exact`, `hybrid` — see [Search modes](#search-modes) | | `scoreThreshold` | number | `0.6` | Max distance to keep — `semantic` & `hybrid` (0–2, lower is closer) | | `includeScore` | boolean | `false` | Include the match score in the output | | `includeSubcollections` | boolean | `false` | Search child collections under `collectionName` | When `sourceField` uses array notation (e.g. `lineItems[*].description`), `targetField` must use the same notation (e.g. `lineItems[*].matchedProduct`) so each item carries its own match. Search modes [#search-modes] `searchMode` selects how candidates are retrieved from the collection before results are returned. Every mode returns **distinct** items (duplicates are collapsed), best match first. | Mode | How it retrieves | Best for | | -------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `semantic` (default) | Dense-vector cosine similarity — matches on meaning, not wording | Natural-language and conceptual matches (e.g. "red sports car" → "crimson convertible") | | `hybrid` | Fuses the semantic and keyword rankings with weighted Reciprocal Rank Fusion | Cases where meaning **and** exact tokens matter — tags, categories, partial identifiers | | `exact` | Case-insensitive substring match on the item text | Literal identifiers — SKUs, routing numbers, account IDs | Notes: * **`scoreThreshold` applies to `semantic` and `hybrid`.** It's a maximum dissimilarity on a 0–2 scale (lower keeps closer matches; default `0.6`). For `hybrid`, the fusion score is mapped onto that same scale, so one threshold covers both modes. `exact` does keyword matching and ignores it. Note `0.6` is calibrated for cosine distance and is relatively strict for `hybrid`, so consider tuning it on hybrid steps. * With `includeScore`, each match carries a `score` and a `scoreType`: `cosineDistance` for `semantic` or `hybridScore` for `hybrid`. Both are 0–2 dissimilarities where **lower is better** (`hybrid`'s fusion score is mapped onto cosine distance's scale; `0.0` = top of both the semantic and keyword rankings). * `semantic` and `hybrid` results are additionally re-ranked by an LLM; `exact` is not. Identifying a match [#identifying-a-match] Every match carries an `id`. What it holds depends on where the match came from, and the prefix tells you which: | Source | `id` | Example | | ---------- | ------------------------------------------- | -------------------- | | Collection | The **collection item** the match came from | `clitm_2xK9…` | | Endpoint | A **content hash** of the match's `data` | `h_a5fef997ef9f8992` | Because a step has one source, every match within a given enriched field carries the same kind of `id`. Use `id` to reference a match back to bem — notably when submitting a ground-truth re-ranking to [`POST /v3/events/{eventID}/enrich-feedback`](/api/v3/feedback/v3-submit-enrich-feedback), which resolves candidates by `id` and rejects anything else. For collection matches the same value joins directly against your collection. Two things worth knowing: * A collection `id` is a **durable** handle: editing the item's data does not change it, so ground truth recorded against it keeps resolving. A content hash does not have this property — editing the underlying data changes the `id`, and labels against the old value stop resolving. * Matches are de-duplicated by payload, so where the same data occupies several rows (the uniqueness constraint is per collection and embedding model, and a search can span collections) the oldest row is the representative. * An endpoint step with no `matchInstructions` returns the raw fetched values, which carry no `id` at all and therefore cannot be re-ranked. Example [#example] Vendor Lookup [#vendor-lookup] Enrich invoice data with vendor information from a master vendor list: ```json { "functionName": "vendor-enricher", "type": "enrich", "displayName": "Vendor Information Lookup", "config": { "steps": [ { "sourceField": "vendorName", "collectionName": "vendor_master_list", "targetField": "vendor", "topK": 1, "searchMode": "semantic" } ] }, "tags": ["vendor", "lookup"] } ``` Product Matching [#product-matching] Match extracted product names against a product catalog: ```json { "functionName": "product-matcher", "type": "enrich", "displayName": "Product Catalog Matcher", "config": { "steps": [ { "sourceField": "lineItems[*].description", "collectionName": "product_catalog", "targetField": "lineItems[*].matchedProduct", "topK": 1, "searchMode": "semantic" } ] }, "tags": ["products", "matching"] } ``` Workflow Pattern [#workflow-pattern] Enrich functions typically follow Transform functions: ``` Document │ ▼ ┌───────────┐ │ Transform │ Extract raw data └───────────┘ │ ▼ ┌───────────┐ │ Enrich │ Add context from collection └───────────┘ │ ▼ Enriched Output ``` Example Flow [#example-flow] 1. **Transform** extracts vendor name "Acme Corp" from invoice 2. **Enrich** searches vendor collection for "Acme Corp" 3. **Output** includes matched vendor ID, address, payment terms Setting Up Collections [#setting-up-collections] Before using Enrich functions, create a collection and add items to it. Each item carries a `data` field that can be either a string or an arbitrary JSON object — the data is embedded asynchronously, and `POST /v3/collections/items` returns immediately with `status: "pending"` and an `eventID` you can correlate with webhook notifications once embedding completes. ```bash # Create the collection curl -X POST https://api.bem.ai/v3/collections \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "collectionName": "vendor_master_list" }' # Add items (mix of string and object payloads) curl -X POST https://api.bem.ai/v3/collections/items \ -H "Content-Type: application/json" \ -H "x-api-key: $BEM_API_KEY" \ -d '{ "collectionName": "vendor_master_list", "items": [ { "data": "Acme Corporation - primary supplier for office supplies" }, { "data": { "vendorID": "V001", "name": "Acme Corporation", "paymentTerms": "Net 30" } } ] }' ``` Collection names must contain only letters, digits, underscores, and dots — each segment must start with a letter or underscore — and dot notation is meaningful: `customers.premium.vip` is a child of `customers.premium`. Texts above the embedding model's 8,192-token limit are rejected; check sizes with `POST /v3/collections/token-count` before bulk uploads. Related [#related] Cookbook: Extract → Enrich pipeline that resolves natural-language line items to ERP/WMS-ready SKUs API reference for creating functions Create collections and add items for enrichment Extract data before enriching # Extract Functions (/guide/function-types/extract) > For the complete documentation index, see [llms.txt](/llms.txt). Extract functions are the most common function type in bem. They pull structured JSON out of unstructured files — PDFs, images, spreadsheets, emails, audio, and video — against a schema you define. The same primitive handles both text-first inputs (where OCR and reasoning are the levers) and visual-first inputs (where layout and appearance carry the signal); the schema and `inputType` drive the strategy. When to Use [#when-to-use] Use an Extract function when you need to: * Extract specific fields from invoices, receipts, forms, or contracts * Parse tabular data from spreadsheets or CSVs * Pull structured information out of images, scans, or video frames * Process email content and attachments * Transcribe and structure audio inputs Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | ------------------ | ------ | --------------------------------------------- | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"extract"` | | `outputSchemaName` | string | Human-readable name for your schema | | `outputSchema` | object | JSON Schema defining the structure to extract | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------------------ | --------- | ------- | -------------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | | `tabularChunkingEnabled` | boolean | `false` | Process CSV/Excel in row batches | Output Schema [#output-schema] The `outputSchema` field defines the structure of the data you want to extract, using standard [JSON Schema](https://json-schema.org/) syntax. Best Practices [#best-practices] 1. **Use descriptive field names** — Choose names that clearly indicate what data should be extracted 2. **Add descriptions** — Include descriptions for complex fields to guide the model 3. **Specify required fields** — Mark essential fields as required in the schema 4. **Use appropriate types** — Use `number` for amounts, `string` for text, `array` for lists Example [#example] ```json { "functionName": "invoice-extractor", "type": "extract", "displayName": "Invoice Data Extractor", "outputSchemaName": "Invoice Schema", "outputSchema": { "type": "object", "required": ["invoiceNumber", "totalAmount", "vendor"], "properties": { "invoiceNumber": { "type": "string", "description": "The unique invoice number" }, "invoiceDate": { "type": "string", "description": "Date of the invoice in ISO 8601 format" }, "totalAmount": { "type": "number", "description": "Total amount due" }, "vendor": { "type": "object", "properties": { "name": { "type": "string", "description": "Vendor company name" }, "address": { "type": "string", "description": "Vendor address" } } }, "lineItems": { "type": "array", "description": "Individual line items on the invoice", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number" } } } } } }, "tags": ["billing", "finance"] } ``` Tabular Chunking [#tabular-chunking] For large spreadsheets or CSVs, enable `tabularChunkingEnabled` to process data in row batches rather than all at once. This improves reliability for documents with many rows. ```json { "functionName": "spreadsheet-processor", "type": "extract", "outputSchemaName": "Row Data", "outputSchema": { "type": "object", "properties": { "productName": { "type": "string" }, "quantity": { "type": "number" }, "price": { "type": "number" } } }, "tabularChunkingEnabled": true } ``` Visual vs. Text-First Inputs [#visual-vs-text-first-inputs] The `extract` primitive adapts its processing strategy to the input: text-first for documents where OCR plus reasoning is the primary lever (spreadsheets, PDF invoices, contracts, emails with attachments), and visual-first for inputs where layout and appearance carry the signal (slide decks, photos, screenshots, video frames). You don't need to choose a mode — the schema and input type drive the behavior. Email Integration [#email-integration] Extract functions automatically receive an email address. Forward emails to this address to process them through the function. The email address is returned in the function response as `emailAddress` (e.g., `eml_xxx@actions.bem.ai`). Related [#related] API reference for creating functions Learn how to build effective output schemas See which file types can be processed Route inputs to different Extract functions based on content # Join Functions (/guide/function-types/join) > For the complete documentation index, see [llms.txt](/llms.txt). Join functions combine multiple inputs into a single, unified output transformed according to the configured output schema. They're useful for aggregating data from parallel processing paths or merging related documents. When to Use [#when-to-use] Use a Join function when you need to: * Combine outputs across different modes (e.g. combining an image, a voicemail, and a PDF that all comprise a single car accident report) * Merge related data from different sources * Create summary outputs from multiple inputs Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | ------------------ | ------ | ------------------------------------------------ | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"join"` | | `description` | string | Description of how inputs should be combined | | `joinType` | string | Type of join operation (currently `"standard"`) | | `outputSchemaName` | string | Human-readable name for your output schema | | `outputSchema` | object | JSON Schema defining the merged output structure | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------- | --------- | ------- | --------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | Join Types [#join-types] | Type | Description | | ---------- | ------------------------------------------------------------------- | | `standard` | Combines all inputs into a single output based on the output schema | Example [#example] Combining Invoice Line Items [#combining-invoice-line-items] When processing a multi-page invoice where each page is extracted separately, use a Join function to combine all line items: ```json { "functionName": "invoice-joiner", "type": "join", "displayName": "Invoice Aggregator", "description": "Combines extracted data from multiple invoice pages into a single complete invoice record. Merges line items from all pages and calculates totals.", "joinType": "standard", "outputSchemaName": "Complete Invoice", "outputSchema": { "type": "object", "required": ["invoiceNumber", "totalAmount", "lineItems"], "properties": { "invoiceNumber": { "type": "string", "description": "The invoice number (from any page header)" }, "vendor": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } } }, "totalAmount": { "type": "number", "description": "Total amount from the invoice" }, "lineItems": { "type": "array", "description": "All line items combined from all pages", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number" }, "totalPrice": { "type": "number" } } } }, "pageCount": { "type": "integer", "description": "Number of pages processed" } } }, "tags": ["aggregation", "invoices"] } ``` Merging Multi-Source Data [#merging-multi-source-data] When combining data from different document types: ```json { "functionName": "shipment-merger", "type": "join", "displayName": "Shipment Data Merger", "description": "Combines data from purchase orders, packing slips, and invoices into a complete shipment record.", "joinType": "standard", "outputSchemaName": "Complete Shipment", "outputSchema": { "type": "object", "properties": { "orderNumber": { "type": "string" }, "shipmentDate": { "type": "string" }, "items": { "type": "array", "items": { "type": "object", "properties": { "sku": { "type": "string" }, "orderedQuantity": { "type": "number" }, "shippedQuantity": { "type": "number" }, "invoicedAmount": { "type": "number" } } } }, "totalInvoiced": { "type": "number" } } } } ``` Related [#related] API reference for creating functions # Function Types Overview (/guide/function-types/overview) > For the complete documentation index, see [llms.txt](/llms.txt). Functions are the core building blocks for data processing in bem. Each function type serves a specific purpose and has its own configuration requirements. If you're coming from V1/V2 and looking for `transform`, `analyze`, or `route`, see [V3 migration](/guide/v3-migration). Those types remain readable and callable; new functions should be created as `extract` or `classify`. Available Function Types [#available-function-types] | Type | Purpose | Key Use Case | | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | | [Extract](/guide/function-types/extract) | Pull structured JSON out of documents, images, and media against a schema | Invoice processing, form extraction, receipt scanning, visual analysis | | [Classify](/guide/function-types/classify) | Direct inputs down labeled paths based on content | Document classification, workflow branching | | [Split](/guide/function-types/split) | Break multi-page documents into pieces | Multi-document PDFs, batch processing | | [Join](/guide/function-types/join) | Combine multiple inputs into one output | Data aggregation, multi-source merging | | [Enrich](/guide/function-types/enrich) | Semantic search against collections | Knowledge base lookup, context enrichment | | [Parse](/guide/function-types/parse) | Render documents into a navigable structure of sections, entities, and relationships | LLM-agent retrieval over a corpus, cross-document memory, RAG-free Q\&A | | [Payload Shaping](/guide/function-types/payload-shaping) | Translate JSON data with JMESPath expressions | Data mapping, format conversion | | [Render](/guide/function-types/render) | Merge structured JSON into a Word template to produce a `.docx` | Generate contracts, reports, and letters from extracted data | | [Send](/guide/function-types/send) | Deliver workflow outputs to a webhook, S3 bucket, or Google Drive folder | Push to downstream APIs, mirror outputs to a data lake | Common Configuration [#common-configuration] All function types share these base fields: | Field | Type | Required | Description | | -------------- | --------- | -------- | ---------------------------------------------------- | | `functionName` | string | Yes | Unique identifier for the function (per environment) | | `type` | string | Yes | The function type (e.g., `"extract"`, `"classify"`) | | `displayName` | string | No | Human-readable name for display in the UI | | `tags` | string\[] | No | Tags for categorizing and organizing functions | Choosing the Right Function Type [#choosing-the-right-function-type] * **Need to pull structured data out of a document, image, or media file?** Use an [Extract Function](/guide/function-types/extract) * **Need to classify inputs and route them down different paths?** Use a [Classify Function](/guide/function-types/classify) * **Have multi-document files to break apart?** Use a [Split Function](/guide/function-types/split) * **Need to combine multiple outputs?** Use a [Join Function](/guide/function-types/join) * **Need to add context from a knowledge base?** Use an [Enrich Function](/guide/function-types/enrich) * **Want LLM agents to navigate a corpus by sections and entities instead of fixed fields?** Use a [Parse Function](/guide/function-types/parse) * **Want to reshape JSON without AI processing?** Use a [Payload Shaping Function](/guide/function-types/payload-shaping) * **Need to merge structured JSON into a Word template and produce a `.docx`?** Use a [Render Function](/guide/function-types/render) * **Want to deliver workflow outputs to a webhook, S3 bucket, or Google Drive folder?** Use a [Send Function](/guide/function-types/send) Finding Functions Used by a Workflow [#finding-functions-used-by-a-workflow] Before editing a workflow, check which function versions its nodes are pinned to. `GET /v3/functions` filters by `workflowNames` / `workflowIDs` for any function a workflow references, or by `workflowNameVersionNums` / `workflowIDVersionNums` to narrow to the functions pinned by one specific workflow version: ```bash curl -G "https://api.bem.ai/v3/functions" \ -H "x-api-key: $BEM_API_KEY" \ -d workflowNameVersionNums=invoice-processing.2 ``` Pass `includeExtraSettings=true` to also populate each function's `extraConfig` block, which is omitted by default. `GET /v3/functions` supports several more filters — see [List Functions](/api/v3/functions/v3-list-functions) for the complete parameter list. API Reference [#api-reference] API endpoint for creating new functions API endpoint for updating existing functions API endpoint for listing and filtering functions # Parse Functions (/guide/function-types/parse) > For the complete documentation index, see [llms.txt](/llms.txt). Parse functions turn a document into a navigable representation of itself. Instead of returning schema-bound JSON, a Parse function emits page-aware sections, named entities (people, organizations, products, identifiers, datasets, …), and the relationships between them. Output is queryable via the [File System API](/api/v3/file-system) using Unix-shell verbs — `ls`, `cat`, `grep`, `head`, `find`, `open`, `xref` — so an LLM agent can browse a corpus the way a developer browses source code. This is the alternative to a RAG pipeline for use cases where the questions aren't known up front. The agent decides what to read next; the platform's job is to keep parsed documents addressable and the entity graph fresh. When to use [#when-to-use] Use a Parse function when you need to: * Stand up an agent loop over a document corpus without building a chunker, embedder, and vector store * Extract entities and relationships from documents where the schema isn't known in advance * Maintain a cross-document memory — one canonical record per real-world thing — across an environment * Power retrieval that needs to reach beyond a top-K window: `grep` across the corpus, `xref` to every section that mentions an entity If you already know exactly which fields you need out of a document, an [Extract function](/guide/function-types/extract) is the simpler tool. Configuration fields [#configuration-fields] Required fields [#required-fields] | Field | Type | Description | | -------------- | ------ | ---------------------------------------------------- | | `functionName` | string | Unique identifier for the function (per environment) | | `type` | string | Must be `"parse"` | Optional fields [#optional-fields] | Field | Type | Default | Description | | --------------------------------- | --------- | ------- | ----------------------------------------------------------------------------- | | `displayName` | string | — | Human-readable display name | | `tags` | string\[] | — | Tags for organization | | `parseConfig.extractEntities` | boolean | `true` | Extract named entities and relationships in addition to sections | | `parseConfig.linkAcrossDocuments` | boolean | `true` | Link entities across documents in the environment to build a cross-doc memory | Output structure [#output-structure] Every parse call emits a single Transformation whose JSON has three top-level arrays: | Array | Always populated | What it contains | | --------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `sections` | yes | Page-aware chunks of the document — labels, types (`heading`, `paragraph`, `table`, `list`, …), page numbers, and content | | `entities` | only when `extractEntities=true` | Named entities pulled out of the document, deduped by canonical name within the doc and counted by mention | | `relationships` | only when `extractEntities=true` | Relationships between entities (e.g. *Author A* `affiliated_with` *Institution B*) | `sections` is the anchor: it's what `cat`, `head`, `grep`, and `xref` read against. `entities` and `relationships` are the per-document slice of the entity graph; the cross-environment view lives in the Memory tab of the dashboard and is reachable via `find` / `open` / `xref`. Table sections [#table-sections] Sections of `type: "table"` carry **both** a tab-separated `content` string (for backward compatibility) **and** a structured `table` object so downstream LLM consumers — especially smaller models doing RAG-style retrieval — can address cells by header name without parsing separators. ```json { "type": "table", "label": "Specifications", "page": 2, "content": "Property\tValue\tUnit\nMass\t12.5\tkg\nLength\t3.2\tm", "table": { "headers": ["Property", "Value", "Unit"], "rows": [ { "Property": "Mass", "Value": "12.5", "Unit": "kg" }, { "Property": "Length", "Value": "3.2", "Unit": "m" } ], "cells": [ ["Mass", "12.5", "kg"], ["Length", "3.2", "m"] ] } } ``` * `headers` — the column header texts in left-to-right order. Empty array when the table has no visible header row. * `rows` — one keyed dict per data row; keys are the header names. Duplicate headers get a `__N` suffix on the second and later occurrences (`"Price", "Price__2"`); headerless tables get synthesized `col_1`, `col_2`, … keys. This guarantees no cell is lost when projecting to dicts. * `cells` — the raw positional rows, parallel to `headers`. Use this when your consumer needs the original column ordering regardless of header de-duplication. All cells are emitted as strings — consumers cast as needed. Merged cells and multi-row headers are not represented; visually-merged cells are flattened by repeating the value across affected rows. The `table` field is present **only** on `type: "table"` sections; paragraphs, lists, and other section types never carry it. The two toggles, in detail [#the-two-toggles-in-detail] `extractEntities` [#extractentities] When `true` (the default), each parse call extracts entities and relationships alongside sections, and dedupes entities by canonical name within the document. When `false`, only `sections[]` is emitted; `entities[]` and `relationships[]` come back empty. Turning entities off is rarely the right call — they're cheap on top of the section pass and they're what lets `grep` scope to `entities` or `relationships` later. Leave the default unless you have a specific reason to drop them. `linkAcrossDocuments` [#linkacrossdocuments] When `true` (the default), after each parse the platform runs a cross-document resolver that merges this document's entities with entities seen in earlier documents in the same environment, building one canonical record per real-world thing across the corpus. Surface forms (`bem.ai`, `bem`, `Brilliant Enterprise Magic, Inc.`) collapse onto one `entityID`. This toggle: * Doesn't change the per-call parse output — entities remain attached to the document via `entity_mentions` * **Is required for the memory-level File System ops** (`find`, `open`, `xref`); with linking off, those ops return an empty list and a `hint` pointing at the toggle * Requires `extractEntities=true` (linking has nothing to link otherwise) The resolver runs asynchronously after the parse event is dispatched, so the Memory tab is briefly eventually-consistent — a few seconds — while the resolver catches up. Example: minimal Parse function [#example-minimal-parse-function] ```json { "functionName": "paper-parser", "type": "parse", "displayName": "Research Paper Parser" } ``` `parseConfig` is omitted, so both toggles default to `true`. This is the canonical setup for a "parse-and-navigate" pipeline. Example: sections only, no memory [#example-sections-only-no-memory] ```json { "functionName": "draft-parser", "type": "parse", "parseConfig": { "extractEntities": false, "linkAcrossDocuments": false } } ``` Use this when you only want the navigable document structure (`ls`, `cat`, `head`, `grep`) and don't need the entity graph — for example, drafting tools that surface sections to a human reviewer. Querying parsed output [#querying-parsed-output] Parsed documents are read through the [File System API](/api/v3/file-system) at `POST /v3/fs`. The verbs split into two groups: * **Doc-level ops** (`ls`, `cat`, `head`, `grep`, `stat`) — work on every parsed document, regardless of toggles. * **Memory-level ops** (`find`, `open`, `xref`) — work on the cross-document entity graph. Require `linkAcrossDocuments=true` on the parse function that produced the docs. For a worked example, see the [Parse and Search over Contracts](/guide/cookbooks/parse-and-search-over-contracts) cookbook. Related [#related] Cookbook: parse a contract, search and reason over it with the File System API `POST /v3/fs` — every op, every flag API reference for creating functions Schema-bound extraction — the alternative when the fields are known up front # Payload Shaping Functions (/guide/function-types/payload-shaping) > For the complete documentation index, see [llms.txt](/llms.txt). Payload Shaping functions transform and reshape data using [JMESPath](https://jmespath.org/) expressions. They're ideal for data mapping, format conversion, and structural transformations without AI processing. When to Use [#when-to-use] Use a Payload Shaping function when you need to: * Transform output from one function to match another system's format * Extract specific fields from complex nested structures * Perform calculations or aggregations on data * Rename or reorganize fields * Prepare data for downstream systems or APIs Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | --------------- | ------ | -------------------------------------- | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"payload_shaping"` | | `shapingSchema` | string | JMESPath expression for transformation | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------- | --------- | ------- | --------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | Reading the Result [#reading-the-result] A Payload Shaping function's reshaped output lands in `call.outputs[].transformedContent`, the same field convention as Extract. See [Reading Workflow Call Outputs](/guide/reading-workflow-call-outputs) for the full field map. JMESPath Basics [#jmespath-basics] JMESPath is a query language for JSON. Here are common patterns: | Pattern | Description | Example | | ---------------- | ---------------- | --------------------- | | `field` | Select a field | `name` | | `field.subfield` | Nested selection | `vendor.name` | | `array[0]` | Array index | `items[0]` | | `array[*].field` | Map over array | `items[*].price` | | `sum(array)` | Sum values | `sum(items[*].price)` | | `length(array)` | Count items | `length(items)` | | `{key: value}` | Create object | `{id: invoiceNumber}` | Examples [#examples] Simple Field Mapping [#simple-field-mapping] Transform invoice data to a simplified format: **Input:** ```json { "invoiceNumber": "INV-001", "vendor": { "name": "Acme Corp", "address": "123 Main St" }, "totalAmount": 1500.00, "lineItems": [...] } ``` **Payload Shaping Function:** ```json { "functionName": "invoice-to-erp", "type": "payload_shaping", "displayName": "Invoice to ERP Format", "shapingSchema": "{ \"id\": invoiceNumber, \"vendorName\": vendor.name, \"total\": totalAmount }" } ``` **Output:** ```json { "id": "INV-001", "vendorName": "Acme Corp", "total": 1500.0 } ``` Aggregation and Calculation [#aggregation-and-calculation] Calculate totals from line items: ```json { "functionName": "calculate-totals", "type": "payload_shaping", "shapingSchema": "{ \"itemCount\": length(lineItems), \"subtotal\": sum(lineItems[*].price), \"avgPrice\": avg(lineItems[*].price) }" } ``` Complex Transformation [#complex-transformation] Restructure nested data for an API: ```json { "functionName": "api-formatter", "type": "payload_shaping", "displayName": "Format for External API", "shapingSchema": "{ \"reference\": invoiceNumber, \"amount\": { \"value\": totalAmount, \"currency\": \"USD\" }, \"vendor\": { \"id\": vendor.id, \"display\": vendor.name }, \"line_items\": lineItems[*].{sku: productCode, qty: quantity, unit_price: unitPrice} }", "tags": ["integration", "api"] } ``` Extracting from Arrays [#extracting-from-arrays] Get unique values or filter arrays: ```json { "functionName": "extract-origins", "type": "payload_shaping", "shapingSchema": "{ \"load_reference\": tenders[0].loadReference, \"total_weight_tons\": sum(tenders[*].weightTons), \"origins\": tenders[*].origin, \"submitters\": tenders[*].submitter.name }" } ``` Use Cases [#use-cases] 1\. Format Conversion [#1-format-conversion] Convert bem output to match your ERP, CRM, or database schema: ```json { "shapingSchema": "{ \"po_number\": purchaseOrderNumber, \"vendor_code\": vendor.id, \"line_count\": length(items), \"total_usd\": totalAmount }" } ``` 2\. Data Enrichment Prep [#2-data-enrichment-prep] Prepare data before sending to an enrichment function: ```json { "shapingSchema": "{ \"query\": join(' ', [vendor.name, vendor.address]), \"context\": { \"amount\": totalAmount, \"date\": invoiceDate } }" } ``` 3\. Webhook Payload Formatting [#3-webhook-payload-formatting] Shape data for webhook delivery: ```json { "shapingSchema": "{ \"event\": 'invoice.processed', \"data\": { \"id\": invoiceNumber, \"amount\": totalAmount }, \"timestamp\": '2024-01-01T00:00:00Z' }" } ``` JMESPath Reference [#jmespath-reference] For complete JMESPath syntax and functions, see the [JMESPath specification](https://jmespath.org/specification.html). Common functions: * `sum()`, `avg()`, `min()`, `max()` - Numeric aggregations * `length()` - Array/string length * `join()` - Join array elements * `sort()`, `sort_by()` - Sort arrays * `contains()` - Check for value * `keys()`, `values()` - Object operations Related [#related] API reference for creating functions Extract data before shaping Learn JMESPath syntax # Render Functions (/guide/function-types/render) > For the complete documentation index, see [llms.txt](/llms.txt). A Render function turns structured JSON into a finished `.docx` using a document template you provide. Create the template in any editor that can save a `.docx`, including Microsoft Word, Google Docs export, or another document editor. Add placeholders for the data you want to fill, then upload the file when you create the function. When Render runs, it checks your JSON against the template, fills the placeholders, applies the requested styles, and stores the finished document. The JSON can come from your own system or from an upstream function such as Extract. Render currently produces `.docx` output. PDF and HTML output aren't supported yet. When to use [#when-to-use] Use a Render function when you need one of these flows: * **Standalone.** You already have the data: invoice line items, a database record, or an API payload. Pass it as JSON and Render produces the document. No extraction step is needed. * **After Extract.** Use Extract to pull structured data out of a document, scan, or photo. Then use Render to place that data into a contract, report, letter, or other template. In both flows, the template controls the layout. Configuration fields [#configuration-fields] Required fields [#required-fields] | Field | Type | Description | | ------------------------------ | ------ | ---------------------------------------------------- | | `functionName` | string | Unique identifier for the function (per environment) | | `type` | string | Must be `"render"` | | `renderConfig.template.base64` | string | The `.docx` template, base64-encoded | Optional fields [#optional-fields] | Field | Type | Default | Description | | ---------------------------- | --------- | ------- | ----------------------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | | `renderConfig.template.name` | string | - | Original filename, shown in the dashboard | When you create a Render function, either pass the template's base64-encoded bytes in `renderConfig.template.base64` or upload the `.docx` in the dashboard. bem validates the template, stores it, and reads the placeholder set and style catalog from the file. You don't provide that contract yourself. The API response returns a single `renderConfig.template` object: the original `name`, a short-lived `downloadURL` you can open to download the stored `.docx`, and the derived contract (`placeholders`, `styleIds`, `tableStyleIds`, `listKinds`). The Render node menu shows the same. The private storage location is never exposed. Example `POST /v3/functions` request: ```json { "functionName": "invoice-render", "type": "render", "renderConfig": { "template": { "name": "invoice-template.docx", "base64": "UEsDBBQABgAIAAAAIQ...base64-encoded .docx..." } } } ``` Template [#template] A template is a `.docx` with two important parts: * **Placeholders** mark where data goes. * **Styles** define the formatting your data can reference. The data that fills the template uses the [primitives](#primitives) below. Its keys must match the template placeholders exactly. A `.docx` is a ZIP package of XML files. The document body, styles, numbering rules, images, and relationships live in separate files inside that package. For Render, the important files are usually `word/document.xml` for placeholders, `word/styles.xml` for style definitions, and `word/numbering.xml` for numbered and bulleted list definitions. Placeholders [#placeholders] Templates support two placeholder forms: * `{{ KEY }}` fills inline text. The matching value must be a string, and Render inserts it where the placeholder appears. * `{{p KEY }}` fills a block. The matching value must be an ordered array of [primitives](#primitives), and Render replaces the placeholder paragraph with those blocks. These are the only supported forms. Render doesn't support filters, loops, or other template expressions. Each key must be unique, so the same key can't appear in both inline and block form. A template is rejected at upload if it uses an unsupported placeholder form or has no placeholders. Match data keys to placeholders [#match-data-keys-to-placeholders] Your data keys and placeholder keys must match exactly. Every placeholder needs a data key, and every data key needs a placeholder. If either side has an extra key, the render fails. Render doesn't partially fill a template. A field set to `null` counts as **missing**. If the template references a key and the data sends `null`, Render rejects the call with a missing-key error. Send a value for every placeholder. For optional text with no content, send an empty string `""` instead of `null`. Styles [#styles] The template defines the paragraph styles and table styles Render can use. Your data references each style by its **style ID**, such as `Heading1` or `TableNormal`. The style ID is the canonical value stored inside the `.docx`; it isn't always the same as the display name shown in Word. Every style ID in your data must exist in the template. If the data names a style the template doesn't define, Render fails before it produces output. When Extract feeds Render, a useful pattern is to list the allowed template styles as enums in your Extract schema. Add a short description to each enum so the model has clear targets and only emits styles the template defines. After uploading the template, use the **Defined styles** list in the Render node menu to find the exact IDs. ```json { "$defs": { "paragraphStyleIds": { "type": "string", "enum": ["Heading1"], "description": "Paragraph style IDs defined in the template. Use Heading1 for section titles." }, "tableStyleIds": { "type": "string", "enum": ["TableGrid"], "description": "Table style IDs defined in the template. Use TableGrid for extracted tables." } } } ``` A paragraph without a style ID inherits the template's default paragraph style, usually `Normal`. Set a style ID only when you want to override that default. List numbering [#list-numbering] To render a multi-level list, for example `1.1` or `1.1.1`, the template must include a numbering definition for each list `kind` your data uses: `decimal` or `bullet`. A numbering definition is the hidden `.docx` rule that tells the renderer how list levels should look and indent. If the template has no matching numbering definition, the render fails. To add numbering, create a numbered list and a bulleted list once in the document body, then save or export the file as `.docx`. The list may look like ordinary editor formatting, but the editor writes the actual rules into `word/numbering.xml` inside the `.docx` package. After upload, the Render node menu shows which definitions the template contains. Reserved underscore prefix [#reserved-underscore-prefix] Placeholder keys can't start with `_`. That prefix is reserved for the platform. A template with a placeholder like `{{ _notes }}` is rejected at upload, and the error names the key. The same convention governs call metadata: keys you attach under `metadata` that start with `_` are stripped before they reach the output. See [`_metadata` echoes back what you attached to the call](/guide/reading-workflow-call-outputs#_metadata-echoes-back-what-you-attached-to-the-call). Primitives [#primitives] Render block data is built from four primitives: `paragraph`, `table`, `image`, and `list`. Each primitive is wrapped in a key with the same name as its kind. Your code, or an Extract schema, composes these primitives into the block values that fill `{{p KEY }}` placeholders. Render validates every primitive. Anything outside the schema is rejected. A `{{p KEY }}` placeholder expects an array of primitives. Each element in the array is one block to render. Render writes those blocks in the same order they appear in the array. For example, this value for `body` renders one paragraph: ```json [{ "paragraph": { "text": "Total due on receipt.", "styleId": "Normal" } }] ``` This value renders a paragraph, then a list, then a table: ```json [ { "paragraph": { "text": "Summary", "styleId": "Heading1" } }, { "list": { "kind": "decimal", "items": [ { "contents": [{ "paragraph": { "text": "Review the order." } }] } ] } }, { "table": { "rows": [ ["Item", "Qty"], ["Widget", "3"] ], "styleId": "TableGrid" } } ] ``` The array must contain at least one primitive. `paragraph` [#paragraph] A paragraph inserts one paragraph of text. ```json { "paragraph": { "text": "Total due on receipt.", "styleId": "Normal" } } ``` | Field | Type | Required | Description | | --------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | | `text` | string | Yes | Text for the paragraph | | `styleId` | string \| null | No | A paragraph style ID defined in the template. Null or absent inherits the template default (see [Styles](#styles)). | `table` [#table] A table is a list of rows, and each row is a list of scalar cells. The data doesn't carry a separate header row; the table style controls the visual distinction. Render stringifies each cell, and `null` becomes an empty cell. ```json { "table": { "rows": [ ["Item", "Qty"], ["Widget", "3"] ], "styleId": "TableGrid" } } ``` | Field | Type | Required | Description | | --------- | --------------- | -------- | ------------------------------------------------------------------ | | `rows` | array of arrays | Yes | At least one row. Each cell is a string, number, boolean, or null. | | `styleId` | string | **Yes** | A table style ID defined in the template. There is no fallback. | `table.styleId` is required. A table that omits it fails validation. `image` [#image] An image comes from the upstream source document. `source` is a 1-based positional reference in the form `image_N`. Render walks the source document in order and resolves `image_N` to the N-th image's bytes. ```json { "image": { "source": "image_1", "alt": "Signed page", "caption": "Exhibit A" } } ``` | Field | Type | Required | Description | | --------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------- | | `source` | string | Yes | A reference of the form `image_N`, where `N` is 1-based. `image_1` is the first image in document order. | | `alt` | string \| null | No | Alt text for accessibility | | `caption` | string \| null | No | Caption rendered below the image | `list` [#list] A list contains numbered or bulleted entries. `kind` selects the numbering style, and `items` holds the entries. Render starts every numbered list at 1 and keeps counters independent across lists. ```json { "list": { "kind": "decimal", "items": [ { "contents": [{ "paragraph": { "text": "First clause." } }] }, { "contents": [{ "paragraph": { "text": "Second clause." } }] } ] } } ``` | Field | Type | Required | Description | | ------- | ----- | -------- | ----------------------------------------------------------- | | `kind` | enum | Yes | One of `"decimal"` (1., 1.1, 1.1.1) or `"bullet"` | | `items` | array | Yes | The list entries, each an [`item`](#the-item-sub-primitive) | A list requires the template to define numbering for its `kind`; see [List numbering](#list-numbering). The `item` sub-primitive [#the-item-sub-primitive] A list is built from items. An `item` is one list entry. It has a required `contents` array with paragraphs, tables, or images in document order. It can also have an optional `items` array for nested children one level deeper. The first paragraph in `contents` carries the list marker. Later primitives render under the same entry with no marker. If an item's `contents` holds only a table or only an image, that block renders without a marker and still keeps its place in the list. Use an item's `items` array for nesting. Don't put a `list` inside `contents`. Each level of `items` becomes one deeper indent level. ```json { "list": { "kind": "decimal", "items": [ { "contents": [{ "paragraph": { "text": "Prepare the sample." } }], "items": [ { "contents": [{ "paragraph": { "text": "Label the container." } }], "items": [ { "contents": [ { "paragraph": { "text": "Record the lot number." } } ] } ] }, { "contents": [{ "paragraph": { "text": "Weigh the sample." } }] } ] }, { "contents": [{ "paragraph": { "text": "Submit for analysis." } }] } ] } } ``` This renders as a three-level numbered list:
1\. Prepare the sample.
1.1. Label the container.
1.1.1. Record the lot number.
1.2. Weigh the sample.
2\. Submit for analysis.
Valid block examples [#valid-block-examples] These examples are valid render inputs for a template whose only placeholder is `{{p body }}`. If your template also has `{{ title }}` or other placeholders, include those keys too. A paragraph followed by a table: ```json { "body": [ { "paragraph": { "text": "Invoice summary" } }, { "table": { "rows": [ ["Item", "Qty"], ["Widget", "3"] ], "styleId": "TableGrid" } } ] } ``` A numbered list with one nested level: ```json { "body": [ { "list": { "kind": "decimal", "items": [ { "contents": [{ "paragraph": { "text": "Prepare the sample." } }], "items": [ { "contents": [ { "paragraph": { "text": "Label the container." } } ] } ] }, { "contents": [{ "paragraph": { "text": "Submit for analysis." } }] } ] } } ] } ``` A mixed block with an image from an upstream Extract document: ```json { "body": [ { "paragraph": { "text": "Evidence", "styleId": "Heading1" } }, { "image": { "source": "image_1", "alt": "Signed page", "caption": "Exhibit A" } }, { "paragraph": { "text": "The signed page is attached above." } } ] } ``` Extract to Render workflow [#extract-to-render-workflow] Extract and Render are designed to chain. The end-to-end path: 1. A source document goes into an **Extract** function. 2. Extract produces structured JSON shaped to the render primitives, with keys matching the template's placeholders. 3. **Render** lays that JSON into the template and stores the finished `.docx`. 4. A downstream **Send** node delivers the document. The Extract schema has a large effect on the finished document. Two practices matter most. **Decompose nested structure into explicit levels.** When the output needs nested content, such as multi-level lists or numbered sub-procedures, define each level as its own named schema step. Avoid a single recursive definition. A recursive `$ref` that points back at itself makes every level look identical, so the model often flattens the output. Explicit levels, such as section, step, and sub-step, give the model a clear structure to fill. **Write strong field descriptions.** Precise descriptions are extremely helpful. Describe each field, explain where the content should come from, and name the styles it may emit. Better descriptions give Extract clearer targets, which gives Render cleaner input. Where images come from [#where-images-come-from] There is no image upload field in Render. In an Extract to Render chain, images come from the **same document Extract processed**. The original document is carried to Render through the call lineage. An image's `source` is a positional reference. `image_N` means the N-th image in that original document, in document order. This gives two render shapes: * **Shape A: workflow.** An upstream document feeds Render through the chain. Image primitives resolve against that document. * **Shape B: direct call, no upstream.** No source document is available, so any `image` primitive fails with a `render_image_unresolved` error. A direct render supports `paragraph`, `table`, and `list`. The `image` primitive needs an upstream document. Worked example: Extract → Render -> Send [#worked-example-extract--render---send] This example renders a short quality-procedure document with a heading, a multi-level numbered list, a table, and an image in one block placeholder. It then delivers the finished `.docx` with Send. The template [#the-template] Create a `.docx` with this body:
{"{{ title }}"}
{"{{p body }}"}
`{{ title }}` is a string placeholder. `{{p body }}` is a block placeholder that takes a sequence of primitives. The template also defines the `Heading1` paragraph style, the `TableGrid` table style, and a `decimal` numbering definition. The Extract output schema [#the-extract-output-schema] The Extract `OutputSchema` emits exactly the keys `title` and `body`. `title` is a string. `body` is the array of primitives that fills `{{p body }}`. This schema is complete enough to copy into an Extract function and adapt. It defines the primitive shapes, the allowed style IDs, and a finite four-level list structure that avoids recursive `$ref` loops. ```json { "type": "object", "additionalProperties": false, "description": "Extract a short procedure document into a Render-ready JSON object. Preserve the document's meaning and order. Use plain text, not markdown. Emit every field required by the schema.", "required": ["title", "body"], "properties": { "title": { "type": "string", "description": "The document title, shown in the inline {{ title }} placeholder." }, "body": { "type": "array", "minItems": 1, "description": "The document body, rendered into the {{p body }} block as an ordered sequence of primitives.", "items": { "$ref": "#/$defs/primitive" } } }, "$defs": { "paragraphStyleIds": { "type": "string", "enum": ["Heading1"], "description": "Paragraph style IDs defined in the template. Use Heading1 for major headings." }, "tableStyleIds": { "type": "string", "enum": ["TableGrid"], "description": "Table style IDs defined in the template. Use TableGrid for tables." }, "paragraph": { "type": "object", "description": "One paragraph of plain text.", "properties": { "paragraph": { "type": "object", "properties": { "text": { "type": "string", "description": "The paragraph text. Strip markdown control syntax and keep the prose itself." }, "styleId": { "description": "A paragraph style ID defined in the template. Use Heading1 only for headings.", "anyOf": [ { "type": "null" }, { "$ref": "#/$defs/paragraphStyleIds" } ] } }, "required": ["text"] } }, "required": ["paragraph"] }, "table": { "type": "object", "description": "One table. The first row can be a header row. Drop empty padding rows.", "properties": { "table": { "type": "object", "properties": { "rows": { "type": "array", "minItems": 1, "items": { "type": "array", "minItems": 1, "items": { "anyOf": [ { "type": "string" }, { "type": "number" }, { "type": "boolean" }, { "type": "null" } ] } } }, "styleId": { "$ref": "#/$defs/tableStyleIds" } }, "required": ["rows", "styleId"] } }, "required": ["table"] }, "image": { "type": "object", "description": "One image from the upstream source document.", "properties": { "image": { "type": "object", "properties": { "source": { "type": "string", "pattern": "^image_[1-9][0-9]*$", "description": "A positional image reference, such as image_1." }, "alt": { "type": ["string", "null"], "description": "Alt text for accessibility." }, "caption": { "type": ["string", "null"], "description": "Caption rendered below the image." } }, "required": ["source"] } }, "required": ["image"] }, "nonListPrimitive": { "description": "A primitive that can appear inside a list item.", "anyOf": [ { "$ref": "#/$defs/paragraph" }, { "$ref": "#/$defs/table" }, { "$ref": "#/$defs/image" } ] }, "level3Item": { "type": "object", "description": "Fourth/deepest list level. Keep any deeper source text at this level instead of dropping it.", "properties": { "contents": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/nonListPrimitive" } } }, "required": ["contents"] }, "level2Item": { "type": "object", "description": "Third list level. Put child items in items.", "properties": { "contents": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/nonListPrimitive" } }, "items": { "type": "array", "items": { "$ref": "#/$defs/level3Item" } } }, "required": ["contents"] }, "level1Item": { "type": "object", "description": "Second list level. Put child items in items.", "properties": { "contents": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/nonListPrimitive" } }, "items": { "type": "array", "items": { "$ref": "#/$defs/level2Item" } } }, "required": ["contents"] }, "level0Item": { "type": "object", "description": "Top-level list item. Put nested list content in items, not in contents.", "properties": { "contents": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/nonListPrimitive" } }, "items": { "type": "array", "items": { "$ref": "#/$defs/level1Item" } } }, "required": ["contents"] }, "list": { "type": "object", "description": "One numbered or bulleted list.", "properties": { "list": { "type": "object", "properties": { "kind": { "type": "string", "enum": ["decimal", "bullet"], "description": "Use decimal for numbered lists and bullet for bulleted lists." }, "items": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/level0Item" } } }, "required": ["kind", "items"] } }, "required": ["list"] }, "primitive": { "description": "One Render primitive.", "anyOf": [ { "$ref": "#/$defs/paragraph" }, { "$ref": "#/$defs/table" }, { "$ref": "#/$defs/image" }, { "$ref": "#/$defs/list" } ] } } } ``` The JSON the run produces [#the-json-the-run-produces] When the workflow runs, Extract emits JSON shaped by that schema. ```json { "title": "Sample Preparation", "body": [ { "paragraph": { "text": "Sample Preparation", "styleId": "Heading1" } }, { "list": { "kind": "decimal", "items": [ { "contents": [{ "paragraph": { "text": "Prepare the sample." } }], "items": [ { "contents": [ { "paragraph": { "text": "Label the container." } } ] } ] }, { "contents": [{ "paragraph": { "text": "Submit for analysis." } }] } ] } }, { "table": { "rows": [ ["Step", "Owner"], ["Prepare", "Lab"], ["Submit", "QA"] ], "styleId": "TableGrid" } }, { "image": { "source": "image_1", "caption": "Filled sample form" } } ] } ``` The produced `.docx` [#the-produced-docx] Render fills `{{ title }}` with `Sample Preparation`, then replaces `{{p body }}` with the four primitives in order. This preview shows the document structure; the exact fonts, spacing, table borders, and image rendering come from the uploaded `.docx` template.

Sample Preparation

1\. Prepare the sample.
1.1. Label the container.
2\. Submit for analysis.
Step Owner
Prepare Lab
Submit QA
Embedded source image: image\_1
Filled sample form
Output delivery [#output-delivery] Deliver the rendered `.docx` with a downstream [Send](/guide/function-types/send) node. Add Send after Render and point it at a webhook, S3 bucket, or Google Drive folder. Send fetches the stored document, presigns it, and delivers `{"s3URL": ""}`. Render output doesn't have a subscription type. The output is a binary `.docx`, not JSON, so Send is the delivery path. For webhook destinations, the Send output includes the delivery status, destination type, delivered render event, and webhook response. ```json { "deliveryStatus": "success", "destinationType": "webhook", "deliveredContent": { "eventID": "evt_example_render_delivery_001", "createdAt": "2026-01-15T18:30:00.000000Z", "referenceID": "example-render-workflow", "eventType": "render", "functionCallID": "fc_example_render_001", "functionID": "fn_example_render_template", "functionName": "render-quality-procedure", "functionVersionNum": 1, "callID": "call_example_workflow_001", "callType": "workflow", "workflowID": "wf_example_extract_render_send", "workflowName": "extract-render-send-example", "workflowVersionNum": 1, "metadata": { "durationFunctionToEventSeconds": 2.5 }, "functionCallTryNumber": 1, "outputDownloadURL": "https://files.example.com/render-outputs/rendered-quality-procedure.docx?expires=1800&signature=example", "validationSeconds": 0.02, "docxRenderSeconds": 0.08 }, "webhookOutput": { "httpStatusCode": 200, "httpResponseBody": "OK" } } ``` Errors [#errors] A failed render appears as an error event on the workflow call with a `kind` and a `msg`. The `msg` names the offending key, type, or style. For example: `data carries keys the template does not reference: ['bar']`. The error kinds are: | Kind | Cause | Fix | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `render_validation` | The data does not satisfy the contract: a key mismatch, wrong primitive type, missing or undefined style, or template authoring error. The `msg` names the specific reason. | Compare the `msg` against the **Fields** and **Defined styles** lists in the Render node menu. Add or remove keys, fix the primitive shape, or use a defined style ID. | | `render_image_unresolved` | An `image` primitive is present, but no source document is available. | Confirm a document-bearing node sits upstream so the platform can recover the source by lineage, or remove the image primitives. See [Where images come from](#where-images-come-from). | | `render_exception` | An unexpected server-side failure during render execution. Not a contract error in your data or template. | Retry the call. If it persists, contact support with the call ID and the error `msg`. | All three errors are terminal. Render doesn't retry. Fix the cause and run the call again. Related [#related] Author the upstream schema that feeds a Render node. Deliver the rendered .docx to a webhook, S3, or Google Drive. How bem reports failures across all function types. Wire Extract, Render, and Send nodes into a graph. # Route Functions (/guide/function-types/route) > For the complete documentation index, see [llms.txt](/llms.txt). **Legacy type.** V3 replaces `route` with [`classify`](/guide/function-types/classify), and `routes` with `classifications`. Existing `route` functions remain readable and callable, but new functions should be created as `classify`. This page is retained for reference. Route functions classify incoming data and direct it to different processing paths. They use AI to analyze content and determine which downstream function should handle it. When to Use [#when-to-use] Use a Route function when you need to: * Classify documents by type (e.g. discerning between invoices, receipts, and contracts) * Direct different document types to specialized transform or analyze functions * Build branching workflows * Handle mixed document batches Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | -------------- | ------ | ---------------------------------------------------- | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"route"` | | `description` | string | Description of the routing logic and expected inputs | | `routes` | array | Array of route definitions | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------- | --------- | ------- | --------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | Route Configuration [#route-configuration] Each route in the `routes` array has these fields: | Field | Type | Required | Description | | ----------------- | ------- | ----------- | ----------------------------------------- | | `name` | string | Yes | Unique name for this route | | `description` | string | No | Description of when to use this route | | `functionName` | string | Conditional | Target function name | | `isErrorFallback` | boolean | No | If `true`, handles unclassified documents | Example [#example] ```json { "functionName": "document-router", "type": "route", "displayName": "Document Classification Router", "description": "Classifies incoming documents and routes them to the appropriate extraction function. Handles invoices, receipts, purchase orders, and contracts.", "routes": [ { "name": "invoices", "description": "Route for invoice documents including bills and payment requests", "functionName": "invoice-extractor" }, { "name": "receipts", "description": "Route for receipts from purchases and transactions", "functionName": "receipt-extractor" }, { "name": "purchase-orders", "description": "Route for purchase order documents", "functionName": "po-extractor" }, { "name": "contracts", "description": "Route for legal contracts and agreements", "functionName": "contract-extractor" }, { "name": "unknown", "description": "Fallback for documents that don't match other categories", "functionName": "generic-extractor", "isErrorFallback": true } ], "tags": ["classification", "workflow"] } ``` Writing Effective Descriptions [#writing-effective-descriptions] The `description` field is critical for accurate routing. Include: 1. **Clear criteria** - What makes a document belong to this route? 2. **Examples** - Specific document types or characteristics 3. **Distinguishing features** - How to differentiate from similar routes Good Description Example [#good-description-example] ```json { "name": "invoices", "description": "Route for invoice documents. These typically include: vendor information, invoice number, line items with quantities and prices, payment terms, and a total amount due. Includes bills, payment requests, and statements with amounts owed." } ``` Weak Description Example [#weak-description-example] ```json { "name": "invoices", "description": "For invoices" } ``` Error Fallback [#error-fallback] Always include a fallback route with `isErrorFallback: true` to handle documents that don't match other categories. This prevents unclassified documents from failing. Email Integration [#email-integration] Route functions receive an email address. When documents are sent to this address, they're automatically classified and routed to the appropriate downstream function. Related [#related] API reference for creating functions Target for route destinations # Send Functions (/guide/function-types/send) > For the complete documentation index, see [llms.txt](/llms.txt). Send functions deliver the output of an upstream workflow node to an external destination — a webhook URL, an S3 bucket, or a Google Drive folder. They're the bem-native way to wire structured data out of a workflow to your systems, without managing a separate subscription resource or polling for events. A Send function is a node in your workflow graph like any other function. Whatever event flows into it from the previous node is delivered to the destination configured on the Send function. You can place Send nodes anywhere in a workflow — mid-graph (deliver an intermediate extract before continuing), behind a Classify branch (deliver invoices to one webhook and receipts to another), or at the terminus of a sequential pipeline. When to Use [#when-to-use] Use a Send function when you need to: * Push successful extract results to a downstream API the moment they're produced * Mirror every transformation into an S3 bucket for an analytics pipeline or warehouse * Fork delivery: send the same payload to multiple destinations by adding multiple Send nodes * Branch delivery: route different document types to different destinations using a Classify upstream * Reshape payloads for a partner-specific contract by chaining a Payload Shaping function before the Send If your goal is "deliver every event of function X to one URL" without a workflow context, a [subscription](/guide/webhooks) on that function is simpler. Send functions become the right tool the moment you need the destination to depend on the workflow shape — branching, fan-out, mid-graph delivery, or reshaped payloads. Configuration Fields [#configuration-fields] Common fields [#common-fields] | Field | Type | Required | Description | | ----------------- | --------- | -------- | ---------------------------------------------------- | | `functionName` | string | Yes | Unique identifier for the function (per environment) | | `type` | string | Yes | Must be `"send"` | | `destinationType` | enum | Yes | One of `"webhook"`, `"s3"`, or `"google_drive"` | | `displayName` | string | No | Human-readable display name | | `tags` | string\[] | No | Tags for organization | The destination-specific fields below depend on `destinationType`. Webhook destination [#webhook-destination] | Field | Type | Required | Description | | ----------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------- | | `webhookUrl` | string | Yes | HTTPS URL bem POSTs the payload to | | `webhookSigningEnabled` | boolean | No | Sign deliveries with a `bem-signature` HMAC-SHA256 header. Defaults to `true` for new Send functions. | S3 destination [#s3-destination] | Field | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `s3Bucket` | string | Yes | Name of the S3 bucket bem writes to. The bucket must be in your AWS account and have a bem-issued IAM role configured for write access — contact support to enable. | | `s3Prefix` | string | No | Key prefix (folder path) under which payloads are written. The final object key combines the prefix with a bem-generated unique key per delivery. | Google Drive destination [#google-drive-destination] The `google_drive` destination is managed through Paragon OAuth — set up the connection from the bem dashboard before creating the function. The function carries a single `googleDriveFolderId` field once configured. Examples [#examples] Deliver invoice extracts to a webhook [#deliver-invoice-extracts-to-a-webhook] ```json { "functionName": "invoice-webhook", "type": "send", "displayName": "Send invoices to AP", "destinationType": "webhook", "webhookUrl": "https://your-app.example.com/webhooks/bem/invoices", "webhookSigningEnabled": true, "tags": ["finance", "ap"] } ``` Every payload arriving at this node is POSTed to the URL above. Verifying the `bem-signature` header is identical to verifying signatures on subscription webhooks — see [Webhooks](/guide/webhooks#step-3-build-the-receiver) for copy-pasteable verification code in Node, Python, and Go. Mirror every output to S3 [#mirror-every-output-to-s3] ```json { "functionName": "archive-to-s3", "type": "send", "displayName": "Archive transformations", "destinationType": "s3", "s3Bucket": "acme-bem-archive", "s3Prefix": "transformations/2026/", "tags": ["archive"] } ``` Each delivery writes one object to `s3://acme-bem-archive/transformations/2026/` containing the full event payload as JSON. What gets delivered [#what-gets-delivered] The body of every Send delivery is the **full protocol event JSON** for the upstream event — the same shape you'd retrieve from `GET /v3/outputs/{eventID}` or that a subscription webhook would deliver. That includes: * The event metadata (`eventID`, `eventType`, `callID`, `functionCallID`, `workflowName`, etc.) * The polymorphic payload for the event type (`transformation` for extract/transform/analyze/join, `routes` for classify, etc.) * A `referenceID` if one was attached to the originating call For ad-hoc calls that take a JSON file input, the Send body is the raw input JSON. For ad-hoc calls with a binary file input, the body contains `{"s3URL": ""}` so the receiver can fetch the file out-of-band. Wiring a Send into a workflow [#wiring-a-send-into-a-workflow] Send functions are workflow nodes. Add them to `nodes` and point an edge at them from whichever upstream node should feed them: ```json { "name": "invoice-pipeline", "mainNodeName": "invoice-extractor", "nodes": [ { "name": "invoice-extractor", "function": { "name": "invoice-extractor" } }, { "name": "send-to-ap", "function": { "name": "invoice-webhook" } }, { "name": "archive-to-s3", "function": { "name": "archive-to-s3" } } ], "edges": [ { "sourceNodeName": "invoice-extractor", "destinationNodeName": "send-to-ap" }, { "sourceNodeName": "invoice-extractor", "destinationNodeName": "archive-to-s3" } ] } ``` This workflow extracts an invoice once and fans the result out to both destinations in parallel. Delivery semantics [#delivery-semantics] Each Send invocation produces a `send` event with a `deliveryStatus` of `success` or `skip`: | Status | What it means | | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `success` | The destination accepted the payload. For webhooks: 2xx response within the timeout. For S3: `PutObject` succeeded. | | `skip` | The Send node was reached but no payload was delivered (for example, an upstream error event with no successful payload to forward). | The full per-destination outcome is captured on the event: * **Webhooks** carry `webhookOutput.httpStatusCode` and `webhookOutput.httpResponseBody` — useful for debugging non-2xx responses. * **S3** carries `s3Output.bucketName` and `s3Output.key` — the exact location the object was written to. A failed Send delivery surfaces as an error event on the call, the same way any other function failure does. See [Errors and status codes](/guide/errors) for how to read `call.errors[]`. Send vs Subscriptions [#send-vs-subscriptions] Both deliver event payloads to a webhook URL. They serve different shapes of the problem: | | Send function | Subscription | | --------------------------- | -------------------------------------------------- | --------------------------------------------------------- | | Where it lives | A node inside a workflow | A standalone resource that listens to a function | | Granularity | Per workflow path; fan-out via multiple nodes | Per source function; delivery is automatic for all events | | Mid-workflow delivery | Yes | No | | Branching by classification | Yes (place Send behind a Classify edge) | No (subscribe-by-function only) | | Payload reshaping | Yes (chain a Payload Shaping node before the Send) | No (delivers the event as-is) | | Setup cost | Workflow node configuration | Single API call to create the subscription | If you're standing up your first webhook, start with a [subscription](/guide/webhooks). Add a Send function when the workflow shape — branching, fan-out, mid-graph delivery, or per-destination payloads — drives the requirement. Related [#related] End-to-end webhook setup, signature verification, and best practices. How to wire Send nodes into the rest of your graph. API reference for creating Send functions. What a failed Send delivery looks like in `call.errors[]`. # Split Functions (/guide/function-types/split) > For the complete documentation index, see [llms.txt](/llms.txt). Split functions break multi-page documents into smaller pieces for individual processing. They're essential for handling documents that contain multiple logical units (e.g., a PDF with multiple invoices). When to Use [#when-to-use] Use a Split function when you need to: * Process multi-page PDFs where multiple documents are bundled together * Handle batched documents in a single file * Classify and separate different document types within one file * Process each section of a document independently Split Types [#split-types] bem supports two split types: | Type | Description | Use Case | | --------------- | ------------------------------------------------- | ----------------------------------- | | `print_page` | Split by physical page boundaries | Each page is a separate document | | `semantic_page` | Split by content/document boundaries semantically | Multi-document files, mixed content | Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | -------------- | ------ | ------------------------------------------ | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"split"` | | `splitType` | string | Either `"print_page"` or `"semantic_page"` | Optional Fields [#optional-fields] | Field | Type | Description | | ------------------------- | --------- | -------------------------------------- | | `displayName` | string | Human-readable display name | | `tags` | string\[] | Tags for organization | | `printPageSplitConfig` | object | Configuration for print page splits | | `semanticPageSplitConfig` | object | Configuration for semantic page splits | Print Page Split [#print-page-split] Use `print_page` when each physical page should be processed as a separate unit. Configuration [#configuration] ```json { "functionName": "page-splitter", "type": "split", "displayName": "Page-by-Page Splitter", "splitType": "print_page", "printPageSplitConfig": { "nextFunctionName": "invoice-extractor" } } ``` Print Page Config Fields [#print-page-config-fields] | Field | Type | Description | | ------------------ | ------ | ---------------------------------- | | `nextFunctionID` | string | Function ID to process each page | | `nextFunctionName` | string | Function name to process each page | Semantic Page Split [#semantic-page-split] Use `semantic_page` when documents should be split by content boundaries and classified into different types. Configuration [#configuration-1] ```json { "functionName": "document-splitter", "type": "split", "displayName": "Document Type Splitter", "splitType": "semantic_page", "semanticPageSplitConfig": { "itemClasses": [ { "name": "invoice", "description": "Invoice documents with billing information", "nextFunctionName": "invoice-extractor" }, { "name": "receipt", "description": "Receipt documents from transactions", "nextFunctionName": "receipt-extractor" }, { "name": "packing-slip", "description": "Packing slips and shipping documents", "nextFunctionName": "packing-slip-extractor" } ] } } ``` Semantic Page Config Fields [#semantic-page-config-fields] | Field | Type | Description | | ------------- | ----- | ----------------------------------- | | `itemClasses` | array | Array of document class definitions | Item Class Fields [#item-class-fields] | Field | Type | Required | Description | | ------------------ | ------ | ----------- | -------------------------------------- | | `name` | string | Yes | Unique name for this document class | | `description` | string | No | Description to help classify documents | | `nextFunctionID` | string | Conditional | Function ID to process this class | | `nextFunctionName` | string | Conditional | Function name to process this class | Example Workflow [#example-workflow] A common pattern combines Split with Extract functions to structure particular components of a single input: ``` Multi-page PDF (Bill of Lading, Invoice, Rate Confirmation) │ ▼ ┌─────────────┐ │ Split │ │ Function │ └─────────────┘ │ ├──► Page 1-3 ──► Bill of Lading Extract Function │ ├──► Page 4-6 ──► Invoice Extract Function │ └──► Page 7 ──► Rate Confirmation Extract Function ``` Related [#related] API reference for creating functions Target for split destinations # Transform Functions (/guide/function-types/transform) > For the complete documentation index, see [llms.txt](/llms.txt). **Legacy type.** V3 replaces `transform` with the unified [`extract`](/guide/function-types/extract) type. Existing `transform` functions remain readable and callable, but new functions should be created as `extract`. This page is retained for reference. Transform functions are the most common function type in bem. They use semantic and visual analysis to extract structured JSON data from unstructured documents like PDFs, images, emails, and spreadsheets. When to Use [#when-to-use] Use a Transform function when you need to: * Extract specific fields from invoices, receipts, or forms * Convert documents into structured JSON * Parse tabular data from spreadsheets or CSVs * Process email content and attachments Configuration Fields [#configuration-fields] Required Fields [#required-fields] | Field | Type | Description | | ------------------ | ------ | --------------------------------------------- | | `functionName` | string | Unique identifier for the function | | `type` | string | Must be `"transform"` | | `outputSchemaName` | string | Human-readable name for your schema | | `outputSchema` | object | JSON Schema defining the structure to extract | Optional Fields [#optional-fields] | Field | Type | Default | Description | | ------------------------ | --------- | ------- | -------------------------------- | | `displayName` | string | - | Human-readable display name | | `tags` | string\[] | - | Tags for organization | | `tabularChunkingEnabled` | boolean | `false` | Process CSV/Excel in row batches | Output Schema [#output-schema] The `outputSchema` field defines the structure of the data you want to extract, using standard [JSON Schema](https://json-schema.org/) syntax. Best Practices [#best-practices] 1. **Use descriptive field names** - Choose names that clearly indicate what data should be extracted 2. **Add descriptions** - Include descriptions for complex fields to guide the AI 3. **Specify required fields** - Mark essential fields as required in the schema 4. **Use appropriate types** - Use `number` for amounts, `string` for text, `array` for lists Example [#example] ```json { "functionName": "invoice-extractor", "type": "transform", "displayName": "Invoice Data Extractor", "outputSchemaName": "Invoice Schema", "outputSchema": { "type": "object", "required": ["invoiceNumber", "totalAmount", "vendor"], "properties": { "invoiceNumber": { "type": "string", "description": "The unique invoice number" }, "invoiceDate": { "type": "string", "description": "Date of the invoice in ISO 8601 format" }, "totalAmount": { "type": "number", "description": "Total amount due" }, "vendor": { "type": "object", "properties": { "name": { "type": "string", "description": "Vendor company name" }, "address": { "type": "string", "description": "Vendor address" } } }, "lineItems": { "type": "array", "description": "Individual line items on the invoice", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "number" } } } } } }, "tags": ["billing", "finance"] } ``` Tabular Chunking [#tabular-chunking] For large spreadsheets or CSVs, enable `tabularChunkingEnabled` to process data in batches rather than all at once. This improves reliability for documents with many rows. ```json { "functionName": "spreadsheet-processor", "type": "transform", "outputSchemaName": "Row Data", "outputSchema": { "type": "object", "properties": { "productName": { "type": "string" }, "quantity": { "type": "number" }, "price": { "type": "number" } } }, "tabularChunkingEnabled": true } ``` Email Integration [#email-integration] Transform functions automatically receive an email address. Forward emails to this address to process them through the function. The email address is returned in the function response as `emailAddress` (e.g., `eml_xxx@actions.bem.ai`). Related [#related] API reference for creating functions Learn how to build effective output schemas See which file types can be processed Compare with Analyze functions # Authentication (/api/legacy/authentication) > For the complete documentation index, see [llms.txt](/llms.txt). API Base URL [#api-base-url] Unless otherwise specified, all endpoints use `https://api.bem.ai` as their base URL. API Keys [#api-keys] For all requests, you'll need an API key. Pass this in using an `x-api-key` header. Webhook Authentication [#webhook-authentication] To confirm authenticity of webhook requests coming from bem, we provide a `bem-signature` header on every outgoing request to the endpoint specified in your pipeline. The header value includes a timestamp (`t=`) and a signature (`v1=`); these values are comma-separated, and the scheme will be versioned in case of future updates. ```jsx bem-signature: t=1492774577, v1=0734be64d748aa8e8ee9dfe87407665541f2c33f9b0ebf19dfd0dd80f08f504c ``` Signatures are generated using HMAC with SHA-256. The webhook secret for your account can be generated, retrieved, and revoked through our API, and we use that secret to encode the payload into the signature we present in the header. To verify the signature, you must complete the following steps: **Step 1: Extract timestamp and signature from header** [#step-1-extract-timestamp-and-signature-from-header] Split the raw string to grab the respective `t` timestamp and `v1` signature values. **Step 2: Prepare the signed payload string** [#step-2-prepare-the-signed-payload-string] The payload string is created by concatenating: * The timestamp (as a string) * The character `.` * The actual JSON payload (stringified request body) **Step 3: Determine the expected signature** [#step-3-determine-the-expected-signature] Compute an HMAC with the SHA-256 hash function (the string output should be in hex). Use your account's webhook secret as the key, and the signed payload string as the message. **Step 4: Compare the signatures** [#step-4-compare-the-signatures] Compare your computed signature with the signature provided in the header doing a simple string equality check. If the signatures match, you've validated that the request to your webhook endpoint is coming from bem. Pagination [#pagination] Our pagination follows the same conventions as the [Stripe API](https://docs.stripe.com/api/pagination), allowing you to use cursors to page back-and-forth through results. Our API uses cursor-based pagination through `startingAfter` and `endingBefore` parameters. Both parameters accept an existing object ID value and return objects in chronological order. The `endingBefore` parameter returns objects listed before the given object. The `startingAfter` parameter returns objects listed after the given object. These parameters are mutually exclusive. You can use either the `startingAfter` or `endingBefore` parameter, but not both simultaneously. A `limit` parameter can be optionally provided to control the page size and our API defaults to a page size of 50 if a limit is not provided. # Authentication (/api/v3/authentication) > For the complete documentation index, see [llms.txt](/llms.txt). API Base URL [#api-base-url] Unless otherwise specified, all V3 endpoints use `https://api.bem.ai` as their base URL and live under the `/v3/*` path prefix. API Keys [#api-keys] Every request requires an API key, sent as an `x-api-key` header. Generate keys from **Settings → API Keys** in the [bem dashboard](https://app.bem.ai). ```bash curl https://api.bem.ai/v3/workflows \ -H "x-api-key: $BEM_API_KEY" ``` The official SDKs and CLI read `BEM_API_KEY` from the environment by default — see [SDKs](/guide/sdks) and [CLI](/guide/cli). Environments [#environments] Every account is seeded with two environments, `production` and `sandbox`, that isolate your data. Each API key belongs to exactly one of them, and the environment is derived from the key — there is no environment header or query parameter on the API. To move work from sandbox to production you swap the key (typically the `BEM_API_KEY` value); the URLs, bodies, and function names stay the same. The dashboard is a separate surface: it selects an environment via its own cookie-based toggle, independent of your API keys. Webhook signatures [#webhook-signatures] When a signing secret is active on your account, every webhook delivery includes a `bem-signature` header in the format `t={unix_timestamp},v1={hex_hmac_sha256}`. The signature covers `{timestamp}.{raw_request_body}` and can be verified with HMAC-SHA256 using your secret. For end-to-end setup (generating the secret, subscribing a function, and verifying deliveries with copy-pasteable code in Node, Python, and Go) see [Webhooks](/guide/webhooks). The endpoints for managing the secret itself are under [Webhook Signing](/api/v3/webhook-signing/v3-get-webhook-secret). Pagination [#pagination] V3 list endpoints use cursor-based pagination via two mutually exclusive parameters: * `startingAfter` — return the page *after* the given object ID. Use this to step forward. * `endingBefore` — return the page *before* the given object ID. Use this to step backward. The cursor is the ID of an object in the previous page (typically the last for forward paging, the first for backward paging). Pass `limit` (default 50, max 100) to control page size. Conventions match the [Stripe API](https://docs.stripe.com/api/pagination). ```bash # First page curl "https://api.bem.ai/v3/workflows?limit=50" -H "x-api-key: $BEM_API_KEY" # Next page (use the last workflow's ID from the previous page) curl "https://api.bem.ai/v3/workflows?limit=50&startingAfter=wf_abc123" \ -H "x-api-key: $BEM_API_KEY" ``` Error responses [#error-responses] Every non-2xx response carries a body with `message`, an optional `code`, and optional `details`. See [Errors and status codes](/guide/errors) for the full breakdown and retry guidance. # Actions (/api/legacy/actions) > For the complete documentation index, see [llms.txt](/llms.txt). Action operations # Correct a Route action's choice for an event (/api/legacy/actions/update-route-action) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated and may be replaced or removed in future versions of the API. Updates a route event with feedback on the desired router choices. # Call a Workflow or Function (/api/legacy/calls/create-calls) > For the complete documentation index, see [llms.txt](/llms.txt). **Create one or more calls to execute workflows or functions.** This endpoint provides a unified way to invoke either workflows or functions. You can submit a single call or batch multiple calls in one request. Calling a Workflow vs Function [#calling-a-workflow-vs-function] Each call in the request can target either: * **A Workflow**: Specify `workflowID` or `workflowName` to execute a multi-step workflow * **A Function**: Specify `functionID` or `functionName` to call a single function directly Input Options [#input-options] Each call accepts input through the `input` object with two modes: * **Single File** (`singleFile`): Process one file per call * **Batch Files** (`batchFiles`): Process multiple files in a single call, each with optional `itemReferenceID` Response and Tracking [#response-and-tracking] The API returns immediately with call IDs. Processing happens asynchronously. Use the `callReferenceID` field to track calls with your own identifiers. Poll `GET /v2/calls/{callID}` to check status and retrieve results. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/calls/v3-call-workflow) instead. See also [#see-also] * [V3 migration](/guide/v3-migration) # Get a Call (/api/legacy/calls/get-call) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve detailed information about a specific call.** Returns the full call object including: * Current status (`pending`, `running`, `completed`, `failed`) * Timing information (`createdAt`, `finishedAt`) * Associated workflow or function details * Nested function calls (for workflow executions) * Input data and transformation results Call Status [#call-status] | Status | Description | | ----------- | ------------------------------------------ | | `pending` | Call is queued and waiting to be processed | | `running` | Call is currently being executed | | `completed` | Call finished successfully | | `failed` | Call encountered an error during execution | Polling for Results [#polling-for-results] For asynchronous calls, poll this endpoint to check when processing completes. The `finishedAt` timestamp will be populated once the call reaches a terminal state. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/calls/v3-get-call) instead. See also [#see-also] * [V3 migration](/guide/v3-migration) # Calls (/api/legacy/calls) > For the complete documentation index, see [llms.txt](/llms.txt). The Calls API provides a unified interface for invoking both **Workflows** and **Functions**. Use this API when you want to: * Execute a complete workflow that chains multiple functions together * Call a single function directly without defining a workflow * Submit batch requests with multiple inputs in a single API call * Track execution status using call reference IDs **Key Difference**: Calls vs Function Calls * **Calls API** (`/v2/calls`): High-level API for invoking workflows or functions by name/ID. Supports batch processing and workflow orchestration. * **Function Calls API** (`/v2/functions/{functionName}/call`): Direct function invocation with function-type-specific arguments. Better for granular control over individual function calls. See also [#see-also] * [V3 migration](/guide/v3-migration) # List Calls (/api/legacy/calls/list-calls) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a list of workflow and function calls with filtering options.** Use this endpoint to: * Monitor the status of submitted calls * Search for calls by reference ID, workflow, or function * Paginate through large result sets Filtering Options [#filtering-options] Filter calls using any combination of: * `callIDs`: Specific call identifiers * `referenceIDs`: Your custom reference IDs (set via `callReferenceID`) * `callTypes`: Filter by call type * `workflowIDs` / `workflowNames`: Filter by workflow * `functionIDs` / `functionNames`: Filter by function Pagination [#pagination] Results are paginated with a default limit of 50. Use `startingAfter` and `endingBefore` cursors for efficient pagination through large result sets. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/calls/v3-list-calls) instead. See also [#see-also] * [V3 migration](/guide/v3-migration) # Add new items to a Collection (/api/legacy/collections/add-collection-items) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Count tokens for texts (/api/legacy/collections/count-tokens) > For the complete documentation index, see [llms.txt](/llms.txt). Count the number of tokens in the provided texts using the BGE M3 tokenizer. This is useful for checking if texts will fit within the embedding model's token limit (8,192 tokens per text) before sending them for embedding. See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Create a Collection (/api/legacy/collections/create-collection) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Delete an item from a Collection (/api/legacy/collections/delete-collection-item) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Delete a Collection (/api/legacy/collections/delete-collection) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Get a Collection (/api/legacy/collections/get-collection) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Collections (/api/legacy/collections) > For the complete documentation index, see [llms.txt](/llms.txt). Collection operations See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # List Collections (/api/legacy/collections/list-collections) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Update existing items in a Collection (/api/legacy/collections/update-collection-items) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) # Create a Connector (/api/legacy/connectors/create-connector) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated. Use `POST /v3/workflows` with inline `connectors` instead. See `/v3/workflows` for the replacement surface. See also [#see-also] * [V3 migration](/guide/v3-migration) # Delete a Connector (/api/legacy/connectors/delete-connector) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated. Use `PATCH /v3/workflows/{workflow_name}` with the desired `connectors` array to remove a connector by omitting its `connectorID`. See also [#see-also] * [V3 migration](/guide/v3-migration) # Connectors (/api/legacy/connectors) > For the complete documentation index, see [llms.txt](/llms.txt). Connector operations See also [#see-also] * [V3 migration](/guide/v3-migration) # List Connectors (/api/legacy/connectors/list-connectors) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated. Use `GET /v3/workflows/{workflow_name}`; connectors are returned inline on the workflow. See also [#see-also] * [V3 migration](/guide/v3-migration) # Get Dashboard (/api/legacy/dashboard/get-dashboard) > For the complete documentation index, see [llms.txt](/llms.txt). Returns aggregate statistics and daily timeseries data for the authenticated account's environment. Statistics include function call counts broken down by type (as a dynamic map keyed by function type), error counts, correction counts, estimated extracted fields, and per-type latency statistics (avg, p50, p90, p99). The timeseries provides daily granularity for volume, errors, and latency (all broken down by function type) over the selected period. If no date range is specified, defaults to the last 90 days. # Dashboard (/api/legacy/dashboard) > For the complete documentation index, see [llms.txt](/llms.txt). Retrieve aggregate analytics and daily timeseries data for your account. The Dashboard endpoint provides a snapshot of your environment's usage including function call volumes (broken down by type), error and correction counts, estimated extracted fields, and end-to-end latency percentiles. All data is scoped to the authenticated account and environment. # Get an Event by ID (/api/legacy/events/get-event) > For the complete documentation index, see [llms.txt](/llms.txt). Get a single event by its `eventID`. To fetch events for a particular call, use [`listEvents`](/api/events/list-events) with the `callIDs` query parameter. See also [#see-also] * [System overview](/guide/system-overview) # Events (/api/legacy/events) > For the complete documentation index, see [llms.txt](/llms.txt). Event operations See also [#see-also] * [System overview](/guide/system-overview) # List Events (/api/legacy/events/list-events) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [System overview](/guide/system-overview) # Receive event webhook (/api/legacy/events/receive-event-webhook) > For the complete documentation index, see [llms.txt](/llms.txt). Webhooks can be configured to send events to your desired URL. See also [#see-also] * [System overview](/guide/system-overview) # Apply Baseline Corrections to Regression Transformations (/api/legacy/function-accuracy/apply-baseline-corrections) > For the complete documentation index, see [llms.txt](/llms.txt). **Applies corrected JSON from baseline transformations to regression transformations** This endpoint copies user-corrected data from baseline function version transformations to their corresponding regression test transformations. This is useful for: * Propagating ground truth corrections to regression test data * Ensuring consistent evaluation data across function versions * Preparing corrected datasets for evaluation after regression testing How It Works [#how-it-works] 1. **Finds Regression Transformations**: Locates transformations created during regression testing 2. **Matches Baseline Corrections**: Finds corresponding baseline transformations with corrected JSON 3. **Applies Corrections**: Copies the corrected JSON to regression transformations 4. **Returns Summary**: Reports applied, skipped, and error counts with transformation IDs See also [#see-also] * [System overview](/guide/system-overview) # Run Function Regression Testing (/api/legacy/function-accuracy/function-regression) > For the complete documentation index, see [llms.txt](/llms.txt). **Initiates regression testing between function versions using historical transformation data** This endpoint creates function calls to test historical data with the latest function version, allowing you to measure performance improvements or regressions against ground truth data. How It Works [#how-it-works] 1. **Selects Historical Data**: Retrieves transformations with user corrections (ground truth) 2. **Creates Function Calls**: Generates new function calls using the latest function version 3. **Returns Call IDs**: Provides immediate response with call tracking 4. **Async Processing**: Calls execute in background using standard infrastructure Checking Results [#checking-results] Use standard call endpoints to monitor progress and retrieve results: * `GET /v2/calls/{callID}` - Check individual call status Best Practices [#best-practices] * Start with smaller sample sizes (10-50) for initial testing * Use `onlyCorrectedData: true` to ensure quality ground truth comparisons * Monitor call completion using webhooks or polling * Compare results manually using transformation endpoints Data Requirements [#data-requirements] * Function must have historical transformations with `correctedJSON` (user corrections) * Baseline version must exist and have associated transformation data * Function must be currently active and callable See also [#see-also] * [System overview](/guide/system-overview) # Function Review (/api/legacy/function-accuracy/function-review) > For the complete documentation index, see [llms.txt](/llms.txt). Analyzes function performance and estimate human review requirements. Calculates sample sizes needed to achieve target accuracy with statistical confidence, finds optimal confidence thresholds within a configurable range, and estimates review effort. Supports custom threshold ranges for focused analysis (e.g., analyze only 0.7-0.9 range). See also [#see-also] * [System overview](/guide/system-overview) # Compare Metrics Between Function Versions (/api/legacy/function-accuracy/function-version-compare) > For the complete documentation index, see [llms.txt](/llms.txt). **Compares metrics between two function versions to show lift or regression** This endpoint retrieves metrics for two function versions and calculates: * Absolute differences for each metric * Percentage lift/regression for each metric * Field-level changes that contribute to overall differences Use Cases [#use-cases] * **Version Comparison**: Compare metrics between any two versions of a function * **Regression Detection**: Identify if a new version has regressed in performance * **Improvement Tracking**: Track lift percentages across function iterations Metrics Compared [#metrics-compared] * **Accuracy**: Overall correctness of extractions * **Precision**: True positives / (True positives + False positives) * **Recall**: True positives / (True positives + False negatives) * **F1 Score**: Harmonic mean of precision and recall * **Confusion Matrix**: TP, FP, TN, FN counts * **Precision-Recall AUC**: Area under the PR curve See also [#see-also] * [System overview](/guide/system-overview) # Get Function Metrics (/api/legacy/function-accuracy/get-function-metrics) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve performance metrics for functions based on labeled transformation data.** This endpoint calculates accuracy metrics by comparing function outputs against ground truth data (user corrections). Metrics help you understand function performance and identify areas for improvement. Available Metrics [#available-metrics] For each function, the response includes: * **Accuracy**: Overall correctness rate * **Precision**: True positives / (True positives + False positives) * **Recall**: True positives / (True positives + False negatives) * **F1 Score**: Harmonic mean of precision and recall * **Confusion Matrix**: TP, FP, TN, FN counts Requirements [#requirements] Metrics are only available for functions with: * Historical transformations that have been labeled/corrected * Sufficient sample size for meaningful calculations Filtering [#filtering] Filter by specific functions using `functionIDs` or `functionNames`, or by function type using the `types` parameter. See also [#see-also] * [System overview](/guide/system-overview) # Function Accuracy (/api/legacy/function-accuracy) > For the complete documentation index, see [llms.txt](/llms.txt). Monitor, analyze, and improve function accuracy with metrics, review estimation, and regression testing. Metrics [#metrics] Track function performance with accuracy, precision, recall, and F1 scores calculated from labeled transformation data (ground truth). Review Estimation [#review-estimation] Analyze function performance to estimate human review requirements: * Calculate sample sizes needed for statistical confidence * Find optimal confidence thresholds * Estimate review effort for quality assurance workflows Regression Testing [#regression-testing] Compare function versions against historical data: * Measure performance improvements or regressions between versions * Test new configurations against ground truth data * Validate changes before deploying to production See also [#see-also] * [System overview](/guide/system-overview) # Call a Function (/api/legacy/function-calls/call-function) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/calls/v3-call-workflow) instead. Request Examples [#request-examples] This endpoint supports both `multipart/form-data` and `application/json` content types. Using `multipart/form-data` is recommended as it allows you to send files directly without base64 encoding. Send a file directly using `multipart/form-data`. This is the simplest approach and avoids the overhead of base64 encoding. **Single file:** ```bash curl -X POST https://api.bem.ai/v2/functions/invoice-extractor/call \ -H "x-api-key: YOUR_API_KEY" \ -F "referenceID=ref-001" \ -F "file=@/path/to/invoice.pdf" ``` **Multiple files (for join functions):** ```bash curl -X POST https://api.bem.ai/v2/functions/my-join-function/call \ -H "x-api-key: YOUR_API_KEY" \ -F "referenceID=ref-001" \ -F "files=@/path/to/file1.pdf" \ -F "files=@/path/to/file2.pdf" ``` Send file contents as base64-encoded strings in a JSON body. ```bash curl -X POST https://api.bem.ai/v2/functions/invoice-extractor/call \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "arguments": [ { "referenceID": "ref-001", "inputType": "pdf", "inputContent": "JVBERi0xLjQKJeLjz9..." } ] }' ``` See also [#see-also] * [V3 migration](/guide/v3-migration) # Delete Function References (/api/legacy/function-calls/delete-function-references) > For the complete documentation index, see [llms.txt](/llms.txt). Deletes references and related transformations for a given function according to the reference IDs provided. This v1/v2 endpoint is deprecated. See the [V3 API Reference](/api/v3/authentication) for current endpoints. See also [#see-also] * [V3 migration](/guide/v3-migration) # Get a Function Call (/api/legacy/function-calls/get-function-call) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/calls/v3-get-call) instead. See also [#see-also] * [V3 migration](/guide/v3-migration) # Function Calls (/api/legacy/function-calls) > For the complete documentation index, see [llms.txt](/llms.txt). Function call operations See also [#see-also] * [V3 migration](/guide/v3-migration) # List Function Calls (/api/legacy/function-calls/list-function-calls) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/calls/v3-list-calls) instead. See also [#see-also] * [V3 migration](/guide/v3-migration) # Copy a Function (/api/legacy/functions/copy-function) > For the complete documentation index, see [llms.txt](/llms.txt). Creates a copy of an existing function with a new name. The copied function will have all the same configuration, schema, and settings as the source function, but with a new name and optionally new display name and tags. The function can optionally be copied to a different environment within the same account. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-copy-function) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # Create a Function (/api/legacy/functions/create-function) > For the complete documentation index, see [llms.txt](/llms.txt). **Create a new function to process and transform data.** Functions are the core building blocks for data transformation. The `type` field in the request body determines which kind of function to create, and each type has its own required configuration. Function Types [#function-types] Each function type has detailed documentation with examples and best practices: | Type | Purpose | Guide | | ----------------- | ------------------------------------------ | ------------------------------------------------------------------------ | | `transform` | Extract structured JSON from documents | [Transform Functions Guide](/guide/function-types/transform) | | `analyze` | Visual analysis of images and documents | [Analyze Functions Guide](/guide/function-types/analyze) | | `route` | Classify and route data to different paths | [Route Functions Guide](/guide/function-types/route) | | `split` | Split multi-page documents into pieces | [Split Functions Guide](/guide/function-types/split) | | `join` | Combine multiple inputs into one output | [Join Functions Guide](/guide/function-types/join) | | `payload_shaping` | Transform data with JMESPath expressions | [Payload Shaping Functions Guide](/guide/function-types/payload-shaping) | | `enrich` | Semantic search against collections | [Enrich Functions Guide](/guide/function-types/enrich) | See the [Function Types Overview](/guide/function-types/overview) for help choosing the right type. Quick Reference [#quick-reference] Transform Functions [#transform-functions] Extract structured data from documents. Requires `outputSchema` and `outputSchemaName`. [Full documentation](/guide/function-types/transform) Analyze Functions [#analyze-functions] Visual analysis optimized for images and scanned documents. Requires `outputSchema` and `outputSchemaName`. [Full documentation](/guide/function-types/analyze) Route Functions [#route-functions] Classify and direct data to different processing paths. Requires `description` and `routes` array. [Full documentation](/guide/function-types/route) Split Functions [#split-functions] Break multi-page documents into smaller pieces. Requires `splitType` (`print_page` or `semantic_page`). [Full documentation](/guide/function-types/split) Join Functions [#join-functions] Combine multiple inputs into a single output. Requires `joinType`, `outputSchema`, and `outputSchemaName`. [Full documentation](/guide/function-types/join) Payload Shaping Functions [#payload-shaping-functions] Transform data using JMESPath expressions. Requires `shapingSchema`. [Full documentation](/guide/function-types/payload-shaping) Enrich Functions [#enrich-functions] Add context via semantic search against collections. Requires `config` with `steps[]`. [Full documentation](/guide/function-types/enrich) Versioning [#versioning] Each create operation creates version 1 of the function. Updates create new versions automatically. Use `GET /v2/functions/{functionName}/versions` to list all versions. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-create-function) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # Delete a Function (/api/legacy/functions/delete-function) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-delete-function) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # Get a Function Version (/api/legacy/functions/get-function-version) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-get-function-version) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # Get a Function (/api/legacy/functions/get-function) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-get-function) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # Functions (/api/legacy/functions) > For the complete documentation index, see [llms.txt](/llms.txt). Functions are the core building blocks of data transformation in Bem. Each function type serves a specific purpose: * **Transform**: Extract structured JSON data from unstructured documents (PDFs, emails, images) * **Analyze**: Perform visual analysis on documents to extract layout-aware information * **Route**: Direct data to different processing paths based on conditions * **Split**: Break multi-page documents into individual pages for parallel processing * **Join**: Combine outputs from multiple function calls into a single result * **Payload Shaping**: Transform and restructure data using JMESPath expressions * **Enrich**: Enhance data with semantic search against collections Use these endpoints to create, update, list, and manage your functions. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # List Function Versions (/api/legacy/functions/list-function-versions) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-list-function-versions) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # List Functions (/api/legacy/functions/list-functions) > For the complete documentation index, see [llms.txt](/llms.txt). List all functions with optional filtering. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-list-functions) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # Update a Function (/api/legacy/functions/update-function) > For the complete documentation index, see [llms.txt](/llms.txt). **Update an existing function's configuration.** Updates create a new version of the function. The previous version remains available for reference and rollback purposes. Important Notes [#important-notes] * The `type` field cannot be changed after creation * Each update increments the version number automatically * Previous versions can be retrieved via `GET /v2/functions/{functionName}/versions/{versionNum}` * Active function calls continue using their original version until completion Function Type Documentation [#function-type-documentation] For detailed documentation on each function type's updatable fields, see the guides: | Type | Guide | | ----------------- | ------------------------------------------------------------------------ | | `transform` | [Transform Functions Guide](/guide/function-types/transform) | | `analyze` | [Analyze Functions Guide](/guide/function-types/analyze) | | `route` | [Route Functions Guide](/guide/function-types/route) | | `split` | [Split Functions Guide](/guide/function-types/split) | | `join` | [Join Functions Guide](/guide/function-types/join) | | `payload_shaping` | [Payload Shaping Functions Guide](/guide/function-types/payload-shaping) | | `enrich` | [Enrich Functions Guide](/guide/function-types/enrich) | Common Updates by Type [#common-updates-by-type] Transform Functions [#transform-functions] * Modify `outputSchema` to extract additional fields * Update `outputSchemaName` for clarity * Toggle `tabularChunkingEnabled` for spreadsheet processing Analyze Functions [#analyze-functions] * Update `outputSchema` for different visual extraction needs * Modify `outputSchemaName` Route Functions [#route-functions] * Add, remove, or modify entries in the `routes` array * Update route `description` for better classification * Change target `functionName` or `functionID` * Set `isErrorFallback: true` for fallback routes Split Functions [#split-functions] * Update `printPageSplitConfig` or `semanticPageSplitConfig` * Modify item classes and their target functions Join Functions [#join-functions] * Update `outputSchema` for the merged output structure * Modify `description` Payload Shaping Functions [#payload-shaping-functions] * Update `shapingSchema` JMESPath expression Enrich Functions [#enrich-functions] * Update `config` settings Version Comparison [#version-comparison] After updating, use the regression testing endpoint (`POST /v2/functions/regression`) to compare the new version against previous versions using historical data. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/functions/v3-update-function) instead. See also [#see-also] * [Function types overview](/guide/function-types/overview) * [V3 migration](/guide/v3-migration) # Create a Pipeline (/api/legacy/pipelines/create-pipeline) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated and may be replaced or removed in future versions of the API. Creates a new pipeline to transform data, given an output schema. It returns the created pipeline's details. Pipelines are long-lived, so we recommend you create them outside of your application loop and reuse them. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — the V3 successor to pipelines * [V3 migration](/guide/v3-migration) # Delete a Pipeline (/api/legacy/pipelines/delete-pipeline) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated and may be replaced or removed in future versions of the API. Deletes an existing pipeline and all related transformations. **IMPORTANT NOTE:** be sure you have exported any relevant transformations produced by the pipeline before deleting given they won't be accessible through our API after deleting the pipeline. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — the V3 successor to pipelines * [V3 migration](/guide/v3-migration) # Get a Pipeline (/api/legacy/pipelines/get-pipeline) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated and may be replaced or removed in future versions of the API. Retrieves configuration of an existing pipeline. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — the V3 successor to pipelines * [V3 migration](/guide/v3-migration) # Pipelines (/api/legacy/pipelines) > For the complete documentation index, see [llms.txt](/llms.txt). Pipeline operations See also [#see-also] * [Workflows explained](/guide/workflows-explained) — the V3 successor to pipelines * [V3 migration](/guide/v3-migration) # List Pipelines (/api/legacy/pipelines/list-pipelines) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated and may be replaced or removed in future versions of the API. Retrieves configurations for all existing pipelines. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — the V3 successor to pipelines * [V3 migration](/guide/v3-migration) # Update a Pipeline (/api/legacy/pipelines/update-pipeline) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated and may be replaced or removed in future versions of the API. Updates an existing pipeline. Follow conventional PATCH behavior, so only included fields will be updated. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — the V3 successor to pipelines * [V3 migration](/guide/v3-migration) # Create a Column in a Golden Data Set (/api/legacy/golden-data-sets/create-golden-data-set-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Create a Golden Data Set from Archive File (/api/legacy/golden-data-sets/create-golden-data-set-from-archive-file) > For the complete documentation index, see [llms.txt](/llms.txt). # Create a Golden Data Set from Schema (/api/legacy/golden-data-sets/create-golden-data-set-from-schema) > For the complete documentation index, see [llms.txt](/llms.txt). # Create a Golden Data Set from Schema and Transformations (/api/legacy/golden-data-sets/create-golden-data-set-from-transformations) > For the complete documentation index, see [llms.txt](/llms.txt). # Create Columns in a Row (/api/legacy/golden-data-sets/create-golden-data-set-row-columns) > For the complete documentation index, see [llms.txt](/llms.txt). # Create a Row in a Golden Data Set (/api/legacy/golden-data-sets/create-golden-data-set-row-from-row) > For the complete documentation index, see [llms.txt](/llms.txt). # Create a Row in a Golden Data Set (/api/legacy/golden-data-sets/create-golden-data-set-row) > For the complete documentation index, see [llms.txt](/llms.txt). # Create a Golden Data Set from Schema (/api/legacy/golden-data-sets/create-golden-data-set) > For the complete documentation index, see [llms.txt](/llms.txt). # Create or Update Rows in a Golden Data Set from Transformations (/api/legacy/golden-data-sets/create-or-update-golden-data-set-rows-from-transformations) > For the complete documentation index, see [llms.txt](/llms.txt). # Delete a Column from a Golden Data Set (/api/legacy/golden-data-sets/delete-golden-data-set-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Delete a Column from a Row (/api/legacy/golden-data-sets/delete-golden-data-set-row-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Delete a Row from a Golden Data Set (/api/legacy/golden-data-sets/delete-golden-data-set-row) > For the complete documentation index, see [llms.txt](/llms.txt). # Delete a Golden Data Set (/api/legacy/golden-data-sets/delete-golden-data-set) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Column from a Golden Data Set (/api/legacy/golden-data-sets/get-golden-data-set-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Column from a Row (/api/legacy/golden-data-sets/get-golden-data-set-row-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Row from a Golden Data Set (/api/legacy/golden-data-sets/get-golden-data-set-row) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Column from a Specific Version of a Golden Data Set (/api/legacy/golden-data-sets/get-golden-data-set-version-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Column from a Row in a Specific Version (/api/legacy/golden-data-sets/get-golden-data-set-version-row-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Row from a Specific Version of a Golden Data Set (/api/legacy/golden-data-sets/get-golden-data-set-version-row) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Specific Version of a Golden Data Set (/api/legacy/golden-data-sets/get-golden-data-set-version) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Golden Data Set (/api/legacy/golden-data-sets/get-golden-data-set) > For the complete documentation index, see [llms.txt](/llms.txt). # Golden Data Sets (/api/legacy/golden-data-sets) > For the complete documentation index, see [llms.txt](/llms.txt). Golden data set operations # List Columns in a Golden Data Set (/api/legacy/golden-data-sets/list-golden-data-set-columns) > For the complete documentation index, see [llms.txt](/llms.txt). # List Columns in a Row (/api/legacy/golden-data-sets/list-golden-data-set-row-columns) > For the complete documentation index, see [llms.txt](/llms.txt). # List Rows in a Golden Data Set (/api/legacy/golden-data-sets/list-golden-data-set-rows) > For the complete documentation index, see [llms.txt](/llms.txt). # List Columns in a Specific Version of a Golden Data Set (/api/legacy/golden-data-sets/list-golden-data-set-version-columns) > For the complete documentation index, see [llms.txt](/llms.txt). # List Columns in a Row from a Specific Version (/api/legacy/golden-data-sets/list-golden-data-set-version-row-columns) > For the complete documentation index, see [llms.txt](/llms.txt). # List Rows in a Specific Version of a Golden Data Set (/api/legacy/golden-data-sets/list-golden-data-set-version-rows) > For the complete documentation index, see [llms.txt](/llms.txt). # List Versions of a Golden Data Set (/api/legacy/golden-data-sets/list-golden-data-set-versions) > For the complete documentation index, see [llms.txt](/llms.txt). # List Golden Data Sets (/api/legacy/golden-data-sets/list-golden-data-sets) > For the complete documentation index, see [llms.txt](/llms.txt). # Update a Column in a Golden Data Set (/api/legacy/golden-data-sets/update-golden-data-set-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Update a Column in a Row (/api/legacy/golden-data-sets/update-golden-data-set-row-column) > For the complete documentation index, see [llms.txt](/llms.txt). # Update a Row in a Golden Data Set (/api/legacy/golden-data-sets/update-golden-data-set-row) > For the complete documentation index, see [llms.txt](/llms.txt). # Update a Golden Data Set (/api/legacy/golden-data-sets/update-golden-data-set) > For the complete documentation index, see [llms.txt](/llms.txt). # Generate a new webhook secret (/api/legacy/security/generate-webhook-secret) > For the complete documentation index, see [llms.txt](/llms.txt). Generates a new webhook secret to be used for webhook signatures. If a webhook secret already exists, this endpoint will overwrite the previous secret and generate a new one. See also [#see-also] * [Webhooks](/guide/webhooks) — signature verification # Get a User Action (/api/legacy/security/get-user-action) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Webhooks](/guide/webhooks) — signature verification # Get the current webhook secret (/api/legacy/security/get-webhook-secret) > For the complete documentation index, see [llms.txt](/llms.txt). Gets the current webhook secret for your account. See also [#see-also] * [Webhooks](/guide/webhooks) — signature verification # Security (/api/legacy/security) > For the complete documentation index, see [llms.txt](/llms.txt). Security operations See also [#see-also] * [Webhooks](/guide/webhooks) — signature verification # List User Actions (/api/legacy/security/list-user-actions) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Webhooks](/guide/webhooks) — signature verification # Create a Subscription (/api/legacy/subscriptions/create-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). Creates a new subscription to listen to transform or error events. See also [#see-also] * [Webhooks](/guide/webhooks) # Delete a Subscription (/api/legacy/subscriptions/delete-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). Deletes an existing subscription. See also [#see-also] * [Webhooks](/guide/webhooks) # Get a Subscription (/api/legacy/subscriptions/get-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Webhooks](/guide/webhooks) # Subscriptions (/api/legacy/subscriptions) > For the complete documentation index, see [llms.txt](/llms.txt). Subscription operations See also [#see-also] * [Webhooks](/guide/webhooks) # List Subscriptions (/api/legacy/subscriptions/list-subscriptions) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Webhooks](/guide/webhooks) # Update a Subscription (/api/legacy/subscriptions/update-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). Updates an existing subscription. Follow conventional PATCH behavior, so only included fields will be updated. See also [#see-also] * [Webhooks](/guide/webhooks) # Create a batch of Transformations (/api/legacy/transformations/create-transformations) > For the complete documentation index, see [llms.txt](/llms.txt). Creates a batch of new transformations, each with a content and input type, and queues them up in bem's servers. Bem supports both `application/json` and `multipart/form-data` requests, where the latter is useful for large files. Request Examples [#request-examples] Send a file directly using `multipart/form-data`. This is recommended for large files as it avoids the overhead of base64 encoding. ```bash curl -X POST https://api.bem.ai/v1-beta/transformations \ -H "x-api-key: YOUR_API_KEY" \ -F "pipelineID=YOUR_PIPELINE_ID" \ -F "referenceID=ref-001" \ -F "file=@/path/to/document.pdf" ``` > **Note:** With `multipart/form-data`, each request creates a single transformation. To process multiple files, send separate requests for each file. Send file contents as base64-encoded strings in a JSON body. This approach supports batching multiple transformations in a single request. ```bash curl -X POST https://api.bem.ai/v1-beta/transformations \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "pipelineID": "YOUR_PIPELINE_ID", "transformations": [ { "referenceID": "ref-001", "inputType": "pdf", "inputContent": "JVBERi0xLjQKJeLjz9..." }, { "referenceID": "ref-002", "inputType": "pdf", "inputContent": "JVBERi0xLjUKJeLjz9..." } ] }' ``` See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Delete Transformations (/api/legacy/transformations/delete-transformations) > For the complete documentation index, see [llms.txt](/llms.txt). Deletes transformations by specifying pipeline ID, list of reference IDs, or list of transformation IDs. Will delete intersection of all params specified. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # (DEPRECATED) Get Evaluation Results for Multiple Transformations (/api/legacy/transformations/deprecated-get-evaluation-results) > For the complete documentation index, see [llms.txt](/llms.txt). DEPRECATED: Use the GET endpoint instead. Retrieves evaluation results for multiple transformations. Returns completed evaluation results, lists transformations that are still pending evaluation, and reports any errors encountered. Invalid transformation IDs will be included in the errors map in the response. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # (DEPRECATED) Correct the Transformation value (/api/legacy/transformations/deprecated-update-transformation) > For the complete documentation index, see [llms.txt](/llms.txt). This endpoint has been deprecated and may be replaced or removed in future versions of the API. Deprecating will be replaced by put. Updates a transformation with feedback on the corrected transformation value. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Queue Evaluation for Multiple Transformations (/api/legacy/transformations/evaluate-transformations) > For the complete documentation index, see [llms.txt](/llms.txt). Queues evaluation jobs for multiple transformations. The evaluations are processed asynchronously by worker jobs. This endpoint returns immediately with a 202 status to indicate the evaluations have been queued. The actual evaluation results are stored in the database and can be retrieved via the transformations list endpoint. Invalid transformation IDs will be included in the errors map in the response. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Get Evaluation Results for Multiple Transformations (/api/legacy/transformations/get-evaluation-results) > For the complete documentation index, see [llms.txt](/llms.txt). Retrieves evaluation results for multiple transformations. Returns completed evaluation results, lists transformations that are still pending evaluation, and reports any errors encountered. Invalid transformation IDs will be included in the errors map in the response. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Transformations (/api/legacy/transformations) > For the complete documentation index, see [llms.txt](/llms.txt). Transformation operations See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # List Transformation errors (/api/legacy/transformations/list-transformation-errors) > For the complete documentation index, see [llms.txt](/llms.txt). Lists all errors encountered while attempting to transform data, with either pagination or specific reference IDs. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # List Transformations (/api/legacy/transformations/list-transformations) > For the complete documentation index, see [llms.txt](/llms.txt). Lists all performed transformations, with either pagination or specific reference IDs. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Receive Transformation Errors webhook (/api/legacy/transformations/receive-transformation-errors-webhook) > For the complete documentation index, see [llms.txt](/llms.txt). Pipelines can also be configured to send webhooks to your desired URL. Webhooks will be called upon the failure of each transformed data point, instead of batched. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Receive completed Transformation webhook (/api/legacy/transformations/receive-transformation-webhook) > For the complete documentation index, see [llms.txt](/llms.txt). Pipelines can also be configured to send webhooks to your desired URL. Webhooks will be called upon the successful completion of each transformed data point, instead of batched. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Correct the Transformation value (/api/legacy/transformations/update-transformation) > For the complete documentation index, see [llms.txt](/llms.txt). Updates a transformation with feedback on the corrected transformation value. See also [#see-also] * [Function types overview](/guide/function-types/overview) — the V3 successor to transformations * [V3 migration](/guide/v3-migration) — what changed in V3 # Create a View (/api/legacy/views/create-view) > For the complete documentation index, see [llms.txt](/llms.txt). Create a new view. A view is a table visualization of transformations that provides insight the output of transform functions. Views enable you to define columns that extract specific fields from transformation outputs, apply filters to narrow down the data, create aggregations for summary statistics, and query transformations from one or more functions. # Delete a View (/api/legacy/views/delete-view) > For the complete documentation index, see [llms.txt](/llms.txt). Delete an existing view. This permanently removes the view and all its versions. # Generate View Aggregation Data (/api/legacy/views/generate-view-aggregation-data) > For the complete documentation index, see [llms.txt](/llms.txt). Generate aggregation results for a view. This endpoint executes the view's aggregations against transformations from the specified functions, applies the defined filters, and returns aggregated values. The request includes the view configuration (columns, filters, aggregations, function IDs) and a time window to filter transformations by creation date. The response contains an array of aggregation results, where each aggregation contains groups and their aggregated values. For grouped aggregations, multiple groups are returned (up to 200 groups per aggregation). For non-grouped aggregations, a single group with an empty group name is returned. Supported aggregation functions include `count` (total count of rows), `count_distinct` (count of unique values in a column), `sum` (sum of numeric values), `average` (average of numeric values), `min` (minimum numeric value), and `max` (maximum numeric value). Note: The `functions` field in the view configuration is required. At least one function ID or name must be specified. # Generate View Table Data (/api/legacy/views/generate-view-table-data) > For the complete documentation index, see [llms.txt](/llms.txt). Generate paginated table data for a view. This endpoint executes the view's query against transformations from the specified functions, applies the defined filters, and returns matching rows with their column values. The request includes the view configuration (columns, filters, function IDs), a time window to filter transformations by creation date, and optional pagination parameters (limit and offset). The response contains an array of rows, where each row contains column values extracted from a transformation, and the total count of matching rows (before pagination). **Note:** The `functions` field in the view configuration is required. At least one function ID or name must be specified. # Get a View (/api/legacy/views/get-view) > For the complete documentation index, see [llms.txt](/llms.txt). Retrieve a single view by its ID. # Views (/api/legacy/views) > For the complete documentation index, see [llms.txt](/llms.txt). View operations # List Views (/api/legacy/views/list-views) > For the complete documentation index, see [llms.txt](/llms.txt). List all views with optional filtering by function IDs. # Update a View (/api/legacy/views/update-view) > For the complete documentation index, see [llms.txt](/llms.txt). Update an existing view. This creates a new version of the view with the updated configuration. The view ID in the path identifies which view to update, and the request body contains the new view configuration. The version number will be automatically incremented. # Copy a Workflow (/api/legacy/workflows/copy-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). Copy a workflow to a new workflow with a different name. Can optionally copy to a different environment or copy a specific version of the workflow. When copying to a different environment, all functions used in the workflow will also be copied. This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-copy-workflow) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # Create a Workflow (/api/legacy/workflows/create-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Create a new workflow to orchestrate function execution.** Workflows define how functions are connected together to process data through multiple steps. A workflow has a `mainFunction` as its entry point, and optional `relationships` that define how data flows between functions. Workflow Structure [#workflow-structure] * **mainFunction**: The entry point function that receives initial input * **relationships**: Define connections between functions (required for multi-function workflows) Single Function Workflows [#single-function-workflows] For simple use cases, create a workflow with just a `mainFunction` and no relationships. The workflow will execute only that function. Multi-Function Workflows [#multi-function-workflows] For complex pipelines, define `relationships` to connect functions: * `sourceFunction`: The function that produces output * `destinationName`: The named output/route from the source function * `destinationFunction`: The function that receives the output This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-create-workflow) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # Delete a Workflow (/api/legacy/workflows/delete-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-delete-workflow) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # Get a Workflow Version (/api/legacy/workflows/get-workflow-version) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-get-workflow-version) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # Get a Workflow (/api/legacy/workflows/get-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-get-workflow) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # Workflows (/api/legacy/workflows) > For the complete documentation index, see [llms.txt](/llms.txt). Workflow operations See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # List Workflow Versions (/api/legacy/workflows/list-workflow-versions) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-list-workflow-versions) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # List Workflows (/api/legacy/workflows/list-workflows) > For the complete documentation index, see [llms.txt](/llms.txt). This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-list-workflows) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # Update a Workflow (/api/legacy/workflows/update-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Update an existing workflow's configuration.** Updates create a new version of the workflow. The previous version remains available for reference. You can update metadata (name, displayName, tags) or the workflow structure (mainFunction, relationships). Update Behavior [#update-behavior] * **Metadata updates**: Change `name`, `displayName`, or `tags` without affecting workflow structure * **Structure updates**: Modify `mainFunction` and/or `relationships` to change how functions are connected Important Notes [#important-notes] * The `mainFunction` and `relationships` fields must be provided together if updating workflow structure * If only `mainFunction` is provided, `relationships` defaults to an empty array * If `relationships` is provided, `mainFunction` must also be provided * Each update increments the version number automatically This v1/v2 endpoint is deprecated. Use the [V3 equivalent](/api/v3/workflows/v3-update-workflow) instead. See also [#see-also] * [Workflows explained](/guide/workflows-explained) * [V3 migration](/guide/v3-migration) # Buckets (/api/v3/buckets) > For the complete documentation index, see [llms.txt](/llms.txt). Buckets are named partitions of the knowledge graph within an account+environment. Entities, mentions, and relations are scoped to a bucket so a single account+environment can host multiple isolated graphs — for example one per data source or workspace. Every account+environment has exactly one **default** bucket, used by unscoped flows. The default bucket can be renamed but never deleted. Use these endpoints to create, list, fetch, rename, and delete buckets: * **`POST /v3/buckets`** creates a non-default bucket. * **`GET /v3/buckets`** lists buckets with cursor pagination (`startingAfter` / `endingBefore` over `bucketID`). * **`PATCH /v3/buckets/{bucketID}`** updates `name` and/or `description`. * **`DELETE /v3/buckets/{bucketID}`** soft-deletes a bucket. A non-empty bucket is rejected with `409 Conflict` unless `?cascade=true` is passed; the default bucket can never be deleted. # Create a Bucket (/api/v3/buckets/v3-create-bucket) > For the complete documentation index, see [llms.txt](/llms.txt). # Delete a Bucket (/api/v3/buckets/v3-delete-bucket) > For the complete documentation index, see [llms.txt](/llms.txt). # Get a Bucket (/api/v3/buckets/v3-get-bucket) > For the complete documentation index, see [llms.txt](/llms.txt). # List Buckets (/api/v3/buckets/v3-list-buckets) > For the complete documentation index, see [llms.txt](/llms.txt). # Update a Bucket (/api/v3/buckets/v3-update-bucket) > For the complete documentation index, see [llms.txt](/llms.txt). # Calls (/api/v3/calls) > For the complete documentation index, see [llms.txt](/llms.txt). The Calls API provides a unified interface for invoking both **Workflows** and **Functions**. Use this API when you want to: * Execute a complete workflow that chains multiple functions together * Call a single function directly without defining a workflow * Submit batch requests with multiple inputs in a single API call * Track execution status using call reference IDs **Key Difference**: Calls vs Function Calls * **Calls API** (`/v3/calls`): High-level API for invoking workflows or functions by name/ID. Supports batch processing and workflow orchestration. * **Function Calls API** (`/v3/functions/{functionName}/call`): Direct function invocation with function-type-specific arguments. Better for granular control over individual function calls. See also [#see-also] * [System overview](/guide/system-overview) — how calls produce events and transformations * [Polling and retries](/guide/polling-and-retries) — wait=true semantics, polling cadence # Call a Workflow (/api/v3/calls/v3-call-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Invoke a workflow.** Submit the input file as either a multipart form request or a JSON request with base64-encoded file content. The workflow name is derived from the URL path. Input Formats [#input-formats] * **Multipart form** (`multipart/form-data`): attach the file directly via the `file` or `files` fields. Set `wait` in the form body to control synchronous behaviour. For `files`, an optional `tags` field labels each file, aligned positionally by index. * **JSON** (`application/json`): base64-encode the file content and set it in `input.singleFile.inputContent` or `input.batchFiles.inputs[*].inputContent`. Pass `wait=true` as a query parameter to control synchronous behaviour. Synchronous vs Asynchronous [#synchronous-vs-asynchronous] By default the call is created asynchronously and this endpoint returns `202 Accepted` immediately with a `pending` call object. Set `wait` to `true` to block until the call completes (up to 30 seconds): * On success: returns `200 OK` with the completed call, `outputs` populated * On failure: returns `500 Internal Server Error` with the call and an `error` message * On timeout: returns `202 Accepted` with the still-running call Tracking [#tracking] Poll `GET /v3/calls/{callID}` to check status, or configure a webhook subscription to receive events when the call finishes. CLI Usage [#cli-usage] Use `@path/to/file` inside JSON string values to embed file contents automatically. Binary files (PDF, images, audio) are base64-encoded; text files are embedded as strings. Single file (synchronous): ```bash bem workflows call \ --workflow-name my-workflow \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' \ --wait ``` Single file (asynchronous, returns callID immediately): ```bash bem workflows call \ --workflow-name my-workflow \ --input.single-file '{"inputContent": "@invoice.pdf", "inputType": "pdf"}' ``` Batch files: ```bash bem workflows call \ --workflow-name my-workflow \ --input.batch-files '{"inputs": [{"inputContent": "@a.pdf", "inputType": "pdf"}, {"inputContent": "@b.png", "inputType": "png"}]}' ``` Alternative: pass the full `--input` flag as JSON: ```bash bem workflows call \ --workflow-name my-workflow \ --input '{"singleFile": {"inputContent": "@invoice.pdf", "inputType": "pdf"}}' \ --wait ``` **Important:** `--wait` is a boolean flag. Use `--wait` or `--wait=true`. Do **not** use `--wait true` (with a space) — the `true` will be parsed as an unexpected positional argument. Supported `inputType` values: csv, docx, email, heic, heif, html, jfif, jpeg, json, m4a, mp3, mov, mp4, pdf, png, pptx, text, wav, webp, xls, xlsx, xml. `jfif` (and `jpg`) are normalized to `jpeg`. See also [#see-also] * [System overview](/guide/system-overview) — how calls produce events and transformations * [Polling and retries](/guide/polling-and-retries) — wait=true semantics, polling cadence # Get Call Trace (/api/v3/calls/v3-get-call-trace) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve the full execution trace of a workflow call.** Returns all function calls and events emitted during the call as flat arrays. The DAG can be reconstructed using `FunctionCallResponseBase.sourceEventID` (the event that spawned each function call) and each event's `functionCallID` (the function call that emitted it). Graph structure [#graph-structure] * A function call with no `sourceEventID` is the root. * An event's `functionCallID` points to the function call that emitted it. * A function call's `sourceEventID` points to the event that triggered it. * `workflowNodeName` identifies the DAG node; `incomingDestinationName` identifies the labelled outlet used to reach this call (absent for unlabelled edges and root calls). The trace is available as soon as the call exists and grows as execution proceeds. See also [#see-also] * [System overview](/guide/system-overview) — how calls produce events and transformations * [Polling and retries](/guide/polling-and-retries) — wait=true semantics, polling cadence # Get a Call (/api/v3/calls/v3-get-call) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a workflow call by ID.** Returns the full call object including status, workflow details, terminal outputs, and terminal errors. `outputs` and `errors` are both populated once the call finishes — they are not mutually exclusive (a partially-completed workflow may have both). Status [#status] | Status | Description | | ----------- | ----------------------------------------------------------- | | `pending` | Queued, not yet started | | `running` | Currently executing | | `completed` | All enclosed function calls finished without errors | | `failed` | One or more enclosed function calls produced an error event | Poll this endpoint or configure a webhook subscription to detect completion. See also [#see-also] * [System overview](/guide/system-overview) — how calls produce events and transformations * [Polling and retries](/guide/polling-and-retries) — wait=true semantics, polling cadence # List Calls (/api/v3/calls/v3-list-calls) > For the complete documentation index, see [llms.txt](/llms.txt). **List workflow calls with filtering and pagination.** Returns calls created via `POST /v3/workflows/{workflowName}/call`. Filtering [#filtering] * `callIDs`: Specific call identifiers * `referenceIDs`: Your custom reference IDs * `workflowIDs` / `workflowNames`: Filter by workflow * `functionIDs` / `functionNames`: Filter by function (function calls only) * `callTypes`: Restrict to workflow calls or to function calls Pagination [#pagination] Use `startingAfter` and `endingBefore` cursors with a default limit of 50. See also [#see-also] * [System overview](/guide/system-overview) — how calls produce events and transformations * [Polling and retries](/guide/polling-and-retries) — wait=true semantics, polling cadence # Collections (/api/v3/collections) > For the complete documentation index, see [llms.txt](/llms.txt). Collections are named groups of embedded items used by Enrich functions for semantic search. Each collection is referenced by a `collectionName`, which supports dot notation for hierarchical paths (e.g. `customers.premium.vip`). Names must contain only letters, digits, underscores, and dots, and each segment must start with a letter or underscore. Items [#items] Items carry either a string or a JSON object in their `data` field. When items are added or updated, their `data` is embedded asynchronously — `POST /v3/collections/items` and `PUT /v3/collections/items` return immediately with a `pending` status and an `eventID` that can be correlated with webhook notifications once processing completes. Listing and hierarchy [#listing-and-hierarchy] Use `GET /v3/collections` with `parentCollectionName` to list collections under a path, or `collectionNameSearch` for a case-insensitive substring match. `GET /v3/collections/items` retrieves a specific collection's items; pass `includeSubcollections=true` to fold in items from all descendant collections. Token counting [#token-counting] Use `POST /v3/collections/token-count` to check whether texts fit within the embedding model's 8,192-token-per-text limit before submitting them for embedding. See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Add new items to a Collection (/api/v3/collections/v3-add-collection-items) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Count tokens for texts (/api/v3/collections/v3-count-tokens) > For the complete documentation index, see [llms.txt](/llms.txt). Count the number of tokens in the provided texts using the BGE M3 tokenizer. This is useful for checking if texts will fit within the embedding model's token limit (8,192 tokens per text) before sending them for embedding. See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Create a Collection (/api/v3/collections/v3-create-collection) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Delete an item from a Collection (/api/v3/collections/v3-delete-collection-item) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Delete a Collection (/api/v3/collections/v3-delete-collection) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Get a Collection (/api/v3/collections/v3-get-collection) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # List Collections (/api/v3/collections/v3-list-collections) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Search a Collection (/api/v3/collections/v3-search-collection) > For the complete documentation index, see [llms.txt](/llms.txt). Run a vector/keyword search against a collection and return the raw retrieval hits (distinct, best match first). Uses the same retrieval as an enrich step — `semantic`, `hybrid` (weighted Reciprocal Rank Fusion), or `exact` — but does NOT apply the LLM re-rank that a full enrich step layers on top. See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Update existing items in a Collection (/api/v3/collections/v3-update-collection-items) > For the complete documentation index, see [llms.txt](/llms.txt). See also [#see-also] * [Enrich functions](/guide/function-types/enrich) — use collections from a workflow # Connectors (/api/v3/connectors) > For the complete documentation index, see [llms.txt](/llms.txt). Connectors are integrations that trigger a Bem workflow from an external system. A connector binds an inbound source — currently Box or a Paragon-managed integration such as Google Drive — to a specific workflow (by `workflowName` or `workflowID`). When the source observes a new file, Bem invokes the bound workflow against that file. Use these endpoints to create, list, and remove connectors. The fields used at create time depend on the connector `type`: Box connectors require Box credentials and a folder to watch, while Paragon connectors carry a `paragonIntegration` identifier and an integration-specific `paragonConfiguration` object (for example, `{ "folderId": "..." }` for Google Drive). # Create a Connector (/api/v3/connectors/v3-create-connector) > For the complete documentation index, see [llms.txt](/llms.txt). # Delete a Connector (/api/v3/connectors/v3-delete-connector) > For the complete documentation index, see [llms.txt](/llms.txt). # List Connectors (/api/v3/connectors/v3-list-connectors) > For the complete documentation index, see [llms.txt](/llms.txt). # Entity Bulk Seed (/api/v3/entity-bulk-seed) > For the complete documentation index, see [llms.txt](/llms.txt). Seed the knowledge graph with a batch of customer-authored canonical entities in one request — their types, descriptions, synonyms, and per-entity attributes. * **`POST /v3/entities/bulk`** creates or merges each entity into a single bucket (the optional `bucket` reference, else the account+environment default). For each row the entity's `type` is resolved or created in your taxonomy, the entity is upserted on its normalized canonical, and any `synonyms` are attached as `customer_defined`. An entity that already exists is **merged** (`onConflict: "merge"`): synonyms are added additively, a longer `description` replaces the old one, and `attributes` are merged with new keys winning. * Small batches (fewer than 100 entities) process **synchronously** and return `200` with a per-row `results` array and a `summary`. * Larger batches process **asynchronously**: the call returns `202` with a `seedJobID` and a `statusURL`. Poll **`GET /v3/entities/seed/{id}`** until `status` is `completed` (or `failed`); the completed response includes the per-row `results`. Each row's outcome is one of `created`, `merged-with`, or `rejected` (with a `reason`, e.g. an attribute key not declared in the type's schema). # Bulk Seed Entities (/api/v3/entity-bulk-seed/v3-bulk-seed-entities) > For the complete documentation index, see [llms.txt](/llms.txt). # Get Seed Job Status (/api/v3/entity-bulk-seed/v3-get-seed-job) > For the complete documentation index, see [llms.txt](/llms.txt). # Entity Curation (/api/v3/entity-curation) > For the complete documentation index, see [llms.txt](/llms.txt). Curate the knowledge graph by transitioning entities through their review lifecycle and editing their metadata. * **`PATCH /v3/entities/{id}`** updates a single entity. Every field is optional but at least one is required: * `status` transitions curation state to `approved` or `rejected` (only from `extracted`/`proposed`; any other transition is `409 Conflict`). Approving emits an `entity_validated` webhook; rejecting emits `entity_rejected`. * `assignedTypeID` sets (or, with the empty string, clears) the customer-assigned type that overrides the bem-inferred type. * `canonical` replaces the canonical surface form. * `addSynonyms` / `removeSynonymIDs` attach `customer_defined` synonyms or soft-delete existing ones (an `extracted` synonym cannot be removed — `409 Conflict`). A merged-away entity id transparently resolves to its surviving canonical entity. * **`POST /v3/entities/bulk-validate`** transitions a batch of entities to `approved` or `rejected` in one request. Each row reports `validated`, `skipped` (not found / not authorized), or `rejected-row` (an illegal transition such as an already-terminal entity), alongside a summary. On the dashboard (JWT) surface these actions additionally require the acting user to be an assigned reviewer for the entity's effective type, or to hold the `admin` role (or higher). On the API-key surface admin-key authorization applies and no per-user reviewer check is made. # Bulk Validate Entities (/api/v3/entity-curation/v3-bulk-validate-entities) > For the complete documentation index, see [llms.txt](/llms.txt). # Update Entity (/api/v3/entity-curation/v3-update-entity) > For the complete documentation index, see [llms.txt](/llms.txt). # Entity Synonyms (/api/v3/entity-synonyms) > For the complete documentation index, see [llms.txt](/llms.txt). Manage the human-readable surface forms (synonyms) attached to a canonical entity. Synonyms feed the matcher's exact-match path, so adding the right synonyms improves cross-document entity resolution. * **`POST /v3/entities/{id}/synonyms`** attaches a `customer_defined` synonym. If the same normalized form already exists as an `extracted` synonym, it is upgraded to `customer_defined` (so the matcher weights it higher); an existing customer/SME synonym is returned unchanged. * **`DELETE /v3/entities/{id}/synonyms/{synonymID}`** soft-deletes a synonym. Only `customer_defined` and `sme_approved` synonyms are deletable; `extracted` synonyms are resolver-owned and the request is rejected with `409 Conflict`. A merged-away entity id transparently resolves to its surviving canonical entity, so a synonym added to a stale id lands on the entity that persists. # Add a Synonym to an Entity (/api/v3/entity-synonyms/v3-add-entity-synonym) > For the complete documentation index, see [llms.txt](/llms.txt). # Remove a Synonym from an Entity (/api/v3/entity-synonyms/v3-remove-entity-synonym) > For the complete documentation index, see [llms.txt](/llms.txt). # Entity Types (/api/v3/entity-types) > For the complete documentation index, see [llms.txt](/llms.txt). Entity Types are the customer-defined taxonomy for the knowledge graph, scoped to an account+environment. Each type has a unique, immutable name and can be organised into hierarchies via `parentTypeID`. A type may carry per-type structured attribute metadata in `attributeSchema` (for example `{"unit": "mg", "range": [0, 100]}`). Use these endpoints to create, list, fetch, update, and delete entity types: * **`POST /v3/entity-types`** creates a type, optionally under a parent. * **`GET /v3/entity-types`** lists types with cursor pagination (`startingAfter` / `endingBefore` over `typeID`) and an optional `parentTypeId` filter for direct children. * **`PATCH /v3/entity-types/{typeID}`** updates `description`, `parentTypeID`, and/or `attributeSchema`. The `name` is immutable. * **`DELETE /v3/entity-types/{typeID}`** soft-deletes a type. The request is rejected with `409 Conflict` while any live entity is assigned to the type or any live child type points at it. # Create an Entity Type (/api/v3/entity-types/v3-create-entity-type) > For the complete documentation index, see [llms.txt](/llms.txt). # Delete an Entity Type (/api/v3/entity-types/v3-delete-entity-type) > For the complete documentation index, see [llms.txt](/llms.txt). # Get an Entity Type (/api/v3/entity-types/v3-get-entity-type) > For the complete documentation index, see [llms.txt](/llms.txt). # List Entity Types (/api/v3/entity-types/v3-list-entity-types) > For the complete documentation index, see [llms.txt](/llms.txt). # Update an Entity Type (/api/v3/entity-types/v3-update-entity-type) > For the complete documentation index, see [llms.txt](/llms.txt). # Errors (/api/v3/errors) > For the complete documentation index, see [llms.txt](/llms.txt). Retrieve terminal error events from workflow calls. Errors are events produced by function steps that failed during processing. A single workflow call may produce multiple error events if several steps fail independently. Errors and outputs from the same call are not mutually exclusive: a partially-completed workflow may have both. Use `GET /v3/errors` to list errors across calls, or `GET /v3/errors/{eventID}` to retrieve a specific error. To get errors scoped to a single call, filter by `callIDs`. See also [#see-also] * [Errors and status codes](/guide/errors) — error response shape, partial success # Get an Error (/api/v3/errors/v3-get-error) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a single error event by ID.** Returns `404` if the event does not exist or if it is not an error event (use `GET /v3/outputs/{eventID}` for non-error events). See also [#see-also] * [Errors and status codes](/guide/errors) — error response shape, partial success # List Errors (/api/v3/errors/v3-list-errors) > For the complete documentation index, see [llms.txt](/llms.txt). **List terminal error events.** Returns error events produced by failed function calls within workflow executions. Non-error output events are excluded; use `GET /v3/outputs` to retrieve those. Filtering [#filtering] Filter by call, workflow, function, or reference ID. Multiple filters are ANDed together. See also [#see-also] * [Errors and status codes](/guide/errors) — error response shape, partial success # Feedback (/api/v3/feedback) > For the complete documentation index, see [llms.txt](/llms.txt). Submit training corrections for `extract`, `classify`, and `join` events. Feedback is event-centric — each correction is attached to an event by its `eventID`, and the server resolves the correct underlying storage (extract/join transformations or classify route events) from the event's function type. Split and enrich function types do not support feedback. See also [#see-also] * [System overview](/guide/system-overview) — events and transformations # Submit Enrich Ground Truth (/api/v3/feedback/v3-submit-enrich-feedback) > For the complete documentation index, see [llms.txt](/llms.txt). **Submit ground-truth re-ranking for an enrich event.** An enrich output ranks candidates per enriched field, each candidate carrying a stable `id`. This endpoint records the client's own ranking of those candidates (by id) as ground truth, for later ranking-quality metrics. Per field, `rankings` gives each candidate a 1-based `rank` (lower = better); ties and gaps are allowed, and candidates left out are treated as unranked. Every id must appear in that field's enrich output. Re-submitting overwrites. Non-enrich events return `400`, matching `POST /v3/events/{eventID}/feedback`, which reports an unsupported function type the same way. A body that is structurally invalid (missing path, empty rankings, `rank` \< 1, duplicate ids) also returns `400`; a body that is well-formed but does not match the output (unknown field path, unknown candidate id) returns `422`. See also [#see-also] * [System overview](/guide/system-overview) — events and transformations # Submit Event Feedback (/api/v3/feedback/v3-submit-event-feedback) > For the complete documentation index, see [llms.txt](/llms.txt). **Submit a correction for an event.** Accepts training corrections for `extract`, `classify`, and `join` events. For extract/join events, `correction` is a JSON object matching the function's output schema. For classify events, `correction` is a JSON string matching one of the function version's declared classifications. Submitting feedback again for the same event overwrites the previous correction. Unsupported function types (split) return `400`. Enrich events use `POST /v3/events/{eventID}/enrich-feedback` instead. See also [#see-also] * [System overview](/guide/system-overview) — events and transformations # File System (/api/v3/file-system) > For the complete documentation index, see [llms.txt](/llms.txt). Unix-shell-style nav over parsed documents and the cross-doc memory store. `POST /v3/fs` is a single op-driven endpoint designed for LLM agents and programmatic consumers that want to walk a corpus the way they'd walk a filesystem. Doc-level ops (every parsed document) [#doc-level-ops-every-parsed-document] * `ls` — list parsed documents with rich per-doc metadata. * `cat` — read one doc's parse JSON, sliced (`range`) or projected (`select`). * `head` — first N sections of one doc. * `grep` — substring or regex search; `scope`, `path`, `countOnly` available. * `stat` — metadata only (page/section/entity counts, timestamps). Memory-level ops (require `linkAcrossDocuments: true` on the parse function) [#memory-level-ops-require-linkacrossdocuments-true-on-the-parse-function] * `find` — list canonical entities across the corpus. * `open` — entity + mentions. * `xref` — for one entity, sections across docs that mention it (with content). Memory ops return an empty list with a `hint` when no docs in this environment have been memory-linked. Pagination [#pagination] List ops paginate by cursor — pass the previous response's `nextCursor` back as `cursor`; `hasMore: false` signals the last page. Same idiom as `/v3/calls` and `/v3/outputs`. # File System Operations (/api/v3/file-system/v3-fs) > For the complete documentation index, see [llms.txt](/llms.txt). **Navigate parsed documents and the cross-doc memory store via Unix-shell verbs.** `POST /v3/fs` is a single op-driven endpoint that lets an LLM agent (or any programmatic client) walk a corpus the way it would walk a filesystem — `ls` to list, `cat` to read, `grep` to search, `head` for a quick peek, `stat` for metadata, and `find` / `open` / `xref` for the cross-doc entity memory layer. The body always carries an `op` field; other fields apply per op. The response envelope is uniform: `{op, data, hasMore?, nextCursor?, count?, hint?}`. Quick reference [#quick-reference] | Op | `path` | Other fields | What it does | | ------ | ----------------------------- | ------------------------------- | ----------------------------------------- | | `ls` | — | `filter`, `limit`, `cursor` | List parsed documents | | `grep` | referenceID *(optional)* | `pattern`, `scope`, `countOnly` | Search across documents | | `cat` | referenceID | `range`, `select` | Read a document's parsed content | | `head` | referenceID | `n` | First N sections (default 10) | | `stat` | referenceID *or* entityID | — | Metadata only | | `find` | — | `filter`, `limit`, `cursor` | List canonical entities | | `open` | entityID | — | Entity detail + all mentions | | `xref` | entityID | `limit`, `cursor` | Sections across docs mentioning an entity | **`path`** is the positional identifier. For doc ops (`cat`, `head`, `stat`), pass a `referenceID` from `ls`. For entity ops (`open`, `xref`), pass an `entityID` from `find`. `grep` optionally takes a `path` to scope search to one document. Examples [#examples] **List documents:** `{"op": "ls"}` **Search one document:** `{"op": "grep", "path": "my-doc-001", "pattern": "holiday", "scope": "sections"}` **Read one page:** `{"op": "cat", "path": "my-doc-001", "range": {"page": 7}}` **Read a page range:** `{"op": "cat", "path": "my-doc-001", "range": {"pageRange": [5, 10]}}` **Project section labels and pages only:** `{"op": "cat", "path": "my-doc-001", "select": ["sections.label", "sections.page", "sections.type"]}` **Preview first 5 sections:** `{"op": "head", "path": "my-doc-001", "n": 5}` **Document metadata:** `{"op": "stat", "path": "my-doc-001"}` **List entities:** `{"op": "find"}` **Entity detail + mentions:** `{"op": "open", "path": "ent_abc123"}` **Cross-document sections for an entity:** `{"op": "xref", "path": "ent_abc123"}` Key details [#key-details] `range` is an **object** with optional keys: `page` (integer), `pageRange` (two-element array `[from, to]`), `sectionTypes` (array of strings like `["table", "heading"]`). `select` is an **array of strings** — dotted paths like `["sections.label", "sections.page"]`. `scope` (grep) is one of `"sections"`, `"entities"`, `"relationships"`, or `"all"` (default). Pagination [#pagination] List ops (`ls`, `find`) paginate by cursor: pass the last item's `nextCursor` from a previous response to fetch the next page; `hasMore: false` signals the last page. Same idiom as `/v3/calls` and `/v3/outputs`. # Function Accuracy (/api/v3/function-accuracy) > For the complete documentation index, see [llms.txt](/llms.txt). Monitor, evaluate, and iterate on the quality of every function in your environment. Function Accuracy bundles two complementary loops: Evaluations (`/v3/eval`) [#evaluations-v3eval] Trigger and retrieve per-transformation evaluations. Evaluations run asynchronously and score each transformation's output against the function's schema for confidence, per-field hallucination detection, and relevance. Supported for `extract`, `transform`, `analyze`, and `join` events. 1. **Trigger** — `POST /v3/eval` queues jobs for a batch of transformation IDs. 2. **Poll** — `GET /v3/eval/results` returns the current state of each requested ID, partitioned into `results`, `pending`, and `failed`. Accepts either `eventIDs` (preferred) or `transformationIDs` as a comma-separated query parameter, and always keys the response by event KSUID. Up to 100 IDs may be submitted per request. Metrics, review, regression (`/v3/functions/{metrics,review,regression,compare}`) [#metrics-review-regression-v3functionsmetricsreviewregressioncompare] Roll evaluation results and user corrections up into actionable function-level signal: * **`GET /v3/functions/metrics`** — aggregate accuracy, precision, recall, F1, and confusion-matrix counts per function. * **`POST /v3/functions/review`** — sample-size estimation, confidence-bucketed distribution, PR-AUC, and per-threshold confidence intervals (Wald or Wilson) for picking review cutoffs. * **`POST /v3/functions/regression`** — replay corrected historical inputs against a new function version, producing a labeled regression dataset. * **`POST /v3/functions/regression/corrections`** — propagate baseline corrections onto the regression dataset so it can be scored. * **`POST /v3/functions/compare`** — compute aggregate and field-level lift between any two versions, optionally scoped to the regression dataset. All five endpoints support `extract` end-to-end on both the vision and OCR paths, alongside the legacy `transform` / `analyze` / `join` types. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Apply Baseline Corrections to Regression Transformations (/api/v3/function-accuracy/v3-apply-baseline-corrections) > For the complete documentation index, see [llms.txt](/llms.txt). **Copy baseline corrections onto regression transformations.** Looks up regression transformations created against the comparison version (`isRegression: true`, `correctedJSON IS NULL`), finds the matching baseline transformation by `referenceID`, and copies the baseline's `correctedJSON` onto the regression row via the same code path used by `POST /v3/events/{eventID}/feedback`. The applied corrections are immediately scored against the regression output, populating the confusion-matrix metrics used by `function-review` and `function-version-compare`. Works for every function type that produces correctable transformations, including `extract` on both the vision and OCR paths. (Previously the vision path silently dropped `is_regression` during the original regression run, so no rows matched the predicate — that has been fixed.) Returns counts plus the list of **event KSUIDs** whose underlying regression transformation received a correction. Errors (e.g. baseline transformation missing for a given `referenceID`) are returned per-row in the `errors` map, keyed by event KSUID, rather than aborting the whole call. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Create a dataset from function outputs (/api/v3/function-accuracy/v3-create-dataset-from-function-outputs) > For the complete documentation index, see [llms.txt](/llms.txt). **Build a dataset from a function's corrected outputs.** Pulls the reviewed/corrected outputs (transformations) selected by `query` and turns each into a dataset row: the input file, the corrected JSON, and the schema it was produced with. Columns are created and role-tagged automatically, so the dataset is self-describing and downstream model comparisons need no column names. Only `name` + `query` are required. Outputs matched by `query` that have no correction are **skipped**, not rejected — a query over a function's outputs normally spans both corrected and uncorrected ones. The request fails only when nothing corrected remains. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Set example column values (/api/v3/function-accuracy/v3-create-dataset-row-columns) > For the complete documentation index, see [llms.txt](/llms.txt). **Set column values on an example (row).** See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Delete an example column value (/api/v3/function-accuracy/v3-delete-dataset-row-column) > For the complete documentation index, see [llms.txt](/llms.txt). **Delete a single column value from an example (row).** See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Delete a dataset example (/api/v3/function-accuracy/v3-delete-dataset-row) > For the complete documentation index, see [llms.txt](/llms.txt). **Delete an example (row).** See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Cancel Score Run (/api/v3/function-accuracy/v3-eval-score-cancel) > For the complete documentation index, see [llms.txt](/llms.txt). **Cancel an in-flight score run.** Transitions the run to `cancelled`. Function calls already in flight are allowed to finish (best-effort cancellation via the job queue); results from completed pairs may still appear in subsequent GETs. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Score Function Against (input, expected) Pairs (/api/v3/function-accuracy/v3-eval-score-create) > For the complete documentation index, see [llms.txt](/llms.txt). **Score a function against a list of (input, expected) pairs.** Submits a batch of `(input, expected)` pairs, runs the named function over each input, and returns per-pair + aggregate accuracy metrics comparing the function's actual output to the provided expected JSON. Scoring runs asynchronously. The response carries a `scoreRunID`; poll `GET /v3/eval/score/{scoreRunID}` until `status` is one of `completed`, `error`, or `cancelled`. This request says only *what to extract*. How the output is compared against the expected value happens on the GET, recomputed from stored JSON each time. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Get Score Run (/api/v3/function-accuracy/v3-eval-score-get) > For the complete documentation index, see [llms.txt](/llms.txt). **Get the status and per-pair results of a score run.** The comparison happens here, not in the run: the function's output is compared against the expected value on every read, under the configuration supplied below. Re-reading the same run with different settings returns different metrics and costs nothing — no model calls are repeated. Comparison is exact and takes no configuration: a value matches the expected one or it is a miss. It is still redone on every read, so the numbers reflect the stored data as it is now. Returns `aggregate` once `status` reaches `completed` or `error`. `perPair` is populated incrementally — each pair's `fieldResults` appears as its underlying function call terminates. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Run Function Regression Testing (/api/v3/function-accuracy/v3-function-regression) > For the complete documentation index, see [llms.txt](/llms.txt). **Kick off a regression run between two versions of a function.** Replays a sample of corrected historical inputs against the comparison version, producing fresh transformations marked `isRegression: true`. Each new run returns the workflow `callID`s you can monitor via `GET /v3/calls/{callID}`. Supported for every function type that produces correctable transformations: `extract`, `transform`, `analyze`, `join`. For `extract` specifically, the regression sample is dispatched through the same OCR vs. vision path used at original call time (PDF, PNG, JPEG, HEIC, HEIF, WebP go through the vision worker; everything else goes through OCR → transform). The comparison version must share a schema-compatible output shape with the baseline; structural differences are reported as a 400 with the offending field-level diffs. Typical flow [#typical-flow] 1. `POST /v3/functions/regression` — queues calls, returns `{ originalReferenceID, callID }` per sample. 2. Wait (poll `GET /v3/calls/{callID}` or subscribe to webhooks). 3. `POST /v3/functions/regression/corrections` to copy baseline corrections onto the new regression transformations. 4. `POST /v3/functions/compare` to compare baseline vs comparison metrics for the regression dataset. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Function Review (/api/v3/function-accuracy/v3-function-review) > For the complete documentation index, see [llms.txt](/llms.txt). **Estimate human review requirements for a function.** Combines confusion-matrix metrics with the per-transformation evaluation scores (confidence / hallucination / relevance produced by the eval service) to compute: * A confidence-bucketed distribution of the function's outputs. * Sample-size estimates at configurable margin-of-error and confidence levels (Wald or Wilson intervals). * A precision-recall AUC and a per-threshold matrix you can use to pick a review cutoff. Supported for every function type that produces transformations and feeds the auto-evaluation pipeline: `extract`, `transform`, `analyze`, `join`. Extract works on both vision (PDF/PNG/JPEG/HEIC/HEIF/WebP) and OCR-routed inputs. Pass `isRegression: true` to scope the review to transformations created by a previous regression run (see `POST /v3/functions/regression`). See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Compare Metrics Between Function Versions (/api/v3/function-accuracy/v3-function-version-compare) > For the complete documentation index, see [llms.txt](/llms.txt). **Compare metrics between two function versions.** Computes aggregate and field-level lift/regression between any two versions of a function: accuracy, precision, recall, F1, and PR-AUC. Field-level changes are returned only for fields whose lift exceeds 1% in either direction. Supported for every function type that produces labeled transformations: `extract`, `transform`, `analyze`, `join`. Pass `isRegression: true` to compare only the regression dataset (rows produced by `POST /v3/functions/regression`) — the canonical way to judge a candidate version before promoting it. Defaults: `baselineVersionNum = currentVersionNum - 1`, `comparisonVersionNum = currentVersionNum`. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Get a dataset example (/api/v3/function-accuracy/v3-get-dataset-row) > For the complete documentation index, see [llms.txt](/llms.txt). **Get one example (row)** with its per-column values. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Get a dataset (/api/v3/function-accuracy/v3-get-dataset) > For the complete documentation index, see [llms.txt](/llms.txt). **Get a dataset**, including its columns and their roles. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Get Evaluation Results (/api/v3/function-accuracy/v3-get-evaluation-results) > For the complete documentation index, see [llms.txt](/llms.txt). **Fetch evaluation results for a batch of events.** Pass either `eventIDs` (preferred — the externally-stable V3 identifier) or `transformationIDs` as a comma-separated query parameter. Exactly one of the two must be provided. Up to 100 IDs per request. For each requested ID the response reports one of three states: a completed `result`, still-`pending`, or `failed`. Results, pending, and failed entries are all keyed by event KSUID regardless of which input form was used. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Get Function Metrics (/api/v3/function-accuracy/v3-get-function-metrics) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve performance metrics for functions based on labeled transformation data.** Calculates accuracy, precision, recall, F1, and the underlying confusion-matrix counts for each matching function by comparing model outputs against user corrections. Metrics are aggregated across every transformation the function has produced, regardless of function type — `extract`, `transform`, `analyze`, and `join` all populate the same `metrics` column on the transformation row, so v3 surfaces all of them uniformly. Filtering [#filtering] Combine `functionIDs` / `functionNames` / `types` to narrow the result set. `types` accepts `extract` alongside the legacy `transform` / `analyze` types (which remain readable). Pagination is cursor-based. Requirements [#requirements] A function only shows non-zero metrics once at least one of its transformations has been labeled — submit corrections via `POST /v3/events/{eventID}/feedback`. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # List dataset examples (/api/v3/function-accuracy/v3-list-dataset-rows) > For the complete documentation index, see [llms.txt](/llms.txt). **List a dataset's rows (examples).** See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # List datasets (/api/v3/function-accuracy/v3-list-datasets) > For the complete documentation index, see [llms.txt](/llms.txt). **List datasets** in the environment, newest first. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Cancel a model comparison (/api/v3/function-accuracy/v3-model-comparison-cancel) > For the complete documentation index, see [llms.txt](/llms.txt). **Cancel a comparison.** Cancels every entry's still-running scoring run. Entries that already finished keep their results; the comparison's status becomes `cancelled`. Returns the updated comparison. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Create a model comparison (/api/v3/function-accuracy/v3-model-comparison-create) > For the complete documentation index, see [llms.txt](/llms.txt). **Compare several function versions on one dataset.** Scores a saved Golden Data Set against each entry's function version (each as an eval-score run) and reports per-entry accuracy, latency, and cost, plus lift of every entry against the baseline (the first entry). Entries may span different functions and different versions. Runs asynchronously. The response carries a `comparisonID`; poll `GET /v3/model-comparisons/{comparisonID}` until `status` is `complete`. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Get a model comparison (/api/v3/function-accuracy/v3-model-comparison-get) > For the complete documentation index, see [llms.txt](/llms.txt). **Get a comparison's metrics.** Returns each entry's metric bundle (aggregate + per-field accuracy, latency, dataset baseline) and its lift against the baseline entry. Every entry is re-scored on each read, so the parameters below decide the answer and none of them had to be chosen before the run started. Re-reading with different settings costs nothing — no model calls are repeated. Structure — how array elements pair up: * `orderMatching`: score array elements in order instead of as sets Strictness — how close two values must be to count as equal: * `matchMode`: `strict` (default) | `normalized` | `fuzzy` `matchMode` is the whole of strictness. There are no separate thresholds to tune: `normalized` already compares numbers numerically and dates by calendar value rather than by spelling, and `fuzzy` adds a fixed similarity pass over free text. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # List model comparisons (/api/v3/function-accuracy/v3-model-comparison-list) > For the complete documentation index, see [llms.txt](/llms.txt). **List model comparisons for the environment**, newest first. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Trigger Transformation Evaluations (/api/v3/function-accuracy/v3-trigger-transformation-evaluations) > For the complete documentation index, see [llms.txt](/llms.txt). **Queue evaluation jobs for a batch of transformations.** Evaluations run asynchronously and score each transformation's output against the function's schema for confidence, hallucination detection, and relevance. Transformations must belong to events of a supported type: `extract`, `transform`, `analyze`, or `join`. Returns immediately with a summary of queued vs. skipped transformations and per-transformation errors. Poll `GET /v3/eval/results` to retrieve results once evaluations complete. See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Update an example column value (/api/v3/function-accuracy/v3-update-dataset-row-column) > For the complete documentation index, see [llms.txt](/llms.txt). **Update a single column value on an example (row).** See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Update a dataset example (/api/v3/function-accuracy/v3-update-dataset-row) > For the complete documentation index, see [llms.txt](/llms.txt). **Update an example (row).** See also [#see-also] * [System overview](/guide/system-overview) — evaluating extraction quality # Functions (/api/v3/functions) > For the complete documentation index, see [llms.txt](/llms.txt). Functions are the core building blocks of data transformation in Bem. Each function type serves a specific purpose: * **Extract**: Extract structured JSON data from unstructured documents (PDFs, emails, images, spreadsheets), with optional layout-aware bounding-box extraction * **Route**: Direct data to different processing paths based on conditions * **Split**: Break multi-page documents into individual pages for parallel processing * **Join**: Combine outputs from multiple function calls into a single result * **Parse**: Render documents into a navigable structure of page-aware sections, named entities, and relationships — designed to be walked by an LLM agent via the [File System API](/api/v3/file-system) (`POST /v3/fs`). Two toggles, both `true` by default: `extractEntities` controls per-document entity and relationship extraction; `linkAcrossDocuments` merges entities into one canonical record per real-world thing across the environment, populating cross-document memory. * **Payload Shaping**: Transform and restructure data using JMESPath expressions * **Enrich**: Enhance data with semantic search against collections * **Send**: Deliver workflow outputs to downstream destinations Use these endpoints to create, update, list, and manage your functions. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # Copy a Function (/api/v3/functions/v3-copy-function) > For the complete documentation index, see [llms.txt](/llms.txt). **Copy a function to a new name within the same environment.** Forks the source function's current configuration into a brand-new function. The copy starts at `versionNum: 1` regardless of how many versions the source has — version history is not carried over. Useful for experimenting with schema or prompt changes against a stable production function without disturbing existing callers. The destination name must be unique in the environment. A copy does not migrate workflows: existing workflow nodes continue to reference the original function. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # Create a Function (/api/v3/functions/v3-create-function) > For the complete documentation index, see [llms.txt](/llms.txt). **Create a function.** The function `type` determines which configuration fields are required — see the `CreateFunctionV3` discriminated union and [Function types overview](/guide/function-types/overview) for the per-type contract. The response contains both `functionID` and `functionName`. Either is a stable handle you can use elsewhere; most workflows reference functions by `functionName` because it's human-readable. Naming rules [#naming-rules] * `functionName` must be unique per environment. * Allowed characters: letters, digits, hyphens, and underscores. * Names cannot be reused after deletion within the same environment for at least the retention window of the previous record. The new function is created at `versionNum: 1`. Subsequent `PATCH /v3/functions/{functionName}` calls produce new versions — the version-1 configuration remains immutable and addressable. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # Delete a Function (/api/v3/functions/v3-delete-function) > For the complete documentation index, see [llms.txt](/llms.txt). **Delete a function and every one of its versions.** Permanent. Running and queued calls that reference this function continue to completion against the version they captured at call time, but no new calls can target it. Before deleting [#before-deleting] Workflow nodes that reference this function will fail at call time after deletion. List workflows that reference it first: ``` GET /v3/workflows?functionNames=my-function ``` Update or remove those workflows, or create a replacement function and re-point the workflow nodes, before deleting. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # Get a Function Version (/api/v3/functions/v3-get-function-version) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a specific historical version of a function.** Versions are immutable. Use this endpoint to inspect what a function looked like at the moment a particular call was made — every event and transformation records the function version it ran against. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # Get a Function (/api/v3/functions/v3-get-function) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a function's current version by name.** Returns the function record with its `currentVersionNum` and the configuration of that version. To inspect a historical version, use `GET /v3/functions/{functionName}/versions/{versionNum}`. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # List Function Versions (/api/v3/functions/v3-list-function-versions) > For the complete documentation index, see [llms.txt](/llms.txt). **List every version of a function.** Returns the full version history, newest-first. Each row captures the configuration the function had between updates. Useful for audits ("when did this schema change?") and for diffing two versions before promoting an update to production. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # List Functions (/api/v3/functions/v3-list-functions) > For the complete documentation index, see [llms.txt](/llms.txt). **List functions in the current environment.** Returns each function's current version. Combine filters freely — they AND together. Filtering [#filtering] * `functionIDs` / `functionNames`: exact-match identity filters. * `displayName`: case-insensitive substring match. * `types`: one or more of `extract`, `classify`, `split`, `join`, `enrich`, `payload_shaping`. Legacy `transform`, `analyze`, `route`, and `send` types remain readable via this filter. * `tags`: returns functions tagged with any of the supplied tags. * `workflowIDs` / `workflowNames`: returns only functions referenced by the named workflows. Useful for "what functions does this workflow depend on?" lookups. * `workflowIDVersionNums` / `workflowNameVersionNums`: the same lookup pinned to a specific workflow version. Pagination [#pagination] Cursor-based with `startingAfter` and `endingBefore` (functionIDs). Default limit 50, maximum 100. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # Update a Function (/api/v3/functions/v3-update-function) > For the complete documentation index, see [llms.txt](/llms.txt). **Update a function. Updates create a new version.** The previous version remains addressable and immutable. Workflow nodes that pinned the function with a `versionNum` continue to use the pinned version; nodes that reference the function by name with no version automatically pick up the new version on their next call. What you can change [#what-you-can-change] Any field allowed by the function's type. Most commonly: `outputSchema` (for `extract`/`join`), `classifications` (for `classify`), `displayName`, and `tags`. Versioning behaviour [#versioning-behaviour] * Each successful update increments `currentVersionNum` by 1. * `displayName`, `tags`, and `functionName` updates also create a new version, so the version history is a complete record of every change. * To revert, fetch the previous version and re-submit its configuration as a new update — versions themselves are immutable. See also [#see-also] * [Function types overview](/guide/function-types/overview) — extract, classify, split, join, enrich, parse, payload shaping * [Schema building guide](/guide/schema-building) — designing outputSchema # Knowledge Graph (/api/v3/knowledge-graph) > For the complete documentation index, see [llms.txt](/llms.txt). Read the cross-document knowledge graph — the canonical entities and the directed relations between them that the Parse pipeline populates when `linkAcrossDocuments` is enabled. * **`GET /v3/entities/{id}/relations`** returns the inbound and outbound edges incident to one entity, split by direction. Supports `direction`, an exact `relationType` filter, and cursor pagination over edges. A merged-away entity id transparently resolves to its surviving canonical entity. * **`GET /v3/knowledge-graph`** returns the graph as `{ nodes, edges }`, paginating over edges. The `nodes` for a page are the distinct endpoint entities of that page's edges (both endpoints of every edge are included). Filter with `type[]`, `since`, and `search`; an edge is returned only when both of its endpoints survive the entity filters. Both endpoints take an optional `bucket` (`bkt_...`) to scope the read to a single bucket; omit it for the unscoped account+environment view. # Get an Entity's Relations (/api/v3/knowledge-graph/v3-get-entity-relations) > For the complete documentation index, see [llms.txt](/llms.txt). # Retrieve the Knowledge Graph (/api/v3/knowledge-graph/v3-get-knowledge-graph) > For the complete documentation index, see [llms.txt](/llms.txt). # Archive a parsed document (/api/v3/memory/v3-archive-parsed-document) > For the complete documentation index, see [llms.txt](/llms.txt). # Outputs (/api/v3/outputs) > For the complete documentation index, see [llms.txt](/llms.txt). Retrieve terminal non-error output events from workflow calls. Outputs are events produced by successful terminal function steps — steps that completed without errors and did not spawn further downstream function calls. A single workflow call may produce multiple outputs (e.g. from a split-then-transform pipeline). Outputs and errors from the same call are not mutually exclusive: a partially-completed workflow may have both. Use `GET /v3/outputs` to list outputs across calls, or `GET /v3/outputs/{eventID}` to retrieve a specific output. To get outputs scoped to a single call, filter by `callIDs`. See also [#see-also] * [System overview](/guide/system-overview) — events and transformations # Get an Output (/api/v3/outputs/v3-get-output) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a single output event by ID.** Fetches any non-error event by its `eventID`. Returns `404` if the event does not exist or if it is an error event (use `GET /v3/errors/{eventID}` for those). See also [#see-also] * [System overview](/guide/system-overview) — events and transformations # List Outputs (/api/v3/outputs/v3-list-outputs) > For the complete documentation index, see [llms.txt](/llms.txt). **List terminal non-error output events.** Returns events that represent successful terminal outputs — primary events (non-split-collection) that did not trigger any downstream function calls. Error events are excluded; use `GET /v3/errors` to retrieve those. Intermediate Events [#intermediate-events] By default, intermediate events (those that spawned a downstream function call in a multi-step workflow) are excluded. Pass `includeIntermediate=true` to include them. Filtering [#filtering] Filter by call, workflow, function, or reference ID. Multiple filters are ANDed together. See also [#see-also] * [System overview](/guide/system-overview) — events and transformations # Schema Inference (/api/v3/schema-inference) > For the complete documentation index, see [llms.txt](/llms.txt). Infer JSON Schemas from uploaded documents using AI. Upload a file (PDF, image, spreadsheet, email, etc.) and receive a general-purpose JSON Schema that captures the document's structure. The inferred schema can be used directly as the `outputSchema` when creating Extract functions. The schema is designed to be broadly applicable to documents of the same type, not just the specific file uploaded. See also [#see-also] * [Schema building guide](/guide/schema-building) — from inferred to production-ready # Infer Schema from File (/api/v3/schema-inference/v3-infer-schema) > For the complete documentation index, see [llms.txt](/llms.txt). **Analyze a file and infer a JSON Schema from its contents.** Accepts a file via multipart form upload and uses Gemini to analyze the document, returning a description of its contents, an inferred JSON Schema capturing all extractable fields, and document classification metadata. The returned schema is designed to be reusable across many similar documents of the same type, not just the specific file uploaded. It can be used directly as the `outputSchema` when creating a Transform function. The endpoint also detects whether the file contains multiple bundled documents and classifies the content nature (textual, visual, audio, video, or mixed). Supported file types [#supported-file-types] PDF, PNG, JPEG (including JFIF), HEIC, HEIF, WebP, CSV, XLS, XLSX, DOCX, JSON, HTML, XML, EML, plain text, WAV, MP3, M4A, MP4. File size limit [#file-size-limit] Maximum file size is **20 MB**. Examples [#examples] Using curl: ```bash curl -X POST https://api.bem.ai/v3/infer-schema \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@invoice.pdf" ``` Using the Bem CLI: ```bash bem infer-schema create --file @invoice.pdf ``` See also [#see-also] * [Schema building guide](/guide/schema-building) — from inferred to production-ready # Subscriptions (/api/v3/subscriptions) > For the complete documentation index, see [llms.txt](/llms.txt). Subscriptions wire up notifications for the events your functions and collections produce. Most subscriptions target a single function (by `functionName` or `functionID`) or a single collection (by `collectionName` or `collectionID`) and select a `type` corresponding to the event you want to receive — for example `transform`, `route`, `join`, `evaluation`, `error`, `enrich`, or `collection_processing`. Entity-lifecycle events are account-wide and target no function or collection. Set `type` to one of the following and provide a `webhookURL` (these event types support webhook delivery only): * `entity_proposed` — an entity entered the `proposed` curation status (queued for review). * `entity_validated` — an entity was approved/validated by a reviewer. * `entity_rejected` — an entity was rejected by a reviewer. Each entity-lifecycle delivery is a JSON POST describing the transition (`entityID`, `typeName`, `priorStatus`, `newStatus`, optional `actorUserID` and `reason`, and a `timestamp`). Deliveries can be sent to any combination of: * `webhookURL` — HTTPS endpoint that receives a JSON POST per event. * `s3Bucket` + `s3FilePath` — sync output JSON into an AWS S3 prefix you own. * `googleDriveFolderID` — drop output JSON into a Google Drive folder. Use `disabled: true` to pause delivery without deleting the subscription. Updates follow conventional PATCH semantics — only the fields you include are changed. # Create a Subscription (/api/v3/subscriptions/v3-create-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). Creates a new subscription to listen to transform or error events. # Delete a Subscription (/api/v3/subscriptions/v3-delete-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). Deletes an existing subscription. # Get a Subscription (/api/v3/subscriptions/v3-get-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). # List Subscriptions (/api/v3/subscriptions/v3-list-subscriptions) > For the complete documentation index, see [llms.txt](/llms.txt). # Update a Subscription (/api/v3/subscriptions/v3-update-subscription) > For the complete documentation index, see [llms.txt](/llms.txt). Updates an existing subscription. Follow conventional PATCH behavior, so only included fields will be updated. # Views (/api/v3/views) > For the complete documentation index, see [llms.txt](/llms.txt). Views are tabular projections over the `transformations` your functions produce — a saved query that turns raw extracted JSON into a filterable, paginatable, aggregatable table. Anatomy [#anatomy] A view declares: * One or more **functions** to read from (by `functionID` or `functionName`). * A list of **columns**, each pinned to a `valueSchemaPath` (a JSON Pointer into the function's output schema). * Optional **filters** (string equality, numeric comparators, null-checks) and **aggregations** (`count`, `count_distinct`, `sum`, `average`, `min`, `max`). Views are versioned: every update produces a new version, and the previous version remains immutable and addressable. Function types that produce transformations with an output schema — `extract`, `transform`, `analyze`, `join` — are all queryable through views; `extract` works uniformly across vision and OCR inputs. Reading data [#reading-data] * **`POST /v3/views/table-data`** — paginated rows of column values. Each row reports the underlying event's `eventID` (the externally-stable KSUID used everywhere else in V3) plus the projected column values. * **`POST /v3/views/aggregation-data`** — group-by-able aggregate values across the same query surface. Both endpoints take a `timeWindow` to bound the transformation set and require at least one `function` to read from. # Create a View (/api/v3/views/v3-create-view) > For the complete documentation index, see [llms.txt](/llms.txt). **Create a view.** A view is a tabular projection over the `transformations` produced by one or more functions. Each column declares a `valueSchemaPath` — a JSON Pointer path into the function's output schema — and the view can additionally carry filters and aggregations. Supported for every function type that produces correctable transformations and an output schema: `extract`, `transform`, `analyze`, `join`. Extract works on both vision (PDF/PNG/JPEG/HEIC/HEIF/WebP) and OCR-routed inputs — the resulting rows surface through views uniformly. The new view is created at `versionNum: 1`. Subsequent updates produce new versions; the version-1 configuration remains addressable. # Delete a View (/api/v3/views/v3-delete-view) > For the complete documentation index, see [llms.txt](/llms.txt). **Delete a view and every one of its versions.** Permanent. Any cached data-table or aggregation result clients have fetched remains valid, but subsequent calls to `POST /v3/views/table-data` or `POST /v3/views/aggregation-data` for this view will fail. # Generate View Aggregation Data (/api/v3/views/v3-generate-view-aggregation-data) > For the complete documentation index, see [llms.txt](/llms.txt). **Generate aggregation results for a view.** Executes each aggregation declared on the view against the `transformations` rows produced by the named functions inside the supplied `timeWindow`, applying the view's filters. Supported aggregation functions: `count`, `count_distinct`, `sum`, `average`, `min`, `max`. Grouped aggregations return up to 200 groups per aggregation; non-grouped aggregations return a single group with an empty `groupName`. As with table-data, the `functions` field is required. # Generate View Table Data (/api/v3/views/v3-generate-view-table-data) > For the complete documentation index, see [llms.txt](/llms.txt). **Generate paginated table data for a view.** Executes the view's query against `transformations` rows produced by the named functions inside the supplied `timeWindow`, applies the view's filters, and returns matching rows. Each row reports the event `eventID` (externally-stable KSUID) plus the projected column values. The `functions` field is required — at least one `functionID` or `functionName` must be supplied. `limit` defaults to 50 with a maximum of 200; `offset` is zero-based. The response's `totalCount` reflects the match count before pagination, so paging can be driven off it. # Get a View (/api/v3/views/v3-get-view) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a view by ID.** Returns the view's current version. To inspect a historical version, fetch the list of versions on the View object and re-request with the desired version pinned (versions are immutable once created). # List Views (/api/v3/views/v3-list-views) > For the complete documentation index, see [llms.txt](/llms.txt). **List views in the current environment, optionally filtered by the functions they read from.** Views are tabular projections over `transformations` rows: each view names one or more functions and a list of columns (JSON-pointer paths into `extractedJson`), and produces a uniform table that can be filtered, paginated, and aggregated. Filters AND together when combined. Pagination is cursor-based on `viewID`; default limit is 50, maximum 100. # Update a View (/api/v3/views/v3-update-view) > For the complete documentation index, see [llms.txt](/llms.txt). **Update a view. Updates create a new version.** The previous version remains addressable and immutable. The new configuration is fully replacing — pass the complete view body, not a patch. The version number is auto-incremented. # Webhooks (/api/v3/webhooks) > For the complete documentation index, see [llms.txt](/llms.txt). bem POSTs a JSON event to your configured webhook URL each time a subscribed function call, workflow output, or collection-processing job fires. This section is the reference for those deliveries: the payload shape per event type, plus the endpoints you use to manage the signing secret. Every variant shares the same envelope — function/workflow IDs, timestamps, the inbound email that triggered the call, and so on — and adds a payload field that depends on the function type. The `eventType` field on the body is the discriminator: dispatch on it to select which payload shape to expect. SDKs generated from this spec expose a `webhooks.unwrap()` helper that performs the dispatch and returns a typed event. Payloads [#payloads] | `eventType` | Payload | Schema | | ----------------------- | ---------------------------------------------------------------------------- | --------------------------- | | `extract` | [Extract event](/api/v3/webhooks/events/extract) | `ExtractEvent` | | `classify` | [Classify event](/api/v3/webhooks/events/classify) | `ClassifyEvent` | | `parse` | [Parse event](/api/v3/webhooks/events/parse) | `ParseEvent` | | `split_collection` | [Split collection event](/api/v3/webhooks/events/split-collection) | `SplitCollectionEvent` | | `split_item` | [Split item event](/api/v3/webhooks/events/split-item) | `SplitItemEvent` | | `join` | [Join event](/api/v3/webhooks/events/join) | `JoinEvent` | | `enrich` | [Enrich event](/api/v3/webhooks/events/enrich) | `EnrichEvent` | | `payload_shaping` | [Payload shaping event](/api/v3/webhooks/events/payload-shaping) | `PayloadShapingEvent` | | `send` | [Send event](/api/v3/webhooks/events/send) | `SendEvent` | | `evaluation` | [Evaluation event](/api/v3/webhooks/events/evaluation) | `EvaluationEvent` | | `collection_processing` | [Collection processing event](/api/v3/webhooks/events/collection-processing) | `collectionProcessingEvent` | | `error` | [Error event](/api/v3/webhooks/events/error) | `ErrorEvent` | Signing secret [#signing-secret] Every delivery includes a `bem-signature` header in the format `t={unix_timestamp},v1={hex_hmac_sha256}`. The signature covers `{timestamp}.{raw_request_body}` and is computed with HMAC-SHA256 using the active signing secret for your environment. To verify a payload: 1. Parse `bem-signature: t={timestamp},v1={signature}`. 2. Construct the signed string: `{timestamp}.{raw_request_body}`. 3. Compute HMAC-SHA256 of that string using your secret. 4. Reject the request if the hex digest doesn't match `v1`, or if the timestamp is more than a few minutes old. Manage the secret with these endpoints: * [**Generate a signing secret**](/api/v3/webhooks/secret/generate-secret) — `POST /v3/webhook-secret`. Returns the new secret in full exactly once. * [**Get the signing secret**](/api/v3/webhooks/secret/get-secret) — `GET /v3/webhook-secret`. Returns the active secret. * [**Revoke the signing secret**](/api/v3/webhooks/secret/revoke-secret) — `DELETE /v3/webhook-secret`. Webhook deliveries continue but are unsigned until a new secret is generated. For zero-downtime rotation, briefly accept both the old and new secret in your verification logic before revoking the old one. Retries [#retries] bem treats any non-2XX response (or a transport failure) as a delivery error and retries with exponential backoff. Return a 2XX as soon as you have durably queued the payload — do not block on downstream work. See also [#see-also] * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Workflows (/api/v3/workflows) > For the complete documentation index, see [llms.txt](/llms.txt). Workflows orchestrate one or more functions into a directed acyclic graph (DAG) for document processing. Use these endpoints to create, update, list, and manage workflows, and to invoke them with file input via `POST /v3/workflows/{workflowName}/call`. The call endpoint accepts files as either multipart form data or JSON with base64-encoded content. In the Bem CLI, use `@path/to/file` inside JSON values to automatically read and encode files: ``` bem workflows call --workflow-name my-workflow \ --input.single-file '{"inputContent": "@file.pdf", "inputType": "pdf"}' \ --wait ``` See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # Copy a Workflow (/api/v3/workflows/v3-copy-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Copy a workflow to a new name.** Forks the source workflow's current version into a brand-new workflow at `versionNum: 1`. The full node graph and edges are carried over, but the *functions* the copied nodes reference are shared, not duplicated — both workflows now point at the same functions. Useful for forking a production workflow to test a topology change without disturbing the live caller. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # Create a Workflow (/api/v3/workflows/v3-create-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Create a workflow.** A workflow is a directed acyclic graph of nodes (each pointing at a function) with one entry point (`mainNodeName`). The graph runs end-to-end on every call. Required structure [#required-structure] * `name`: unique within the environment, alphanumeric plus hyphens and underscores. * `mainNodeName`: must match one of the `nodes[].name` values, and must not be the destination of any edge. * `nodes`: at least one. Each node has a unique `name` and a `function` reference (by `functionName` or `functionID`, optionally pinned to a `versionNum`). * `edges`: optional for single-node workflows. For branching sources (Classify, semantic Split), each edge carries a `destinationName` matching a `classifications[].name` or `itemClasses[].name` on the source function. The created workflow is at `versionNum: 1`. Subsequent `PATCH /v3/workflows/{workflowName}` calls produce new versions. Common patterns [#common-patterns] * **Single-node**: one extract/classify function, no edges. * **Sequential**: extract → enrich → payload\_shaping (linear edges). * **Branching**: classify → multiple extracts (one edge per classification name). * **Split-then-process**: split → multiple extracts (one edge per item class). See [Workflows explained](/guide/workflows-explained) for end-to-end examples of each pattern. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # Delete a Workflow (/api/v3/workflows/v3-delete-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Delete a workflow and every one of its versions.** Permanent. Running and queued calls against this workflow continue to completion against the version they captured at call time; subsequent attempts to call the workflow return `404 Not Found`. Functions referenced by the deleted workflow are not removed — they remain available to other workflows or for direct reference. Any connectors attached to the workflow are torn down first. Teardown is best-effort: per-connector failures are reported in `connectorErrors` but do not block the deletion, so check that array rather than relying on the status code alone. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # Get a Workflow Version (/api/v3/workflows/v3-get-workflow-version) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a specific historical version of a workflow.** Versions are immutable. Use this endpoint to see what a workflow looked like at the moment a particular call was made — every call record carries the workflow `versionNum` it ran against. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # Get a Workflow (/api/v3/workflows/v3-get-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Retrieve a workflow's current version by name.** Returns the full workflow record: `currentVersionNum`, `mainNodeName`, the `nodes` array (with each node's function reference and pinned `versionNum` if any), and the `edges` array. To inspect a historical version, use `GET /v3/workflows/{workflowName}/versions/{versionNum}`. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # List Workflow Versions (/api/v3/workflows/v3-list-workflow-versions) > For the complete documentation index, see [llms.txt](/llms.txt). **List every version of a workflow.** Versions are immutable. Each row captures what the workflow looked like between updates: graph topology, metadata, and timestamps. Returns newest-first by default. Cursor pagination via `startingAfter` / `endingBefore` over `versionNum`. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # List Workflows (/api/v3/workflows/v3-list-workflows) > For the complete documentation index, see [llms.txt](/llms.txt). **List workflows in the current environment.** Returns each workflow's current version, including its node graph and main node. Combine filters freely — they AND together. Filtering [#filtering] * `workflowIDs` / `workflowNames`: exact-match identity filters. * `displayName`: case-insensitive substring match. * `tags`: returns workflows tagged with any of the supplied tags. * `functionIDs` / `functionNames`: returns only workflows that reference the named functions in any node. Useful for "which workflows depend on this function?" lookups before changing or deleting a function. * `functionIDVersionNums` / `functionNameVersionNums`: the same lookup narrowed to nodes pinned to a specific function version. Pagination [#pagination] Cursor-based with `startingAfter` and `endingBefore` (workflowIDs). Default limit 50, maximum 100. See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # Update a Workflow (/api/v3/workflows/v3-update-workflow) > For the complete documentation index, see [llms.txt](/llms.txt). **Update a workflow. Updates create a new version.** The previous version remains addressable and immutable. Pending and running calls captured at the old version continue against it; new calls run against the new version. Topology updates [#topology-updates] To change the graph you must provide `mainNodeName`, `nodes`, AND `edges` together — partial topology updates are rejected. The full graph is replaced atomically. Metadata-only updates [#metadata-only-updates] Omit all three fields to update only `displayName`, `tags`, or `name` while keeping the topology of the current version. Reverting [#reverting] To roll back, fetch the desired prior version and resubmit its `mainNodeName`/`nodes`/`edges` as a new update. Versions themselves are immutable — there is no "pin to version N" operation at the workflow level (use `nodes[].function.versionNum` to pin individual functions). See also [#see-also] * [Workflows explained](/guide/workflows-explained) — concepts and patterns * [Quickstart](/guide/quickstart) — end-to-end example # Classify event (/api/v3/webhooks/events/classify) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a `classify` function chooses a category for a document. The selected label is in `choice` and matches one of the function version's declared `classifications`. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Collection processing event (/api/v3/webhooks/events/collection-processing) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a batch add or update against a Collection finishes. Reports how many items were processed and lists the IDs that were touched. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Enrich event (/api/v3/webhooks/events/enrich) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when an `enrich` function augments an input payload with results from semantic search against a collection. The enriched payload is in `enrichedContent`. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Error event (/api/v3/webhooks/events/error) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a function call terminates with an error that's surfaceable to the customer. The user-facing failure reason is in `message`. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Evaluation event (/api/v3/webhooks/events/evaluation) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a function-accuracy evaluation completes for a transformation. The evaluator's scores and per-field judgments are in `result`; check `status` for terminal success/failure. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Extract event (/api/v3/webhooks/events/extract) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when an `extract` function completes processing of a document and produces structured JSON. The body's `transformedContent` matches the function's `outputSchema`. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Join event (/api/v3/webhooks/events/join) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a `join` function combines outputs from multiple upstream calls into a single transformed payload. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Parse event (/api/v3/webhooks/events/parse) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a `parse` function renders a document into a navigable structure of sections, entities, and relationships. The full parsed structure is in `transformedContent`. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Payload shaping event (/api/v3/webhooks/events/payload-shaping) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a `payload_shaping` function applies its JMESPath expressions to reshape an input payload. The reshaped result is in `transformedContent`. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Send event (/api/v3/webhooks/events/send) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a `send` function attempts to deliver a payload to a downstream destination (webhook, S3, or Google Drive). The result of that delivery — including the destination's response — is in the body. The original payload that was delivered is mirrored in `deliveredContent`. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Split collection event (/api/v3/webhooks/events/split-collection) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered when a `split` function completes its collection-level pass and emits the list of items it found. Each item is fanned out to a downstream function as its own input. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Split item event (/api/v3/webhooks/events/split-item) > For the complete documentation index, see [llms.txt](/llms.txt). Delivered for each individual item produced by a `split` function. Carries the item's offset within its parent collection and a presigned URL to its bytes. For signing-secret setup and retry semantics that apply to every variant, see the [Webhooks overview](/api/v3/webhooks). See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Generate Webhook Secret (/api/v3/webhooks/secret/generate-secret) > For the complete documentation index, see [llms.txt](/llms.txt). **Generate a new webhook signing secret.** Creates a new signing secret for this environment (or replaces the existing one). The new secret is returned in full exactly once — store it securely. After rotation all newly delivered webhooks will be signed with the new secret. Update your verification logic before calling this endpoint if you need zero-downtime rotation. See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Get Webhook Secret (/api/v3/webhooks/secret/get-secret) > For the complete documentation index, see [llms.txt](/llms.txt). **Get the current webhook signing secret.** Returns the active secret used to sign outbound webhook deliveries via the `bem-signature` header. Returns 404 if no secret has been generated for this environment yet. Use the secret to verify incoming webhook payloads: 1. Parse `bem-signature: t={timestamp},v1={signature}`. 2. Construct the signed string: `{timestamp}.{raw request body}`. 3. Compute HMAC-SHA256 of that string using the secret. 4. Compare the hex digest against `v1`. 5. Reject requests where the timestamp is more than a few minutes old. See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify # Revoke Webhook Secret (/api/v3/webhooks/secret/revoke-secret) > For the complete documentation index, see [llms.txt](/llms.txt). **Revoke the current webhook signing secret.** Deletes the active signing secret. Webhook deliveries will continue but will no longer include a `bem-signature` header until a new secret is generated. See also [#see-also] * [Webhooks overview](/api/v3/webhooks) — envelope, signing, retries * [Webhooks guide](/guide/webhooks) — subscribe, receive, verify