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

# Webhooks

> Trigger automated Avido tests from outside your code and validate payloads securely.

## Why webhook‑triggered tests?

**Part of Avido's secret sauce is that you can kick off a test *without touching your code*.**\
Instead of waiting for CI or redeploys, Avido sends an HTTP `POST` to an endpoint that **you** control.

| Benefit                 | What it unlocks                                               |
| ----------------------- | ------------------------------------------------------------- |
| **Continuous coverage** | Run tests on prod/staging as often as you like and automated. |
| **SME‑friendly**        | Non‑developers trigger & tweak tasks from the Avido UI.       |

***

## How it works

1. **A test is triggered** in the dashboard or automatically.
2. **Avido POSTs** to your configured endpoint.
3. **Validate** the `signature` + `timestamp` with our API/SDK.
4. **Run your LLM flow** using `prompt` from the payload.
5. **Emit a trace** that includes `testId` to connect results in Avido.\
   *Using OpenTelemetry?* Set the `avido.test.id` span attribute instead — see the [OpenTelemetry guide](/opentelemetry#common-attributes).
6. **Return `200 OK`** – any other status tells Avido the test failed.

<Callout type="info">
  Validating a webhook with the API requires both `x-api-key` and `x-application-id`
  headers. Use the application ID that issued the API key.
</Callout>

### Payload example

When Avido triggers your webhook endpoint, it sends:

```json Webhook payload theme={null}
{
  "prompt": "Write a concise onboarding email for new users.",
  "testId": "123e4567-e89b-12d3-a456-426614174000",
  "metadata": {
    "customerId": "1",
    "priority": "high"
  }
}
```

Headers:

| Header              | Purpose                                                    |
| ------------------- | ---------------------------------------------------------- |
| `x-avido-signature` | HMAC signature of the payload                              |
| `x-avido-timestamp` | Unix ms timestamp (in milliseconds) the request was signed |

**Notes:**

* `testId` is **always** included in webhook payloads and is unique per test run. Pass it to your `ingest.create()` call so the trace is linked to the Avido test.
* `metadata` is optional and only included when available from the originating task.
* The webhook body **may contain additional fields** beyond those shown above. For example, experiment-related data is included when tests are part of an experiment. Do not assume a fixed schema — always handle unknown fields gracefully.

<Callout type="tip">
  You do **not** need to set `traceId` when responding to a webhook. The server generates one automatically. If you do set it, use a random UUID — do not reuse the `testId` as the `traceId`. See the [Tracing guide](/traces#understanding-testid-and-traceid) for details.
</Callout>

<Callout type="warning">
  **Always validate the raw body.** The HMAC signature is computed over the exact JSON payload sent by Avido. You must forward the **raw, unmodified request body** to the `/v0/validate-webhook` endpoint (or use it directly when verifying the signature). If you parse and re-serialize the body, differences in key ordering or whitespace can cause signature verification to fail.
</Callout>

***

## Verification flow

```mermaid theme={null}
sequenceDiagram
  Avido->>Your Endpoint: POST payload + headers
  Your Endpoint->>Avido API: /v0/validate-webhook
  Avido API-->>Your Endpoint: { valid: true }
  Your Endpoint->>LLM: run(prompt)
  Your Endpoint->>Avido Ingest API: POST /v0/ingest (testId)
```

If validation fails, respond **401** (or other 4xx/5xx). Avido marks the test as **failed**.

***

## Body validation best practices

The webhook body **is not a fixed schema**. While the most common fields are `prompt`, `testId`, and `metadata`, the payload may include additional fields at any time (for example, `experiment` data when a test is part of an experiment). Your endpoint should:

1. **Accept any valid JSON body** — do not reject requests that contain unknown fields.
2. **Forward the entire raw body for signature validation** — the HMAC signature covers every field in the payload. If you strip, rename, or re-order fields before calling `/v0/validate-webhook`, the signature check will fail.
3. **Read only the fields you need** after validation succeeds — safely ignore fields you don't recognize.

<Callout type="tip">
  When using a framework that automatically parses JSON (e.g., Express, Flask, FastAPI), pass the **parsed object** directly to the validation endpoint or SDK method. Avoid manually re-serializing it, as subtle differences (key order, whitespace) can break signature verification.
</Callout>

***

## Code examples

<CodeGroup>
  ```bash cURL (default) theme={null}
  curl --request POST \
    --url https://api.avidoai.com/v0/validate-webhook \
    --header 'Content-Type: application/json' \
    --header 'x-application-id: <application-id>' \
    --header 'x-api-key: <api-key>' \
    --data '{
    "signature": "abc123signature",
    "timestamp": 1687802842609,
    "body": {
      "prompt": "Write a concise onboarding email for new users.",
      "testId": "123e4567-e89b-12d3-a456-426614174000",
      "metadata": {
        "customerId": "1",
        "priority": "high"
      }
    }
  }'
  ```

  ```ts Node theme={null}
  import express from 'express';
  import Avido from '@avidoai/sdk-node';

  const app = express();
  // Use express.json() to parse the body — the SDK handles
  // sending the parsed object for validation.
  app.use(express.json());
  const client = new Avido({
    applicationId: process.env.AVIDO_APPLICATION_ID!,
    apiKey: process.env.AVIDO_API_KEY!,
  });

  app.post('/avido/webhook', async (req, res) => {
    const signature = req.get('x-avido-signature');
    const timestamp = req.get('x-avido-timestamp');
    // Pass the full body as-is — do not pick specific fields before
    // validation. The body may contain additional fields such as
    // experiment data, and all fields are covered by the signature.
    const body = req.body;

    try {
      const { valid } = await client.validateWebhook.validate({
        signature,
        timestamp,
        body
      });
      if (!valid) return res.status(401).send('Invalid webhook');

      const result = await runAgent(body.prompt); // your LLM call

      // Send the trace linked to the test — pass testId from the webhook payload.
      // You do NOT need to set traceId; the server generates one automatically.
      await client.ingest.create({
        events: [
          {
            type: 'trace',
            timestamp: new Date().toISOString(),
            testId: body.testId,
            input: body.prompt,
            output: result,
          },
        ],
      });

      return res.status(200).send('OK');
    } catch (err) {
      console.error(err);
      return res.status(500).send('Internal error');
    }
  });
  ```

  ```python Python theme={null}
  import os
  from datetime import datetime, timezone
  from flask import Flask, request, jsonify
  from avido import Avido

  app = Flask(__name__)
  client = Avido(
      application_id=os.environ["AVIDO_APPLICATION_ID"],
      api_key=os.environ["AVIDO_API_KEY"],
  )

  @app.route("/avido/webhook", methods=["POST"])
  def handle_webhook():
      # Parse the full JSON body as-is. Do not discard or reshape
      # fields before validation — the signature covers the entire
      # payload, which may include additional fields like experiment data.
      body = request.get_json(force=True) or {}
      signature = request.headers.get("x-avido-signature")
      timestamp = request.headers.get("x-avido-timestamp")

      if not signature or not timestamp:
          return jsonify({"error": "Missing signature or timestamp"}), 400

      try:
          resp = client.validate_webhook.validate(
              signature=signature,
              timestamp=timestamp,
              body=body
          )
          if not resp.valid:
              return jsonify({"error": "Invalid webhook signature"}), 401
      except Exception as e:
          return jsonify({"error": str(e)}), 401

      result = run_agent(body.get("prompt"))  # your LLM pipeline

      # Send the trace linked to the test — pass testId from the webhook payload.
      # You do NOT need to set traceId; the server generates one automatically.
      client.ingest.create(
          events=[
              {
                  "type": "trace",
                  "timestamp": datetime.now(timezone.utc).isoformat(),
                  "testId": body.get("testId"),
                  "input": body.get("prompt"),
                  "output": result,
              },
          ],
      )
      return jsonify({"status": "ok"}), 200
  ```
</CodeGroup>

***

## SDK method reference

### `validateWebhook.validate()`

Verifies that an incoming webhook request was signed by Avido.

<CodeGroup>
  ```ts Node theme={null}
  const { valid } = await client.validateWebhook.validate({
    signature,  // from x-avido-signature header
    timestamp,  // from x-avido-timestamp header
    body,       // the full parsed request body
  });
  ```

  ```python Python theme={null}
  resp = client.validate_webhook.validate(
      signature=signature,  # from x-avido-signature header
      timestamp=timestamp,  # from x-avido-timestamp header
      body=body,            # the full parsed request body
  )
  # resp.valid -> bool
  ```
</CodeGroup>

| Parameter   | Type   | Description                                 |
| ----------- | ------ | ------------------------------------------- |
| `signature` | string | The `x-avido-signature` header value        |
| `timestamp` | string | The `x-avido-timestamp` header value        |
| `body`      | object | The full webhook request body (parsed JSON) |

Returns an object with a `valid` boolean field.

***

## Experiments

When a test is triggered as part of an **experiment**, the webhook payload includes an additional `experiment` field. Experiments let you compare different configurations (variants) of your LLM pipeline against a baseline, and the `experiment` field tells your application which overrides to apply.

### Experiment payload

```json Webhook payload with experiment theme={null}
{
  "prompt": "Write a concise onboarding email for new users.",
  "testId": "123e4567-e89b-12d3-a456-426614174000",
  "metadata": {
    "customerId": "1",
    "priority": "high"
  },
  "experiment": {
    "experimentId": "aaa11111-bbbb-cccc-dddd-eeeeeeeeeeee",
    "experimentVariantId": "fff22222-3333-4444-5555-666666666666",
    "overrides": {
      "response_generator": {
        "model": "reasoning",
        "system": "You are a concise assistant."
      }
    }
  }
}
```

| Field                            | Type                                      | Description                                                                                                                                                                                             |
| -------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `experiment.experimentId`        | `string (uuid)`                           | Unique identifier for the experiment                                                                                                                                                                    |
| `experiment.experimentVariantId` | `string (uuid)`                           | Unique identifier for the variant being tested                                                                                                                                                          |
| `experiment.overrides`           | `Record<string, Record<string, unknown>>` | Configuration overrides keyed by **inference step name** (e.g. `response_generator`, `classifier`). Each step's value is an object of parameter overrides (e.g. `model`, `system`, `max_output_tokens`) |

**How overrides work:**

Your application already has its own configuration (prompts, models, parameters, etc.) for each step in your LLM pipeline. The `overrides` object tells you **what to change**: each key is a step name, and each value contains the specific settings to overwrite for that step.

For example, if `overrides` contains `{ "response_generator": { "model": "reasoning" } }`, you should use the `reasoning` model for your `response_generator` step instead of whatever your default is — and keep all other settings as-is.

### Using experiments in your application

When your webhook handler receives a payload with `experiment`, apply the overrides to the corresponding steps in your LLM pipeline:

<CodeGroup>
  ```ts Node theme={null}
  app.post('/avido/webhook', async (req, res) => {
    // ... validation omitted for brevity ...
    const body = req.body;

    // Check if this test is part of an experiment
    if (body.experiment) {
      const { overrides } = body.experiment;

      // Apply overrides to each inference step in your pipeline.
      // The keys in `overrides` match the step names you defined in Avido.
      for (const [stepName, params] of Object.entries(overrides)) {
        // Example: override the model and system prompt
        // for your "response_generator" step
        applyStepConfig(stepName, params);
      }
    }

    const result = await runAgent(body.prompt); // your LLM call

    await client.ingest.create({
      events: [
        {
          type: 'trace',
          timestamp: new Date().toISOString(),
          testId: body.testId,
          input: body.prompt,
          output: result,
        },
      ],
    });

    return res.status(200).send('OK');
  });

  // Example helper — adapt to your framework / LLM client
  function applyStepConfig(stepName: string, params: Record<string, unknown>) {
    // Look up the step in your pipeline config and merge overrides
    pipelineConfig[stepName] = {
      ...pipelineConfig[stepName],
      ...params,
    };
  }
  ```

  ```python Python theme={null}
  @app.route("/avido/webhook", methods=["POST"])
  def handle_webhook():
      # ... validation omitted for brevity ...
      body = request.get_json(force=True) or {}

      # Check if this test is part of an experiment
      experiment = body.get("experiment")
      if experiment:
          overrides = experiment.get("overrides", {})

          # Apply overrides to each inference step in your pipeline.
          # The keys in `overrides` match the step names you defined in Avido.
          for step_name, params in overrides.items():
              # Example: override the model and system prompt
              # for your "response_generator" step
              apply_step_config(step_name, params)

      result = run_agent(body.get("prompt"))  # your LLM pipeline

      client.ingest.create(
          events=[
              {
                  "type": "trace",
                  "timestamp": datetime.now(timezone.utc).isoformat(),
                  "testId": body.get("testId"),
                  "input": body.get("prompt"),
                  "output": result,
              },
          ],
      )
      return jsonify({"status": "ok"}), 200


  # Example helper — adapt to your framework / LLM client
  def apply_step_config(step_name: str, params: dict):
      # Look up the step in your pipeline config and merge overrides
      pipeline_config[step_name] = {**pipeline_config.get(step_name, {}), **params}
  ```
</CodeGroup>

<Callout type="info">
  **You don't need to change your trace ingestion code.** The `testId` already links the trace back to the correct experiment variant in Avido. Just pass `body.testId` as usual.
</Callout>

<Callout type="tip">
  If your application doesn't run experiments yet, you can safely ignore the `experiment` field — its presence is optional and your existing webhook handler will continue to work without changes.
</Callout>

***

## Troubleshooting

| Problem                          | Cause                                    | Fix                                                                                                   |
| -------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Signature always invalid         | Body was re-serialized before validation | Pass the parsed JSON object directly; don't `JSON.stringify` it first                                 |
| Missing signature header         | Framework strips custom headers          | Check your reverse proxy / load balancer isn't dropping `x-avido-*` headers                           |
| Test shows as failed             | Endpoint returned non-200 status         | Ensure you return `200 OK` after successful processing                                                |
| Trace not linked to test         | Missing `testId` in ingestion            | Pass `body.testId` in your `ingest.create()` call                                                     |
| Experiment overrides not applied | `experiment` field ignored in handler    | Check for `body.experiment` and apply `overrides` to your pipeline config before running the LLM call |

***

## Next steps

* Send us [Trace events](/traces) to capture your full LLM workflow.
* Using OpenTelemetry? See the [OpenTelemetry Integration](/opentelemetry) guide.
* Schedule or trigger tasks from **Tasks** in the dashboard.
* Invite teammates so they can craft evals and review results directly in Avido.

***
