# Webhooks

## Overview

Webhooks are at the centre of how Vantage API is able to keep you connected to the kit throughout the fulfilment process. This guide will outline how we use webhooks and how you can integrate with them.

### Webhook Verification

* Diagnostics API uses **HMAC-SHA256** with your unique signing secret to generate webhook signatures
* Below is some example code on how to verify a webhook secret

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

```python
import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

SIGNING_SECRET = "your_signing_secret_here"
TIMESTAMP_TOLERANCE = 300000  # 5 minutes in milliseconds

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

    # 2. Parse signature header
    parts = dict(item.split('=') for item in signature_header.split(','))

    try:
        timestamp = int(parts.get('t', 0))
        received_signature = parts.get('v1', '')
    except (ValueError, AttributeError):
        return False, "Invalid signature format"

    # 3. Verify timestamp
    current_time = int(time.time() * 1000)
    age = current_time - timestamp

    if abs(age) > TIMESTAMP_TOLERANCE:
        return False, "Timestamp too old or in future"

    # 4. Get raw body
    body = request.get_data(as_text=True)

    # 5. Construct signed string
    signed_string = f"{timestamp}.{body}"

    # 6. Calculate expected signature
    expected_signature = hmac.new(
        SIGNING_SECRET.encode('utf-8'),
        signed_string.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # 7. Compare signatures (constant-time)
    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)

    # Process webhook
    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"
    "strconv"
    "strings"
    "time"
)

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

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

    // 3. Parse signature header
    parts := strings.Split(signatureHeader, ",")
    if len(parts) != 2 {
        return false, fmt.Errorf("invalid signature format")
    }

    var timestamp int64
    var receivedSig string

    for _, part := range parts {
        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]
        }
    }

    // 4. Verify timestamp is recent (within 5 minutes)
    now := time.Now().UnixMilli()
    age := now - timestamp
    if age > 300000 || age < -300000 {
        return false, fmt.Errorf("timestamp too old or in future")
    }

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

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

    // 7. Compare signatures (constant-time)
    if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(receivedSig)) != 1 {
        return false, fmt.Errorf("signature mismatch")
    }

    return true, nil
}

// Example HTTP handler
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
    }

    // Process webhook...
    w.WriteHeader(http.StatusOK)
}

```

{% endtab %}
{% endtabs %}

#### Headers

Every webhook Terra sends include these headers

| Header              | Description                     | Example                        |
| ------------------- | ------------------------------- | ------------------------------ |
| `X-Terra-Signature` | HMAC signature with timestamp   | `t=1700000000000,v1=a1b2c3...` |
| `X-Terra-Trace-Id`  | Unique request ID for debugging | `251285377982321505`           |
| `Content-Type`      | Always `application/json`       | `application/json`             |

#### Signature Format

The `X-Terra-Signature` header follows this format:

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

* `t` = Unix timestamp in milliseconds when the webhook was sent
* `v1` = HMAC-SHA256 signature in hexadecimal format

**Example:**

```
X-Terra-Signature: t=1732115200000,v1=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
```

### Body

{% hint style="warning" %}
`fulfilment.payment_processing` is not actually returned as a webhook but is part of the response once an order is placed
{% endhint %}

<table><thead><tr><th width="271.47265625">Event Type</th><th width="208.6171875">Description</th><th>Webhook Payload</th></tr></thead><tbody><tr><td>fulfilment.payment_processing</td><td>This is the default event returned once an order is placed. Not actually returned as an individual webhook.</td><td>Look at hint above</td></tr><tr><td>fulfilment.payment_complete</td><td>This event is sent once payment is confirmed</td><td><pre class="language-json"><code class="lang-json">{
  "data": {
    "order_id": 249956252111773696,
    "status": "fulfillment.payment_complete"
  },
  "event_id": 249956266972192768,
  "event_type": "order.status_changed",
  "timestamp": 1763661418
}

