Salta al contenuto

Sviluppatori

API, webhook e MCP di Tilly

Tilly parla con i programmi della tua azienda: un’API per leggere e scrivere, webhook firmati che avvisano quando succede qualcosa, un server MCP per gli assistenti AI. Qui c’è tutto, con esempi da copiare.

Per iniziare

Ogni programma entra con una chiave dell’API. La crea un amministratore dell’assistenza in Amministrazione → App e integrazioni → API e chiavi, scegliendo se può solo leggere o anche scrivere, e se deve scadere. La chiave ha questa forma e si vede per intero una volta sola, appena creata:

tly_7_Q2hpYXZlIGRpIGVzZW1waW8sIG5vbiB2YWxlIG5pZW50ZQ

Va mandata in ogni richiesta nell’intestazione Authorization. L’indirizzo di base è https://tilly.omegasuite.it/api/v1. Il cookie del browser non vale: l’API è fatta per i programmi, e una chiave non va mai messa in una pagina web pubblica.

curl https://tilly.omegasuite.it/api/v1/me -H "Authorization: Bearer tly_…"

Una chiave agisce con i permessi della persona che l’ha creata, ricontrollati a ogni richiesta: se quella persona esce dal team, la chiave smette di funzionare. Le modifiche fatte da un programma compaiono negli eventi del ticket come «API (nome della chiave)» e partono dal canale api: trigger, SLA, notifiche ed email scattano come per un operatore.

Convenzioni

  • Corpi in JSON, con Content-Type: application/json, massimo 1 MB.
  • Nomi dei campi in snake_case; date in ISO 8601, in UTC (2026-10-14T09:30:02.551Z).
  • Un elemento torna dentro data; un elenco torna come { "data": [...], "has_more": true, "next_cursor": "…" }.
  • Paginazione a cursore: limit da 1 a 100 (predefinito 25). Per la pagina dopo si ripete la stessa richiesta con cursor=<next_cursor>. Quando next_cursor è null l’elenco è finito. Il cursore non cambia se nel frattempo arrivano ticket nuovi: niente righe saltate o ripetute.
  • Lo stato di un ticket è la categoria (new, open, pending, hold, solved, closed) più l’id e l’etichetta dello stato personalizzato (status_id, status_label).
  • Si aggiungono campi nuovi senza preavviso; non se ne tolgono né rinominano senza una /api/v2. Il tuo programma deve ignorare i campi che non conosce.
  • Ogni risposta porta X-Request-Id: citalo se scrivi all’assistenza.

Errori

Gli errori hanno sempre la stessa forma, con il messaggio in italiano pronto da mostrare:

{ "error": { "code": "invalid", "message": "Campo «assignee_id»: operatore non trovato o disattivato (vedi GET /agents).", "details": [...] } }
Codice HTTPcodeQuando
400bad_jsonIl corpo non è un JSON valido.
401unauthorizedChiave assente, sbagliata, revocata o scaduta.
402read_onlyL’abbonamento dell’assistenza è scaduto: si legge tutto, non si scrive.
403read_only_keyLa chiave è di sola lettura e la richiesta scrive.
403forbiddenLa persona della chiave non ha il permesso (per esempio un collaboratore che risponde in pubblico).
404not_foundPercorso o elemento che non esiste.
405method_not_allowedIl percorso esiste, il metodo no.
409closed, conflict, idempotency_in_progressUn conflitto: ticket chiuso, nome già usato, richiesta uguale ancora in corso.
413too_largeCorpo oltre 1 MB.
415unsupported_media_typeCorpo che non è JSON.
422invalid, idempotency_mismatchDati non validi (il campo è nel messaggio e in details), oppure Idempotency-Key riusata per un’altra richiesta.
429rate_limitedTroppe richieste: aspetta i secondi di Retry-After.
500serverUn guasto nostro: riprova; se continua, scrivici con l’X-Request-Id.

Limiti e ripetizioni

Ogni chiave ha 300 richieste al minuto (API e MCP insieme). Le risposte dicono quante ne restano: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (secondi alla nuova finestra). Oltre il limite si riceve 429 con Retry-After. Da un indirizzo che prova 30 chiavi sbagliate in un minuto si riceve 429 per il resto del minuto.

Le POST accettano l’intestazione Idempotency-Key (da 1 a 255 caratteri, per esempio l’id dell’ordine nel tuo gestionale). Se la rete cade a metà e ripeti la stessa richiesta con la stessa chiave, ricevi la risposta della prima volta, con Idempotent-Replayed: true, invece di un secondo ticket. La stessa chiave con un corpo diverso dà 422; una risposta d’errore non viene ricordata (puoi riprovare dopo aver corretto). Le chiavi di idempotenza valgono 24 ore.

