> 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/destinations/webhooks.md).

# Webhooks

Receive Terra events as POST requests to your URL, verify their signatures, and handle retries, timeouts and ping mode.

Webhooks are the most basic [destination](/destinations/setting-up-data-destinations.md) to set up, and involve Terra making a POST request to a predefined callback URL you pass in when setting up the Webhook.

They are automated messages sent from apps when something happens.

Terra uses webhooks to notify you whenever new data is made available for any of your users. New data, such as activity, sleep, etc will be normalised and sent to your webhook endpoint URL where you can process it however you see fit.

After a user authenticates with your service through Terra, you will automatically begin receiving webhook messages containing data from their wearable.

## Before you begin

* A URL on your server that accepts POST requests. For testing, you can use [webhook.site](https://webhook.site/#!/) as a temporary endpoint.

## Add the destination in Terra

1. Open the [Connections page](https://dashboard.tryterra.co/dashboard/connections) in your Terra dashboard.
2. Click **Add New** and select Webhook.
3. Enter your callback URL and click **Apply**.

## Secure your endpoint

Exposing a URL on your server can pose a number of security risks, allowing a potential attacker to

* **Launch denial of service (DoS) attacks** to overload your server.
* **Tamper with data** by sending malicious payloads.
* **Replay legitimate requests** to cause duplicated actions.

among other exploits.

In order to secure your URL, Terra offers two separate methods of securing your URL endpoint: payload signing and IP allowlisting.

### Verify the signature

Every webhook sent by Terra includes an HMAC-based signature header `X-Terra-Signature`, which takes the form:

```
t=1723808700,v1=a5ee9dba96b4f65aeff6c841aa50121b1f73ec7990d28d53b201523776d4eb00
```

In order to verify the payload, you may use one of Terra's SDKs as follows:

{% hint style="warning" %}
Terra requires the **raw, unaltered** body of the request to perform signature verification. If you’re using a framework, make sure it doesn’t manipulate the raw body (many frameworks do by default)

**Any** manipulation to the raw body of the request causes the verification to fail.
{% endhint %}

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

```python
import os

from flask import Flask, jsonify, request
from terra.utils.verify_signature import verify_terra_webhook_signature

app = Flask(__name__)

signing_secret = os.getenv('SIGNING_SECRET')  # Your signing secret from Terra

@app.route('/webhook', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('x-terra-signature')
    body = request.get_data(as_text=True)

    if signature is None:
        return jsonify({"error": "x-terra-signature header missing"}), 400

    try:
        is_valid = verify_terra_webhook_signature(
            payload=body, signature_header=signature, signing_secret=signing_secret
        )
        if not is_valid:
            return jsonify({"error": "Invalid signature"}), 400

        # Process the webhook data here
        webhook_data = request.get_json()
        print(f"Received valid webhook data: {webhook_data}")

        return jsonify({"message": "Webhook received successfully"}), 200

    except Exception as e:
        return jsonify({"error": str(e)}), 500

if __name__ == '__main__':
    app.run(port=8000)
```

{% endtab %}

{% tab title="Javascript SDK" %}

```javascript
const express = require("express");
const { verifyTerraWebhookSignature } = require("terra-api");

const app = express();

// Use the built-in Express middleware to parse raw JSON bodies.
// This must be placed before the webhook endpoint.
app.use(
    express.raw({
        inflate: true,
        limit: "4000kb", // Set a limit appropriate for your use case
        type: "application/json",
    })
);

// Make the route handler function async
app.post("/consumeTerraWebhook", async (req, res) => {
    try {
        const signature = req.headers["x-terra-signature"];
        const secret = process.env.TERRA_SECRET;

        // First, verify the signature. The function will throw an error if verification fails.
        // The req.body is a Buffer, which is the expected format.
        await verifyTerraWebhookSignature(req.body, signature, secret);

        // If verification is successful, send a 200 OK response immediately.
        res.sendStatus(200);

        // Process the data *after* responding to prevent timeouts.
        const data = JSON.parse(req.body.toString("utf8"));
        console.log("Received & Verified Terra Webhook Data:");
        console.log(JSON.stringify(data, null, 2));

    } catch (error) {
        // If verifyTerraWebhookSignature throws an error, catch it here.
        console.error("Webhook verification failed:", error.message);
        return res.sendStatus(401); // Respond with Unauthorized
    }
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
    console.log(`Server started on http://localhost:${port}`);
});
```

{% endtab %}

{% tab title="Java SDK" %}

```java
import co.tryterra.terraclient.WebhookHandlerUtility;

