API Reference / Note e allegati

Note e allegati

Note e allegati non sono moduli a sé: si agganciano a un record (di qualsiasi modulo) tramite la coppia related_module + related_record_id. Le note sono testo; gli allegati sono file.

Prima di iniziare

Envelope, errori e paginazione sono comuni: vedi Introduzione alle API. Ogni richiesta richiede Authorization: Bearer <accessToken>.

01L'oggetto Nota

Ogni nota restituita dall'API ha questa forma. Le date sono stringhe ISO 8601 in UTC. La coppia related_module + related_record_id è obbligatoria: una nota esiste sempre agganciata a un record.

AttributoTipoDescrizione
idintegerIdentificativo univoco della nota.
contentstringTesto della nota.
related_modulestringModulo del record collegato (moduli base o chiave di un modulo custom).
related_record_idintegerId del record collegato.
owner_user_idinteger | nullUtente proprietario del record.
created_byinteger | nullUtente che ha creato la nota.
updated_byinteger | nullUltimo utente che l'ha modificata.
is_deletedbooleantrue se in cestino (soft delete).
created_atstringData/ora di creazione (ISO 8601).
updated_atstringData/ora ultima modifica (ISO 8601).
json · l'oggetto Nota
{
  "id": 4021,
  "content": "Cliente interessato al rinnovo",
  "related_module": "accounts",
  "related_record_id": 128,
  "owner_user_id": 7,
  "created_by": 7,
  "updated_by": 7,
  "is_deleted": false,
  "created_at": "2026-07-07T15:24:00.000Z",
  "updated_at": "2026-07-07T15:24:00.000Z"
}

02Endpoint · Note

MetodoPathAzioneCosa fa
GET/api/notesreadElenco (filtrabile per record collegato).
GET/api/notes/:idreadUna singola nota.
POST/api/notescreateCrea una nota su un record.
PATCH/api/notes/:idupdateModifica il testo.
DELETE/api/notes/:iddeleteSoft delete.
GET/api/notes/export.csv · .xlsxexportEsporta l'elenco filtrato.

03Elencare le note

GET /api/notes bearer richiesto

Restituisce le note visibili all'utente, paginate. Per le note di un record filtra su related_module e related_record_id con advanced_filters.

Parametri query
ParametroTipoDescrizione
limitintegeropzionaleRighe per pagina. Default 50, max 200.
offsetintegeropzionaleRighe da saltare (default 0).
sort_bystringopzionaleColonna di ordinamento (es. created_at).
sort_dirstringopzionaleasc o desc.
advanced_filtersstring (JSON)opzionaleArray di condizioni: vedi Filtri avanzati.
Richiesta
curl "https://crm.tuodominio.it/api/notes?advanced_filters=%5B%7B%22field%22%3A%22related_module%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22accounts%22%7D%5D" \
  -H "Authorization: Bearer <accessToken>"
