@tanqory/plugin-sdk
v0.6.3
Published
Build a Tanqory marketplace app. Server-side token exchange and REST client, webhook signature verification, a manifest schema, and a React hook that boots an embedded app inside the Tanqory dashboard.
Readme
@tanqory/plugin-sdk
Build an app for the Tanqory marketplace. You host it; Tanqory frames it, routes its API calls, and tells it who the merchant is.
npm install @tanqory/plugin-sdkThe two halves
import { createTanqoryApp, verifyWebhookSignature } from '@tanqory/plugin-sdk' // your server
import { useTanqoryApp } from '@tanqory/plugin-sdk/client' // your UISeparate entry points on purpose: importing the server half must not drag React into a serverless function.
Embedded UI
'use client'
import { useTanqoryApp } from '@tanqory/plugin-sdk/client'
export default function Page() {
const { ready, store, api } = useTanqoryApp()
if (!ready) return <p>Loading…</p>
return (
<button onClick={() => api!.get('/orders', { limit: 5 }).then(console.log)}>
Recent orders for {store!.storeName}
</button>
)
}useTanqoryApp performs the whole handshake: verifying who framed you,
requesting store context, retrying an unanswered handshake, holding a token in
memory and renewing it before it expires, and reporting a dead session to the
host. Getting any of that wrong produces a blank frame with a clean console,
which is unpleasant to debug from the outside.
Server side
const tanqory = createTanqoryApp({
clientId: process.env.TANQORY_CLIENT_ID!,
clientSecret: process.env.TANQORY_CLIENT_SECRET!,
})
const orders = await tanqory.store(storeId).get('/orders', { query: { limit: 5 } })Tokens are cached per store and renewals are single-flighted, so parallel work triggers one exchange rather than one each.
Webhooks
export async function POST(request: Request) {
const raw = await request.text()
if (!verifyWebhookSignature(raw, request.headers.get('x-tanqory-signature'), SECRET)) {
return new Response('bad signature', { status: 401 })
}
…
}Pass the raw body. Re-serialising a parsed object changes key order and whitespace, and the signature will not match.
Three things that will save you a day
Never name an API host. Every request is relative and Tanqory's edge resolves which regional cell holds the store from the id in the path. An app that hardcodes a hostname is pinned to one cell and breaks for merchants elsewhere.
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 renders a blank frame. Set frame-ancestors instead.
Never navigate 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. On auth failure the SDK tells the host and the host decides.
The manifest
tanqory.app.json is what review approves, and the only thing a version pins.
Your code is yours — deploy whenever you like. Changing the manifest (a new
scope, origin, or webhook topic) needs approval, because it changes what
merchants agreed to.
{
"handle": "order-notifier",
"name": "Order Notifier",
"appUrl": "https://order-notifier.example.com",
"devUrl": "http://localhost:3000",
"embedded": { "enabled": true, "path": "/embed" },
"scopes": ["orders.view"],
"webhooks": [{ "topic": "orders/create", "path": "/hooks/orders" }]
}devUrl is used instead of appUrl, and only on a DEVELOPMENT store — so
you can point at localhost while building without any localhost origin ever
being trusted for a real merchant.
Validate it in your own build:
import { parseAppManifest } from '@tanqory/plugin-sdk'
parseAppManifest(JSON.parse(readFileSync('tanqory.app.json', 'utf8')))It reports every problem at once rather than stopping at the first.
