File Uploads
Some inputs in this API take a file rather than a value. A product image, a scanned supplier invoice, a customer's GDPR document: all of them are sent with the mutation that creates or updates the record, in a single request.
You can spot them in the reference by their type. Any input field typed Upload takes a file, and every one of them is sent the same way. This page explains that one way. Once you can send a product image, you can send anything else on the list.
Which fields take a file
| Field | Where | What it holds |
|---|---|---|
img | Products, product variants, product categories | The image shown for that record |
file and fileOriginal | Purchase and supplier documents, migrated documents | An attachment, typically a scan of the original |
img1 | Company | The company logo |
img | Your own account (meUpdate) | Your profile picture |
img | Identification templates | The logo used on that template |
img1 | Partner proposals | The image for the proposal |
gdprFile | Customers | A GDPR consent document, stored privately |
file | Smart Scan (ocrJobCreate) | A document to be read automatically |
How an upload request differs from a normal call
A normal call to https://api.molonion.pt/v1 sends one JSON body. A call carrying a file sends multipart/form-data instead: the same body you would have sent, plus the file itself, split into separate parts of one request. This follows the GraphQL multipart request specification, which most GraphQL client libraries already support.
There are three kinds of part, and they go in this order.
1. operations
Your ordinary GraphQL request, as JSON: the query and its variables, exactly as you would normally send them. The one difference is that wherever a file belongs, you put null. That reserves the place; the file arrives later in the request.
2. map
A small JSON object saying which file fills which null. Each key is the name of a file part, and each value is a list containing the path to that variable, written with dots:
{ "0": ["variables.data.img"] }
Read that as "the part named 0 goes into variables.data.img". If you send two files, you get two entries.
3. The file parts
One part per file, named 0, 1, 2 and so on, matching the keys in map. Each part carries the file's bytes, its filename and its content type. The API reads the filename to check the extension and to name the stored file.
Accepted file types
The extension is checked against a fixed list. Anything else is refused with a normal field error, not a crash.
| Used for | Accepted extensions |
|---|---|
| Images: products, variants, categories, company logo, profile picture | .jpg, .jpeg, .jpe, .png, .gif, .bmp |
| Document attachments and the customer GDPR file | .pdf, .doc, .docx, .zip, .jpg, .jpeg, .png, .gif |
| Smart Scan | .pdf, .jpg, .jpeg, .png |
Attaching a file when you create a record
Send the file in the same mutation that creates the record. There is no separate upload step and nothing to do beforehand.
Worked example: a product image
This creates a product and gives it an image in one request. Replace the ids with your own: productCategoryId comes from productCategories, measurementUnitId from measurementUnits and taxId from taxes.
curl https://api.molonion.pt/v1 \
-H "Authorization: Bearer <access-token>" \
-F operations='{
"query": "mutation ($companyId: Int!, $data: ProductInsert!) { productCreate(companyId: $companyId, data: $data) { data { productId name img } errors { field msg } } }",
"variables": {
"companyId": 1,
"data": {
"name": "Blue mug",
"reference": "MUG-BLUE",
"type": 1,
"measurementUnitId": 1,
"productCategoryId": 21,
"price": 9.9,
"taxes": [{ "taxId": 1, "ordering": 1 }],
"img": null
}
}
}' \
-F map='{ "0": ["variables.data.img"] }' \
-F 0=@mug-blue.png
Three things to notice: img is null inside variables, map points at variables.data.img with dots, and the file part is named 0 to match the key in map.
The response is an ordinary mutation response. The img field now holds the path where the image was stored:
{
"data": {
"productCreate": {
"data": {
"productId": 8812,
"name": "Blue mug",
"img": "/assets/image/molonion/2026/09/10/14/9f3c1a72-....png"
},
"errors": []
}
}
}
From then on, img is how the image is referenced. It is a path, not the image and not a full URL. See Getting the file back below.
Adding or replacing a file on an existing record
The request looks identical, using the update mutation instead. What differs is what happens to the file that was already there.
curl https://api.molonion.pt/v1 \
-H "Authorization: Bearer <access-token>" \
-F operations='{
"query": "mutation ($companyId: Int!, $data: ProductUpdate!) { productUpdate(companyId: $companyId, data: $data) { data { productId img } errors { field msg } } }",
"variables": {
"companyId": 1,
"data": { "productId": 8812, "img": null }
}
}' \
-F map='{ "0": ["variables.data.img"] }' \
-F 0=@mug-blue-v2.png
Three rules cover every case:
- Sending a file replaces the old one. The previous file is deleted and the new one stored in its place. There is no version history, so a replacement cannot be undone.
- Leaving the field out changes nothing. An update that does not mention the file field leaves the existing file alone.
- Sending
nullclears it. On a document, clearingfilealso clearsfileOriginal.
Worked example: an attachment on a document
Documents take two fields together: file for the upload itself, and fileOriginal for the filename to show in the interface. fileOriginal is a plain string, not a file part.
This creates a supplier invoice with a scan attached. The five document fields shown are the ones SupplierInvoiceInsert requires. Replace the ids with your own: documentSetId comes from documentSets, supplierId from suppliers and productId from products.
curl https://api.molonion.pt/v1 \
-H "Authorization: Bearer <access-token>" \
-F operations='{
"query": "mutation ($companyId: Int!, $data: SupplierInvoiceInsert!) { supplierInvoiceCreate(companyId: $companyId, data: $data) { data { documentId file fileOriginal } errors { field msg } } }",
"variables": {
"companyId": 1,
"data": {
"documentSetId": 3,
"supplierId": 512,
"date": "2026-09-10T00:00:00.000Z",
"expirationDate": "2026-10-10",
"products": [
{ "productId": 8812, "ordering": 1, "qty": 10 }
],
"file": null,
"fileOriginal": "invoice-4471.pdf"
}
}
}' \
-F map='{ "0": ["variables.data.file"] }' \
-F 0=@invoice-4471.pdf
The response carries the stored path and the original name:
{
"data": {
"supplierInvoiceCreate": {
"data": {
"documentId": 90114,
"file": "/privateassets/file/molonion/2026/09/10/14/4c8e0b31-....pdf",
"fileOriginal": "invoice-4471.pdf"
},
"errors": []
}
}
}
Getting the file back
Reads return the stored path, never the file itself. How you fetch it depends on whether the file is public.
Public files
Product images, category images, company logos and profile pictures are public. Put the media host in front of the path and you have a URL you can use directly, in an <img> tag or anywhere else:
https://mediaapi.moloni.org/assets/image/molonion/2026/09/10/14/9f3c1a72-....png
Private files
Document attachments and the customer GDPR file are private, and the path alone will not fetch them. Ask for a short-lived token first. There are two queries, one per kind of file, and both return the same { token, path, filename }.
For a document attachment, pass the document type's plural apiCode:
query GetAttachmentToken($companyId: Int!, $documentId: Int!) {
getDocumentAttachmentToken(
companyId: $companyId
apiCodePlural: "supplierInvoices"
documentId: $documentId
) {
data {
token
path
filename
}
errors {
field
msg
}
}
}
For the customer GDPR file, the document query cannot serve it: it looks the id up among documents, and a GDPR file belongs to a customer. Use getCustomerGdprFileToken instead:
query GetGdprFileToken($companyId: Int!, $customerId: Int!) {
getCustomerGdprFileToken(companyId: $companyId, customerId: $customerId) {
data {
token
path
filename
}
errors {
field
msg
}
}
}
Either way, combine the path and the token the same way:
https://mediaapi.moloni.org{path}?jwt={token}
When an upload is refused
A rejected file comes back as an ordinary field error in the errors array, with the name of the field that failed. The mutation does not throw.
| Message | Cause |
|---|---|
Invalid file type | The extension is not on the accepted list for that field |
Invalid file name | The filename on the file part is blank or only spaces |
Because these are normal field errors, handle them exactly as described in Error Handling.
Next steps
- Products: Overview & Creation: The full set of product fields, including
img - Document Types: The
apiCodeof every document type, needed for attachments and download tokens - Downloading a Document PDF: The same token pattern, used for generated PDFs
- Error Handling: How to read the
errorsarray a refused upload returns - Schema & Tooling: Generating types, so
Uploadfields show up in your client