Tiny Shops
Developers

Orders API & Webhooks

Connect a warehouse, fulfilment service, or AI agent to your orders — read them, mark them paid or shipped, and get a signed webhook for every change.

Tiny Shops gives external systems two things:

  • An HTTP API to read an order, change its status, and ship it with tracking.
  • A webhook that POSTs every new order and every status or tracking change to a URL you choose.

Both use the same order shape, so anything a webhook told you can be re-read with the API field for field.

Читаете по-русски? See the Russian version of this page.

Want an agent to manage more than orders? See Connect an AI agent.

1. Create an API token

Open API tokens

In the dashboard, go to Settings → API tokens (/settings/api-tokens). Only the store owner sees this page.

Create a token with the right scope

  • Read — get_order only.
  • Write — everything on this page: get_order, set_order_status, ship_order.

Copy the token (tsh_live_…) when it's shown. It isn't shown again.

Every request sends it as a Bearer token:

Authorization: Bearer tsh_live_…

2. The three calls

All calls are POST https://mcp.tiny-shops.com/v1/<call> with a JSON body. The same calls are available as MCP tools with the same names on https://mcp.tiny-shops.com/mcp.

Every response has the same envelope:

{ "ok": true, "data": { … } }
{ "ok": false, "error": { "code": "not_found", "message": "Order '42' not found" } }
HTTPerror.codeMeaning
400invalid_inputThe body doesn't match the call's input
401invalid_tokenMissing, malformed, expired, or revoked token
403forbiddenThe token's scope is too narrow (for example a read token calling ship_order)
404not_foundNo such order in your store — orders of other stores are never visible
404unknown_toolNo such call
429rate_limitedToo many calls; wait for Retry-After seconds

Rate limits are per token: 100 read calls and 30 write calls per minute.

get_order — read one order

curl -X POST https://mcp.tiny-shops.com/v1/get_order \
  -H "Authorization: Bearer $TINYSHOPS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"orderId": 1042}'

data is the order object.

set_order_status — change the status

curl -X POST https://mcp.tiny-shops.com/v1/set_order_status \
  -H "Authorization: Bearer $TINYSHOPS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"orderId": 1042, "status": "PROCESSING"}'

data is { "orderId", "status", "order" }. Status changes run the same store rules as the dashboard — for example, cashback configured to be granted at a given status.

StatusMeaning
PENDINGNew order, not yet confirmed or paid
PROCESSINGPaid / being prepared. "Mark paid" means setting this status.
SHIPPEDHanded to the carrier — prefer ship_order, which also saves tracking
DELIVEREDReceived by the shopper
CANCELLEDCancelled

ship_order — mark shipped with tracking

curl -X POST https://mcp.tiny-shops.com/v1/ship_order \
  -H "Authorization: Bearer $TINYSHOPS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"orderId": 1042, "carrier": "DHL", "trackingNumber": "JD014600003SE", "trackingUrl": "https://dhl.com/track?id=JD014600003SE", "notify": true}'
FieldRequiredNotes
orderIdyes
trackingUrlone of these threeMust start with http:// or https://
carrierone of these threeUp to 100 characters
trackingNumberone of these threeUp to 100 characters
notifyno, default falsetrue sends the shopper the same Telegram message as the dashboard's Ship & Notify

The status becomes SHIPPED and the tracking fields are saved. With notify: true the shopper gets your store's shipping message; the Track Order button appears only when there is a trackingUrl, otherwise the carrier and tracking number are written in the message.

data is { "order", "notified", "notifyFailureReason" }. notified is absent when you didn't ask to notify, and false with a reason (bot_not_configured, unreachable, send_failed) when the message couldn't be sent — the order is shipped either way.

3. Webhooks

Set it up

Go to Settings → Order webhooks, enter a public https:// URL, and save. You get a signing secret (whsec_…) once — store it. Lost it? Click Rotate secret; the old one stops working immediately. The page also shows recent deliveries.

Use Send test event to POST a signed sample order.created synchronously. Test envelopes contain "test": true and sample data; they never create an order. Your endpoint should still verify the signature and return a 2xx.