const filters = JSON.stringify([{ field: "related_module", op: "eq", value: "accounts" }]);
const url = `https://crm.tuodominio.it/api/notes?advanced_filters=${encodeURIComponent(filters)}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
const { data } = await res.json();
Risposta · 200
json
{
  "success": true,
  "data": {
    "items": [ { "id": 4021, "content": "Cliente interessato al rinnovo",  } ],
    "pagination": { "limit": 50, "offset": 0, "count": 1 }
  }
}

04Creare una nota

POST /api/notes bearer richiesto
Parametri body
CampoTipoNote
contentstringrichiestoTesto della nota. 1–10.000 caratteri.
related_modulestringrichiestoModulo del record collegato (moduli base o chiave di un modulo custom).
related_record_idintegerrichiestoId del record collegato. Va insieme a related_module.
owner_user_idintegeropzionaleProprietario. Assegnabile solo a sé o a un sottoposto nella gerarchia dei ruoli (admin: chiunque). Vedi Utenti e team.
Richiesta
curl -X POST https://crm.tuodominio.it/api/notes \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Cliente interessato al rinnovo", "related_module": "accounts", "related_record_id": 128 }'
const res = await fetch("https://crm.tuodominio.it/api/notes", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ content: "Cliente interessato al rinnovo", related_module: "accounts", related_record_id: 128 }),
});
const { data } = await res.json();
Risposta · 201
json
{ "success": true, "data": { "id": 4021, "content": "Cliente interessato al rinnovo", "related_module": "accounts", "related_record_id": 128,  } }
Esiti
StatoCodiceQuando
201·Nota creata; nel corpo l'oggetto completo.
422VALIDATION_ERRORcontent, related_module o related_record_id mancante o non valido.
403FORBIDDENCampo non scrivibile dal ruolo.

05Modificare ed eliminare una nota

PATCH/api/notes/:idbearer richiesto

Modifica parziale: invia i campi da cambiare (tutti opzionali; almeno uno richiesto). Se cambi il collegamento devi inviare related_module e related_record_id insieme. Risponde 200 con l'oggetto aggiornato, 404 se fuori scope, 422 in validazione.

curl
curl -X PATCH https://crm.tuodominio.it/api/notes/4021 \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Rinnovo confermato per 12 mesi" }'
DELETE/api/notes/:idbearer richiesto

Soft delete: la nota viene marcata eliminata (is_deleted: true), non cancellata. Risponde 200 { deleted: true, id }.

06Allegati

Gli allegati sono file agganciati a un record. A differenza delle note, l'id di un allegato è un UUID e related_record_id è una stringa. Solo gli allegati in stato ready sono elencabili e scaricabili; gli upload presigned non ancora confermati restano pending.

L'oggetto Allegato
AttributoTipoDescrizione
idstring (UUID)Identificativo univoco dell'allegato.
related_modulestringModulo del record collegato.
related_record_idstringId del record collegato (fino a 120 caratteri).
virtual_folderstring | nullCartella logica opzionale (max 120 caratteri).
file_namestringNome file originale (sanitizzato).
mime_typestringTipo MIME dichiarato.
size_bytesintegerDimensione in byte.
storage_backendstringBackend di storage (es. local, s3).
statusstringready (scaricabile) o pending (presigned non confermato).
created_byinteger | nullUtente che ha caricato il file.
created_atstringData/ora di creazione (ISO 8601).
MetodoPathCosa fa
GET/api/attachmentsElenco allegati di un record.
POST/api/attachmentsUpload diretto del file (multipart).
POST/api/attachments/uploadsAvvia un upload presigned verso S3 (nessun byte qui).
POST/api/attachments/:id/completeConferma un upload presigned completato.
GET/api/attachments/:id/downloadScarica il file.
DELETE/api/attachments/:idElimina l'allegato.

Elenco allegati di un record

GET/api/attachmentsbearer richiesto

Restituisce gli allegati in stato ready di un record, ordinati per data (dal più recente).

Parametri query
ParametroTipoDescrizione
related_modulestringopzionaleModulo del record collegato.
related_record_idstringopzionaleId del record collegato.
Richiesta
curl "https://crm.tuodominio.it/api/attachments?related_module=deals&related_record_id=88" \
  -H "Authorization: Bearer <accessToken>"
const res = await fetch("https://crm.tuodominio.it/api/attachments?related_module=deals&related_record_id=88", {
  headers: { Authorization: `Bearer ${accessToken}` },
});
const { data } = await res.json();
Risposta · 200
json
{ "success": true, "data": { "items": [ { "id": "7c2a…-…", "file_name": "offerta.pdf", "mime_type": "application/pdf", "size_bytes": 84213, "status": "ready",  } ] } }

Upload diretto (multipart)

POST/api/attachmentsbearer richiesto

Il modo più semplice: invii il file e la destinazione in un'unica richiesta multipart/form-data. Il campo file si chiama file.

Campi form-data
CampoTipoNote
filefilerichiestoIl file da caricare.
related_modulestringrichiestoModulo del record collegato.
related_record_idstringrichiestoId del record collegato.
virtual_folderstringopzionaleCartella logica (max 120 caratteri).
Richiesta
curl -X POST https://crm.tuodominio.it/api/attachments \
  -H "Authorization: Bearer <accessToken>" \
  -F "file=@offerta.pdf" \
  -F "related_module=deals" \
  -F "related_record_id=88"
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("related_module", "deals");
form.append("related_record_id", "88");
const res = await fetch("https://crm.tuodominio.it/api/attachments", {
  method: "POST",
  headers: { Authorization: `Bearer ${accessToken}` },
  body: form,
});
Esiti
StatoCorpo
201{ success: true, data: { item: { …attachment } } }
422VALIDATION_ERROR: file mancante o destinazione non valida.

Upload presigned (S3)

Per file grandi con storage S3-compatibile, il client carica direttamente sull'object storage, senza far passare i byte dall'app. Tre passi:

POST/api/attachments/uploadsbearer richiesto
Parametri body
CampoTipoNote
file_namestringrichiestoNome file. Max 255 caratteri.
mime_typestringrichiestoTipo MIME. Max 160 caratteri.
size_bytesintegerrichiestoDimensione in byte. Intero positivo.
related_modulestringrichiestoModulo del record collegato.
related_record_idstringrichiestoId del record collegato.
virtual_folderstringopzionaleCartella logica (max 120 caratteri).
Richiesta
curl -X POST https://crm.tuodominio.it/api/attachments/uploads \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{ "file_name": "video.mp4", "mime_type": "video/mp4", "size_bytes": 52428800, "related_module": "deals", "related_record_id": "88" }'
const res = await fetch("https://crm.tuodominio.it/api/attachments/uploads", {
  method: "POST",
  headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    file_name: "video.mp4", mime_type: "video/mp4", size_bytes: 52428800,
    related_module: "deals", related_record_id: "88",
  }),
});
const { data } = await res.json();
// data.mode === "direct": PUT del file su data.upload.url con data.upload.headers,
// poi POST /api/attachments/${data.attachmentId}/complete
Risposta · 201
json
{
  "success": true,
  "data": {
    "mode": "direct",
    "attachmentId": "7c2a…-…",
    "upload": { "url": "https://bucket.s3…/…?X-Amz-Signature=…", "method": "PUT", "headers": { "Content-Type": "video/mp4" } }
  }
}
Storage locale

Se il backend attivo non supporta il trasferimento diretto, la risposta è 200 { "mode": "proxy" }: usa l'upload diretto multipart.

POST/api/attachments/:id/completebearer richiesto

Conferma che il file è stato caricato sul bucket. Il server verifica l'oggetto (HeadObject, che rileva anche la dimensione reale) e flippa la riga a ready. Idempotente: una seconda chiamata su una riga già ready la restituisce e basta.

Richiesta
curl -X POST "https://crm.tuodominio.it/api/attachments/7c2a…-…/complete" \
  -H "Authorization: Bearer <accessToken>"
const res = await fetch(`https://crm.tuodominio.it/api/attachments/${attachmentId}/complete`, {
  method: "POST",
  headers: { Authorization: `Bearer ${accessToken}` },
});
const { data } = await res.json();
Risposta · 201
json
{ "success": true, "data": { "item": { "id": "7c2a…-…", "status": "ready", "file_name": "video.mp4",  } } }
Nota

