gstly External API
v1 gstly-backend.onrender.com

External API reference

Server-to-server endpoints for creating and reading products and invoices, authenticated with a per-business API key instead of a user login. Every response follows one envelope — { "success": boolean, "data"?: object, "message"?: string }.

Getting started

Create a key from Settings → API keys in the gstly app (owner only), then make your first call:

cURL
# Create a product
curl -X POST https://gstly-backend.onrender.com/api/v1/external/products \
  -H "X-API-Key: gstly_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"description": "Blue Widget", "unitPrice": 199, "gstRate": 18}'
📎 The full request/response contract is also published as an OpenAPI 3.0 spec — import it into Postman or Insomnia, or generate a client in your language of choice.

Authentication

Send your key on every request as the X-API-Key header:

X-API-Key: gstly_sk_a1b2c3d4e5f6...

The plaintext key is shown exactly once, at creation time — gstly stores only its hash. If you lose it, revoke it and create a new one; there's no way to retrieve it again. Actions taken through a key are attributed to the business owner who created it, the same as if they'd performed the action themselves.

Scopes & rate limits

Every key is created with an explicit set of scopes — there's no default. A request to an endpoint your key doesn't carry the scope for returns 403.

ScopeUnlocks
products:readList and get products
products:writeCreate products, upload product images
invoices:readList and get invoices, including the machine-readable projection
invoices:writeCreate invoices and change their status
General limit
60 requests / 15 min per key
Invoice mutations
10 requests / min per key (create, update, publish, move-to-draft, mark-paid, cancel — shared)

Errors

Non-2xx responses always carry success: false and a human-readable message; validation failures also carry an errors array.

StatusReason
400Validation failure, or every submitted product name/file conflicted
401Missing, invalid, or revoked API key
403Key doesn't carry the required scope, or (publish / mark-paid) the key-owning account's email is unverified
404No product or invoice matches for this business
409Duplicate product name with a different price/labels, or a duplicate invoice number
429Rate limit exceeded

Data models

The full shape of the two objects this API reads and writes. Response objects may carry additional internal fields not listed here (audit metadata, computed stats, etc.) — treat anything not documented as informational and forward-compatible; new fields may be added without notice, existing ones won't be removed or repurposed.

Product

FieldTypeDescription
_idstringMongo id — use as ref in other calls, or the barcode below.
descriptionstringProduct name.
unitPricenumberSelling price.
costPricenumber | null
hsnCodestring | null4, 6, or 8 digits.
unitenumPCS · KG · GRAM · LITRE · ML · BOX · DOZEN · PACK · METER
gstRateenum0 · 5 · 12 · 18 · 28
isInclusivebooleanWhether unitPrice already includes GST.
productTypeenumRAW_MATERIAL · FINISHED_GOOD · BOTH
categorystring | null
tagsstring[]
barcodestringAuto-assigned (GST00000123) unless set manually at creation.
barcodeSourceenumAUTO · MANUAL · IMPORT · MIGRATION
labelFieldsarray{ key, value }[] — up to 5, business-defined (e.g. Batch, Design).
imagesarray{ url, thumbUrl }[]
hasImage / imageUrlboolean / stringConvenience shortcuts to images[0].
isActiveboolean
visibleOnStorefrontboolean
createdAt / updatedAtstring (ISO 8601)

Invoice

FieldTypeDescription
_idstring
invoiceNumberstringe.g. INV-202608-000042
invoiceDate / dueDatestring (ISO 8601)dueDate defaults to +30 days.
statusenumdraft · published · paid · cancelled
invoiceTypeenumsales · proforma · credit_note
billTypeenumgst · simple
country / currencystringe.g. IN / INR
businessDetailsobject{ name, address, gstin, pincode } — the seller, as it appears on the document.
customerDetailsobject{ name, gstin, email, phone, address }
itemsarraySee Invoice item below.
totalsobjectSee Totals below.
paymentDetailsobject{ status, method } — status: pending · paid · failed
businessIdstring
publishedAtstring | nullSet when status becomes published.
createdAt / updatedAtstring (ISO 8601)

Invoice item (each entry in items[])

FieldTypeDescription
descriptionstring
quantitynumber0 is allowed (free items).
unitPricenumber
gstRatenumber0–100.
isInclusiveboolean
hsnCodestring
productIdstring | nullLinks the line to a Product.
baseAmount / gstAmountnumberServer-computed — taxable value and tax for this line.
cgst / sgst / igstnumberServer-computed. cgst+sgst for intra-state, igst for inter-state — the other pair is 0.
totalAmountnumberbaseAmount + gstAmount.

Totals