// Using the Spark framework (http://sparkjava.com)
public Object handle(Request request, Response response) {
    String payload = request.body();
    String sigHeader = request.headers("x-terra-signature");

    // Find your secret on https://dashboard.tryterra.co/dashboard/connections
    WebhookHandlerUtility handlerUtility = new WebhookHandlerUtility("SIGNING_SECRET");

    boolean validSignature = handlerUtility.verifySignature(sigHeader, payload);

    if (!validSignature) {
        response.status(401);
        return "";
    }

    // Deserialize the object inside the event & handle the event
    // ...
    response.status(200);
    return "";
}
```

{% endtab %}

{% tab title="Manual verification" %}
We recommend that you use our official libraries to verify webhook event signatures. You can however create a custom solution by following this section.

The `X-Terra-Signature` header included in each signed event contains **a timestamp** and **one or more signatures** that you must verify.

* the timestamp is prefixed by `t=`
* each signature is prefixed by a ***scheme***. Schemes start with `v`, followed by an integer. (e.g. `v1`)

```
X-Terra-Signature:
t=1492774577,
v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,
v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
```

Terra generates signatures using a hash-based message authentication code ([HMAC](https://en.wikipedia.org/wiki/Hash-based_message_authentication_code)) with [SHA-256](https://en.wikipedia.org/wiki/SHA-2). To prevent [downgrade attacks](https://en.wikipedia.org/wiki/Downgrade_attack), ignore all schemes that aren’t `v1`

To create a manual solution for verifying signatures, you must complete the following steps:

**Step 1: Extract the timestamp and signatures from the header**

Split the header using the `,` character as the separator to get a list of elements. Then split each element using the `=` character as the separator to get a prefix and value pair.

The value for the prefix `t` corresponds to the timestamp, and `v1` corresponds to the signature (or signatures). You can discard all other elements.

**Step 2: Prepare the `signed_payload`string**

The `signed_payload` string is created by concatenating:

* The timestamp (as a string)
* The character `.`
* The actual JSON payload (that is, the request body)

**Step 3: Determine the expected signature**

Compute an HMAC with the SHA256 hash function. Use the endpoint’s signing secret as the key, and use the `signed_payload` string as the message.

**Step 4: Compare the signatures**

Compare the signature (or signatures) in the header to the expected signature. For an equality match, compute the difference between the current timestamp and the received timestamp, then decide if the difference is within your tolerance.

To protect against timing attacks, use a constant-time-string comparison to compare the expected signature to each of the received signatures.
{% endtab %}
{% endtabs %}

### Signing secret

Each webhook has its own signing secret. Verify a webhook's deliveries with that webhook's secret. A new webhook gets its own secret when you create it. Webhooks created earlier may still share your environment's secret until you rotate them.

To find it, open the [Connections page](https://dashboard.tryterra.co/dashboard/connections), click the key icon on the webhook, and copy **Signing secret**. It always shows the secret that webhook's deliveries are currently signed with.

### Rotate a signing secret

Rotating gives one webhook a new secret. Deliveries to that webhook are signed with the new secret straight away. Your other webhooks keep theirs. Verification itself does not change: same header, same HMAC-SHA256 scheme, only the key is new.

{% stepper %}
{% step %}
**Make sure your endpoint returns a non-2xx status when a signature does not match**, for example `401`. Terra then retries the delivery instead of treating it as delivered.
{% endstep %}

{% step %}
**Rotate the secret.** On the Connections page, click the key icon on the webhook, then **Rotate secret** and confirm. Only account admins can rotate. The new secret is shown unmasked: copy it.
{% endstep %}

{% step %}
**Update your endpoint to verify with the new secret** and deploy.
{% endstep %}
{% endstepper %}

Deliveries that arrive between the rotation and your deploy fail verification and get your non-2xx response. Terra retries them for several hours (see [Retries](#retries)), and each retry is signed with the current secret, so they verify once your endpoint has the new one.

You can also rotate with the [admin API](https://docs.tryterra.co/reference/admin-api/webhook-signing-secrets) or the Terra CLI.

### Allow Terra's IPs

An IP allowlist lets you to only allow requests from a preset list of allowed IPs. An attacker trying to reach your URL from an IP outside this list will have their request rejected.

Terra sends all outbound requests (webhooks, database connections, etc.) from the same set of IPs. See the [full IP list on the Destinations page](/destinations/setting-up-data-destinations.md#ip-allowlist).

## Retries

A webhook attempt fails when your server responds with anything other than a 2xx, including a 3xx redirect, or does not respond within 8 seconds. Terra then retries the delivery with exponential backoff for several hours. When the retries run out, the delivery becomes `failed` and Terra does not notify you.

Resend failed events from the [Events](https://dashboard.tryterra.co/dashboard/debug) page of the dashboard once your endpoint is fixed. [Event delivery](/destinations/events.md#retries) has the full retry and resend rules.

{% prompt description="Find failed webhook deliveries and resend them" icon="terminal" openInAIProviders="true" defaultExpanded="full" %}

```markdown
Show me every failed webhook delivery in production from the last 24 hours, and resend the ones for user 9ebfb96d-e376-4c17-8d4b-0e85db39b1b5.