</code></pre></td></tr><tr><td>fulfilment.delivery\_fulfilled</td><td>This event is sent once delivery details such as tracking number is available</td><td><pre class="language-json"><code class="lang-json">{
"data": {
"order\_id": 249956252111773696,
"status": "fulfillment.delivery\_fulfilled",
"tracking\_number": "KnD3d5PMZyq5ulNcWkrq"
},
"event\_id": 249956485092777984,
"event\_type": "order.status\_changed",
"timestamp": 1763661470
}

</code></pre></td></tr><tr><td>results.kit\_activated</td><td>The event is sent once an end user activates his/her kit. Only applies to suppliers which support 2-step activation process</td><td><p></p><pre class="language-json"><code class="lang-json">{
"data": {
"order\_id": 249956252111773696,
"order\_item\_id": 249956252111773699,
"results\_status": "results.kit\_activated",
"test\_taker": {
"test\_taker\_id": "257837964552478720",
"country\_code": 1,
"email": "<john.doe@example.com>",
"first\_name": "John",
"last\_name": "Doe",
"phone\_number": 4155551234
},
"variant\_id": 100021
},
"event\_id": 251744114461286400,
"event\_type": "order\_item.results\_status\_change",
"timestamp": 1764087674
} </code></pre></td></tr><tr><td>results.sample\_processing\_in\_lab</td><td>This event is sent when test receipt is confirmed by the lab</td><td><pre class="language-json"><code class="lang-json">{
"data": {
"order\_id": 249956252111773696,
"order\_item\_id": 249956252111773699,
"results\_status": "results.sample\_processing\_in\_lab",
"test\_taker": {
"test\_taker\_id": "257837964552478720",
"email": "<john.doe@example.com>",
"first\_name": "John",
"last\_name": "Doe",
"country\_code": 1,
"phone\_number": 4155551234
},
"variant\_id": 100021
},
"event\_id": 249958648347009024,
"event\_type": "order\_item.results\_status\_change",
"timestamp": 1763661985
} </code></pre></td></tr><tr><td>results.results\_ready</td><td>This event is sent once result availability is confirmed by lab</td><td><pre class="language-json"><code class="lang-json">{
"data": {
"order\_id": 249956252111773696,
"order\_item\_id": 249956252111773699,
"results\_status": "results.results\_ready",
"test\_taker": {
"test\_taker\_id": "257837964552478720",
"country\_code": 1,
"email": "<john.doe@example.com>",
"first\_name": "John",
"last\_name": "Doe",
"phone\_number": 4155551234
},
"variant\_id": 100021
},
"event\_id": 249958796259139584,
"event\_type": "order\_item.results\_status\_change",
"timestamp": 1763662021
} </code></pre></td></tr><tr><td>results.sample\_rejected</td><td>This event is sent if the sample is rejected by the lab</td><td><pre class="language-json"><code class="lang-json">{
"data": {
"failure\_cause" : "blood contaminated",
"order\_id": 249956252111773696,
"order\_item\_id": 249956252111773699,
"results\_status": "results.sample\_rejected",
"test\_taker": {
"test\_taker\_id": "257837964552478720",
"country\_code": 1,
"email": "<john.doe@example.com>",
"first\_name": "John",
"last\_name": "Doe",
"phone\_number": 4155551234
},
"variant\_id": 100021
},
"event\_id": 249958796259139584,
"event\_type": "order\_item.results\_status\_change",
"timestamp": 1763662021
} </code></pre></td></tr><tr><td>results.escalation\_raised</td><td>This event is sent if there is an escalation in the result set</td><td><pre class="language-json"><code class="lang-json">{
"data": {
"escalation\_level": "medium",
"order\_id": 249956252111773696,
"order\_item\_id": 249956252111773699,
"results\_status": "results.escalation\_raised",
"test\_taker": {
"test\_taker\_id": "257837964552478720",
"country\_code": 44,
"email": "<john.tan@example.com>",
"first\_name": "John",
"last\_name": "Tan",
"phone\_number": 1234567890
},
"variant\_id": 100011
},
"event\_id": 252476147693166592,
"event\_type": "order\_item.results\_status\_change",
"timestamp": 1764262204
} </code></pre></td></tr></tbody></table>


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.tryterra.co/vantage-api-docs/documentation/webhooks.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
