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-Event-Id
A unique ID for this event (a UUID), the same value as event_id in the body. It is identical on retries and on every endpoint that receives the event, so use it to drop duplicates.
X-Tern-Webhook-Event
The event name, e.g. trade_in.status_changed.
X-Tern-Webhook-Subscription-Id
The ID of the registered webhook, as a string.
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 — event_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
Date format change (September 2026 release). Every datetime field in the payload is ISO 8601 in UTC, e.g. 2026-09-18T09:00:00.000000Z. Before this release customer.created_at, the discounts[] date fields and exchange_purchase_order_refund.shopify_created_at / shopify_processed_at were display strings such as 18th Sep 2026 10:00 AM; parse them with any ISO 8601 parser instead.
Every delivery has the same envelope:
event
string
Always trade_in.status_changed for this event.
event_id
string (UUID)
Unique and stable for this status change — retries and every registered endpoint receive the same value. Also sent as the X-Tern-Webhook-Event-Id header.
triggered_at
string (ISO 8601)
When the status change was recorded.
triggered_status
object
The status this event is for — see status. trade_in.status is the trade-in's status at the moment the payload is built, which can be later than triggered_status if it has moved on since.
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
confirmed_at
datetime, nullable
When the customer confirmed the trade-in.
opened_at
datetime, nullable
When the trade-in was opened, i.e. when the items started being expected.
trade_in_closed
datetime, nullable
When the trade-in was closed.
status_id
integer
Current status ID.
notification_email
string, nullable
total_price
number
Sum of each item's expected quantity × its customer-agreed value (its adjusted value where none is agreed yet), before deductions.
net_price
number
total_price less deductions.
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
is_item_exchange
boolean
true when the customer is exchanging the items for a product rather than receiving a reward. Exchange trade-ins never enter an offer stage.
latest_shipment_tracking
object, present only if a shipment tracking event exists
The shipment's current tracking event, the same one the admin and Returns Centre show. It only ever moves forward in time, so a late-arriving older carrier event never replaces it. See shipment_tracking entries.
exchange_purchase_order_refund
object, nullable
See exchange_purchase_order_refund. null if not applicable to this trade-in.
return_shipments
array of object
The parcels that send returned items back to the customer; empty if none. See return_shipments.
latest_offer
object, nullable
The highest-numbered offer round; null if the trade-in has never had one. See latest_offer / offers.
offers
array of object
Every offer round, oldest first; empty if the trade-in has never had one. See latest_offer / offers.
deductions and the full shipment_tracking history are not included in this payload — only the latest shipment tracking event is (latest_shipment_tracking).
Reward value vocabulary
reward_quoted, reward_inspected, reward_offered, and reward_agreed are gross figures — a plain sum of item values x quantity, with no deduction subtracted and no floor at zero. reward_deductions carries the deduction (e.g. customer shipping fee) separately, and credit_amount_granted is the net amount actually minted. The identity holds once a reward has been issued:
Quoted
reward_quoted
What the customer saw on the storefront at confirm time (storefront value x expected quantity). null if no item has a quote (e.g. the shop hides pricing); an item with no quote counts as 0 when others have one.
Inspected
reward_inspected
The inspection result: each item's reward_value_payable x received quantity, summed. null before the trade-in has been received.
Offered
reward_offered
The latest sent, accepted, or rejected offer round's total. null if no offer round has been sent.
Agreed
reward_agreed
The latest accepted offer round's total. If no offer round has ever been opened, the confirm-time quote (reward_quoted) stands as the agreed value. null while an offer is open (sent) or rejected.
Issued
credit_amount_granted
The net amount actually minted — reward_agreed less reward_deductions, once issuance has run.
reward_quoted is null whenever the customer saw no price (e.g. hidden pricing). Check for null explicitly before comparing it numerically — don't assume 0 in that case.
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
datetime, nullable
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
carrier for a booked carrier label, custom for a merchant's own shipping option.
label_carrier_name
string, nullable
Display name of the carrier behind the selected shipping option.
label_service_name
string, nullable
Name of the selected carrier service.
label_format
string, nullable
label_url
string, nullable
label_qr_code_url
string, nullable
label_tracking_number
string, nullable
label_tracking_url
string, nullable
tracking_available
boolean
Whether the label supports carrier tracking.
custom_description
string, nullable
Only set for custom/manual carriers.
custom_link
string, nullable
Only set for custom/manual carriers.
Before the September 2026 backend release, label_provider_type, label_carrier_name, label_service_name, custom_description and custom_link were always null. They are populated from that release on; the field set itself is unchanged.
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
Every customer-visible note on the trade-in, oldest last. Item-level notes are not included here; the ones the merchant wrote while changing a value surface as customer_note on the matching offer item.
id
integer
note_type_id
integer
name
string
note
string
created_at
datetime
A note saved without text is sent as No note provided.
discounts
Only rewards that have actually been issued are included; a reward that is still being issued is not sent until it has completed.
id
integer
created_at
datetime
starts_at
datetime
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
minted_at
datetime
minimum_order_value
number, nullable
expires_at
datetime, nullable
redeemed_at
datetime, nullable
redeemed_value
number, nullable
combines_with shape: { "orders": boolean, "products": boolean, "shipping": boolean }.
exchange_purchase_order_refund
Base refund fields plus:
shopify_created_at
datetime, nullable
shopify_processed_at
datetime, nullable
line_items
array of object
latest_offer / offers
An offer round records one round of the offer/approval flow: the shop proposes a value (or the system does, automatically), and the customer accepts or rejects it. latest_offer is the highest-numbered round; offers is every round for the trade-in, ordered by round ascending. If the trade-in has never had an offer round (e.g. shops with no offer stages enabled), latest_offer is null and offers is an empty array. latest_offer never includes items — read the items from the matching entry in offers.
id
integer
trade_in_id
integer
round
integer
1-indexed. Round 1 is the first offer sent for this trade-in.
stage
string
before_shipping or after_receipt — whether this round was opened before or after the items were received.
trigger
string
automatic or manual — whether the round was opened by the inspection rule or by an admin sending/revising an offer.
status
string
sent, superseded (the shop revised the offer before the customer responded), accepted, rejected (the customer declined it — the trade-in moves to Declined), or withdrawn (the trade-in was cancelled while this round was still open).
total_previous
number, nullable
The trade-in's total before this round. null for round 1 of a trade-in with hidden pricing (there is no previous value to show).
total_offered
number
The trade-in's total after this round, net of deductions.
total_offered_gross
number
total_offered before deductions (total_offered + total_deductions).
total_deductions
number
The trade-in's deductions at the time the round was sent. total_offered and total_previous are net of it.
sent_by_user_id
integer, nullable
The admin user who sent this round. null for automatic rounds.
sent_at
datetime, nullable
responded_at
datetime, nullable
When the customer accepted or rejected this round.
decline_outcome
string, nullable
What the shop told the customer would happen if they decline this round, copied from the shop's setting when the round was sent: return, recycle or review (the shop reviews it and decides). null only on rounds sent before 4.0.0. It is set on every round, whether or not the customer declines.
reject_outcome
string, nullable
The same value as decline_outcome, under its older name. Use decline_outcome.
created_at
datetime
updated_at
datetime
items
array of object, offers entries only
See offer items below. Empty if the round changed no item's value.
Offer items
One row per item whose value changed in that round. Round 1 of a hidden-price trade-in lists every item (reason = initial_quote).
id
integer
trade_in_offer_id
integer
trade_in_item_id
integer
quantity
integer
The quantity this value applies to — expected quantity before shipping, received quantity after.
value_previous
number, nullable
Per unit. null if there is no previous value to compare against.
value_offered
number
Per unit.
reason
string
initial_quote, inspection, not_received, returned, recycle_only, or admin_adjustment.
customer_note
string, nullable
The merchant's customer-facing explanation for this row, taken from the notes attached to the value changes this round presented. Several notes are joined with a blank line. null when the merchant left none. The same text appears under the row in the offer email.
items
id
integer
trade_in_id
integer
quantity_expected
integer
status_id
integer
reward_type
string, nullable
reward_value_base
number
The pricing engine's value for the item's product before condition answers, not limited by the shop's maximum item reward. A recalculation of the item's value never goes above it.
reward_value_quoted
number, nullable
What the customer saw at checkout. null when the shop hides prices or the item was added after checkout. Never overwritten.
reward_value_agreed
number, nullable
The customer's value: the quote they confirmed, then the value of the last offer round they accepted. null until they've seen or accepted a value.
reward_value_adjusted
number
The merchant's working value: what the next offer round proposes and what issuance pays.
reward_value_payable
number
The per-unit value the item pays now. Once the trade-in's reward has been issued, the amount that was issued; before then, 0 for an item that pays nothing (outcome return, or status returned or not received), the shop's recycle reward for a recycle-only item, and otherwise reward_value_adjusted.
reward_value_issued
number, nullable
Per unit paid. null until issued, 0 for an item with nothing received.
total_reward_value_issued
number, nullable
reward_value_issued × quantity_received.
inspection_outcome
string, nullable
What happens to the item: eligible (rewarded), recycle-only or return. Set when the item is received, and updated when the merchant marks it returned or recycled or when a customer declining an offer sets it (return or recycle-only, per the round's decline_outcome; review leaves it as it was). null before receipt, and again if receipt is undone.
created_at
datetime
quantity_received
integer, nullable
image_url
string, nullable
return_shipment
object, nullable
The parcel sending this item back to the customer; null until the item is dispatched. See return_shipments.
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.
return_shipments
The outbound parcel that sends returned items back to the customer, recorded when the merchant marks items as returned. The items on a parcel are the ones whose return_shipment.id matches.
id
integer
carrier
string, nullable
tracking_number
string, nullable
tracking_url
string, nullable
dispatched_at
datetime, nullable
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.
Statuses
status.id, status.name and status.slug use these values.
1
Trade-in - not confirmed
not-confirmed
2
Trade-in confirmed
confirmed
6
Trade-in requested
requested
Awaiting the merchant's decision.
7
Trade-in offered
offered
An offer round is open.
18
Trade-in accepted
accepted
The customer accepted an offer.
26
Trade-in declined
declined
The customer declined an offer.
8
Trade-in rejected
rejected
The merchant rejected a request. Passes straight on to Cancelled.
4
Trade-in opened
opened
5
Trade-in received
received
10
Trade-in reward granted
rewarded
9
Trade-in closed
closed
3
Trade-in cancelled
cancelled
Items have their own statuses, used in items[].status and items[].status_id:
19
requested
20
accepted
21
rejected
13
awaited
14
received
15
not received
22
recycled
23
repaired
25
returned
16
shopify relisted
17
export relisted
24
fully relisted
Shopify Flow Trigger
Separately from outbound webhooks, Tern also ships a Shopify Flow trigger — "Trade-in status changed" (handle trade-in-status-changed) — for shops that want to build Flow automations (e.g. tag the customer, send an internal Slack notification) without standing up a webhook endpoint. It's enabled per shop in Settings → Integrations.
The trigger fires for a subset of trade-in status changes: Confirmed, Opened, Received, Reward granted, Closed, Cancelled, Offered, Accepted, Rejected (a merchant rejecting a request) and Declined (a customer declining an offer). See Statuses for how Rejected and Declined differ.
Payload fields
customerId
ID, nullable
Shopify customer ID.
tradeInId
string
Trade-in reference/display name.
status
enum
One of CONFIRMED, CANCELLED, OPENED, RECEIVED, CLOSED, REWARDED, OFFERED, ACCEPTED, REJECTED, DECLINED.
triggeredAt
string (ISO 8601)
email
string
rewardOffered
number, nullable
Gross. See Reward value vocabulary. This field's meaning changed when offer rounds were introduced — it previously reported the confirm-time storefront total for every trade-in (that value is now rewardQuoted).
rewardIssued
number
The net amount actually issued to the customer.
offerRound
integer
The current offer round number, or 0 if no offer round has been opened.
currency
string
The shop's ISO currency code (e.g. GBP, USD).
tradeInUrl
string
Link to this trade-in in the Tern admin.
rewardType
string
How the reward is issued (e.g. store-credit, gift-card, cash, item-exchange).
offerStage
string
The stage of the latest sent, accepted, or rejected offer round: before_shipping or after_receipt, or "none" if no offer round exists.
offerStatus
string
The status of that same offer round: sent, accepted, or rejected, or "none" if no offer round exists.
rejectOutcome
string
What the shop told the customer would happen if they decline that offer round: return, recycle or review, or "none" if there is no such offer round. It is set whether or not the customer declines; the decline itself shows as status DECLINED.
itemsExpected
integer
Total quantity of items expected on this trade-in.
itemsReceived
integer
Total quantity of items received so far.
itemsRewarded
integer
Quantity of received items being rewarded at their inspected value.
itemsRecycled
integer
Quantity of received items being recycled.
itemsReturned
integer
Quantity of received items being returned to the customer.
marketingConsent
boolean
rewardQuoted, rewardInspected, rewardOffered, and rewardAgreed follow the same reward value vocabulary as the webhook payload — see Reward value vocabulary: they are gross figures (no deduction subtracted, no floor at zero), rewardDeductions carries the deduction, and rewardAgreed - rewardDeductions = rewardIssued once issued.
rewardQuoted is null whenever the customer saw no price (e.g. hidden pricing). Check for null explicitly before using it in a Flow condition — a numeric comparison against null will not behave as "no price shown".
Last updated