Tringify POSTs signed JSON to your app's webhook_url when something happens to an installation or, if you subscribe, to the store's data. Set the URL in tringify.app.json or the Developer Portal, and generate the signing key under the app's Webhooks. Each store gets the events, scopes and API version of the release it installed.
App events#
Event |
When |
|---|---|
|
A store installs the app. Carries the store's Store API token and granted scopes. |
|
The store's token is replaced, for example after the store approves an update that asks for more scopes. Carries the new token; the old one stops working. |
|
The store moves to a newer release of your app. |
|
The store's plan for your app changes. |
|
The store uninstalls the app, or the store is being deleted. The token stops working. |
|
Your app's references on cart or wishlist lines change. See App-owned line artifacts. |
|
An order line carrying your artifact is linked, held or fulfilled. |
|
A store changes your sales channel's settings. Sent only if listed in |
|
You send a test with |
Every app event except app.channel_settings_updated and app.test is always sent: you can't unsubscribe from them.
Store data events#
To hear about the store's data, such as new orders or changed products, list the events in data_subscribed_events, such as orders/create or products/update. The full list, with each event's payload and required scope, is in Webhook events. Tringify only produces an event when an installed app or a store endpoint subscribes to it, so a new subscription receives events from a few seconds after it is saved, not changes made before.
The request#
{
"event": "app.installed",
"event_id": "del_5f2c0b8e4a1d4c7e9b3a6d2f1e0c8b7a",
"delivery_id": "del_5f2c0b8e4a1d4c7e9b3a6d2f1e0c8b7a",
"store_id": "74e634ea-84ec-43b8-b66b-986214c2c5a3",
"app_id": "d6a26466-7903-4e2f-8fd7-7c59f3c8cd9a",
"api_version": "2025-01",
"timestamp": "2026-07-29T20:06:24+05:30",
"data": {
"app_id": "d6a26466-7903-4e2f-8fd7-7c59f3c8cd9a",
"store_id": "74e634ea-84ec-43b8-b66b-986214c2c5a3",
"release_id": "69e5eed4-25f4-4734-8a20-e80364b2bb06",
"app_version": "1.0.0",
"installation_id": "0b9e7c41-58d2-4f6a-a3e1-7d2c9b4f8a10",
"access_token": "tpat_...",
"scopes": ["read_products"],
"installed_by": "5c24b66d-1b0a-4acd-8d9f-5d4372bda777",
"installed_at": "2026-07-29T20:06:24+05:30"
}
}
Field |
Meaning |
|---|---|
|
The event name. |
|
Identifies the event. Process each one once: a retried delivery carries the same |
|
Identifies the delivery, shown in the Delivery Log. |
|
The store and your app. |
|
The API version of the release the store installed. |
|
When the event was created, in the store's time zone with its offset. |
|
The event's payload. |
Every time in a webhook, the envelope timestamp and each *_at field in data, uses one form: RFC 3339 in the store's time zone with its offset, to the second, for example 2026-10-10T14:05:09+05:30. The offset keeps the instant exact, so convert to UTC by parsing it; the local clock time matches what the merchant sees in Store Admin.
On app.installed, installation_id identifies this installation: a reinstall gets a new one, and app.uninstalled carries the same value. installed_by is the staff member who installed it, as an ID, not an email address. Plan-based installs also carry plan_revision_id, and subscription_id and is_test when the store installed a free or test plan; is_test is true on development stores, where your paid plans install without charge. See Billing.
{
"event": "app.token_rotated",
"data": {
"app_id": "d6a26466-7903-4e2f-8fd7-7c59f3c8cd9a",
"store_id": "74e634ea-84ec-43b8-b66b-986214c2c5a3",
"release_id": "8c1b4e2a-5f3d-4a6b-9c7e-0d2f1a3b4c5d",
"access_token": "tpat_...",
"scopes": ["read_products", "write_products"],
"reason": "release_scope_consent",
"rotated_by": "5c24b66d-1b0a-4acd-8d9f-5d4372bda777",
"rotated_at": "2026-07-29T20:07:47+05:30"
}
}
On app.token_rotated, rotated_by is the staff member who caused the rotation, as an ID. release_id and reason are present only when the rotation came from approving an update. The event has no installation_id.
{
"event": "app.uninstalled",
"data": {
"app_id": "d6a26466-7903-4e2f-8fd7-7c59f3c8cd9a",
"store_id": "74e634ea-84ec-43b8-b66b-986214c2c5a3",
"installation_id": "0b9e7c41-58d2-4f6a-a3e1-7d2c9b4f8a10",
"installed_at": "2026-07-29T20:06:24+05:30",
"uninstalled_at": "2026-07-29T20:09:01+05:30"
}
}
When a store is permanently deleted, app.uninstalled has data.reason set to store_deleted: delete everything you hold for that store, keeping only records you're legally required to keep. A merchant's uninstall has no reason. Treat a reason you don't recognize as an ordinary uninstall.
Verify the signature#
Read the raw body before parsing it. X-Tringify-Signature has the form t=<timestamp>,v1=<hex digest>, and may carry more than one v1 value (see Rotate the signing key). Compute HMAC-SHA256 with your signing key over <X-Tringify-Timestamp>.<raw body>, compare it in constant time with each v1 value, accept the request if any one matches, and reject a timestamp more than five minutes from your clock, in either direction. Use the signing key as the text shown in the Developer Portal, all 64 characters; do not hex-decode it. The payment protocol reference has a worked example with a key, timestamp, body and the resulting header to test your verifier against.
import crypto from "node:crypto";
export function verifyTringifyWebhook(rawBody, headers, signingKey) {
const timestamp = headers["x-tringify-timestamp"];
// One v1 per signing key: accept the request if any of them matches.
const received = (headers["x-tringify-signature"] ?? "").split(",")
.filter((part) => /^v1=[a-f0-9]{64}$/.test(part))
.map((part) => Buffer.from(part.slice(3), "hex"));
if (!timestamp || received.length === 0 || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac("sha256", signingKey).update(`${timestamp}.${rawBody}`).digest();
return received.some((signature) => crypto.timingSafeEqual(signature, expected));
}
Requests to provider endpoints carry the same signature, so this function verifies them too.
Rotate the signing key#
Today: immediate replacement. Generating a new key under the app's Webhooks replaces the old one at once for every installation. Tringify stops signing with the old key immediately, and your endpoint rejects every request until it has the new one. Have the deployment that updates the key ready, generate the key (it is shown once), deploy it immediately, then confirm with Send test webhook. Lifecycle events and refund requests rejected in the gap are retried; a payment app's charge request is not, so that checkout attempt fails and the shopper can try again. See Payment protocol reference.
Coming: a 72-hour overlap. In a forthcoming change, Tringify will keep signing with the previous key for 72 hours after a rotation. During that time X-Tringify-Signature carries one v1 value per key, current first (t=<timestamp>,v1=<current>,v1=<previous>), so an endpoint holding either key keeps verifying while you deploy the new one. Until that change ships, rotation is the immediate replacement above. Build your verifier for both now: accept a request when any v1 value matches, as the function above does, and never assume the header has exactly one.
Answer and retries#
Answer with any 2xx once you have stored the event. Otherwise Tringify retries with backoff, keeping the same event_id, payload, endpoint and API version. The Delivery Log in the Developer Portal, or tringify app deliveries, shows each attempt and your endpoint's answer, and lets you retry a failed delivery.
Order, priority and merged updates#
Each store's deliveries to your app go out by importance: orders and fulfillment first, then stock and gift cards, then customers and market settings, then catalog and content. Within each, the oldest goes first. Don't depend on the order your endpoint sees: retries and concurrent sending can change it.
This applies to app events too. Key what you store for a store by installation_id, and ignore an app.uninstalled whose installation_id is not the installation you hold: after a reinstall, a late uninstall of the old installation must not erase the new token or settings. Apply app.token_rotated only when its rotated_at is later than when the token you hold was issued (its installed_at or rotated_at), and confirm the new token with a Store API call before discarding the old one.
Updates that carry a resource's current state are merged while they wait: products/update, product_publications/update, variants/update, customers/update, collections/update and categories/update. If a newer update for the same resource arrives before the earlier one reaches you, you get one delivery with the latest state, and its changed_fields covers both changes. Creates and deletes are never merged, and neither is a products/update sent by a CSV product import, which carries the product's variants.
Adding or removing variants sends products/update with variants in changed_fields, alongside variants/create or variants/delete. A CSV product import sends no variants/create or variants/update: its products/create and products/update carry the product's complete variant list in product.variants (empty for a product without variants), and its tags/update and brands/update for assignment changes arrive once per import chunk for each tag or brand whose products changed, with the counts after the chunk. variants/delete is sent as usual. Metafields deleted together with their product or variant send no metafield delete events: products/delete and variants/delete cover them.
app.installed and app.token_rotated carry the full token only in the request to your endpoint; the Delivery Log shows its first characters. Store it encrypted and never log it. Retrying one of these deliveries by hand after it has failed for good replaces the token and delivers the new one: the previous token stops working.