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

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.

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

Changed in 4.0.0

Webhook payload:

  • Items: reward_value_offered is renamed reward_value_agreed, and the strategy field is removed. Items gain reward_value_base, reward_value_quoted, reward_value_adjusted, reward_value_payable, reward_value_issued, total_reward_value_issued, inspection_outcome and return_shipment (see items).

  • Trade-in: gains reward_quoted, reward_inspected, reward_offered, reward_agreed, reward_deductions, is_item_exchange, return_shipments, latest_offer and offers.

  • Statuses: a new trade-in status Declined (id 26) and a new item status Returned (id 25). A customer declining an offer now moves the trade-in to Declined, and Rejected is only used when a merchant rejects a request — see Statuses.

Shopify Flow trigger:

  • The status enum gains OFFERED, ACCEPTED, REJECTED and DECLINED.

  • rewardOffered changes meaning (it is now the latest offer round's total; the confirm-time total is rewardQuoted).

  • New fields: rewardQuoted, rewardInspected, rewardAgreed, rewardDeductions, offerRound, currency, tradeInUrl, rewardType, offerStage, offerStatus, rejectOutcome, itemsExpected, itemsReceived, itemsRewarded, itemsRecycled, itemsReturned.

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

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.

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

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.

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.

return_shipments

array of object

The parcels that send returned items back to the customer; empty if none. See return_shipments.

reward_quoted

number, nullable

reward_inspected

number, nullable

reward_offered

number, nullable

reward_agreed

number, nullable

reward_deductions

number

The deduction (e.g. customer shipping fee) — see Reward value vocabulary.

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:

Stage
Field
Meaning

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.

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

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

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

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

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

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.

Field
Type

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.

Field
Type

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:

Field
Type

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.

Field
Type
Description

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

Field
Type
Description

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

Field
Type
Description

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

status

object

See status.

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.

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.

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.

Field
Type

id

integer

carrier

string, nullable

tracking_number

string, nullable

tracking_url

string, nullable

dispatched_at

datetime, nullable

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.

Statuses

status.id, status.name and status.slug use these values.

Id
Name
Slug
Notes

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:

Id
Slug

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

Rejected is transient; Declined is where a customer's decline rests.

  • When a merchant rejects a requested trade-in, it passes from Requested to Rejected to Cancelled within the same action, so a webhook subscribed to Rejected receives that event and one subscribed to Cancelled receives a second event moments later.

  • When a customer declines an offer, the offer round becomes rejected and the trade-in moves to Declined, where it stays until the shop acts. From Declined the shop can send a revised offer (back to Offered), cancel the trade-in (Cancelled), or, for a trade-in with received items, issue the reward for the recycled items (Reward granted, then Closed). If the items were received, the round's decline_outcome also sets each undecided item's inspection_outcome to return or recycle-only (with review they are left for the shop to decide). Items marked returned move to the returned item status.

Subscribe to Declined to be told when a customer declines, and to Cancelled as well if you also need the outcome when the shop then cancels.

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

Field
Type
Description

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

rewardQuoted

number, nullable

rewardInspected

number, nullable

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

rewardAgreed

number, nullable

rewardDeductions

number

The deduction (e.g. customer shipping fee) — see Reward value vocabulary.

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.

Last updated