Le risorse

MetodoPercorsoCosa fa
GET/meLa chiave, la persona a cui appartiene, l’assistenza, il limite.
GET/ticketsElenco dei ticket con filtri (vedi sotto).
POST/ticketsCrea un ticket.
GET/tickets/{id}Un ticket con tutti i commenti.
PATCH/tickets/{id}Modifica un ticket (e, se vuoi, aggiunge un commento).
GET/tickets/{id}/commentsI commenti di un ticket, dal più vecchio.
POST/tickets/{id}/commentsUna risposta pubblica o una nota interna.
GET/attachments/{id}Scarica un allegato (il tipo vero è in X-Tilly-Content-Type).
GET/customersClienti: q, email, phone, organization_id, tag, updated_after.
POST/customersCrea un cliente: name più email, phone o whatsapp.
GET/customers/{id}Un cliente.
PATCH/customers/{id}Modifica un cliente (contatti, organizzazione, tag, note, campi, suspended).
GET/customers/{id}/ticketsI ticket di un cliente (stessi filtri di /tickets).
GET/organizationsOrganizzazioni: q, domain.
POST/organizationsCrea un’organizzazione: name, domains, tags, notes, group_id, fields.
GET/organizations/{id}Un’organizzazione.
PATCH/organizations/{id}Modifica un’organizzazione.
GET/organizations/{id}/ticketsI ticket di un’organizzazione.
GET/groupsI gruppi, con gli id dei membri. Anche /groups/{id}.
GET/agentsGli operatori attivi (active=all per tutti). Anche /agents/{id}.
GET/viewsLe viste che la persona della chiave vede. Anche /views/{id}.
GET/views/{id}/ticketsI ticket di una vista, nel suo ordine.
GET/kb/articlesArticoli: q, status (published, draft, archived), section_id.
GET/kb/articles/{id}Un articolo con l’HTML.
POST/kb/articlesCrea un articolo: title, html o body, section_id, status, tags, promoted.
PATCH/kb/articles/{id}Modifica un articolo.
GET/kb/categoriesCategorie e sezioni della base di conoscenza.
GET/tagsI tag usati nei ticket, con quanti ticket li hanno (q per filtrare).

Le scritture su clienti, organizzazioni e articoli seguono le stesse regole dell’interfaccia: un’email già di un altro cliente dà 422 con l’id di chi la possiede, un collaboratore non scrive articoli, un nome di organizzazione è unico.

Ticket

Filtri dell’elenco

ParametroValori
statusUna o più categorie separate da virgola: new,open,pending,hold,solved,closed
status_idLo stato personalizzato
prioritylow, normal, high, urgent, oppure none
typequestion, incident, problem, task, oppure none
channelemail, web, help_center, whatsapp, phone, chat, api, agent, rule
assignee_id, group_id, requester_id, organization_id, brand_id, form_idUn id, oppure none
tagUn tag
qParole nell’oggetto o nei messaggi
created_after, created_before, updated_after, updated_beforeDate ISO 8601
sortcreated_desc (predefinito, i più nuovi prima), updated_desc, updated_asc (per sincronizzare: si riparte dall’ultimo visto)

Ticket eliminati, segnati come spam e uniti a un altro non compaiono negli elenchi.

Creare e modificare

CampoNote
subjectOggetto, massimo 500 caratteri. Se manca si usa la prima riga del messaggio.
requester_idIl cliente. In creazione, in alternativa: requester con email (o phone) e name — lo si trova o lo si crea.
comment{ "body": "testo" } oppure { "html": "<p>…</p>" }, con "public": true (risposta, predefinito) o false (nota interna). L’HTML viene ripulito.
status, status_idnew, open, pending, hold, solved — o l’id di uno stato personalizzato. Per risolvere servono assegnatario e campi obbligatori.
priority, typeCome nei filtri; null per togliere.
assignee_id, group_id, brand_id, form_idId esistenti (GET /agents, /groups); null per togliere.
tagsSostituisce i tag. In modifica anche add_tags e remove_tags.
fieldsI campi personalizzati: { "chiave_del_campo": valore }.
due_atScadenza (per i ticket di tipo task).
follower_ids, cc_idsOperatori che seguono; clienti in copia.
organization_id, requester_id, problem_idSolo in modifica.

