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

ParameterTypeDescription
Base URLhttps://twelfthfretmfg.com/api/v1
FormatJSON requests and responses, UTF-8. Money is in decimal currency units (e.g. 189.00). Times are ISO 8601 in UTC.
VersioningThe 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 IDsEvery 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"
ParameterTypeDescription
products:readscopeList products and variants.
inventory:readscopeRead stock status for SKUs or variants.
orders:readscopeRead orders, their status and shipments.
orders:writescopeCreate orders.
Keep keys on your server. A key with 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..."
  }
}
ParameterTypeDescription
400 invalid_requestSomething in the request is missing or wrong. The message says what.
401 invalid_api_keyMissing, invalid, revoked or expired key.
402 subscription_inactiveThe workspace's subscription isn't active.
403 insufficient_scopeThe key doesn't have the scope this endpoint needs.
404 not_foundNo such endpoint or record (in your workspace).
409 idempotency_in_progressA request with the same Idempotency-Key is still running.
422 idempotency_mismatchThe Idempotency-Key was used with a different body.
429 rate_limitedToo many requests. Wait for the Retry-After seconds.
500 internal_errorOur 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:

ParameterTypeDescription
X-RateLimit-LimitintegerRequests allowed per minute.
X-RateLimit-RemainingintegerRequests left in the current minute.
X-RateLimit-Resetunix timeWhen 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

ParameterTypeDescription
in_stockReady to ship. available is the number of units free to sell (null for items not stock-tracked).
made_to_orderNone on the shelf, but it can be ordered; lead_time says how long (e.g. "4-6 weeks").
out_of_stockNone 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.

ParameterTypeDescription
limitinteger1–100, default 50.
starting_afterproduct idCursor from the previous page.
include_inactivebooleanInclude 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).

ParameterTypeDescription
external_idstringYour order number. Recommended: makes the request safe to repeat.
items[]array, required1–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.
customerobject{ name, email }
shipping_addressobjectAny address fields you use (stored as given).
shipping, taxnumberAmounts charged.
paymentobject{ amount, method, reference }: what the customer paid at checkout (e.g. a deposit).
currencystring3-letter code; defaults to the workspace's currency.
metadataobjectUp 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.

ParameterTypeDescription
statusin_production, preparing_shipment, partially_shipped, shipped, delivered, cancelled, refunded
ready_to_shipbooleanTrue when at least one unit is built and waiting to ship.
shipments[]array{ carrier, tracking_number, shipped_at, items[] }

List orders

GET/v1/orders

ParameterTypeDescription
limitinteger1–100, default 50.
created_beforedate-timeCursor from the previous page (newest first).
statusstringOnly orders with this status.
updated_sincedate-timeOnly 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).

ParameterTypeDescription
inventory.updatedA variant's available quantity, lead time or active flag changed.
order.createdAn order was created (API or manual).
order.status_changedAn order's status changed.
order.shippedA shipment was sent, with carrier, tracking and items.
order.refundedAn 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.