> ## Documentation Index
> Fetch the complete documentation index at: https://docs.connie.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks for tool calls

> Get the outcome of a long Tool API call posted to your server when it ends, instead of polling, and verify that it came from Connie.

Most tool calls that reach the web (scrapes, dataset collections, enrichment) take longer than the 25 seconds the [Tool API](/developers/tool-api) waits, so they answer `202`. Instead of polling, pass a `webhook` URL with the call: when the call ends, Connie POSTs its outcome there.

## Ask for a webhook

Add `webhook` next to `input` when you run a tool:

```bash theme={null}
curl -s -X POST "https://conigma-tool-server-production.up.railway.app/api/tool/instagram-profiles-collect-by-url" \
  -H "Authorization: Bearer $CONNIE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f6c2a40-9b1e-4f55-8a5e-1c2d3e4f5a6b" \
  -d '{"input": {"url": "https://www.instagram.com/nasa"}, "webhook": "https://example.com/connie-webhook"}'
```

* The URL must use `https`, contain no username or password and be at most 2,048 characters. Otherwise the call is refused with `400 invalid_webhook` and nothing runs.
* Connie looks up the URL's host when it accepts the call. A host on a private network, or one with no DNS address, is refused with `400 invalid_webhook` and nothing runs; send the corrected call with a new `Idempotency-Key`. If the lookup fails for a moment, the call answers `503 webhooks_unavailable` and nothing runs: send the same request again.
* The webhook is part of the request: sending the same `Idempotency-Key` with a different webhook (or none) is `409 idempotency_conflict`.
* You can still poll the operation as usual. A successful call that ends within 25 seconds answers `200` with its outcome and also sends the webhook. A failed call returns its error status and sends the corresponding outcome.

## What Connie sends

One `POST` with a JSON body, when the call ends:

```json theme={null}
{
  "id": "0b9e7c1e-6f0d-4c55-9b0a-2d0c1a4f8e21",
  "type": "tool_invocation.completed",
  "createdAt": "2026-10-12T09:41:07.000Z",
  "data": {
    "invocationId": "7c0e5a52-1f7a-4e0e-9a51-0d6b2f7d1c11",
    "toolId": "instagram-profiles-collect-by-url",
    "status": "completed",
    "ok": true,
    "result": { "...": "the tool's output" },
    "billing": { "chargedCredits": 1, "items": [ { "toolId": "instagram-profiles-collect-by-url", "summary": "…", "chargedCredits": 1, "status": "charged" } ] }
  }
}
```

`data` is the operation returned by `GET /api/tool-invocations/{invocationId}`, without `replayed` or `chargedThisRequest`. A result over 1 MiB comes as `resultRef`: fetch it with `?result=full`.

| `type` | When |
| - | - |
| `tool_invocation.completed` | The tool ran. `data.ok` says whether it succeeded; a failure has `data.error`. |
| `tool_invocation.not_executed` | Nothing ran and nothing was charged. If `data.error.retryable` is `true`, run it again with the same key. |
| `tool_invocation.unknown_outcome` | The tool may have run; don't run it again automatically. |

Headers on every webhook:

| Header | Value |
| - | - |
| `webhook-id` | The event id, the same as `id` in the body. It stays the same on every retry of this event. |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds. |
| `webhook-signature` | `v1,<signature>`. Right after a [secret rotation](#rotate-the-secret) there are two, separated by a space. |
| `user-agent` | `Connie-Webhooks/1.0` |

## Verify the signature

Webhooks are signed the [Standard Webhooks](https://www.standardwebhooks.com/) way, so any Standard Webhooks library verifies them. Get your workspace's signing secret once and keep it on your server:

```bash theme={null}
curl -s "https://conigma-tool-server-production.up.railway.app/api/tool-webhooks/secret" -H "Authorization: Bearer $CONNIE_API_KEY"
# {"secret":"whsec_…"}
```

The signature is the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`. Check it against the **raw** body, before parsing it, and reject timestamps more than 5 minutes off.

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    import { createHmac, timingSafeEqual } from 'node:crypto';

    /** True when the webhook came from Connie. `rawBody` is the exact request body string. */
    export function verifyConnieWebhook(rawBody, headers, secret) {
      const id = headers['webhook-id'], timestamp = headers['webhook-timestamp'], header = headers['webhook-signature'];
      if (!id || !timestamp || !header) return false;
      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
      const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
      const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
      return header.split(' ').some((part) => {
        const [version, signature] = part.split(',');
        const given = Buffer.from(signature ?? '', 'base64');
        return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected);
      });
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import base64, hashlib, hmac, time

    def verify_connie_webhook(raw_body: bytes, headers, secret: str) -> bool:
        """True when the webhook came from Connie. `raw_body` is the exact request body."""
        msg_id, timestamp, header = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
        if not (msg_id and timestamp and header):
            return False
        if abs(time.time() - int(timestamp)) > 300:
            return False
        key = base64.b64decode(secret.removeprefix("whsec_"))
        expected = base64.b64encode(hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()).decode()
        return any(hmac.compare_digest(part, f"v1,{expected}") for part in header.split(" "))
    ```
  </Tab>
</Tabs>

## Answer quickly, handle duplicates

* **Answer with any `2xx` within 10 seconds**, then do the work. Connie doesn't read the body.
* **Retries:** any other answer, a timeout or a connection error is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours and 8 hours (about 16 hours in all). Answer `410 Gone` to stop retries for that event.
* **Redirects are not followed.** Give Connie the final URL.
* **A host that can't be looked up** is retried on the same schedule. Connie stops for good only if the host now points to a private network.
* **Duplicates happen.** The same event can arrive more than once: skip a `webhook-id` you have already handled.
* **Order isn't guaranteed** between different calls. For one call, the event's `data` is its final outcome. If a call that only reads was interrupted and ran again, you may get an `unknown_outcome` event first and then, with a new `webhook-id`, the real outcome.

## Rotate the secret

```bash theme={null}
curl -s -X POST "https://conigma-tool-server-production.up.railway.app/api/tool-webhooks/secret/rotate" -H "Authorization: Bearer $CONNIE_API_KEY"
# {"secret":"whsec_new…","previousSecretValidUntil":"2026-10-13T09:41:07.000Z"}
```

For the next 24 hours every webhook carries two signatures, one with each secret, so you can deploy the new secret without missing an event. The verification code above accepts either.

Reading or rotating the secret needs a key that can run tools. The secret is the same for every key in the workspace.

## Related

* [Tool API](/developers/tool-api)
* [Webhook reference](/api-reference/tool-api/tool-invocation-ended)
* [Errors and limits](/developers/errors-and-limits#tool-api)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.