Connect your shop's systems to Teasr
Backstage is the Teasr API for shops. Get paid orders into your own app the moment they happen, and keep Teasr stock in step with your inventory system.
Overview
Three building blocks
| Tool | Direction | Use it to |
|---|---|---|
| Order webhook | Teasr → your app | Receive every paid order in real time (HTTPS POST, signed). |
| Inventory API | Your app → Teasr | Set stock on your Teasr listings by SKU, variant id, or listing id. |
| Orders API | Both | Backfill or poll orders you might have missed, and mark orders shipped with tracking. |
No code? The order webhook also works with Zapier and Make, including creating the order in Shopify. The setup guide is in Seller Hub → Integrations.
Step 1
Getting started
- Open Seller Hub → Integrations and create a key under API keys. Copy it right away: Teasr only shows it once. Keys start with
tsk_. - Keep the key on your server. Never put it in a browser, mobile app, or public repo. Anyone with it can change your stock and read your orders, including buyer addresses. Revoke a key in the same place and it stops working immediately.
- Send it on every request as
Authorization: Bearer tsk_…(orX-Teasr-Api-Key: tsk_…).
Base URL: https://backstage.teasr.live/v1. Check your key:
curl https://backstage.teasr.live/v1/me \
-H "Authorization: Bearer tsk_your_key"{
"data": { "sellerId": "…", "sellerName": "Velvet Vault", "handle": "velvetvault" },
"timestamp": "2026-10-12T17:05:00.000Z"
}Every successful response wraps the result in data. Errors come back as { "statusCode": 400, "message": "…" }, where message is a string or a list of strings.
Step 2
Inventory and stock
How Teasr identifies an item
Each stock update names its item by exactly one of:
sku: your SKU. Variants carry their own SKU; a listing without variants has a listing-level SKU. Matching ignores case and surrounding spaces. If the same SKU is on more than one of your listings, all of them are updated, since they are the same item on your shelf.variantId: a Teasr variant id.productId: a Teasr listing id, for listings without variants only.
SKUs come from your CSV import or the listing editor. To fill in missing ones, list your catalog and set them through the API (below). Doing that once means your system can push stock by SKU from then on, without storing any Teasr ids.
List your catalog
GET/v1/products?page=1&limit=100
Your live and draft listings (archived ones are left out), oldest first, with variants, SKUs, and stock. limit is up to 250; keep requesting the next page while hasMore is true. Add sku=ABC-1 to find the listings carrying one SKU.
{
"data": {
"products": [
{
"id": "4c1f…",
"title": "Lace Bodysuit",
"status": "active",
"sku": null,
"price": "60.00",
"quantity": 7,
"isDigital": false,
"stockSource": "teasr",
"createdAt": "2026-09-01T15:20:11.000Z",
"updatedAt": "2026-10-12T16:02:40.000Z",
"variants": [
{ "id": "9a0e…", "title": "S", "sku": "VV-LACE-S", "price": "60.00", "quantity": 3 },
{ "id": "b71d…", "title": "M", "sku": "VV-LACE-M", "price": "60.00", "quantity": 4 }
]
}
],
"page": 1,
"limit": 100,
"hasMore": false
}
}- A listing with variants has
sku: null, and itsquantityis the total across its variants. Teasr keeps that total up to date for you. quantity: nullmeans unlimited (digital listings).stockSourcesays who owns the count. Onlyteasrlistings accept stock updates through the API.shopifyandspiceracklistings are synced from those stores, andnalpaclistings are managed by the distributor.
GET/v1/products/{productId}
One listing, same shape.
PATCH/v1/products/{productId}
Set the SKU on a listing without variants. Body: { "sku": "VV-BLF" }. Send null to clear it.
PATCH/v1/products/{productId}/variants/{variantId}
Set the SKU on one variant. Same body.
Push stock
POST/v1/inventory
Set the stock for up to 250 items per request. Quantities are absolute (how many you have), not changes, so sending the same request twice is safe.
curl -X POST https://backstage.teasr.live/v1/inventory \
-H "Authorization: Bearer tsk_your_key" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "sku": "VV-BLF", "quantity": 12 },
{ "sku": "VV-LACE-S", "quantity": 0 },
{ "variantId": "b71d…", "quantity": 4 }
]
}'{
"data": {
"results": [
{
"sku": "VV-BLF",
"status": "updated",
"matches": [
{ "productId": "1e2a…", "variantId": null, "previousQuantity": 5, "quantity": 12, "status": "updated" }
]
},
{ "sku": "VV-LACE-S", "status": "updated", "matches": [ … ] },
{ "variantId": "b71d…", "status": "unchanged", "matches": [ … ] }
]
}
}Results come back in the same order as items. One bad item never fails the others, so read each result:
| status | Meaning |
|---|---|
updated | Stock changed on at least one matching listing. |
unchanged | Already at that quantity. Nothing written. |
not_found | No listing of yours has that SKU or id. |
skipped | Found but not writable, with a reason: the stock is synced from Shopify, Spicerack, or the distributor, the listing is digital, or you sent a productId for a listing with variants. |
A quantity of 0 marks the listing sold out. It stays on Teasr, sorted after in-stock items, and becomes buyable again as soon as you push stock back.
Keeping both sides in step
A sale on Teasr lowers the Teasr stock straight away. Your system needs to hear about that sale too, or its next push will put the sold unit back. The reliable pattern:
- Receive each paid order through the order webhook and take the units out of your own inventory.
- Whenever stock changes in your system (sales anywhere, restocks, counts), push the new absolute quantity for those SKUs.
- Every hour or so, poll GET /v1/orders for anything the webhook missed, then push a full snapshot of your stock.
Step 3
Order webhook
Teasr sends an HTTPS POST to your URL the moment an order is paid. Set the URL in Seller Hub → Integrations → Order webhook, then use Send test order to get a sample. The URL must be https:// on a public host.
POST /your/endpoint
Content-Type: application/json
User-Agent: Teasr-Webhooks/1.0
X-Teasr-Topic: order.paid
X-Teasr-Webhook-Version: 2026-08-01
X-Teasr-Delivery-Id: 0d6c7c3e-…
X-Teasr-Hmac-Sha256: q1Zx…=
{
"event": "order.paid",
"webhookVersion": "2026-08-01",
"sentAt": "2026-10-12T17:03:45.000Z",
"order": { …the order object… }
}- Reply with any
2xxwithin 10 seconds. Do the heavy work after you reply. - A failed delivery is retried twice: after 5 seconds and after 30 seconds. Retries reuse the same
X-Teasr-Delivery-Id. After that, pick the order up with the Orders API. - Each order is sent once. Still, store
order.idand ignore ones you have already processed, so a retry after a slow reply can't create a duplicate. - Test deliveries have
"test": trueat the top level. Skip them in production. - Each order has one line item, so
lineItems[0]is always the item sold.
Verify the signature
X-Teasr-Hmac-Sha256 is the base64 HMAC-SHA256 of the raw request body, keyed with the signing secret shown next to your webhook URL. Compute it over the exact bytes you received, before parsing the JSON.
// Node.js (Express)
const crypto = require("crypto");
app.post("/teasr/orders", express.raw({ type: "application/json" }), (req, res) => {
const expected = crypto
.createHmac("sha256", process.env.TEASR_WEBHOOK_SECRET)
.update(req.body) // raw Buffer
.digest("base64");
const received = req.get("X-Teasr-Hmac-Sha256") || "";
const ok =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.status(401).end();
const { event, test, order } = JSON.parse(req.body);
res.status(200).end();
if (event === "order.paid" && !test) queueOrder(order);
});# Python (Flask)
import base64, hashlib, hmac, os
from flask import Flask, request, abort
@app.post("/teasr/orders")
def teasr_orders():
raw = request.get_data()
expected = base64.b64encode(
hmac.new(os.environ["TEASR_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(expected, request.headers.get("X-Teasr-Hmac-Sha256", "")):
abort(401)
payload = request.get_json()
if payload["event"] == "order.paid" and not payload.get("test"):
queue_order(payload["order"])
return "", 200If the secret leaks, rotate it from the same panel and update your receiver.
Step 4
Orders API
GET/v1/orders?updatedSince=2026-10-12T00:00:00Z&limit=50
Your paid and refunded orders, oldest change first. An order shows up again whenever it changes (shipped, delivered, refunded), so one loop catches new orders and updates. limit is up to 250.
{
"data": {
"orders": [ { …order object… } ],
"nextCursor": "MTc5MTgwMTI5NTg0MXxjY2Nj…",
"hasMore": true
}
}Start with updatedSince (or nothing, for all orders), then pass cursor=nextCursor while hasMore is true. Save the last nextCursor; on your next poll, send it again to get only what changed since.
GET/v1/orders/{orderId}
One order, same shape as the webhook.
POST/v1/orders/{orderId}/fulfillment
Mark a paid order shipped. This does exactly what Seller Hub does: the buyer gets the shipped email with tracking, and your payout for the order is released.
curl -X POST https://backstage.teasr.live/v1/orders/b7e2c9a4-…/fulfillment \
-H "Authorization: Bearer tsk_your_key" \
-H "Content-Type: application/json" \
-d '{ "trackingNumber": "9400111899223456789012", "trackingCarrier": "USPS" }'Both fields are optional, but send tracking whenever you have it. Orders from the same live show that ship in one box share the tracking number and are marked shipped together.
Reference
The order object
The webhook and the Orders API return the same object:
{
"id": "b7e2c9a4-1f3d-4e8a-9c21-6f5d8a0b3e77",
"orderNumber": "TSR-10482",
"status": "pending",
"paymentStatus": "paid",
"createdAt": "2026-10-12T17:03:41.000Z",
"updatedAt": "2026-10-12T17:03:44.000Z",
"currency": "USD",
"subtotalAmount": "64.00",
"shippingAmount": "6.99",
"taxAmount": "5.68",
"totalAmount": "76.67",
"source": "live_show",
"liveShowId": "3f9a1c2e-7b6d-4a51-8e02-d94c5b7f1a30",
"isGiveaway": false,
"seller": { "id": "…", "sellerName": "Velvet Vault", "handle": "velvetvault" },
"buyer": {
"firstName": "Jamie",
"lastName": "Rivera",
"email": "jamie.rivera@example.com",
"phone": "+15555550142"
},
"shippingAddress": {
"name": "Jamie Rivera",
"line1": "482 Cedarwood Ave",
"line2": "Apt 3B",
"city": "Austin",
"state": "TX",
"postalCode": "78704",
"country": "US"
},
"lineItems": [
{
"productId": "…",
"variantId": "…",
"title": "Silk Blindfold Set",
"variantTitle": "Midnight Black",
"sku": "VV-BLF-BLK",
"quantity": 1,
"unitPrice": "64.00",
"totalPrice": "64.00",
"shopifyVariantGid": null,
"shopifyVariantId": null
}
],
"fulfillment": {
"trackingNumber": null,
"trackingCarrier": null,
"shippedAt": null,
"deliveredAt": null
}
}| Field | Notes |
|---|---|
id | Teasr order id. Use it to deduplicate. |
orderNumber | Shown to the buyer, e.g. TSR-10482. |
status | pending, processing, shipped, delivered, cancelled, or refunded. |
paymentStatus | paid or refunded. |
subtotalAmount, shippingAmount, taxAmount, totalAmount | Decimal strings in USD. shippingAmount and taxAmount can be null (free or older orders). |
source | live_show (with liveShowId) or teasr for any other checkout. |
isGiveaway | True for giveaway wins: ship it, but nothing was charged for the item. |
shippingAddress | Where to ship. null for digital items. |
lineItems[0].sku | The variant SKU, or the listing SKU for listings without variants. |
lineItems[0].shopifyVariantId | Numeric Shopify variant id, only for listings linked to Shopify. |
fulfillment | Tracking and timestamps once the order ships. |
New fields may be added at any time, so ignore fields you don't recognize. Breaking changes get a new webhookVersion.
Reference
Limits and errors
- 120 requests per minute. Over that you get
429; wait a minute and retry. Batch stock updates (250 items per request) instead of sending one request per SKU. - Up to 10 active API keys per shop.
| Status | Meaning |
|---|---|
400 | The request is malformed. message says which field. |
401 | Missing, invalid, or revoked API key. |
404 | Not found, or it belongs to another shop. |
429 | Too many requests. |
500 | Something broke on our side. Retry with backoff. |
Questions or stuck on something? Contact support and mention the Seller API.