Moloni ON Logo WhiteGuidesAPI ReferenceExplorer
Guides

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

FieldWhereWhat it holds
imgProducts, product variants, product categoriesThe image shown for that record
file and fileOriginalPurchase and supplier documents, migrated documentsAn attachment, typically a scan of the original
img1CompanyThe company logo
imgYour own account (meUpdate)Your profile picture
imgIdentification templatesThe logo used on that template
img1Partner proposalsThe image for the proposal
gdprFileCustomersA GDPR consent document, stored privately
fileSmart 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 forAccepted 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 null clears it. On a document, clearing file also clears fileOriginal.

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.

MessageCause
Invalid file typeThe extension is not on the accepted list for that field
Invalid file nameThe 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

© 2026 Moloni ON

Tax Authority Certificate No. 3075