Skip to main content

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.

RouteQuery parametersCreator needsReturns
GET /api/service/productssearch, updatedAfter, limit, cursorproducts:product:readA summary of each product and its variants, newest created first. Paged.
GET /api/service/products/:idNoneproducts:product:readOne product, with each variant's price ladder (prices) and the product's unit rows (uoms).
GET /api/service/uomsNoneproducts:product:readActive units of measure. Not paged.
GET /api/service/payment-termsNoneAny 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:readActive payment terms. Not paged.
GET /api/service/warehousesNoneinventory:warehouse:readThe workspace's warehouses, whatever their status. Not paged.
GET /api/service/stockvariantIds (required)inventory:stock_level:readStock per variant and active warehouse, plus the ids that are not variants in the workspace (unknownVariantIds).
GET /api/service/customerssearch, updatedAfter, limit, cursorcustomers:customer:readCustomers, oldest change first. Paged.
GET /api/service/customers/:idNonecustomers:customer:readOne customer.
GET /api/service/sales-orderssearch, updatedAfter, customerId, status, limit, cursorsales:sales_order:readSales orders, oldest change first. Paged.
GET /api/service/sales-orders/:idNonesales:sales_order:readOne sales order.
GET /api/service/sales-quotessearch, updatedAfter, customerId, status, limit, cursorsales:quote:readEvery quote in the workspace, whoever created it, oldest change first. Paged.
GET /api/service/sales-quotes/:idNonesales:quote:readOne 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​

ParameterRules
limitAn integer from 1 to 100. The default is 50. A value outside that range is refused with 400; it is not capped.
cursorSend 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.
updatedAfterAn ISO-8601 instant, for example 2026-10-01T00:00:00Z. Only rows changed strictly after it are returned.
searchUp 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.
customerIdA customer id (UUID).
statusOne 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.
variantIdsA 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 updatedAfter also returns rows that were removed since then, with deleted: true. Delete them from your copy. A pull without updatedAfter returns 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 returns 404.
  • 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 largest updatedAt across 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.

ResourceFields
Productid, 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 termcode, label, kind (NET, COD or PREPAID), netDays, isDefault, isActive
Warehouseid, code, name, type, status, address
Stock row (data[])variantId, warehouseId, warehouseName, onHandQuantity, reservedQuantity, availableQuantity, tracked
CustomerAs in the example above
Quoteid, quoteNumber, status, customerId, quoteDate, validUntil, currency, exchangeRate, subtotal, discountAmount, taxAmount, shippingAmount, totalAmount, paymentTerms, convertedToSalesOrderId, items, updatedAt
Sales orderid, 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's currency. 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 uoms key, and its variants carry salesUom but no prices key. GET /api/service/products/:id returns each variant's price ladder in prices (and no variant salesUom), plus the product's full unit rows in uoms. Read the unit for a document line from the detail route's uoms, or from the product's salesUom or baseUom when uoms is empty.
  • Stored prices are not quoted prices. price, salePrice and prices are 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. availableQuantity is never negative. tracked: false means 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 in unknownVariantIds instead; the call still succeeds. Check that list rather than treating a missing variant as out of stock.
  • Payment terms. Send a term's code as paymentTerms when 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 CreateQuoteInput shape used by the canonical quote service.
  • Replay protection: Idempotency-Key header or idempotencyKey body field.
  • Result: { success: true, data: <quote> }.

Sales orders​

POST /api/service/sales-orders
  • Authentication: tenant API key.
  • Scope: READ_WRITE.
  • Body: the CreateSalesOrderInput shape.
  • Replay protection: Idempotency-Key header or idempotencyKey body 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:

  • uomId is required on every order line (it is optional on quote lines). Take it from GET /api/service/products/:id: a uoms row with isSales or isBase set, for that variant or with no variantId. If the product has no uoms rows, use its salesUom or baseUom. The quantity must also meet the product's order-quantity rules (minimum, multiple and maximum), or the order is refused with LINE_QTY_RULE.
  • unitPrice is 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
OperationScopePurpose
Create importREAD_WRITERegister 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.
StageREAD_WRITEQueue parsing and validation of the uploaded file.
PreviewREAD_ONLYRead the proposed diff and row counts before approval.
ApproveREAD_WRITEApprove an import in PREVIEW status and enqueue the commit.
CancelREAD_WRITEAbandon an import that has not started committing, freeing the workspace's single import slot.
Roll backREAD_WRITEUndo a COMPLETED import's created products and variants. Rows it updated are not reverted.
StatusREAD_ONLYPoll 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 a READ_ONLY key 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 unknown status. An unknown import id is also a 400 (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 (body code is API_KEY_RATE_LIMITED), or the global per-IP throttle blocked the call. Wait for Retry-After seconds, 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.