Gli upload avviati ma mai confermati (passo 3) restano pending e vengono ripuliti automaticamente dal worker (il janitor riduce le righe pending scadute). Su storage locale il flusso presigned non serve: usa l'upload diretto.

Download

GET/api/attachments/:id/downloadbearer richiesto

Restituisce il contenuto del file. Per sicurezza la risposta porta sempre X-Content-Type-Options: nosniff; l'anteprima inline nel browser è consentita solo per un insieme sicuro di tipi (image/png, image/jpeg, image/webp, image/gif, application/pdf), mentre tutto il resto viene servito come download forzato (Content-Disposition: attachment).

Se l'allegato è su S3, il server risponde con un redirect 302 a un URL firmato a vita breve: i byte scendono dal bucket, mai dall'app.

curl
curl -L -OJ "https://crm.tuodominio.it/api/attachments/7c2a…-…/download" \
  -H "Authorization: Bearer <accessToken>"

Eliminare un allegato

DELETE/api/attachments/:idbearer richiesto

Elimina l'allegato (riga e file). Risponde 200 { deleted: true, item: { …attachment } }.

Sicurezza

Le chiavi di storage sono generate lato server (nessun path scelto dal client) e i nomi file sono sanitizzati. Anche caricando un file con un MIME dichiarato "immagine" ma contenente HTML, il download non lo esegue nell'origine dell'app.