Skip to main content

Files, document PDFs, and exports

caution

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​

SurfaceCredentialHow to send it
REST routes on this page: /api/upload/* (except the public image proxy), /api/exports/documents/*, /api/analytics/export/*Staff user JWTAuthorization: Bearer <accessToken>
GraphQL PDF and export mutationsStaff user JWTAuthorization: Bearer <accessToken> on /graphql
GET /api/upload/serve/* (public image proxy)None, by designNothing

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/exports or /api/analytics is rejected with 401.
  • 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, default categories. 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."
  • 413 when 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.

caution

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"
  • 413 when 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.

Private folders

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 404 and 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.
  • w is 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.

MutationRequiresDocument
generateQuotePdf(quoteId: ID!, template: String): String!sales:quote:readQuote
generateSalesOrderPdf(salesOrderId: ID!, template: String): String!sales:sales_order:readSales order
generateInvoicePdf(invoiceId: ID!, template: String): String!payments:invoice:readSales and purchase invoices
generatePurchaseOrderPdf(purchaseOrderId: ID!, template: String): String!purchase:purchase_order:readPurchase order
generateCreditNotePdf(creditNoteId: ID!, template: String): String!sales:credit_note:readCredit note
generateDebitNotePdf(debitNoteId: ID!, template: String): String!purchase:debit_note:readDebit note
generatePaymentPdf(paymentId: ID!, template: String): String!payments:payment:readCollections 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.
  • template is optional: professional, modern or classic. 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, or professional if 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:

  1. Call the GraphQL authorizeExport mutation. It checks permission, reports whether the organization requires encrypted exports, and writes nothing.
  2. Call POST /api/exports/documents/start-batch to get a password and a batch token.
  3. POST the export route with the X-Export-Batch-Token header.
  4. Save the file, and open it with the password.
  5. Call the GraphQL recordExport mutation, 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 …:export permission that matches the export key. The permission for each route is in the table below.
  • AuthorizeExportInput is { exportKey: String!, entityLabel: String }.
    • exportKey is one of sales-order-export, purchase-order-export, invoice-export, payment-export, payment-collection-export, grn-export, stock-adjustment-export or stock-transfer-export.
    • entityLabel is optional, up to 200 characters, and is recorded as audit context.
  • AuthorizeExportResult is { 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.

RouteBodyRequires
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 403 Missing required permission: <permission>.
  • Every include… flag is opt-in. Only the value true turns 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 example Export 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-Type is application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. It has a Content-Disposition: attachment; filename="…" header and an X-Row-Count header.
  • These endpoints always return a password-protected .xlsx. The password is 12 characters, generated by the server, and returned once in the start-batch response. 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, payments or signals.
  • Each domain needs the matching permission, analytics:<domain>:export, spelled with the domain exactly as in the path. For example, analytics:quotes:export covers quotes and analytics:sales-orders:export covers sales-orders. Without it the request returns 403 Missing required permission: analytics:<domain>:export. An unknown domain therefore usually returns that 403. A caller with a wildcard permission gets 400 "Unknown export domain …" instead.
  • The date window is mandatory and can be at most 400 days.
  • You can pass an optional scope parameter. 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 400 Export too large: <n> rows (maximum 50000). Narrow the date range and try again.
  • The file is an encrypted <domain>-<stamp>.xlsx when 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 as text/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-Disposition and X-Row-Count headers.

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​

ItemLimit
Image upload5 MB, 5 image types
Document upload10 MB, 10 types
Presigned upload URL1 hour
Secure URLDefault 1 hour. You set it with expiresIn.
Export batch token5 minutes
Export rowsMaximum 50,000
Analytics export window400 days or less
PDFBase64 inline, no expiry
Request timeout60 seconds

Pitfalls​

  • Use a user JWT, not an API key. A tenant API key is rejected on these routes.
  • Persist the relative url as 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 404 on 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 ARC Authorization header, and treat uploadUrl as 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 recordExport or 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.