For the complete documentation index, see llms.txt. This page is also available as Markdown.

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 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. 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.

Enabled

Toggle on each registered webhook. Switch it off to pause deliveries without deleting the webhook.

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)

  • 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:

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.

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

Node.js

Payload Reference

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.

Every delivery has the same envelope:

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.

customer

object

address

object

See address.

shipment

object, nullable

See shipment. null if the trade-in has no shipment.

latest_status_tracking

object

latest_shipment_tracking

object, present only if a shipment tracking event exists

notes_for_customer

array of object

discounts

array of object

exchange_purchase_order_refund

object, nullable

See exchange_purchase_order_refund. null if not applicable to this trade-in.

items

array of object

See 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

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.

recordable

object, nullable

The record backing this item — shape depends on the item type, see below.

repair_services

array of object

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.

Last updated