Octov0.11.7
Guides

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.

samples/parallel-research/config.yaml (excerpt)
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/events

Answer now, when you can

parallel-search answers in the same request, and its response becomes the body:

samples/parallel-research/config.yaml (excerpt)
- type: parallel-search
  settings:
    connector: research
    objective: body.question
    searchQueries: '[body.question]'
    mode: fast
    maxResults: 8

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

samples/parallel-research/config.yaml (excerpt)
- 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.

samples/parallel-research/config.yaml (excerpt)
- 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: research

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

samples/parallel-research/config.yaml (excerpt)
    - 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.

samples/parallel-research/callback_test.yaml (excerpt)
- 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 signature

See also

On this page