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

# Outbound Webhooks

## Overview

Outbound webhooks let your systems receive a notification whenever a trade-in's status changes in Tern, without needing to poll Tern.

Today there is a single event:

| Event                     | Fires when                                                                             |
| ------------------------- | -------------------------------------------------------------------------------------- |
| `trade_in.status_changed` | A trade-in transitions to a new status (e.g. Opened → Confirmed → Received → Rewarded) |

Delivery is asynchronous. When a status change happens, Tern queues a delivery for each matching, enabled webhook and sends it in the background. Expect delivery to happen shortly after the status change, not synchronously with it, and do not assume deliveries for the same trade-in arrive in strict order — each is queued and delivered independently.

## Registering a Webhook

Webhooks are managed in the Tern admin app: open **Settings → Integrations** and use the **Webhooks** card to register, edit, pause, or delete webhooks. Managing webhooks requires the **Webhooks** team role — for team members without it, the card is disabled.

Each webhook has the following settings:

| Setting                            | Description                                                                                                                                                            |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Webhook URL**                    | The endpoint Tern delivers events to. Must satisfy the [endpoint requirements](#endpoint-requirements) below.                                                          |
| **Event**                          | The event the webhook fires for — currently fixed to `trade_in.status_changed`.                                                                                        |
| **Status filters**                 | Optional. Limits the webhook to specific trade-in statuses — see [Event and Status Filtering](#event-and-status-filtering). Leave empty to receive all status changes. |
| **Secret**                         | The HMAC signing secret (minimum 32 characters). Leave empty and Tern generates a random 40-character secret for you.                                                  |
| **Basic Auth username / password** | Optional. See [Basic Auth](#optional-http-basic-auth).                                                                                                                 |
| **Enabled**                        | Toggle on each registered webhook. Switch it off to pause deliveries without deleting the webhook.                                                                     |

{% hint style="warning" %}
The signing secret is only visible immediately after you register the webhook — copy it into your integration's configuration straight away. Afterwards Tern only shows whether a secret is set, never its value, and it cannot be retrieved again.

To rotate the secret, edit the webhook and enter a new value of your own (32+ characters) in the Secret field.
{% endhint %}

When editing a webhook, the Basic Auth password field is always shown blank; leave it blank to keep the current password. To stop sending Basic Auth entirely, use the **Remove credentials** action on the registered webhook.

## Endpoint Requirements

Your webhook URL must pass validation both when you save it and again immediately before every delivery attempt. A URL that fails validation at delivery time (for example, because its DNS record changed after you saved it) is silently skipped for that delivery rather than retried.

Requirements:

* Must use `https://`. Plain HTTP is rejected.
* Must use the standard HTTPS port (443). Explicitly specifying any other port is rejected.
* The hostname (or its resolved IP addresses) must not be a private, loopback, link-local, or otherwise reserved address — this includes `localhost`, `metadata.google.internal`, and hostnames ending in `.local`, `.internal`, or `.localhost`.
* If the host is a plain hostname, its DNS A/AAAA records are resolved and checked against the same restrictions.

In practice: your endpoint must be a publicly resolvable HTTPS host, reachable on port 443, that does not resolve to an internal or private network address.

### Redirects

Delivery requests do not follow redirects. Your endpoint must respond directly at the configured URL — a 3xx response is treated as a failed delivery, not followed.

### Optional HTTP Basic Auth

If you configure Basic Auth credentials on a webhook, every delivery to it includes an `Authorization: Basic` header with those credentials.

## Delivery

Each delivery is an HTTP `POST` request:

* **Content-Type:** `application/json`
* **Body:** the JSON-encoded payload (see [Payload Reference](#payload-reference))
* **Timeout:** 15 seconds

### Headers

| Header                     | Description                                                             |
| -------------------------- | ----------------------------------------------------------------------- |
| `Content-Type`             | Always `application/json`.                                              |
| `User-Agent`               | `Tern-Webhook/1.0`                                                      |
| `X-Tern-Webhook-Id`        | The ID of the registered webhook, as a string.                          |
| `X-Tern-Webhook-Event`     | The event name, e.g. `trade_in.status_changed`.                         |
| `X-Tern-Webhook-Timestamp` | Unix timestamp (seconds), as a string, of when the request was sent.    |
| `X-Tern-Webhook-Signature` | Hex-encoded HMAC-SHA256 signature of the request body — see below.      |
| `Authorization`            | `Basic <credentials>`, only if Basic Auth is configured on the webhook. |

## Verifying Signatures

`X-Tern-Webhook-Signature` is computed as:

```
hex(HMAC_SHA256(secret, raw_request_body))
```

The signature covers the **raw JSON request body only** — the exact bytes that were sent, encoded as UTF-8. It does not cover the timestamp, event name, or any other header.

{% hint style="warning" %}
Because `X-Tern-Webhook-Timestamp` is not part of the signed content, its authenticity is not verified by the signature check. Don't treat it as a trustworthy replay-prevention value on its own. If replay protection matters for your integration, deduplicate on something inside the verified payload instead — for example, the combination of `trade_in.id` and `trade_in.latest_status_tracking.id` — rather than rejecting requests based on the timestamp header.
{% endhint %}

To verify a delivery:

1. Read the **raw** request body — do not parse and re-serialize the JSON before verifying, since re-encoding can change byte-for-byte output (key order, whitespace) and break the comparison.
2. Compute `HMAC-SHA256(your_secret, raw_body)` and hex-encode it.
3. Compare it to `X-Tern-Webhook-Signature` using a timing-safe comparison.
4. Only after verification succeeds, parse the body as JSON and process it.

### PHP

```php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_TERN_WEBHOOK_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
```

### Node.js

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyTernWebhook(rawBody, signatureHeader, secret) {
  const expected = createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  const expectedBuffer = Buffer.from(expected, 'hex');
  const givenBuffer = Buffer.from(signatureHeader ?? '', 'hex');

  if (expectedBuffer.length !== givenBuffer.length) {
    return false;
  }

  return timingSafeEqual(expectedBuffer, givenBuffer);
}

// Express: use express.raw({ type: 'application/json' }) on this route so
// req.body is the raw Buffer, not an already-parsed object.
app.post('/webhooks/tern', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer
  const signature = req.get('X-Tern-Webhook-Signature');

  if (!verifyTernWebhook(rawBody, signature, process.env.TERN_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const payload = JSON.parse(rawBody.toString('utf8'));
  // ... handle payload
  res.sendStatus(200);
});
```

## Payload Reference

{% hint style="info" %}
Webhook payloads contain customer personal data — name, email, phone, and address. Your endpoint, and anything downstream of it (logs, third-party processors), receives this data: secure it accordingly and account for it in your data-processing arrangements.
{% endhint %}

Every delivery has the same envelope:

```json
{
  "event": "trade_in.status_changed",
  "triggered_at": "2026-08-28T09:15:32+00:00",
  "trade_in": { }
}
```

| Field          | Type              | Description                                      |
| -------------- | ----------------- | ------------------------------------------------ |
| `event`        | string            | Always `trade_in.status_changed` for this event. |
| `triggered_at` | string (ISO 8601) | When the status change was recorded.             |
| `trade_in`     | object            | The trade-in, in the shape below.                |

### `trade_in`

| Field                             | Type                                                     | Description                                                                                                         |
| --------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `id`                              | integer                                                  |                                                                                                                     |
| `uuid`                            | string                                                   |                                                                                                                     |
| `shop_id`                         | integer                                                  |                                                                                                                     |
| `shopify_customer_id`             | integer, nullable                                        |                                                                                                                     |
| `name`                            | string                                                   | Trade-in reference/display name.                                                                                    |
| `created_at`                      | datetime                                                 |                                                                                                                     |
| `trade_in_opened`                 | datetime, nullable                                       | When the trade-in was confirmed/opened.                                                                             |
| `trade_in_closed`                 | datetime, nullable                                       | When the trade-in was closed.                                                                                       |
| `status_id`                       | integer                                                  | Current status ID.                                                                                                  |
| `notification_email`              | string, nullable                                         |                                                                                                                     |
| `total_price`                     | number                                                   |                                                                                                                     |
| `net_price`                       | number                                                   |                                                                                                                     |
| `total_quantity`                  | integer                                                  |                                                                                                                     |
| `currency_symbol`                 | string                                                   | The shop's currency symbol.                                                                                         |
| `credit_amount_granted`           | number                                                   | Total reward value issued so far.                                                                                   |
| `is_non_monetary_reward`          | boolean                                                  |                                                                                                                     |
| `non_monetary_reward_description` | string, nullable                                         |                                                                                                                     |
| `non_monetary_reward_terms`       | string, nullable                                         |                                                                                                                     |
| `non_monetary_reward_count`       | integer, nullable                                        |                                                                                                                     |
| `marketing_consent`               | boolean                                                  |                                                                                                                     |
| `status`                          | object                                                   | See [`status`](#status).                                                                                            |
| `customer`                        | object                                                   | See [`customer`](#customer).                                                                                        |
| `address`                         | object                                                   | See [`address`](#address).                                                                                          |
| `shipment`                        | object, nullable                                         | See [`shipment`](#shipment). `null` if the trade-in has no shipment.                                                |
| `latest_status_tracking`          | object                                                   | See [`latest_status_tracking`](#latest_status_tracking).                                                            |
| `latest_shipment_tracking`        | object, present only if a shipment tracking event exists | See [`shipment_tracking entries`](#shipment_tracking-entries).                                                      |
| `notes_for_customer`              | array of object                                          | See [`notes_for_customer`](#notes_for_customer).                                                                    |
| `discounts`                       | array of object                                          | See [`discounts`](#discounts).                                                                                      |
| `exchange_purchase_order_refund`  | object, nullable                                         | See [`exchange_purchase_order_refund`](#exchange_purchase_order_refund). `null` if not applicable to this trade-in. |
| `items`                           | array of object                                          | See [`items`](#items).                                                                                              |

`deductions` and the full `shipment_tracking` history are not included in this payload — only the latest shipment tracking event is (`latest_shipment_tracking`).

#### `status`

| Field  | Type    |
| ------ | ------- |
| `id`   | integer |
| `name` | string  |
| `slug` | string  |

#### `customer`

| Field                 | Type              | Notes                                              |
| --------------------- | ----------------- | -------------------------------------------------- |
| `id`                  | integer           |                                                    |
| `shop_id`             | integer           |                                                    |
| `shopify_customer_id` | integer, nullable |                                                    |
| `first_name`          | string, nullable  |                                                    |
| `last_name`           | string, nullable  |                                                    |
| `email`               | string, nullable  |                                                    |
| `phone`               | string, nullable  |                                                    |
| `note`                | string, nullable  |                                                    |
| `currency`            | string, nullable  |                                                    |
| `created_at`          | string, nullable  | Formatted date string (not ISO 8601).              |
| `default_address`     | object, nullable  | `null` if the customer record has been anonymised. |
| `number_of_orders`    | integer, nullable |                                                    |
| `total_trade_ins`     | integer, nullable |                                                    |

#### `address`

| Field                | Type              |
| -------------------- | ----------------- |
| `id`                 | integer or string |
| `shopify_address_id` | integer, nullable |
| `first_name`         | string, nullable  |
| `last_name`          | string, nullable  |
| `address1`           | string, nullable  |
| `address2`           | string, nullable  |
| `city`               | string, nullable  |
| `company`            | string, nullable  |
| `country`            | string, nullable  |
| `country_code`       | string, nullable  |
| `phone`              | string, nullable  |
| `province`           | string, nullable  |
| `province_code`      | string, nullable  |
| `zip`                | string, nullable  |
| `is_completed`       | boolean           |

#### `shipment`

| Field                   | Type             | Description                          |
| ----------------------- | ---------------- | ------------------------------------ |
| `id`                    | integer          |                                      |
| `created_at`            | datetime         |                                      |
| `label_provider_type`   | string, nullable | Shipping carrier provider type.      |
| `label_carrier_name`    | string, nullable |                                      |
| `label_service_name`    | string, nullable |                                      |
| `label_format`          | string, nullable |                                      |
| `label_url`             | string, nullable |                                      |
| `label_qr_code_url`     | string, nullable |                                      |
| `label_tracking_number` | string, nullable |                                      |
| `label_tracking_url`    | string, nullable |                                      |
| `custom_description`    | string, nullable | Only set for custom/manual carriers. |
| `custom_link`           | string, nullable | Only set for custom/manual carriers. |

#### `latest_status_tracking`

| Field        | Type                             |
| ------------ | -------------------------------- |
| `id`         | integer                          |
| `user_id`    | integer, nullable                |
| `status_id`  | integer                          |
| `status`     | object — see [`status`](#status) |
| `created_at` | datetime                         |

#### `shipment_tracking` entries

Used for `latest_shipment_tracking`.

| Field                        | Type               |
| ---------------------------- | ------------------ |
| `id`                         | integer            |
| `status`                     | string             |
| `status_details`             | string, nullable   |
| `status_detail_code`         | string, nullable   |
| `status_detail_description`  | string, nullable   |
| `carrier_status_code`        | string, nullable   |
| `carrier_status_description` | string, nullable   |
| `status_updated_at`          | datetime, nullable |
| `tracking_number`            | string, nullable   |
| `created_at`                 | datetime           |
| `updated_at`                 | datetime           |

#### `notes_for_customer`

| Field          | Type     |
| -------------- | -------- |
| `id`           | integer  |
| `note_type_id` | integer  |
| `name`         | string   |
| `note`         | string   |
| `created_at`   | datetime |

#### `discounts`

| Field                          | Type                              |
| ------------------------------ | --------------------------------- |
| `id`                           | integer                           |
| `created_at`                   | string (formatted date)           |
| `starts_at`                    | string (formatted date)           |
| `shopify_id`                   | string, nullable                  |
| `shopify_resource_id`          | string, nullable                  |
| `trade_in_id`                  | integer                           |
| `type`                         | string                            |
| `title`                        | string, nullable                  |
| `code`                         | string, nullable                  |
| `amount_type`                  | string                            |
| `value`                        | number                            |
| `applies_on_each_item`         | boolean                           |
| `usage_limit`                  | integer, nullable                 |
| `applies_once_per_customer`    | boolean                           |
| `can_be_used_by_all_customers` | boolean                           |
| `combines_with`                | object                            |
| `minimum_order_value`          | number, nullable                  |
| `expires_at`                   | string, nullable (formatted date) |
| `redeemed_at`                  | string, nullable (formatted date) |
| `redeemed_value`               | number, nullable                  |

`combines_with` shape: `{ "orders": boolean, "products": boolean, "shipping": boolean }`.

#### `exchange_purchase_order_refund`

Base refund fields plus:

| Field                  | Type                              |
| ---------------------- | --------------------------------- |
| `shopify_created_at`   | string, nullable (formatted date) |
| `shopify_processed_at` | string, nullable (formatted date) |
| `line_items`           | array of object                   |

#### `items`

| Field                  | Type              | Description                                                               |
| ---------------------- | ----------------- | ------------------------------------------------------------------------- |
| `id`                   | integer           |                                                                           |
| `trade_in_id`          | integer           |                                                                           |
| `quantity_expected`    | integer           |                                                                           |
| `status_id`            | integer           |                                                                           |
| `reward_type`          | string, nullable  |                                                                           |
| `strategy`             | string, nullable  |                                                                           |
| `reward_value_offered` | number, nullable  |                                                                           |
| `created_at`           | datetime          |                                                                           |
| `quantity_received`    | integer, nullable |                                                                           |
| `image_url`            | string, nullable  |                                                                           |
| `status`               | object            | See [`status`](#status).                                                  |
| `recordable`           | object, nullable  | The record backing this item — shape depends on the item type, see below. |
| `repair_services`      | array of object   | See [`repair_services`](#repair_services).                                |

`recordable` is one of two shapes depending on how the item was traded in:

* **Category/product-option record** (customer chose a product from your category tree):

  | Field                  | Type               |
  | ---------------------- | ------------------ |
  | `id`                   | integer            |
  | `uuid`                 | string             |
  | `product_option_id`    | integer, nullable  |
  | `product_option_title` | string, nullable   |
  | `category_id`          | integer, nullable  |
  | `category_title`       | string, nullable   |
  | `category_path`        | string, nullable   |
  | `created_at`           | datetime           |
  | `updated_at`           | datetime           |
  | `deleted_at`           | datetime, nullable |
* **Linked Shopify product record** (item is tied to a specific Shopify product/variant): the underlying record's own fields (IDs, pricing, etc.). The related product, variant, order line item, and offer/profile type objects are not expanded in this payload.

#### `repair_services`

| Field               | Type             |
| ------------------- | ---------------- |
| `id`                | integer          |
| `trade_in_item_id`  | integer          |
| `repair_service_id` | integer          |
| `description`       | string, nullable |
| `warranty`          | string, nullable |
| `cost`              | number, nullable |
| `note`              | string, nullable |
| `created_at`        | datetime         |
| `updated_at`        | datetime         |

## Retries and Failure Handling

A delivery is considered successful if your endpoint returns any `2xx` status code.

If a delivery fails — a non-`2xx` response, or a connection-level error such as a timeout or DNS failure — Tern retries once (2 attempts total for that event), waiting 200ms between attempts. If both attempts fail, that delivery is abandoned; it is not queued for later retry, and the underlying status change is not automatically re-sent.

When a delivery ultimately fails, Tern emails the shop owner with the failure details (endpoint URL, status code, and a summary of the response body). To avoid flooding the owner's inbox from a persistently broken endpoint, these failure emails are rate-limited to one per webhook per hour.

Repeated failures do not automatically disable a webhook — it keeps receiving delivery attempts (and you keep receiving failure emails, subject to the hourly limit) until you fix the endpoint or switch the webhook off in the admin.

## Event and Status Filtering

Two settings control which status changes a webhook receives:

* **Event** — the webhook only fires for its selected event type. Today the only supported event is `trade_in.status_changed`.
* **Status filters** — optional. If set, the webhook only fires when a trade-in transitions **into** one of the selected statuses. If left empty, the webhook fires for every trade-in status change on the shop.

Filtering is evaluated per webhook, so different webhooks on the same shop can watch different subsets of statuses.
