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_Q2hpYXZlIGRpIGVzZW1waW8sIG5vbiB2YWxlIG5pZW50ZQVa 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:
limitda 1 a 100 (predefinito 25). Per la pagina dopo si ripete la stessa richiesta concursor=<next_cursor>. Quandonext_cursorènulll’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 HTTP | code | Quando |
|---|---|---|
| 400 | bad_json | Il corpo non è un JSON valido. |
| 401 | unauthorized | Chiave assente, sbagliata, revocata o scaduta. |
| 402 | read_only | L’abbonamento dell’assistenza è scaduto: si legge tutto, non si scrive. |
| 403 | read_only_key | La chiave è di sola lettura e la richiesta scrive. |
| 403 | forbidden | La persona della chiave non ha il permesso (per esempio un collaboratore che risponde in pubblico). |
| 404 | not_found | Percorso o elemento che non esiste. |
| 405 | method_not_allowed | Il percorso esiste, il metodo no. |
| 409 | closed, conflict, idempotency_in_progress | Un conflitto: ticket chiuso, nome già usato, richiesta uguale ancora in corso. |
| 413 | too_large | Corpo oltre 1 MB. |
| 415 | unsupported_media_type | Corpo che non è JSON. |
| 422 | invalid, idempotency_mismatch | Dati non validi (il campo è nel messaggio e in details), oppure Idempotency-Key riusata per un’altra richiesta. |
| 429 | rate_limited | Troppe richieste: aspetta i secondi di Retry-After. |
| 500 | server | Un 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
| Metodo | Percorso | Cosa fa |
|---|---|---|
| GET | /me | La chiave, la persona a cui appartiene, l’assistenza, il limite. |
| GET | /tickets | Elenco dei ticket con filtri (vedi sotto). |
| POST | /tickets | Crea 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}/comments | I commenti di un ticket, dal più vecchio. |
| POST | /tickets/{id}/comments | Una risposta pubblica o una nota interna. |
| GET | /attachments/{id} | Scarica un allegato (il tipo vero è in X-Tilly-Content-Type). |
| GET | /customers | Clienti: q, email, phone, organization_id, tag, updated_after. |
| POST | /customers | Crea 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}/tickets | I ticket di un cliente (stessi filtri di /tickets). |
| GET | /organizations | Organizzazioni: q, domain. |
| POST | /organizations | Crea un’organizzazione: name, domains, tags, notes, group_id, fields. |
| GET | /organizations/{id} | Un’organizzazione. |
| PATCH | /organizations/{id} | Modifica un’organizzazione. |
| GET | /organizations/{id}/tickets | I ticket di un’organizzazione. |
| GET | /groups | I gruppi, con gli id dei membri. Anche /groups/{id}. |
| GET | /agents | Gli operatori attivi (active=all per tutti). Anche /agents/{id}. |
| GET | /views | Le viste che la persona della chiave vede. Anche /views/{id}. |
| GET | /views/{id}/tickets | I ticket di una vista, nel suo ordine. |
| GET | /kb/articles | Articoli: q, status (published, draft, archived), section_id. |
| GET | /kb/articles/{id} | Un articolo con l’HTML. |
| POST | /kb/articles | Crea un articolo: title, html o body, section_id, status, tags, promoted. |
| PATCH | /kb/articles/{id} | Modifica un articolo. |
| GET | /kb/categories | Categorie e sezioni della base di conoscenza. |
| GET | /tags | I 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
| Parametro | Valori |
|---|---|
status | Una o più categorie separate da virgola: new,open,pending,hold,solved,closed |
status_id | Lo stato personalizzato |
priority | low, normal, high, urgent, oppure none |
type | question, incident, problem, task, oppure none |
channel | email, web, help_center, whatsapp, phone, chat, api, agent, rule |
assignee_id, group_id, requester_id, organization_id, brand_id, form_id | Un id, oppure none |
tag | Un tag |
q | Parole nell’oggetto o nei messaggi |
created_after, created_before, updated_after, updated_before | Date ISO 8601 |
sort | created_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
| Campo | Note |
|---|---|
subject | Oggetto, massimo 500 caratteri. Se manca si usa la prima riga del messaggio. |
requester_id | Il 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_id | new, open, pending, hold, solved — o l’id di uno stato personalizzato. Per risolvere servono assegnatario e campi obbligatori. |
priority, type | Come nei filtri; null per togliere. |
assignee_id, group_id, brand_id, form_id | Id esistenti (GET /agents, /groups); null per togliere. |
tags | Sostituisce i tag. In modifica anche add_tags e remove_tags. |
fields | I campi personalizzati: { "chiave_del_campo": valore }. |
due_at | Scadenza (per i ticket di tipo task). |
follower_ids, cc_ids | Operatori che seguono; clienti in copia. |
organization_id, requester_id, problem_id | Solo in modifica. |
I campi sconosciuti si rifiutano con 422: un errore di battitura non passa in silenzio. Una risposta tipo:
{
"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
# 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" }'// 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 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.
| Evento | Quando | Dentro data |
|---|---|---|
ticket.created | Un ticket nuovo, da qualunque canale. | ticket |
ticket.updated | Ogni modifica, commenti compresi. | ticket, changes (campo → { from, to }) |
ticket.solved | Un ticket passa a «Risolto». | ticket |
ticket.reopened | Un ticket risolto o chiuso torna aperto. | ticket |
comment.created | Una risposta o una nota su un ticket esistente (il primo messaggio sta in ticket.created). | ticket, comment |
customer.created | Un cliente nuovo, da qualunque strada (email, modulo, WhatsApp, a mano, importazione, API). | customer |
satisfaction.received | Il cliente valuta la richiesta. | ticket, satisfaction { rating, comment } |
webhook.test | Il 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.
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:
- leggi il corpo così com’è arrivato, come testo (non rifatto da un JSON già letto);
- rifai l’HMAC con il tuo segreto e confrontalo con
v1con un confronto a tempo costante; - scarta le consegne con un
tpiù 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.
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
});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
$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).
# 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.