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:
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:
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.
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.
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/jsonBody: the JSON-encoded payload (see Payload Reference)
Timeout: 15 seconds
Headers
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.
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.
To verify a delivery:
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.
Compute
HMAC-SHA256(your_secret, raw_body)and hex-encode it.Compare it to
X-Tern-Webhook-Signatureusing a timing-safe comparison.Only after verification succeeds, parse the body as JSON and process it.
PHP
Node.js
Payload Reference
Every delivery has the same envelope:
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
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
latest_shipment_tracking
object, present only if a shipment tracking event exists
exchange_purchase_order_refund
object, nullable
See exchange_purchase_order_refund. null if not applicable to this trade-in.
deductions and the full shipment_tracking history are not included in this payload — only the latest shipment tracking event is (latest_shipment_tracking).
status
id
integer
name
string
slug
string
customer
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
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
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
shipment_tracking entries
Used for latest_shipment_tracking.
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
id
integer
note_type_id
integer
name
string
note
string
created_at
datetime
discounts
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:
shopify_created_at
string, nullable (formatted date)
shopify_processed_at
string, nullable (formatted date)
line_items
array of object
items
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
recordable
object, nullable
The record backing this item — shape depends on the item type, see below.
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):
FieldTypeidinteger
uuidstring
product_option_idinteger, nullable
product_option_titlestring, nullable
category_idinteger, nullable
category_titlestring, nullable
category_pathstring, nullable
created_atdatetime
updated_atdatetime
deleted_atdatetime, 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
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