{
  "openapi": "3.1.0",
  "info": {
    "title": "AgileChannels API",
    "version": "1.0.0",
    "description": "Router multicanale multi-tenant. Il pilota e' **email via Mailgun EU**.\n\n**Quattro regole che valgono per tutte le rotte, e non sono negoziabili.**\n\n1. **Lo scope lo dice il token, mai un header.** Non esiste `X-Tenant-Id`:\n   il chiamante presenta una chiave (`Authorization: Bearer agch_...`) e da\n   quella si ricavano `owner_scope`, `product_code`, `tenant_uid`,\n   `user_uid`. E' il difetto n.2 del seme `nexus-channel-ms`, dove il\n   tenant arrivava da `req.headers['x-tenant-id']` non verificato e, sui\n   webhook, dal **corpo** con ripiego a `tenant 1`. Se un chiamante manda\n   `X-Tenant-Id`, la richiesta **non** viene interpretata: l'header viene\n   ignorato e il tentativo registrato in `audit_log`.\n2. **`Idempotency-Key` e' obbligatoria su ogni invio.** Senza, 400. Con la\n   stessa chiave nello stesso scope si riceve **la stessa risposta** del\n   primo tentativo (200 + `duplicate: true`), non un secondo invio. La\n   protezione dura **30 giorni dal primo invio**, e vale anche dopo che il\n   messaggio e' stato consegnato: un ritentativo che arrivi quando la busta\n   e' gia' partita NON ne fa partire una seconda. Oltre i 30 giorni la\n   chiave torna libera. Il `status` della risposta 200 e' quello VERO del\n   primo messaggio (puo' essere `DELIVERED`), non la costante `QUEUED`.\n3. **`/v1` si congela alla pubblicazione.** Cambi solo additivi; il resto va\n   in `/v2`.\n4. **Le rotte marcate `x-interno: true` rispondono SOLO DA DENTRO.** Sono\n   quelle che GOVERNANO — le licenze (`/admin/licenze`), la console delle\n   chiavi (`/v1/console/chiavi`) — e `/salute`, che per scelta non chiede\n   nessuna credenziale. Non escono da nessun nome pubblico: sulla base\n   pubblica di questo contratto rispondono **404** con lo stesso corpo di una\n   rotta che non esiste, perche' un 403 direbbe al mondo che esistono.\n   Si raggiungono soltanto dalla **porta di governo sul WireGuard** di Agile,\n   che non ha un nome pubblico e non si dichiara qui: chi la deve chiamare ne\n   ha l'indirizzo per altra via. Per questo quelle rotte non dichiarano\n   nessun `servers:`. I client ufficiali ne portano comunque i metodi (le sei\n   della console): un prodotto Agile che gira dentro li usa, e chi li chiama\n   dalla base pubblica riceve il 404 qui descritto.\n\n**Gli stati non li decide chi chiede**: sono derivati dalla catena\n`message_event`, che e' append-only e ha `provider_event_id` unico perche'\ni webhook dei fornitori arrivano doppi e fuori ordine.\n"
  },
  "servers": [
    {
      "url": "https://api.channels.agile.software/v1"
    }
  ],
  "security": [
    {
      "chiaveDiProdotto": []
    }
  ],
  "tags": [
    {
      "name": "invio"
    },
    {
      "name": "operazioni"
    },
    {
      "name": "messaggi"
    },
    {
      "name": "configurazione"
    },
    {
      "name": "conformita"
    },
    {
      "name": "webhook"
    },
    {
      "name": "sessione"
    },
    {
      "name": "licenze"
    },
    {
      "name": "pratiche"
    }
  ],
  "paths": {
    "/openapi.yaml": {
      "get": {
        "tags": [
          "operazioni"
        ],
        "summary": "Questo contratto in YAML, senza le rotte che rispondono solo da dentro",
        "security": [],
        "description": "Il contratto pubblico, in YAML: **questo** documento, meno i percorsi\nmarcati `x-interno: true` (la quarta regola in testa). Nessuna\n`Authorization`: chi legge il contratto e' anche chi la chiave non l'ha\nancora.\n\nLo stesso documento si prende anche da `GET /openapi.yaml`, fuori da\n`/v1`.\n\nI nomi delle rotte di governo restano nella PROSA — la regola 4 le cita,\ne le descrizioni degli schemi pubblici nominano le porte che un prodotto\nAgile chiama da dentro: dice il vero (esistono e si chiamano da dentro) e\nnon e' un indirizzo da chiamare. Cio' che il documento non DICHIARA fra i\nsuoi `paths` non si puo' generare ne' provare da un pulsante.\n",
        "responses": {
          "200": {
            "description": "il contratto pubblico, in YAML",
            "content": {
              "application/yaml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "`CONTRATTO_ASSENTE` — il documento generato non c'e' sul disco di\nquesto processo (un `/opt` vecchio, o il minuto di un rilascio). NON\nun 404: «questo documento non esiste» e' la cosa sbagliata da dire a\nchi lo sta cercando, e un 503 dice «riprova», che e' il vero.\n"
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "tags": [
          "operazioni"
        ],
        "summary": "Questo contratto in JSON, senza le rotte che rispondono solo da dentro",
        "security": [],
        "description": "Lo stesso documento di `GET /v1/openapi.yaml`, in JSON — la forma che\nmangiano i generatori di client. Nessuna `Authorization`.\n\nLo stesso documento si prende anche da `GET /openapi.json`, fuori da\n`/v1`.\n",
        "responses": {
          "200": {
            "description": "il contratto pubblico, in JSON",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "503": {
            "description": "`CONTRATTO_ASSENTE` — il documento generato non c'e' sul disco di\nquesto processo. Vedi `GET /v1/openapi.yaml`.\n"
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "operazioni"
        ],
        "summary": "Se adesso ti mando un messaggio, lo prendi e lo porti?",
        "security": [],
        "description": "La sonda per chi integra: **200** `{\"stato\":\"ok\"}` se il servizio\naccetta e porta i messaggi, **503** `SERVIZIO_NON_PRONTO` se no. Non\nmanda niente e non costa un invio. Nessuna `Authorization`: una sonda\nche ha bisogno di una credenziale e' una sonda che un giorno smette di\nguardare.\n\nIl verdetto e' lo stesso della salute interna del servizio (il\ndatabase risponde, la coda avanza, lo schema e' quello che il codice\npretende); il corpo **non** ne dice le ragioni ne' i conti.\n\nLo stesso verdetto si prende anche da `GET /health`, fuori da `/v1`.\n",
        "responses": {
          "200": {
            "description": "il servizio accetta e porta i messaggi",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "stato"
                  ],
                  "properties": {
                    "stato": {
                      "type": "string",
                      "enum": [
                        "ok"
                      ]
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "`SERVIZIO_NON_PRONTO` — adesso un messaggio non verrebbe preso o\nnon verrebbe portato. Riprova fra poco; un messaggio gia' accettato\n(202) resta in coda e parte quando il servizio torna pronto.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          }
        }
      }
    },
    "/messages": {
      "post": {
        "tags": [
          "invio"
        ],
        "summary": "Accoda un messaggio (non lo spedisce: lo accoda, e lo dice)",
        "description": "Scrive `message` + `message_body` + `outbox` in **una sola\ntransazione**: o esistono tutti e tre o nessuno. Risponde **202** con\n`status: QUEUED` — non 200 «inviato», perche' a questo punto non e'\nstato inviato, e dire il contrario e' la bugia da cui nascono i\nmessaggi persi.\n\nRifiuta **prima** di accodare se: manca il consenso valido\n(`consent`), l'indirizzo e' in `suppression` (globale o di scope), o lo\nscope del token non possiede un canale di quel tipo dopo la cascata\nUSER -> TENANT -> PRODUCT -> PLATFORM.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaInvio"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accodato",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoAccodamento"
                }
              }
            }
          },
          "200": {
            "description": "**Doppione riconosciuto.** Stessa `Idempotency-Key` nello stesso\nscope: si torna l'esito del primo tentativo, senza accodare nulla.\nVale anche a consegna avvenuta (finestra di 30 giorni), e in quel\ncaso `status` e' lo stato VERO del primo messaggio.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoDoppione"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "description": "`SUPPRESSED` o `CONSENT_MISSING`: non e' un guasto nostro ed e'\ndefinitivo. Un indirizzo bruciato non si ritenta mai.\n\nL'elenco di chi non vuole essere contattato ha **due specie**: la\nSALUTE della casella (rimbalzi e segnalazioni di spam, valida per\ntutti i prodotti, perche' una casella morta brucia la reputazione\ndel dominio) e la VOLONTA' della persona **dentro un prodotto** —\nquella e' legata al prodotto e non si propaga a un altro: la stessa\npersona puo' essere soppressa per un prodotto e nuova per un altro.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "503": {
            "description": "`SUPPRESSION_NON_LEGGIBILE`: l'elenco della VOLONTA' della persona\nper questo prodotto esiste e in questo momento **non si puo'\nleggere** (manca la chiave con cui quelle impronte sono calcolate).\n\nNon e' `SUPPRESSED`, e la differenza e' quella che conta per chi\nintegra: «e' soppresso» e' un fatto definitivo e non si ritenta,\n«non posso sapere se e' soppresso» e' un nostro guasto temporaneo e\n**si ritenta**. Non si accoda niente: spedire col dubbio vorrebbe\ndire scrivere a chi potrebbe aver chiesto di non essere contattato.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "422": {
            "description": "`CATEGORIA_NON_VALIDA`: la `category` dichiarata non e' una delle\ncategorie del prodotto (`product_message_category`). Il corpo e' ben\nformato, ma il valore non esiste per questo prodotto.\n\n`ALLEGATO_NON_SUPPORTATO`: l'`allegato` chiesto e' di un tipo che il\ncanale non porta (SMS niente, Teams solo documenti per link). Il corpo e'\nben formato, ma quel canale non porta quel media: il `message` nomina\nil canale e cosa porta.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "429": {
            "description": "**Due limiti diversi, due codici diversi**, e la differenza conta\nperche' si aspetta l'uno o si chiede l'altro:\n\n- `LIMITE_FREQUENZA` — il tetto al MINUTO della chiave (porta\n  pubblica, P3). Si aspetta: `Retry-After` dice pochi secondi.\n- `QUOTA_MENSILE_SUPERATA` — il tetto MENSILE della chiave\n  (`monthly_quota`), speso dai soli messaggi accodati da QUESTA\n  chiave. Non si aspetta: `Retry-After` dice i secondi che mancano\n  al mese nuovo, e per alzarlo prima serve chi governa la chiave\n  (`POST /v1/console/chiavi`). Il corpo porta `quota_mensile`,\n  `usati_nel_mese` e `mese`, cosi' non si deve indovinare di quale\n  limite si parla.\n\nTutt'e due i rifiuti portano **`frenato_da`**: `chiave` se ha\nfrenato la riga della chiave, `licenza` se ha frenato il patto sotto\ncui la chiave apre. Senza, chi integra legge un 429 e non sa se\ntelefonare al commerciale o cambiare la propria chiave.\n\nNessuno dei due e' `QUOTA_SUPERATA`, che e' un'altra cosa ancora: il\nmuro GIORNALIERO del conto presso il fornitore, che non e' della\nchiave e non arriva da questa porta (lo scrive il ciclo sul\nmessaggio, che finisce `FAILED` con quel codice).\n",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer",
                  "minimum": 1
                },
                "description": "Secondi da attendere. Pochi per `LIMITE_FREQUENZA`, quelli che\nmancano al primo del mese per `QUOTA_MENSILE_SUPERATA`.\n"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          }
        }
      }
    },
    "/sandbox/messages": {
      "post": {
        "tags": [
          "invio"
        ],
        "summary": "Prova un invio in sandbox: consegnato da un fornitore FINTO",
        "description": "La stessa richiesta di `POST /v1/messages`, ma il messaggio non tocca\nnessun fornitore vero: il ciclo lo consegna scrivendo `DELIVERED` in\nlocale. Un cliente esterno la usa per provare chiave, idempotenza,\nlimite di frequenza e lettura dello stato, senza spendere un invio di\nproduzione.\n\n**E senza spendere la quota mensile della chiave**: la sandbox non\nconsegna a nessuno e non costa niente, quindi non consuma\n`monthly_quota`. Un collaudo che erodesse il tetto commerciale farebbe\nscoprire il `429 QUOTA_MENSILE_SUPERATA` prima del primo messaggio vero.\nIl freno che resta e' quello al minuto.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaInvio"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accodato in sandbox",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoSandbox"
                }
              }
            }
          },
          "200": {
            "description": "**Doppione riconosciuto.** Stessa `Idempotency-Key` nello stesso\nscope: si torna l'esito del primo tentativo, senza accodare nulla.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoDoppioneSandbox"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "429": {
            "description": "`LIMITE_FREQUENZA`: il limite di frequenza della chiave e' stato\nsuperato. `Retry-After:` dice quanti secondi attendere. Qui **non**\nsi prende `QUOTA_MENSILE_SUPERATA`: la sandbox non spende il tetto\nmensile.\n",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer",
                  "minimum": 1
                },
                "description": "secondi da attendere prima di riprovare"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/dlq": {
      "get": {
        "tags": [
          "messaggi"
        ],
        "summary": "I messaggi MORTI del proprio scope — la coda che non aveva finestra",
        "description": "`outbox.riaccoda()` esisteva, provata, e **non la chiamava nessuno**:\nla coda dei morti non aveva **ne' un'uscita ne' una finestra**. Un\nmessaggio che ci finiva restava li' per sempre e nessuno poteva sapere\nche c'era.\n\nNon torna i **corpi**: un elenco di guasti serve a capire che cosa e'\nandato storto e a riprovare, non a leggere i messaggi. Della controparte\ntorna l'**impronta**, che basta a riconoscere «di nuovo quello» senza\ntirare fuori i dati di nessuno.\n",
        "responses": {
          "200": {
            "description": "Elenco dei morti (tetto 200, con `limite_raggiunto`)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "limite_raggiunto": {
                      "type": "boolean"
                    },
                    "dead": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "outbox_id": {
                            "$ref": "#/components/schemas/Ulid"
                          },
                          "message_id": {
                            "$ref": "#/components/schemas/Ulid"
                          },
                          "channel_type": {
                            "type": "string"
                          },
                          "attempts": {
                            "type": "integer"
                          },
                          "peer_hash": {
                            "type": "string"
                          },
                          "last_error": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "dead_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/dlq/{id}/retry": {
      "post": {
        "tags": [
          "messaggi"
        ],
        "summary": "Riporta in coda un messaggio morto",
        "description": "**409 `INDIRIZZO_SOPPRESSO`** se il messaggio e' morto perche'\nl'indirizzo e' bruciato: riaccodarlo vorrebbe dire ritentare un\nindirizzo che non si deve ritentare, e un tentativo verso un indirizzo\nbruciato costa la reputazione del dominio **a tutti gli scope**, non\nsolo a chi lo chiede.\n\n**409 `CANALI_ESAURITI`** se il messaggio e' gia' partito su **ogni**\ncanale che la politica gli ammette — il caso normale dopo un ripiego\n(la chiamata voce muta, poi l'email). Riaccodarlo lo farebbe morire di\nnuovo al primo giro, e ogni tentativo aggiungerebbe un `FAILED` alla\ncatena, che e' **append-only**: la prova di consegna firmata di quel\nmessaggio crescerebbe di una riga per ogni tentativo che non poteva\nriuscire. La risposta porta `canali_gia_usati` (l'elenco dei canali che\nl'hanno gia' portato), perche' un rifiuto senza la via d'uscita e' un\nmuro: per riprovare serve un canale in piu' nella politica, o un altro\nindirizzo per il contatto.\n\n**409 `RIPIEGO_NON_AMMESSO`** se la politica della categoria ha\n`fallback_allowed = 0`: il messaggio ha **un canale solo** anche quando\n`channels` ne dichiara due, e il secondo non si prova **per scelta del\ncliente** (e' la clausola «se l'email non arriva, non telefonare»). Non\ne' lo stesso rifiuto di `CANALI_ESAURITI` e non si cura allo stesso\nmodo: aggiungere un canale alla politica non servirebbe a niente,\nperche' e' la colonna del ripiego a decidere. La risposta porta\n`canali_esclusi` (quelli che la clausola tiene fuori) accanto a\n`canali_gia_usati`.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Ulid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tornato in coda"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/messages/{id}": {
      "get": {
        "tags": [
          "messaggi"
        ],
        "summary": "Stato di un messaggio, con la sua catena di eventi",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Ulid"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Messaggio"
                }
              }
            },
            "description": "`status` e' **derivato** da `events`, non una colonna scritta a\nmano. Se `body_purged_at` e' valorizzato, il corpo non c'e' piu' —\nper conservazione o per richiesta GDPR — e resta l'indice: lo dice\ninvece di far finta che il messaggio non sia mai esistito.\n"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore",
            "description": "**404 anche quando il messaggio esiste ma e' di un altro scope**, e\nnon 403: un 403 confermerebbe che quell'id esiste, e l'esistenza di\nun id e' gia' un'informazione su un altro cliente. Chi non ha\ndiritto di vederlo non ha diritto di sapere che c'e'.\n\nLa visibilita' e' per **uguaglianza esatta** dello scope, **non**\nper cascata: la cascata risolve la configurazione (con che mittente\nparte un messaggio), non la proprieta' dei dati. Se una chiave di\nPRODUCT debba leggere i messaggi dei suoi tenant e' una decisione\ndi privacy del titolare, non un dettaglio di questa rotta.\n\n**Il corpo non c'e' in nessuna risposta.** Questa rotta dice «che\nfine ha fatto», non «che cosa diceva»: una porta di sola lettura\nche restituisse il testo sarebbe il modo piu' comodo di aggirare la\ncifratura.\n"
          }
        }
      }
    },
    "/messages/{id}/prova": {
      "get": {
        "tags": [
          "messaggi"
        ],
        "summary": "La PROVA DI CONSEGNA di un messaggio, firmata e verificabile da chi la riceve",
        "description": "Il referto risponde a «che fine ha fatto, chi lo attesta, e come faccio\na fidarmi». **Non** contiene il corpo, l'oggetto ne' l'indirizzo del\ndestinatario: di cio' che e' stato mandato c'e' solo l'**impronta**\n(`corpo.sha256`), che chi possiede il testo originale puo' rifare, e\nche non dice niente a chi non lo possiede.\n\n**La firma.** Il corpo della risposta e' il referto, e la firma sta\nnegli header — `X-AgileChannels-Timestamp` e\n`X-AgileChannels-Signature`, HMAC-SHA256 sulla stringa\n`<timestamp>|<corpo grezzo>`: **la stessa** dei webhook in uscita, con\n**lo stesso segreto** della tua iscrizione (`POST /v1/webhooks`). Il\ncodice che gia' verifichi i webhook verifica anche questo.\n\n**La seconda firma, Ed25519.** Accanto all'HMAC c'e' una firma\nasimmetrica che chi riceve il referto NON puo' rifare: nel corpo esce\n`firma_servizio` (`alg: Ed25519` e `kid`) e nell'header\n`X-AgileChannels-Signature-Ed25519` (esadecimale), sulla STESSA stringa\n`<timestamp>|<corpo grezzo>` — due stringhe diverse sarebbero due\nregole da sbagliare. La chiave pubblica si prende da\n`GET /v1/prova/chiavi-pubbliche` (pubblica, nessuna Authorization), e\nil `kid` dice QUALE chiedere. Serve a chi esibisce il referto a un\nTERZO — un assicuratore, un giudice, la controparte: l'HMAC lo puo'\nrifare chi tiene il segreto, questa firma no.\n\n**Verificando un referto, DISATTIVA la finestra anti-replay.** Sui\nwebhook una firma vecchia di piu' di 300 s si rifiuta, ed e' giusto:\nli' il timestamp difende da una consegna rigiocata. Un referto invece\ne' un **documento**, si conserva e si rilegge in una contestazione mesi\ndopo: applicargli la finestra vorrebbe dire buttare via la prova\nproprio quando serve. Il timestamp dice **quando il referto e' stato\nemesso**, e lo stesso istante e' dentro il corpo (`emesso_il`).\n\n**Senza un segreto il referto esce lo stesso**, con `firma: null` e\nsenza gli header — e accanto a `firma` c'e' `perche_senza_firma`, che\ndice QUALE dei tre motivi vale: «nessuna iscrizione attiva per questo\nscope» (si ripara iscrivendosi), «il caveau non ha risposto» (un guasto\nnostro, momentaneo: si ritenta) oppure «il servizio non sa leggere il\ncaveau» (un guasto di configurazione nostro). Negare il referto o\nfirmarlo con un segreto che non hai sarebbero due modi peggiori di dire\nla stessa cosa: si consegna comunque, e lo si dice in faccia.\n\n**Senza la chiave Ed25519 il referto esce lo stesso**, con\n`firma_servizio: null` e `perche_senza_firma_servizio` accanto, che dice\nQUALE dei quattro motivi vale — e sono quattro perche' hanno quattro\nrimedi diversi, e tre di loro NON dipendono da te: «la chiave di firma\nnon c'e' nel caveau» (non e' mai stata depositata: e' una riga che\ndobbiamo depositare noi, e nel frattempo NON serve che ritenti),\n«il caveau non ha risposto» (un guasto nostro e momentaneo: ritenta),\n«la chiave di firma nel caveau non e' una chiave Ed25519» (c'e', ma e'\nstata depositata o ruotata male: la rideposiamo noi) oppure «il servizio\nnon sa leggere il caveau» (un guasto di configurazione nostro). La\ndifferenza che conta per chi integra e' una sola: **solo il secondo si\nripara ritentando**. Mai una firma finta, mai un referto negato.\n\n**Che cosa dimostra**: due firme, due promesse diverse. L'HMAC\n(`firma`) dimostra che il documento non e' stato ritoccato **fra noi e\nte** — e chi tiene il segreto puo' rifarlo, quindi da sola non prova a\nun terzo che il documento venga da noi. L'Ed25519 (`firma_servizio`)\ndimostra che il documento **viene da noi e non lo puo' rifare nessun\naltro**: e' la firma che si esibisce a chi non si fida di te. Poi: che\nla conferma di lettura non e' stata scritta dopo ne' riscritta\n(`catena.integra`, il ricalcolo dell'intera catena a hash concatenato\ndello scope); che i due registri in cui la conferma vive — il filo\ndegli eventi e la catena — sono d'accordo\n(`conferma_di_lettura.concorde`).\n\n**Che cosa NON dimostra**: una marca temporale opponibile a un terzo\nche contesti anche la nostra buona fede. Gli istanti sono i nostri e\nquelli del fornitore; la catena prova che non sono cambiati DOPO, non\nche un terzo li abbia visti allora.\n\nOgni evento porta la sua **fonte**: `fornitore` (lo attesta il\nfornitore, con il suo `id_evento_fornitore`), `destinatario` (lo\nattesta chi ha ricevuto: e' la conferma di lettura), `noi`. «Consegnato\nsecondo il fornitore» e «letto secondo il destinatario» non valgono\nuguale in una contestazione, e un referto che li elencasse allo stesso\nmodo li farebbe sembrare la stessa cosa.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Ulid"
            }
          }
        ],
        "responses": {
          "200": {
            "headers": {
              "X-AgileChannels-Timestamp": {
                "schema": {
                  "type": "string"
                },
                "description": "L'istante (secondi UNIX) in cui il referto e' stato emesso, ed\ne' lo stesso di `emesso_il` nel corpo. Entra nella firma, come\nper i webhook. **Verificando un referto non applicare la\nfinestra anti-replay**: e' un documento che si conserva.\n"
              },
              "X-AgileChannels-Signature": {
                "schema": {
                  "type": "string"
                },
                "description": "HMAC-SHA256 di `<timestamp>|<corpo grezzo>` col segreto della\ntua iscrizione. Si verifica sui BYTE della risposta, non\nsull'oggetto riparsato: riserializzare il JSON in un'altra\nlingua cambia spazi e ordine delle chiavi, cioe' firma un\ndocumento diverso. Assente se non hai un'iscrizione attiva.\n"
              },
              "X-AgileChannels-Signature-Ed25519": {
                "schema": {
                  "type": "string"
                },
                "description": "Ed25519 (esadecimale) della STESSA stringa\n`<timestamp>|<corpo grezzo>`, firmata con la chiave privata del\nservizio. Si verifica con la chiave pubblica di\n`GET /v1/prova/chiavi-pubbliche` — il `kid` da chiedere sta in\n`firma_servizio.kid` nel corpo. Anche qui, e a maggior ragione,\n**niente finestra anti-replay**: un referto e' un documento che\nsi conserva. Assente se il caveau non ha servito la chiave.\n"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RefertoDiConsegna"
                }
              }
            },
            "description": "Il referto, firmato negli header se c'e' un'iscrizione attiva."
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore",
            "description": "**404 anche quando il messaggio esiste ma e' di un altro scope**:\nconfermare che un id esiste e' gia' un'informazione su un altro\ncliente.\n"
          },
          "429": {
            "$ref": "#/components/responses/Errore",
            "description": "`LIMITE_FREQUENZA` della porta pubblica, con `Retry-After`. Il\nreferto ricalcola tutta la catena dello scope: e' la richiesta piu'\ncara dell'API, e chi la chiede finisce nel registro d'accesso —\n«chi ha chiesto il referto e quando» e' parte della storia.\n"
          }
        }
      }
    },
    "/messages/{id_evento_inbound}/presenza": {
      "parameters": [
        {
          "name": "id_evento_inbound",
          "in": "path",
          "required": true,
          "description": "L'id della riga di `inbound_event` del messaggio RICEVUTO: quello che\nla notifica in arrivo del tuo webhook porta come id dell'evento.\n",
          "schema": {
            "$ref": "#/components/schemas/Ulid"
          }
        }
      ],
      "post": {
        "tags": [
          "messaggi"
        ],
        "summary": "Dichiara al fornitore «ho letto» e/o «sto scrivendo» su un messaggio ricevuto",
        "description": "Due manopole **indipendenti** in una chiamata sola: `letto` dichiara al\nfornitore che quel messaggio e' stato letto, `scrivendo` fa comparire al\nmittente il segno che qualcuno sta scrivendo. Chiederle insieme fa UNA\nrichiesta al fornitore — sono un atto solo — ma chi chiede solo\n`scrivendo` **non** dichiara letto niente: aggiungere il «letto» di\nnascosto direbbe a una persona una cosa che il cliente non ha detto.\n\n**E' un atto del cliente**: basta la chiave del tenant (o del\nprodotto). L'isolamento e' il solito e risponde **404 e non 403** su un\nevento che non e' tuo: un id che non e' tuo non esiste per te.\n\n**Non tocca niente in casa**: nessuna riga cambia, nessuno stato si\nmuove. L'atto e' fuori, presso il fornitore.\n\n**Il canale deve saperlo fare**, e lo dichiara l'adattatore\n(`capacita.presenza`): oggi WhatsApp. Su un canale che non la dichiara\nla risposta e' `409 PRESENZA_NON_SUPPORTATA` — una verita' stabile del\nfornitore, che non si ritenta — e **nessuna credenziale della linea\nviene nemmeno letta**.\n\n**Il corpo grezzo dell'evento puo' essere scaduto.** L'id del messaggio\npresso il fornitore (per WhatsApp il `wamid`) non sta in una colonna:\nvive nel corpo grezzo del webhook, che la conservazione cancella dopo i\ngiorni dichiarati. Su un evento con il corpo purgato la risposta e'\n`409 CORPO_SCADUTO` con la ragione in chiaro, non un 500 e non un 200\nche finge: non sappiamo piu' quale messaggio dichiarare.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "letto": {
                    "type": "boolean",
                    "description": "dichiara letto quel messaggio presso il fornitore"
                  },
                  "scrivendo": {
                    "type": "boolean",
                    "description": "mostra al mittente che qualcuno sta scrivendo"
                  }
                },
                "description": "Almeno una delle due a `true`, o la risposta e'\n`400 PRESENZA_VUOTA`: non si chiama il fornitore per non dire\nniente. Un valore che non e' un booleano e'\n`400 PRESENZA_NON_BOOLEANA`.\n"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "La presenza e' ARRIVATA al fornitore. **200 solo in questo caso**:\nse il fornitore ha detto no, o non ha risposto, la risposta e' 502 —\nun 200 lascerebbe credere che il mittente veda «letto» mentre\nguarda ancora una chat muta.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "$ref": "#/components/schemas/Ulid"
                    },
                    "channel_type": {
                      "type": "string"
                    },
                    "provider": {
                      "type": "string"
                    },
                    "letto": {
                      "type": "boolean",
                      "description": "la manopola come e' stata MANDATA"
                    },
                    "scrivendo": {
                      "type": "boolean",
                      "description": "la manopola come e' stata MANDATA"
                    },
                    "message_id_fornitore": {
                      "type": "string",
                      "description": "L'id del messaggio del contatto presso il fornitore (per\nWhatsApp il `wamid`): e' la tua conversazione, e serve a\nlegare questo 200 a un filo di assistenza.\n"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore",
            "description": "`PRESENZA_VUOTA` (nessuna manopola chiesta), `PRESENZA_NON_BOOLEANA`\n(un valore che non e' `true`/`false`) o `HANDLE_ASSENTE` (il caveau\nrisponde e non ha il nome del segreto di quella linea).\n"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore",
            "description": "`EVENTO_ASSENTE`, **anche quando l'evento esiste ma e' di un altro\nscope**: confermare che un id esiste e' gia' un'informazione su un\naltro cliente.\n"
          },
          "409": {
            "$ref": "#/components/responses/Errore",
            "description": "`PRESENZA_NON_SUPPORTATA` (il canale non la dichiara),\n`CORPO_SCADUTO` (la conservazione ha portato via il corpo grezzo),\n`CORPO_SENZA_MESSAGGIO` (quella riga non e' un messaggio ricevuto:\nuna ricevuta di consegna ha una riga sua) o `LINEA_ASSENTE` (la\nlinea su cui il messaggio e' arrivato non e' piu' attiva).\n"
          },
          "429": {
            "$ref": "#/components/responses/Errore",
            "description": "`LIMITE_FREQUENZA` della porta pubblica, con `Retry-After`."
          },
          "502": {
            "$ref": "#/components/responses/Errore",
            "description": "`PRESENZA_NON_ARRIVATA`: il fornitore ha rifiutato o non ha\nrisposto. Fail-closed, col suo motivo in `message`: un silenzio non\nsi presume andato a buon fine.\n"
          },
          "503": {
            "$ref": "#/components/responses/Errore",
            "description": "`PRESENZA_SENZA_PORTA` (questo processo non ha una porta verso i\nfornitori o un caveau) o `CAVEAU_ASSENTE` (il caveau non risponde:\ndire «il nome non c'e'» mentre il tunnel e' muto manderebbe a\ncercare il guasto dalla parte opposta).\n"
          }
        }
      }
    },
    "/prova/chiavi-pubbliche": {
      "get": {
        "tags": [
          "prova"
        ],
        "summary": "La chiave PUBBLICA con cui si verifica la seconda firma (Ed25519) del referto",
        "security": [],
        "x-chiamata-dal-prodotto": true,
        "description": "PUBBLICA, nessuna Authorization: una chiave pubblica e' pubblica per\ndefinizione, ed e' il punto. Torna l'elenco delle chiavi di firma del\nservizio, ciascuna con il suo `kid` (lo stesso che il referto porta in\n`firma_servizio.kid`), l'algoritmo `Ed25519` e la chiave pubblica in\nPEM. Se il caveau non risponde, l'elenco e' vuoto — e un referto\nemesso in quel momento ha gia' `firma_servizio: null` col suo motivo,\nquindi non c'e' niente da verificare con una chiave che non si e'\npotuta leggere.\n\nQUANDO L'ELENCO E' VUOTO la risposta porta ANCHE\n`perche_senza_firma_servizio`: lo stesso campo, con lo stesso nome e\ngli stessi valori, che il referto pubblica accanto a `firma_servizio`.\nServe a chi verifica da fuori: un elenco vuoto muto non distingue «la\nchiave non c'e' e non ci sara'» (ritentare e' inutile, quel referto non\nsi verifica) da «il caveau non ha risposto adesso» (basta richiedere la\nchiave piu' tardi), e i due rimedi sono opposti. Chi tiene in cache\nquesta risposta nella forma JWKS NON cachi un elenco vuoto che porta un\nmotivo di guasto momentaneo.\n",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChiaviPubbliche"
                }
              }
            }
          }
        }
      }
    },
    "/channels/config": {
      "get": {
        "tags": [
          "configurazione"
        ],
        "summary": "La configurazione EFFETTIVA dopo la cascata, e da quale grado viene",
        "description": "Torna anche `resolved_from`, cioe' il grado che ha vinto. Serve a\nrispondere a «perche' e' partita da questo mittente?» senza leggere il\ncodice.\n\n**E torna `fornitori`, il catalogo del canale chiesto**, nei due esiti:\nnel 200 e nel 404 `CANALE_NON_CONFIGURATO`. E' una LETTURA, e non c'e'\nnessun'altra: un applet che fa scegliere il fornitore al tenant lo\nchiede qui, prima di aver configurato qualunque cosa, invece di tentare\nuna `PUT` sbagliata di proposito per leggere il catalogo dal 400.\n\n**404 `CANALE_NON_CONFIGURATO`**: nessuna riga di quel canale per questo\nscope dopo la cascata. Non e' un guasto — e' il primo giorno di un\ntenant — e per questo porta `fornitori`.\n",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ConfigurazioneEffettiva"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      },
      "put": {
        "tags": [
          "configurazione"
        ],
        "summary": "Configura un canale al proprio grado",
        "description": "`secret_handle` e' il **nome** del segreto nel caveau, non il valore.\nUn valore in chiaro qui dentro viene **rifiutato** con 400\n`SECRET_IN_BODY`: e' il difetto n.3 del seme, dove i token stavano in\n`channel_configs.config` JSON.\n\n**Come avviene il rifiuto, perche' conta.** Non si prova a riconoscere\nun segreto — non si puo', un segreto e' una stringa come un'altra. Si\nriconosce il **manico**: `adp__<fornitore>__<chiave>`, la forma dei nomi\ndel caveau. Tutto cio' che non ha quella forma e' rifiutato, e un valore\nnon l'ha mai. In piu' viene rifiutato ogni campo il cui **nome** somigli\na un segreto (`*key*`, `*secret*`, `*token*`, `*password*`), perche' il\nmodo tipico di sbagliare e' aggiungere un campo, non riempire quello\ngiusto.\n\n**E poi il nome viene verificato al caveau**: se il caveau risponde e\nnon lo conosce, `400 HANDLE_ASSENTE`. Un manico scritto male passerebbe\nogni controllo di forma e fallirebbe **al primo invio vero**, in\nproduzione, quando nessuno guarda. Se questo processo non sa parlare col\ncaveau — o se il caveau **non risponde** — risponde `503\nCAVEAU_ASSENTE` invece di scrivere una configurazione non verificata:\n`secret_fp` esiste per confrontare senza leggere, e un'impronta\ninventata la renderebbe inutile.\n\n**«Non ha questo nome» e «non risponde» sono due guasti diversi**, e si\nriparano in due modi opposti: il primo e' una configurazione da\ncorreggere, il secondo e' un tunnel da guardare. La lettura del caveau\nha una scadenza — quella che la porta DICHIARA, o una corta che la rotta\nimpone quando la porta non ne dichiara nessuna — cosi' una `PUT` non\nresta appesa su un caveau muto, con la connessione del cliente aperta.\n\n**E la risposta dice se quel canale CONSEGNA**, non solo se e' scritto:\n`credenziali_ok` (e il suo alias storico `consegnabile`, con lo STESSO\nvalore) e `motivo`, gli stessi campi della sezione `canali` di\n`/salute`. La configurazione viene sondata **senza spedire niente**\n(il messaggio si compone e l'adattatore si ferma sulla porta di rete) e\n**la scrittura non si annulla** se il canale non consegna: configurare\nun canale prima di avere la credenziale giusta nel caveau e' l'ordine\nnormale delle cose. La differenza la si legge qui, con le mani sulla\ntastiera, invece che al primo messaggio perso.\n`credenziali_ok` pero' parla del CANALE e non di un contatto: se un\ncontatto e' raggiungibile ADESSO si chiede a parte, con\n`POST /v1/channels/reachability`.\n\n`404 CLIENTE_ASSENTE` se il prodotto che la chiave nomina **non ha\nl'anagrafica**: `channel_config.product_code` ha una chiave esterna\nverso `product`, e senza quella riga la linea non si scrive. Non e' un\nrefuso del corpo — il `product_code` viene dalla **chiave** — e la riga\nla scrive `POST /v1/clienti`, che vuole una chiave di piattaforma:\ncapita a chi ha ricevuto una chiave coniata a mano. Prima rispondeva\n`500 INTERNAL_ERROR` (misurato e curato il 7/10/2026).\n\n`409 MITTENTE_NEGATO_DAL_FORNITORE` se il fornitore **risponde e nega**\nche quella linea sia sua: `sender_identity` dice un numero o un bot, e\nil segreto sotto quel manico ne governa un altro (su Telegram `getMe`\nnomina l'id vero; su WhatsApp la sonda sta sull'id del numero). La riga\n**non si scrive**, e il verdetto del fornitore col suo motivo esce nel\ncampo `verifica` del rifiuto: «il mittente e' basilare, non puo'\nesserci un invio senza un chiaro mittente», e un mittente che il\nfornitore non riconosce non e' chiaro. Si corregge `sender_identity`, o\nil segreto nel caveau, e si richiama questa rotta. Se per quella stessa\nlinea esisteva gia' una riga `ACTIVE`, questo atto la **ferma**\n(`SUSPENDED`, e il rifiuto porta `linea_id` e `status`): la riapre il\ngoverno con `POST /v1/lines/{id}/riapri`, che la stessa domanda la fa di\nnuovo. **Il silenzio non e' un no**: un fornitore che non risponde, o\nche dell'identita' della linea non sa niente (Mailgun, BeeVoip), da'\n`verifica.provata: null` e la linea si accoglie — altrimenti un\nsinghiozzo del fornitore spegnerebbe ogni accoglienza.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaConfigurazione"
              }
            }
          }
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ConfigurazioneEffettiva"
                }
              }
            }
          },
          "400": {
            "description": "`MITTENTE_ASSENTE`: `sender_identity` manca, e' vuoto o e' di soli\nspazi. «Il mittente e' basilare: non puo' esserci un invio senza un\nchiaro mittente» (titolare, 8/10/2026, A147): il rifiuto arriva\nPRIMA del manico e del caveau, e il `message` dice la forma attesa\nper quel canale. Non esiste nessun mittente di ripiego.\n\n`MITTENTE_FORMA_SBAGLIATA`: `sender_identity` c'e' ma non e' il\nmittente di quella linea. Oggi vale per **WHATSAPP**, dove il\nmittente e' il `phone_number_id` (sole cifre) e non il numero di\ntelefono: una linea registrata con `+39...` si accoglieva e poi non\nriceveva niente per sempre, perche' la ricevuta di Meta nomina il\n`phone_number_id` (regia, 8/10/2026, A99). Una FORMA si guarda —\nsenza chiedere niente al fornitore, quindi anche quando il fornitore\ntace — mentre un'IDENTITA' si chiede, ed e' il\n`MITTENTE_NEGATO_DAL_FORNITORE` del 409. Per EMAIL, SMS, TELEGRAM e\nVOICE questo codice non esce: le loro forme sono regole del\nfornitore.\n\n`FORNITORE_SCONOSCIUTO`: il `provider` dichiarato non e' fra i\nfornitori registrati **per quel canale** — un nome che il registro\ndegli adattatori non conosce, oppure un nome che c'e' ma il cui\nadattatore e' di un altro canale (`telegram` su una linea `EMAIL`).\nIl rifiuto arriva PRIMA del manico, del caveau e della sonda, e\nporta il campo additivo `fornitori`: i nomi ammessi di quel canale,\ncioe' il catalogo che un applet di configurazione mostra al tenant\n(titolare, 9/10/2026, A218: «il tenant sceglie il fornitore,\ninserisce le sue credenziali, prova l'invio»). Prima di questa\nguardia la riga nasceva `ACTIVE` — e, se era la prima linea del\ncanale, con `priority` 0, cioe' davanti alla linea buona\nconfigurata dopo — e il primo messaggio vero MORIVA con «adattatore\nsconosciuto». Un `provider` **assente** non e' questo caso e resta\naccolto. E' lo stesso codice che `POST /v1/webhooks/{provider}` da'\ncol 404: la' il nome e' nel percorso, qui e' un campo del corpo.\n\n`SECRET_IN_BODY`: `secret_handle` non ha la forma di un NOME del\ncaveau (`adp__<fornitore>__<chiave>`), oppure il corpo porta un\ncampo il cui nome somiglia a un segreto.\n\n`ORDINE_NON_VALIDO`: la precedenza dei fornitori non e' una lista di\nnomi configurati per questo canale.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "description": "`CLIENTE_ASSENTE`: il prodotto che la chiave nomina non ha\nl'anagrafica.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "409": {
            "description": "`LINEA_RITIRATA`: esiste una linea di questo scope, per questo\nfornitore e con questo mittente, che non e' `ACTIVE` — questa porta\nnon la riaccende, la riapre il governo.\n\n`MITTENTE_NEGATO_DAL_FORNITORE`: il fornitore ha risposto e ha\nnegato che la linea sia sua. La riga non si scrive e il rifiuto\nporta `verifica` col motivo del fornitore.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/channels/providers": {
      "get": {
        "tags": [
          "configurazione"
        ],
        "summary": "Il catalogo dei fornitori del Postino, canale per canale",
        "description": "**IL CATALOGO DEL PRODOTTO**, cioe' l'elenco fra cui un applet di\nconfigurazione fa scegliere il tenant (titolare, 9/10/2026, A218: per\nogni canale c'e' un applet dove il tenant «sceglie il fornitore,\ninserisce le sue credenziali, prova l'invio»).\n\nE' uguale per ogni tenant e per ogni chiave: NON dice che cosa ha\nconfigurato chi chiama — quello lo dice `GET /v1/channels/config` — e\nper questo non e' filtrato sui canali della chiave. Una chiave che apre\nsolo `EMAIL` legge comunque che esiste il canale `SMS`, altrimenti\nl'applet di un canale nuovo non si potrebbe nemmeno disegnare.\n\n**Perche' esiste, visto che la `GET /channels/config` porta\n`fornitori`.** Quel campo da' i soli NOMI, di UN canale. Qui ci sono\ntutti i canali in un colpo e, per ogni fornitore, cio' che serve a\nscegliere: da quali `regioni` spedisce (un tenant NIS2 non mette i suoi\nmessaggi su una `US`), che cosa sa fare (`capacita`), se esce dal\nprocesso (`esce_dal_processo`: un fornitore FINTO non e' una scelta da\noffrire a chi compra) e soprattutto **quali credenziali il tenant deve\ndepositare** (`credenziale_del_tenant`). Senza quest'ultima, un applet\nche ha fatto scegliere il fornitore puo' solo mostrare una casella di\ntesto e sperare.\n\n**NIENTE SEGRETI, in nessuna forma.** `credenziale_del_tenant` dichiara\nla FORMA di cio' che va depositato, mai un valore e **nemmeno il nome\nnel caveau**: il manico lo compone il servizio, e una risposta che lo\npubblicasse insegnerebbe a chi chiama a indovinare i nomi degli altri.\n\n**400 `CANALE_NON_SUPPORTATO`** quando `channel_type` non e' un canale\ndel Postino: un rifiuto e non una lista vuota, perche' «quel canale non\nha fornitori» e «quel canale non esiste» sono due cose diverse e il\nrimedio non e' lo stesso.\n",
        "parameters": [
          {
            "name": "channel_type",
            "in": "query",
            "description": "Se presente, si risponde col solo catalogo di quel canale. I valori\nammessi sono quelli di `CanaleDelCatalogo.channel_type` (l'`ENUM`\ndella colonna `channel_config.channel_type`): un valore fuori da\nquell'insieme e' un 400 `CANALE_NON_SUPPORTATO`, non una lista\nvuota.\n",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CatalogoDeiFornitori"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "500": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/channels/reachability": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Se un CONTATTO e' raggiungibile adesso su un canale configurato, senza spedire",
        "description": "«Configurato» non e' «consegnabile», e «consegnabile» non e'\n«raggiungibile adesso». `credenziali_ok` parla del CANALE e vale per\nchiunque; questa rotta risponde della CONVERSAZIONE — questo contatto,\nadesso. Su WhatsApp Meta accetta il libero modulo solo entro 24 ore\ndall'ultimo messaggio del contatto: un contatto che non ci ha mai\nscritto, o che ci ha scritto piu' di 24 ore fa, resta irraggiungibile\npur con le credenziali a posto.\n\nLa risposta porta i DUE nomi INSIEME — `credenziali_ok` e\n`raggiungibile_adesso` — perche' sono due domande e la seconda non\ncancella la prima: `credenziali_ok: true` accanto a\n`raggiungibile_adesso: false` dice la cosa vera, e il `motivo` la\nspiega.\n\n**Il destinatario sta nel CORPO, mai in una query string**: un\nindirizzo in una query finisce nei log degli intermediari, e questo\nservizio non mette mai indirizzi in chiaro da nessuna parte. Il valore\nviene normalizzato e trasformato SUBITO in impronta; non viene scritto\nin nessuna tabella e non finisce in nessun log.\n\n**Non e' un invio**: non accoda in `outbox`, non scrive un `message`,\nnon spende il tetto mensile della chiave. Non serve `Idempotency-Key`\n(e una eventuale, mandata per abitudine, viene ignorata: non c'e'\nniente da rendere idempotente). Vale il freno di frequenza come per le\naltre letture.\n\nLo stesso codice di `GET /v1/channels/config` per un canale non\nconfigurato: **404 `CANALE_NON_CONFIGURATO`**.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaRaggiungibilita"
              }
            }
          }
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Raggiungibilita"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "429": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/channels/prova-invio": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Prova l'invio su UNA linea configurata, e aspetta l'esito del fornitore",
        "description": "La terza cosa dell'applet di un canale: il tenant sceglie il fornitore\n(`GET /v1/channels/providers`), deposita le sue credenziali\n(`PUT /v1/channels/config`) e **prova l'invio**. Le tre porte accanto\nrispondono ad altre domande e nessuna a questa: `POST /v1/messages`\nACCODA (torna un `id`, non un esito), `POST /v1/channels/reachability`\ndice se un CONTATTO e' raggiungibile adesso e dichiara lei stessa di non\nessere un invio, e il `credenziali_ok` della GET e della PUT e' una\nSENTINELLA che arriva al fornitore **senza spedire**.\n\n**E' un invio VERO, e quindi pesa.** Scrive un `message`, accoda, spende\nil tetto mensile della chiave e incontra le stesse guardie della rotta\nche spedisce (indirizzo soppresso, consenso revocato). Col tetto finito\nla risposta e' lo STESSO rifiuto di `POST /v1/messages`\n(`429 QUOTA_MENSILE_SUPERATA` coi suoi tre numeri), non un 200 di\ncortesia: una prova che non contasse sarebbe il modo di spendere il\ntetto a gratis, e un messaggio vero costa soldi al fornitore.\n\n**Il corpo lo compone il prodotto**, non chi chiama: un testo di prova\ncol `riferimento` del messaggio dentro, composto al canale di partenza.\nNon si manda un testo: chi prova una linea non deve inventarsi un\nmessaggio, e un testo libero qui sarebbe il modo di spedire senza\npassare dalla rotta che spedisce.\n\n**Prova UNA linea e non ripiega.** `sender_identity` dice QUALE linea di\nquel canale si prova; senza, e' quella che spedirebbe davvero (la fila\nla decide lo smistatore, non questa rotta). Il messaggio della prova non\ndichiara nessuna categoria, quindi nessuna politica lo puo' spostare su\nun altro canale: una prova che ripiegasse mostrerebbe un verde per una\nlinea che non ha spedito niente. La risposta dichiara il canale, il\n`provider` e la linea che hanno spedito **davvero**, letti dall'evento\n`SENT` del filo e non dalla configurazione riletta adesso.\n\n**L'esito e' quello del fornitore, e si aspetta.** `200` solo quando il\nfornitore ha accettato; se non e' partita, **409 `PROVA_NON_PARTITA`**\ncol `motivo` e con l'`id` del messaggio, che resta leggibile da\n`GET /v1/messages/{id}` con la sua prova di consegna. Un `200` su un\nmessaggio ancora in coda sarebbe la bugia peggiore di tutte.\n\n**Una credenziale che manca non e' un 500**: e'\n**422 `CREDENZIALE_ASSENTE`**, e dice QUALE CAMPO va depositato — col\nnome che il fornitore usa nella sua console, quello che l'applet\ndisegna. Non esce mai il nome del segreto nel caveau e non esce mai un\nvalore. Se manca il SECONDO deposito (il segreto con cui si verifica la\nfirma dei webhook) il messaggio parte e la risposta porta un **avviso**:\nla linea spedisce e non riceve, e la prova di consegna non arrivera' mai\nperche' la verifica e' fail-closed.\n\n**Il destinatario sta nel CORPO, mai in una query string**: un\nindirizzo in una query finisce nei log degli intermediari. Viene\nnormalizzato e trasformato subito in impronta.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaProvaInvio"
              }
            }
          }
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoProvaInvio"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "description": "`CANALE_NON_CONFIGURATO` (lo stesso codice di\n`GET /v1/channels/config`): nessuna riga di quel canale per questo\nscope dopo la cascata. `LINEA_ASSENTE`: il canale c'e' ma nessuna\ndelle sue linee e' utilizzabile adesso, e il `message` dice perche'\nognuna e' stata scartata (mittente assente o di forma sbagliata,\nconto sospeso, interruttore aperto, quota finita); con\n`sender_identity` vuol dire che quella linea non esiste ATTIVA in\nquesto scope.\n"
          },
          "409": {
            "description": "`PROVA_NON_PARTITA`: il messaggio e' nato e **non e' partito**, e il\n`motivo` lo dice (il no del fornitore, la politica, la quota del\nconto). `SUPPRESSED` / `CONSENT_MISSING`: le stesse due guardie di\n`POST /v1/messages`, perche' questo e' un invio vero — e per la\nstessa ragione questa porta puo' tornare\n`503 SUPPRESSION_NON_LEGGIBILE`.\n"
          },
          "422": {
            "description": "`CREDENZIALE_ASSENTE`: la linea non ha nel caveau la sua credenziale\ndi invio. `campi_mancanti` porta i nomi dei CAMPI da depositare,\nmai il nome del segreto nel caveau e mai un valore.\n"
          },
          "429": {
            "$ref": "#/components/responses/Errore"
          },
          "500": {
            "$ref": "#/components/responses/Errore"
          },
          "503": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/consents": {
      "post": {
        "tags": [
          "conformita"
        ],
        "summary": "Registra un consenso (contatto x canale x scope), con base giuridica",
        "description": "`legal_basis` e' **obbligatoria** e non ha valore per difetto: una riga\nche dice «si'» senza sapere perche' non si puo' difendere, e mettere\n`CONSENT` d'ufficio sarebbe la scelta comoda e anche la piu' facile da\ncontestare — il consenso ha requisiti che le altre basi non hanno.\n\n`address` entra **in chiaro e non resta in chiaro**: viene normalizzato,\nne viene fatta l'impronta, e nella tabella va solo quella. Chi registra\nun consenso l'indirizzo lo ha gia'.\n\n`proof` tiene **dove e quando** (un modulo, un IP, una spunta), **non il\ncontenuto**: se qui finisse il testo di una pagina, la conformita'\ndiventerebbe un archivio di contenuti.\n\n**`revoke: true` non cancella la riga**: scrive `revoked_at`. Un\nconsenso cancellato e' un consenso che non si puo' dimostrare di aver\nmai avuto, e la domanda che arriva e' «con che base giuridica hai\nscritto a questa persona in marzo», non «oggi hai il consenso?». Se non\nc'e' niente da revocare: **404**, perche' «revocato un consenso che non\nc'era» e' una bugia che poi qualcuno cita in un audit.\n\n`granted_at` lo puo' dichiarare il chiamante — il consenso e' stato dato\nsul suo modulo, magari ieri — ma **non nel futuro**.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "address"
                ],
                "properties": {
                  "address": {
                    "type": "string"
                  },
                  "channel_type": {
                    "type": "string",
                    "default": "EMAIL"
                  },
                  "legal_basis": {
                    "type": "string",
                    "enum": [
                      "CONSENT",
                      "CONTRACT",
                      "LEGITIMATE_INTEREST",
                      "LEGAL_OBLIGATION"
                    ]
                  },
                  "granted_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "proof": {
                    "type": "object",
                    "description": "dove e quando",
                    "non il contenuto": null
                  },
                  "revoke": {
                    "type": "boolean",
                    "default": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registrato"
          },
          "200": {
            "description": "Revocato"
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/suppressions": {
      "get": {
        "tags": [
          "conformita"
        ],
        "summary": "Gli indirizzi bruciati, globali e del proprio scope",
        "description": "Torna le **impronte**, mai gli indirizzi in chiaro. Il motivo e' la\nriga `owner_key IS NULL`: una soppressione **globale** nasce dal\nrimbalzo di un contatto che puo' appartenere a un **altro scope**, e\ndecifrarla per mostrarla qui sarebbe una fuga fra clienti dentro una\nfunzione di conformita'.\n\nPer la domanda vera — «questo indirizzo e' bruciato?» — si usa\n`?address=`: viene normalizzato, ne viene fatta l'impronta e si cerca\nquella. Il chiamante l'indirizzo lo ha gia', quindi non gli si dice\nniente che non sappia.\n\n**Due specie, e con `?address=` si guardano tutt'e due.** La SALUTE\ndella casella (rimbalzi, segnalazioni di spam, righe globali) vale per\ntutti i prodotti; la VOLONTA' della persona vale **dentro un prodotto**\ne non si propaga a un altro. Senza `?address=` l'elenco mostra le righe\nche questo scope vede, comprese quelle del suo prodotto.\n\nCon `?address=` questa rotta risponde alla domanda di chi sta per\nscrivere, e quindi vale lo stesso fail-closed della porta che accoda: se\nl'elenco della volonta' non si puo' leggere, invece di dire «non c'e'\nin elenco» torna `503 SUPPRESSION_NON_LEGGIBILE`.\n\nTetto di **500** righe con `limite_raggiunto`: dire «ce n'erano altri»\ne' meno utile di una pagina, ed e' molto meglio che restituire i primi\ncinquecento facendoli sembrare tutti.\n",
        "parameters": [
          {
            "name": "channel_type",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "EMAIL",
                "WHATSAPP",
                "SMS",
                "TELEGRAM",
                "VOICE",
                "TEAMS"
              ],
              "default": "EMAIL"
            }
          },
          {
            "name": "address",
            "in": "query",
            "description": "se presente, si cerca SOLO l'impronta di questo indirizzo",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "limite_raggiunto": {
                      "type": "boolean"
                    },
                    "suppressions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "contact_hash": {
                            "type": "string",
                            "minLength": 64,
                            "maxLength": 64
                          },
                          "channel_type": {
                            "type": "string"
                          },
                          "reason": {
                            "type": "string",
                            "enum": [
                              "HARD_BOUNCE",
                              "COMPLAINT",
                              "OPT_OUT",
                              "MANUAL"
                            ]
                          },
                          "scope": {
                            "type": "string",
                            "enum": [
                              "OWN",
                              "GLOBAL"
                            ],
                            "description": "GLOBAL = valida per tutti gli scope (owner_key NULL)"
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            },
            "description": "Elenco delle impronte bruciate"
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "503": {
            "description": "`SUPPRESSION_NON_LEGGIBILE` (solo con `?address=`): l'elenco della\nvolonta' della persona per questo prodotto non si puo' leggere\nadesso. Si ritenta: una risposta a meta' spacciata per completa\nmanderebbe chi chiede a scrivere a chi ha chiesto di non esserlo.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          }
        }
      }
    },
    "/uso": {
      "get": {
        "tags": [
          "operazioni"
        ],
        "summary": "La misura d'uso dello scope della chiave: consumo, quota, tetto al minuto",
        "description": "Delle sei cose che una API con chiavi deve avere — emissione, ambito,\nquota, limite al minuto, revoca e **misura d'uso** — questa e' la\nsesta. I contatori esistevano dal primo giorno (`usage_counter`:\natomici, per scope, giorno e canale) e non si potevano CHIEDERE: il\ncliente vedeva il primo numero in fattura, e un numero che arriva solo\nin fattura e' un numero che si contesta.\n\n**Lo scope e' quello della chiave, e non c'e' un parametro per\nchiederne un altro** — non perche' sia filtrato, ma perche' non esiste.\n\nTre cose che questa rotta NON fa, e ognuna e' un difetto noto:\n\n· **il conto non viene da `access_log`.** Quello e' il registro delle\n  RICHIESTE: contiene i 401, i 429 e i tentativi rifiutati, e una\n  richiesta rifiutata non e' un messaggio spedito. Il conto viene da\n  `usage_counter`, che si incrementa quando il fornitore ha accettato;\n· **non c'e' un secondo confine del mese.** Lo stesso che decide quando\n  un contatore diventa contabile (`quote.js`);\n· **non escono id di messaggi.** E' una rotta di conti: i messaggi\n  stanno in `GET /v1/messages/{id}`, coi suoi controlli.\n\n**`chiuso` non e' «mese passato».** Il ciclo chiude i contatori con un\nmargine di alcuni giorni, perche' gli eventi di consegna dei fornitori\narrivano in ritardo: dal 1° al 5 del mese il mese prima e' finito e i\nsuoi numeri possono ancora cambiare. `chiuso: true` vuol dire\n**contabile e immutabile**; fino a quel momento e' `false`, e il mese\nresta comunque nell'elenco — un elenco dei soli mesi chiusi farebbe\nsparire dallo storico il mese appena finito, e nessuno capirebbe\nperche'.\n\n**Il costo non c'e'.** `cost_micros` e' quello che il fornitore fa a\nnoi: i volumi sono del cliente, il prezzo lo dice AgileBilling.\n\n**La chiave passe-partout di prova qui non entra** (403\n`PASSEPARTOUT_SOLO_PROVA`): il consumo e' un dato commerciale anche\nquando lo scope e' quello di prova. Chi valuta il prodotto spedisce in\nsandbox; i conti li legge con una chiave sua.\n",
        "parameters": [
          {
            "name": "mesi",
            "in": "query",
            "description": "Quanti mesi di finestra, contando quello in corso. Di serie 3,\ntetto 12; un valore fuori misura non e' un errore — si usa il\ntetto, o la finestra di serie, e la risposta dichiara quale\nfinestra ha usato in `finestra_mesi`.\n",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 12,
              "default": 3
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MisuraDUso"
                }
              }
            },
            "description": "I conti dello scope della chiave."
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore",
            "description": "`KEY_REVOKED` per una chiave revocata o scaduta,\n`IP_NOT_ALLOWED` per un indirizzo non ammesso,\n`PASSEPARTOUT_SOLO_PROVA` per la chiave di prova dei servizi\nsovrani.\n"
          }
        }
      }
    },
    "/estratto": {
      "get": {
        "tags": [
          "operazioni"
        ],
        "summary": "L'estratto mensile dello scope della chiave: il conto di UN mese, per fatturare",
        "description": "`GET /v1/estratto?mese=YYYY-MM` — il conto di un mese SOLO, chiesto per\nnome, da mettere in fattura. Non e' `GET /v1/uso` con un parametro in\npiu': quella risponde a «quanto ho consumato?» con una finestra che\nscorre e la quota giornaliera; questa risponde a «che cosa metto in\nfattura per il mese 2026-09?».\n\nTre cose che qui si DICONO, e nessuna e' prosa:\n\n· **i conti per canale e per fornitore** — ogni canale porta i\n  `fornitori` che hanno DAVVERO spedito in quel mese su quel canale\n  (`[{provider, account_ref}]`, in ordine di primo invio), e `provider`\n  vale il primo di quella fila: e' l'ATTESTAZIONE del filo del\n  messaggio (`message_event`), non la configurazione di adesso;\n· **lo stato del mese** — `stato: 'aperto'` o `'chiuso'`. `chiuso`\n  vuol dire TUTTE le righe del mese bloccate (`usage_counter.locked_at`),\n  cioe' contabile e immutabile: non e' «mese passato», perche' il ciclo\n  chiude con un margine e un mese appena finito puo' ancora cambiare;\n· **la fonte del conto** — `fonte: 'usage_counter'`. L'estratto legge\n  dallo stesso contatore che il servizio ha sempre tenuto, non da una\n  seconda contabilita': la somma dell'estratto e' IDENTICA a quella del\n  contatore, e la prova lo controlla.\n\n**I NUMERI sono immutabili a mese chiuso, il fornitore e' un'attestazione.**\nI conti (`inviati`/`consegnati`/`falliti`) vengono da `usage_counter` e\na mese chiuso non si muovono piu'. Il `provider` e i `fornitori` sono\nl'attestazione del filo del messaggio, che scade con\n`retention_index_days`: su un mese molto vecchio gli eventi `SENT`\npossono essere stati potati, e allora quel canale porta `provider: null`\ne `fornitori: []`. E' l'opposto di prima: il fornitore di un mese chiuso\nnon cambia piu' quando qualcuno tocca una configurazione.\n\n**Non c'e' un prezzo.** `cost_micros` e' il costo che il fornitore fa a\nNOI; qui si contano i messaggi. Il listino e' del titolare, e un\nimporto inventato nel codice diventerebbe il listino vero al primo\ncliente.\n\n**Lo scope e' quello della chiave, e non c'e' un parametro per\nchiederne un altro.** Come `GET /v1/uso`, la passe-partout di prova qui\nnon entra (403 `PASSEPARTOUT_SOLO_PROVA`): il conto e' un dato\ncommerciale anche per lo scope di prova.\n",
        "parameters": [
          {
            "name": "mese",
            "in": "query",
            "required": true,
            "description": "Il mese di cui si chiede l'estratto, `YYYY-MM` (es. `2026-09`).\nAssente e' `MESE_MANCANTE`; presente ma malformato e'\n`MESE_NON_VALIDO`.\n",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]{4}-[0-9]{2}$"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EstrattoMensile"
                }
              }
            },
            "description": "L'estratto del mese, per canale e fornitore, con stato e fonte."
          },
          "400": {
            "$ref": "#/components/responses/Errore",
            "description": "`MESE_MANCANTE` se il parametro non c'e', `MESE_NON_VALIDO` se\nnon e' un mese vero.\n"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore",
            "description": "`KEY_REVOKED` per una chiave revocata o scaduta,\n`IP_NOT_ALLOWED` per un indirizzo non ammesso,\n`PASSEPARTOUT_SOLO_PROVA` per la chiave di prova dei servizi\nsovrani.\n"
          }
        }
      }
    },
    "/calls": {
      "get": {
        "tags": [
          "operazioni"
        ],
        "summary": "Le chiamate dello scope della chiave, dalla piu' recente",
        "description": "Una chiamata (WhatsApp Business Calling, voce) e' una riga per ogni\nevento del fornitore — il `connect` e il `terminate` della stessa\nchiamata sono due righe — e questa rotta le rilegge per il cliente\ndella chiave, dalla piu' recente, dentro una finestra facoltativa.\n\n**Lo scope e' quello della chiave, e non c'e' un parametro per\nchiederne un altro.** Con la chiave di un altro cliente l'elenco e'\nvuoto: non un 404, perche' un elenco non nomina nessuna risorsa.\n\n**La controparte esce SOLO come impronta** (`controparte_hash`, lo\n`sha256` del numero normalizzato: la stessa impronta di\n`GET /v1/suppressions`). Il numero in chiaro non esce mai, nemmeno\ncifrato: chi integra il numero lo ha gia', e con l'impronta lo\nriconosce senza che noi lo ripetiamo.\n\n`duration_s` `null` vuol dire «non ancora finita» (il `connect`), `0`\nvuol dire «finita in zero secondi»: sono due fatti diversi.\n\nLa passe-partout di prova qui non entra (403\n`PASSEPARTOUT_SOLO_PROVA`): le chiamate sono dati di un cliente.\n",
        "parameters": [
          {
            "name": "da",
            "in": "query",
            "required": false,
            "description": "Istante ISO 8601 da cui (compreso) si leggono le chiamate, sull'ora\nDEL FORNITORE (`occurred_at`). Illeggibile e' `DATA_NON_VALIDA`.\n",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "a",
            "in": "query",
            "required": false,
            "description": "Istante ISO 8601 fino a cui (compreso) si leggono le chiamate.\nIlleggibile e' `DATA_NON_VALIDA`.\n",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "limite",
            "in": "query",
            "required": false,
            "description": "Quante righe al massimo (tetto e valore di serie 200). Un valore\nfuori misura non e' un errore: si stringe fra 1 e il tetto, e\n`limite_raggiunto` dice se ce n'erano altre.\n",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Le chiamate dello scope della chiave (tetto 200, con `limite_raggiunto`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ElencoChiamate"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore",
            "description": "`DATA_NON_VALIDA` se `da` o `a` non sono un istante."
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore",
            "description": "`KEY_REVOKED` per una chiave revocata o scaduta,\n`IP_NOT_ALLOWED` per un indirizzo non ammesso,\n`PASSEPARTOUT_SOLO_PROVA` per la chiave di prova dei servizi\nsovrani.\n"
          }
        }
      }
    },
    "/privacy/export": {
      "post": {
        "tags": [
          "conformita"
        ],
        "summary": "Esporta tutto di un contatto (GDPR), in un comando",
        "description": "**Strada B**, quella che questo contratto dichiarava fin dall'inizio:\n**202 preso in carico**, il ciclo prepara un file **cifrato**,\nscaricabile **una volta** entro 24 ore, poi cancellato.\n\nNon la strada del JSON nella risposta, che sarebbe piu' semplice: un\nexport e' per definizione una copia dei dati **fuori** dalla\nconservazione che il resto del modulo fa rispettare, e nella risposta\nHTTP quella copia finisce nei log del proxy, nella cronologia del\nclient, in una cache — posti che non controlliamo e da cui non scade\nmai. Qui la copia ha una data di morte.\n\n**La chiave del file non la teniamo.** Si genera alla richiesta, si\nconsegna **una volta sola** in `download_key`, e nel database resta solo\nla sua impronta. Conseguenza voluta: **non possiamo riaprire il file che\nabbiamo scritto**. Se la chiave si perde, l'export si rifa'.\n\n**CHI PUO' CHIEDERLO.** La **tua** chiave, per i **tuoi** contatti. Dal\n17/9 al 12/10/2026 questa rotta rispondeva 503 apposta, perche' «chi\npuo' chiedere un export in chiaro» non e' un dettaglio tecnico: la\nchiave di prodotto la ha l'integratore, non l'interessato. Il\n12/10/2026 il titolare ha deciso, e la regola e' questa:\n\n- **ogni cliente con la sua chiave**: nessun secondo fattore, nessuna\n  autorizzazione da chiedere a noi. Il titolare del trattamento di quei\n  dati sei tu, e la chiave e' tua;\n- **solo per i suoi contatti**: l'export raccoglie messaggi, consensi,\n  soppressioni e indirizzo con un confronto **esatto** sullo scope\n  della chiave. Nessuna cascata: con la chiave di un prodotto non si\n  esportano i contatti di un altro, e una chiave di **piattaforma** —\n  che non e' di nessun cliente — riceve **403 `EXPORT_SOLO_DEL_CLIENTE`**\n  invece di un archivio vuoto che si leggerebbe come «non abbiamo\n  niente di questa persona»;\n- **ogni export nel registro**: tre righe in `audit_log`, nella catena\n  firmata che puoi verificare tu stesso — `privacy.export.request`\n  (questa chiamata), `privacy.export.prepared` (quando il file e'\n  pronto), `privacy.export.download` (quando i byte escono). Se il\n  registro non si scrive, il file **non parte**.\n\nResta **503 `AUTORIZZAZIONE_ASSENTE`** su un servizio a cui non e'\nconfigurata la cartella dove atterrano i file: e' una scelta di\nschieramento, non una decisione sospesa.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "address"
                ],
                "properties": {
                  "address": {
                    "type": "string"
                  },
                  "channel_type": {
                    "type": "string",
                    "default": "EMAIL"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Preso in carico; la chiave viaggia una volta sola"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          },
          "503": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/privacy/export/{id}": {
      "get": {
        "tags": [
          "conformita"
        ],
        "summary": "Scarica l'export — UNA volta, poi il file sparisce",
        "description": "**202** se non e' ancora pronto (non 404: «non ancora» e «non esiste»\nsono due risposte diverse, e chi integra deve poter ritentare invece di\nricominciare). **410** se e' gia' stato scaricato o e' scaduto: il file\nnon esiste piu', ed e' il senso della consegna una volta sola.\n\nLo scaricamento si marca **prima** di mandare i byte e in modo atomico:\nmarcarlo dopo vorrebbe dire che due richieste contemporanee ottengono il\nfile tutt'e due, e «una volta sola» sarebbe una frase.\n\nI byte sono **cifrati** con la chiave consegnata alla richiesta: questo\nservizio spedisce un contenuto che non sa leggere.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Ulid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Il file cifrato"
          },
          "202": {
            "description": "Non ancora pronto"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "410": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/privacy/erase": {
      "post": {
        "tags": [
          "conformita"
        ],
        "summary": "Cancella un contatto — riscrittura del corpo, non DELETE silenzioso",
        "description": "La cancellazione **riscrive** `message_body` e lascia un evento in\n`audit_log`: l'indice resta, il contenuto no. Un `DELETE` muto\nrenderebbe impossibile dimostrare di aver cancellato.\n\n**Due guardie**, perche' l'operazione e' irreversibile e agisce su\nun'impronta — e un'impronta sbagliata cancella il contatto sbagliato:\n\n1. **`expected_messages` e' obbligatorio**: chi chiede dichiara quanti\n   messaggi si aspetta di cancellare. Se il conto non torna, **409** con\n   **tutt'e due i numeri** e non si cancella niente. «Non combacia»\n   senza dire quanti ce ne sono costringerebbe a indovinare su una rotta\n   che cancella;\n2. **finestra di ripensamento**: la richiesta si **deposita** (202) e la\n   esegue il ciclo dopo **72 ore** (il valore e' una decisione del\n   titolare del 12/10/2026, direttiva A355, e si legge dalla manopola\n   `AGILECHANNELS_RIPENSAMENTO_ORE`); fino a quel momento\n   `DELETE /v1/privacy/erase/{id}` la annulla, e una richiesta annullata\n   dentro la finestra non cancella **niente**. Il Regolamento da' un\n   mese, quindi la finestra non costa niente a nessuno e vale un errore\n   di integrazione in meno.\n\nUna sola richiesta **viva** per contatto e canale nello stesso scope\n(409 `RICHIESTA_GIA_IN_CORSO`): due in volo vorrebbero dire due\nesecuzioni, e la seconda troverebbe zero messaggi e sembrerebbe un\nguasto.\n\nAll'esecuzione il conteggio si **rifa'**: fra la richiesta e\nl'esecuzione passano tre giorni, e cancellare solo i messaggi vecchi\nsarebbe una cancellazione a meta' che nessuno ha chiesto.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "address",
                  "expected_messages"
                ],
                "properties": {
                  "address": {
                    "type": "string"
                  },
                  "channel_type": {
                    "type": "string",
                    "default": "EMAIL"
                  },
                  "expected_messages": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "quanti messaggi ti aspetti di cancellare; se non torna, 409"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Preso in carico; si esegue dopo la finestra"
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/privacy/erase/{id}": {
      "delete": {
        "tags": [
          "conformita"
        ],
        "summary": "Annulla una cancellazione, finche' la finestra non e' passata",
        "description": "**409 `GIA_ESEGUITA`** se la finestra e' passata: i corpi non ci sono\npiu', e rispondere «annullato» sarebbe una bugia consolatoria.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Ulid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Annullata"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/webhooks": {
      "post": {
        "tags": [
          "webhook"
        ],
        "summary": "Iscrive (o aggiorna) l'URL a cui il Postino notifica gli stati",
        "description": "Il prodotto registra il proprio punto di consegna: quando un suo\nmessaggio cambia stato, il Postino chiama questo URL. La consegna è\n**firmata HMAC-SHA256 sul corpo grezzo**, con finestra temporale, e\nviene ritentata con attesa crescente; ogni tentativo resta nel registro\n(`webhook_delivery`).\n\n**Come si firma.** Ogni consegna porta due intestazioni:\n`X-AgileChannels-Timestamp` (epoch secondi) e\n`X-AgileChannels-Signature` (HMAC-SHA256 in esadecimale), calcolata su\n`<timestamp>|<corpo grezzo>` col segreto. Il corpo di uno stato è\n`{\"id\": \"<messaggio>\", \"status\": \"<stato>\", \"occurred_at\": \"<ISO>\"}`.\nIl ricevitore ricalcola l'HMAC, confronta a tempo costante e scarta i\ntimestamp fuori da ±300 s (anti-replay).\n\n**La ragione di un `FAILED`.** Il corpo di un `FAILED` porta un quarto\ncampo, `last_error`: la ragione per cui il messaggio è morto, la stessa\nche `GET /v1/messages/{id}` espone in `last_error`. Gli altri stati —\n`SENT` compreso — restano a tre campi: il campo c'è solo sul `FAILED`,\nnon «sempre presente ma `null`», perché i clienti di ieri leggono i tre\ncampi e un quarto campo `null` sarebbe comunque una forma che non\nconoscono. `last_error` è una stringa (al più 500 caratteri); il corpo\nresta JSON e la firma copre il corpo grezzo, quindi chi parsa il JSON\nnon cambia una riga.\n\n**Stati notificati** (il valore di `status` nel corpo, e l'elemento di\n`events` a cui ci si iscrive): `SENT` (accettato dal fornitore),\n`DELIVERED` (consegnato), `READ` (letto), `FAILED` (fallito),\n`SUPPRESSED` (rimbalzato/rifiutato). Il «ripiegato» del contratto C03\n— un cambio di fornitore — sarà un evento del futuro smistatore e oggi\nnon è ancora emesso dal pilota email.\n\n**Messaggi in arrivo.** C'è un evento che NON è un cambio di stato:\n`messaggio_in_arrivo`, il messaggio che ENTRA su una linea comprata dal\ncliente. Per questo evento il corpo non è quello degli stati ma un fatto\nnuovo, con nove campi:\n\n`{\"tipo\": \"messaggio_in_arrivo\", \"id\": \"<riga dell'evento>\",\n\"channel_type\": \"<canale>\", \"from\": \"<identita' del mittente>\",\n\"to\": \"<linea entrata, o null>\", \"body\": \"<testo>\",\n\"thread_id\": \"<filo del nostro messaggio, o null>\",\n\"contact_id\": \"<contatto stabile del mittente, o null>\",\n\"occurred_at\": \"<ISO>\", \"media\": <blocco media, o null>}`.\n\n`id` è l'identificatore della RIGA dell'evento in arrivo: il fornitore\nnon può ripeterlo, quindi un secondo tentativo dello stesso evento non\nproduce una seconda notifica. `from` è l'identità **attestata dal\ncanale** (il numero su WhatsApp, l'id su Telegram), mai il nome\nvisualizzato. `to` è la linea su cui il messaggio è entrato quando il\nfornitore la dichiara, e `null` quando non lo dice: non si indovina.\n`body` è il testo. `thread_id` è il filo del NOSTRO messaggio quando\nl'evento si è risolto per risposta o per riferimento, e `null`\naltrimenti. `contact_id` è il **contatto stabile** di chi scrive: il\ncontatto del CLIENTE che incarna la coppia (linea, mittente). Due\nmessaggi dello STESSO mittente sulla STESSA linea portano lo STESSO\n`contact_id`, anche a giorni di distanza e senza che il cliente abbia\nmai risposto — è così che una conversazione si appende a una persona\ninvece che a un numero. La riga nasce al primo messaggio in arrivo se\nnon c'era, e riusa quella che l'invio aveva già scritto; lo stesso\nnumero su due linee di due clienti sono due `contact_id` diversi, e\nnessuno dei due vede l'altro. `null` quando il canale non attesta il\nmittente o lo scope non ha una chiave viva. La firma è la STESSA degli\nstati, sullo stesso corpo grezzo.\n\n**Il media in arrivo.** Un vocale, una foto o un documento non arrivano\nmuti e non arrivano come byte. `media` è `null` quando il messaggio è\nsolo testo — e allora è `body` a portare il testo. Quando invece il\nmessaggio È un media, `body` è `null` e `media` porta il fatto:\n`{\"tipo\": \"vocale\", \"voce\": true, \"mime\": \"audio/ogg; codecs=opus\",\n\"nome\": null, \"caption\": null, \"url\": \"https://<la-nostra-base>/v1/media/m1....\",\n\"scade_il\": \"<ISO>\"}`.\n\n`tipo` è il tipo **dichiarato dal canale** (`vocale`, `audio`,\n`immagine`, `documento`, `video`), portato così com'è: `voce` distingue\nun vocale da un audio allegato, un'immagine da un documento. `mime` e\n`nome` sono quelli che il fornitore dichiara (il `nome` c'è per i\ndocumenti, `null` altrove). `caption` è la didascalia, e non si perde:\nè **l'unica cosa che si legge senza scaricare niente**. `url` è un\nindirizzo **NOSTRO, firmato e a scadenza** — la rotta `GET\n/media/{gettone}` qui sotto — e nel corpo NON passano mai i byte del\nfornitore: si spedisce un riferimento, il file lo prende chi lo vuole.\n`scade_il` è la scadenza dell'indirizzo in ISO (24 ore). Chi ha la firma\ndel webhook riceve l'indirizzo; chi non ce l'ha non scarica niente, e un\nindirizzo vecchio non è una chiave lasciata in giro per sempre.\n\nUn messaggio in arrivo si accoda anche quando il processo non ha un\nconnettore di persona: per un cliente terzo il webhook è l'unica\nconsegna che conta. Un cliente sospeso o cessato non riceve niente.\n\nLo scope è quello della chiave: un evento si notifica SOLO all'endpoint\ndello stesso scope del messaggio, senza cascata.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaWebhook"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Iscritto (o aggiornato, se lo stesso URL c'era già)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpoint"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "503": {
            "$ref": "#/components/responses/Errore"
          }
        }
      },
      "get": {
        "tags": [
          "webhook"
        ],
        "summary": "I propri punti di consegna",
        "description": "Torna l'**impronta** del segreto (`secret_fp`), mai il valore né il\nmanico completo: chi legge sa che l'URL esiste, non il segreto con cui\nsi firma.\n",
        "responses": {
          "200": {
            "description": "L'elenco dei propri endpoint",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "endpoints": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookEndpoint"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/webhooks/mailgun": {
      "post": {
        "tags": [
          "webhook"
        ],
        "summary": "Eventi da Mailgun — la porta dedicata, firma `timestamp+token`",
        "security": [],
        "description": "La porta d'ingresso **dedicata** di Mailgun, accanto a quella unica\n`/webhooks/{provider}`. È l'entrata più vecchia: verifica la firma di\nMailgun (`timestamp + token`, dentro il JSON) con\n`entrata.riceviMailgun` e scrive in `inbound_event` prima di decidere.\n\nLa porta unica non la raggiunge mai per `mailgun`, perché express serve\nla rotta più specifica — e questa è registrata prima della generica.\nPer questo esiste dichiarata qui e non dentro il segnaposto.\n",
        "responses": {
          "204": {
            "description": "Preso in carico (o riconosciuto come doppione)"
          },
          "401": {
            "description": "Firma non valida — registrato come `BAD_SIGNATURE`"
          },
          "422": {
            "description": "Scope non risolvibile — registrato come `UNKNOWN_SCOPE`"
          }
        }
      }
    },
    "/webhooks/mailgun-entrata": {
      "post": {
        "tags": [
          "webhook"
        ],
        "summary": "Una mail a licenze@ diventa una pratica col numero (A361) — oggi SPENTA",
        "security": [],
        "description": "**L'entrata della posta dei Servizi Sovrani.** Una route di Mailgun\nconsegna qui la mail arrivata al recapito unico delle licenze, e la mail\ndiventa una **pratica** col numero della famiglia (`SS-AAAA-NNNNN`), nel\n**registro dei moduli** — `origine: EMAIL`.\n\nIl corpo e' il modulo che manda Mailgun\n(`application/x-www-form-urlencoded`: `recipient`, `sender`, `from`,\n`subject`, `body-plain`, `stripped-text`, `Message-Id`) con i tre campi\ndella firma `timestamp`, `token`, `signature` **al primo livello** — non\nannidati come in `/webhooks/mailgun`. Lo schema di firma e' lo stesso\n(HMAC-SHA256 su `timestamp + token`, con la chiave di firma HTTP\ndell'account), cambia solo dove stanno i campi.\n\n**Una mail = una pratica. Mail doppia = la stessa pratica**, col suo\nnumero: l'identita' e' il `Message-Id`, quindi un ritentativo di Mailgun\n(o una seconda consegna del gruppo che gira `licenze@`) non apre un\nsecondo numero. La risposta porta `numero_pratica`.\n\n**Oggi questa porta e' SPENTA** e risponde `503 ENTRATA_ASSENTE`:\nl'interruttore e' `AGILECHANNELS_POSTA_IN_ARRIVO=accesa`, e si accende\nsolo quando il titolare decide il sottodominio con gli MX (attesa #135).\n`GET /salute` lo dice in `posta_in_arrivo.accesa`, senza chiave e solo\nda dentro (regola 4).\n\nUna mail **con un allegato** consegnata come `multipart/form-data` resta\nun `415` `ENTRATA_CON_ALLEGATO`: questa porta non legge il multipart, e\nnon lo fa passare per una firma sbagliata. **Gli allegati arrivano per\nun'altra strada**, decisa il 13/10/2026: una route con `store(notify=)`\nmanda un corpo `application/x-www-form-urlencoded` — quello che questa\nporta legge — con `message-url` e `attachments` (una **stringa** che\ncontiene il JSON dei metadati, uno per allegato: `url`, `content-type`,\n`name`, `size`). A firma verificata il Postino va a prendere i BYTE\ndalla copia, e di ogni allegato **tiene solo nome, tipo e peso**: un\ndocumento di un ente non si conserva qui. Gli allegati **non fermano la\npratica**, e quello che non si e' potuto prendere si dice.\n",
        "responses": {
          "200": {
            "description": "La pratica c'e', col suo numero — anche per la mail doppia\n(`gia_registrata: true`). Quando la mail **dichiara allegati**, la\nrisposta porta anche `allegati` con i CONTI (`presi`, `scartati`) e,\nse non si e' potuto guardare, il `motivo`: i conti e non i nomi dei\nfile, perche' i nomi di quello che un ente ci manda non si scrivono\nnel giornale di un fornitore. Gli allegati non fermano la pratica.\n"
          },
          "401": {
            "description": "Firma non valida — registrato come `BAD_SIGNATURE`"
          },
          "415": {
            "description": "Corpo `multipart/form-data` (mail con allegato) o tipo ignoto —\n`ENTRATA_CON_ALLEGATO`\n"
          },
          "422": {
            "description": "La mail non porta un mittente che abbia la forma di un indirizzo: non\nc'e' nessun recapito a cui tornare col numero\n"
          },
          "429": {
            "description": "Troppe richieste da questo indirizzo (`Retry-After`)"
          },
          "503": {
            "description": "`ENTRATA_ASSENTE` — l'interruttore e' spento (lo stato di oggi); o\n`CAVEAU_ASSENTE` — il segreto di firma non si legge\n"
          }
        }
      }
    },
    "/webhooks/{provider}": {
      "x-fornitori-vivi": [
        "mailgun",
        "telegram",
        "whatsapp",
        "beevoip",
        "twiliosms",
        "vonage",
        "twilio",
        "infobip",
        "360dialog"
      ],
      "get": {
        "tags": [
          "webhook"
        ],
        "summary": "La verifica del webhook (Meta) — il challenge, in chiaro, a chi ha il gettone",
        "security": [],
        "description": "**Non autenticata con la chiave di prodotto, e non aperta**: chi la\nraggiunge e' Meta, che prima di salvare un webhook non ci crede e lo\n**verifica**. Manda `hub.mode=subscribe`, `hub.verify_token` e\n`hub.challenge`, e pretende un `200` col corpo **esattamente uguale** al\nchallenge. Senza questa porta la `GET` finiva nel 404 di casa, la\nconsole di Meta non riusciva a iscrivere il webhook e il canale WhatsApp\nnon esisteva — in silenzio, col banco verde.\n\nSi risponde col challenge **solo** se `hub.verify_token` e' quello\nconfigurato (`AGILECHANNELS_WHATSAPP_VERIFY_TOKEN`), confrontato a\n**tempo costante**. Il gettone e' un segreto di **fornitore** e si legge\ndal **caveau** per **nome** (il manico `<prefisso>__whatsapp__verify_token`):\nl'ambiente ne porta solo il **NOME**, mai il valore.\nGettone sbagliato,\nassente, o non configurato: `403`\n**senza eco** — se il challenge tornasse a chi non ha la credenziale, la\nverifica non verificherebbe nulla. Il valore del gettone non entra mai\nnel contratto ne' nel repo: qui c'e' solo il **nome** della variabile.\n\nE' una `GET` che non scrive niente, e non tocca il database.\n",
        "parameters": [
          {
            "name": "provider",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "mailgun",
                "telegram",
                "whatsapp",
                "beevoip",
                "twiliosms",
                "vonage",
                "twilio",
                "infobip",
                "360dialog"
              ]
            }
          },
          {
            "name": "hub.mode",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "subscribe"
              ]
            }
          },
          {
            "name": "hub.verify_token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Il gettone condiviso con la console di Meta."
          },
          {
            "name": "hub.challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "La stringa che Meta pretende indietro, tale e quale."
          }
        ],
        "responses": {
          "200": {
            "description": "Gettone giusto: il corpo e' **esattamente** il `hub.challenge`\nricevuto, in `text/plain`.\n",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "description": "Gettone sbagliato, assente, o non configurato sul servizio: nessuna\nrisposta, nessun eco del challenge.\n"
          }
        }
      },
      "post": {
        "tags": [
          "webhook"
        ],
        "summary": "Eventi dal fornitore — firma verificata, replay rifiutato",
        "security": [],
        "description": "**Non autenticata con la chiave di prodotto, ma NON aperta**: la firma\ndel fornitore e' verificata **sul corpo grezzo** (`express.json` con\n`verify`, perche' in `nexus-ticket-ms` la firma era calcolata su\n`JSON.stringify(req.body)` e quindi non era il corpo firmato) e con\nfinestra anti-replay sul timestamp.\n\nLo scope **non** si legge dal corpo: si risolve dal\n`provider_message_id` o dall'identita' del mittente. Ogni evento entra\nin `inbound_event` con `provider_event_id` unico e l'esito della\nverifica **registrato**, anche quando e' negativo.\n\n**I due fornitori SMS** (E05, 11/10/2026). `twiliosms` posta un\n**modulo** (`application/x-www-form-urlencoded`) firmato in\n`X-Twilio-Signature` (HMAC-SHA1 dell'URL piu' i parametri ordinati) con\nl'`authToken` della credenziale della LINEA; `vonage` posta JSON firmato\ncon un JWT HS256 in `Authorization` col `signature_secret` della linea.\nLa linea e' il destinatario di un arrivo (`To`/`to`) e il mittente di una\nricevuta (`From`/`from`), e deve essere una `sender_identity` accesa di\nQUEL fornitore. Un SMS che dice soltanto `STOP` (o `STOPALL`,\n`UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, o che Twilio marca\n`OptOutType=STOP`) e' un **opt-out**: il numero entra fra le soppressioni\n`OPT_OUT` dello scope, l'evento chiude `204` e non va alla persona.\n\n**I tre fornitori WhatsApp non-Meta** (E13/E19, 11/10/2026). `twilio`\nposta un **modulo** firmato come `twiliosms` (`X-Twilio-Signature` con\nl'`authToken` della credenziale della LINEA), con `From`/`To` nella\nforma `whatsapp:+<numero>`; `infobip` posta un lotto JSON `results[]`\ne non firma: rimanda in `Authorization` il segreto scelto dal tenant\n(`notify_authorization` del catalogo), confrontato a tempo costante;\n`360dialog` posta il corpo di Meta su una porta protetta da\n`Authorization: Basic` (`webhook_basic_auth` del catalogo; la firma\nHMAC di Meta resta accettata). La linea e' il destinatario di un arrivo\n(per 360dialog il `phone_number_id`) e deve essere una\n`sender_identity` accesa di QUEL fornitore: lo stesso numero su un\naltro fornitore non conta, ne' per la firma ne' per lo scope. Le\nricevute di Infobip non dicono la linea e si verificano col segreto\ndel fornitore. Twilio non manda l'istante di un arrivo, quindi un\narrivo Twilio non apre la finestra delle 24 ore; i media di 360dialog\nnon si servono (passa la didascalia come testo).\n",
        "parameters": [
          {
            "name": "provider",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "mailgun",
                "telegram",
                "whatsapp",
                "beevoip",
                "twiliosms",
                "vonage",
                "twilio",
                "infobip",
                "360dialog"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Un corpo di **sole CHIAMATE** (WhatsApp Business Calling,\n`value.calls[]`: `id`, `from`/`to` in E.164, `event`, `direction`,\n`status`, `duration`, `timestamp`): preso in carico e **registrato\nnel giornale del servizio** come evento di tipo `chiamata` — non\nesiste una tabella dove atterri, e non se ne inventa una. E' un ramo\na parte, **prima** del dispatcher dei messaggi: la chiamata non e' un\nmessaggio, non produce nessun evento inbound (`inbound_event` resta\nfermo) e non viene consegnata a nessuna persona. Come i `204`, e per\nla stessa ragione, la risposta **non porta `code`**: `code` e' il\ncampo dei RIFIUTI, e questo e' un successo. Un corpo con `messages`\nprosegue sulla strada di sempre (il `204` qui sotto).\n"
          },
          "204": {
            "description": "Preso in carico (o riconosciuto come doppione). Un messaggio di Telegram chiude 204 anche quando il testo e' per la persona, in DUE modi: se e' una RISPOSTA (`reply_to_message` verso una nostra consegna) lo scope lo risolve il `risposta_a`; se NON e' una risposta lo scope lo risolve la CHAT del mittente, se quell'indirizzo ci risulta (`contact_address`, scritto da noi spedendo). In entrambi i casi l'evento resta `ACCEPTED` e il messaggio in entrata viene consegnato a chi risponde per quello scope"
          },
          "401": {
            "description": "Firma non valida — registrato come `BAD_SIGNATURE`. Da ONB-1 il segreto di firma e' della LINEA (per WhatsApp il `phone_number_id` che il corpo dichiara): una ricevuta per una linea che NON e' in `channel_config` non ha nessun segreto da provare e prende lo STESSO 401, con lo stesso `code` e lo stesso `message`. Lo stesso 401, identico, prende una linea che E' in `channel_config` ma per cui il caveau non ha NESSUN segreto (migrazione 037). Nel REGISTRO i tre casi si distinguono (`LINEA_IGNOTA`, `SEGRETO_ASSENTE`, e il `BAD_SIGNATURE` di un segreto che non combacia), nella risposta no, e non e' una dimenticanza: dirlo regalerebbe a chi bussa un modo di setacciare i nostri numeri"
          },
          "422": {
            "description": "Scope non risolvibile — registrato come `UNKNOWN_SCOPE`. Non finisce qui chi risolve a UN solo scope: una RISPOSTA di Telegram che aggancia una nostra consegna, o un indirizzo che ci risulta (la chat di Telegram e il `phone_number_id` di WhatsApp, scritti da noi spedendo). Il 422 resta per cio' che NON risolve a UN solo scope — una chat di Telegram a cui non abbiamo MAI scritto (il vero primo contatto), un indirizzo registrato su DUE scope, un indirizzo registrato su un ALTRO canale, e un `phone_number_id` WhatsApp che risolve a piu' di uno scope"
          },
          "404": {
            "description": "`FORNITORE_SCONOSCIUTO`: `{provider}` non e' fra i fornitori\ndell'entrata (`x-fornitori-vivi` qui sopra). Non si scrive niente —\nnemmeno il contatore del freno — e non si tocca il database: chi\nbussa a caso non e' un evento ricevuto. E' un 404 col SUO codice, non\nil `NOT_FOUND` di casa: chi integra distingue «questa porta non\nesiste» da «questo fornitore non e' collegato».\n"
          },
          "429": {
            "description": "`TROPPI_TENTATIVI` con `Retry-After`: troppe richieste dallo stesso\nindirizzo nello stesso minuto. Una richiesta frenata NON è un evento\nricevuto, quindi non lascia riga in `inbound_event`.\n"
          }
        }
      }
    },
    "/conferme/{gettone}": {
      "get": {
        "tags": [
          "messaggi"
        ],
        "summary": "Il destinatario conferma di aver letto (link firmato)",
        "security": [],
        "description": "**Non autenticata con una chiave, e non aperta**: chi la raggiunge e'\nil destinatario del messaggio — una persona con una casella di posta,\nche non ha e non avra' mai una nostra chiave. L'autorizzazione sta\n**dentro il gettone**, che e' un HMAC-SHA256 su `message_id` + scope +\nscadenza, firmato con un segreto del caveau e verificato a tempo\ncostante. Un gettone coniato per un messaggio non ne conferma un altro,\ne senza il segreto il servizio **non raccoglie conferme** (503) invece\ndi accettarle senza firma.\n\nE' una `GET` che scrive, e il motivo e' che un link in una email si apre\ncliccando: non c'e' modo di far mandare una POST a un client di posta.\nE' resa innocua dall'idempotenza — il precaricamento del client, il\ndoppio clic e l'inoltro registrano **una** conferma sola.\n\nRisponde in JSON, o in HTML se l'`Accept` lo chiede (chi clicca e' una\npersona). Nella pagina non entra **niente** del messaggio: ne' oggetto,\nne' testo, ne' indirizzo, e nessuna risorsa remota — un link di conferma\nfinisce negli inoltri, e chi lo apre per sbaglio non deve imparare\nnulla.\n",
        "parameters": [
          {
            "name": "gettone",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Il gettone firmato, coniato al momento dell'invio."
          }
        ],
        "responses": {
          "200": {
            "description": "Conferma registrata. `already_confirmed: true` quando era gia'\nstata data: per chi clicca la seconda volta il fatto e' vero, e un\nerrore gli farebbe pensare di non avere confermato.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "confirmed": {
                      "type": "boolean",
                      "const": true
                    },
                    "message_id": {
                      "$ref": "#/components/schemas/Ulid"
                    },
                    "status": {
                      "type": "string"
                    },
                    "already_confirmed": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "description": "Firma non valida — `FIRMA_NON_VALIDA`, non lo abbiamo coniato noi"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "410": {
            "description": "Gettone scaduto — `GETTONE_SCADUTO`, vale 30 giorni"
          },
          "503": {
            "description": "`CONFERME_NON_CONFIGURATE`: manca il segreto di firma nel caveau.\nFail-closed: senza, una conferma non si puo' ne' chiedere ne'\nverificare.\n"
          }
        }
      }
    },
    "/inviti/{gettone}": {
      "get": {
        "tags": [
          "messaggi"
        ],
        "summary": "Accetta l'invito presentando il gettone (senza chiave)",
        "security": [],
        "description": "Chi raggiunge questa rotta e' la persona che ha ricevuto la mail\nd'invito: non ha e non avra' mai una nostra chiave, e l'autorizzazione\nsta **dentro il gettone** — un HMAC-SHA256 su tenant + persona +\nscadenza, coniato dallo sportello `POST /v1/tenant/{tenant_uid}/inviti`\ne verificato a tempo costante.\n\nNessun ambito si deduce da un header o dal corpo: il tenant e la persona\nstanno nel gettone, e la risposta non porta **nessun dato del tenant**\noltre il minimo per dire «sei dentro».\n\nI rifiuti sono distinti, come li produce il gettone: `GETTONE_MALFORMATO`\n(400, il formato non e' il nostro), `FIRMA_NON_VALIDA` (403, non lo\nabbiamo coniato noi), `GETTONE_SCADUTO` (410, e' vecchio), piu'\n`UTENTE_ASSENTE` (404), `GIA_ACCETTATO` (409, vale una volta sola),\n`CLIENTE_SOSPESO` (403, un prodotto disdetto non fa entrare nessuno) e\n`TENANT_SOSPESO` (403, un tenant sospeso non fa entrare nessuno).\n\nE una porta senza chiave e' la porta che qualcuno prova: il freno e'\nper **indirizzo**, non per chiave — oltre il tetto la risposta e' `429\nTROPPI_TENTATIVI` con `Retry-After`.\n\nRisponde in JSON, o in HTML se l'`Accept` lo chiede (chi clicca e' una\npersona). Anche nella pagina non entra niente del tenant: solo «sei\ndentro» o il motivo del rifiuto.\n",
        "parameters": [
          {
            "name": "gettone",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Il gettone firmato, coniato dallo sportello dell'invito."
          }
        ],
        "responses": {
          "200": {
            "description": "Invito accettato: la persona passa da INVITATA ad ATTIVA. La\nrisposta e' volutamente minima.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoAccettazioneInvito"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "description": "`FIRMA_NON_VALIDA` (non lo abbiamo coniato noi), `CLIENTE_SOSPESO`\n(il prodotto e' sospeso o disdetto) oppure `TENANT_SOSPESO` (il\ntenant e' sospeso).\n"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "description": "`GIA_ACCETTATO` — un invito vale una volta sola\n"
          },
          "410": {
            "description": "`GETTONE_SCADUTO` — il link e' vecchio\n"
          },
          "429": {
            "description": "`TROPPI_TENTATIVI` — troppi gettoni presentati da questo indirizzo.\nPorta `Retry-After`.\n"
          },
          "503": {
            "description": "`INVITI_NON_CONFIGURATI`: manca il segreto di firma degli inviti.\nFail-closed: senza, un invito non si puo' ne' coniare ne' verificare.\n"
          }
        }
      }
    },
    "/media/{gettone}": {
      "get": {
        "tags": [
          "messaggi"
        ],
        "summary": "Scaricare il media di un messaggio in arrivo (URL nostro, firmato e a scadenza)",
        "security": [],
        "x-chiamata-dal-prodotto": true,
        "description": "**Non autenticata con una chiave, e non aperta.** Chi raggiunge questa\nrotta e' il cliente che ha ricevuto un vocale, una foto o un documento\nsulla propria linea, o un suo sistema: l'autorizzazione sta **dentro il\ngettone**, un HMAC-SHA256 su `media_id` del fornitore + la LINEA da cui\ne' entrato + la scadenza, coniato al momento della notifica e verificato\na tempo costante. Un gettone coniato per un media non ne apre un altro,\ne uno coniato per una linea non usa la credenziale di un'altra.\n\n**Solo con la firma valida si chiede il media al fornitore.** La rotta\nverifica prima il gettone, poi risolve la linea **e il suo cliente**, e\n**solo allora** chiede i byte a Meta (in due passi: il dettaglio del\nmedia, che porta l'URL temporaneo, e poi quell'URL). Un gettone\nmanomesso, scaduto o di un altro servizio non fa partire **nessuna**\nchiamata al fornitore: non ha nemmeno senso chiederglielo.\n\nDi chi e' la linea si guarda nella **stessa** query che la risolve (i due\n`LEFT JOIN` di `sospensione.js`, su `channel_config` che gia' porta\n`product_code` e `tenant_uid`): se il prodotto o l'azienda di quella\nlinea sono **sospesi o disdetti**, la richiesta si rifiuta `403\nCLIENTE_SOSPESO` / `TENANT_SOSPESO` **prima** di chiamare Meta — un\ncliente sospeso non consuma il nostro credenziale di fornitore.\n\nI rifiuti sono distinti, come li produce il gettone: `GETTONE_MALFORMATO`\n(400, il formato non e' il nostro), `FIRMA_NON_VALIDA` (403, non lo\nabbiamo coniato noi), `GETTONE_SCADUTO` (410, e' vecchio, vale 24 ore),\n`CLIENTE_SOSPESO` / `TENANT_SOSPESO` (403, il prodotto o l'azienda della\nlinea sono sospesi o disdetti), piu' `MEDIA_NON_DISPONIBILE` (404, la\nlinea non e' piu' attiva) e `MEDIA_NON_SCARICABILE` (502, il fornitore\nnon ha consegnato i byte).\n\nI byte escono col `Content-Type` che il fornitore ha dichiarato, e non\nsi scrivono da nessuna parte: la risposta e' il file, e nient'altro.\n",
        "parameters": [
          {
            "name": "gettone",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Il gettone firmato e a scadenza, coniato quando la notifica\n`messaggio_in_arrivo` e' stata messa sul filo e consegnato nel campo\n`media.url`.\n"
          }
        ],
        "responses": {
          "200": {
            "description": "I byte del media, col `Content-Type` dichiarato dal fornitore.\n",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "`GETTONE_MALFORMATO` — il gettone non ha la forma attesa\n"
          },
          "403": {
            "description": "`FIRMA_NON_VALIDA` — questo indirizzo non lo abbiamo coniato noi;\noppure `CLIENTE_SOSPESO` / `TENANT_SOSPESO` — la firma e' nostra, ma\nil prodotto o l'azienda della linea sono sospesi o disdetti, e un\ncliente sospeso non consuma il nostro credenziale di fornitore\n"
          },
          "404": {
            "description": "`MEDIA_NON_DISPONIBILE` — la linea di questo media non e' (piu')\nattiva: non si scarica da una linea spenta\n"
          },
          "410": {
            "description": "`GETTONE_SCADUTO` — l'indirizzo e' vecchio (vale 24 ore)\n"
          },
          "502": {
            "description": "`MEDIA_NON_SCARICABILE` — il fornitore non ha consegnato i byte del\nmedia\n"
          },
          "503": {
            "description": "`MEDIA_NON_CONFIGURATI` — manca il segreto di firma dei media nel\ncaveau. Fail-closed: senza, un indirizzo di media non si puo' ne'\nconiare ne' verificare.\n"
          }
        }
      }
    },
    "/richieste-licenza": {
      "post": {
        "tags": [
          "licenze"
        ],
        "summary": "Chiedere una licenza dalla landing (registro, non recapito)",
        "security": [],
        "description": "**Il registro e' la verita', l'inoltro e' un recapito.** La richiesta si\nSCRIVE prima che questa rotta risponda, e il recapito verso la regia lo\nfa il giro dell'ora. Il motivo e' la sola proprieta' per cui questa rotta\nesiste — **nessuna richiesta inghiottita** — e il verso contrario\n(inoltrare dentro la richiesta HTTP e scrivere dopo) perderebbe ogni\nrichiesta arrivata mentre il recapito e' giu', cioe' esattamente quando un\ncliente che vuole comprare non deve essere perso.\n\n**Non autenticata, e non aperta.** Le tre cose che sostituiscono la\nchiave, e ognuna chiude un difetto: (1) questa rotta *non consegna\nniente a nessuno* — una rotta pubblica che facesse partire una email\nsarebbe un ripetitore di posta non richiesta offerto a Internet, dal\nservizio che ha «prova di consegna» sull'insegna; (2) il freno di\nfrequenza e' **sull'indirizzo** e non sulla chiave, perche' una chiave non\nc'e' (`429` con `Retry-After`, lo stesso contatore della porta pubblica);\n(3) i campi sono quelli e non uno di piu' — non si raccoglie cio' che non\nserve a rispondere.\n\n`202` e non `201`: la richiesta e' *registrata*, il recapito e' un'altra\ncosa. Lo stesso `202` con `gia_registrata: true` per il doppio clic — lo\nstesso modulo inviato due volte lo stesso giorno e' **una** richiesta\n(unicita' nel motore su contenuto + giorno), e rispondere con un errore\nfarebbe pensare a chi ha premuto due volte di non aver chiesto niente.\n\nI **prezzi non stanno qui e non stanno nella landing**: sono «su\nrichiesta» (titolare). Il `piano` di questo corpo e' quello *chiesto*, non\nquello concesso: i numeri veri li scrive chi crea la licenza.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "azienda",
                  "nome",
                  "email"
                ],
                "properties": {
                  "azienda": {
                    "type": "string",
                    "maxLength": 160
                  },
                  "nome": {
                    "type": "string",
                    "maxLength": 160
                  },
                  "email": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "telefono": {
                    "type": "string",
                    "maxLength": 40
                  },
                  "piano": {
                    "type": "string",
                    "enum": [
                      "prova",
                      "standard",
                      "enterprise"
                    ],
                    "description": "Il piano CHIESTO. Assente = non ha scelto, ed e' legittimo."
                  },
                  "canali": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "I canali che gli interessano, come li ha spuntati."
                  },
                  "messaggio": {
                    "type": "string",
                    "maxLength": 4000
                  },
                  "riferimento_interno": {
                    "type": "string",
                    "description": "**Campo trappola**: deve restare VUOTO. Un browser non lo\ncompila, un robot lo compila. Se arriva pieno la risposta e'\n`400 CAMPO_TRAPPOLA` e **non** un `202` finto: un 202 senza\nriga sarebbe, alla lettera, una richiesta inghiottita — e il\ngiorno in cui un compilatore di moduli lo riempie per una\npersona, quella persona legge «registrata» e nessuno la\nrichiama. Un no che si legge costa a un robot esattamente\nquanto un silenzio.\n"
                  },
                  "lingua": {
                    "type": "string",
                    "pattern": "^[a-z]{2}$",
                    "description": "La lingua in cui ha compilato, per rispondergli nella sua."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Richiesta REGISTRATA. `gia_registrata: true` quando era lo stesso\nmodulo dello stesso giorno.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "registrata": {
                      "type": "boolean",
                      "const": true
                    },
                    "numero": {
                      "type": "integer",
                      "description": "Il numero da dire a voce: «richiesta registrata n. X». Un\nULID e' l'id giusto per un programma e quello sbagliato per\nuna persona. Il doppio clic rilegge lo STESSO numero.\n"
                    },
                    "richiesta_id": {
                      "$ref": "#/components/schemas/Ulid"
                    },
                    "numero_pratica": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "pattern": "^SS-\\d{4}-\\d{5}$",
                      "description": "**Il numero di pratica dei Servizi Sovrani** (`SS-AAAA-NNNNN`),\ndirettiva A358 del titolare (12/10/2026): uno per tutta la\nfamiglia, dato da questo servizio a ogni richiesta che\narriva dal modulo di una pagina sovrana. E' il numero che\nsi cita in una mail e al telefono; `numero` qui sopra resta\nil progressivo delle richieste di licenza di questo\nprodotto, che non e' la stessa cosa.\n\nProgressivo per anno **senza buchi** e **mai riusato**: lo\nda' un contatore in tabella dentro la stessa transazione\nche scrive la pratica, non un `AUTO_INCREMENT` (che non\ntorna indietro e lascia un buco a ogni INSERT annullato).\nIl doppio clic rilegge lo STESSO numero di pratica.\n\n`null` quando la pratica non si e' potuta aprire: in quel\ncaso arriva anche `pratica_motivo`, e la richiesta di\nlicenza **resta registrata comunque** — un guasto del\nregistro della famiglia non deve far perdere una richiesta\nche e' gia' salva.\n"
                    },
                    "pratica_motivo": {
                      "type": "string",
                      "description": "Presente SOLO con `numero_pratica: null`: il codice del\nperche' la pratica non si e' aperta. Dichiarato e non\nnascosto, perche' un numero assente che tace e' una\npratica che non esiste per nessuno.\n"
                    },
                    "gia_registrata": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Modulo incompleto o malformato, con l'errore DAL NOME:\n`CAMPO_MANCANTE`, `CAMPO_TROPPO_LUNGO`, `CAMPO_NON_TESTO`,\n`EMAIL_NON_VALIDA`, `PIANO_SCONOSCIUTO`, `CANALI_NON_LISTA`,\n`CAMPO_TRAPPOLA`. Un campo\ntroppo lungo si RIFIUTA e non si tronca: un messaggio tagliato a meta'\narriva a chi deve rispondere come una frase incompleta, e chi l'ha\nscritto non sa di averlo perso.\n"
          },
          "429": {
            "description": "`LIMITE_FREQUENZA` con `Retry-After`: troppe richieste dallo stesso\nindirizzo nello stesso minuto.\n"
          }
        }
      }
    },
    "/webhooks/{id}": {
      "delete": {
        "tags": [
          "webhook"
        ],
        "summary": "Rimuove un punto di consegna",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Ulid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Rimosso"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/categorie": {
      "get": {
        "tags": [
          "configurazione"
        ],
        "summary": "Le categorie del proprio prodotto, con la classe di politica di ognuna",
        "description": "Torna le righe di `product_message_category` del prodotto della chiave,\ncon la **classe** a cui ognuna appartiene. È la mappa che\n`POST /v1/messages` usa per risolvere la politica: senza una riga qui, un\nmessaggio con `category` non risolve nessuna classe e non si applica\nniente — né scadenza, né silenzio, né consenso, né ordine dei canali.\n\n`403 SOLO_CLIENTE` se la chiave non ha un prodotto (è di piattaforma).\n",
        "responses": {
          "200": {
            "description": "Le categorie del prodotto, con la classe di ognuna",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoCategorie"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          }
        }
      },
      "put": {
        "tags": [
          "configurazione"
        ],
        "summary": "Dichiara le proprie categorie e la classe di politica di ognuna",
        "description": "La **classe** è **obbligatoria** per ogni categoria, e deve essere una\ndelle quattro del §4 — `sicurezza`, `transazionale`, `avviso`,\n`marketing`. Una categoria senza classe (400 `CAMPI_MANCANTI`) o con una\nclasse fuori dalle quattro (422 `CATEGORIA_NON_VALIDA`) si rifiuta: una\nclasse inventata risolverebbe su una `routing_policy` che non esiste, e\nil messaggio tornerebbe a essere senza politica in silenzio.\n\nLa dichiarazione è un **upsert** idempotente: aggiunge le categorie nuove\ne corregge la classe di quelle già presenti, senza cancellare le\ncategorie non nominate.\n\n`403 SOLO_CLIENTE` se la chiave non ha un prodotto (è di piattaforma).\n\n`403 SOLO_PRODOTTO` se la chiave è di grado TENANT o USER: queste righe\nsono del **prodotto** (`product_message_category` ha per chiave il solo\n`product_code`), quindi scriverle da un tenant cambierebbe la classe di\npolitica anche per i **tenant fratelli**. Leggerle, invece, un tenant\npuò: sapere con che classe partono i propri messaggi è suo diritto.\n\n`404 CLIENTE_ASSENTE` se il prodotto che la chiave nomina **non ha\nl'anagrafica**: `product_message_category.product_code` ha una chiave\nesterna verso `product`, e senza quella riga non si scrive niente. Non\nè un refuso del corpo — il `product_code` viene dalla **chiave** — e la\nriga la scrive `POST /v1/clienti`, che vuole una chiave di piattaforma:\ncapita a chi ha ricevuto una chiave coniata a mano. Prima rispondeva\n`500 INTERNAL_ERROR` (misurato e curato il 7/10/2026).\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaCategorie"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Le categorie dopo la dichiarazione",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoCategorie"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "description": "`CLIENTE_ASSENTE`: il prodotto che la chiave nomina non ha\nl'anagrafica.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "422": {
            "description": "`CATEGORIA_NON_VALIDA`: una classe fuori dalle quattro del §4.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          }
        }
      }
    },
    "/clienti": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Attiva un cliente del Postino (crea il proprietario e conia la chiave)",
        "description": "**Riservata ad ADP e ai canali di vendita**: serve una chiave di\n**piattaforma** (`agch_platform_…`), non di prodotto. Un acquisto del\npostino in ADP chiama `POST /v1/clienti` e ottiene un cliente attivo che\npuò mandare la sua prima consegna senza interventi della regia.\n\nCrea il **proprietario**: il grado PRODUCT (una società) in `product`, e\n— se `tenant_uid` è dato — il grado TENANT dentro quel prodotto. Poi\n**conia la chiave** (o le chiavi): il valore in chiaro viaggia **una\nvolta sola** in questa risposta, nel database resta solo l'impronta\n`sha256`. Chi lo riceve lo deposita nel caveau e lo dimentica.\n\n**Non accetta segreti di fornitore** (400 `SECRET_IN_BODY`): i canali e\ni mittenti si configurano con `PUT /v1/channels/config`. E **non conosce\nil catalogo**: ADP vende e mostra il postino, AgileChannels consegna,\nprova e conta.\n\n**`Idempotency-Key` è obbligatoria**, come su `POST /v1/messages` e\nsulla sandbox: il 400 `IDEMPOTENCY_KEY_MISSING` arriva prima di ogni\naltra cosa. Con la stessa chiave nello stesso `product_code` il\nritentativo non conia una seconda chiave: torna l'esito del primo\ntentativo con `duplicate: true`, e **il valore in chiaro non si può\nritornare** (nel database c'è solo l'impronta) — la risposta lo dice in\nfaccia con `key_id`, `key_fp` e `chiave_assente`. La protezione dura\n`scadenza.GIORNI_IDEMPOTENZA` giorni (30), come per gli invii.\n\nUna `Idempotency-Key` **nuova** sullo stesso prodotto è una\n**riattivazione voluta**: conia una chiave nuova e le vecchie restano\nattive (convivono), perché revocarle in silenzio spegnerebbe la chiave\nche il cliente sta usando. Per chiuderne una in particolare c'è\n`DELETE /v1/clienti/{product_code}/chiavi/{key_id}`.\n\n**Il patto lo dichiara chi vende** (1/10): `piano`, e `scadenza` o\n`giorni`. Chi non dichiara niente riceve il patto di serie — `prova`,\n**30 giorni**, grazia 24 ore, coi due tetti ratificati — e il 201 lo\n**ridice** nel blocco `licenza`. `standard` ed `enterprise` non hanno\nuna scadenza di serie: senza `scadenza` né `giorni` la porta risponde\n400 `SCADENZA_DA_DICHIARARE`.\n\n**Il piano dichiarato arriva sulla riga che apre**, anche quando il\ntenant ha già una licenza aperta: se quella riga è una `prova`, si\n**converte** nella stessa transazione (una licenza sola, non una\nseconda, con la sua riga di catena `licenza.convertita`). Una\nattivazione **senza** piano dichiarato non riscrive un contratto che\nnessuno ha venduto. Se la riga aperta è un patto che **non** è la\n`prova` e il piano dichiarato è **diverso**, la porta si ferma con 409\n`PATTO_APERTO`: chi vende chiude quello che non vale più (revoca) e poi\nattiva il nuovo — il default di un piano non decide un contratto.\n\n**Una scadenza nuova su un patto aperto non si applica da qui** (4/10):\nse il tenant ha già un patto aperto con lo **stesso** piano e la\nrichiesta dichiara una `scadenza` (o `giorni`), la porta si ferma con\n409 `SCADENZA_SUL_PATTO_APERTO`, e il messaggio nomina la porta giusta.\nUn rinnovo **non è una seconda attivazione**: sposta la data (e riapre,\nse era scaduta) con un atto suo, `POST /admin/licenze/{id}/rinnova`.\nFino a ieri questa richiesta riceveva un 2xx con la scadenza **vecchia**:\nchi vende credeva di aver spostato la data e il cliente moriva alla\nscadenza vecchia. Chi **non** dichiara una scadenza continua a riusare\nil patto che c'è, com'è sempre stato.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaAttivazione"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Cliente attivato; la chiave viaggia una volta sola",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoAttivazione"
                }
              }
            }
          },
          "200": {
            "description": "Doppione riconosciuto. Stessa `Idempotency-Key` nello stesso\n`product_code`: è l'esito del primo tentativo, senza una chiave\nnuova, e senza il valore in chiaro (che non si conserva).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoDuplicatoAttivazione"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/clienti/{product_code}": {
      "get": {
        "tags": [
          "configurazione"
        ],
        "summary": "Legge un cliente e dice, canale per canale, se consegna",
        "description": "**Riservata ad ADP e ai canali di vendita** (chiave di piattaforma).\nLa stessa fotografia che `POST /v1/clienti` da' all'attivazione, ma\n**dopo**: chi attiva e chi guarda il giorno dopo non sono la stessa\npersona, e la chiave in chiaro dell'attivazione viaggia una volta sola.\n\nPorta l'anagrafica, **quante** chiavi sono attive (mai il loro valore),\nil postino dichiarato (un puntatore al Lettore Unico, non un'identita')\ne — la ragione per cui esiste — lo stato dei **canali ammessi**:\n`configurato` (c'e' una riga di `channel_config` dopo la cascata) e\n`credenziali_ok` (con l'alias storico `consegnabile`, STESSO valore:\nquella riga basta davvero al fornitore, chiesto senza spedire niente,\ncome fa la sezione `canali` di `/salute`). `credenziali_ok` pero' parla\ndel CANALE e non del contatto: se un contatto e' raggiungibile ADESSO\nsi chiede a parte, con `POST /v1/channels/reachability`.\n\nSola lettura: non scrive nessuna configurazione. I canali si\nconfigurano con `PUT /v1/channels/config`, che porta gli stessi campi\nper lo scope di chi chiama.\n\n`?tenant_uid=` legge il grado TENANT dentro il prodotto: la cascata dei\ncanali parte da li'.\n\n`404 CLIENTE_ASSENTE` se il prodotto non esiste.\n",
        "parameters": [
          {
            "name": "product_code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tenant_uid",
            "in": "query",
            "required": false,
            "description": "legge il grado TENANT dentro il prodotto",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Il cliente, con lo stato dei suoi canali",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoCliente"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      },
      "delete": {
        "tags": [
          "configurazione"
        ],
        "summary": "Disdice un cliente (revoca le chiavi, dichiara la conservazione)",
        "description": "**Disdetta** = revoca delle chiavi (smettono di aprire subito) +\nsospensione del prodotto + conservazione dichiarata, poi cancellazione.\n\nNon cancella i dati **adesso**: li lascia seguire la conservazione\ndichiarata all'attivazione, che `scadenza.giro` esegue (24 mesi indice\n/ 12 mesi testo di serie). L'**export** resta disponibile prima della\ncancellazione con `POST /v1/privacy/export` — gli stessi comandi privacy\nche il servizio ha già.\n\n`404 CLIENTE_ASSENTE` se il prodotto non esiste: «non esiste» e «esiste\nma non puoi» sono due risposte diverse.\n",
        "parameters": [
          {
            "name": "product_code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Disdetta eseguita",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoDisdetta"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/clienti/{product_code}/chiavi/{key_id}": {
      "delete": {
        "tags": [
          "configurazione"
        ],
        "summary": "Revoca UNA chiave del cliente, senza disdirlo",
        "description": "**Riservata ad ADP e ai canali di vendita** (chiave di piattaforma).\nIn una riattivazione voluta le chiavi convivono (vedi\n`POST /v1/clienti`): questa è la strada per chiuderne **una** in\nparticolare, quando si sa quale — per `key_id`, che l'attivazione ha\ndato in risposta.\n\nNon tocca le altre chiavi e non sospende il prodotto: è la revoca\npuntuale, non la disdetta (`DELETE /v1/clienti/{product_code}`, che\nrevoca TUTTE le chiavi). `404 CLIENTE_ASSENTE` se il prodotto non\nesiste, `404 CHIAVE_ASSENTE` se la chiave non è di quel prodotto.\n",
        "parameters": [
          {
            "name": "product_code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "key_id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Ulid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Chiave revocata (o già revocata)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoRevocaChiave"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Crea un tenant (azienda, o accesso individuale di una persona)",
        "description": "La apre **ADP** (chiave di piattaforma) o la chiave del **prodotto che\nospita** il tenant, dentro il proprio prodotto: una chiave di tenant non\ncrea altri tenant (`403 SOLO_PIATTAFORMA`), e un `product_code` nel corpo\nche non combacia con quello della chiave si rifiuta\n(`403 PRODUCT_NOT_ALLOWED`) invece di essere aggiustato.\n\n`kind: AZIENDA` (di serie) = piu' persone con ruoli. `kind: PERSONA` =\n**accesso individuale**: un utente solo, se stesso, amministratore di se\nstesso — e serve `amministratore_uid`, perche' senza quella persona\nl'accesso individuale non e' niente. Un tenant PERSONA non invita\nnessuno (`409 TENANT_PERSONALE`).\n\n`amministratore_uid` nasce **ATTIVO** e non INVITATO: non c'e' nessuno\nche possa averlo invitato, e un tenant che nasce con un invito da\naccettare e' un tenant che nessuno amministra se quell'invito non arriva.\n\n`404 CLIENTE_ASSENTE` se il prodotto non e' stato attivato: un tenant\nvive dentro un prodotto, e prima si attiva con `POST /v1/clienti`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaTenant"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tenant creato (col suo primo amministratore, se dato)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoTenant"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant/{tenant_uid}": {
      "patch": {
        "tags": [
          "configurazione"
        ],
        "summary": "Sospende o riattiva un tenant intero (solo la piattaforma)",
        "description": "`status: SUSPENDED` = il tenant **non apre niente**: nessuna sua chiave e\nnessun suo utente passa piu' i controlli dell'organizzazione.\n`status: ACTIVE` lo riapre.\n\n**Solo una chiave di piattaforma**: un tenant che potesse riattivarsi da\nse' non sarebbe sospeso, e uno che potesse sospendersi da se'\nchiuderebbe fuori i suoi utenti senza che ADP lo sappia.\n",
        "parameters": [
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "ACTIVE",
                      "SUSPENDED"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stato del tenant cambiato",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoTenant"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant/{tenant_uid}/utenti": {
      "get": {
        "tags": [
          "configurazione"
        ],
        "summary": "Gli utenti di un tenant, col ruolo e lo stato dell'invito",
        "description": "Un **amministratore** (e la piattaforma) vede tutti gli utenti. Un\n**utente semplice vede se stesso e nient'altro**: non l'elenco\naccorciato, non il numero degli altri — la query e' filtrata sul suo\n`user_uid`, perche' un conteggio e' gia' un'informazione\nsull'organizzazione di qualcun altro.\n\nUna chiave senza persona e senza potere di amministrare (per esempio una\nchiave di PRODOTTO) non ha un «se stesso» da leggere: `403\nNON_AMMINISTRATORE`.\n\nIl campo `io` dice con che cosa si sta guardando (grado della chiave,\npersona, ruolo efficace, se amministra): serve a una console per sapere\nquali tasti mostrare senza tirare a indovinare.\n",
        "parameters": [
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Il tenant, chi sta guardando, e gli utenti visibili",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ElencoUtentiDelTenant"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          }
        }
      },
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Invita una persona nel tenant (la invita l'amministratore)",
        "description": "L'invitato nasce `INVITATO`, cioe' **senza nessun ruolo efficace**: un\ninvito mandato all'identita' sbagliata non da' accesso a niente finche'\nqualcuno non lo accetta (`POST .../accetta`).\n\n`role` e' `UTENTE` di serie, o `AMMINISTRATORE`. Chi ha invitato non lo\ndichiara il corpo: si legge dalla persona del token\n(`invited_by`) e dalla chiave (`invited_actor`), con le stesse parole di\n`audit_log.actor`.\n\n`409 UTENTE_GIA_PRESENTE` se c'e' gia': si cambia il suo ruolo o lo si\nriattiva, non si invita due volte.\n",
        "parameters": [
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaInvito"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Invitato",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UtenteDelTenant"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant/{tenant_uid}/inviti": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Conia l'invito e lo accoda alla consegna (solo l'amministratore)",
        "description": "LO SPORTELLO DELL'INVITO, prima porta. Un amministratore del tenant\nconia il gettone e accoda la mail che lo porta, dal modello\n`invito-utente`: e' la stessa identita' di\n`POST /v1/tenant/{tenant_uid}/utenti` (chi invita e' l'amministratore,\nl'invitato nasce INVITATO) piu' il pezzo che mancava — il messaggio\nVERO, che il Postino consegna con la sua prova\n(`GET /v1/messages/{id}/prova`).\n\n`Idempotency-Key` e' **obbligatoria**: un ritentativo non deve mandare\ndue inviti alla stessa persona. Con la stessa chiave la rotta torna il\nmessaggio di prima (`duplicate: true`).\n\nIl gettone NON esce da qui: va nella mail, dentro il link composto dalla\nbase pubblica dichiarata nell'ambiente. Il valore non finisce in nessun\nregistro — nella catena di audit va l'impronta, all'accettazione.\n\n`role` e' `UTENTE` di serie, o `AMMINISTRATORE`. Se la persona e'\ngia' INVITATA si ri-invia il link; se e' ATTIVA o SOSPESA si rifiuta\n(`409 UTENTE_GIA_PRESENTE`).\n\n**L'invito SPENDE un'unita' del patto**, come ogni altro messaggio: lo\nconsegna un fornitore vero e finisce nell'estratto del mese. Quindi\nquesta porta ha gli stessi due freni di `POST /v1/messages` — il tetto\nal minuto e la quota mensile, nel valore EFFETTIVO (il piu' stretto fra\nla riga della chiave e la licenza) — e lascia la sua riga nel registro\nd'accesso. Il tetto lo spende la chiave CHE CONIA, cioe' quella\nautenticata; lo scope del messaggio resta quello del TENANT, perche' la\nDEK e il canale sono dell'azienda. Un invito rifiutato per il tetto\n**non lascia la persona INVITATA**: non c'e' niente da ripulire.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RichiestaInvitoSportello"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Invitato e accodato alla consegna",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoAccodamento"
                }
              }
            }
          },
          "200": {
            "description": "Gia' accodato con questa Idempotency-Key: il campo `duplicate` esce\na `true`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EsitoAccodamento"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "description": "`NON_AMMINISTRATORE` (serve il ruolo di amministratore del tenant),\n`CLIENTE_SOSPESO` (il prodotto e' sospeso o disdetto) oppure\n`TENANT_SOSPESO` (il tenant e' sospeso).\n"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          },
          "429": {
            "description": "I due freni del patto, come su `POST /v1/messages` e per lo stesso\nmotivo (un invito e' un messaggio vero che si paga):\n\n- `LIMITE_FREQUENZA` — il tetto al MINUTO. Si aspetta: `Retry-After`\n  dice pochi secondi.\n- `QUOTA_MENSILE_SUPERATA` — il tetto MENSILE. Non si aspetta:\n  `Retry-After` dice i secondi che mancano al mese nuovo, e il corpo\n  porta `quota_mensile`, `usati_nel_mese` e `mese`.\n\nIn entrambi i casi `frenato_da` dice QUALE patto ha frenato\n(`licenza` o `chiave`), cosi' chi integra sa se telefonare al\ncommerciale o cambiare la propria chiave.\n",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer",
                  "minimum": 1
                },
                "description": "secondi da attendere prima di riprovare"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant/{tenant_uid}/utenti/{user_uid}": {
      "patch": {
        "tags": [
          "configurazione"
        ],
        "summary": "Assegna il ruolo di una persona del tenant",
        "description": "Serve il ruolo di amministratore. Non si puo' togliere il ruolo\nall'**ultimo amministratore attivo** (`409 ULTIMO_AMMINISTRATORE`):\nun'azienda che si chiude fuori da sola ha bisogno di un turno della\nregia per un tasto premuto per sbaglio.\n",
        "parameters": [
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "user_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "role"
                ],
                "properties": {
                  "role": {
                    "type": "string",
                    "enum": [
                      "AMMINISTRATORE",
                      "UTENTE"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ruolo assegnato",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UtenteDelTenant"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant/{tenant_uid}/utenti/{user_uid}/accetta": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Accetta l'invito (da INVITATO a ATTIVO)",
        "description": "Lo fa **la persona invitata** — una chiave di grado USER col suo\n`user_uid` — oppure un amministratore del tenant a nome suo, che serve\nquando l'identita' arriva da ADP e non ha ancora una chiave propria.\nChiunque altro: `403 NON_AMMINISTRATORE`.\n\n`409 STATO_NON_VALIDO` se la persona non era in attesa d'invito.\n",
        "parameters": [
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "user_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Invito accettato",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UtenteDelTenant"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant/{tenant_uid}/utenti/{user_uid}/sospendi": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Sospende una persona del tenant",
        "description": "La persona non apre piu' niente e **la riga resta**: chi c'era e quando\nlo si deve poter dire dopo. Non si puo' sospendere l'ultimo\namministratore attivo (`409 ULTIMO_AMMINISTRATORE`).\n",
        "parameters": [
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "user_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Sospesa",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UtenteDelTenant"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/tenant/{tenant_uid}/utenti/{user_uid}/riattiva": {
      "post": {
        "tags": [
          "configurazione"
        ],
        "summary": "Riattiva una persona sospesa, e la rimette dove era",
        "description": "Chi aveva accettato torna `ATTIVO`, chi era solo invitato torna\n`INVITATO`: riattivare a ATTIVO chi non aveva mai accettato sarebbe far\nentrare qualcuno che non ha mai risposto all'invito — e il vincolo\n`ck_stato_utente_coerente` lo rifiuterebbe comunque, perche' non\nesisterebbe l'istante in cui e' entrato.\n\n`409 STATO_NON_VALIDO` se la persona non era sospesa.\n",
        "parameters": [
          {
            "name": "tenant_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "user_uid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Riattivata",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UtenteDelTenant"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/videochat/sessioni": {
      "post": {
        "tags": [
          "sessione"
        ],
        "summary": "Apre una sessione videochat e ne riceve il token-invito",
        "description": "Chi possiede uno scope apre una sessione per una persona digitale\n(`persona_agent_key`) e riceve il **token-invito**, mostrato **una\nvolta sola** — come la chiave di prodotto. Il token è il segreto\ndell'ospite: chi lo presenta entra (vedi `POST /videochat/invito`).\n\nIl motore video resta fuori (adp/agiletav): qui si scrive solo il\nciclo di vita. La sessione scade da sola (`expires_at`), la scadenza la\nmarca un cron del servizio, e `invito` rifiuta comunque una sessione il\ncui `expires_at` è passato anche prima che il cron la segni.\n\n409 `PERSONA_BUSY` se la persona ha già una sessione attiva per lo\nstesso proprietario (il cap è `max_concurrenti`, di serie 1).\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "persona_agent_key"
                ],
                "properties": {
                  "persona_agent_key": {
                    "type": "string",
                    "description": "la persona digitale che risponde nella videochat"
                  },
                  "guest_email": {
                    "type": "string"
                  },
                  "durata_sec": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "durata della sessione in secondi (di serie 300)"
                  },
                  "max_concurrenti": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "cap di sessioni attive per persona (di serie 1)"
                  },
                  "thread_id": {
                    "$ref": "#/components/schemas/Ulid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Sessione aperta; il token va mostrato una volta sola",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "session_id": {
                      "$ref": "#/components/schemas/Ulid"
                    },
                    "token": {
                      "type": "string",
                      "description": "l'invito",
                      "32 byte esadecimali": null
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "durata_sec": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/videochat/invito": {
      "post": {
        "tags": [
          "sessione"
        ],
        "summary": "L'ospite presenta il token-invito ed entra (o viene rifiutato)",
        "security": [],
        "description": "**Non autenticata con la chiave di prodotto**: il token È il segreto\ndell'ospite. Chi lo ha ricevuto entra finché la sessione è attiva e non\nscaduta; un token sconosciuto (`TOKEN_SCONOSCIUTO`, 404), scaduto\n(`SCADUTA`, 410) o di una sessione non attiva (`NON_ATTIVA`, 409) è\nrifiutato con un codice distinto, così chi integra capisce quale.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "il token-invito ricevuto all'apertura"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Il token è valido: la sessione e chi vi risponde",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "session_id": {
                      "$ref": "#/components/schemas/Ulid"
                    },
                    "persona_agent_key": {
                      "type": "string"
                    },
                    "guest_email": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "thread_id": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "409": {
            "$ref": "#/components/responses/Errore"
          },
          "410": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/videochat/chiudi": {
      "post": {
        "tags": [
          "sessione"
        ],
        "summary": "Chiude una sessione, con un motivo esplicito",
        "description": "Servono **sia la chiave** (lo scope) **sia il token**: un tenant non\nchiude la sessione di un altro nemmeno conoscendone il token, perché il\nfiltro lega anche `owner_key`. Il motivo è uno fra `USER_LEFT`,\n`TIMEOUT`, `ADMIN_KILLED` (di serie `USER_LEFT`). La chiusura è\nidempotente: `colpite` dice quante righe sono cambiate.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "il token della sessione da chiudere"
                  },
                  "motivo": {
                    "type": "string",
                    "enum": [
                      "USER_LEFT",
                      "TIMEOUT",
                      "ADMIN_KILLED"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Chiusa (o già chiusa)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "ENDED"
                    },
                    "colpite": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/videochat/riassunto": {
      "post": {
        "tags": [
          "sessione"
        ],
        "summary": "Scrive il riassunto della sessione nel filo",
        "description": "Chi possiede la sessione scrive il testo riassunto, che resta su\n`videochat_session` collegato al filo da `thread_id`. Anche qui il\nfiltro lega `owner_key`: solo il proprietario riassume la propria\nsessione.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "testo"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "il token della sessione"
                  },
                  "testo": {
                    "type": "string",
                    "description": "il testo del riassunto"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Scritto (o già scritto: `colpite` dice quante righe sono cambiate)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "OK"
                    },
                    "colpite": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          }
        }
      }
    },
    "/chat/fili": {
      "post": {
        "tags": [
          "sessione"
        ],
        "summary": "Apre un filo di chat (la chat sovrana, senza fornitore)",
        "description": "Un canale di chat di cui AgileChannels è l'unico fornitore: dall'altra\nparte c'è un widget che apre un filo e ci scrive, e il Postino che\nrisponde nello STESSO filo consegnando alla persona digitale di ADP. Il\nfilo è la tabella `thread` della 002, riusata com'è: la chat è un caso\ndel filo generico, non un'eccezione.\n",
        "responses": {
          "201": {
            "description": "Filo aperto",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "thread_id": {
                      "$ref": "#/components/schemas/Ulid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "429": {
            "description": "**Il tetto al MINUTO** della chiave o del patto sotto cui apre (porta\npubblica, P3): `LIMITE_FREQUENZA`. Si aspetta — `Retry-After` dice\npochi secondi. Il corpo porta **`frenato_da`**: `chiave` se ha frenato\nla riga della chiave, `licenza` se ha frenato il patto. Senza, chi\nintegra legge un 429 e non sa se telefonare al commerciale o cambiare\nla propria chiave. Qui NON c'e' `QUOTA_MENSILE_SUPERATA`: la chat non\nconsuma il tetto mensile, quindi l'unico freno di questa porta e'\nquello al minuto.\n",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer",
                  "minimum": 1
                },
                "description": "Secondi da attendere prima di ritentare (la finestra e' il minuto)."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          }
        }
      }
    },
    "/chat/fili/{id}/messaggi": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "$ref": "#/components/schemas/Ulid"
          }
        }
      ],
      "post": {
        "tags": [
          "sessione"
        ],
        "summary": "Manda un messaggio nel filo; la persona risponde nello stesso filo",
        "description": "Il messaggio entra nel filo (`IN`), viene consegnato alla persona\ndigitale giusta (connettore F3c), e la risposta torna nello STESSO filo\n(`OUT`). Il testo è **cifrato** con la busta esistente.\n\n503 `CONSEGNA_ASSENTE` se questo processo non ha un connettore\n«consegna alla persona» configurato: il messaggio non si consegna, e non\nsi finge che sia stato consegnato. 404 `FILO_SCONOSCIUTO` se il filo non\nesiste o è di un altro scope (stessa risposta: non si conferma\nl'esistenza di un filo altrui).\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "testo"
                ],
                "properties": {
                  "testo": {
                    "type": "string",
                    "description": "il testo del messaggio"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Consegna alla persona; la risposta torna nello stesso filo",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "risposta": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "persona": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "motivo": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Presente SOLO quando nessuno ha risposto: `risposta` e\n`persona` sono `null` e questa stringa dice **perche'** —\nil tetto di consegna scaduto (`AGILECHANNELS_CONSEGNA_PERSONA_MS`,\nE06 «entrata consegnata ENTRO 2 S»: di serie 2000 ms), la\nscadenza per chiamata di una delle due uscite al Lettore\nUnico scaduta, oppure nessuna persona che presidia quel\ndestinatario. Un tetto scaduto NON e' un 5xx: e' un esito\ndichiarato, e senza questa stringa chi aspetta vedrebbe un\nfilo muto senza sapere di chi e' la lentezza. Su una\nconsegna riuscita il campo non c'e'.\n"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Errore"
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "429": {
            "description": "**Il tetto al MINUTO** della chiave o del patto sotto cui apre (porta\npubblica, P3): `LIMITE_FREQUENZA`. Si aspetta — `Retry-After` dice\npochi secondi. Il corpo porta **`frenato_da`**: `chiave` se ha frenato\nla riga della chiave, `licenza` se ha frenato il patto. Senza, chi\nintegra legge un 429 e non sa se telefonare al commerciale o cambiare\nla propria chiave. Qui NON c'e' `QUOTA_MENSILE_SUPERATA`: la chat non\nconsuma il tetto mensile, quindi l'unico freno di questa porta e'\nquello al minuto.\n",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer",
                  "minimum": 1
                },
                "description": "Secondi da attendere prima di ritentare (la finestra e' il minuto)."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Errore"
          }
        }
      },
      "get": {
        "tags": [
          "sessione"
        ],
        "summary": "Lo storico del filo, decifrato e in ordine",
        "description": "I messaggi del filo, decifrati, in ordine. Ogni riga si apre con la DEK\nche la riga stessa nomina, non con la più recente dello scope: il giorno\ndi una rotazione lo storico non diventa illeggibile. 404\n`FILO_SCONOSCIUTO` per un filo che non esiste o è di un altro scope.\n",
        "responses": {
          "200": {
            "description": "Lo storico, IN e OUT alternati come sono accaduti",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "messaggi": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "$ref": "#/components/schemas/Ulid"
                          },
                          "direzione": {
                            "type": "string",
                            "enum": [
                              "IN",
                              "OUT"
                            ]
                          },
                          "testo": {
                            "type": "string"
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Errore"
          },
          "403": {
            "$ref": "#/components/responses/Errore"
          },
          "404": {
            "$ref": "#/components/responses/Errore"
          },
          "429": {
            "description": "Anche la LETTURA sta dietro la porta pubblica (P3), come\n`GET /v1/messages/{id}/prova`: la porta non e' solo il freno di chi\nscrive, e' il registro di chi e' passato di qui. **Il tetto al\nMINUTO** della chiave o del patto sotto cui apre: `LIMITE_FREQUENZA`.\nSi aspetta — `Retry-After` dice pochi secondi. Il corpo porta\n**`frenato_da`**: `chiave` o `licenza`. Qui NON c'e'\n`QUOTA_MENSILE_SUPERATA`: la lettura non consuma il tetto mensile.\n",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer",
                  "minimum": 1
                },
                "description": "Secondi da attendere prima di ritentare (la finestra e' il minuto)."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Errore"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "chiaveDiProdotto": {
        "type": "http",
        "scheme": "bearer",
        "description": "`agch_<prodotto>_<48 esadecimali>` (la piattaforma usa `agch_platform_`).\nNel database **solo** `sha256`: il valore in chiaro si vede una volta\nsola, all'emissione. Confronto timing-safe, revoca a caldo, allowlist\nIP con anti-spoofing. Preso da `nexus-product-gateway`, esteso allo\nscope **per canale** e ai gradi TENANT/USER.\n\nSi presenta in `Authorization: Bearer`, oppure in `X-Product-Key` o\n`X-Api-Key` (quest'ultimo per chi arriva dagli altri servizi sovrani,\nche usano quel nome). Se ne arrivano due, vince `Authorization`.\n\n**La chiave viva di un cliente sospeso NON apre** (29/9): la porta\nguarda lo stato della chiave *e* lo stato del cliente, su **ogni** rotta\ne non solo su `/v1/tenant/...` —\n\n| stato | risposta |\n|---|---|\n| `product.status = SUSPENDED` (disdetta) | `403 CLIENTE_SOSPESO` |\n| `tenant.status = SUSPENDED` | `403 TENANT_SOSPESO` |\n\nVale anche per le chiavi di grado USER dentro quel tenant, e per le\nchiavi coniate **dopo** la disdetta (la disdetta revoca quelle che\nesistono in quel momento: il prodotto sospeso ferma anche le altre). La\n**piattaforma** passa sempre — e' ADP, ed e' chi riapre: un cliente\nsospeso che chiudesse fuori anche lei resterebbe sospeso per sempre.\nRiapre `PATCH /v1/tenant/{tenant_uid}` per il tenant e `POST /v1/clienti`\n(riattivazione, `riaperto: true`) per il prodotto.\n"
      },
      "passepartoutDiProva": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "**LA CHIAVE PASSE-PARTOUT DI PROVA** dei servizi sovrani (una sola per\nAgileTTS, AgileAscolto, AgileV, AgileQ, AgileTav, AgilePixel e il\nPostino). Serve a provare il servizio senza aprire una pratica: non si\nemette e non si revoca da qui: vive nel caveau, e la ruota chi l'ha\ndepositata.\n\n**Che cosa apre** — e l'elenco è chiuso:\n\n| rotta | perché |\n|---|---|\n| `POST /v1/sandbox/messages` | l'unica che scrive: consegna col fornitore FINTO, non tocca nessuna rete |\n| `GET /v1/messages/{id}` | per vedere il `DELIVERED` del messaggio che hai appena messo in sandbox |\n| `GET /v1/messages/{id}/prova` | la prova di consegna firmata, che è ciò che il Postino vende |\n\nSu ogni **altra** rotta risponde `403 PASSEPARTOUT_SOLO_PROVA`: per\nspedire davvero serve una chiave tua (`POST /v1/clienti` la emette).\n\n**Ambito**: `PRODUCT|agilechannels-prova`, un prodotto che esiste solo\nper questo — non vede e non tocca i dati di nessun cliente.\n**Tetto**: 30 richieste al minuto, poi `429 LIMITE_FREQUENZA` con\n`Retry-After`. **Scadenza**: dichiarata nel servizio e nel caveau; dopo,\n`403 KEY_REVOKED` — che è «chiedine una nuova», non «hai sbagliato a\nscriverla». Ogni richiesta lascia una riga nel registro d'accesso.\n"
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "minLength": 8,
          "maxLength": 255
        },
        "description": "**Obbligatoria.** Unica per scope: due prodotti diversi possono\nmandare la stessa chiave senza sapere l'uno dell'altro. Provato sullo\nschema: `PRIMARY KEY (owner_key, idempotency_key)` sul registro\n`idempotency` (migrazione 017), che **sopravvive alla consegna**: prima\nla chiave viveva quanto la riga di coda, quindi un ritentativo dopo la\nconsegna faceva partire una seconda busta. La protezione dura\n**30 giorni** dal primo invio; oltre, la chiave torna libera.\n\n**Il suo 400 arriva prima del 401 della chiave**, su ogni rotta che\nquesto parametro porta: se ti mancano entrambe le cose ricevi\n`400 IDEMPOTENCY_KEY_MISSING` e non `401 KEY_MISSING`. Non e' un\naccidente dell'ordine in cui e' scritto il codice, e' una decisione:\naltrimenti il caso «chiave giusta, ritentativo, nessuna\n`Idempotency-Key`» risponderebbe una cosa diversa su ogni rotta. Il\nprezzo di sbagliarlo e' stato pagato una volta, su un'attivazione\nripetuta che lasciava viva una credenziale che nessuno sapeva di avere\nemesso.\n"
      }
    },
    "responses": {
      "Errore": {
        "description": "Errore",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Errore"
            }
          }
        }
      }
    },
    "schemas": {
      "Ulid": {
        "type": "string",
        "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$",
        "description": "ULID. Ordinato nel tempo, generabile fuori dal DB, non rivela i volumi."
      },
      "Errore": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "ALLEGATO_IN_CORPO",
              "ALLEGATO_NON_SUPPORTATO",
              "ALLEGATO_NON_VALIDO",
              "AUTORIZZAZIONE_ASSENTE",
              "BAD_SIGNATURE",
              "BASE_GIURIDICA_ASSENTE",
              "BASE_PUBBLICA_ASSENTE",
              "CAMPI_MANCANTI",
              "CANALE_NON_CONFIGURATO",
              "CANALE_NON_SUPPORTATO",
              "CATEGORIA_NON_VALIDA",
              "CAVEAU_ASSENTE",
              "CHANNEL_NOT_ALLOWED",
              "CANALI_NON_CONSENTITI",
              "CHIAVE_GIA_REVOCATA",
              "CHIAVE_GIA_RUOTATA",
              "CHIAVE_IN_GRAZIA",
              "CHIAVE_SCADUTA",
              "TROPPE_CHIAVI_VIVE",
              "AMBITO_IMMUTABILE",
              "NON_SI_ALLARGA_DA_SE",
              "CHIAVE_NON_TROVATA",
              "GRAZIA_NON_VALIDA",
              "LIMITE_NON_CONSENTITO",
              "QUOTA_NON_CONSENTITA",
              "SCADENZA_NON_VALIDA",
              "AMBITO_NON_CONSENTITO",
              "SCADENZA_DA_DICHIARARE",
              "SCADENZA_DOPPIA",
              "SCADENZA_SUL_PATTO_APERTO",
              "CHIAVE_ASSENTE",
              "CANALI_ESAURITI",
              "CAMPO_DA_DICHIARARE",
              "CAMPO_MANCANTE",
              "CAMPO_NON_TESTO",
              "CAMPO_TRAPPOLA",
              "CAMPO_TROPPO_LUNGO",
              "CANALI_NON_LISTA",
              "EMAIL_NON_VALIDA",
              "PIANO_SCONOSCIUTO",
              "PRATICA_SCONOSCIUTA",
              "LICENZA_AMBITO_NON_COPERTO",
              "LICENZA_CHE_NON_APRE",
              "LICENZA_DA_NOMINARE",
              "LICENZA_DI_UN_ALTRO",
              "LICENZA_REVOCATA",
              "LICENZA_SCADUTA",
              "LICENZA_SCONOSCIUTA",
              "LICENZA_SENZA_TENANT",
              "LICENZA_SOSPESA",
              "STATO_SCONOSCIUTO",
              "TENANT_SCONOSCIUTO",
              "CLIENTE_ASSENTE",
              "CLIENTE_SOSPESO",
              "CONFERME_NON_CONFIGURATE",
              "CONSEGNA_ASSENTE",
              "CONSENSO_ASSENTE",
              "CONSENT_MISSING",
              "CREDENZIALE_ASSENTE",
              "CONTO_ASSENTE",
              "CONTO_NON_COMBACIA",
              "CONTRATTO_ASSENTE",
              "DATA_NON_VALIDA",
              "DEK_ASSENTE",
              "ENTRATA_ASSENTE",
              "ENTRATA_CON_ALLEGATO",
              "EVENTI_NON_VALIDI",
              "EXPORT_ASSENTE",
              "EXPORT_FALLITO",
              "EXPORT_GIA_IN_CORSO",
              "EXPORT_IN_CORSO",
              "EXPORT_NON_AUTORIZZATO",
              "EXPORT_SCADUTO",
              "EXPORT_SOLO_DEL_CLIENTE",
              "FILO_SCONOSCIUTO",
              "FIRMA_NON_VALIDA",
              "FORNITORE_SCONOSCIUTO",
              "GETTONE_MALFORMATO",
              "GETTONE_SCADUTO",
              "GIA_ACCETTATO",
              "GIA_ESEGUITA",
              "HANDLE_ASSENTE",
              "IDEMPOTENCY_KEY_MISSING",
              "INDIRIZZO_NON_VALIDO",
              "INDIRIZZO_SOPPRESSO",
              "INTERNAL_ERROR",
              "INVITI_NON_CONFIGURATI",
              "IP_NOT_ALLOWED",
              "KEY_INVALID",
              "KEY_MISSING",
              "KEY_REVOKED",
              "LIMITE_FREQUENZA",
              "LINEA_ASSENTE",
              "LINEA_RITIRATA",
              "MEDIA_NON_CONFIGURATI",
              "MEDIA_NON_DISPONIBILE",
              "MEDIA_NON_SCARICABILE",
              "MITTENTE_ASSENTE",
              "MITTENTE_FORMA_SBAGLIATA",
              "PRESENZA_VUOTA",
              "PRESENZA_NON_BOOLEANA",
              "PRESENZA_NON_SUPPORTATA",
              "PRESENZA_NON_ARRIVATA",
              "PRESENZA_SENZA_PORTA",
              "EVENTO_ASSENTE",
              "CORPO_SCADUTO",
              "CORPO_SENZA_MESSAGGIO",
              "MITTENTE_NEGATO_DAL_FORNITORE",
              "MESE_MANCANTE",
              "MESE_NON_VALIDO",
              "QUOTA_MENSILE_SUPERATA",
              "MESSAGGIO_ASSENTE",
              "MESSAGGIO_SCONOSCIUTO",
              "MODELLO_SCONOSCIUTO",
              "MORTO_ASSENTE",
              "NON_AMMINISTRATORE",
              "NON_ATTIVA",
              "NON_RIACCODATO",
              "NOT_FOUND",
              "ORDINE_NON_VALIDO",
              "PASSEPARTOUT_SOLO_PROVA",
              "PATTO_APERTO",
              "PERSONA_BUSY",
              "PERSONA_NON_VALIDA",
              "PRODUCT_NOT_ALLOWED",
              "PROVA_NON_PARTITA",
              "RICHIESTA_ASSENTE",
              "RICHIESTA_GIA_IN_CORSO",
              "RIPIEGO_NON_AMMESSO",
              "RUOLO_NON_VALIDO",
              "SCADUTA",
              "SECRET_IN_BODY",
              "SERVIZIO_NON_PRONTO",
              "SOLO_CLIENTE",
              "SOLO_PIATTAFORMA",
              "SOLO_PRODOTTO",
              "STATO_NON_VALIDO",
              "SUPPRESSED",
              "SUPPRESSION_NON_LEGGIBILE",
              "TEMPLATE_E_MODELLO",
              "TEMPLATE_NON_VALIDO",
              "TENANT_ASSENTE",
              "TENANT_GIA_PRESENTE",
              "TENANT_PERSONALE",
              "TENANT_SOSPESO",
              "TESTO_ASSENTE",
              "TIPO_NON_VALIDO",
              "TOKEN_ASSENTE",
              "TOKEN_SCONOSCIUTO",
              "TROPPI_TENTATIVI",
              "ULTIMO_AMMINISTRATORE",
              "UNKNOWN_SCOPE",
              "UTENTE_ASSENTE",
              "UTENTE_GIA_PRESENTE",
              "VARIABILE_MANCANTE",
              "VARIABILE_NON_VALIDA",
              "VARIABILE_SCONOSCIUTA",
              "WEBHOOK_ASSENTE"
            ]
          },
          "message": {
            "type": "string"
          }
        },
        "description": "Codici distinti e non un 403 unico: `KEY_REVOKED` e `IP_NOT_ALLOWED`\nsono guasti diversi e chi integra deve poterli distinguere. Questo\nelenco e' lo specchio esatto dei codici che il servizio mette nel campo\n`code` — una prova (`contratto-rotte.test.js`) lo confronta col codice e\ndiventa rossa se l'uno si discosta dall'altro.\n"
      },
      "Licenza": {
        "type": "object",
        "description": "Il **diritto del tenant**, e le chiavi ci stanno sotto (migrazione 029).\n\nDue campi vanno letti insieme e non sono un doppione: `stato` e' la\n**colonna** — quello che ha scritto chi governa — e `apre` e' il\n**giudizio della porta**, dato dalla stessa funzione che accetta o\nrifiuta le chiavi. Possono non concordare, e vederlo e' il punto: una\nlicenza `ATTIVA` con la scadenza passata ha `apre: false`, perche' lo\nstato vero e' la DATA e non la colonna (se lo stato vero fosse la\ncolonna, basterebbe un giro notturno che non gira e un patto finito\ncontinuerebbe a spedire a nostre spese). `non_apre_perche` porta il\ncodice esatto con cui la porta rifiuta, cosi' chi guarda l'elenco legge\nla stessa frase che leggera' il cliente.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "tenant_uid": {
            "$ref": "#/components/schemas/Ulid"
          },
          "piano": {
            "type": "string",
            "enum": [
              "interna",
              "prova",
              "standard",
              "enterprise"
            ]
          },
          "stato": {
            "type": "string",
            "enum": [
              "ATTIVA",
              "SOSPESA",
              "SCADUTA",
              "REVOCATA"
            ]
          },
          "apre": {
            "type": "boolean",
            "description": "il giudizio della PORTA: le chiavi di questo tenant aprono o no"
          },
          "non_apre_perche": {
            "type": [
              "string",
              "null"
            ],
            "description": "il codice con cui la porta rifiuta (LICENZA_SOSPESA, LICENZA_SCADUTA, ...)"
          },
          "porta_del_rinnovo": {
            "type": [
              "string",
              "null"
            ],
            "description": "**Da dove si riapre.** Quando a chiudere la porta e' la DATA\n(`non_apre_perche: LICENZA_SCADUTA`) il campo vale\n`POST /admin/licenze/{id}/rinnova`: la scadenza non si sposta dalle\nrotte vecchie (ne' da `/riattiva`, ne' da un `PATCH` a mano), e chi\nlegge il 200 — ADP compresa — non deve indovinarlo dal manuale.\n`null` quando il patto apre, o quando a chiudere non e' la data\n(sospesa o revocata: li' il rinnovo non e' la risposta).\n"
          },
          "motivo": {
            "type": [
              "string",
              "null"
            ],
            "description": "perche e stata sospesa o revocata, scritto da chi lo ha deciso"
          },
          "inizio": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "scadenza": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "null = non scade (le licenze interne della 029)"
          },
          "grazia_ore": {
            "type": "integer"
          },
          "grazia_fino_a": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "in_grazia": {
            "type": "boolean",
            "description": "«sta aprendo per cortesia, non per contratto». E l'unico campo che\npermette di accorgersene PRIMA del primo 403.\n"
          },
          "quota_mensile": {
            "type": [
              "integer",
              "null"
            ],
            "description": "null = non dichiarata sulla licenza; vale quella della chiave"
          },
          "limite_al_minuto": {
            "type": [
              "integer",
              "null"
            ]
          },
          "ambiti": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          },
          "chiavi_sotto": {
            "type": "integer",
            "description": "Quante chiavi APRONO sotto questa licenza: la domanda di chi sta per\nsospendere. Una revocata non si conta — non e una credenziale che si\nfermerebbe.\n"
          }
        }
      },
      "LicenzaDellaPorta": {
        "type": "object",
        "description": "La licenza **come la vede una chiave che apre sotto di lei**: è la forma\nche la porta costruisce a ogni richiesta (`licenze.dallaRiga`), e non\nquella che legge chi governa (`Licenza`). Qui non ci sono `apre`,\n`non_apre_perche` né `chiavi_sotto`: sono il giudizio e il conteggio di\nchi vende, non il patto di chi spedisce.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "piano": {
            "type": "string",
            "enum": [
              "interna",
              "prova",
              "standard",
              "enterprise"
            ]
          },
          "stato": {
            "type": "string",
            "enum": [
              "ATTIVA",
              "SOSPESA",
              "SCADUTA",
              "REVOCATA"
            ]
          },
          "scadenza": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "null = non scade."
          },
          "grazia_ore": {
            "type": "integer"
          },
          "grazia_fino_a": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "in_grazia": {
            "type": "boolean",
            "description": "«sta aprendo per cortesia, non per contratto»."
          },
          "quota_mensile": {
            "type": [
              "integer",
              "null"
            ],
            "description": "null = non dichiarata sulla licenza."
          },
          "quota_mensile_usati": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Quanto ha speso **il patto** nel mese in corso: la somma di tutte le\nchiavi che aprono sotto questa licenza, contata su un contatore suo\n(`license_monthly_usage`, migrazione 032) e non per credenziale.\n`null` quando la licenza non dichiara una quota — «nessun tetto» e\n«zero speso» sono due cose diverse.\n\nSta qui e non dentro `quota_mensile_della_chiave` perche' quel campo\nsi chiama cosi' e `/v1` e' congelato: i numeri del PATTO si\naggiungono dove il patto e' gia' dichiarato.\n"
          },
          "quota_mensile_resta": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`quota_mensile - quota_mensile_usati`, mai negativo. E' il numero da\nguardare prima di coniare una chiave nuova: **coniare non riapre il\npatto** (una quota che si moltiplica emettendo chiavi non e' una\nquota), e prima della migrazione 032 questa rotta diceva a una chiave\nappena coniata che il patto era intero.\n"
          },
          "limite_al_minuto": {
            "type": [
              "integer",
              "null"
            ]
          },
          "ambiti": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          }
        }
      },
      "RichiestaInvio": {
        "type": "object",
        "required": [
          "channel_type",
          "to"
        ],
        "properties": {
          "channel_type": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ],
            "description": "**`VOICE` c'e'** (aggiunto l'8/10/2026): il Postino telefona e\nlegge il messaggio ad alta voce, e la colonna `message.channel_type`\nquel valore lo accetta da sempre. Fino a quel giorno questo `enum`\nne dichiarava quattro, e un cliente generato dal contratto **non\npoteva chiedere una consegna a voce** — mentre lo stesso contratto\nla voce la dichiarava fra i canali di un cliente comprato\n(`RichiestaAttivazione.allowed_channels`), fra quelli di cui si\nchiede la raggiungibilita' (`RichiestaRaggiungibilita.channel_type`)\ne fra quelli che il referto riporta (`RefertoDiConsegna.canale`):\nil contratto vendeva la voce e non la lasciava chiedere.\n\nIl canale CHIESTO, e non necessariamente quello che porta il\nmessaggio: l'ordine lo decide la **politica** della categoria\n(`channels`), e un messaggio chiesto `EMAIL` parte per telefono se\nla politica mette la voce prima. E' il pezzo [2] del progetto, ed e'\nvoluto — chi integra chiede «consegna questo», non «usa questa\nporta».\n\n**E con il ripiego spento il canale e' il primo della politica, non\nquello chiesto** (decisione della regia, 30/9/2026): con\n`fallback_allowed = 0` il messaggio ha **un canale solo**, ed e' il\nprimo di `channels`. Quindi un cliente con\n`channels: [\"SMS\",\"EMAIL\"]` e il ripiego spento che accoda `EMAIL`\nriceve un SMS e nient'altro. Se un giorno servisse «il canale\nCHIESTO e nessun altro», e' un'opzione in piu' della politica e non\nun cambio di questa.\n\nCon che cosa e' partito DAVVERO si legge dal filo\n(`message_event.detail_json.canale` di ogni `SENT`) e da\n`GET /v1/messages/{id}`: `channel_type` del messaggio resta il\ncanale chiesto e nessuno lo riscrive.\n"
          },
          "to": {
            "type": "string",
            "description": "Normalizzato dal servizio: E.164 o lowercase."
          },
          "subject": {
            "type": "string"
          },
          "body": {
            "type": "string",
            "description": "Contenuto libero. Il messaggio nasce da `body` (e `subject` per\nl'email), OPPURE da `modello` + `variabili`, OPPURE da un\n`allegato`: uno dei tre deve esserci. `body` e `allegato` **insieme**\nsono un 400 `ALLEGATO_NON_VALIDO`: un allegato e' un messaggio a se',\nnon si unisce al testo.\n"
          },
          "allegato": {
            "type": "object",
            "description": "Un **documento**, un **audio** o un **video** da consegnare al\ndestinatario. E' il quarto modo di far nascere un messaggio, accanto\na `body`, a `modello` + `variabili`.\n\n**Si manda un `link` o un `id` del media, MAI i byte.** Il corpo\ndella richiesta ha un tetto di 256 kB: un audio di dieci secondi lo\nsupera, e lo supererebbe proprio il giorno in cui la funzione\ncomincia a funzionare (un 413 al posto dell'allegato). I byte si\ncaricano dal fornitore e si passano per riferimento, come fanno i\ncanali veri.\n\n**E' il CANALE a decidere che cosa porta**: WhatsApp porta documenti,\naudio e video, Telegram anche, Teams solo documenti (per link), la posta\nelettronica solo documenti (per link), SMS niente. Un allegato chiesto su\nun canale che non lo porta e' un 422 `ALLEGATO_NON_SUPPORTATO` che\n**nomina il canale** e cosa porta al suo posto — non e'\n`CHANNEL_NOT_ALLOWED`, che e' il rifiuto della CHIAVE e parla\nd'altro.\n\nFuori dalla finestra di 24 h di WhatsApp un allegato non parte come\ntesto libero: resta in coda e finisce `FAILED`, perche' la regola e'\ndi Meta e vale per i media come per il testo.\n",
            "required": [
              "tipo"
            ],
            "properties": {
              "tipo": {
                "type": "string",
                "enum": [
                  "documento",
                  "audio",
                  "video"
                ],
                "description": "Che cosa si manda. WhatsApp li porta tutti e tre; Telegram solo\n`documento`. Il `video` e' un mp4 per riferimento (`link` o\n`id`) e porta la `didascalia`; l'`audio` non la porta, perche'\nil fornitore non la legge su un vocale.\n"
              },
              "link": {
                "type": "string",
                "description": "URL pubblico del media, che il fornitore scarica da solo. Sulla\nposta elettronica lo scarica AgileChannels e lo allega lui: solo\n`https` sulla porta 443, solo indirizzi di internet pubblico,\nnessun reindirizzamento, al piu' 7 MiB, e niente `id` (un media\ncaricato presso un altro fornitore non c'e'). E'\n**alternativo** a `id`: darli tutti e due e' un 400\n`ALLEGATO_NON_VALIDO`, e non se ne da' nemmeno uno altrettanto.\nUn `link` che comincia per `data:` e' byte in base64 mascherati\ne si rifiuta con 400 `ALLEGATO_IN_CORPO`.\n"
              },
              "id": {
                "type": "string",
                "description": "L'identificatore di un media gia' caricato presso il fornitore.\nAlternativo a `link`.\n"
              },
              "nome": {
                "type": "string",
                "description": "Il nome del file mostrato al destinatario (solo `documento`; su\nWhatsApp diventa `filename`).\n"
              },
              "didascalia": {
                "type": "string",
                "description": "La riga di testo che accompagna il media (`documento` e `video`,\nnon `audio`; su WhatsApp diventa `caption`).\n"
              }
            }
          },
          "modello": {
            "type": "string",
            "description": "Il NOME di un modello di piattaforma (`invito-team`,\n`reset-password`, `otp`, `notifica`, `csirt`). Quando c'è, il\ncontenuto lo compone il modello e `body` non serve; le variabili si\npassano in `variabili`. Una variabile mancante o di tipo sbagliato è\nun 422, non un messaggio col buco `{{nome}}`. Il nome e la versione\ndel modello restano nel filo (`message_event.detail_json`).\n"
          },
          "variabili": {
            "type": "object",
            "description": "Le variabili del modello, dichiarate e validate col tipo."
          },
          "lingua": {
            "type": "string",
            "enum": [
              "it",
              "en"
            ],
            "description": "La lingua del destinatario; se il modello non la ha (o non è data),\nsi ripiega sull'italiano.\n"
          },
          "template": {
            "type": "object",
            "description": "Il **template approvato da Meta che il CLIENTE dichiara per nome**,\nsulla **sua** WABA. E' la strada di un terzo (per esempio Discovery)\nche ha i **suoi** template approvati e non vuole farsi creare da noi\nun `modello` nel **nostro** catalogo per ogni testo che Meta gli\napprova. Fuori dalla finestra delle 24 ore e' l'unica strada per\nparlare su WhatsApp: dentro la finestra parte il `body` libero, e il\ntemplate dichiarato non si allega.\n\nNon convive con `modello`: insieme sono due sorgenti per lo stesso\ncontenuto, e la richiesta si rifiuta con **400 `TEMPLATE_E_MODELLO`**\nnominandoli entrambi — niente precedenza silenziosa.\n\nLa **registrazione** del template e' un atto sulla **console di\nMeta**: il Postino non la fa per il cliente e non tiene le\ncredenziali della WABA di nessuno.\n",
            "required": [
              "nome"
            ],
            "properties": {
              "nome": {
                "type": "string",
                "description": "Il nome del template **come Meta lo ha approvato**."
              },
              "lingua": {
                "type": "string",
                "description": "La lingua del template approvato (per esempio `it`, `en`); se\nassente si ripiega sulla lingua della richiesta e poi\nsull'italiano.\n"
              },
              "parametri": {
                "type": "array",
                "description": "I valori dei buchi del template, **POSIZIONALI**: l'ordine e' il\ncontenuto, e un array e' l'unica forma che lo conserva (un\noggetto perderebbe l'ordine, e si rifiuta con **400\n`TEMPLATE_NON_VALIDO`**). Con N parametri attesi e nessun valore\nil messaggio non parte: fuori finestra il rifiuto nomina la\nfinestra delle 24 ore e il template.\n",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "product_code": {
            "type": "string",
            "description": "Facoltativo. Se dichiarato, deve essere il prodotto della CHIAVE:\nuna chiave di prodotto che ne dichiara un altro nel corpo e' un\ntentativo di mandare a nome di un cliente diverso dal proprio, e si\nrifiuta con 403 `PRODUCT_NOT_ALLOWED`.\n"
          },
          "category": {
            "type": "string",
            "description": "Facoltativo. Se dichiarato, deve essere una delle categorie del\nprodotto (`product_message_category`); altrimenti 422\n`CATEGORIA_NON_VALIDA`.\n"
          },
          "thread_id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "short_lived": {
            "type": "boolean",
            "default": false,
            "description": "OTP, link firmato, reset password: il **corpo** scade a 30 giorni\nqualunque sia la conservazione dello scope, e resta l'indice.\nDecisione della regia del 17/9 16:07.\n"
          },
          "read_receipt": {
            "type": "boolean",
            "default": false,
            "description": "Fase P1. Chiedi al destinatario di **confermare la lettura**. Il\nPostino aggiunge in coda al testo una riga sola con un link\nfirmato (email) o l'invito a rispondere (Telegram); quando il\ndestinatario conferma, il messaggio passa a `READ` e il fatto entra\nnella catena di audit, da cui e' **dimostrabile**.\n\nNon e' l'apertura tracciata dal fornitore (il pixel di Mailgun, il\n`read` di WhatsApp): quella e' un indizio, che produce anche un\nantivirus e che sopprime un client che blocca le immagini. Questa\ne' un **atto del destinatario**, ed e' cio' che si puo' portare a\nchi chiede una prova.\n\nVale su `EMAIL`, `TELEGRAM` e `VOICE`. Sulla voce la conferma non\ncosta un link: il Postino chiude l'avviso chiedendo di premere un\ntasto, e il tono che torna nella CDR e' l'atto (`via`\n`tasto-sul-telefono` nel referto). Sugli altri canali il campo si\naccetta e non produce nulla: non c'e' ancora un modo di raccogliere\nla conferma, e fingere che ci sia sarebbe peggio.\n"
          },
          "persona": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "Facoltativo (aggiunto l'11/10/2026, migrazione 043, chiesto da TRPG).\nL'`agent_key` della persona digitale **per conto di cui** il\nprodotto spedisce: si scrive sul messaggio e torna in\n`GET /v1/messages/{id}`. Lo stesso nome che AgileLLM usa nel corpo.\n\nE' **attribuzione, non identita'**: chi parla — il nome nel\nmittente, la firma, la voce della telefonata — resta il postino\ndello scope, dichiarato all'acquisto (`postman_agent_key`). Una\npersona nel corpo non veste il messaggio, perche' una chiave non\ndeve poter parlare col nome e la voce della persona di un altro\nprodotto.\n\nUna stringa vuota, non stringa, con caratteri di controllo o oltre\n120 caratteri si rifiuta con **400 `PERSONA_NON_VALIDA`**, prima di\naccodare.\n"
          }
        }
      },
      "EsitoAccodamento": {
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "status": {
            "type": "string",
            "const": "QUEUED"
          },
          "resolved_from": {
            "type": "string",
            "enum": [
              "USER",
              "TENANT",
              "PRODUCT",
              "PLATFORM"
            ],
            "description": "Il grado che ha vinto la cascata."
          }
        }
      },
      "EsitoSandbox": {
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "status": {
            "type": "string",
            "const": "QUEUED"
          },
          "sandbox": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "EsitoDoppione": {
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "status": {
            "type": "string",
            "enum": [
              "QUEUED",
              "SENT",
              "DELIVERED",
              "READ",
              "FAILED",
              "SUPPRESSED",
              "EXPIRED"
            ],
            "description": "Lo stato VERO del primo messaggio, non una costante. `EXPIRED`\ncompreso: un reinvio con la stessa `Idempotency-Key` di un\nmessaggio scaduto riceve quello stato (vedi `Messaggio.status`).\n"
          },
          "resolved_from": {
            "type": "string",
            "enum": [
              "USER",
              "TENANT",
              "PRODUCT",
              "PLATFORM"
            ]
          },
          "duplicate": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "EsitoDoppioneSandbox": {
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "status": {
            "type": "string",
            "enum": [
              "QUEUED",
              "SENT",
              "DELIVERED",
              "READ",
              "FAILED",
              "SUPPRESSED",
              "EXPIRED"
            ]
          },
          "sandbox": {
            "type": "boolean",
            "const": true
          },
          "duplicate": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "Messaggio": {
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "status": {
            "type": "string",
            "enum": [
              "QUEUED",
              "SENT",
              "DELIVERED",
              "READ",
              "FAILED",
              "SUPPRESSED",
              "EXPIRED"
            ],
            "description": "**`EXPIRED` c'e'** (aggiunto l'8/10/2026): vuol dire «e' passato il\ntempo **prima** che partisse» (`DECISIONI.md`, 29/9/2026), ed e' lo\nstato con cui si chiude una riga scaduta — la regola degli OTP, e\nquella con cui riparte la coda di un cliente che era sospeso. Lo\nscrive il lavoratore, lo proietta `src/proiettore.js` e la colonna\n`message.status` lo accetta. Dichiararne sei faceva **rifiutare a\nun cliente severo una nostra risposta vera**: non e' un refuso di\ndocumentazione, e' la stessa famiglia di difetti di\n`trattenuto.motivo`.\n"
          },
          "cost_micros": {
            "type": "integer",
            "description": "Dato **contabile**, non contatore: immutabile a mese chiuso,\nriconciliabile con l'estratto del fornitore, esportabile ad\nagilefattura. AgileChannels **non fattura**: produce le righe.\n"
          },
          "persona": {
            "type": [
              "string",
              "null"
            ],
            "description": "La `persona` dichiarata nell'invio (`RichiestaInvio.persona`), o\n`null` se non e' stata dichiarata. Sempre presente.\n"
          },
          "body_purged_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "trattenuto": {
            "type": [
              "object",
              "null"
            ],
            "description": "**La coda tiene fermo questo messaggio di proposito, e lo dice.**\n`null` quando non c'e' nessuna attesa dichiarata — la voce c'e'\nsempre, perche' un campo che compare solo quando c'e' qualcosa non\nlo nota nessuno quando manca.\n\nOggi il motivo e' uno solo, `SILENZIO`: l'ora del destinatario e'\ndentro la finestra di silenzio della sua categoria (22-8 per\n`avviso` e `marketing` nel seme delle regole), quindi l'avviso parte\nalle 8 invece che alle 23. **Non e' un guasto**: e' la politica che\nfunziona, ed e' il motivo per cui questa voce esiste.\n\nVale **finche' vale l'istante**: passato `fino_a` il campo torna\n`null` anche se il motivo resta scritto nella riga. Un motivo di\nieri che spiega un messaggio fermo oggi sarebbe una perdita nascosta\ndietro una buona notizia.\n",
            "properties": {
              "motivo": {
                "type": "string",
                "enum": [
                  "SILENZIO",
                  "MURO_DEL_GIORNO",
                  "TETTO_DEL_MESE"
                ],
                "description": "`SILENZIO` e' la finestra della politica. `MURO_DEL_GIORNO` e'\nil tetto giornaliero presso il fornitore\n(`provider_account.hard_limit_day`): quel muro non cade dopo N\nore, cade a mezzanotte, quindi il messaggio aspetta invece di\nbruciare gli otto tentativi in quattro minuti. Era dalla coda\ndal 30/9 e da questo elenco no: un client generato dal contratto\ne severo sull'enum avrebbe rifiutato una nostra risposta vera.\n"
              },
              "fino_a": {
                "type": "string",
                "format": "date-time",
                "description": "Quando riparte. E' l'istante del MOTORE, in UTC."
              }
            }
          },
          "ripiego_in_corso": {
            "type": [
              "object",
              "null"
            ],
            "description": "**Il messaggio ha fallito su un canale e sta uscendo su un altro.**\n`null` quando non sta ripiegando.\n\nLo si legge insieme a `status: FAILED`, e i due non si contraddicono:\nlo stato dice che il canale chiesto non ce l'ha fatta — la chiamata a\ncui nessuno ha risposto, per esempio — e questa voce dice che la\nconsegna **sta andando avanti** su un'altra strada. Senza di lei un\nripiego in corso si legge come un fallimento e basta, che e' la\nrisposta peggiore possibile per un messaggio che sta per arrivare.\n\nCompare solo finche' la riga di coda nuova e' **viva**: in DLQ, o\npassato il tempo di grazia senza che il ripiego sia partito, il campo\ntorna `null` — allora il ripiego davvero non parte, e continuare a\ndichiararlo sarebbe una perdita vestita da consegna in corso.\n",
            "properties": {
              "da": {
                "type": "string",
                "format": "date-time",
                "description": "Da quando e' in coda la riga NUOVA (non da quando e' nato il\nmessaggio: un ripiego appena partito su un messaggio di ieri e'\nappena partito).\n"
              },
              "eta_ms": {
                "type": "integer",
                "description": "Da quanto",
                "in millisecondi.": null
              }
            }
          },
          "attese_dichiarate_complete": {
            "type": "boolean",
            "description": "**Se l'elenco qui sotto si puo' leggere come completo.** `true` nel\ncaso normale. `false` quando la potatura delle attese dichiarate\n(migrazione 028) e' arrivata **oltre l'accodamento di questo\nmessaggio**: allora l'elenco puo' essere incompleto e il suo vuoto NON\ne' piu' una risposta. Lo stesso campo, con lo stesso valore, sta nel\nreferto di consegna firmato: lo calcola un posto solo.\n"
          },
          "attese_dichiarate": {
            "type": "array",
            "description": "**Le attese che ci SONO STATE, e chi le ha decise.** Elenco vuoto\nquando non e' stato trattenuto niente: il vuoto e' esso stesso una\nrisposta, «no, quel ritardo non l'abbiamo deciso noi».\n\n`trattenuto` qui sopra vale all'istante della risposta e vive quanto\nla riga di coda, che sparisce quando il messaggio parte davvero.\nQuesta voce risponde alla domanda che si fa DOPO — «perche' il mio\navviso di ieri sera e' arrivato alle 8?» — e la sua sorgente e' un\nregistro append-only (migrazione 027) che sopravvive alla consegna e\nsi pota con la **conservazione dichiarata dell'indice** del cliente,\nmai prima della finestra del pilota (migrazione 028).\n\nE' anche il motivo per cui la **mediana del pilota** puo' scalare\nquelle ore dal tempo di consegna: cio' che si sottrae e' scritto\nqui, con il motivo e i due istanti.\n",
            "items": {
              "type": "object",
              "properties": {
                "motivo": {
                  "type": "string",
                  "enum": [
                    "SILENZIO",
                    "MURO_DEL_GIORNO",
                    "TETTO_DEL_MESE"
                  ]
                },
                "dal": {
                  "type": "string",
                  "format": "date-time",
                  "description": "Quando e' cominciata l'attesa. Istante del MOTORE, in UTC."
                },
                "fino_a": {
                  "type": "string",
                  "format": "date-time",
                  "description": "Quando sarebbe finita. Puo' essere prima di `dal` meno di\nquanto si e' aspettato davvero, se il messaggio e' ripartito\nprima (un riaccodamento a mano dalla DLQ): la mediana del\npilota infatti taglia alla consegna e non scala mai piu' di\nquanto si sia atteso.\n"
                },
                "ms": {
                  "type": "integer",
                  "description": "Quanto e' durata",
                  "in millisecondi.": null
                }
              }
            }
          },
          "notifica_trattenuta": {
            "type": [
              "object",
              "null"
            ],
            "description": "**Un esito e' arrivato e NON te l'abbiamo notificato, perche' il tuo\nprodotto (o il tuo tenant) era sospeso.** `null` quando non ti e'\nstato trattenuto nessuno stato di questo messaggio — la voce c'e'\nsempre, e qui il `null` e' esso stesso una risposta.\n\nE' l'unica delle tre voci del silenzio in cui il silenzio l'abbiamo\ndeciso NOI. Una sospensione o una disdetta non butta via l'entrata:\nl'evento del fornitore si registra, la catena si scrive, lo stato del\nmessaggio si muove — si ferma solo cio' che ESCE, cioe' la chiamata\nverso l'URL che hai iscritto, e si ferma con questo motivo scritto\nsulla riga dell'entrata. Questa voce e' il posto dove lo leggi, dopo\nla riapertura: chi e' ancora sospeso non arriva nemmeno qui, perche'\nla porta gli risponde `403 CLIENTE_SOSPESO`.\n\nEsce il FATTO e niente altro: mai il corpo grezzo del webhook del\nfornitore, che porta dentro l'indirizzo del destinatario in chiaro.\nPer lo stato di ADESSO non serve una ripetizione della notifica: lo\nporta questa stessa risposta.\n",
            "properties": {
              "motivo": {
                "type": "string",
                "enum": [
                  "CLIENTE_SOSPESO",
                  "TENANT_SOSPESO"
                ],
                "description": "Le STESSE parole con cui la porta rifiuta la chiave\n(`chiavi.scopeDaChiave`). Una sospensione, un nome.\n"
              },
              "dal": {
                "type": "string",
                "format": "date-time",
                "description": "Da quando: l'istante del PRIMO esito trattenuto, cioe' da quando\nnon ti diciamo niente di questo messaggio.\n"
              }
            }
          },
          "attempts": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Quanti tentativi di consegna sono stati fatti. `null` quando non\nc'e' (piu') una riga di coda: un messaggio consegnato non ne ha una.\n"
          },
          "last_error": {
            "type": [
              "string",
              "null"
            ],
            "description": "**Perche' l'ultimo tentativo non e' andato**, con le parole con cui\nil motore l'ha scritto. `null` quando nessun tentativo e' fallito —\ne il `null` e' esso stesso una risposta.\n\nE' il compagno obbligato delle voci qui sopra: `trattenuto`,\n`ripiego_in_corso`, `attese_dichiarate` e `notifica_trattenuta`\nraccontano un messaggio che **sta andando avanti**; questa racconta\nquello che non ci va. Prima esisteva solo in `GET /v1/dlq`, cioe'\n**solo da morto**: nella scena misurata il 6/10 (chiave e DEK si,\n`channel_config` a zero righe) il primo messaggio vero del primo\ncliente vero aveva la sua ragione dal PRIMO tentativo, e per tutti\ngli otto — ore — questa rotta diceva `QUEUED` e `/v1/dlq` tornava\nzero righe: la ragione non stava da nessuna parte.\n\nStesso nome e stesso significato in `GET /v1/dlq`: chi legge\nl'elenco dei morti riusa il suo codice.\n"
          },
          "dead_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "**Quando si e' smesso di ritentare, per sempre.** `null` finche' un\naltro tentativo e' possibile.\n\nE' l'unica voce che distingue un `FAILED` che sta per essere\nritentato da uno che non lo sara' mai piu': senza, i due si leggono\nidentici. Un messaggio morto si puo' ancora rimettere in coda da\n`POST /v1/dlq/{id}/retry`.\n"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "event": {
                  "type": "string"
                },
                "occurred_at": {
                  "type": "string",
                  "format": "date-time",
                  "description": "L'ora **del fornitore**, non la nostra."
                },
                "provider_event_id": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "RefertoDiConsegna": {
        "type": "object",
        "description": "Il referto di consegna. Nessun campo porta contenuti: il corpo e'\nrappresentato dalla sua impronta e il destinatario non compare.\n",
        "properties": {
          "versione": {
            "type": "string",
            "const": "r1"
          },
          "emesso_il": {
            "type": "string",
            "format": "date-time"
          },
          "message_id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "owner_key": {
            "type": "string",
            "description": "Lo scope proprietario, nella forma GRADO|prodotto|tenant|utente."
          },
          "canale": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ],
            "description": "**Il canale dell'accodamento**, cioe' quello da cui il messaggio e'\npartito: e' il campo che i clienti leggono per sapere che cosa hanno\nchiesto, e non cambia mai, nemmeno quando il ripiego consegna su un\naltro canale.\n\nIl canale che ha CONSEGNATO davvero sta in `canale_di_consegna`: i\ndue coincidono quando non c'e' stato ripiego, e differiscono quando\nce n'e' stato uno. «Che cosa ho chiesto» e «dove e' arrivato» sono\ndue domande con due campi, perche' confonderle era il difetto.\n"
          },
          "canale_di_consegna": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS",
              null
            ],
            "description": "**Il canale che ha consegnato davvero**: l'ultimo `SENT` della\ncatena, cioe' il canale dell'ULTIMO tentativo che e' riuscito.\n\nE' l'ultimo e non il primo perche' un messaggio puo' attraversare\ndue canali (il tentativo sul canale chiesto fallisce, il ripiego\nconsegna su un altro), ed e' l'ultimo che dice dove e' arrivato. Il\nvalore viene dal `detail_json` del `SENT`, dove lo scrive la\npartenza.\n\n`null` quando il messaggio non e' ancora partito: la voce c'e'\nsempre, e il `null` e' esso stesso un'attestazione — «non e'\n(ancora) partito di qui». Per i `SENT` scritti prima che il canale\nfinisse nel filo vale il canale dell'accodamento, che per quelle\nrighe e' l'unica attestazione disponibile.\n\nSta DENTRO la firma, come tutto il resto del documento: un fatto\nservito e non attestato, in una contestazione, e' peggio di un fatto\nassente.\n"
          },
          "direzione": {
            "type": "string",
            "enum": [
              "OUT",
              "IN"
            ]
          },
          "accodato_il": {
            "type": "string",
            "format": "date-time"
          },
          "stato": {
            "type": "string",
            "enum": [
              "QUEUED",
              "SENT",
              "DELIVERED",
              "READ",
              "FAILED",
              "SUPPRESSED",
              "EXPIRED"
            ],
            "description": "Ricalcolato dagli eventi, non letto da una colonna. `EXPIRED`\ncompreso: la prova di consegna di un messaggio scaduto e' un\ndocumento che si vende come gli altri, e chi lo valida contro questo\ncontratto non deve poterlo dichiarare non valido.\n"
          },
          "id_fornitore": {
            "type": [
              "string",
              "null"
            ],
            "description": "L'identificativo del messaggio presso il fornitore: serve a chi\nriceve il referto per chiedere conto **allo stesso fornitore**,\nsenza passare da noi.\n"
          },
          "corpo": {
            "type": "object",
            "description": "L'impronta di cio' che e' stato mandato, mai il testo.",
            "properties": {
              "sha256": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "`null` per i messaggi `short_lived`: l'impronta di un OTP di\nsei cifre si indovina con un ciclo, e pubblicarla sarebbe\npubblicare il segreto a chi tiene il referto. In quel caso\n`perche_senza_impronta` lo dice.\n"
              },
              "perche_senza_impronta": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "cancellato_il": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "motivo_cancellazione": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "RETENTION",
                  "SHORT_LIVED",
                  "GDPR_REQUEST",
                  null
                ]
              }
            }
          },
          "eventi": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "evento": {
                  "type": "string"
                },
                "canale": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "enum": [
                    "EMAIL",
                    "WHATSAPP",
                    "SMS",
                    "TELEGRAM",
                    "VOICE",
                    "TEAMS",
                    null
                  ],
                  "description": "**Il canale a cui appartiene QUESTO evento**, quando l'evento\nlo dichiara nel suo `detail_json`: per un `SENT` e' il canale\nche ha consegnato quel tentativo, per un `FAILED` e' il canale\nsu cui il tentativo e' fallito. Serve a leggere le catene che\nattraversano due canali — il tentativo fallito e il ripiego\nriuscito — riga per riga.\n\n`null` quando l'evento non dichiara il canale (gli eventi\nscritti prima che il canale finisse nel filo, e quelli che non\nnascono da un invio, per esempio gli stati del destinatario).\n"
                },
                "avvenuto_il": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date-time"
                },
                "registrato_il": {
                  "type": "string",
                  "format": "date-time"
                },
                "fonte": {
                  "type": "string",
                  "enum": [
                    "fornitore",
                    "destinatario",
                    "noi"
                  ]
                },
                "id_evento_fornitore": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "motivo": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "**Il perche' dell'evento, con le parole del fornitore**, quando\nil fornitore lo dichiara: per un `FAILED` sul canale `VOICE` e'\nl'esito della CDR (`NO_ANSWER`, `BUSY`, `REJECTED`…), per un\nrimbalzo email la ragione del rifiuto. Si legge dal `detail_json`\ndell'evento, dove l'entrata dei webhook lo scrive accanto allo\nstato normalizzato: e' il campo che distingue «non ha risposto»\nda «il numero non esiste» dentro lo stesso `FAILED`.\n\n`null` quando l'evento non ha un motivo — gli stati che nascono\nda noi (l'accodamento, la partenza) e i fornitori che non ne\nmandano uno. Il campo c'e' sempre, perche' su un referto firmato\nanche il `null` e' un'attestazione.\n"
                }
              }
            }
          },
          "conferma_di_lettura": {
            "type": [
              "object",
              "null"
            ],
            "description": "L'atto del destinatario, quando c'e'. `via` dice COME l'ha compiuto,\ne le vie sono TRE, una per forma di canale: `link-firmato` (il click\nsul link che il Postino aggiunge in coda al testo, su `EMAIL`),\n`risposta-sul-canale` (la risposta breve nello stesso filo, su\n`TELEGRAM`) e `tasto-sul-telefono` (il tasto premuto mentre il\nPostino parla, su `VOICE`: durante una chiamata non c'e' niente da\ncliccare e niente a cui rispondere, quindi l'atto e' il tono, che\ntorna dalla CDR del fornitore). `concorde` dice se il filo degli eventi e la\ncatena raccontano la stessa conferma; falso significa che uno dei\ndue registri e' stato toccato, ed e' il solo controllo che vede una\nmanomissione degli EVENTI.\n",
            "properties": {
              "avvenuta_il": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "via": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "link-firmato",
                  "risposta-sul-canale",
                  "tasto-sul-telefono",
                  null
                ]
              },
              "da": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "catena": {
                "type": [
                  "object",
                  "null"
                ]
              },
              "concorde": {
                "type": "boolean"
              },
              "perche": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "trattenuto": {
            "type": [
              "object",
              "null"
            ],
            "description": "**La coda tiene fermo questo messaggio di proposito, e lo dice.**\n`null` quando non c'e' nessuna attesa dichiarata — la voce c'e'\nsempre, perche' un campo che compare solo quando c'e' qualcosa non\nlo nota nessuno quando manca.\n\nOggi il motivo e' uno solo, `SILENZIO`: l'ora del destinatario e'\ndentro la finestra di silenzio della sua categoria (22-8 per\n`avviso` e `marketing` nel seme delle regole), quindi l'avviso parte\nalle 8 invece che alle 23. **Non e' un guasto**: e' la politica che\nfunziona, ed e' il motivo per cui questa voce esiste.\n\nVale **finche' vale l'istante**: passato `fino_a` il campo torna\n`null` anche se il motivo resta scritto nella riga. Un motivo di\nieri che spiega un messaggio fermo oggi sarebbe una perdita nascosta\ndietro una buona notizia.\n",
            "properties": {
              "motivo": {
                "type": "string",
                "enum": [
                  "SILENZIO",
                  "MURO_DEL_GIORNO",
                  "TETTO_DEL_MESE"
                ],
                "description": "`SILENZIO` e' la finestra della politica. `MURO_DEL_GIORNO` e'\nil tetto giornaliero presso il fornitore\n(`provider_account.hard_limit_day`): quel muro non cade dopo N\nore, cade a mezzanotte, quindi il messaggio aspetta invece di\nbruciare gli otto tentativi in quattro minuti. Era dalla coda\ndal 30/9 e da questo elenco no: un client generato dal contratto\ne severo sull'enum avrebbe rifiutato una nostra risposta vera.\n"
              },
              "fino_a": {
                "type": "string",
                "format": "date-time",
                "description": "Quando riparte. E' l'istante del MOTORE, in UTC."
              }
            }
          },
          "ripiego_in_corso": {
            "type": [
              "object",
              "null"
            ],
            "description": "**Il messaggio ha fallito su un canale e sta uscendo su un altro.**\n`null` quando non sta ripiegando.\n\nLo si legge insieme a `status: FAILED`, e i due non si contraddicono:\nlo stato dice che il canale chiesto non ce l'ha fatta — la chiamata a\ncui nessuno ha risposto, per esempio — e questa voce dice che la\nconsegna **sta andando avanti** su un'altra strada. Senza di lei un\nripiego in corso si legge come un fallimento e basta, che e' la\nrisposta peggiore possibile per un messaggio che sta per arrivare.\n\nCompare solo finche' la riga di coda nuova e' **viva**: in DLQ, o\npassato il tempo di grazia senza che il ripiego sia partito, il campo\ntorna `null` — allora il ripiego davvero non parte, e continuare a\ndichiararlo sarebbe una perdita vestita da consegna in corso.\n",
            "properties": {
              "da": {
                "type": "string",
                "format": "date-time",
                "description": "Da quando e' in coda la riga NUOVA (non da quando e' nato il\nmessaggio: un ripiego appena partito su un messaggio di ieri e'\nappena partito).\n"
              },
              "eta_ms": {
                "type": "integer",
                "description": "Da quanto",
                "in millisecondi.": null
              }
            }
          },
          "attese_dichiarate_complete": {
            "type": "boolean",
            "description": "**Se l'elenco qui sotto si puo' leggere come completo.** `true` nel\ncaso normale. `false` quando la potatura delle attese dichiarate\n(migrazione 028) e' arrivata **oltre l'accodamento di questo\nmessaggio**: allora l'elenco puo' essere incompleto e il suo vuoto\nNON attesta piu' niente.\n\nEsiste perche' il vuoto di `attese_dichiarate` e' una dichiarazione\nfirmata — «no, quel ritardo non l'abbiamo deciso noi» — e su un\nmessaggio le cui attese sono state potate quella frase sarebbe falsa\nnel verso che condanna noi. Sta **dentro la firma**, come tutto il\nresto del documento.\n"
          },
          "attese_dichiarate": {
            "type": "array",
            "description": "**Le attese che ci SONO STATE, e chi le ha decise.** Elenco vuoto\nquando non e' stato trattenuto niente: il vuoto e' esso stesso una\nrisposta, «no, quel ritardo non l'abbiamo deciso noi».\n\n`trattenuto` qui sopra vale all'istante della risposta e vive quanto\nla riga di coda, che sparisce quando il messaggio parte davvero.\nQuesta voce risponde alla domanda che si fa DOPO — «perche' il mio\navviso di ieri sera e' arrivato alle 8?» — e la sua sorgente e' un\nregistro append-only (migrazione 027) che sopravvive alla consegna e\nsi pota con la **conservazione dichiarata dell'indice** del cliente,\nmai prima della finestra del pilota (migrazione 028). Per questo il\nvuoto attesta **solo** quando `attese_dichiarate_complete` e' `true`.\n\nE' anche il motivo per cui la **mediana del pilota** puo' scalare\nquelle ore dal tempo di consegna: cio' che si sottrae e' scritto\nqui, con il motivo e i due istanti.\n",
            "items": {
              "type": "object",
              "properties": {
                "motivo": {
                  "type": "string",
                  "enum": [
                    "SILENZIO",
                    "MURO_DEL_GIORNO",
                    "TETTO_DEL_MESE"
                  ]
                },
                "dal": {
                  "type": "string",
                  "format": "date-time",
                  "description": "Quando e' cominciata l'attesa. Istante del MOTORE, in UTC."
                },
                "fino_a": {
                  "type": "string",
                  "format": "date-time",
                  "description": "Quando sarebbe finita. Puo' essere prima di `dal` meno di\nquanto si e' aspettato davvero, se il messaggio e' ripartito\nprima (un riaccodamento a mano dalla DLQ): la mediana del\npilota infatti taglia alla consegna e non scala mai piu' di\nquanto si sia atteso.\n"
                },
                "ms": {
                  "type": "integer",
                  "description": "Quanto e' durata",
                  "in millisecondi.": null
                }
              }
            }
          },
          "notifica_trattenuta": {
            "type": [
              "object",
              "null"
            ],
            "description": "**Un esito e' arrivato e NON te l'abbiamo notificato, perche' il tuo\nprodotto (o il tuo tenant) era sospeso.** `null` quando non ti e'\nstato trattenuto nessuno stato di questo messaggio — la voce c'e'\nsempre, e qui il `null` e' esso stesso una risposta.\n\nE' l'unica delle tre voci del silenzio in cui il silenzio l'abbiamo\ndeciso NOI. Una sospensione o una disdetta non butta via l'entrata:\nl'evento del fornitore si registra, la catena si scrive, lo stato del\nmessaggio si muove — si ferma solo cio' che ESCE, cioe' la chiamata\nverso l'URL che hai iscritto, e si ferma con questo motivo scritto\nsulla riga dell'entrata. Questa voce e' il posto dove lo leggi, dopo\nla riapertura: chi e' ancora sospeso non arriva nemmeno qui, perche'\nla porta gli risponde `403 CLIENTE_SOSPESO`.\n\nEsce il FATTO e niente altro: mai il corpo grezzo del webhook del\nfornitore, che porta dentro l'indirizzo del destinatario in chiaro.\nPer lo stato di ADESSO non serve una ripetizione della notifica: lo\nporta questa stessa risposta.\n",
            "properties": {
              "motivo": {
                "type": "string",
                "enum": [
                  "CLIENTE_SOSPESO",
                  "TENANT_SOSPESO"
                ],
                "description": "Le STESSE parole con cui la porta rifiuta la chiave\n(`chiavi.scopeDaChiave`). Una sospensione, un nome.\n"
              },
              "dal": {
                "type": "string",
                "format": "date-time",
                "description": "Da quando: l'istante del PRIMO esito trattenuto, cioe' da quando\nnon ti diciamo niente di questo messaggio.\n"
              }
            }
          },
          "catena": {
            "type": "object",
            "description": "L'esito del RICALCOLO dell'intera catena di audit dello scope, non\nla sua esistenza: `integra: false` e' una manomissione trovata.\n",
            "properties": {
              "integra": {
                "type": "boolean"
              },
              "ultima_impronta": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "perche": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "firma": {
            "type": [
              "object",
              "null"
            ],
            "description": "`alg` e l'impronta del segreto usato (`secret_fp`), per sapere\nQUALE segreto verifica senza doverli provare tutti. La firma vera\nsta negli header. `null` quando il referto non e' firmato: il\nmotivo sta in `perche_senza_firma`.\n",
            "properties": {
              "alg": {
                "type": "string",
                "const": "HMAC-SHA256"
              },
              "secret_fp": {
                "type": "string"
              }
            }
          },
          "perche_senza_firma": {
            "type": [
              "string",
              "null"
            ],
            "description": "Quando `firma` e' `null`, il motivo: «nessuna iscrizione attiva per\nquesto scope», «il caveau non ha risposto» o «il servizio non sa\nleggere il caveau». Tre risposte diverse al cliente, perche' si\nriparano in tre modi diversi. `null` quando il referto e' firmato.\n"
          },
          "firma_servizio": {
            "type": [
              "object",
              "null"
            ],
            "description": "La SECONDA firma, asimmetrica (Ed25519): `alg` e' l'algoritmo e\n`kid` l'impronta della chiave pubblica, per sapere QUALE chiedere a\n`GET /v1/prova/chiavi-pubbliche` senza indovinarla. La firma vera\nsta nell'header `X-AgileChannels-Signature-Ed25519`, sulla stessa\nstringa dell'HMAC. `null` quando il servizio non ha potuto firmare:\nil motivo sta in `perche_senza_firma_servizio`.\n",
            "properties": {
              "alg": {
                "type": "string",
                "const": "Ed25519"
              },
              "kid": {
                "type": "string"
              }
            }
          },
          "perche_senza_firma_servizio": {
            "type": [
              "string",
              "null"
            ],
            "description": "Quando `firma_servizio` e' `null`, il motivo, e sono QUATTRO\nperche' hanno quattro rimedi diversi: «la chiave di firma non c'e'\nnel caveau» (mai depositata), «il caveau non ha risposto» (guasto\nnostro momentaneo), «la chiave di firma nel caveau non e' una chiave\nEd25519» (depositata o ruotata male) o «il servizio non sa leggere\nil caveau» (configurazione nostra). Nessuno dipende da una tua\niscrizione, e **solo il secondo si ripara ritentando**: sugli altri\ntre un ritenta a raffica non cambia niente, la riparazione e'\nnostra. Il campo resta una stringa libera senza `enum`: le frasi\npossono crescere, la regola del ritenta no. `null` quando il referto\nporta la seconda firma.\n"
          }
        }
      },
      "ChiaviPubbliche": {
        "type": "object",
        "description": "Le chiavi pubbliche con cui si verifica la seconda firma (Ed25519) del\nreferto. Un `kid` per chiave, lo stesso che il referto porta in\n`firma_servizio.kid`: chi verifica sa QUALE chiedere senza indovinarla.\n",
        "properties": {
          "chiavi": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "kid": {
                  "type": "string"
                },
                "alg": {
                  "type": "string",
                  "const": "Ed25519"
                },
                "pubblica_pem": {
                  "type": "string"
                }
              }
            }
          },
          "perche_senza_firma_servizio": {
            "type": "string",
            "nullable": true,
            "description": "IL MOTIVO per cui ADESSO non si firma. C'e' quando `chiavi` e'\nvuoto e ANCHE quando l'elenco e' pieno, se in questo momento la\nchiave non si legge: l'elenco conserva le chiavi che il servizio ha\nvisto vive (una rotazione non deve invalidare le prove gia'\nvendute), quindi «l'elenco c'e'» e «adesso si firma» sono due fatti\ndiversi, e chi verifica un referto da fuori ha bisogno del secondo:\nse cerca il suo `kid` e non lo trova, deve sapere se quella chiave\nnon esiste (il documento non si verifica ne' oggi ne' domani) o se\nil caveau non ha risposto adesso (basta richiedere l'elenco fra un\nminuto). Non compare quando si firma: un motivo che c'e' anche\nallora non spiega niente, perche' si giudica per DIFFERENZA.\nStesso nome e stessi valori del campo omonimo del referto, e stesso\ncontenuto che `/salute` pubblica in `firma_servizio.motivo`.\n"
          }
        }
      },
      "RichiestaConfigurazione": {
        "type": "object",
        "required": [
          "provider",
          "secret_handle",
          "sender_identity"
        ],
        "properties": {
          "provider": {
            "type": "string",
            "enum": [
              "mailgun",
              "telegram",
              "whatsapp",
              "sms",
              "beevoip",
              "sendgrid",
              "postmark",
              "ses",
              "twilio",
              "infobip",
              "twiliosms",
              "vonage",
              "360dialog",
              "twiliovoice",
              "teams"
            ]
          },
          "secret_handle": {
            "type": "string",
            "description": "Il NOME nel caveau (es. `adp__mailgun__api_key`). Mai il valore."
          },
          "sender_identity": {
            "type": "string",
            "minLength": 1,
            "description": "L'identita' con cui questa linea si presenta al destinatario, e il\nlegame col conto presso il fornitore (`provider_account.account_ref`).\nPer canale: EMAIL `Nome Visibile <noreply@dominio>` sul dominio\nverificato; SMS il mittente alfanumerico registrato o il numero\nE.164; WHATSAPP il `phone_number_id` che il token governa; TELEGRAM\nl'id NUMERICO del bot (non l'`@username`: un username si cambia da\nBotFather, un id no); VOICE il numero chiamante (`callerId`) in E.164.\n"
          },
          "priority": {
            "type": "integer",
            "minimum": 0,
            "maximum": 65535,
            "description": "Il POSTO della linea nell'ordine di invio: piu' piccolo = provata\nprima. Se manca, la linea va in fondo (`MAX(priority)+1` fra le\nlinee dello stesso canale dello stesso proprietario, 0 se e' la\nprima) e una linea gia' accolta che si riaccoglie **tiene il suo\nposto**. Se dichiarata e non intera fra 0 e 65535: 400\n`ORDINE_NON_VALIDO`, e non si scrive niente.\n"
          },
          "retention_index_days": {
            "type": "integer"
          },
          "retention_body_days": {
            "type": "integer"
          }
        }
      },
      "ConfigurazioneEffettiva": {
        "type": "object",
        "properties": {
          "channel_type": {
            "type": "string"
          },
          "fornitori": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IL CATALOGO di questo canale: i nomi dei fornitori che il registro\ndegli adattatori dichiara per `channel_type`, cioe' l'elenco fra cui\nun applet di configurazione fa scegliere il tenant (titolare,\n9/10/2026, A218). E' lo stesso elenco che il 400\n`FORNITORE_SCONOSCIUTO` porta nel rifiuto, dalla stessa funzione\n(`registro.fornitoriDelCanale`): una seconda lista divergerebbe al\nprimo adattatore nuovo.\n\nLo portano le risposte della `GET` — **anche il 404\n`CANALE_NON_CONFIGURATO`**, che e' il momento in cui l'applet deve\ndisegnare la scelta e prima di oggi era un `code` e un `message`.\nSenza, a chi integra restavano due strade ed erano entrambe\nsbagliate: tentare una `PUT` sbagliata di proposito per leggere il\ncatalogo dal 400, o scriversi la lista dentro. Un `channel_type`\nche non e' un canale ha catalogo vuoto (`[]`).\n"
          },
          "line_id": {
            "type": "string",
            "description": "L'id della riga di `channel_config` di cui questa risposta parla.\nNella `PUT` e' la riga **appena scritta** (non la vincitrice della\ncascata): e' il NOME che serve a `GET /v1/lines/{id}/salute` e a\n`POST /v1/lines/{id}/ritiro`.\n"
          },
          "priority": {
            "type": "integer",
            "minimum": 0,
            "maximum": 65535,
            "description": "Il posto nell'ordine di invio della riga di cui la risposta parla.\nNella `PUT` e' l'ordine SCRITTO, non quello della vincitrice.\n"
          },
          "vince_la_cascata": {
            "type": "object",
            "description": "Presente SOLO nella risposta della `PUT`, e solo quando la riga\nappena scritta NON e' quella che vincera' la cascata: dice chi\nspedira' davvero, senza oscurare chi hai scritto. Assente quando la\nriga scritta e' proprio la vincitrice.\n",
            "properties": {
              "line_id": {
                "type": "string"
              },
              "sender_identity": {
                "type": "string"
              }
            }
          },
          "provider": {
            "type": "string"
          },
          "sender_identity": {
            "type": "string"
          },
          "secret_fp": {
            "type": "string",
            "description": "12 caratteri di sha256 del segreto. Serve a confrontare senza leggere."
          },
          "resolved_from": {
            "type": "string",
            "enum": [
              "USER",
              "TENANT",
              "PRODUCT",
              "PLATFORM"
            ]
          },
          "retention_index_days": {
            "type": "integer"
          },
          "retention_body_days": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "SUSPENDED"
            ],
            "description": "Lo STATO della riga di `channel_config` di cui la risposta parla.\n`ACTIVE` e' una linea che spedisce e che la cascata puo' scegliere;\n`SUSPENDED` e' una linea RITIRATA (`POST /v1/lines/{id}/ritiro`,\nONB-3): resta scritta ma non spedisce piu' e la cascata non la\nsceglie. Le due porte lo servivano gia' (`src/rotte.js`), ma il\ncontratto non lo dichiarava: chi integra leggendo `openapi/v1.yaml`\nnon aveva nessun campo documentato per sapere che la sua linea e'\nritirata.\n"
          },
          "credenziali_ok": {
            "type": "boolean",
            "nullable": true,
            "description": "Le CREDENZIALI di questo canale, cosi' come sono configurate,\nbastano al fornitore? E' la risposta della sonda, ed e' il nome con\ncui la regia ha deciso di dirlo (1/10): `credenziali_ok` — le\ncredenziali vanno; `raggiungibile_adesso` — questo contatto, adesso\n(`POST /v1/channels/reachability`). La risposta si ottiene **senza\nspedire niente**: il messaggio viene composto come lo comporrebbe\nchi spedisce e l'adattatore del fornitore viene fermato sulla porta\ndi rete. `null` vuol dire «non lo so» (il caveau non risponde, o la\nsonda non e' attendibile) e **non e' un no**.\nNON risponde «questo contatto e' raggiungibile adesso»: quella e'\nuna proprieta' della CONVERSAZIONE, dipende dal contatto e si chiede\na parte. Per i canali che hanno una proprieta' della conversazione\n(oggi WhatsApp, la finestra delle 24 h) il `motivo` positivo lo dice.\n"
          },
          "consegnabile": {
            "type": "boolean",
            "nullable": true,
            "description": "**Alias storico di `credenziali_ok`**, con lo STESSO valore, tenuto\nper chi legge il contratto di oggi. Dice se questa configurazione di\ncanale consegnerebbe, chiesto **senza spedire niente**: il messaggio\nviene composto come lo comporrebbe chi spedisce e l'adattatore del\nfornitore viene fermato sulla porta di rete. `null` vuol dire «non lo\nso» (il caveau non risponde, o la sonda non e' attendibile) e **non\ne' un no**. Non risponde «questo contatto adesso».\n"
          },
          "motivo": {
            "type": "string",
            "nullable": true,
            "description": "Perche' non consegnerebbe, con le parole dell'adattatore: le stesse\nche si vedrebbero al primo invio vero (`OUTCALL_NOT_CONFIGURED`,\n«la credenziale non si legge dal caveau», «configurazione\nSUSPENDED»). `null` quando consegna. Non contiene mai un valore di\nsegreto ne' il NOME del manico nel caveau.\n"
          },
          "verifica": {
            "type": "object",
            "description": "Il verdetto d'IDENTITA' della linea, chiesto AL FORNITORE durante la\nregistrazione (`PUT /v1/channels/config`). **Non e' `consegnabile`**:\nla sonda di consegnabilita' compone il messaggio e si ferma sulla\nporta di rete, non esce dal processo, quindi `consegnabile: true` e'\nvero per qualunque stringa non vuota nel caveau. Qui invece si chiede\nse QUESTO token governa DAVVERO QUESTO numero, con la sonda del\ncontratto §5 (`registro.saluteDettagliata`) e la credenziale composta\ndal lavoratore, **senza spedire niente**. E' presente solo nella\nrisposta della `PUT` (le altre due risposte di `consegnabile` — `GET`\ne `POST /v1/channels/reachability` — non registrano una linea e non\nportano questo campo).\n",
            "properties": {
              "provata": {
                "type": "boolean",
                "nullable": true,
                "description": "Tre valori, e il terzo e' il punto. `true`: il fornitore ha\nconfermato che quel token parla di quel numero. `false`: il\nfornitore ha RISPOSTO e ha negato (es. Meta 401\n`OAuthException`). `null`: il fornitore NON ha risposto (muto) o\nquesto fornitore non sa provare l'identita' — un silenzio non\nprova niente, e non si spaccia per un no ne' per un si'. Con\n`null` la linea viene scritta LO STESSO: accogliere non puo'\ndipendere dal fornitore su, ma la risposta lo dice.\n"
              },
              "motivo": {
                "type": "string",
                "nullable": true,
                "description": "Perche' quel verdetto, con le parole dell'adattatore quando c'e'\nuna risposta (es. `Cloud API 401`). `null` quando `provata` e'\n`true`. Non contiene mai il valore del segreto ne' il NOME del\nmanico nel caveau.\n"
              }
            }
          },
          "iscrizione": {
            "type": "object",
            "description": "Il TERZO verbo dell'accoglienza: la linea e' stata detta al\nfornitore perche' i messaggi di quel numero arrivino al webhook,\nchiesto durante la registrazione (`PUT /v1/channels/config`) con la\nstessa credenziale della `verifica`. E' lo specchio di `verifica`,\ne come lei e' presente SOLO nella risposta della `PUT`: la `GET` e\n`POST /v1/channels/reachability` non registrano una linea e non\ncompiono il verbo. Senza questo campo, chi integra sul contratto non\nha modo di sapere che l'accoglienza dice al fornitore di mandarci i\nmessaggi, ne' se ci e' riuscita.\n",
            "properties": {
              "iscritta": {
                "type": "boolean",
                "nullable": true,
                "description": "Tre valori, e il terzo e' il punto. `true`: il fornitore ha\nconfermato l'iscrizione (il `POST /{phoneId}/subscribed_apps` e'\npassato). `false`: la linea NON ricevera' i messaggi, e il\n`motivo` dice quale dei casi e': il fornitore ha RISPOSTO e ha\nrifiutato (es. Meta 401), NON SA iscriversi, oppure NON HA\nRISPOSTO — un silenzio non si presume, fail-closed come la\ndisiscrizione del ritiro. `null`: non lo so (non c'e' una\nconfigurazione da iscrivere, questo processo non ha una porta\nverso i fornitori, o la credenziale non si legge); non e' un si'\ne non e' un no. Con `false` o `null` la linea resta accolta LO\nSTESSO: accogliere non puo' dipendere dal fornitore su, ma la\nrisposta lo dice.\n"
              },
              "motivo": {
                "type": "string",
                "nullable": true,
                "description": "Perche' quel verdetto, con le parole dell'adattatore quando c'e'\nuna risposta (es. `Cloud API 401`). `null` quando `iscritta` e'\n`true`. Non contiene mai il valore del segreto ne' il NOME del\nmanico nel caveau.\n"
              }
            }
          }
        }
      },
      "CatalogoDeiFornitori": {
        "type": "object",
        "description": "Il catalogo del PRODOTTO: i canali del Postino e, per ognuno, i\nfornitori che il registro degli adattatori dichiara. Nessuna riga di\nconfigurazione di nessun tenant entra qui.\n",
        "properties": {
          "canali": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CanaleDelCatalogo"
            }
          }
        },
        "required": [
          "canali"
        ]
      },
      "CanaleDelCatalogo": {
        "type": "object",
        "properties": {
          "channel_type": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ],
            "description": "Il canale col nome della COLONNA (`channel_config.channel_type`, da\ncui questo elenco si legge): e' il valore che si scrive nella `PUT\n/v1/channels/config`. Un canale **senza nessun fornitore registrato**\ncompare lo stesso, con `fornitori: []`: tacerlo lo farebbe sembrare\ninesistente.\n"
          },
          "fornitori": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FornitoreDelCatalogo"
            }
          }
        },
        "required": [
          "channel_type",
          "fornitori"
        ]
      },
      "FornitoreDelCatalogo": {
        "type": "object",
        "description": "Un fornitore come il suo adattatore lo DICHIARA (contratto degli\nadattatori, PROGETTO §5), piu' il nome con cui si configura.\n",
        "properties": {
          "provider": {
            "type": "string",
            "description": "**Il valore da scrivere in `provider`** nella `PUT\n/v1/channels/config`. E' questo e non `nome`: i due possono\ndifferire (il fornitore finto si registra come `sms` e si dichiara\n`sms-finto`), e chi mandasse il `nome` si prenderebbe un 400\n`FORNITORE_SCONOSCIUTO` per un fornitore che e' registrato.\n"
          },
          "nome": {
            "type": "string",
            "description": "Il nome che l'adattatore dichiara di se'."
          },
          "canale": {
            "type": "string",
            "description": "Il canale col nome di casa nostra, come il descrittore del §5 lo\ndichiara: per la voce e' `VOCE` e **non** `VOICE`. Il valore da\nscrivere in `channel_type` e' quello del `CanaleDelCatalogo` che\ncontiene questa voce, non questo campo.\n"
          },
          "regioni": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Da dove il fornitore spedisce, come lo dichiara (`EU`, `US`,\n`mondiale`). Serve a chi ha un vincolo di residenza dei dati: non e'\nuna promessa contrattuale del Postino, e' la dichiarazione\ndell'adattatore.\n"
          },
          "capacita": {
            "type": "object",
            "additionalProperties": true,
            "description": "Che cosa quel fornitore sa fare, come il descrittore lo dichiara:\n`html`, `lunghezza_max`, `allegati`, `ricevute` (gli stati che sa\nriportare), `modelli_approvati`, e dove c'e' `presenza`. Le chiavi\nsono quelle del §5 e crescono con gli adattatori: chi legge non\npretenda un insieme chiuso.\n"
          },
          "esce_dal_processo": {
            "type": "boolean",
            "description": "`false` e' un fornitore FINTO (non parla con nessuno): utile alle\nprove, **non** una scelta da offrire a chi compra. Un applet che non\nlegge questo campo offre al tenant una linea che non spedira' mai.\n"
          },
          "credenziale_del_tenant": {
            "type": "object",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "obbligatorio": {
                  "type": "boolean"
                },
                "forma": {
                  "type": "string",
                  "description": "Com'e' fatto il valore, in una frase (mai un valore)."
                },
                "a_cosa_serve": {
                  "type": "string",
                  "description": "Dove finisce quel campo, cioe' che cosa si rompe senza."
                }
              }
            },
            "description": "**Le caselle che l'applet deve disegnare**: una voce per ogni campo\nche il tenant deve depositare nel caveau, con la sua FORMA. Mai un\nvalore e mai il nome nel caveau. Un elenco **vuoto** vuol dire «a\nquesto fornitore non serve niente dal tenant», ed e' il caso del\nfornitore finto (`esce_dal_processo: false`).\n"
          },
          "credenziale_di_firma_webhook": {
            "nullable": true,
            "type": "object",
            "description": "**IL SECONDO DEPOSITO**, e non e' dentro `credenziale_del_tenant`:\nquello e' cio' che serve a SPEDIRE, questo e' il segreto con cui si\nverifica la firma di cio' che ARRIVA (eventi di consegna, messaggi\nin entrata, CDR). La verifica e' **fail-closed**: un tenant che\ndeposita il primo e non il secondo ha un canale che spedisce e non\nriceve, con un 200 in mano e nessuna spiegazione.\n\n`null` vuol dire «questo fornitore non ci manda webhook» (e non «non\nsi sa»): un applet che disegnasse la casella la chiederebbe per\nniente.\n",
            "properties": {
              "campo": {
                "type": "string",
                "description": "Come lo chiama il fornitore nella SUA console (`app_secret`,\n`webhook_secret_token`, `cdr_token`,\n`webhook_signing_key`). Non e' il nome nel caveau: il manico non\nsi pubblica.\n"
              },
              "obbligatorio": {
                "type": "boolean"
              },
              "per_linea": {
                "type": "boolean",
                "description": "`true` = si deposita per OGNI linea e non una volta per\nfornitore (WhatsApp: due linee dello stesso tenant possono\nstare su due app di Meta, con due `app_secret` diversi).\n"
              },
              "origine": {
                "type": "string",
                "enum": [
                  "dato_dal_fornitore",
                  "scelto_dal_tenant"
                ],
                "description": "**Chi fa esistere quel valore**, ed e' la differenza che decide\nche schermata l'applet deve disegnare:\n\n· `dato_dal_fornitore` — il valore ESISTE GIA' nella console del\n  fornitore e si COPIA (la «HTTP webhook signing key» di\n  Mailgun, l'«App Secret» di Meta): «copia il valore da `dove`»;\n· `scelto_dal_tenant` — il valore NON esiste: si INVENTA e si\n  deposita nella console del fornitore, perche' e' cosi' che il\n  fornitore sa che e' il nostro (il `secret_token` di\n  `setWebhook` di Telegram, il Bearer statico delle CDR di\n  BeeVoip): «scegline uno e incollalo in `dove`».\n\nChi cerca nella console del fornitore un valore\n`scelto_dal_tenant` non lo trova MAI, e la ricerca non finisce\ncon un errore: finisce con un canale che spedisce e non riceve.\n"
              },
              "dove": {
                "type": "string",
                "description": "Il posto della console del fornitore che lega quel valore a noi:\n**da dove si copia** quando `origine` e' `dato_dal_fornitore`,\n**dove si incolla** quando e' `scelto_dal_tenant`. Mai un\nvalore, e mai il nome nel caveau.\n"
              },
              "forma": {
                "type": "string",
                "description": "Com'e' fatto il valore (mai un valore). Dove si prende lo dice `dove`."
              },
              "a_cosa_serve": {
                "type": "string",
                "description": "Che cosa non arriva piu' senza quel segreto."
              }
            }
          }
        },
        "required": [
          "provider",
          "nome",
          "canale",
          "regioni",
          "capacita",
          "esce_dal_processo",
          "credenziale_del_tenant",
          "credenziale_di_firma_webhook"
        ]
      },
      "RichiestaRaggiungibilita": {
        "type": "object",
        "required": [
          "channel_type",
          "to"
        ],
        "properties": {
          "channel_type": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ],
            "description": "Il canale su cui si chiede se il contatto e' raggiungibile adesso."
          },
          "to": {
            "type": "string",
            "description": "L'indirizzo del contatto, in chiaro perche' e' del chiamante che lo\nmanda. Non resta in chiaro: viene normalizzato, se ne fa l'impronta\ne la risposta non lo contiene mai.\n"
          }
        }
      },
      "RichiestaProvaInvio": {
        "type": "object",
        "required": [
          "channel_type",
          "to"
        ],
        "properties": {
          "channel_type": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ],
            "description": "Il canale della linea da provare. Non c'e' ripiego: il messaggio\ndella prova non dichiara nessuna categoria, quindi nessuna politica\nlo puo' spostare su un altro canale.\n"
          },
          "to": {
            "type": "string",
            "description": "Il destinatario della prova, in chiaro perche' e' del chiamante che\nlo manda. Non resta in chiaro: viene normalizzato e ne resta solo\nl'impronta. Riceve un messaggio VERO.\n"
          },
          "sender_identity": {
            "type": "string",
            "description": "QUALE linea di quel canale si prova (il `sender_identity` di\n`channel_config`). Senza, si prova quella che spedirebbe davvero: la\nprima della fila dello smistatore. Una linea nominata che non esiste\nATTIVA in questo scope e' `404 LINEA_ASSENTE` e **non** un ripiego\nsulla linea accanto: chi ha chiesto quella linea non vuole l'altra.\n"
          }
        }
      },
      "EsitoProvaInvio": {
        "type": "object",
        "description": "Il `200` esiste solo se il fornitore ha ACCETTATO. Tre campi dicono\n**con che cosa e' partita davvero** e si leggono dall'evento `SENT` del\nfilo, non dalla configurazione riletta adesso: `channel_type`,\n`provider` e `sender_identity`.\n",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "status": {
            "type": "string",
            "description": "Lo stato RICALCOLATO dalla catena degli eventi, come in\n`GET /v1/messages/{id}`: `SENT`, o `DELIVERED` se la ricevuta del\nfornitore e' arrivata nello stesso istante.\n"
          },
          "channel_type": {
            "type": "string",
            "description": "Il canale che ha spedito DAVVERO."
          },
          "channel_type_chiesto": {
            "type": "string",
            "description": "Il canale che era stato chiesto. Coincide con `channel_type` per\ncostruzione (una prova non ripiega) e si dichiara comunque: un campo\nche esiste solo quando divergono non lo nota nessuno quando manca.\n"
          },
          "provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "Il fornitore che ha spedito DAVVERO (`detail_json.provider` del `SENT`)."
          },
          "sender_identity": {
            "type": [
              "string",
              "null"
            ],
            "description": "La linea che ha spedito DAVVERO (`detail_json.account_ref` del `SENT`)."
          },
          "avvisi": {
            "type": "array",
            "description": "Elenco VUOTO quando non c'e' niente da dire. Oggi porta una voce\nsola: il SECONDO deposito che manca — il segreto con cui si verifica\nla firma dei webhook di quel fornitore. Il messaggio e' partito, e\nla prova di consegna non arrivera' mai, perche' la verifica e'\nfail-closed. `campo` e' il nome come il fornitore lo chiama nella\nsua console; il nome nel caveau non esce mai.\n",
            "items": {
              "type": "object",
              "properties": {
                "campo": {
                  "type": "string"
                },
                "a_cosa_serve": {
                  "type": "string"
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "Raggiungibilita": {
        "type": "object",
        "properties": {
          "channel_type": {
            "type": "string"
          },
          "credenziali_ok": {
            "type": "boolean",
            "nullable": true,
            "description": "Le CREDENZIALI del canale bastano al fornitore? E' la stessa\ndomanda di `GET /v1/channels/config`, e vale per CHIUNQUE: non\ndipende dal contatto. `null` vuol dire «non lo so» (il caveau non\nrisponde, o la sonda non e' attendibile) e **non e' un no**.\n"
          },
          "raggiungibile_adesso": {
            "type": "boolean",
            "description": "QUESTO contatto e' raggiungibile ADESSO su questo canale? E' una\nproprieta' della CONVERSAZIONE: oggi su WhatsApp dipende dalla\nfinestra delle 24 ore dall'ultimo messaggio del contatto, oppure da\nun template approvato. La regola non si ricopia: la risposta esce\ndalla stessa funzione che il servizio usa per decidere\n(`consegnabile.raggiungibileAdesso`).\n"
          },
          "motivo": {
            "type": "string",
            "nullable": true,
            "description": "Perche' non e' raggiungibile adesso, quando non lo e': oggi\n«nessun template approvato per questo mittente» per un contatto\nWhatsApp senza finestra aperta. `null` quando lo e'. Non contiene\nmai l'indirizzo in chiaro.\n"
          }
        }
      },
      "RichiestaWebhook": {
        "type": "object",
        "required": [
          "url",
          "events",
          "secret_handle"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "L'URL a cui il Postino spedisce gli stati (http o https)."
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "SENT",
                "DELIVERED",
                "READ",
                "FAILED",
                "SUPPRESSED",
                "messaggio_in_arrivo"
              ]
            },
            "description": "A quali eventi iscriversi. I primi cinque sono i cambi di stato di\nun messaggio spedito; `messaggio_in_arrivo` è il messaggio che entra\nsu una linea del cliente, e ha un corpo suo (vedi `POST /v1/webhooks`).\n"
          },
          "secret_handle": {
            "type": "string",
            "description": "Il NOME del segreto di firma nel caveau. Mai il valore."
          }
        }
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "url": {
            "type": "string"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "secret_fp": {
            "type": "string",
            "description": "12 caratteri di sha256 del segreto, per confrontare senza leggere."
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "SUSPENDED"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RichiestaAttivazione": {
        "type": "object",
        "required": [
          "product_code",
          "name"
        ],
        "properties": {
          "product_code": {
            "type": "string",
            "description": "Il codice della società (grado PRODUCT). Non un dato di catalogo."
          },
          "name": {
            "type": "string",
            "description": "Il nome della società, per l'anagrafica del prodotto."
          },
          "tenant_uid": {
            "type": "string",
            "description": "Se dato, si crea anche il grado TENANT dentro il prodotto **e la\nsua anagrafica** in `tenant`: senza quella riga, tutte le rotte di\n`/v1/tenant/{tenant_uid}/...` e la console delle chiavi rispondono\n404 al cliente appena attivato. Un `tenant_uid` gia' usato da un\nALTRO prodotto fa fallire l'attivazione intera (409), perche' e'\nunico su tutti i prodotti.\n"
          },
          "tenant_name": {
            "type": "string",
            "description": "Il nome dell'azienda (o della persona) che vedra' chi la\namministra. Se manca si usa `name`. Su un `tenant_uid` che esiste\ngia' NON si riscrive niente.\n"
          },
          "tenant_kind": {
            "type": "string",
            "enum": [
              "AZIENDA",
              "PERSONA"
            ],
            "description": "`AZIENDA` (di serie) oppure `PERSONA`, l'accesso individuale di una\nsola persona. Non e' un quinto grado di proprieta': e' un tenant con\nun utente solo, e non invita nessuno.\n"
          },
          "amministratore_uid": {
            "type": "string",
            "description": "La persona che ha comprato, primo amministratore del tenant. Nasce\nATTIVA (nessuno poteva invitarla) e serve **obbligatoriamente** se\n`tenant_kind` e' `PERSONA`. Su un tenant che esisteva gia' viene\nignorata e la risposta lo dice in `tenant.amministratore_ignorato`:\nun amministratore attivo scritto qui sarebbe una strada per entrare\nin un'azienda saltando l'accettazione dell'invito.\n"
          },
          "allowed_channels": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "EMAIL",
                "WHATSAPP",
                "SMS",
                "TELEGRAM",
                "VOICE",
                "TEAMS"
              ]
            },
            "description": "I canali che la chiave apre (di serie EMAIL)."
          },
          "retention_index_days": {
            "type": "integer",
            "description": "Conservazione dell'indice in giorni (di serie 730)."
          },
          "retention_body_days": {
            "type": "integer",
            "description": "Conservazione del testo in giorni (di serie 365)."
          },
          "categorie": {
            "type": "array",
            "description": "Le categorie di messaggio del cliente, ognuna con la **classe** di\npolitica a cui appartiene. È la mappa che `POST /v1/messages` usa\nper risolvere la politica; senza, i messaggi del cliente non\napplicano niente. La classe è **obbligatoria** e deve essere una\ndelle quattro del §4 (stessa validazione di `PUT /v1/categorie`).\n",
            "items": {
              "$ref": "#/components/schemas/CategoriaConClasse"
            }
          },
          "piano": {
            "type": "string",
            "enum": [
              "prova",
              "standard",
              "enterprise"
            ],
            "description": "Il patto che chi vende dichiara (1/10). `prova` (di serie) dura\ntrenta giorni; `standard` ed `enterprise` NON hanno una scadenza di\nserie: chi li vende deve dichiarare `scadenza` o `giorni`, altrimenti\nla porta risponde 400 `SCADENZA_DA_DICHIARARE`. `interna` non si\naccetta dalla porta: è il piano che la migrazione 029 conia per i\ntenant già esistenti, e un cliente che lo comprasse non verrebbe mai\nfatturato.\n"
          },
          "scadenza": {
            "type": "string",
            "format": "date-time",
            "description": "Quando scade il patto, in una data ISO. Uno dei due fra `scadenza` e\n`giorni`, mai tutti e due (400 `SCADENZA_DOPPIA`). Senza né l'uno né\nl'altro, per `prova` vale il default del piano (trenta giorni).\n"
          },
          "giorni": {
            "type": "integer",
            "minimum": 1,
            "description": "Quanti giorni dura il patto, dalla data di attivazione: l'altro modo\ndi dire `scadenza` con un numero. Uno dei due, non entrambi.\n"
          },
          "postman_agent_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Chi e' il postino di questo cliente: un PUNTATORE al Lettore Unico\ndi ADP, non un'identita' (nome, firma, lingua e voce si leggono a\nogni consegna). Non e' un segreto, ed e' per questo che sta qui:\nchi compra il Postino dichiara alla porta d'acquisto chi e' il suo.\nIl legame nasce sullo scope piu' specifico che l'attivazione crea\n(il TENANT se c'e' `tenant_uid`, altrimenti il PRODUCT), e una\nriattivazione con un altro valore lo corregge invece di raddoppiarlo.\nSenza, il cliente spedisce col mittente configurato sul canale.\n"
          }
        }
      },
      "EsitoAttivazione": {
        "type": "object",
        "properties": {
          "product_code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "riaperto": {
            "type": "boolean",
            "description": "`true` se QUESTA attivazione ha **riaperto un cliente disdetto**: la\ndisdetta lascia il prodotto `SUSPENDED`, e dal 29/9 una chiave di un\nprodotto sospeso non apre nessuna rotta (`403 CLIENTE_SOSPESO`).\nRivendere a un cliente disdetto quindi lo riattiva — altrimenti la\nchiave nuova non consegnerebbe niente — e chi vende deve saperlo:\nun cliente che torna attivo in silenzio e' una riattivazione che\nnessuno ha registrato. Su un replay e' sempre `false` (a riaprire e'\nstato il primo tentativo).\n"
          },
          "retention": {
            "type": "object",
            "properties": {
              "index_days": {
                "type": "integer"
              },
              "body_days": {
                "type": "integer"
              }
            }
          },
          "licenza": {
            "$ref": "#/components/schemas/LicenzaDelPatto",
            "description": "Il patto che l'attivazione ha appena dato (o riusato, se il tenant\nne aveva già uno che apre). Se il tenant aveva una licenza **in\nprova** aperta e chi vende ha dichiarato un piano, è LA STESSA riga\nconvertita — non una seconda — e il piano qui è quello venduto.\n`null` solo per un prodotto senza `tenant_uid`: una licenza è il\ndiritto di UN tenant.\n"
          },
          "prodotto": {
            "type": "object",
            "description": "La chiave di grado PRODUCT.",
            "properties": {
              "key_id": {
                "$ref": "#/components/schemas/Ulid"
              },
              "key_hash": {
                "type": "string",
                "description": "L'impronta sha256 della chiave: nel DB resta solo questa."
              },
              "chiave": {
                "type": "string",
                "description": "Il valore in chiaro, UNA volta sola. Da depositare nel caveau."
              }
            }
          },
          "tenant": {
            "type": [
              "object",
              "null"
            ],
            "description": "La chiave di grado TENANT, se `tenant_uid` era dato, **e l'azienda\nche quella chiave rappresenta**. Sul replay di una stessa\n`Idempotency-Key` l'anagrafica c'e' lo stesso e `chiave` no.\n",
            "properties": {
              "tenant_uid": {
                "type": "string"
              },
              "key_id": {
                "$ref": "#/components/schemas/Ulid"
              },
              "key_hash": {
                "type": "string"
              },
              "chiave": {
                "type": "string"
              },
              "anagrafica": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/Tenant"
                  }
                ],
                "description": "La riga di `tenant` come la legge `GET /v1/tenant`."
              },
              "creata": {
                "type": "boolean",
                "description": "`true` se e' stata QUESTA attivazione a creare il tenant,\n`false` se esisteva gia' (riemissione di chiave).\n"
              },
              "amministratore": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/UtenteDelTenant"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Il primo amministratore, quando il tenant nasce adesso."
              },
              "amministratore_ignorato": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Perche' `amministratore_uid` non e' stato applicato. Un campo\nmandato e ignorato in silenzio sarebbe un permesso che chi\nchiama crede di aver dato.\n"
              },
              "sospeso_avviso": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Valorizzato quando il tenant esisteva ed e' **SOSPESO**: la\nchiave si conia lo stesso ma non aprira' nessuna rotta\n(`403 TENANT_SOSPESO`) finche' non lo riattiva chi lo ha\nsospeso con `PATCH /v1/tenant/{tenant_uid}`. Una riemissione di\nchiave NON riattiva un tenant: lo riapre chi lo ha chiuso.\n"
              }
            }
          },
          "postman_agent_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Chi e' il postino di questo cliente: un PUNTATORE al Lettore Unico\ndi ADP, non un'identita' (nome, firma, lingua e voce si leggono a\nogni consegna). Non e' un segreto, ed e' per questo che sta qui.\n"
          },
          "categorie": {
            "type": "array",
            "description": "Le categorie dichiarate all'attivazione, nella stessa forma di\n`GET /v1/categorie` (ordinate per nome). Vuoto se l'attivazione non\nne ha dichiarate.\n",
            "items": {
              "$ref": "#/components/schemas/CategoriaConClasse"
            }
          },
          "canali": {
            "type": "array",
            "description": "**L'attivazione non configura i canali, e lo dice.** I fornitori e\nle predefinizioni stanno nella UI Creator di ADP (D32): qui si\nconiano le chiavi e si dichiarano i canali AMMESSI. Senza questa\nriga, l'attivazione rispondeva 201 con una chiave che funziona e il\nprimo messaggio del cliente moriva con `CANALE_NON_CONFIGURATO`.\nUna configurazione che manca **non fa fallire l'attivazione**: un\ncliente che non si attiva perche' il suo canale non e' pronto e'\npeggio del problema.\n",
            "items": {
              "$ref": "#/components/schemas/StatoDelCanale"
            }
          }
        }
      },
      "EsitoDuplicatoAttivazione": {
        "type": "object",
        "description": "Il replay di un'attivazione già fatta: stessa `Idempotency-Key` nello\nstesso `product_code`. **Non c'è il valore in chiaro** — nel database\nresta solo l'impronta, e chi ritenta riceve `key_id`, `key_fp` (dodici\ncifre dell'impronta, che bastano a riconoscere la chiave che ha gia' in\nmano) e un campo `chiave_assente` che dice perché. **L'impronta intera\nnon esce**: dove il valore in chiaro non c'e', `sha256(valore)` e' il\nverificatore della chiave — si prova offline quante candidate si vuole,\nsenza limite di frequenza e senza una riga in nessun registro.\n",
        "properties": {
          "product_code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "duplicate": {
            "type": "boolean",
            "const": true
          },
          "riaperto": {
            "type": "boolean",
            "const": false,
            "description": "Sempre `false`: `riaperto` parla di QUESTA chiamata, e un replay non\nriapre niente — se il cliente era disdetto, a riaprirlo e' stato il\nprimo tentativo.\n"
          },
          "retention": {
            "type": "object",
            "properties": {
              "index_days": {
                "type": "integer"
              },
              "body_days": {
                "type": "integer"
              }
            }
          },
          "licenza": {
            "$ref": "#/components/schemas/LicenzaDelPatto",
            "description": "Lo stesso patto del primo tentativo, con lo stesso `id` e la stessa\nscadenza: un replay non tace ciò che il 201 aveva detto. `null` solo\nper un prodotto senza `tenant_uid`.\n"
          },
          "prodotto": {
            "type": "object",
            "properties": {
              "key_id": {
                "$ref": "#/components/schemas/Ulid"
              },
              "key_fp": {
                "type": "string",
                "description": "Le prime 12 cifre di `sha256` della chiave, come `secret_fp` di\n`channel_config`. Bastano a riconoscere la chiave che hai gia'\nin mano, non bastano a nient'altro.\n"
              },
              "chiave_assente": {
                "type": "string",
                "description": "Perché il valore in chiaro non c'è (non si conserva)."
              }
            }
          },
          "tenant": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "tenant_uid": {
                "type": "string"
              },
              "key_id": {
                "$ref": "#/components/schemas/Ulid"
              },
              "key_fp": {
                "type": "string"
              },
              "anagrafica": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/Tenant"
                  }
                ],
                "description": "L'azienda che quella chiave rappresenta, anche nel replay."
              },
              "chiave_assente": {
                "type": "string"
              }
            }
          },
          "postman_agent_key": {
            "type": [
              "string",
              "null"
            ]
          },
          "canali": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StatoDelCanale"
            }
          }
        }
      },
      "LicenzaDelPatto": {
        "type": "object",
        "description": "Il patto di UN tenant, come lo dice l'attivazione: `id`, `piano`,\n`scadenza` (o `null`, se non scade) e i tre numeri. È la stessa forma\nche poi `GET /v1/uso` e l'elenco delle licenze dicono, così chi vende e\nchi compra leggono la stessa cosa con le stesse parole.\n",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "piano": {
            "type": "string",
            "enum": [
              "prova",
              "standard",
              "enterprise",
              "interna"
            ]
          },
          "scadenza": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "La data di scadenza, o `null` se il patto non scade."
          },
          "grazia_ore": {
            "type": "integer",
            "description": "La grazia in ore dopo la scadenza (24 di serie)."
          },
          "quota_mensile": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto mensile dichiarato sulla licenza (o `null`)."
          },
          "limite_al_minuto": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto al minuto dichiarato sulla licenza (o `null`)."
          }
        }
      },
      "StatoDelCanale": {
        "type": "object",
        "description": "Un canale ammesso di un cliente: **configurato** (c'e' una riga di\n`channel_config` dopo la cascata) e **con credenziali che vanno**\n(`credenziali_ok`: quella riga basta davvero al fornitore). Le due cose\nsono diverse, ed e' la differenza fra un canale che si accende e un\navviso che non arriva a una persona.\n\nLe due domande hanno due nomi: `credenziali_ok` risponde del CANALE,\n`raggiungibile_adesso` risponde del CONTATTO e si chiede a parte\n(`POST /v1/channels/reachability`).\n\nLa domanda si fa **senza spedire niente** (`src/consegnabile.js`), con\nla scadenza corta delle sonde, e non esce mai ne' un valore di segreto\nne' il NOME di un manico del caveau.\n",
        "properties": {
          "channel_type": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ]
          },
          "configurato": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`false` = nessuna configurazione per questo canale dopo la cascata.\n`null` = non si e' potuto leggere (e l'attivazione non fallisce per\nquesto).\n"
          },
          "credenziali_ok": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Le credenziali di questo canale, cosi' come sono configurate,\nbastano al fornitore? `null` non e' un no: e' «non lo so», cioe' la\nsonda non ha potuto rispondere. Non risponde «questo contatto e'\nraggiungibile adesso»: quella e' una proprieta' della conversazione\ne si chiede a parte.\n"
          },
          "consegnabile": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "**Alias storico di `credenziali_ok`**, con lo STESSO valore, tenuto\nper chi legge il contratto di oggi. `null` non e' un no: e' «non lo\nso», cioe' la sonda non ha potuto rispondere.\n"
          },
          "motivo": {
            "type": [
              "string",
              "null"
            ],
            "description": "Perche' non consegna, con le parole dell'adattatore — le stesse che\nsi vedrebbero al primo invio vero. `null` quando consegna e il\ncanale non ha una proprieta' della conversazione da nominare; per i\ncanali che ce l'hanno (oggi WhatsApp, la finestra delle 24 h) il\nmotivo positivo lo dice e rimanda a `POST /v1/channels/reachability`.\n"
          },
          "provider": {
            "type": "string",
            "description": "Il fornitore che vince la cascata"
          },
          "resolved_from": {
            "type": "string",
            "description": "Il grado da cui viene la configurazione (PLATFORM, PRODUCT, TENANT, USER)"
          }
        }
      },
      "EsitoCliente": {
        "type": "object",
        "description": "La fotografia di un cliente attivo. **Non porta nessuna chiave**: di\nquelle si dice solo quante sono vive.\n",
        "properties": {
          "product_code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "SUSPENDED"
            ]
          },
          "tenant_uid": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "retention": {
            "type": "object",
            "properties": {
              "index_days": {
                "type": "integer"
              },
              "body_days": {
                "type": "integer"
              }
            }
          },
          "postman_agent_key": {
            "type": [
              "string",
              "null"
            ]
          },
          "chiavi_attive": {
            "type": "integer",
            "description": "Quante chiavi ATTIVE ha questo cliente. Mai il loro valore."
          },
          "allowed_channels": {
            "type": "array",
            "description": "I canali ammessi, letti dalle chiavi vive (non da un elenco ricopiato).",
            "items": {
              "type": "string"
            }
          },
          "canali": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StatoDelCanale"
            }
          }
        }
      },
      "EsitoDisdetta": {
        "type": "object",
        "properties": {
          "product_code": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "DISDETTA"
            ]
          },
          "revoked_keys": {
            "type": "integer",
            "description": "Quante chiavi sono state revocate."
          },
          "retention": {
            "type": "object",
            "description": "La conservazione dichiarata all'attivazione, che i dati seguiranno.",
            "properties": {
              "index_days": {
                "type": "integer"
              },
              "body_days": {
                "type": "integer"
              }
            }
          },
          "export": {
            "type": "string",
            "description": "L'export resta disponibile prima della cancellazione."
          }
        }
      },
      "EsitoRevocaChiave": {
        "type": "object",
        "description": "L'esito della revoca di UNA chiave (`DELETE\n/v1/clienti/{product_code}/chiavi/{key_id}`). `REVOCATA` se era attiva,\n`GIA_REVOCATA` se lo era già: revocare una chiave già revocata non è un\nerrore.\n",
        "properties": {
          "product_code": {
            "type": "string"
          },
          "key_id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "status": {
            "type": "string",
            "enum": [
              "REVOCATA",
              "GIA_REVOCATA"
            ]
          }
        }
      },
      "CategoriaConClasse": {
        "type": "object",
        "required": [
          "category",
          "classe"
        ],
        "properties": {
          "category": {
            "type": "string",
            "description": "La categoria del prodotto (il suo vocabolario: `otp`, `invito`, ...)."
          },
          "classe": {
            "type": "string",
            "enum": [
              "sicurezza",
              "transazionale",
              "avviso",
              "marketing"
            ],
            "description": "La classe di politica del §4 a cui la categoria appartiene. Decide\nscadenza, ripiego, silenzio e consenso. Una classe fuori da queste\nquattro si rifiuta con 422 `CATEGORIA_NON_VALIDA`.\n"
          }
        }
      },
      "RichiestaCategorie": {
        "type": "object",
        "required": [
          "categorie"
        ],
        "properties": {
          "categorie": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CategoriaConClasse"
            },
            "description": "Le categorie da dichiarare, ognuna con la sua classe."
          }
        }
      },
      "EsitoCategorie": {
        "type": "object",
        "properties": {
          "product_code": {
            "type": "string"
          },
          "categorie": {
            "type": "array",
            "description": "Le categorie del prodotto, ordinate per nome.",
            "items": {
              "$ref": "#/components/schemas/CategoriaConClasse"
            }
          }
        }
      },
      "ElencoChiamate": {
        "type": "object",
        "description": "Le chiamate dello scope della chiave, dalla piu' recente.",
        "properties": {
          "limite_raggiunto": {
            "type": "boolean"
          },
          "calls": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Chiamata"
            }
          }
        }
      },
      "Chiamata": {
        "type": "object",
        "description": "Un evento di chiamata come l'ha detto il fornitore. Nessun numero in\nchiaro: la controparte e' la sua impronta.\n",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "provider": {
            "type": "string"
          },
          "provider_call_id": {
            "type": "string",
            "description": "L'id della chiamata presso il fornitore."
          },
          "line_identity": {
            "type": "string",
            "description": "La linea NOSTRA che ha ricevuto l'evento (il `phone_number_id` di Meta)."
          },
          "direction": {
            "type": "string",
            "enum": [
              "IN",
              "OUT"
            ],
            "description": "`IN` il contatto ha chiamato, `OUT` abbiamo chiamato noi."
          },
          "event": {
            "type": "string",
            "description": "L'evento del fornitore (`connect`, `terminate`)."
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "duration_s": {
            "type": "integer",
            "nullable": true,
            "description": "`null` = non ancora finita, `0` = finita in zero secondi."
          },
          "controparte_hash": {
            "type": "string",
            "description": "`sha256` del numero normalizzato, mai il numero."
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "L'ora DEL FORNITORE."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "L'ora in cui la riga e' stata scritta da noi."
          }
        }
      },
      "MisuraDUso": {
        "type": "object",
        "description": "I conti dello scope della chiave: consumo per mese e per canale, quota\ngiornaliera per canale, tetto al minuto della chiave.\n",
        "properties": {
          "ambito": {
            "type": "object",
            "description": "Lo scope della chiave, in chiaro, perche' chi integra deve poter\nverificare a nome di CHI sta leggendo. Non l'`owner_key`: quella e'\nla forma interna che porta gli indici e non esce da nessuna rotta.\n",
            "properties": {
              "owner_scope": {
                "type": "string",
                "enum": [
                  "PLATFORM",
                  "PRODUCT",
                  "TENANT",
                  "USER"
                ]
              },
              "product_code": {
                "type": "string",
                "nullable": true
              },
              "tenant_uid": {
                "type": "string",
                "nullable": true
              },
              "user_uid": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "mese_corrente": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MeseDiUso"
              }
            ],
            "description": "C'e' SEMPRE, anche a zero: un cliente che non ha ancora spedito\ndeve leggere «0», non un elenco vuoto — un elenco vuoto si legge\ncome un guasto della rotta.\n"
          },
          "mesi_precedenti": {
            "type": "array",
            "description": "I mesi precedenti nella finestra, dal piu' recente. Ognuno col suo\n`chiuso`: ci sono anche quelli non ancora contabili.\n",
            "items": {
              "$ref": "#/components/schemas/MeseDiUso"
            }
          },
          "quote": {
            "type": "array",
            "description": "Le quote per canale, canale per canale, dai conti presso i\nfornitori risolti a cascata: una riga col `periodo` GIORNO (il muro\ndel giorno) e una col `periodo` MESE (il tetto del mese, migrazione\n024). Sono le STESSE che il servizio guarda prima di chiamare il\nfornitore, non una copia: i numeri del mese vengono da `quote.verifica`\ne da `usage_counter`, esattamente la fonte che li fa valere.\n",
            "items": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/QuotaDelCanale"
                },
                {
                  "$ref": "#/components/schemas/QuotaMensileDelCanale"
                }
              ]
            }
          },
          "limite_al_minuto": {
            "type": "integer",
            "nullable": true,
            "description": "Il tetto EFFETTIVO di richieste al minuto: il piu' stretto fra la\nriga della chiave e la licenza sotto cui apre. `null` = nessun\ntetto. Questa rotta lo dice e non lo applica: il freno sta sulle\nrotte che spendono. E' la risposta a «quanto posso chiedere?», che\nsi fa prima di sbattere nel 429.\n"
          },
          "limite_al_minuto_da": {
            "type": "string",
            "enum": [
              "chiave",
              "licenza"
            ],
            "nullable": true,
            "description": "Da chi viene il tetto al minuto effettivo: `chiave` se stringe la\nriga, `licenza` se stringe il patto, `null` se non c'e' tetto.\n"
          },
          "quota_mensile_della_chiave": {
            "type": "object",
            "description": "Il tetto MENSILE EFFETTIVO di questa chiave, e quanto ne resta: il\npiu' stretto fra la riga della chiave e la licenza. Non e' una\ndelle `quote` qui sopra: quelle sono del conto presso il fornitore,\ngiornaliere e dello SCOPE (uguali per tutte le chiavi del cliente);\nquesto si consuma solo coi messaggi accodati da questa chiave. Si\nlegge prima di sbattere nel `429 QUOTA_MENSILE_SUPERATA`.\n",
            "properties": {
              "mese": {
                "type": "string",
                "pattern": "^[0-9]{4}-[0-9]{2}$",
                "description": "Il mese del contatore, dall'orologio del motore."
              },
              "tetto": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "`null` = nessun tetto dichiarato."
              },
              "usati": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Gli accodati veri contati sul contatore del tetto che frena per\nprimo, e `quota_mensile_da` dice QUALE: con `chiave` e' il numero\ndi questa chiave (`key_monthly_usage`); con `licenza` e' quello\ndel PATTO (`license_monthly_usage`), speso da TUTTE le chiavi del\ncliente — una chiave appena coniata che non ha mai spedito legge\nqui il consumo del patto, ed e' giusto, perche' e' quello che la\nfermera'. I due numeri del patto stanno anche in chiaro sotto\n`licenza` (`quota_mensile_usati`, `quota_mensile_resta`). `null`\nquando non c'e' tetto: senza tetto non si conta, e «0» direbbe\nun'altra cosa.\n"
              },
              "resta": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Tetto meno usati, mai sotto zero; `null` senza tetto."
              }
            }
          },
          "quota_mensile_da": {
            "type": "string",
            "enum": [
              "chiave",
              "licenza"
            ],
            "nullable": true,
            "description": "Da chi viene il tetto mensile effettivo, come per\n`limite_al_minuto_da`.\n"
          },
          "chiave": {
            "type": "object",
            "description": "I numeri GREZZI della chiave, accanto a quelli della licenza e non\ndentro: sono due patti diversi, e un campo solo direbbe «ti resta il\npiu' stretto dei due» senza dirgli quale.\n",
            "properties": {
              "rate_limit_per_minute": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Il tetto al minuto scritto sulla riga della chiave."
              },
              "monthly_quota": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "La quota mensile scritta sulla riga della chiave."
              }
            }
          },
          "licenza": {
            "$ref": "#/components/schemas/LicenzaDellaPorta"
          },
          "finestra_mesi": {
            "type": "integer",
            "description": "Quanti mesi copre questa risposta, contando quello in corso."
          }
        }
      },
      "MeseDiUso": {
        "type": "object",
        "properties": {
          "mese": {
            "type": "string",
            "pattern": "^[0-9]{4}-[0-9]{2}$",
            "description": "Il mese, come lo vede il motore (un solo orologio)."
          },
          "chiuso": {
            "type": "boolean",
            "description": "`true` = contabile e immutabile (tutte le righe del mese bloccate).\n`false` = i numeri possono ancora cambiare: non e' «mese in corso»,\ne' «non ancora chiuso».\n"
          },
          "canali": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "channel_type": {
                  "type": "string",
                  "enum": [
                    "EMAIL",
                    "WHATSAPP",
                    "SMS",
                    "TELEGRAM",
                    "VOICE",
                    "TEAMS"
                  ]
                },
                "inviati": {
                  "type": "integer"
                },
                "consegnati": {
                  "type": "integer",
                  "description": "Gli esiti del fornitore (`delivered`, `read`) contati sul giorno dell'invio."
                },
                "falliti": {
                  "type": "integer",
                  "description": "I messaggi NOSTRI morti davvero, uno per messaggio: non i rimbalzi del fornitore e non i giri del ritenta."
                }
              }
            }
          },
          "totale": {
            "type": "object",
            "properties": {
              "inviati": {
                "type": "integer"
              },
              "consegnati": {
                "type": "integer",
                "description": "Gli esiti del fornitore (`delivered`, `read`) contati sul giorno dell'invio."
              },
              "falliti": {
                "type": "integer",
                "description": "I messaggi NOSTRI morti davvero, uno per messaggio: non i rimbalzi del fornitore e non i giri del ritenta."
              }
            }
          }
        }
      },
      "QuotaDelCanale": {
        "type": "object",
        "properties": {
          "channel_type": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ]
          },
          "provider": {
            "type": "string"
          },
          "periodo": {
            "type": "string",
            "enum": [
              "GIORNO"
            ],
            "description": "Il muro e' del GIORNO e si dice: una quota senza il suo periodo si\nlegge come mensile, ed e' l'errore che fa programmare una campagna\nil giorno prima della scadenza.\n"
          },
          "giorno": {
            "type": "string",
            "format": "date"
          },
          "usati": {
            "type": "integer",
            "nullable": true,
            "description": "Quanti inviati oggi su questo canale; `null` = nessun conto configurato."
          },
          "freno": {
            "type": "integer",
            "nullable": true,
            "description": "La soglia di avviso, SOTTO il muro di proposito."
          },
          "muro": {
            "type": "integer",
            "nullable": true,
            "description": "Il limite del fornitore. `null` = non dichiarato."
          },
          "resta": {
            "type": "integer",
            "nullable": true,
            "description": "Quanto resta prima del muro. `null` quando non c'e' un muro:\n«non lo so» e «illimitato» sono due cose diverse, e un numero finto\nqui direbbe la seconda al posto della prima.\n"
          }
        }
      },
      "QuotaMensileDelCanale": {
        "type": "object",
        "properties": {
          "channel_type": {
            "type": "string",
            "enum": [
              "EMAIL",
              "WHATSAPP",
              "SMS",
              "TELEGRAM",
              "VOICE",
              "TEAMS"
            ]
          },
          "provider": {
            "type": "string"
          },
          "periodo": {
            "type": "string",
            "enum": [
              "MESE"
            ],
            "description": "Il tetto e' del MESE e si dice: lo stesso campo che distingue il\nperiodo della quota giornaliera, cosi' il cliente sa subito se il\nnumero che legge e' quello che si azzera a mezzanotte o a fine mese.\n"
          },
          "mese": {
            "type": "string",
            "pattern": "^[0-9]{4}-[0-9]{2}$",
            "description": "Il mese del tetto, dall'orologio del motore (un solo orologio)."
          },
          "usati": {
            "type": "integer",
            "nullable": true,
            "description": "Quanti inviati in questo mese su questo canale; `null` = nessun\nconto configurato.\n"
          },
          "muro": {
            "type": "integer",
            "nullable": true,
            "description": "Il tetto del mese del fornitore. `null` = nessun tetto dichiarato:\n«nessun tetto» non e' «tetto zero», e uno zero qui spegnerebbe in\nsilenzio chi oggi funziona senza.\n"
          },
          "resta": {
            "type": "integer",
            "nullable": true,
            "description": "Quanto resta prima del tetto del mese. `null` quando non c'e' un\ntetto o un conto: «non lo so» e «illimitato» sono due cose diverse.\n"
          }
        }
      },
      "RichiestaTenant": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "product_code": {
            "type": "string",
            "description": "Il prodotto che ospita il tenant. Obbligatorio per una chiave di\npiattaforma; per una chiave di prodotto viene dallo SCOPE e un\nvalore diverso qui si rifiuta (403 PRODUCT_NOT_ALLOWED).\n"
          },
          "tenant_uid": {
            "type": "string",
            "maxLength": 26,
            "description": "L'identificatore del tenant. Se manca lo genera il servizio (ULID).\nSi passa quando esiste gia' una chiave di grado TENANT coniata da\n`POST /v1/clienti` per quel `tenant_uid`.\n"
          },
          "kind": {
            "type": "string",
            "enum": [
              "AZIENDA",
              "PERSONA"
            ],
            "default": "AZIENDA",
            "description": "AZIENDA = piu' persone con ruoli. PERSONA = accesso individuale di\nuna persona: un utente solo, se stesso, amministratore di se stesso.\nNon e' un quinto grado di proprieta': lo scope resta TENANT, e\nl'isolamento fra due persone e' lo stesso codice che isola due\naziende.\n"
          },
          "name": {
            "type": "string",
            "maxLength": 160
          },
          "amministratore_uid": {
            "type": "string",
            "maxLength": 26,
            "description": "Il PRIMO amministratore, che nasce ATTIVO (nessuno puo' averlo\ninvitato). Obbligatorio se `kind: PERSONA`.\n"
          }
        }
      },
      "Tenant": {
        "type": "object",
        "properties": {
          "tenant_uid": {
            "type": "string"
          },
          "product_code": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "AZIENDA",
              "PERSONA"
            ]
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "SUSPENDED"
            ]
          },
          "owner_key": {
            "type": "string",
            "description": "La chiave di proprieta' di questo tenant\n(`TENANT|<prodotto>|<tenant_uid>|`), la stessa che compare nei\nreferti di consegna e nella catena di `audit_log`. Non e' un\nsegreto: e' l'indirizzo della proprieta'.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "EsitoTenant": {
        "type": "object",
        "properties": {
          "tenant": {
            "$ref": "#/components/schemas/Tenant"
          },
          "amministratore": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/UtenteDelTenant"
              },
              {
                "type": "null"
              }
            ],
            "description": "Il primo amministratore, se la richiesta l'ha dichiarato."
          }
        }
      },
      "RichiestaInvito": {
        "type": "object",
        "required": [
          "user_uid"
        ],
        "properties": {
          "user_uid": {
            "type": "string",
            "maxLength": 26,
            "description": "L'identita' della persona invitata (la stessa di ADP)."
          },
          "role": {
            "type": "string",
            "enum": [
              "AMMINISTRATORE",
              "UTENTE"
            ],
            "default": "UTENTE"
          }
        }
      },
      "RichiestaInvitoSportello": {
        "type": "object",
        "required": [
          "user_uid",
          "to"
        ],
        "properties": {
          "user_uid": {
            "type": "string",
            "maxLength": 26,
            "description": "L'identita' della persona invitata (la stessa di ADP)."
          },
          "to": {
            "type": "string",
            "description": "La mail a cui il Postino consegna l'invito. Si normalizza in\nminuscolo come ogni indirizzo EMAIL.\n"
          },
          "role": {
            "type": "string",
            "enum": [
              "AMMINISTRATORE",
              "UTENTE"
            ],
            "default": "UTENTE"
          }
        }
      },
      "EsitoAccettazioneInvito": {
        "type": "object",
        "description": "L'esito dell'accettazione, volutamente minimo: «sei dentro», e\nnient'altro. Nessun dato del tenant esce da una rotta il cui utente e'\nuna persona che clicca un link finito negli inoltri.\n",
        "properties": {
          "accepted": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "UtenteDelTenant": {
        "type": "object",
        "description": "Una persona di un tenant. Il RUOLO non e' il grado della chiave: il grado\ndice a quali dati si arriva, il ruolo che cosa si puo' fare\ndell'organizzazione. E **solo `ATTIVO` porta un ruolo efficace**: un\ninvitato che non ha accettato e un sospeso non amministrano niente, o una\nsospensione sarebbe decorativa.\n",
        "properties": {
          "user_uid": {
            "type": "string"
          },
          "role": {
            "type": "string",
            "enum": [
              "AMMINISTRATORE",
              "UTENTE"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "INVITATO",
              "ATTIVO",
              "SOSPESO"
            ]
          },
          "invited_by": {
            "type": [
              "string",
              "null"
            ],
            "description": "La persona che ha invitato, quando dietro la chiave c'era una\npersona. `null` quando l'invito viene dalla piattaforma o dalla\ncreazione del tenant.\n"
          },
          "invited_actor": {
            "type": "string",
            "description": "La chiave che ha invitato, con le stesse parole di `audit_log.actor`:\n«chi mi ha invitato» non deve richiedere un accesso all'audit.\n"
          },
          "invited_at": {
            "type": "string",
            "format": "date-time"
          },
          "accepted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "suspended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "ElencoUtentiDelTenant": {
        "type": "object",
        "properties": {
          "tenant": {
            "$ref": "#/components/schemas/Tenant"
          },
          "io": {
            "type": "object",
            "description": "Con che cosa si sta guardando. Serve a una console per sapere quali tasti mostrare.",
            "properties": {
              "owner_scope": {
                "type": "string",
                "enum": [
                  "PLATFORM",
                  "PRODUCT",
                  "TENANT",
                  "USER"
                ]
              },
              "user_uid": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "ruolo": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Il ruolo EFFICACE di chi chiede in questo tenant (`null` se non ne ha)."
              },
              "amministra": {
                "type": "boolean"
              }
            }
          },
          "utenti": {
            "type": "array",
            "description": "Tutti, per un amministratore. **Solo se stesso** per un utente\nsemplice: non l'elenco accorciato, non il numero degli altri.\n",
            "items": {
              "$ref": "#/components/schemas/UtenteDelTenant"
            }
          }
        }
      },
      "RichiestaEmissioneChiave": {
        "type": "object",
        "required": [
          "name"
        ],
        "description": "Se `owner_scope` non c'e', la chiave nasce nell'ambito di chi chiama. Se\nc'e', vanno dichiarate anche le colonne che quel grado richiede\n(PRODUCT: `product_code`; TENANT: + `tenant_uid`; USER: + `user_uid`) e\nl'ambito deve stare **sotto** il proprio.\n",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Il nome della chiave («integrazione fatturazione», «cron notturno»).\nObbligatorio: un elenco di impronte senza nome non dice quale chiave\nsi puo' spegnere.\n"
          },
          "owner_scope": {
            "type": "string",
            "enum": [
              "PLATFORM",
              "PRODUCT",
              "TENANT",
              "USER"
            ]
          },
          "product_code": {
            "type": "string"
          },
          "tenant_uid": {
            "type": "string"
          },
          "user_uid": {
            "type": "string"
          },
          "licenza_id": {
            "type": "string",
            "description": "**Sotto quale licenza nasce questa chiave.** Facoltativo: con una\nsola licenza che apre e' implicita, e la chiave nasce sotto quella.\nCon **due** licenze aperte va dichiarato, altrimenti la richiesta si\nferma con `409 LICENZA_DA_NOMINARE`: il patto sotto cui nasce una\ncredenziale e' una decisione commerciale, e sceglierlo noi vorrebbe\ndire fatturarti un tetto che non hai comprato.\n\nSe non e' una licenza di quel cliente: `404 LICENZA_SCONOSCIUTA`. Se\ne' sua ma non apre (sospesa, revocata, scaduta oltre la grazia):\n`409 LICENZA_CHE_NON_APRE`, col motivo — una chiave che nascesse\nsotto un patto chiuso sarebbe gia' morta. Su una chiave PLATFORM o\nPRODUCT il campo non ha senso e si ferma con `400\nLICENZA_SENZA_TENANT`: una licenza e' il diritto di UN tenant.\n"
          },
          "allowed_channels": {
            "type": "array",
            "description": "I canali che la chiave apre. Di serie quelli di chi la emette, e in\nnessun caso di piu'.\n",
            "items": {
              "type": "string"
            }
          },
          "allowed_ips": {
            "type": "array",
            "description": "Allowlist IP (anti-spoofing sull'`X-Forwarded-For`).",
            "items": {
              "type": "string"
            }
          },
          "monthly_quota": {
            "type": [
              "integer",
              "null"
            ],
            "description": "La quota mensile di messaggi di questa chiave. Fa **due** cose: frena\ngli invii veri (oltre il tetto, `429 QUOTA_MENSILE_SUPERATA` fino al\nmese nuovo; la sandbox non lo spende) e impedisce di emetterne una\npiu' larga di se'. Si conta per CHIAVE e conta i messaggi\n**accettati**, non i consegnati: il tetto si spende alla porta, una\nvolta, e non torna indietro sui fallimenti.\n\n**Assente = eredita il tuo tetto**, come per i canali. Esplicitamente\n`null` = «nessun tetto», e allora vale come escalation se tu ne hai\nuno: senza questa distinzione, chi ha una quota non poteva emettere\nuna chiave senza ricopiarsela a ogni chiamata.\n"
          },
          "rate_limit_per_minute": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto di richieste al minuto (429 con `Retry-After`). Assente =\neredita il tuo; `null` = nessun tetto, e vale come escalation.\n"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Quando smette di aprire. Nel futuro, o 400 SCADENZA_NON_VALIDA."
          }
        }
      },
      "RichiestaCambioChiave": {
        "type": "object",
        "description": "Almeno uno dei campi, e **solo** questi: l'ambito (`owner_scope`,\n`product_code`, `tenant_uid`, `user_uid`) non si cambia e il valore non\nsi cambia — quello si ruota. Un campo assente resta come e'; un campo\npresente col valore che c'e' gia' non e' un cambio, e non lascia righe\nnel registro.\n",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Il nome della chiave. Non si svuota: un elenco di impronte senza nome\nnon dice quale chiave si puo' spegnere.\n"
          },
          "monthly_quota": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto mensile nuovo, che vale **da adesso** sullo stesso contatore\nche la porta guarda. `null` = nessun tetto, e vale come escalation se\nchi chiama ne ha uno. Non si alza oltre il proprio, e una chiave non\nse lo alza da se'.\n"
          },
          "rate_limit_per_minute": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Come sopra, per le richieste al minuto."
          },
          "allowed_channels": {
            "type": "array",
            "description": "I canali che la chiave apre. Mai piu' di quelli di chi chiama, e una\nchiave non se ne aggiunge da se'. Non si svuota, che sarebbe una\nrevoca travestita.\n",
            "items": {
              "type": "string"
            }
          },
          "allowed_ips": {
            "type": [
              "array",
              "null"
            ],
            "description": "L'allowlist IP. `null` (o elenco vuoto) la toglie — ma non sulla\nPROPRIA chiave, dove si possono solo togliere indirizzi da un elenco\nche c'e' gia'.\n",
            "items": {
              "type": "string"
            }
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Quando smette di aprire. Nel futuro (`400 SCADENZA_NON_VALIDA`);\n`null` la toglie. Sulla PROPRIA chiave si puo' solo anticipare. Su\nuna chiave in grazia di rotazione non si tocca (`409\nCHIAVE_IN_GRAZIA`).\n"
          }
        }
      },
      "RichiestaRotazioneChiave": {
        "type": "object",
        "properties": {
          "grazia_giorni": {
            "type": "integer",
            "description": "Per quanti giorni la chiave VECCHIA continua ad aprire. Fra 0 e 30,\ndi serie 7. Non prolunga una scadenza che la vecchia aveva gia'.\n"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "La scadenza della chiave NUOVA. Di serie eredita quella della\nvecchia; obbligatoria se la vecchia e' gia' scaduta, o la nuova\nnascerebbe scaduta anche lei.\n"
          }
        }
      },
      "RichiestaRevocaChiave": {
        "type": "object",
        "properties": {
          "motivo": {
            "type": "string",
            "maxLength": 160,
            "description": "Perche' si revoca. Finisce in `api_key.revoke_reason` e nel registro."
          }
        }
      },
      "ChiaveDellaConsole": {
        "type": "object",
        "description": "Una chiave come la mostra la console. **Mai il valore in chiaro**, tranne\nnella risposta dell'emissione e della rotazione (campo `chiave`), dove\nesce una volta sola.\n\nCampi che compaiono solo in alcune risposte: `chiave` e `avviso`\n(emissione e rotazione), `sostituisce` (rotazione), `duplicato` e\n`motivo` (doppione di `Idempotency-Key`),\n`revocata_subito` (revoca), `ruolo_di_chi_emette` (emissione),\n`cambiato`, `tetto_mensile`, `nota` e `ruolo_di_chi_cambia` (PATCH),\n`mese` e i due conti d'uso (elenco e lettura di una).\n",
        "properties": {
          "key_id": {
            "$ref": "#/components/schemas/Ulid"
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "stato": {
            "type": "string",
            "enum": [
              "ATTIVA",
              "IN_GRAZIA",
              "SCADUTA",
              "REVOCATA"
            ],
            "description": "Piu' preciso della colonna `status`, che conosce due valori e non sa\ndire «e' scaduta» ne' «sta finendo la grazia di una rotazione».\n"
          },
          "owner_scope": {
            "type": "string",
            "enum": [
              "PLATFORM",
              "PRODUCT",
              "TENANT",
              "USER"
            ]
          },
          "product_code": {
            "type": [
              "string",
              "null"
            ]
          },
          "tenant_uid": {
            "type": [
              "string",
              "null"
            ]
          },
          "user_uid": {
            "type": [
              "string",
              "null"
            ]
          },
          "allowed_channels": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "allowed_ips": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          },
          "key_fp": {
            "type": "string",
            "description": "Le prime 12 cifre di `sha256` della chiave, come `secret_fp` di\n`channel_config`: bastano a confrontare, non bastano a nient'altro.\n"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "issued_at": {
            "type": "string",
            "format": "date-time"
          },
          "issued_by": {
            "type": [
              "string",
              "null"
            ],
            "description": "Chi l'ha emessa, nella stessa forma di `audit_log.actor`: la\nchiave che l'ha chiesta dalla console (`chiave:<key_id>`), oppure\nl'attore non-chiave dell'atto quando nessuna credenziale e' stata\npresentata - il conio da riga di comando della regia, unica strada\nper una chiave di grado PRODUCT o PLATFORM, scrive\n`riga-di-comando:chiave-prodotto` sia qui sia sulla catena. NULL\nsulle chiavi nate prima della console.\n"
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "revoke_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "rotated_from": {
            "type": [
              "string",
              "null"
            ],
            "description": "La chiave che questa sostituisce."
          },
          "rotated_to": {
            "type": [
              "string",
              "null"
            ],
            "description": "La chiave che sostituisce questa."
          },
          "grace_until": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fino a quando apre, essendo stata ruotata."
          },
          "rate_limit_per_minute": {
            "type": [
              "integer",
              "null"
            ]
          },
          "quota_mensile_dichiarata": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto mensile scritto sulla chiave. `null` = nessun tetto, e\nallora non si conta niente.\n"
          },
          "mese": {
            "type": "string",
            "description": "Il mese dei tre conti d'uso, `YYYY-MM`."
          },
          "richieste_della_chiave_nel_mese": {
            "type": "integer",
            "description": "Le richieste di QUESTA chiave, da `access_log`. Una richiesta\nrifiutata e' comunque una richiesta: non e' un conto di messaggi.\n"
          },
          "messaggi_dello_scope_nel_mese": {
            "type": "integer",
            "description": "I messaggi dello SCOPE, da `usage_counter`, che conta per\n`owner_key`. Due chiavi dello stesso scope portano lo stesso numero.\n"
          },
          "accodati_della_chiave_nel_mese": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto SPESO da questa chiave nel mese (`key_monthly_usage`): i\nmessaggi veri che ha fatto accettare, sandbox esclusa. `null` quando\nla chiave non dichiara un tetto suo — senza tetto quel contatore non\nsi scrive affatto, e uno zero farebbe leggere «non ha mai spedito».\nNON e' da solo il numero del `429 QUOTA_MENSILE_SUPERATA`: il 429 lo\nda' il piu' stretto fra i due tetti, e `quota_mensile_da` dice quale.\n"
          },
          "resta_del_tetto_mensile": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`quota_mensile_dichiarata` meno gli accodati, mai sotto zero.\n`null` = nessun tetto dichiarato.\n"
          },
          "quota_mensile_del_patto": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto mensile VENDUTO col patto (`license.quota_mensile`) sotto cui\nquesta chiave apre. `null` quando la chiave non sta sotto nessuna\nlicenza, o quando la licenza non dichiara un tetto.\n"
          },
          "accodati_del_patto_nel_mese": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Il tetto del PATTO speso nel mese (`license_monthly_usage`), da TUTTE\nle chiavi che aprono sotto quella licenza: le chiavi sorelle portano\nlo stesso numero, ed e' vero — il patto si vende una volta sola\n(migrazione 032). Per il cliente normale, che il tetto lo ha sul patto\ne non sulla riga della chiave, e' questo il numero che si consuma.\n`null` = nessun tetto del patto (e allora quel contatore non si\nscrive: `null` non e' zero).\n"
          },
          "resta_del_patto_nel_mese": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`quota_mensile_del_patto` meno gli accodati del patto, mai sotto zero.\n`null` = nessun tetto del patto.\n"
          },
          "quota_mensile_da": {
            "type": "string",
            "enum": [
              "chiave",
              "licenza"
            ],
            "nullable": true,
            "description": "QUALE dei due tetti mensili frenerebbe per primo: `chiave` se stringe\nla riga, `licenza` se stringe il patto, `null` se non c'e' nessun\ntetto. Stesso nome e stessi valori di `GET /v1/uso`, e lo decide la\nstessa funzione che decide il 429 — nel corpo del `429` il campo\n`frenato_da` dice chi ha frenato DAVVERO.\n"
          },
          "chiave": {
            "type": [
              "string",
              "null"
            ],
            "description": "Il valore in chiaro, UNA volta sola (emissione e rotazione). Nel\ndatabase resta solo `sha256`: non c'e' nessun modo di rivederlo.\n"
          },
          "avviso": {
            "type": "string"
          },
          "ruolo_di_chi_emette": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "AMMINISTRATORE",
              "UTENTE",
              "null"
            ],
            "description": "Il ruolo che il resolver iniettato ha dato a chi ha emesso, quando\nc'e'. `null` = nessun resolver, e vale il comportamento di sempre.\n"
          },
          "duplicato": {
            "type": "boolean",
            "description": "Vero se questa `Idempotency-Key` aveva gia' emesso la chiave."
          },
          "motivo": {
            "type": "string",
            "description": "Perche' il valore in chiaro non c'e', sul doppione."
          },
          "sostituisce": {
            "type": "object",
            "description": "Sulla rotazione: `key_id` della chiave vecchia, `apre_fino_al` (la\nfine della grazia), `grazia_giorni_chiesti` e una `nota` quando la\ngrazia e' stata accorciata alla scadenza che la vecchia aveva gia'.\n"
          },
          "revocata_subito": {
            "type": "boolean",
            "description": "Vero sulla risposta della revoca: non c'e' nessuna grazia."
          }
        }
      },
      "ElencoChiavi": {
        "type": "object",
        "properties": {
          "ambito": {
            "type": "string",
            "description": "La chiave di proprieta' del sotto-albero che si sta guardando\n(`GRADO|prodotto|tenant|utente`). Dichiarata perche' un elenco che\nnon dice di quale ambito e' si legge come «tutte le chiavi», e non lo\ne' quasi mai.\n"
          },
          "ruolo": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "AMMINISTRATORE",
              "UTENTE",
              "null"
            ]
          },
          "mese": {
            "type": "string"
          },
          "chiavi": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChiaveDellaConsole"
            }
          },
          "nota_uso": {
            "type": "string"
          },
          "nota_passepartout": {
            "type": "string"
          }
        }
      },
      "EstrattoMensile": {
        "type": "object",
        "description": "Il conto di UN mese dello scope della chiave, per canale e fornitore,\ncon lo stato del mese e la fonte dichiarata del conto. Si contano i\nmessaggi, non i prezzi: il listino e' del titolare.\n",
        "properties": {
          "ambito": {
            "type": "object",
            "description": "Lo scope della chiave, in chiaro, perche' chi fattura deve poter\nverificare a nome di CHI sta leggendo. Non l'`owner_key`: quella e'\nla forma interna che porta gli indici e non esce da nessuna rotta.\n",
            "properties": {
              "owner_scope": {
                "type": "string",
                "enum": [
                  "PLATFORM",
                  "PRODUCT",
                  "TENANT",
                  "USER"
                ]
              },
              "product_code": {
                "type": "string",
                "nullable": true
              },
              "tenant_uid": {
                "type": "string",
                "nullable": true
              },
              "user_uid": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "mese": {
            "type": "string",
            "pattern": "^[0-9]{4}-[0-9]{2}$",
            "description": "Il mese chiesto, come l'ha chiesto il chiamante."
          },
          "stato": {
            "type": "string",
            "enum": [
              "aperto",
              "chiuso"
            ],
            "description": "`chiuso` = TUTTE le righe del mese bloccate (`usage_counter.locked_at`),\ncioe' contabile e immutabile. `aperto` = i numeri possono ancora\ncambiare (o il mese non ha righe).\n"
          },
          "fonte": {
            "type": "string",
            "enum": [
              "usage_counter"
            ],
            "description": "La fonte del conto, DICHIARATA: l'estratto legge dallo stesso\ncontatore che il servizio ha sempre tenuto, non da una seconda\ncontabilita'. La somma dell'estratto e' identica a quella del\ncontatore.\n"
          },
          "canali": {
            "type": "array",
            "description": "I conti per canale, ognuno coi fornitori che hanno davvero spedito.\nUn canale con contatori ma senza nessun evento SENT nel mese (righe\nvecchie, eventi potati) ha `provider: null` e `fornitori: []`.\n",
            "items": {
              "type": "object",
              "properties": {
                "channel_type": {
                  "type": "string",
                  "enum": [
                    "EMAIL",
                    "WHATSAPP",
                    "SMS",
                    "TELEGRAM",
                    "VOICE",
                    "TEAMS"
                  ]
                },
                "provider": {
                  "type": "string",
                  "nullable": true
                },
                "fornitori": {
                  "type": "array",
                  "description": "I fornitori che hanno davvero spedito in quel mese su quel\ncanale, in ordine di primo invio: `[{provider, account_ref}]`.\nE' un fatto letto dal filo del messaggio, non una scelta.\n",
                  "items": {
                    "type": "object",
                    "properties": {
                      "provider": {
                        "type": "string"
                      },
                      "account_ref": {
                        "type": "string",
                        "nullable": true
                      }
                    }
                  }
                },
                "inviati": {
                  "type": "integer"
                },
                "consegnati": {
                  "type": "integer",
                  "description": "Gli esiti del fornitore (`delivered`, `read`) contati sul giorno dell'invio."
                },
                "falliti": {
                  "type": "integer",
                  "description": "I messaggi NOSTRI morti davvero, uno per messaggio: non i rimbalzi del fornitore e non i giri del ritenta."
                },
                "minuti": {
                  "$ref": "#/components/schemas/MinutiDiChiamata"
                }
              }
            }
          },
          "totale": {
            "type": "object",
            "description": "La somma dei conti del mese, per canale.",
            "properties": {
              "inviati": {
                "type": "integer"
              },
              "consegnati": {
                "type": "integer",
                "description": "Gli esiti del fornitore (`delivered`, `read`) contati sul giorno dell'invio."
              },
              "falliti": {
                "type": "integer",
                "description": "I messaggi NOSTRI morti davvero, uno per messaggio: non i rimbalzi del fornitore e non i giri del ritenta."
              },
              "minuti": {
                "$ref": "#/components/schemas/MinutiDiChiamata"
              }
            }
          }
        }
      },
      "MinutiDiChiamata": {
        "type": "object",
        "description": "I minuti di chiamata del mese (E22), dalle righe di `call_event` dello\nscope con una durata (`duration_s` NULL non entra, 0 vale zero\nimpulsi), a impulsi di SEI secondi arrotondati in su. Un canale senza\nchiamate ha i blocchi a zero, mai null. Si contano i secondi, non i\nprezzi: il listino e' del titolare.\n",
        "properties": {
          "fatturabili": {
            "$ref": "#/components/schemas/BloccoDiMinuti"
          },
          "gratuiti": {
            "$ref": "#/components/schemas/BloccoDiMinuti"
          }
        }
      },
      "BloccoDiMinuti": {
        "type": "object",
        "description": "`fatturabili` = le chiamate iniziate da noi; `gratuiti` = quelle in\nentrata, gratuite alla fonte e tenute a parte.\n",
        "properties": {
          "chiamate": {
            "type": "integer"
          },
          "impulsi": {
            "type": "integer"
          },
          "secondi": {
            "type": "integer"
          },
          "minuti": {
            "type": "number"
          }
        }
      },
      "Pratica": {
        "type": "object",
        "description": "Una pratica dei Servizi Sovrani (A358), come la legge chi la lavora. Il\n`numero` e' il nome della pratica: quello che l'ente cita al telefono e\nin una mail, e quello che va nel percorso di\n`PATCH /admin/pratiche/{numero}`. L'`id` (un ULID) non si pubblica qui:\ne' il nome per un programma, e due nomi per la stessa cosa sono il modo\ndi citare quello sbagliato.\n\nI dati di persona sono **solo quelli che l'ente scrive** (A358), e il\n`messaggio` c'e' perche' senza quello una pratica non si puo' lavorare.\n`conferma_at` e `regia_at` a `null` vogliono dire «il recapito non e'\nancora partito»: si vede sulla riga, invece di essere un silenzio.\n",
        "properties": {
          "numero": {
            "type": "string",
            "example": "SS-2026-00007",
            "description": "SS-AAAA-NNNNN, progressivo dell'anno, senza buchi e mai riusato."
          },
          "anno": {
            "type": "integer"
          },
          "progressivo": {
            "type": "integer"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "origine": {
            "type": "string",
            "enum": [
              "MODULO",
              "EMAIL"
            ]
          },
          "origine_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "La riga NOSTRA da cui la pratica e' nata (`richieste_licenza.id` per\nil modulo di AgileChannels), oppure `null` se non ce n'e' una.\n"
          },
          "servizio": {
            "type": "string"
          },
          "ente": {
            "type": [
              "string",
              "null"
            ]
          },
          "recapito": {
            "type": "string"
          },
          "nome": {
            "type": [
              "string",
              "null"
            ]
          },
          "telefono": {
            "type": [
              "string",
              "null"
            ]
          },
          "oggetto": {
            "type": [
              "string",
              "null"
            ]
          },
          "messaggio": {
            "type": [
              "string",
              "null"
            ]
          },
          "lingua": {
            "type": [
              "string",
              "null"
            ]
          },
          "stato": {
            "type": "string",
            "enum": [
              "RICEVUTA",
              "PRESA_IN_CARICO",
              "RISPOSTA",
              "CHIUSA"
            ]
          },
          "stato_at": {
            "type": "string",
            "format": "date-time"
          },
          "conferma_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "regia_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      }
    }
  },
  "x-generato-da": "bin/fai-contratto-pubblico.js (da openapi/v1.yaml, senza i percorsi x-interno)"
}