Use the Terra CLI: brew install tryterra/tap/terra, or npm install -g @tryterra/cli, then terra login. Run terra docs ask --question "<question>" for anything you need from the Terra docs, and terra <command> --help for usage.
```

{% endprompt %}

## Timeout and circuit breaker

Terra waits **8 seconds** for your server to respond before considering the request timed out.

When a destination shows a sustained high failure rate, Terra activates a **per-destination circuit breaker** that briefly pauses delivery. This protects your server from continued load while it's clearly unhealthy and protects Terra's delivery pipeline from spending capacity on requests that are unlikely to succeed.

**What trips the circuit:**

* Client-side **timeouts** (your server didn't respond within 8 seconds).
* HTTP **5xx** responses from your server.
* HTTP **4xx** responses that persist across several delivery attempts on the same event. A 4xx on an event's early attempts is treated as a per-message issue. Only sustained 4xx across retries is taken as evidence that the destination itself is misconfigured.

The breaker only fires on **unambiguous meltdowns**: a destination that is failing the vast majority of recent attempts over a meaningful sample. A handful of failures or a mild degradation (e.g. occasional timeouts) does not trip the circuit; the rest of your traffic flows normally.

**While the circuit is open:**

* Data events for that destination are held back, not attempted, and re-queued with the same exponential backoff used for normal retries.
* Each re-queue counts toward the delivery's retry limit, even though no request was sent. If the destination stays broken until the retries run out, the delivery fails without ever reaching your server.
* **Authentication and de-authentication events still flow during a circuit break.** These control-plane events bypass the breaker so your auth/connection lifecycle is never delayed by data-path issues.

**Recovery:**

The breaker is short-lived and recovers automatically. After a brief pause, Terra cautiously tests the destination with a single request. If your server responds successfully, normal traffic resumes immediately. If it still fails, the pause re-engages. There is no manual action required. The circuit cycles until your destination is healthy or until the deliveries have used up their retries.

{% hint style="warning" %}
**Respond within 8 seconds.** If you need more time to process the data, accept the webhook immediately (return 200) and process it asynchronously. A brief timeout episode can cascade into dropped events if the circuit breaker stays open until the event's retries run out.
{% endhint %}

{% hint style="info" %}
**4xx responses on first attempts are safe.** Validation errors on a single bad payload (e.g. an unexpected field) are treated as per-message issues. Terra will not penalize your destination for an occasional 4xx, only for **persistent** 4xx across retries on the same event, which suggests the URL itself is misconfigured.
{% endhint %}

{% hint style="warning" %}
**Monitor for sustained outages.** If your destination is down long enough that an event's retries run out (this takes several hours), its delivery fails and there's no notification. New events after recovery are delivered normally. Events from the outage stay `failed` until you resend them. We recommend setting up endpoint health monitoring on your side to catch extended outages early.
{% endhint %}

## Optional: Use ping mode

For high-volume integrations or large payloads, Terra supports a **ping mode** that keeps webhook payloads small. Instead of sending the full data payload in the POST body, Terra uploads it to cloud storage and sends a lightweight summary with a pre-signed download URL.

When ping mode is enabled, your webhook receives:

```json
{
  "status": "success",
  "type": "s3_payload",
  "url": "https://presigned-download-url.example.com/...",
  "expires_in": 300
}
```

* `url`: a pre-signed link to the full data payload, hosted on Terra's infrastructure. Download it with a `GET` request.
* `expires_in`: URL validity in seconds (300 = 5 minutes).

**How to process ping mode payloads:**

1. Receive the webhook POST as usual and verify the signature.
2. Check if the `type` field is `"s3_payload"`.
3. Download the full payload from the `url` field using a `GET` request.
4. Process the downloaded JSON as you would a normal webhook payload.

```python
@app.route('/webhook', methods=['POST'])
def handle_webhook():
    data = request.get_json()

    if data.get("type") == "s3_payload":
        # Ping mode: download the full payload from the URL
        response = requests.get(data["url"])
        payload = response.json()
    else:
        # Normal mode: the full payload is in the POST body
        payload = data

    # Process payload as usual
    process(payload)
    return "", 200
```

{% hint style="info" %}
**Authentication and connection events** (auth success, deauth, etc.) are always delivered inline in the POST body, even when ping mode is enabled. Only data payloads use the S3 URL delivery.
{% endhint %}

{% hint style="info" %}
**To enable ping mode**, open your webhook destination on the [Connections page](https://dashboard.tryterra.co/dashboard/connections), set **Delivery mode** to **Ping**, and save. It is configured per destination and does not require any changes to your webhook URL. If you do not see a Delivery mode option, contact Terra support and we'll enable it for you.
{% endhint %}

## Next steps

* [User authentication](/unified-api/authentication-flow.md): link a user's wearable so events start flowing.
* [Event delivery](/destinations/events.md): see what each delivery status means and how resends work.
* [Health data events](/unified-api/receiving-data-updates.md): handle the data payloads your endpoint receives.