Each store has one webhook URL.

Events

EventWhen
order.createdEvery new order, from any checkout or payment method
order.updatedThe status, tracking, or items changed — in the dashboard, through the API, or through MCP. Setting an order to the status it already has sends nothing

The request

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: TinyShops-Webhooks/1
X-TinyShops-Event: order.updated
X-TinyShops-Delivery: 3b0c4f7e-5a41-4d8e-9f0e-2d6b1c1a9e27
X-TinyShops-Signature: 5f1c…e9a2

{
  "id": "3b0c4f7e-5a41-4d8e-9f0e-2d6b1c1a9e27",
  "event": "order.updated",
  "createdAt": "2026-09-23T10:15:02.114Z",
  "data": { …the order object… }
}
  • X-TinyShops-Signature — lowercase hex HMAC-SHA256 of the raw request body, keyed with your signing secret.
  • X-TinyShops-Delivery and the body's id — the delivery id. It is the same on every retry of that delivery.

Verify the signature

Compute the HMAC over the raw bytes you received, before any JSON parsing, and compare in constant time.

import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();

app.post(
  '/tinyshops/orders',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const expected = createHmac('sha256', process.env.TINYSHOPS_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');
    const received = req.get('X-TinyShops-Signature') ?? '';
    const valid =
      received.length === expected.length &&
      timingSafeEqual(Buffer.from(received), Buffer.from(expected));
    if (!valid) return res.status(401).end();

    const event = JSON.parse(req.body.toString('utf8'));
    res.status(204).end(); // answer fast, then do the work
    handleOrderEvent(event);
  },
);
import hashlib, hmac, json, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["TINYSHOPS_WEBHOOK_SECRET"].encode()

@app.post("/tinyshops/orders")
def tinyshops_orders():
    expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    received = request.headers.get("X-TinyShops-Signature", "")
    if not hmac.compare_digest(received, expected):
        abort(401)
    event = json.loads(request.get_data())
    handle_order_event(event)
    return "", 204

Retries

  • A delivery succeeds when your endpoint answers any 2xx within 10 seconds.
  • Otherwise it is retried up to 5 more times, after 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours. After the sixth failed attempt it is marked failed and shown on the settings page.
  • Events are sent within about a minute of the change.
  • Redirects are not followed; the URL must answer directly.

Idempotency and ordering

  • Use the delivery ID (X-TinyShops-Delivery or body id) to ignore retries. For order state, keep the newest data.updatedAt. Do not deduplicate only by order ID and status—tracking or items can change without a status change.
  • Setting an order to the status it already has sends no event.
  • Missed something? Call get_order — it returns exactly the object a webhook would have carried.

The order object

{
  "id": 1042,
  "status": "SHIPPED",
  "paymentMethod": "Stripe",
  "totalPrice": 459000,
  "tipAmount": null,
  "currency": "USD",
  "telegramUserId": "5566778899",
  "shippingAddress": { "name": "Ivan Petrov", "phone_number": "+15551234567" },
  "checkoutData": [{ "id": "f1", "label": "Address", "type": "text", "value": "Main st. 1" }],
  "items": [
    {
      "productId": "sneaker-01",
      "title": "Running Sneakers",
      "quantity": 1,
      "price": 459000,
      "size": "42",
      "variantOptionTitle": null,
      "variantValue": null
    }
  ],
  "trackingUrl": "https://dhl.com/track?id=JD014600003SE",
  "carrier": "DHL",
  "trackingNumber": "JD014600003SE",
  "createdAt": "2026-09-23T09:58:40.512Z",
  "updatedAt": "2026-09-23T10:15:02.101Z"
}
FieldNotes
totalPrice, items[].price, tipAmountMinor units of currency (cents; whole stars for XTR)
currencyThe store's currency code
telegramUserIdThe shopper's Telegram user id, as a string (it can exceed 2^53)
shippingAddressShipping details collected by the payment flow (for example Telegram's order info), or null
checkoutDataThe checkout form rows the shopper filled in, or null
trackingUrl, carrier, trackingNumbernull until set