> For the complete documentation index, see [llms.txt](https://docs.tryterra.co/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.tryterra.co/vantage-api-docs/documentation/webhooks.md).

# Webhooks

## Overview

Webhooks are how Vantage API keeps you connected to the kit throughout the fulfilment and results process. Every state change is delivered as a signed HTTP `POST` to the HTTPS endpoint you registered ([Account setup](/vantage-api-docs/account-setup-and-api-keys.md#configure-your-webhook-endpoint)).

There are **two event families**, distinguished by the `event_type` field:

| `event_type`                       | Scope      | What it carries                                                                                                   |
| ---------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- |
| `order.status_changed`             | Order      | Fulfilment progress: a `status` in the same `order.*` vocabulary REST uses, plus `tracking_number` once available |
| `order_item.results_status_change` | Order item | Results progress: a `results_status` in the `results.*` vocabulary, plus the `test_taker`                         |

{% hint style="info" %}
`event_type` tells you **which family** the event belongs to; the `status` / `results_status` field inside `data` tells you **which state** was reached. Route on `event_type`, then switch on the status field.
{% endhint %}

Every payload has the same envelope:

```json
{
  "event_type": "order.status_changed",
  "event_id": "249956485092777984",
  "timestamp": 1763661470,
  "data": { }
}
```

* `event_id` - unique per event, serialized as a JSON **string** (like all Vantage IDs). Delivery is **at-least-once**: deduplicate on `event_id`. It equals the `X-Terra-Trace-Id` header.
* `timestamp` - Unix **seconds** when the event was sent.

## Headers

| Header              | Description                                      | Example                     |
| ------------------- | ------------------------------------------------ | --------------------------- |
| `X-Terra-Signature` | HMAC signature with timestamp                    | `t=1763661470,v1=a1b2c3...` |
| `X-Terra-Trace-Id`  | Unique ID for debugging (matches the `event_id`) | `249956485092777984`        |
| `Content-Type`      | Always `application/json`                        | `application/json`          |

## Signature verification

Webhooks are signed with **HMAC-SHA256** using your Terra signing secret (the same signing secret shown in your Terra dashboard). Verify every delivery before trusting it.

The `X-Terra-Signature` header has the format:

```
t=<unix_timestamp_seconds>,v1=<hex_signature>
```

* `t` - Unix timestamp in **seconds** when the webhook was sent
* `v1` - hex-encoded `HMAC-SHA256(signing_secret, "<t>.<raw_body>")`

Verification steps:

1. Parse `t` and `v1` from the header.
2. Reject if `t` is outside your tolerance window of now (5 minutes is a sensible default).
3. Concatenate `<t>` + `.` + the **raw, unaltered request body**.
4. Compute HMAC-SHA256 over that string with your signing secret.
5. Compare to `v1` with a constant-time comparison.

{% tabs %}
{% tab title="Python" %}

```python
import hashlib
import hmac
import time

from flask import Flask, abort, request

app = Flask(__name__)

SIGNING_SECRET = "your_signing_secret_here"
TIMESTAMP_TOLERANCE = 300  # 5 minutes, in seconds


def verify_webhook(request):
    signature_header = request.headers.get("X-Terra-Signature")
    if not signature_header:
        return False, "Missing signature header"

    try:
        parts = dict(
            item.split("=", 1)
            for item in signature_header.split(",")
            if "=" in item
        )
        timestamp = int(parts["t"])
        received_signature = parts["v1"]
    except (KeyError, ValueError):
        return False, "Invalid signature format"

    if abs(int(time.time()) - timestamp) > TIMESTAMP_TOLERANCE:
        return False, "Timestamp too old or in future"

    body = request.get_data(as_text=True)
    signed_string = f"{timestamp}.{body}"

    expected_signature = hmac.new(
        SIGNING_SECRET.encode("utf-8"),
        signed_string.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected_signature, received_signature):
        return False, "Signature mismatch"

    return True, None


@app.route("/webhooks/terra", methods=["POST"])
def webhook_handler():
    valid, error = verify_webhook(request)
    if not valid:
        abort(401, description=error)

    data = request.get_json()
    print(f"Received webhook: {data}")

    return "", 200


if __name__ == "__main__":
    app.run(port=3000)
```

{% endtab %}

{% tab title="Go" %}

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const timestampTolerance = 300 // 5 minutes, in seconds

func VerifyWebhook(r *http.Request, signingSecret string) (bool, error) {
	bodyBytes, err := io.ReadAll(r.Body)
	if err != nil {
		return false, fmt.Errorf("failed to read body: %w", err)
	}
	defer r.Body.Close()

	signatureHeader := r.Header.Get("X-Terra-Signature")
	if signatureHeader == "" {
		return false, fmt.Errorf("missing X-Terra-Signature header")
	}

	var timestamp int64
	var receivedSig string
	for _, part := range strings.Split(signatureHeader, ",") {
		keyValue := strings.SplitN(part, "=", 2)
		if len(keyValue) != 2 {
			continue
		}
		switch keyValue[0] {
		case "t":
			timestamp, err = strconv.ParseInt(keyValue[1], 10, 64)
			if err != nil {
				return false, fmt.Errorf("invalid timestamp: %w", err)
			}
		case "v1":
			receivedSig = keyValue[1]
		}
	}

	age := time.Now().Unix() - timestamp
	if age > timestampTolerance || age < -timestampTolerance {
		return false, fmt.Errorf("timestamp too old or in future")
	}

	signedString := fmt.Sprintf("%d.%s", timestamp, string(bodyBytes))

	mac := hmac.New(sha256.New, []byte(signingSecret))
	mac.Write([]byte(signedString))
	expectedSig := hex.EncodeToString(mac.Sum(nil))

	if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(receivedSig)) != 1 {
		return false, fmt.Errorf("signature mismatch")
	}
	return true, nil
}

func WebhookHandler(w http.ResponseWriter, r *http.Request) {
	signingSecret := os.Getenv("TERRA_SIGNING_SECRET")

	valid, err := VerifyWebhook(r, signingSecret)
	if err != nil || !valid {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	w.WriteHeader(http.StatusOK)
}
```

{% endtab %}
{% endtabs %}

{% hint style="warning" %}
Compute the HMAC over the **raw request body bytes**, before any JSON parsing or re-serialization - re-encoding the JSON can reorder keys and change whitespace, which breaks the signature.
{% endhint %}

## Delivery and retries

* Respond with any **2xx** status quickly (within 10 seconds); do heavy processing asynchronously.
* On failure (network error, timeout, `408`, `429`, or any `5xx`), the first delivery makes up to **5 HTTP attempts** with exponential backoff and jitter (roughly 1s, 2s, 4s, 8s). If they all fail, the event is re-queued and redelivered as **single attempts with growing delays** (starting at 5s, doubling up to a 10-minute cap), up to 10 deliveries in total - at most \~14 calls spread over \~30 minutes - before the event is parked. Make your handler idempotent either way. Other `4xx` responses are treated as a rejection and are **not** retried.
* Parked (dead-lettered) events can be replayed by Terra - they are not silently lost.
* Delivery is **at-least-once** and ordering is not guaranteed: deduplicate on `event_id`, and treat each event's status as the authoritative current state rather than assuming you saw every intermediate step.

You can inspect delivery outcomes (delivered, rejected, dead-lettered, attempt counts, final status codes) via `GET /api/v1/webhook-deliveries` - see [Monitoring and debugging](/vantage-api-docs/documentation/monitoring.md).

## Event reference

### `order.status_changed`

`data` fields: `order_id` (string), `status`, plus `tracking_number` and (sandbox only) `supplier_item_id` once available.

| `status`                   | Meaning                                                                                                                 |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `order.payment_processing` | Payment being processed (initial state of payment-gated orders; appears in the order response rather than as a webhook) |
| `order.payment_complete`   | Payment confirmed                                                                                                       |
| `order.payment_failed`     | Payment failed                                                                                                          |
| `order.processing`         | Order accepted and being prepared by the supplier                                                                       |
| `order.delayed`            | Fulfilment delayed                                                                                                      |
| `order.delivery_fulfilled` | Delivery details available - carries `tracking_number`                                                                  |
| `order.completed`          | Order complete                                                                                                          |
| `order.cancelled`          | Order cancelled                                                                                                         |

Example:

```json
{
  "event_type": "order.status_changed",
  "event_id": "249956485092777984",
  "timestamp": 1763661470,
  "data": {
    "order_id": "251285377984405504",
    "status": "order.delivery_fulfilled",
    "tracking_number": "KnD3d5PMZyq5ulNcWkrq"
  }
}
```

{% hint style="info" %}
REST reads of the same order use the identical vocabulary - a webhook `status` can be matched verbatim against `order_status` or `status_history`.
{% endhint %}

### `order_item.results_status_change`

`data` fields: `order_id`, `order_item_id`, `variant_id` (all strings), `results_status`, and a `test_taker` object (`test_taker_id` string, `first_name`, `last_name`, `email`, `phone_number` E.164 string). Depending on the status, also: `failure_cause`, `escalation_level`, `acknowledgment_due_by`.

| `results_status`                   | Meaning                                                               | Extra fields                                  |
| ---------------------------------- | --------------------------------------------------------------------- | --------------------------------------------- |
| `results.kit_activated`            | End user activated their kit (suppliers with an activation step only) | -                                             |
| `results.sample_processing_in_lab` | Lab confirmed receipt of the sample                                   | -                                             |
| `results.partial_results_ready`    | A subset of the panel has resulted                                    | -                                             |
| `results.results_ready`            | Results available - fetch and acknowledge                             | -                                             |
| `results.sample_rejected`          | Lab rejected the sample                                               | `failure_cause` (e.g. `"blood contaminated"`) |
| `results.lab_processing_error`     | Lab could not process the sample                                      | -                                             |
| `results.escalation_raised`        | Clinical escalation on the result set                                 | `escalation_level`, `acknowledgment_due_by`   |

(`results.awaiting_sample` is the initial per-item state set at order creation; it appears in the order response rather than as a webhook.)

Example - results ready:

```json
{
  "event_type": "order_item.results_status_change",
  "event_id": "249958796259139584",
  "timestamp": 1763662021,
  "data": {
    "order_id": "251285377984405504",
    "order_item_id": "251285377984405507",
    "variant_id": "100041",
    "results_status": "results.results_ready",
    "test_taker": {
      "test_taker_id": "257837964552478720",
      "first_name": "John",
      "last_name": "Doe",
      "email": "john.doe@example.com",
      "phone_number": "+14155551234"
    }
  }
}
```

Example - escalation raised:

```json
{
  "event_type": "order_item.results_status_change",
  "event_id": "252476147693166592",
  "timestamp": 1764262204,
  "data": {
    "order_id": "251285377984405504",
    "order_item_id": "251285377984405507",
    "variant_id": "100041",
    "results_status": "results.escalation_raised",
    "escalation_level": "medium",
    "acknowledgment_due_by": "2026-07-24T16:04:01Z",
    "test_taker": {
      "test_taker_id": "257837964552478720",
      "first_name": "John",
      "last_name": "Doe",
      "email": "john.doe@example.com",
      "phone_number": "+14155551234"
    }
  }
}
```

`escalation_level` is one of `not_escalated`, `very_low`, `low`, `medium`, `high`, `very_high`. Escalations require prompt acknowledgment - see [Acknowledging Results](/vantage-api-docs/important-information/acknowledging-results.md).