I campi sconosciuti si rifiutano con 422: un errore di battitura non passa in silenzio. Una risposta tipo:

GET /tickets/1042
{
  "data": {
    "id": 1042,
    "url": "https://tilly.omegasuite.it/app/tickets/1042",
    "subject": "La fattura di settembre non torna",
    "status": "open",
    "status_id": 2,
    "status_label": "Aperto",
    "priority": "high",
    "type": "question",
    "channel": "email",
    "requester": { "id": 87, "name": "Marta Rinaldi", "email": "marta@esempio.it", "phone": null },
    "assignee": { "id": 3, "name": "Luca Bassi", "email": "luca@tuaazienda.it" },
    "group": { "id": 1, "name": "Amministrazione" },
    "organization": { "id": 12, "name": "Rinaldi Srl" },
    "brand_id": 1,
    "form_id": 1,
    "tags": ["fatture", "urgente"],
    "fields": { "numero_ordine": "A-5531" },
    "preview": "Buongiorno, nella fattura di settembre…",
    "satisfaction": null,
    "created_at": "2026-10-14T08:12:40.118Z",
    "updated_at": "2026-10-14T09:30:02.551Z",
    "last_public_at": "2026-10-14T09:30:02.551Z",
    "due_at": null,
    "solved_at": null,
    "closed_at": null,
    "first_reply_at": "2026-10-14T09:30:02.551Z",
    "follower_ids": [],
    "cc": [],
    "problem_id": null,
    "merged_into_id": null,
    "comments": [
      {
        "id": 5120,
        "ticket_id": 1042,
        "public": true,
        "author": { "type": "customer", "id": 87, "name": "Marta Rinaldi" },
        "html": "<p>Buongiorno, nella fattura di settembre…</p>",
        "text": "Buongiorno, nella fattura di settembre…",
        "via": "email",
        "attachments": [
          { "id": 77, "name": "fattura.pdf", "size": 48211, "content_type": "application/pdf",
            "url": "https://tilly.omegasuite.it/api/v1/attachments/77" }
        ],
        "created_at": "2026-10-14T08:12:40.118Z"
      }
    ]
  }
}

Un ticket chiuso non si modifica più (409): per continuare la conversazione si apre un ticket nuovo.

Esempi

curl
# Le chiavi si creano in Amministrazione → App e integrazioni → API e chiavi
export TILLY_KEY="tly_…"

# I ticket aperti, 25 alla volta
curl "https://tilly.omegasuite.it/api/v1/tickets?status=open,pending" \
  -H "Authorization: Bearer $TILLY_KEY"

# Un ticket nuovo a nome di un cliente (lo crea se non c'è)
curl -X POST "https://tilly.omegasuite.it/api/v1/tickets" \
  -H "Authorization: Bearer $TILLY_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ordine-A-5531" \
  -d '{
    "subject": "Reso dell’ordine A-5531",
    "requester": { "email": "marta@esempio.it", "name": "Marta Rinaldi" },
    "comment": { "body": "Il pacco è arrivato danneggiato." },
    "priority": "high",
    "tags": ["reso"]
  }'

# Rispondere al cliente e mettere il ticket in attesa
curl -X PATCH "https://tilly.omegasuite.it/api/v1/tickets/1042" \
  -H "Authorization: Bearer $TILLY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "comment": { "body": "Le mando l’etichetta per il reso.", "public": true }, "status": "pending" }'
JavaScript
// Node.js 18 o più recente (fetch è già dentro)
const API = 'https://tilly.omegasuite.it/api/v1';
const headers = { Authorization: `Bearer ${process.env.TILLY_KEY}`, 'Content-Type': 'application/json' };

// Tutti i ticket cambiati da ieri, pagina dopo pagina
async function ticketCambiati(dal) {
  const tutti = [];
  let cursor = null;
  do {
    const q = new URLSearchParams({ sort: 'updated_asc', updated_after: dal, limit: '100' });
    if (cursor) q.set('cursor', cursor);
    const r = await fetch(`${API}/tickets?${q}`, { headers });
    if (r.status === 429) { await new Promise((ok) => setTimeout(ok, Number(r.headers.get('retry-after')) * 1000)); continue; }
    if (!r.ok) throw new Error((await r.json()).error.message);
    const pagina = await r.json();
    tutti.push(...pagina.data);
    cursor = pagina.next_cursor;
  } while (cursor);
  return tutti;
}

// Una nota interna su un ticket
await fetch(`${API}/tickets/1042/comments`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ body: 'Rimborso approvato dal magazzino.', public: false }),
});
Python
# Python 3 con requests (pip install requests)
import os, requests

