Service REST API
ARC's REST surface is deliberately smaller than the GraphQL surface. The documented machine-integration routes are under /api/service/* and use tenant API keys. They read the catalog, customers, reference lists and document status, create quotes and sales orders, and run bulk product imports. Other REST controllers exist for ARC's own web/mobile surfaces, file bytes, provider callbacks, and operational probes; do not treat every controller in the repository as a public integration contract.
Open the generated Service REST API reference → Every route with its parameters, request bodies, responses and errors, generated from the API's controllers each time this site is built. To import the same description into Postman or Insomnia, or to generate a client, download the OpenAPI 3.0.3 document: service-api.json. This guide explains when to use each route; the reference is the exact current contract.
Base URL and authentication
https://<arc-host>/api/service/…
Authorization: Bearer gm_live_<48-hex-characters>
Content-Type: application/json
The API key's organization is the only tenant context. The request body and query string must not contain a tenant selector. See Authentication.
Every request made with a key counts against that key's per-minute limit, reads included. See Rate limits and request limits.
Read endpoints
A key of either scope (READ_ONLY or READ_WRITE) can call these routes. A key acts as the user who created it: each route needs that user's current read permission, shown in the table. If the creator does not hold it, the call is refused with 403 Missing required permission: <permission>; if the creator has been deactivated or removed, every read with the key is refused with 401. The routes return only the fields listed under Response fields.
| Route | Query parameters | Creator needs | Returns |
|---|---|---|---|
GET /api/service/products | search, updatedAfter, limit, cursor | products:product:read | A summary of each product and its variants, newest created first. Paged. |
GET /api/service/products/:id | None | products:product:read | One product, with each variant's price ladder (prices) and the product's unit rows (uoms). |
GET /api/service/uoms | None | products:product:read | Active units of measure. Not paged. |
GET /api/service/payment-terms | None | Any one of sales:quote:read, sales:sales_order:read, sales:sales_invoice:read, payments:invoice:read, purchase:purchase_order:read, customers:customer:read, suppliers:supplier:read, settings:tenant:read | Active payment terms. Not paged. |
GET /api/service/warehouses | None | inventory:warehouse:read | The workspace's warehouses, whatever their status. Not paged. |
GET /api/service/stock | variantIds (required) | inventory:stock_level:read | Stock per variant and active warehouse, plus the ids that are not variants in the workspace (unknownVariantIds). |
GET /api/service/customers | search, updatedAfter, limit, cursor | customers:customer:read | Customers, oldest change first. Paged. |
GET /api/service/customers/:id | None | customers:customer:read | One customer. |
GET /api/service/sales-orders | search, updatedAfter, customerId, status, limit, cursor | sales:sales_order:read | Sales orders, oldest change first. Paged. |
GET /api/service/sales-orders/:id | None | sales:sales_order:read | One sales order. |
GET /api/service/sales-quotes | search, updatedAfter, customerId, status, limit, cursor | sales:quote:read | Every quote in the workspace, whoever created it, oldest change first. Paged. |
GET /api/service/sales-quotes/:id | None | sales:quote:read | One quote. |
Within what its creator may read, a key reads the whole workspace: no sales-rep visibility scope applies to it. Mint integration keys from a dedicated user whose role holds only the read permissions the integration needs.
Query parameters
| Parameter | Rules |
|---|---|
limit | An integer from 1 to 100. The default is 50. A value outside that range is refused with 400; it is not capped. |
cursor | Send back the page.nextCursor of the previous page, unchanged. A cursor works only on the list that returned it, and a cursor it did not issue does not reliably fail. Do not build, decode or reuse one across lists. |
updatedAfter | An ISO-8601 instant, for example 2026-10-01T00:00:00Z. Only rows changed strictly after it are returned. |
search | Up to 200 characters, matched case-insensitively anywhere in the text. Products match on the product name, a variant's SKU, name or barcode, or the brand name. Customers match on name, code, display name, legal name, customer code, or a contact's value or name. Sales orders match on order number, customer name, tracking number, customer email or notes. Quotes match on quote number, customer name, customer email, reference, or the customer record's name. |
customerId | A customer id (UUID). |
status | One or more statuses, as ?status=CONFIRMED&status=SHIPPED or ?status=CONFIRMED,SHIPPED. Sales orders: DRAFT, AWAITING_PAYMENT, CONFIRMED, PARTIALLY_INVOICED, INVOICED, PARTIALLY_SHIPPED, SHIPPED, DELIVERED, CANCELLED, RETURNED. Quotes: DRAFT, PENDING, SENT, VIEWED, ACCEPTED, APPROVED, REJECTED, EXPIRED, CONVERTED, CANCELLED. An unknown value is refused with 400. |
variantIds | A comma-separated list of 1 to 200 variant ids. Duplicates are ignored. |
Every :id in a path, and every id in variantIds, must be a UUID; anything else is refused with 400. A well-formed :id that is not in the key's workspace, or that was removed, returns 404.
Response envelope and paging
Every read returns the same envelope. Paged lists add page:
{
"success": true,
"data": [],
"page": { "hasMore": true, "nextCursor": "<cursor>" }
}
nextCursor is null when hasMore is false. Single-record routes return the object in data and have no page. GET /api/service/stock adds unknownVariantIds next to data.
Delta sync
To keep a copy in step, store the largest updatedAt you have seen for each list and send it as updatedAfter on the next pull. Keep the same updatedAfter while you page through one pull, and de-duplicate by id, because a row that changes during the pull can appear again later.
- Products and customers. A pull with
updatedAfteralso returns rows that were removed since then, withdeleted: true. Delete them from your copy. A pull withoutupdatedAfterreturns only rows that still exist. - Sales orders and quotes. The lists never include removed documents, with or without
updatedAfter. A removed document's detail route returns404. - Order of rows. Customers, sales orders and quotes are returned oldest change first, so the last row of the last page carries the newest
updatedAt. Products are returned newest created first, so take the largestupdatedAtacross every page of the pull.
Request and response example
Look up a customer by name, code or contact:
curl --fail-with-body --get "$ARC_API_URL/api/service/customers" \
--header "Authorization: Bearer $ARC_API_KEY" \
--data-urlencode "search=<customer-name-or-code>" \
--data-urlencode "limit=10"
{
"success": true,
"data": [
{
"id": "<customer-id>",
"code": "<partner-code>",
"customerCode": "<customer-code>",
"name": "<customer-name>",
"displayName": "<display-name>",
"legalName": "<legal-name>",
"taxId": "<tax-id>",
"status": "<status>",
"paymentTerms": "<payment-term-code>",
"currency": "<currency-code>",
"creditLimit": 50000,
"availableCredit": 42000,
"taxExempt": false,
"defaultWarehouseId": "<warehouse-id>",
"addresses": [
{
"id": "<address-id>",
"type": "<address-type>",
"label": "<label>",
"street1": "<street>",
"street2": null,
"city": "<city>",
"state": "<state>",
"country": "<country>",
"postalCode": "<postal-code>",
"isDefault": true,
"isBranch": false,
"isActive": true
}
],
"contacts": [
{
"type": "EMAIL",
"value": "buyer@example.com",
"name": "<contact-name>",
"isDefault": true
}
],
"deleted": false,
"updatedAt": "<ISO-8601>"
}
],
"page": { "hasMore": false, "nextCursor": null }
}
Response fields
Responses carry only the fields below. A field with no value is null, and a list with no entries is []. Tolerate new fields, because more may be added.
| Resource | Fields |
|---|---|
| Product | id, name, description, status, brand (id, name), categories (id, name), tags, imageUrls, isTaxExempt, baseUom, salesUom, variants, deleted, updatedAt. The detail route adds uoms |
Variant (variants[]) | id, productId, sku, name, barcode, attributes, price, salePrice, isActive, isDefault, minOrderQuantity, maxOrderQuantity, updatedAt. The list adds salesUom; the detail route adds prices instead |
Price ladder row (prices[]) | name, minQuantity, maxQuantity, price, discountPercent, uomId, currency, validFrom, validTo, isActive |
Product unit row (uoms[]) | uomId, variantId, uom, isBase, isSales, conversionFactor, minOrderQty, maxOrderQty, orderMultiple, listPrice, salePrice, barcode |
Unit reference (baseUom, salesUom, uoms[].uom) | id, code, name, symbol |
Unit of measure (/uoms) | id, code, name, symbol, category, isBase, isActive, decimalPlaces, allowFractional |
| Payment term | code, label, kind (NET, COD or PREPAID), netDays, isDefault, isActive |
| Warehouse | id, code, name, type, status, address |
Stock row (data[]) | variantId, warehouseId, warehouseName, onHandQuantity, reservedQuantity, availableQuantity, tracked |
| Customer | As in the example above |
| Quote | id, quoteNumber, status, customerId, quoteDate, validUntil, currency, exchangeRate, subtotal, discountAmount, taxAmount, shippingAmount, totalAmount, paymentTerms, convertedToSalesOrderId, items, updatedAt |
| Sales order | id, orderNumber, status, paymentStatus, customerId, orderDate, promisedDate, shippedDate, deliveredDate, currency, exchangeRate, subtotal, discountAmount, taxAmount, shippingAmount, totalAmount, paymentTerms, customerPoNumber, reference, trackingNumber, items, updatedAt |
Document line (items[]) | productId, variantId, sku, name, quantity, uomId, unitPrice, discountPercent, discountAmount, taxRate, taxAmount, totalAmount. Sales-order lines add shippedQuantity, invoicedQuantity and canceledQuantity |
Points that integrations get wrong:
- Money is a plain number. Amounts are the stored values, unformatted. Pair each with its currency code: the document's
currency, or a price row'scurrency. Render them with your own currency formatting; never attach a symbol to the raw number. - The list is a summary; the detail route is the full product. A list row has no
uomskey, and its variants carrysalesUombut nopriceskey.GET /api/service/products/:idreturns each variant's price ladder inprices(and no variantsalesUom), plus the product's full unit rows inuoms. Read the unit for a document line from the detail route'suoms, or from the product'ssalesUomorbaseUomwhenuomsis empty. - Stored prices are not quoted prices.
price,salePriceandpricesare what the seller entered. The price a document gets is resolved by ARC's pricing engine. See Money, pricing, and tax. - Stock. For each requested variant, the rows cover the workspace's active warehouses, most available first.
availableQuantityis never negative.tracked: falsemeans the variant does not track stock: every active warehouse is listed with zero quantities. An id that is not a variant in the key's workspace, including a removed variant, gets no stock rows and is listed inunknownVariantIdsinstead; the call still succeeds. Check that list rather than treating a missing variant as out of stock. - Payment terms. Send a term's
codeaspaymentTermswhen you create an order. If the workspace has not set up its own terms, ARC's built-in default terms are returned.
Write routes
Sales quotes
POST /api/service/sales-quotes
- Authentication: tenant API key.
- Scope:
READ_WRITE. - Body: the
CreateQuoteInputshape used by the canonical quote service. - Replay protection:
Idempotency-Keyheader oridempotencyKeybody field. - Result:
{ success: true, data: <quote> }.
Sales orders
POST /api/service/sales-orders
- Authentication: tenant API key.
- Scope:
READ_WRITE. - Body: the
CreateSalesOrderInputshape. - Replay protection:
Idempotency-Keyheader oridempotencyKeybody field. - Result:
{ success: true, data: <sales order> }.
A minimal order body — every id comes from the same organization:
{
"customerId": "<customer-id>",
"paymentTerms": "<payment-term-code>",
"currency": "<currency-code>",
"items": [
{
"productId": "<product-id>",
"variantId": "<variant-id>",
"quantity": 10,
"uomId": "<uom-id>"
}
],
"idempotencyKey": "order-sync-2026-09-18-0001"
}
Two differences from quotes that integrations get wrong:
uomIdis required on every order line (it is optional on quote lines). Take it fromGET /api/service/products/:id: auomsrow withisSalesorisBaseset, for that variant or with novariantId. If the product has nouomsrows, use itssalesUomorbaseUom. The quantity must also meet the product's order-quantity rules (minimum, multiple and maximum), or the order is refused withLINE_QTY_RULE.unitPriceis optional on order lines. When omitted, the server resolves the line price through the canonical pricing pipeline. If you send it, it is treated as the client price.
The paymentTerms code is a tenant-configured term such as NET_30. List the tenant's terms
with GET /api/service/payment-terms before creating your first order, and send one of their code values. A customer's
own paymentTerms is accepted only when that code is in the list. Omit currency to use the workspace's
base currency.
The route delegates to the same order service used by GraphQL, so numbering, pricing, inventory, credit checks, and audit attribution remain server-side.
Bulk product imports
POST /api/service/products/bulk
POST /api/service/products/bulk/:importId/stage
GET /api/service/products/bulk/:importId/preview
POST /api/service/products/bulk/:importId/approve
POST /api/service/products/bulk/:importId/cancel
POST /api/service/products/bulk/:importId/rollback
GET /api/service/products/bulk/:importId
| Operation | Scope | Purpose |
|---|---|---|
| Create import | READ_WRITE | Register a file name and choose CREATE_ONLY (the default) or UPSERT. Returns an importId and a presigned uploadUrl; you then PUT the file bytes to uploadUrl. |
| Stage | READ_WRITE | Queue parsing and validation of the uploaded file. |
| Preview | READ_ONLY | Read the proposed diff and row counts before approval. |
| Approve | READ_WRITE | Approve an import in PREVIEW status and enqueue the commit. |
| Cancel | READ_WRITE | Abandon an import that has not started committing, freeing the workspace's single import slot. |
| Roll back | READ_WRITE | Undo a COMPLETED import's created products and variants. Rows it updated are not reverted. |
| Status | READ_ONLY | Poll status and counts. |
Both this route and the GraphQL createProductImport operation use the same presigned-upload flow: create, upload the file to the returned URL, then stage. See Bulk product imports for the full sequence and the file format.
Write request and response example
curl --fail-with-body --request POST "$ARC_API_URL/api/service/sales-orders" \
--header "Authorization: Bearer $ARC_API_KEY" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: order/<external-order-id>" \
--data @sales-order.json
{
"success": true,
"data": {
"id": "<arc-sales-order-id>",
"orderNumber": "<tenant-order-number>"
}
}
The created quote or order comes back in the same shape as GET /api/service/sales-quotes/:id and GET /api/service/sales-orders/:id (see Response fields). Persist the ARC id and document number, but treat the id as opaque. To follow the document afterwards, read it back with GET /api/service/sales-orders/:id (or sales-quotes/:id), or pull changes with updatedAfter.
Status and retry handling
401 Unauthorized: key missing/invalid/expired/revoked, IP not allowed, or aREAD_ONLYkey called a write route.402 Payment Required: the workspace has reached its plan's monthly quote limit (POST /api/service/sales-quotes). Do not retry; the limit resets at the start of the next calendar month (UTC) or with a plan upgrade.403 Forbidden: the workspace's plan does not include API access, or (read routes) the key's creator lacks the route's read permission —Missing required permission: <permission>.400 Bad Request: malformed input or a business rule rejected the command. On reads: an id that is not a UUID, a query parameter out of range, or an unknownstatus. An unknown import id is also a400(Import <id> not found).404 Not Found: a referenced or requested record is not visible in the key's tenant, or was removed.409 Conflict: an idempotency or optimistic-concurrency conflict may require a re-read.429 Too Many Requests: the key used up its per-minute limit (bodycodeisAPI_KEY_RATE_LIMITED), or the global per-IP throttle blocked the call. Wait forRetry-Afterseconds, then retry. See Rate limits and request limits.5xx: retry only with the same idempotency key and reconcile if the outcome is uncertain.
See Errors, pagination, and idempotency for the complete client policy.
OpenAPI and non-public routes
The Service API's OpenAPI document is published with this site: read it as the Service REST API reference or download service-api.json. Re-download it when the API changelog lists a change. It lists only the /api/service/* routes described in this guide; the inbound callbacks for domains and guest activity are not part of it.
In production, /api-docs and /api-docs-json on the API host are off unless the deployment turns them on. They are not a public API catalogue, so do not build against them; use the document above.
The generated GraphQL reference, the Service REST API reference and this guide are the public developer contract. Uploads, document PDFs and data exports are user-JWT routes, documented in Files, document PDFs, and exports. Ask your Growmax account manager before using any other unlisted REST controller, such as inbound provider callbacks.
Next: Bulk product imports.