Async Research with Parallel
Start a long-running research job and take its answer back as a verified webhook.
This guide builds a service that starts a Parallel
research run, answers the caller immediately with a receipt, and takes the real
answer minutes later as a webhook it can prove came from Parallel. It follows
samples/parallel-research.
The shape recurs with any slow provider: one flow starts the work, another
finishes it, joined only by an id you chose.
Set up the connector
Create an API key on platform.parallel.ai, and
copy your webhook secret from Settings → Webhooks, including its whsec_
prefix, which is part of the value.
Declare the connector with both.
connectors:
- name: research
type: parallel
settings:
apiKey: ${PARALLEL_API_KEY}
webhookSecret: ${PARALLEL_WEBHOOK_SECRET}The secret is decoded at startup: whsec_ says the rest is base64 key
material, and a malformed one fails the service immediately.
Expose the service so Parallel can reach it (a tunnel such as ngrok while developing) and point the callback URL at the route you will own:
export PARALLEL_WEBHOOK_URL=https://your-host/parallel/eventsAnswer now, when you can
parallel-search answers in the same request, and its response becomes the
body:
- type: parallel-search
settings:
connector: research
objective: body.question
searchQueries: '[body.question]'
mode: fast
maxResults: 8The objective says, in natural language, what the caller is after; the
searchQueries are the searches to run toward it. Parallel ranks results against
the objective, so both are required.
Start the run, and hand back a receipt
parallel-task-run does not wait. It returns a receipt, {run_id, status, ...},
and is the one block in this connector whose result does not become the
body: the handle goes into vars.parallelRun, so the flow still has its own body
to respond with.
- type: parallel-task-run
name: start-run
settings:
connector: research
processor: core
input: body.question
metadata: '{"correlationId": body.id}'
webhookURL: ${PARALLEL_WEBHOOK_URL}
- type: set-payload
name: receipt
settings:
value: '{"runId": vars.parallelRun.run_id, "status": vars.parallelRun.status}'Whatever you put in metadata comes back on the webhook, which is how the
callback finds the request that started the run. Use your own correlation id, not
Parallel's run_id, which you only learn after the run exists.
Take the answer back, and prove it
The callback route is reachable by anyone on the internet, so its first block refuses anything not provably from Parallel.
- name: callback
source:
connector: api
type: http
settings:
path: /parallel/events
methods: [POST]
headers: [webhook-id, webhook-timestamp, webhook-signature]
rawBody: true
process:
- type: parallel-verify-request
name: verify
settings:
connector: researchTwo lines of that source are required. headers: copies all three signature
headers into variables; the id and timestamp are inside the signed string, which
stops a captured signature being replayed under another event id, or outside a
five-minute window. rawBody: true delivers the exact request bytes, because a
re-serialized body no longer verifies; after verifying, the block parses them
back into body, so body.data.status works downstream. Everything after
verify runs only for requests Parallel sent.
- type: log
name: log-result
settings:
logger: out
message: >
"run " + body.data.run_id + " is " + body.data.status +
" (correlationId " + body.data.metadata.correlationId + ")"Unlike Notion, there is no bootstrap handshake: Parallel's
secret exists in its dashboard before the first webhook does, so a
parallel-verify-request whose connector has no webhookSecret fails to
build.
Testing it
The reject paths test cleanly: an unsigned request, a wrongly-signed one, and a
valid signature over different bytes are all refused forever. The accept path
cannot be tested from a committed signature, because the signature covers a
timestamp inside a five-minute window, so any signature in a file is stale by
the time it runs. samples/parallel-research/callback_test.yaml turns that into
a test (a correctly signed request refused for being too old) and leaves the
accept path to the Go tests, which inject the clock.
- name: a correctly signed request from outside the replay window is rejected
input:
vars:
webhook-id: msg_stale_1
webhook-timestamp: "1767225600"
webhook-signature: v1,ZMFQE/t5RwjrnZGLz+sItdZStfPyGwi54q5RwTqOjo8=
rawBody: '{"timestamp":"2026-01-01T00:00:00Z","type":"task_run.status",...}'
expect:
error: invalid request signatureSee also
- The
parallelconnector reference: every setting on every block. - Verifying webhooks: the general pattern, including providers with no connector of their own.
- Raw content: what
rawBody: truedelivers.