Skip to content

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

bash
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):

javascript
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();
python
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

bash
# 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:

TypePrefixUse
Secrettp_sk_live_Server-side only. Full scoped access. Never ship it in an app or web page.
Publishabletp_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 listings
  • assets:read: item detail with signed 3D model URLs
  • webhooks:manage: create and manage webhook endpoints
  • analytics:read: reserved for partner-facing analytics reads (not yet available)
Signed asset URLs expire. The 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:

json
// 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).

bash
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
done

Webhooks

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 processing
  • model.failed: processing failed
  • item.created / item.updated / item.deleted
  • menu.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 type
  • X-TruePlate-Delivery: a unique delivery id (per attempt row)
  • X-TruePlate-Signature: t=<unix>,v1=<hmac>
json
{
  "id": "evt_1a2b3c",
  "type": "model.ready",
  "createdAt": "2026-07-15T12:00:00.000Z",
  "data": { "itemId": "itm_...", "itemSlug": "...", "itemName": "..." }
}
Deduplicate on 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.

javascript
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);
  },
);
python
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.

html
<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>
React / Vue: pass the key as 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:

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

AR Quick Look only launches from real Safari, not inside an in-app WebView (Instagram, Facebook, etc.). The embed viewer detects this and prompts the user to open in the browser.

Android (Scene Viewer)

Use the glbUrl with Google's Scene Viewer via an intent:// URL:

text
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:

json
{
  "error": {
    "code": "subscription_inactive",
    "message": "The restaurant's subscription or Partner API add-on is inactive.",
    "requestId": "b1f2..."
  }
}
StatusCodeMeaning
401invalid_api_keyUnknown, revoked, or expired key.
403forbidden_scopeThe key or grant lacks the required scope.
403subscription_inactiveThe restaurant's subscription or add-on lapsed. Data access is paused.
403expired_signatureA signed asset URL expired. Fetch a fresh one.
404not_foundNo such resource, or one this key isn't authorized to see (the two are deliberately indistinguishable).
429rate_limitedPer-key rate limit exceeded. Back off and retry.
429quota_exceededThe 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 ceiling
  • X-RateLimit-Remaining: requests left in the current window
  • X-RateLimit-Reset: seconds until the window resets
  • Retry-After (on 429 only): 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.