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
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:
| 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
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 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 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
;;
esacimport 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"
fiFor 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 60sclient = Bem(timeout=35.0) # seconds; default is 60simport "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 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
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 deterministiccallReferenceIDso 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=truequery param. Mixing them silently no-ops. - Looking for
call.output(singular) on a 200. It'scall.outputs[](plural array). See Reading workflow call outputs.
Related
Polling and retries
Polling cadence, idempotency, and retry semantics for the 202 fallback path
Webhooks
Subscribe an endpoint and verify signed deliveries
Reading workflow call outputs
Where the extracted data lives in the response, and how to access it in every SDK
Call a Workflow API
POST /v3/workflows/{workflowName}/call — every parameter