API = "https://tilly.omegasuite.it/api/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['TILLY_KEY']}"

# Cerca un cliente per email e i suoi ticket
c = s.get(f"{API}/customers", params={"email": "marta@esempio.it"}).json()["data"]
if c:
    t = s.get(f"{API}/customers/{c[0]['id']}/tickets", params={"limit": 50}).json()
    for ticket in t["data"]:
        print(ticket["id"], ticket["status"], ticket["subject"])

# Risolvi un ticket
r = s.patch(f"{API}/tickets/1042", json={"status": "solved"})
if r.status_code == 422:
    print("Non si può ancora:", r.json()["error"]["message"])

Webhook

Un webhook avvisa un tuo programma quando succede qualcosa. Si registra in Amministrazione → App e integrazioni → Webhook: l’indirizzo (solo https://) e gli eventi. Tilly manda una POST con un JSON a ogni evento, firmata con il segreto del webhook.

EventoQuandoDentro data
ticket.createdUn ticket nuovo, da qualunque canale.ticket
ticket.updatedOgni modifica, commenti compresi.ticket, changes (campo → { from, to })
ticket.solvedUn ticket passa a «Risolto».ticket
ticket.reopenedUn ticket risolto o chiuso torna aperto.ticket
comment.createdUna risposta o una nota su un ticket esistente (il primo messaggio sta in ticket.created).ticket, comment
customer.createdUn cliente nuovo, da qualunque strada (email, modulo, WhatsApp, a mano, importazione, API).customer
satisfaction.receivedIl cliente valuta la richiesta.ticket, satisfaction { rating, comment }
webhook.testIl bottone «Invia una prova».message

Il ticket e il cliente hanno la stessa forma delle risorse dell’API. Il corpo è la fotografia del momento dell’evento: un ritentativo due ore dopo manda il ticket com’era allora. Per lo stato di adesso, leggi la risorsa con l’API.

Una consegna
POST /il-tuo-indirizzo HTTP/1.1
Content-Type: application/json
User-Agent: Tilly-Webhook/1.0
Tilly-Event: comment.created
Tilly-Event-Id: evt_Ab3dEf8hIj0kLm2n
Tilly-Delivery: 3381
Tilly-Attempt: 1
Tilly-Signature: t=1792138202,v1=5f0c4e…(64 caratteri esadecimali)

{
  "id": "evt_Ab3dEf8hIj0kLm2n",
  "type": "comment.created",
  "created_at": "2026-10-14T09:30:02.700Z",
  "account": { "id": 7, "name": "Assistenza Rinaldi" },
  "actor": { "type": "agent", "name": "Luca Bassi" },
  "data": {
    "ticket": { "id": 1042, "subject": "La fattura di settembre non torna", "status": "pending", … },
    "comment": { "id": 5121, "public": true, "text": "Le mando la nota di credito.", … }
  }
}

Ritentativi

Una consegna è riuscita se il tuo indirizzo risponde con un codice 2xx entro 10 secondi. Altrimenti Tilly ritenta 5 volte: dopo 1 minuto, 5 minuti, 30 minuti, 2 ore e 6 ore (i tentativi partono col giro pianificato, quindi possono slittare di qualche minuto). Dopo l’ultimo la consegna resta nel registro come «Non riuscita» e si può rimandare con Riprova. Un webhook che fallisce 25 tentativi di fila si spegne da solo; si riaccende dall’amministrazione. Il registro tiene le consegne 30 giorni.

Lo stesso evento può arrivare più di una volta (per esempio se il tuo programma ha risposto oltre i 10 secondi): usa id dell’evento (lo stesso di Tilly-Event-Id) per scartare i doppioni. L’ordine di arrivo non è garantito: per ordinare usa created_at.

Cosa Tilly non fa, per sicurezza

  • Non consegna a indirizzi http://, né a indirizzi della rete interna (127.0.0.1, 10.x, 192.168.x, 169.254.x e simili): il nome si risolve e si controlla prima di ogni consegna, e di nuovo dentro la connessione.
  • Non segue i redirect: un 301 o 302 conta come errore. Registra l’indirizzo finale.
  • Non legge più di 64 kB della tua risposta, e ne conserva nel registro solo i primi 500 caratteri.
  • Non mette nome utente e password nell’indirizzo: per riconoscere Tilly c’è la firma.

La firma dei webhook

L’intestazione Tilly-Signature ha la forma t=<secondi>,v1=<firma>, dove t è l’istante dell’invio (secondi dal 1970, UTC) e v1 è l’HMAC-SHA256, in esadecimale, del testo t + "." + corpo con il segreto del webhook come chiave. Per controllarla:

  1. leggi il corpo così com’è arrivato, come testo (non rifatto da un JSON già letto);
  2. rifai l’HMAC con il tuo segreto e confrontalo con v1 con un confronto a tempo costante;
  3. scarta le consegne con un t più vecchio di 5 minuti: è la difesa contro chi rigioca una consegna copiata.

Il segreto (inizia con whsec_) si vede in Webhook → Mostra il segreto di firma e si può rigenerare in qualunque momento. A ogni ritentativo la firma si rifà con un t nuovo.

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();
// Il corpo serve COSÌ COM'È ARRIVATO: rifatto da JSON.parse non torna più.
app.post('/webhook/tilly', express.text({ type: 'application/json' }), (req, res) => {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(req.get('tilly-signature') ?? '');
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return res.sendStatus(400);
  const atteso = crypto.createHmac('sha256', process.env.TILLY_WEBHOOK_SECRET)
    .update(`${m[1]}.${req.body}`).digest('hex');
  if (!crypto.timingSafeEqual(Buffer.from(m[2], 'hex'), Buffer.from(atteso, 'hex'))) return res.sendStatus(400);

  const evento = JSON.parse(req.body);
  // Lo stesso evento può arrivare due volte (un ritentativo dopo un tempo scaduto):
  // tieni da parte evento.id e salta quelli già visti.
  res.sendStatus(200); // rispondi subito, lavora dopo
});
Python
import hmac, hashlib, time

def firma_valida(corpo: bytes, intestazione: str, segreto: str) -> bool:
    try:
        parti = dict(p.split("=", 1) for p in intestazione.split(","))
        t, v1 = parti["t"], parti["v1"]
    except (ValueError, KeyError):
        return False
    if abs(time.time() - int(t)) > 300:
        return False
    atteso = hmac.new(segreto.encode(), f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
    return hmac.compare_digest(atteso, v1)
PHP
<?php
$corpo = file_get_contents('php://input');
$intestazione = $_SERVER['HTTP_TILLY_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $intestazione, $m) || abs(time() - (int)$m[1]) > 300) {
  http_response_code(400); exit;
}
$atteso = hash_hmac('sha256', $m[1] . '.' . $corpo, getenv('TILLY_WEBHOOK_SECRET'));
if (!hash_equals($atteso, $m[2])) { http_response_code(400); exit; }
$evento = json_decode($corpo, true);
http_response_code(200);

Assistenti AI (MCP)

Tilly ha un server Model Context Protocol all’indirizzo https://tilly.omegasuite.it/api/mcp (trasporto Streamable HTTP: POST con JSON-RPC, risposta JSON). Ci si collega con una chiave dell’API, mai col cookie. Gli strumenti: search_tickets, get_ticket, reply_to_customer, add_internal_note, set_ticket_status, search_customers, search_articles. Con una chiave di sola lettura l’assistente vede solo quelli che leggono. Ogni strumento usa le stesse risorse dell’API: stessi permessi, stessi trigger, stesse email. Le istruzioni passo per passo per Claude Desktop, Claude Code, Cursor e VS Code sono in Amministrazione → App e integrazioni → Assistenti AI esterni (MCP).

Configurazione
# Claude Code
claude mcp add --transport http tilly https://tilly.omegasuite.it/api/mcp \
  --header "Authorization: Bearer tly_…"

# Cursor (~/.cursor/mcp.json) e altri client con trasporto HTTP
{
  "mcpServers": {
    "tilly": {
      "url": "https://tilly.omegasuite.it/api/mcp",
      "headers": { "Authorization": "Bearer tly_…" }
    }
  }
}

# Claude Desktop (claude_desktop_config.json), con il ponte mcp-remote
{
  "mcpServers": {
    "tilly": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://tilly.omegasuite.it/api/mcp", "--header", "Authorization: Bearer tly_…"]
    }
  }
}

Esportazione completa

Per portare via tutto in una volta non serve l’API: in Amministrazione → Account → Esportazione dei dati titolare e amministratori preparano un archivio ZIP con ticket, commenti, eventi, clienti, organizzazioni, articoli e regole, in JSON e in CSV (punto e virgola, UTF-8 con BOM, pronto per Excel), più gli allegati in una cartella per ticket. Il link per scaricarlo vale 24 ore.