Files, document PDFs, and exports
These routes authenticate as a signed-in staff user (a user JWT), not a tenant API key. They are the same routes ARC's admin web uses. Confirm with Growmax before you build a production dependency on them. For the credential model, see Authentication and Service REST API.
This page covers image and attachment uploads, the public image proxy, document PDFs, and data exports.
1. Choose the credential
| Surface | Credential | How to send it |
|---|---|---|
REST routes on this page: /api/upload/* (except the public image proxy), /api/exports/documents/*, /api/analytics/export/* | Staff user JWT | Authorization: Bearer <accessToken> |
| GraphQL PDF and export mutations | Staff user JWT | Authorization: Bearer <accessToken> on /graphql |
GET /api/upload/serve/* (public image proxy) | None, by design | Nothing |
Use the base URL below for the REST routes. GraphQL stays at /graphql.
https://<arc-host>/api/…
Authorization: Bearer <accessToken>
The tenant comes from the JWT. Never send an organization id in a body, query string or header.
- A tenant API key sent to
/api/upload,/api/exportsor/api/analyticsis rejected with401. - A customer-portal or buyer token is refused with "This operation is available to staff users only."
- There is no API-key route for uploads or exports. For unattended server-to-server work, use the routes in Service REST API, or ask your Growmax account manager first.
Requests time out after 60 seconds. Keep uploads small.
2. Upload an image
POST /api/upload/image takes multipart/form-data with two fields:
file: the image. Required.folder: a text field naming the destination folder. Optional, defaultcategories. Use it for product, variant, category and brand images.
Allowed types are image/jpeg, image/jpg, image/png, image/webp and image/gif. The maximum size is 5 MB, and you can send one file per request. Any signed-in staff user of the organization can call this route.
curl --fail-with-body --request POST "$ARC_API_URL/api/upload/image" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--form "file=@./product-front.jpg;type=image/jpeg" \
--form "folder=product-variants"
{"success":true,"url":"/api/upload/serve/<organization-id>/product-variants/<uuid>.jpg","key":"<organization-id>/product-variants/<uuid>.jpg","originalName":"product-front.jpg","size":123456,"provider":"<provider>"}
Errors:
400"Invalid file type. Only images are allowed."413when the file is larger than 5 MB. The upload is refused before ARC stores anything.400"No file provided"401"Organization not found"
The url in the response is a relative path. Store it as returned, and add your ARC host when you render it. ARC replaces your filename with a server-generated UUID. Your original filename is in originalName.
Put the returned url in the imageUrls field of CreateProductInput or CreateProductVariantInput. The catalog flow is in Read the catalog and resolve prices.
If a non-production server returns "provider": "local" with a warning, do not persist that URL.
3. Upload a document or attachment
POST /api/upload/document uses the same multipart shape as the image route. The folder field defaults to documents.
Allowed types are image/jpeg, image/jpg, image/png, image/webp, image/gif, application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, video/mp4 and video/3gpp. The maximum size is 10 MB, and you can send one file per request.
curl --fail-with-body --request POST "$ARC_API_URL/api/upload/document" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--form "file=@./brochure.pdf;type=application/pdf" \
--form "folder=documents"
Errors:
400"Invalid file type. Allowed: images, PDF, DOC, DOCX, MP4/3GPP video"413when the file is larger than 10 MB.400"No file provided"401"Organization not found"
The success response has the same shape as the image upload.
Folders named after a document type, such as documents, invoices, quotes, orders, contracts or exports, are private. The public image proxy returns 404 for them. Read these files back with secure-url (section 5).
Use a public image folder, such as product-variants, only for assets that are meant to be shown publicly.
4. Upload directly with a presigned URL
A presigned upload has two steps. The file bytes go straight to ARC's file storage and do not pass through ARC.
Step 1. Ask ARC for an upload URL:
curl --fail-with-body --request POST "$ARC_API_URL/api/upload/presigned-url" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{"fileName":"hero.png","fileType":"image/png","folder":"product-variants"}'
The body is { "fileName": string, "fileType": string, "folder"?: string }. folder defaults to categories. fileType must be one of the five image types listed in section 2. Any other value returns 400 "Invalid file type. Only images are allowed." ARC validates only the declared type when it issues the URL. Keep images within the same 5 MB limit as the direct upload.
The response contains uploadUrl, fileUrl, key and provider.
Step 2. Send the raw bytes to uploadUrl with an HTTP PUT. Use the same Content-Type you declared in step 1. Do not send your ARC Authorization header to this URL.
curl --fail-with-body --request PUT "$UPLOAD_URL" \
--header "Content-Type: image/png" \
--upload-file hero.png
Here UPLOAD_URL is the uploadUrl from step 1.
uploadUrl expires after 1 hour. It is a write capability, so treat it as a secret and do not log it. Store fileUrl, which is a relative /api/upload/serve/<key> path.
5. Read back private files and delete files
Read a private file
POST /api/upload/secure-url returns a temporary link to a file. The body is { "key": string, "expiresIn"?: number }. expiresIn is in seconds and defaults to 3600. The response is { "success", "url", "expiresIn", "provider" }.
403"You do not have access to this file" if the key is not in your organization.400"File key is required" if you do not send a key.
Request the shortest lifetime you need, and don't cache the URL.
curl --fail-with-body --request POST "$ARC_API_URL/api/upload/secure-url" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{"key":"<organization-id>/documents/<uuid>.pdf","expiresIn":300}'
Delete a file
DELETE /api/upload/delete takes the body { "imageUrl": string }. The response is { "success", "provider", "queued"? }.
400"Image URL is required"400"Failed to delete image: Could not resolve a storage key from the image URL"400"Failed to delete image: You do not have access to this file" if the file is outside your organization
When the response has queued: true, the deletion is queued, and a file that another record still references is kept. Without queued, the file is deleted immediately, whether or not a record still uses it. Unlink the image from the product or variant first in both cases.
curl --fail-with-body --request DELETE "$ARC_API_URL/api/upload/delete" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{"imageUrl":"/api/upload/serve/<organization-id>/product-variants/<uuid>.jpg"}'
6. Fetch public images through the proxy
GET /api/upload/serve/<key>[?w=<px>] serves images without authentication, by design. That lets images work in emails, PDFs and storefronts.
curl "$ARC_API_URL/api/upload/serve/<organization-id>/product-variants/<uuid>.jpg?w=<width-px>" --output image.jpg
- The proxy serves public asset folders only. Private document-type folders return
404. - A missing image returns
404and is not cached. - A hit is cached as immutable for one year. Never overwrite a file at the same key. Upload a new file and update the record.
wis a width in pixels. ARC snaps it to the nearest supported width. Thumbnails may be generated on the first request.
Anyone with the URL can fetch the file. Do not put confidential files in a public folder.
7. Generate a document PDF
The PDF mutations are in GraphQL, and they return the PDF as a base64 string rather than a URL. Send the user JWT to /graphql. The generated API reference lists the current types.
| Mutation | Requires | Document |
|---|---|---|
generateQuotePdf(quoteId: ID!, template: String): String! | sales:quote:read | Quote |
generateSalesOrderPdf(salesOrderId: ID!, template: String): String! | sales:sales_order:read | Sales order |
generateInvoicePdf(invoiceId: ID!, template: String): String! | payments:invoice:read | Sales and purchase invoices |
generatePurchaseOrderPdf(purchaseOrderId: ID!, template: String): String! | purchase:purchase_order:read | Purchase order |
generateCreditNotePdf(creditNoteId: ID!, template: String): String! | sales:credit_note:read | Credit note |
generateDebitNotePdf(debitNoteId: ID!, template: String): String! | purchase:debit_note:read | Debit note |
generatePaymentPdf(paymentId: ID!, template: String): String! | payments:payment:read | Collections and supplier payments |
These rules apply to every PDF mutation:
- The PDF is rendered on demand from the stored document. There is no stored file, so there is no URL and no expiry.
- It renders stored values only. Amounts and tax are never recalculated. See Money, pricing, and tax.
templateis optional:professional,modernorclassic. Omit it to use the organization's saved default for that document type. An unrecognised value is treated as omitted, with no error: you get the saved default, orprofessionalif none is saved.- An unknown id, or another organization's id, returns "not found", for example
Quote <id> not found. - These are mutations because each call is audited. Request one document per call.
Templates and branding are configured by an administrator in ARC settings.
mutation QuotePdf($quoteId: ID!) {
generateQuotePdf(quoteId: $quoteId, template: "professional")
}
Variables:
{"quoteId":"<quote-id>"}
Response:
{"data":{"generateQuotePdf":"<base64-encoded-pdf>"}}
Decode the string to a file:
jq -r '.data.generateQuotePdf' response.json | base64 --decode > quote.pdf
8. Export data
The admin web runs a document export in five steps:
- Call the GraphQL
authorizeExportmutation. It checks permission, reports whether the organization requires encrypted exports, and writes nothing. - Call
POST /api/exports/documents/start-batchto get a password and a batch token. - POST the export route with the
X-Export-Batch-Tokenheader. - Save the file, and open it with the password.
- Call the GraphQL
recordExportmutation, after you have kept the file.
Steps 2 and 3 produce the file. The export routes check permission themselves, so step 1 is a pre-check. Step 5 is what writes the audit entry.
authorizeExport → start-batch → export route (X-Export-Batch-Token) → keep the file → recordExport
Authorize and record
mutation AuthorizeExport($input: AuthorizeExportInput!) {
authorizeExport(input: $input) { allowed requireEncryption }
}
Variables:
{"input":{"exportKey":"invoice-export","entityLabel":"Invoices for <period>"}}
authorizeExport(input: AuthorizeExportInput!): AuthorizeExportResult!requires the…:exportpermission that matches the export key. The permission for each route is in the table below.AuthorizeExportInputis{ exportKey: String!, entityLabel: String }.exportKeyis one ofsales-order-export,purchase-order-export,invoice-export,payment-export,payment-collection-export,grn-export,stock-adjustment-exportorstock-transfer-export.entityLabelis optional, up to 200 characters, and is recorded as audit context.
AuthorizeExportResultis{ allowed: Boolean!, requireEncryption: Boolean! }.
mutation RecordExport($input: AuthorizeExportInput!) {
recordExport(input: $input)
}
recordExport(input: AuthorizeExportInput!): Boolean! writes the audit entry. Call it only after you have kept the file. If you skip it, the export leaves no audit entry.
Start a batch and download
Start a batch once, then reuse its token for every file in that batch:
curl --fail-with-body --request POST "$ARC_API_URL/api/exports/documents/start-batch" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN"
# → {"password":"<generated-password>","batchToken":"<batch-token>"}
Then POST the export route with the token:
curl --fail-with-body --request POST "$ARC_API_URL/api/exports/documents/invoices" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--header "X-Export-Batch-Token: <batch-token>" \
--data '{"ids":["<invoice-id>"],"includeItems":true}' \
--output invoices.xlsx
The export routes are below. Base path: /api/exports/documents. All use the user JWT.
| Route | Body | Requires |
|---|---|---|
POST /sales-orders | { ids, includeCustomerDetails?, includePaymentInfo?, includeTaxInfo?, includeItems? } | sales:sales_order:export |
POST /purchase-orders | { ids, includeSupplierDetails?, includePaymentInfo?, includeTaxInfo?, includeItems? } | purchase:purchase_order:export |
POST /invoices | { ids, includePayments?, includeNotes?, includeItems? } | payments:invoice:export |
POST /payments | { ids, includePartnerDetails?, includeInvoiceInfo?, includePaymentDetails? } | payments:payment:export |
POST /payment-collections | { ids, includeCustomerDetails?, includePaymentDetails?, includeBankDetails? } | payments:collection:export |
POST /stock-adjustments | { filter?: { status?, warehouseId?, search?, performedAtFrom?, performedAtTo? }, includeWarehouseInfo?, includeProductDetails?, includeCostInfo?, includeBatchSerial?, includeUserInfo? }. Takes a filter, not ids. | inventory:stock_adjustment:export |
POST /stock-transfers | { ids, includeWarehouseInfo?, includeProductDetails?, includeCostInfo?, includeBatchSerial?, includeUserInfo?, includeAddresses? } | inventory:stock_transfer:export |
POST /grns | { ids } | purchase:grn:export |
GET /grns/<grn-id> | None. Exports one GRN. | purchase:grn:export |
Rules for these routes:
- Send ids in the JSON body, never in a query string. Long id lists exceed header limits.
- A missing permission returns
403Missing required permission: <permission>. - Every
include…flag is opt-in. Only the valuetrueturns it on. - An empty selection returns
400, for example "No invoices selected to export." For stock adjustments, a filter that matches nothing returns "No stock adjustments match the current filter." - A selection over 50,000 rows returns
400, for exampleExport too large: <n> invoices (maximum 50000). Narrow the selection and try again.The stock-adjustment message says "Narrow the filter" instead. - Ids from other organizations are silently excluded.
- The response is a binary attachment, not JSON. Its
Content-Typeisapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet. It has aContent-Disposition: attachment; filename="…"header and anX-Row-Countheader. - These endpoints always return a password-protected
.xlsx. The password is 12 characters, generated by the server, and returned once in thestart-batchresponse. It is never shown again. Store it in your secret store, and never log it. - The batch token is valid for 5 minutes and is bound to the user and the organization. Reuse one token for every file in the batch. One password opens all of them.
- A wrong or expired token returns
400"Export session expired or invalid. Start the export again." - Text cells that begin with
=,@, a tab or a carriage return are prefixed with an apostrophe to block spreadsheet-formula injection. So are cells that begin with+or-and are not a plain signed number. Strip the apostrophe when you re-import the file.
Export analytics data
Analytics exports use the same password-and-token pattern, under /api/analytics/export.
curl --fail-with-body --request POST "$ARC_API_URL/api/analytics/export/start-batch" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN"
# → {"password":"<generated-password>","batchToken":"<batch-token>"}
Download one domain with the token and a date window:
curl --fail-with-body "$ARC_API_URL/api/analytics/export/invoices?from=<from-iso>&to=<to-iso>" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--header "X-Export-Batch-Token: <batch-token>" \
--output invoices-export
- The domain is the last path segment: one of
quotes,sales-orders,invoices,paymentsorsignals. - Each domain needs the matching permission,
analytics:<domain>:export, spelled with the domain exactly as in the path. For example,analytics:quotes:exportcoversquotesandanalytics:sales-orders:exportcoverssales-orders. Without it the request returns403Missing required permission: analytics:<domain>:export. An unknown domain therefore usually returns that403. A caller with a wildcard permission gets400"Unknown export domain …" instead. - The date window is mandatory and can be at most 400 days.
- You can pass an optional
scopeparameter. Results are clamped to the caller's visibility scope, as with the analytics queries. See Analytics and search. - A selection over 50,000 rows returns
400Export too large: <n> rows (maximum 50000). Narrow the date range and try again. - The file is an encrypted
<domain>-<stamp>.xlsxwhen the data is sensitive and the organization requires encrypted exports, which is the default. All five domains are classed as sensitive. Otherwise it is<domain>-<stamp>.csv, served astext/csv; charset=utf-8. The password opens the encrypted.xlsx. A CSV is not password-protected. - The batch token is checked only when the file is encrypted. Send it on every request anyway, because you cannot know in advance which format you will get. A wrong or expired token on an encrypted export returns
400"Export session expired or invalid. Start the export again." - The response includes
Content-DispositionandX-Row-Countheaders.
The download does not write an audit entry. After you keep the file, confirm it:
curl --fail-with-body --request POST "$ARC_API_URL/api/analytics/export/confirm/invoices" \
--header "Authorization: Bearer $ARC_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{"from":"<from-iso>","to":"<to-iso>","filename":"invoices-<stamp>.xlsx"}'
# → {"recorded": true}
The confirm body is { "from": string, "to": string, "rowCount"?: number, "filename"?: string }. Include rowCount if you have it from X-Row-Count.
9. Limits and lifetimes
| Item | Limit |
|---|---|
| Image upload | 5 MB, 5 image types |
| Document upload | 10 MB, 10 types |
| Presigned upload URL | 1 hour |
| Secure URL | Default 1 hour. You set it with expiresIn. |
| Export batch token | 5 minutes |
| Export rows | Maximum 50,000 |
| Analytics export window | 400 days or less |
| Base64 inline, no expiry | |
| Request timeout | 60 seconds |
Pitfalls
- Use a user JWT, not an API key. A tenant API key is rejected on these routes.
- Persist the relative
urlas returned. Never hard-code a storage host. - Files are never overwritten. To replace a file, upload a new one, update the record, and then delete the old file after you unlink it.
- Private folders return
404on the public proxy. Read those files with secure-url. - Keep nothing confidential in a public folder.
- For a presigned PUT, use the same
Content-Type, do not send the ARCAuthorizationheader, and treatuploadUrlas a secret. - PDFs come back as base64. An unknown template silently becomes
professional. Amounts are never re-priced. - A PDF "not found" response is the same for a wrong id and for another organization's id.
- For exports, send ids in the body, stay under 50,000 rows, and use the batch token within 5 minutes. The password is shown once. One batch can cover several files.
- Skipping
recordExportor the analytics confirm call leaves no audit entry. - Strip the formula-guard apostrophe when you re-import an export.
Next: Production checklist and Errors, pagination, and idempotency.