Developers
Connect your website
The TFM Manufacturing API lets your website show live stock status, send in orders, and read order status and tracking. It's a JSON REST API over HTTPS. Webhooks (Growth plan) tell your site when stock or orders change.
Overview
| Parameter | Type | Description |
|---|---|---|
Base URL | https://twelfthfretmfg.com/api/v1 | |
Format | JSON requests and responses, UTF-8. Money is in decimal currency units (e.g. 189.00). Times are ISO 8601 in UTC. | |
Versioning | The version is in the path (/v1). Breaking changes only ever ship as a new version; new fields can be added to v1 at any time, so ignore fields you don't recognise. | |
Request IDs | Every response has an X-Request-Id header. Include it when you contact support. |
Authentication
Create an API key in Settings → API keys (owners and admins). Choose only the scopes the key needs. The key is shown once; we store only a hash of it, so keep it somewhere safe. Send it as a bearer token:
curl https://twelfthfretmfg.com/api/v1/ping \
-H "Authorization: Bearer tfm_your_key_here"| Parameter | Type | Description |
|---|---|---|
products:read | scope | List products and variants. |
inventory:read | scope | Read stock status for SKUs or variants. |
orders:read | scope | Read orders, their status and shipments. |
orders:write | scope | Create orders. |
orders:write must never appear in browser code. GET requests are allowed from browsers (CORS) so a read-only key (products:read, inventory:read) can be used client-side if you accept that anyone can read your stock levels; POST requests are only accepted from servers.Errors
Errors use standard HTTP status codes and always have the same shape:
{
"error": {
"code": "invalid_request",
"message": "items must be a list of 1 to 500 lines.",
"request_id": "req_6pV0..."
}
}| Parameter | Type | Description |
|---|---|---|
400 invalid_request | Something in the request is missing or wrong. The message says what. | |
401 invalid_api_key | Missing, invalid, revoked or expired key. | |
402 subscription_inactive | The workspace's subscription isn't active. | |
403 insufficient_scope | The key doesn't have the scope this endpoint needs. | |
404 not_found | No such endpoint or record (in your workspace). | |
409 idempotency_in_progress | A request with the same Idempotency-Key is still running. | |
422 idempotency_mismatch | The Idempotency-Key was used with a different body. | |
429 rate_limited | Too many requests. Wait for the Retry-After seconds. | |
500 internal_error | Our fault. Safe to retry with the same Idempotency-Key. |
Rate limits
Each key can make 120 requests a minute on Starter and 600 on Growth. Every response includes:
| Parameter | Type | Description |
|---|---|---|
X-RateLimit-Limit | integer | Requests allowed per minute. |
X-RateLimit-Remaining | integer | Requests left in the current minute. |
X-RateLimit-Reset | unix time | When the window resets. |
Tip: cache stock status for a minute or two, or use webhooks, rather than calling the API on every page view.
Pagination
List endpoints return { data, has_more, next_cursor }. Pass next_cursor back (as starting_after for products, created_before for orders) to get the next page. limit is 1–100, default 50.
Idempotency
Networks fail. To retry an order safely, send an Idempotency-Key header (any unique string, e.g. a UUID, up to 255 characters). Retrying with the same key and body returns the original response instead of creating a second order. Keys are kept for 24 hours. Orders with the same external_id are also never created twice.
Stock status
| Parameter | Type | Description |
|---|---|---|
in_stock | Ready to ship. available is the number of units free to sell (null for items not stock-tracked). | |
made_to_order | None on the shelf, but it can be ordered; lead_time says how long (e.g. "4-6 weeks"). | |
out_of_stock | None on the shelf and no lead time set: don't take orders for it. |
List products
GET/v1/products
Scope products:read. Active products and their variants, with price and stock status.
| Parameter | Type | Description |
|---|---|---|
limit | integer | 1–100, default 50. |
starting_after | product id | Cursor from the previous page. |
include_inactive | boolean | Include inactive products and variants. |
{
"data": [{
"id": "8f0c…", "name": "Koa Guitar Stand", "category": "Stands", "active": true,
"track_inventory": true, "lead_time": "2-3 weeks",
"variants": [{
"id": "c1a2…", "sku": "KOA-WAL", "attributes": { "wood": "Walnut" },
"price": 189.00, "available": 7, "stock_status": "in_stock", "lead_time": null
}]
}],
"has_more": false,
"next_cursor": null
}Check inventory
GET/v1/inventory?sku=KOA-WAL,PANEL-2X4
Scope inventory:read. Stock status for up to 200 SKUs (case-insensitive) or variant_ids.
const res = await fetch('https://twelfthfretmfg.com/api/v1/inventory?sku=KOA-WAL', {
headers: { Authorization: `Bearer ${process.env.TFM_API_KEY}` },
});
const { data } = await res.json();
const label = data[0].stock_status === 'in_stock' ? 'In stock'
: data[0].stock_status === 'made_to_order' ? `Made to order: ${data[0].lead_time}`
: 'Out of stock';Create an order
POST/v1/orders
Scope orders:write. Send it when a customer checks out. Units on the shelf are reserved straight away; the rest goes onto the build list, and the order's status follows automatically. Returns 201 (or 200 with the existing order if this external_id was already sent).
| Parameter | Type | Description |
|---|---|---|
external_id | string | Your order number. Recommended: makes the request safe to repeat. |
items[] | array, required | 1–500 lines: { sku | variant_id, quantity, unit_price? }. unit_price defaults to the variant's price. A line with only name, unit_price and quantity is a custom item. |
customer | object | { name, email } |
shipping_address | object | Any address fields you use (stored as given). |
shipping, tax | number | Amounts charged. |
payment | object | { amount, method, reference }: what the customer paid at checkout (e.g. a deposit). |
currency | string | 3-letter code; defaults to the workspace's currency. |
metadata | object | Up to 8 KB of your own data, returned with the order. |
curl -X POST https://twelfthfretmfg.com/api/v1/orders \
-H "Authorization: Bearer $TFM_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f1c2e9a-website-order-10457" \
-d '{
"external_id": "10457",
"customer": { "name": "Sam Rivera", "email": "sam@example.com" },
"items": [ { "sku": "KOA-WAL", "quantity": 1 }, { "sku": "PANEL-2X4", "quantity": 2 } ],
"shipping": 25, "tax": 31.40,
"payment": { "amount": 434.40, "method": "Stripe", "reference": "pi_3Q…" }
}'Retrieve an order
GET/v1/orders/{id}
GET/v1/orders/by-external-id/{external_id}
Scope orders:read. Status, items (with ready, shipped and refunded counts) and shipments with carrier and tracking.
| Parameter | Type | Description |
|---|---|---|
status | in_production, preparing_shipment, partially_shipped, shipped, delivered, cancelled, refunded | |
ready_to_ship | boolean | True when at least one unit is built and waiting to ship. |
shipments[] | array | { carrier, tracking_number, shipped_at, items[] } |
List orders
GET/v1/orders
| Parameter | Type | Description |
|---|---|---|
limit | integer | 1–100, default 50. |
created_before | date-time | Cursor from the previous page (newest first). |
status | string | Only orders with this status. |
updated_since | date-time | Only orders changed since then. Handy for syncing. |
Webhooks
Growth plan. Add an HTTPS endpoint in Settings → Webhooks and choose events. We POST JSON to it and retry with backoff for about a day if your endpoint doesn't answer with a 2xx within 10 seconds; an endpoint that fails 50 times in a row is switched off (you'll see it in the app).
| Parameter | Type | Description |
|---|---|---|
inventory.updated | A variant's available quantity, lead time or active flag changed. | |
order.created | An order was created (API or manual). | |
order.status_changed | An order's status changed. | |
order.shipped | A shipment was sent, with carrier, tracking and items. | |
order.refunded | An order was fully refunded. |
POST https://your-site.com/webhooks/tfm
TFM-Event-Id: 1c9e…
TFM-Event-Type: order.shipped
TFM-Signature: t=1792300000,v1=5f2b…
{
"id": "1c9e…",
"type": "order.shipped",
"created": 1792300000,
"data": {
"id": "a41f…", "order_number": 1043, "external_id": "10457", "status": "partially_shipped",
"shipment": { "carrier": "UPS", "tracking_number": "1Z999…", "items": [ { "sku": "KOA-WAL", "quantity": 1 } ] }
}
}Events can arrive more than once or out of order: use id to skip duplicates and fetch the order if you need its latest state.
Verifying signatures
Every webhook is signed with your endpoint's secret (shown once when you create it). Check the signature on the raw request body before trusting it, and reject old timestamps to stop replays:
import crypto from 'node:crypto';
export function verifyTfmWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 || '', 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}Questions or need another endpoint? Contact us.