Skip to main content

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
PackageWhat it is for
@tanqory/plugin-sdkManifest parsing, webhook verification, the server API client, and …/client for the embedded page
@tanqory/app-bridgeThe 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:

appsordersproductscustomers
marketingcontentanalyticsdiscounts
store-automationonline-storesubscriptionsmarkets

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.

The listing is not in the manifest

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.

warning
A plain embed/page.tsx looks right and 404s

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

Your client secret does not sign anything

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.

danger
Never point apiBase at a cell

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