Manual setup
The quickstart is tanqory app dev doing all of this for you.
Read this page when you are adding Tanqory to a project that already exists, or
when something the CLI did needs explaining.
0. A project
Any framework that serves HTTP works. Next.js is what the examples use:
npx create-next-app@latest order-notifier --ts --app
cd order-notifier
npm install @tanqory/plugin-sdk
| Package | What it is for |
|---|---|
@tanqory/plugin-sdk | Manifest parsing, webhook verification, the server API client, and …/client for the embedded page |
@tanqory/app-bridge | The underlying frame protocol. plugin-sdk/client wraps it — you rarely need it directly |
1. Register the app
Developer dashboard → Apps → Create App. A name and a handle is all it asks for; listing details come later, at review.
You get a client ID and a client secret. Copy the secret now — it is shown once, because Tanqory stores only a hash of it.
# .env.local
TANQORY_CLIENT_ID=tqa_…
TANQORY_CLIENT_SECRET=tqs_… # authenticates you to /apps/token — never signs a webhook
The manifest
tanqory.app.json, at the root of your project:
{
"handle": "order-notifier",
"name": "Order Notifier",
"appUrl": "https://order-notifier.example.com",
"devUrl": "http://localhost:3000",
"embedded": { "enabled": true, "path": "/embed" },
"navigation": {
"placement": "orders",
"items": [{ "label": "Notifications", "path": "/embed" }]
},
"scopes": ["orders.view"],
"webhooks": [{ "topic": "orders/create", "path": "/hooks/orders" }],
"supportUrl": "https://example.com/support",
"privacyPolicyUrl": "https://example.com/privacy"
}
devUrl is used instead of appUrl, and only when the store installing you
is a development store. That is what lets you point at localhost while you
build, without any localhost origin ever being trusted for a real merchant.
Validate it in your own build, so a typo fails there rather than at submission:
import { parseAppManifest } from '@tanqory/plugin-sdk'
parseAppManifest(JSON.parse(readFileSync('tanqory.app.json', 'utf8')))
It reports every problem at once, not one per run. Your editor will also autocomplete and validate the file if you add:
"$schema": "https://unpkg.com/@tanqory/plugin-sdk/schema/tanqory-app.schema.json"
Where your app appears in the menu
placement chooses which menu your app sits under. Write items paths
without the store id — the dashboard inserts that segment when it frames
you. Thirteen placements exist.
Twelve nest your app inside an existing menu:
apps | orders | products | customers |
marketing | content | analytics | discounts |
store-automation | online-store | subscriptions | markets |
The thirteenth, root, gives your app its own top-level entry beside them,
for an app that is not an extension of any existing section. A root app is a
single entry carrying its name and icon — its items are not rendered.
Omit navigation entirely and your app is reachable from Apps → Installed,
which is a reasonable place to start.
supportUrl and privacyPolicyUrl are the only two listing fields the manifest
holds. Your icon, tagline, category and description — what merchants browse
by — are a form in the developer dashboard, and nothing refuses to publish
without them. Fill them in before review, or your approved app reaches the
marketplace as a blank card. See Going live.
The embedded page
The dashboard frames <embedded.path>/<storeId>/<your route>, so the store id
is a segment in your own routing:
src/app/embed/[storeId]/[[...slug]]/page.tsx
Both dynamic segments earn their place. [storeId] lets you render for the
right store before the bridge has answered; the optional catch-all is what makes
a refresh on an inner page work instead of remounting the app at the top.
// src/app/embed/[storeId]/[[...slug]]/page.tsx
'use client'
import { useTanqoryApp } from '@tanqory/plugin-sdk/client'
export default function Embed() {
const { ready, error, store, api } = useTanqoryApp()
// Render `error`. Without it a failed handshake is indistinguishable from a
// slow one, and you will stare at "Loading…" with a clean console.
if (error) return <p>Could not reach the dashboard: {error}</p>
if (!ready) return <p>Loading…</p>
return (
<button onClick={() => api!.get('/orders', { limit: 5 }).then(console.log)}>
Recent orders for {store!.storeName}
</button>
)
}
useTanqoryApp does the whole handshake: verifying who framed you, fetching the
store context, retrying if the dashboard is slow to answer, and holding a token
that renews itself before it expires. It returns
{ ready, error, store, api, toast }, and every page calls it for itself.
embed/page.tsx looks right and 404sIt fails the moment a merchant first opens your app, which is the worst time to
find out. Keep the [storeId] segment.
The webhook handler
Deliveries are signed with the webhook_secret for that store's
installation — a whsec_… value, not your client secret. You read it from the
same /apps/token response you already call for an access token, and it is
different for every store your app is installed on.
// src/app/hooks/orders/route.ts
import { verifyWebhookSignature } from '@tanqory/plugin-sdk'
import { webhookSecretFor } from '@/lib/tanqory' // your own per-store cache
export async function POST(request: Request) {
const raw = await request.text()
// Read the store from the body BEFORE trusting it: it only selects which
// secret to check against, and a wrong guess fails the check below.
const { storeId, payload } = JSON.parse(raw)
const secret = await webhookSecretFor(storeId)
if (!verifyWebhookSignature(raw, request.headers.get('x-tanqory-signature'), secret)) {
return new Response('bad signature', { status: 401 })
}
console.log('new order', payload.id, 'in', storeId)
return new Response('ok')
}
Pass the raw body. Re-serialising a parsed object changes key order and whitespace, and the signature will not match — the single most common first-day mistake.
It authenticates you to the token endpoint and nothing else. Verifying against it fails every delivery, and because a failed verification looks exactly like an attack, the usual outcome is an app that silently drops every webhook it is sent.
When a delivery fails
Tanqory records every delivery attempt — status, HTTP code, timing — and keeps the exact body it sent. Developer dashboard → your app → Test install → View webhook deliveries, then Replay any one of them.
A replay re-signs with the webhook's current secret and posts to its current URL, so it lands even after you have rotated the secret or moved the endpoint. You do not have to re-trigger the original event in the store.
Reach for this when your handler was down or shipped a bug: fix it, then replay the deliveries it dropped instead of asking the merchant to recreate the orders.
Calling the API from your server
import { createTanqoryApp } from '@tanqory/plugin-sdk'
const tanqory = createTanqoryApp({
clientId: process.env.TANQORY_CLIENT_ID!,
clientSecret: process.env.TANQORY_CLIENT_SECRET!,
apiBase: 'https://app.tanqory.com',
})
const orders = await tanqory.store(storeId).get('/orders', { query: { limit: 5 } })
apiBase is required and the client throws at construction without it —
deliberately, because the alternative was a default that worked in testing and
404'd in production.
Use https://app.tanqory.com and leave it alone. Tanqory runs each store in one
of several regional cells; that host reads the store id out of the path and
forwards to the right one, so the same value is correct for every store you will
ever be installed on.
apiBase at a cellHosts shaped api-do-sgp1.tanqory.com are internal plumbing. Which cell a store
sits on is a placement decision the platform makes and can change, and a request
to the wrong one returns a 404 that looks exactly like a deleted record.
Install it on a development store
Start your app first — npm run dev on the port devUrl names — and leave it
running.
Developer dashboard → your app → Test install, and pick a development store.
That installs the current version regardless of review state, so you can use it
before anyone has looked at it, and it is what makes the dashboard frame your
devUrl rather than appUrl.
Then open that store's dashboard and find your app: under the menu you named in
navigation.placement, or under Apps → Installed if you declared none.
This is the first time your page renders. Everything before this was written
blind — that is normal here, and it is why Loading… in a browser tab earlier
meant nothing was wrong.
Three things that will cost you an afternoon
Never send X-Frame-Options. It has no allow-list form, so SAMEORIGIN
overrides your Content-Security-Policy: frame-ancestors in browsers that still
read it, and the dashboard shows a blank frame with no error at all. Set
frame-ancestors and do not set X-Frame-Options at all.
Never redirect to a login page. You are inside an iframe — a login screen renders in a box, and if it escaped it would throw the merchant out of their dashboard. The SDK reports a dead session to the dashboard and lets it decide.
A 403 is not retryable. It means the merchant did not grant that scope, or the endpoint is not available to apps. Retrying will never fix it; ask for the scope in your next manifest version.