TruePlate Partner API
Scan your dishes once with the TruePlate iOS app, then use the true-to-size 3D models and menu data anywhere: your own website, mobile app, or POS. REST API, webhooks, and a drop-in AR viewer.
Getting started
The Partner API is included with the Partner API add-on. A restaurant owner enables it and creates keys from their dashboard under Developers; a platform integrator gets keys spanning every restaurant that authorizes them.
1. Create a secret key
In the dashboard, open Developers → API keys → New key, choose Secret, and pick the scopes you need. Copy the tp_sk_live_…token immediately (it's shown once).
2. Make your first request
curl https://api.trueplate.ai/v1/partner/me \
-H "Authorization: Bearer tp_sk_live_YOUR_KEY"The same request from Node and Python (any HTTP client works):
const res = await fetch("https://api.trueplate.ai/v1/partner/me", {
headers: { Authorization: `Bearer ${process.env.TRUEPLATE_API_KEY}` },
});
if (!res.ok) throw new Error(`TruePlate ${res.status}`);
const { data } = await res.json();import os, requests
res = requests.get(
"https://api.trueplate.ai/v1/partner/me",
headers={"Authorization": f"Bearer {os.environ['TRUEPLATE_API_KEY']}"},
timeout=10,
)
res.raise_for_status()
data = res.json()["data"]3. Read a menu and its 3D models
# List the restaurants this key can read
curl https://api.trueplate.ai/v1/partner/restaurants \
-H "Authorization: Bearer tp_sk_live_YOUR_KEY"
# Fetch an item with signed model URLs (USDZ + GLB)
curl https://api.trueplate.ai/v1/partner/items/ITEM_ID \
-H "Authorization: Bearer tp_sk_live_YOUR_KEY"See every endpoint on the API reference.
Authentication
Send your secret key as a bearer token on every request: Authorization: Bearer tp_sk_live_…. Keys come in two types:
| Type | Prefix | Use |
|---|---|---|
| Secret | tp_sk_live_ | Server-side only. Full scoped access. Never ship it in an app or web page. |
| Publishable | tp_pk_live_ | Browser-safe. Only works with the embed viewer, and only from the origins you allow-list on the key. |
Scopes
menu:read: restaurants, menus, item listingsassets:read: item detail with signed 3D model URLswebhooks:manage: create and manage webhook endpointsanalytics:read: reserved for partner-facing analytics reads (not yet available)
usdzUrl/glbUrl you get back are short-lived. Cache the item, not the URL; when a URL stops resolving, fetch a fresh one with POST /partner/items/{id}/asset-urls.Test mode
Build your integration against a test key before pointing production traffic at a live one. Pick Test when creating a key and you get a tp_sk_test_… (or tp_pk_test_…) token that hits the same endpoints, the same data, and the same permission checks, with three differences:
- Never billed.Test requests and embed views don't count toward the restaurant's monthly quota or overage.
- Fixed caps.Test keys run at up to 60 requests/minute with a 5,000/day ceiling, regardless of the key's configured tier.
- Visibly labeled. Every response carries
X-TruePlate-Environment: test, and the embed viewer renders a "Test mode" badge, so a sandbox key can never masquerade as production.
Everything else is identical: signed asset URLs, pagination, error shapes, and the subscription kill-switch (a lapsed restaurant is unreadable in both environments). Webhook deliveries reflect real data changes and are environment-independent. When you're ready, mint a live key and swap the token; no code changes.
Responses & pagination
Single resources come back wrapped in a data object; lists add a meta object with the next-page cursor:
// GET /partner/items/{id}
{ "data": { "id": "itm_...", "priceCents": 1200, "assets": { } } }
// GET /partner/restaurants/{id}/items?limit=50
{
"data": [ { "id": "itm_..." }, { "id": "itm_..." } ],
"meta": { "nextCursor": "eyJ1IjoiMjAyNi0..." }
}Paginate by following meta.nextCursor: pass it back as the cursor query param until it comes back null. The cursor is opaque and ordered by (updatedAt, id), so it is stable for incremental sync alongside updatedAfter. Money is integer minor units in priceCents (the legacy major-units price is deprecated).
cursor=""
while :; do
page=$(curl -s "https://api.trueplate.ai/v1/partner/restaurants/$RID/items?limit=100&cursor=$cursor" \
-H "Authorization: Bearer tp_sk_live_YOUR_KEY")
echo "$page" | jq '.data[]'
cursor=$(echo "$page" | jq -r '.meta.nextCursor // empty')
[ -z "$cursor" ] && break
doneWebhooks
Instead of polling, subscribe to events. Register an https endpoint under Developers → Webhooks (or via the API) and we POST a signed JSON body when things change.
Events
model.ready: a scanned 3D model finished processingmodel.failed: processing faileditem.created/item.updated/item.deletedmenu.updated: a coalesced menu change (fires once per burst)grant.activated/grant.revoked: a restaurant's authorization changed
Payload & headers
Every delivery is a POST with these headers and a JSON body:
X-TruePlate-Event: the event typeX-TruePlate-Delivery: a unique delivery id (per attempt row)X-TruePlate-Signature:t=<unix>,v1=<hmac>
{
"id": "evt_1a2b3c",
"type": "model.ready",
"createdAt": "2026-07-15T12:00:00.000Z",
"data": { "itemId": "itm_...", "itemSlug": "...", "itemName": "..." }
}id. Deliveries are at-least-once, so the same event may arrive more than once (a retry after your endpoint was slow to ack). Treat id as an idempotency key and ignore an event you have already processed.Verifying signatures
Every delivery carries X-TruePlate-Signature: t=<unix>,v1=<hmac>, an HMAC-SHA256 of {t}.{raw body}with your endpoint's signing secret. Reject deliveries older than 5 minutes.
import express from "express";
import crypto from "node:crypto";
const app = express();
// Capture the RAW body: the signature is computed over the exact bytes we
// sent, so verify BEFORE any JSON parsing re-serializes them.
app.post(
"/webhooks/trueplate",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body; // a Buffer, thanks to express.raw()
const header = req.get("X-TruePlate-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=")),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return res.sendStatus(400);
// Reject replays outside the 5-minute window.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.TRUEPLATE_WEBHOOK_SECRET)
.update(t + "." + raw.toString("utf8"))
.digest("hex");
// Length-guard BEFORE timingSafeEqual (it throws on unequal lengths).
const a = Buffer.from(v1);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw.toString("utf8"));
// Idempotency: event.id is stable across retries, so dedupe on it.
// event.type: "model.ready" | "item.updated" | ...
res.sendStatus(200);
},
);import hmac, hashlib, time
def verify(sig_header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(), f'{parts["t"]}.'.encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(parts["v1"], expected)Failed deliveries retry with exponential backoff (8 attempts from 60s, about 2.1 hours of total patience); an endpoint that keeps failing is auto-disabled and you're notified. Inspect and re-send deliveries from the dashboard.
Embed viewer
The fastest way to put a dish on your web menu: drop in one script tag and a custom element with a publishable key. It renders the 3D model and launches AR on phones.
<script src="https://www.trueplate.ai/embed/v1.js" async></script>
<trueplate-viewer
item-id="ITEM_ID"
publishable-key="tp_pk_live_YOUR_PUBLISHABLE_KEY">
</trueplate-viewer>publishable-key (or data-key). The bare key attribute is reserved by both frameworks and never reaches the DOM, so the viewer would report a missing key.Add every website origin that will embed the viewer to the publishable key's allow-list (exact https://www.example.com or wildcard https://*.example.com). The viewer refreshes expiring URLs automatically.
See it live on the embed demo.
iOS (AR Quick Look)
The usdzUrl is a ready-to-use AR Quick Look asset. On iOS Safari, a link with rel="ar" launches the system AR viewer at true size:
<a rel="ar" href="SIGNED_USDZ_URL">
<img src="SIGNED_THUMBNAIL_URL" />
</a>In a native app, download the signed USDZ and present it with QLPreviewController (or load it into a RealityKit scene). The model's scaleLock is already baked into the USDZ, so it appears at real-world size.
Android (Scene Viewer)
Use the glbUrl with Google's Scene Viewer via an intent:// URL:
intent://arvr.google.com/scene-viewer/1.0?file=SIGNED_GLB_URL&mode=ar_preferred#Intent;scheme=https;package=com.google.ar.core;action=android.intent.action.VIEW;end;In a native Android app, hand the GLB to Scene Viewer or render it with SceneView / Filament. The embed viewer builds this intent for you.
Errors & rate limits
Every error uses one envelope, with a stable machine-readable code:
{
"error": {
"code": "subscription_inactive",
"message": "The restaurant's subscription or Partner API add-on is inactive.",
"requestId": "b1f2..."
}
}| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_api_key | Unknown, revoked, or expired key. |
| 403 | forbidden_scope | The key or grant lacks the required scope. |
| 403 | subscription_inactive | The restaurant's subscription or add-on lapsed. Data access is paused. |
| 403 | expired_signature | A signed asset URL expired. Fetch a fresh one. |
| 404 | not_found | No such resource, or one this key isn't authorized to see (the two are deliberately indistinguishable). |
| 429 | rate_limited | Per-key rate limit exceeded. Back off and retry. |
| 429 | quota_exceeded | The restaurant's monthly allowance (including its overage budget) is exhausted. Resets with the billing period; the owner can raise the overage budget in the dashboard. |
Rate limits
Each key has a per-minute limit (set when the key is created). Every response carries your budget, and a 429 tells you exactly how long to wait:
X-RateLimit-Limit: the per-minute ceilingX-RateLimit-Remaining: requests left in the current windowX-RateLimit-Reset: seconds until the window resetsRetry-After(on429only): seconds to back off before retrying
Include the requestId when contacting support. Send If-None-Match with the menu ETag to get cheap 304s, and use updatedAfter on the items endpoint for incremental sync.