FieldTypeDescription
taxableValuenumberSum of item baseAmounts, before discount.
totalGST / totalCGST / totalSGST / totalIGSTnumber
discountAmountnumber
grandTotalnumberExact sum after tax and discount.
roundedTotalnumberWhat actually gets collected — rounded to the nearest rupee.

Products

GET /products products:read

Paginated, filterable list of products for your business.

ParamTypeNotes
page, limitintegerDefaults: page 1, limit 20
searchstringMatches name, HSN, category, tags, label key/value, barcode
statusenumactive (default) · inactive · all
sortBy, sortOrderenumcreatedAt (default, desc) · updatedAt · description · unitPrice …
Response 200
{
  "success": true,
  "count": 2, "total": 42, "page": 1, "pages": 3,
  "data": [
    { "_id": "66f1...", "description": "Blue Widget",
      "unitPrice": 199, "barcode": "GST00000123", … }
  ]
}
GET /products/{ref} products:read

ref is a product's Mongo _id or its barcode — barcode is tried automatically when ref isn't a valid id. Pass ?by=false to require an exact _id match only.

200 found 404 no match for this business
POST /products products:write

Only description and unitPrice are required. Submitting the same name + price + labels again returns the existing product (200, alreadyExists: true) rather than duplicating it; if the price or labels differ you get a 409 — resend with forceCreateSeparate: true to create a separate product anyway. Omit barcode to have one assigned automatically.

Request
{
  "description": "Blue Widget",
  "unitPrice": 199,
  "gstRate": 18,
  "hsnCode": "8471",
  "category": "Hardware"
}
Response 201
{
  "success": true,
  "data": {
    "_id": "66f1a2...",
    "description": "Blue Widget",
    "unitPrice": 199,
    "barcode": "GST00000481", …
  }
}
POST /products/{ref}/images products:write

multipart/form-data, field name images — up to 5 files per request, 5 per product total. JPEG, PNG, GIF, or WEBP, 5MB max each. Identical images (by content hash) are skipped as duplicates rather than rejected.

cURL
curl -X POST \
  .../products/GST00000481/images \
  -H "X-API-Key: gstly_sk_..." \
  -F "[email protected]"
Response 200
{
  "success": true,
  "data": {
    "added": 1,
    "images": [{ "url": "https://…" }],
    "skippedDuplicates": [],
    "skippedCapacity": []
  }
}

Invoices

GET /invoices invoices:read

Filter by status, billType, invoiceType, date range, and more — see the OpenAPI spec for the full parameter list.

GET /invoices/{id} invoices:read

Fetch one invoice by its Mongo _id.

GET /invoices/{id}/machine-readable invoices:read

The canonical JSON projection the PDF and QR code are built from — the recommended shape to parse programmatically, since it's guaranteed to agree with what a human sees on the document (statutory fields, GST summary, amount-in-words).

POST /invoices invoices:write

Creates a draft invoice — nothing is legally final until you publish it. No pre-existing customer record is required; one is resolved or created automatically from customerDetails.name at publish time.

⚠️ Use an Idempotency-Key header on this call. A retried request with the same key returns the original invoice (200, idempotentReplay: true) instead of creating a duplicate — a fresh invoice number is minted on every call that doesn't supply one, so an unprotected retry double-bills.
Request
{
  "customerDetails": { "name": "Acme Corp" },
  "items": [{
    "description": "Blue Widget",
    "quantity": 2, "unitPrice": 199,
    "gstRate": 18
  }]
}
Response 201
{
  "success": true,
  "data": {
    "_id": "66f1b7...",
    "invoiceNumber": "INV-202608-000042",
    "status": "draft", …
  }
}
PUT /invoices/{id} invoices:write

Same body shape as create. Editing a published invoice's items or other significant fields automatically reverts it to draft and reverses any stock deducted — it must be re-published. Paid and cancelled invoices can't be updated.

PUT /invoices/{id}/publish invoices:write

draft → published — the step that makes it a legal invoice. Requires the business name/address/GSTIN to be resolvable and at least one complete line item; 400 with a field-by-field list otherwise. Deducts stock for items linked to a productId.

PUT /invoices/{id}/move-to-draft invoices:write

Reverses a publish and any stock it deducted. Only valid from published.

PUT /invoices/{id}/mark-paid invoices:write

Only valid from published. Paid invoices are immutable afterward.

Request
{ "paymentMethod": "upi" }
PUT /invoices/{id}/cancel invoices:write

Only valid from published — paid invoices can't be cancelled. Reverses any stock deducted at publish.

🚫 Not available over the API: proforma workflow, invoice delete, clone, send-email, and bulk export. Each has behavior specific to a live user session that isn't safe to expose behind a shared business key — use the gstly app for these.