Synchronous Mode

Block on a workflow call with wait=true for up to 30 seconds — the contract, latency expectations, and how to use it in production

Hand off to an LLM

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

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.

# 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:

OutcomeHTTP statuscall.statusBody
Completed within 30s200 OKcompletedFinal call object with outputs[] populated
Failed within 30s500 Internal Server ErrorfailedCall object with errors[] populated
Still running at 30s202 Acceptedpending or runningCall object with outputs[] empty (no terminal events yet)

wait=true doesn't 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

Most calls finish well inside 30 seconds. As a rough order-of-magnitude guide:

Workflow shapeTypical latencyLikely outcome
Single Extract on a short PDF (1–3 pages)a few seconds200
Extract → Enrich (single PDF, semantic catalog match)~10s200
Classify → Extract (single PDF)~10s200
Single Extract on a long or scan-heavy PDF (15+ pages)15–30susually 200, sometimes 202
Split + per-piece Extract on a multi-document filetens of secondsoften 202
OCR-heavy or large multi-modal inputstens of seconds to minutesusually 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 below.

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.

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
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");
}
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)
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)
}
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 SingleFile { 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);
}
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:

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. For the response payload shape — where transformedContent vs enrichedContent etc. lives — see Reading workflow call outputs.

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.

curl --max-time 35 -X POST "https://api.bem.ai/v3/workflows/.../call?wait=true" ...
const client = new Bem({ timeout: 35_000 });  // ms; default is 60s
client = Bem(timeout=35.0)  # seconds; default is 60s
import "github.com/bem-team/bem-go-sdk/option"

client := bem.NewClient(option.WithRequestTimeout(35 * time.Second))
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

You're buildingUse
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 machineSync — simplest code path
A backend RPC where the caller has its own request budget under 30sSync — 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

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 for the full semantics.

Don't 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

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.

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 for the subscription setup and signature verification.

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

  • 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.

On this page