Developer reference · API v1

YarnTally REST API

Read your inventory, sales and exports from your own systems. Every call is scoped to the organisation that issued the key. Included on the Business plan and above.

Authentication

An organisation admin issues keys at Admin → API access. The full key is shown once; YarnTally stores only its SHA-256. Send it as a bearer token:

curl "https://yarntally.mnbresearch.com/api/v1/sales?from=2026-10-01&to=2026-10-13&limit=100" \
  -H "Authorization: Bearer yt_0a1b2c3d_<rest of your key>"

Keys are read_only or read_write. Every v1 endpoint today is a read.

Resource scopes (optional)

A key can be limited to specific data. A key with no scopes reaches every resource — that is how every key issued before scopes existed behaves, and it is unchanged. A key missing the scope a route needs gets 403 missing_scope.

ScopeCovers
sales:readGET /api/v1/sales
inventory:readGET /api/v1/inventory
exports:readGET /api/v1/export/customers, /export/expenses, /export/mill-payments

Endpoints

GET/api/v1/inventoryinventory:read

List lots in stock

Every lot with kg remaining > 0, newest received first, with its SKU, godown and cost.

  • godown_code — Only lots in this godown.
  • yarn_type — Only SKUs of this yarn type.
  • limit — Rows per page, 1–1000 (default 200).
  • offset — Rows to skip, 0–100000. Use next_offset from the previous page.
GET/api/v1/salessales:read

List sales

Sales in a date window (India business days, IST), newest first. Cancelled sales are excluded. Defaults to the last 30 days.

  • from — First business day (YYYY-MM-DD). Default: 30 days ago.
  • to — Last business day (YYYY-MM-DD), inclusive. Default: today.
  • limit — Rows per page, 1–1000 (default 200).
  • offset — Rows to skip, 0–100000. Use next_offset from the previous page.
GET/api/v1/export/customersexports:read

Customers CSV

Every customer with credit limit and totals. `outstanding` has credit notes netted off. Returns `text/csv` with a header row: `id`, `name`, `contact`, `gstin`, `address`, `credit_limit`, `total_billed`, `total_paid`, `outstanding`. Not paginated — the whole export in one file (each source table is capped at 10,000 rows). Errors are JSON, in the standard envelope.

GET/api/v1/export/expensesexports:read

Expenses CSV

Expenses, newest first, with who recorded each. Returns `text/csv` with a header row: `date`, `category`, `amount`, `notes`, `created_by`. Not paginated — the whole export in one file (each source table is capped at 10,000 rows). Errors are JSON, in the standard envelope.

GET/api/v1/export/mill-paymentsexports:read

Mill invoices and payments CSV

One row per mill invoice (`type=invoice`) and per payment (`type=payment`), newest first. Returns `text/csv` with a header row: `supplier_name`, `type`, `date`, `amount`, `reference`, `method`. Not paginated — the whole export in one file (each source table is capped at 10,000 rows). Errors are JSON, in the standard envelope.

GET/api/v1/openapi.jsonpublic

This document

The OpenAPI 3.1 description of the API. Public, no key needed, cacheable.

Pagination

JSON list endpoints take limit and offset and answer with has_more and next_offset. Keep requesting with offset=next_offset until has_more is false. CSV exports return the whole file in one response.

Errors

Every error is JSON: { "error": "<message>", "code": "<code>" }.error is the human-readable string it has always been; switch on code. Messages never contain database details.

HTTPcodeMeaning
401missing_tokenNo Authorization header, or not `Bearer yt_<prefix>_<secret>`.
401invalid_api_keyThe key is unknown, revoked or expired.
401key_not_boundThe key is not bound to an organisation (legacy key — re-issue it).
403read_only_keyA read-only key attempted a write.
403missing_scopeThe key has resource scopes and this resource is not one of them.
403plan_requiredThe organisation's plan does not include the REST API (Business and above).
403subscription_lapsedThe organisation's subscription has lapsed.
400bad_requestA query parameter is malformed (e.g. a date that is not YYYY-MM-DD).
429rate_limitedToo many requests this minute for this key. See Retry-After.
500internal_errorSomething failed on our side. The message never contains database details.

Rate limits

Per key, per minute, by the organisation's plan. The REST API itself needs Business or above — the lower plans are listed because the limiter knows them, but their requests are refused with plan_required before any limit applies.

PlanRequests / minute
starter60
growth300
business1,200
scale3,000
enterprise6,000

Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). A 429 adds Retry-After.

Key lifecycle

  • Rotate issues a replacement with the same access; the old key keeps working for the grace period you choose (24 or 72 hours), and its responses carry X-YarnTally-Key-Superseded: true.
  • Regenerate is a rotation with no grace: the old key stops immediately. Use it if a key may have leaked.
  • Revoke stops a key immediately, with an optional reason kept in the audit log.
  • A key with an expiry sends X-YarnTally-Key-Expires on every response.

Webhook signatures

Each delivery is a POST with four headers: X-YarnTally-Signature, X-YarnTally-Timestamp, X-YarnTally-Event and X-YarnTally-Delivery. The signature is the lower-case hex HMAC-SHA256 of <timestamp>.<raw body> using your endpoint's signing secret. Reject anything older than 300 seconds, compare in constant time, and verify the raw bytes before parsing JSON. Events: sale.created, sale.cancelled, sale.dispatched, invoice.paid, customer.created, stock.low, compliance.due.

// Verify a YarnTally webhook (Node.js)
import crypto from "crypto";

const SECRET = process.env.YARNTALLY_WEBHOOK_SECRET; // shown once, at creation

export function verify(rawBody, headers) {
  const signature = headers["x-yarntally-signature"];
  const timestamp = headers["x-yarntally-timestamp"]; // unix seconds
  if (!signature || !timestamp) return false;

  // Reject replays. We re-send the ORIGINAL timestamp on retries, so allow
  // 300 seconds.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  // Sign the timestamp, a dot, then the raw body — bytes exactly as received,
  // before any JSON parsing or re-serialising.
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(timestamp + "." + rawBody)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
# Verify a YarnTally webhook (Python 3)
import hmac, hashlib, time

SECRET = b"whsec_..."  # shown once, when the endpoint was created

def verify(raw_body: bytes, headers) -> bool:
    signature = headers.get("X-YarnTally-Signature")
    timestamp = headers.get("X-YarnTally-Timestamp")  # unix seconds
    if not signature or not timestamp:
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False  # replay
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)