# API Integrazioni esterne — v1

> Ultimo aggiornamento: 6 ottobre 2026

API per software esterni (casse, gestionali, sistemi di prenotazione, e-commerce, software sportivi) che
devono aggiornare punti/bollini, leggere lo stato di un cliente, riscattare premi e annullare un movimento
su Tesserando. **Feature ULTRA**, abilitata dopo una
richiesta del tenant approvata dall'amministrazione Tesserando.

> Contratto **congelato**: `v1` non cambia in modo incompatibile. Eventuali evoluzioni vanno
> su `v2`.

## Base URL

```
https://www.tesserando.it/api/external/v1
```

> ⚠️ **Il prefisso `www.` è obbligatorio.** L'apex `https://tesserando.it` risponde `301` verso
> `www` e la maggior parte dei client HTTP **rimuove l'header `Authorization` seguendo un
> redirect verso un host diverso**: la chiamata arriva senza credenziali e fallisce con
> `401 UNAUTHORIZED` ("Missing or invalid Authorization header"), facendo sembrare sbagliata la
> API key. Se vedi quel messaggio mentre sei certo della key, il primo controllo è il `www.`
> nella URL. Vale anche per il redirect da `http://`: usa sempre `https://www.`.

Fisso e identico per tutti i tenant: non è configurabile né per-cliente, va semplicemente
cablato nel software esterno (non esiste un sotto-dominio o path diverso per tenant).
L'isolamento dei dati tra tenant non dipende dall'URL ma dalla **API key**: ogni key è legata
a una sola integrazione/tenant lato server (vedi [Autenticazione](#autenticazione)).

## Autenticazione

Tutte le chiamate richiedono l'header:

```
Authorization: Bearer <API_KEY>
```

La API key ha il formato `tsk_live_<43 caratteri base64url>` (52 caratteri totali), viene
generata all'approvazione e recuperata dal **titolare** dell'attività (ruolo `TENANT_OWNER`) in
**Impostazioni → Integrazioni esterne**, dove può rivederla in qualsiasi momento finché l'integrazione
è attiva (non è una key "mostrata una sola volta"). Il titolare può rigenerarla (la vecchia smette
subito di funzionare) e attivarla/disattivarla.

Come viene conservata: la key in chiaro **non** è salvata come tale. Lato server restano due valori
derivati: l'**hash SHA-256**, usato per riconoscere la key a ogni chiamata, e una **copia cifrata**
(AES-256-GCM, con una chiave ricavata da un segreto della piattaforma) che serve solo a far rivedere la
key al titolare. La copia cifrata viene cancellata quando il team Tesserando disattiva forzatamente
l'integrazione e sostituita a ogni rigenerazione. Se il segreto di cifratura della piattaforma venisse
cambiato, la key non sarebbe più visualizzabile nel pannello (quella già configurata nel software esterno
continua a funzionare, perché si autentica con l'hash): basta rigenerarla.

Controlli eseguiti su ogni chiamata, in ordine (il primo che fallisce determina la risposta):

| Ordine | Controllo | Errore se fallisce |
|---|---|---|
| 1 | Header `Authorization: Bearer <key>` presente e key valida | `401 UNAUTHORIZED` |
| 2 | Integrazione con stato `ACTIVE` | `403 INTEGRATION_DISABLED` |
| 3 | Tenant non sospeso (abbonamento non pagato oltre il periodo di tolleranza, o sospensione amministrativa) | `403 TENANT_SUSPENDED` |
| 4 | Tenant ancora su piano **ULTRA** | `403 PLAN_REQUIRED` |
| 5 | Allowlist di rete, solo se configurata | `403 IP_NOT_ALLOWED` |
| 6 | Limite generale di richieste (120/min per key) | `429 RATE_LIMITED` |

Il limite generale (6) si consuma **dopo** i controlli 1-5, quindi un rifiuto per integrazione disattivata, tenant
sospeso, piano o IP non conta per il limite. I rifiuti 2-5 (dal momento in cui la key identifica
un'integrazione) vengono registrati in **"Ultime chiamate API"** con il loro codice, così il merchant vede
perché le chiamate non passano; il `429` generale e il `401` non vengono registrati. I limiti **dedicati** di
singole rotte (tabella in [Note operative](#note-operative)) scattano più avanti, dentro la rotta, **dopo** il
limite generale.

### Allowlist di rete (opzionale, difesa in profondità)

La sicurezza primaria è la **API key**. In aggiunta, l'admin Tesserando può restringere, in
fase di approvazione dell'integrazione, gli IP che possono chiamare l'API:

- **URL/host consentito** (`allowedUrl`, sorgente primaria): l'host viene risolto via DNS
  (record **A + AAAA** in parallelo, con cache in-memory **TTL 5 minuti** per i risultati
  validi; una risposta **vuota** — host non risolvibile, errore transitorio del resolver — si ricorda
  solo **10 secondi**, e se prima c'era un risultato valido si continua a usare quello) e solo gli IP
  risultanti possono chiamare. Utile quando il software esterno ha un dominio ma IP che
  possono cambiare. Se l'host configurato è già un indirizzo IP letterale, viene usato
  direttamente senza lookup DNS.
- **IP/CIDR statici** (`allowedIp`, fallback/aggiunta): lista di voci separate da spazi,
  virgole, punto e virgola o a capo — supporta IPv4, IPv6 e notazione CIDR per entrambe
  le famiglie (es. `203.0.113.7, 10.0.0.0/8, 2001:db8::/48`). Si **somma** (non sostituisce)
  agli IP risolti da `allowedUrl`, se presente.

Il confronto normalizza le forme equivalenti: IPv6 compresso/esteso, zone id (`%eth0`,
ignorato), e IPv4-mapped (`::ffff:203.0.113.7` ≡ `203.0.113.7`).

**Quale IP viene confrontato**: nell'ordine `cf-connecting-ip` (Cloudflare, sorgente normale in
produzione) → `x-real-ip` → **ultimo** hop di `x-forwarded-for`. Il **primo** hop di
`x-forwarded-for` è volutamente **scartato**: è sotto il controllo del chiamante, che potrebbe
inviare lui stesso l'header per far figurare un IP arbitrario. Se nessuno dei tre header è
presente l'IP risulta `unknown` e non combacia con nessuna voce dell'allowlist: un'integrazione
con allowlist configurata che chiamasse senza passare dall'edge riceverebbe `403 IP_NOT_ALLOWED`.
Resta comunque difesa in profondità, non sicurezza primaria.

**Se nessuna allowlist è configurata, o se `allowedUrl` non risolve alcun IP e `allowedIp` è
vuoto**, il controllo di rete è saltato e l'accesso resta protetto dalla sola API key — non è
un errore, è il comportamento di default per le integrazioni senza restrizioni di rete.
Un fallimento DNS su `allowedUrl` non causa un errore visibile al chiamante: viene trattato
come "nessun IP da questa fonte" e si ricade sull'eventuale `allowedIp` statico; se non resta
nessuna voce, la chiamata passa con la sola API key e Tesserando lo registra nei propri log
interni (`integration_allowlist_skipped`).

Queste impostazioni (`allowedUrl`, `allowedIp`) sono configurate dal team Tesserando in fase
di approvazione, non sono self-service nel pannello del tenant. **Dopo l'approvazione non esiste una
schermata per cambiarle** (né per cambiare `defaultTemplateId` e l'invito automatico): il tenant
deve contattare il supporto, che le aggiorna con un intervento tecnico. In approvazione l'indirizzo
non viene verificato: un `allowedUrl` che non risolve nessun IP fa saltare l'allowlist (vedi sopra),
non genera un errore.

## Concetti

- **externalCustomerId**: l'identificatore del cliente nel software esterno. È la chiave con cui
  Tesserando tiene il collegamento. Stabile e univoco lato chiamante.
- **Collegamento (link)**: associazione tra `externalCustomerId` e una carta Tesserando. Si crea
  con `resolve`. Stati: `LINKED` (pronto), `PENDING_REVIEW` (più candidati, da risolvere a mano
  dal merchant — le eventuali `POST /transactions` ricevute in questo stato vengono accodate e
  applicate automaticamente alla risoluzione, vedi la nota dedicata su
  [`POST /transactions`](#post-transactions)), `INVITE_SENT` (mandata email di invito alla
  registrazione), `ORPHANED` (la carta collegata è stata cancellata, o `resolve` la considera tale perché
  appartiene a una campagna archiviata: vedi sotto), `NOT_FOUND` (nessun match).
  **Precisazione**: i soli stati realmente salvati sul collegamento sono `LINKED` e `PENDING_REVIEW`.
  `ORPHANED` è un collegamento `LINKED` la cui carta è stata cancellata; `INVITE_SENT` e `NOT_FOUND`
  sono solo **esiti** di `resolve`, non restano salvati. `GET /customers/{id}` risponde `ORPHANED`
  per ogni collegamento che non sia `LINKED` con una carta viva e non sia `PENDING_REVIEW`.
  **Carte archiviate**: una carta archiviata non si collega più a nessun cliente esterno (né per `linkCode`,
  né per telefono o email, né per nome in `lookup`); un collegamento `LINKED` già esistente che punta a una
  carta archiviata vale come orfano **per `resolve`** (vedi la cascata sotto), mentre `GET /customers/{id}` e
  `POST /transactions` continuano a leggere il collegamento salvato (`cardArchived: true` / `409 CARD_ARCHIVED`)
  finché non si chiama `resolve`.
- **defaultTemplateId**: se l'integrazione ha un modello di tessera predefinito impostato
  dall'admin in fase di approvazione, il matching automatico per telefono/email in `resolve`
  (e il matching per nome/cognome in `lookup`) cerca **solo** tra le carte di quel modello. Senza
  `defaultTemplateId`, il match avviene su tutte le carte del tenant. Non limita invece l'elenco delle carte
  restituito da [`GET /cards`](#get-cards) né quello di `cards[]` in `lookup`: lì il modello predefinito è
  solo segnalato con `isDefault`. È inoltre **richiesto** per
  poter usare `POST /customers` (la nuova carta viene creata su quel modello). Non è esposto né
  modificabile nel payload delle chiamate API.
- **allowIdentityLookup** / **allowCustomerCreation**: due flag opt-in per integrazione,
  impostati dall'admin in fase di approvazione (default **off**, come `defaultTemplateId`),
  che abilitano rispettivamente [`POST /customers/lookup`](#post-customerslookup) e — lo stesso
  `allowCustomerCreation` copre entrambe — [`POST /customers`](#post-customers) e
  [`POST /customers/register-link`](#post-customersregister-link); vedi le note PII/consenso in
  quelle sezioni. Come `defaultTemplateId`, non sono self-service: per attivarli il tenant deve
  contattare il supporto.
- **Tipo di carta** (`CardType` sul modello di tessera, impostato dal merchant, non modificabile
  via API): `POINTS`, `STAMPS` o `PROGRESSION`. Ai fini di questa API `POINTS` e `STAMPS` si
  comportano in modo identico (saldo a scalare); `PROGRESSION` ha una semantica diversa — un
  contatore che non scende mai (salvo l'[annullo](#post-transactionsreverse) di un accredito) e può avere più
  traguardi indipendenti — vedi `isProgression` su
  [`GET /customers/{id}`](#get-customersexternalcustomerid) e il riscatto su
  [`POST /transactions`](#post-transactions) (nota bene il limite di validazione lato server
  descritto lì).
- **amount**: sempre in **punti/bollini** (l'unità della carta), **mai in euro**. Rappresenta un
  evento di business deciso dal merchant — un acquisto, ma anche una partita giocata, una
  visita, una prenotazione — non necessariamente legato a una spesa. La conversione da quell'evento
  a un numero di punti/bollini è responsabilità del software esterno: Tesserando accredita
  esattamente l'intero passato, senza applicare alcun rapporto interno.
- **storeCode (sede)**: il codice con cui il software esterno chiama un punto vendita (es. il "PV 01"
  del suo gestionale). Il merchant lo associa alla sede in **Impostazioni → Negozi** (campo «Codice nel
  tuo gestionale»); è univoco dentro l'attività e non c'è nulla da configurare lato API. Un campo
  opzionale `storeCode` su [`POST /transactions`](#post-transactions),
  [`POST /customers`](#post-customers), [`POST /customers/register-link`](#post-customersregister-link) e
  [il riscatto della promo compleanno](#post-customersexternalcustomeridbirthday-promoredeem)
  attribuisce l'operazione a quella sede, **come per le operazioni fatte dal pannello o dall'app**: i
  movimenti compaiono nelle viste per negozio (filtro clienti per negozio, statistiche per sede, messaggi
  per negozio). Senza `storeCode` il movimento resta senza sede, come prima. Un `storeCode` che non
  corrisponde a nessuna sede dell'attività risponde `422 STORE_NOT_FOUND`: mai attribuito a nessuna sede in
  silenzio. Un `storeCode` **vuoto** (anche solo spazi) o malformato non è "sconosciuto": risponde
  `422 INVALID_PAYLOAD` («storeCode must not be empty»). Il multi-negozio richiede il piano ULTRA, come l'API. Se la sede viene **eliminata** dal merchant
  dopo, i movimenti già attribuiti restano nello storico e nei totali dell'attività come senza sede; le
  chiamate successive con quel `storeCode` rispondono `422 STORE_NOT_FOUND` (il codice non corrisponde più a
  nessuna sede). Un movimento accodato e riprodotto dopo l'eliminazione della sede viene comunque
  **accreditato, senza sede** — mai rifiutato: per Tesserando il saldo del cliente conta più
  dell'attribuzione.
- **Vantaggi del cliente**: quello che la tessera può dare oltre al saldo. Sono **punti, un moltiplicatore e
  un regalo a testo libero: non esistono sconti in euro o in percentuale**, quindi il software esterno non ha
  nessuno sconto di Tesserando da applicare. Si leggono tutti in [`GET /customers/{id}`](#get-customersexternalcustomerid),
  e **ci sono solo quelli riscattabili in quel momento**: `coupons` (win-back e compleanno, con `points` e
  `expiresAt`; **non hanno un riscatto separato**: il prossimo `EARN` li incassa da solo e li somma ai punti
  di quel movimento, vedi `winBackBonus` / `birthdayBonus`), `birthdayPromo` (il regalo di compleanno da
  consegnare, si scarica con
  [`POST /customers/{id}/birthday-promo/redeem`](#post-customersexternalcustomeridbirthday-promoredeem)),
  `happyHour` (moltiplicatore in corso, applicato da solo all'`EARN`), premi e traguardi. Se la carta non
  accetta più accrediti (campagna finita) coupon e Happy Hour non compaiono, perché nessun accredito li
  applicherebbe.
- **Id del premio**: ogni premio di una carta a Punti o Bollini (della carta o del catalogo globale) ha un `id`
  stabile, che si legge in `rewards[].id` di [`GET /customers/{id}`](#get-customersexternalcustomerid). **Non cambia
  se il merchant rinomina il premio o ne cambia il costo**; il merchant lo vede anche nel pannello (editor della
  carta e catalogo premi, con «Copia»). Si passa come `rewardId` al `REDEEM` di [`POST /transactions`](#post-transactions):
  nome e costo si leggono dal premio e `amount` non serve. I traguardi delle carte a Traguardi hanno già il loro `id`
  (`milestones[].id`, usato come `milestoneId`). Il nome e il costo di un premio sono scelti dal merchant e non
  hanno un valore in euro: **a cosa corrisponde un premio in cassa** (uno sconto, un articolo) lo decide chi
  configura il software esterno, che lo lega all'`id`.
- **Quando conta un movimento**: un movimento vale **al momento in cui Tesserando lo riceve**. Non esiste un
  campo "data dell'evento" (scelta di prodotto: non si sposta il momento in cui i punti vengono accreditati per
  adattarsi al ritmo di invio del software esterno). Conseguenza pratica per chi invia in blocco a fine
  giornata: Happy Hour e fine campagna vengono valutati all'ora della chiamata, non a quella dell'evento, e il
  saldo letto durante il giorno non contiene ancora i movimenti non inviati. Conviene inviare ogni movimento
  **quando l'evento si chiude** (vendita, visita, partita, prenotazione).
- **Un riferimento per operazione**: `externalReference` è la chiave di idempotenza **assoluta** di
  `POST /transactions` (vedi lì): se due operazioni diverse — per esempio l'accredito e il riscatto di un
  premio fatti sulla stessa vendita — usano lo stesso riferimento, la seconda risponde `alreadyApplied: true`
  **e non viene eseguita**. Usa un riferimento diverso per ogni operazione (es. `V-000123-EARN`,
  `V-000123-REDEEM`). Il riscatto della promo compleanno ha uno spazio di riferimenti suo e non collide con
  quelli di `POST /transactions`.
- **Annullo**: un movimento fatto per errore o reso si annulla per intero con
  [`POST /transactions/reverse`](#post-transactionsreverse); resta nello storico come annullato e non conta più
  nelle statistiche.
- **Bonus automatici**: se il tenant ha un'happy hour attiva, un coupon win-back pendente o un
  bonus di compleanno per quella carta, vengono applicati automaticamente anche alle chiamate
  `EARN` via API, esattamente come per le operazioni fatte dal pannello (vedi
  [`POST /transactions`](#post-transactions)). Il software esterno non deve fare nulla per
  attivarli: si riflettono nei campi opzionali della risposta.

## Esempio di flusso: un software di cassa

Un esempio concreto con un software di cassa che ha un proprio codice cliente (per esempio a 13 cifre) e legge il
QR della tessera Tesserando. Lo stesso schema vale per un gestionale di prenotazioni, un e-commerce o un software
sportivo: cambia solo l'evento che fa scattare l'accredito. **Il QR contiene solo il numero di tessera
(`serialNumber`)**: 16 cifre numeriche casuali, senza struttura né codici incorporati. Il codice del software
esterno non è ricavabile dal QR: si associa una volta sola.

1. **Prima scansione**: il software legge il QR (`6321233419848724`) e collega i due codici, una volta:
   ```bash
   curl -s -X POST https://www.tesserando.it/api/external/v1/customers/resolve \
     -H "Authorization: Bearer tsk_live_xxx" -H "Content-Type: application/json" \
     -d '{ "externalCustomerId": "0400000000015", "linkCode": "6321233419848724" }'
   ```
   `200 { "status": "LINKED", "passId": "..." }`. Da qui in poi il codice del gestionale
   (`externalCustomerId`) basta, senza rileggere il QR.
2. **Alla scansione**: lo stato del cliente, con saldo, premi e vantaggi, in una sola chiamata:
   `GET /customers/0400000000015` (vedi [sotto](#get-customersexternalcustomerid)).
3. **A vendita chiusa**: accredito dei punti/bollini che il software ha calcolato, con sede e un riferimento
   univoco **per questa operazione**:
   ```bash
   curl -s -X POST https://www.tesserando.it/api/external/v1/transactions \
     -H "Authorization: Bearer tsk_live_xxx" -H "Content-Type: application/json" \
     -d '{ "externalCustomerId": "0400000000015", "type": "EARN", "amount": 12,
           "externalReference": "PV01-20260930-000123-EARN", "storeCode": "01", "operator": "Cassa 3" }'
   ```
4. **Se il cliente ritira un premio**: `POST /transactions` con `"type": "REDEEM"` e il `rewardId` del premio letto
   al passo 2 (carte a Punti/Bollini: il costo si legge dal premio, `amount` non serve; in alternativa l'`amount`
   del premio, con `rewardName` per indicare quale) oppure `milestoneId` (carte a Traguardi), con un
   riferimento **diverso** da quello dell'accredito (es. `PV01-20260930-000123-REDEEM`).
5. **Se è il suo compleanno e c'è una promo**: `birthdayPromo.available` è `true` in `GET`; la consegna si
   registra con [`POST .../birthday-promo/redeem`](#post-customersexternalcustomeridbirthday-promoredeem).
6. **Reso o errore**: [`POST /transactions/reverse`](#post-transactionsreverse) con il riferimento del movimento
   da annullare (es. `PV01-20260930-000123-EARN`).
7. **Se non sai com'è andata una chiamata** (timeout, errore di rete): [`GET /transactions?externalReference=…`](#get-transactions)
   dice se quel movimento è arrivato, senza rimandarlo.

## Endpoint

### `GET /ping`
Verifica credenziali, utile come health-check di configurazione.

```bash
curl -s https://www.tesserando.it/api/external/v1/ping \
  -H "Authorization: Bearer tsk_live_xxx"
```

`200`:
```json
{ "ok": true, "integration": "Cassa XYZ", "tenantId": "..." }
```

### `GET /cards`
Le carte (campagne) **attive** dell'attività: serve al software esterno per sapere tra quali scegliere —
per esempio quando un cliente ne ha più di una (vedi [`POST /customers/lookup`](#post-customerslookup))
o per mostrare al cassiere i programmi disponibili. Sola lettura: non contiene nessun dato dei clienti.
Non richiede flag opt-in: basta una API key valida.

```bash
curl -s https://www.tesserando.it/api/external/v1/cards \
  -H "Authorization: Bearer tsk_live_xxx"
```

`200`:
```json
{
  "cards": [
    {
      "id": "cm...",
      "name": "Tessera Fedeltà",
      "cardType": "POINTS",
      "isDefault": true,
      "campaign": { "phase": "OPEN", "endsAt": "2026-11-30T22:59:59.999Z", "redeemUntil": "2026-12-15T22:59:59.999Z" }
    },
    {
      "id": "cm...",
      "name": "Percorso Soci",
      "cardType": "PROGRESSION",
      "isDefault": false,
      "milestones": [ { "id": "m1", "name": "Caffè in omaggio", "targetValue": 5 } ]
    }
  ]
}
```
- `id`: l'identificativo della carta (lo stesso `templateId` che compare in `cards[]` di `lookup`).
- `cardType`: `POINTS`, `STAMPS` o `PROGRESSION` (vedi [Concetti](#concetti)).
- `isDefault`: `true` solo per il modello predefinito dell'integrazione (`defaultTemplateId`, quello su cui
  crea [`POST /customers`](#post-customers)).
- `campaign`: **additivo e opzionale**, assente (mai `null`) se la carta non ha una fine campagna attiva; stessa
  forma e stesso significato di `campaign` in [`GET /customers/{id}`](#get-customersexternalcustomerid)
  (`phase` = `OPEN` / `REDEEM_ONLY` / `EXPIRED`). Una carta con campagna `EXPIRED` o `REDEEM_ONLY` è comunque
  elencata, ma non accetta più iscrizioni né accumuli: la fase serve proprio a non proporla.
- `milestones`: **solo carte `PROGRESSION`**, i traguardi configurati (`id`, `name`, `targetValue`), gli stessi che
  `GET /customers/{id}` mostra con lo stato del singolo cliente. `[]` se la carta non ne ha di validi.
- Le carte **archiviate** non compaiono mai. L'elenco è sempre quello dell'attività della API key (mai scelto dal
  chiamante) e ordinato per nome.

Errori: solo la tassonomia standard (nessun codice dedicato); vale il limite generale di 120 richieste/minuto.

### `POST /customers/lookup` — opt-in `allowIdentityLookup` (default off)
Ritrova un cliente per identità (nome+cognome, opzionalmente email), pensato per abilitare un
**login OTP gestito dal software esterno** (l'OTP lo manda lui: qui serve solo per sapere a quale
indirizzo). **È l'unica rotta v1 che espone l'email del cliente** — nessun'altra, nemmeno
`GET /customers/{id}`, la restituisce mai. Per questo è **opt-in per integrazione**, spenta di
default, e con un rate limit dedicato (vedi sotto).

Body:
```json
{ "firstName": "Mario", "lastName": "Rossi", "email": "mario@example.com" }
```
- Senza `email`: ricerca per nome+cognome (match **esatto**, case-insensitive — non "contiene" —
  scoping al `defaultTemplateId` se impostato).
- Con `email`: aggiunge un filtro esatto sull'email, usato per **confermare/disambiguare** un
  match multiplo ottenuto in una chiamata precedente senza `email`.

**`found` conta persone, non carte.** Due carte con lo stesso nome+cognome **e** la stessa email (senza
badare alle maiuscole) sono la stessa persona: `found: 1`. Due omonimi con email diverse, o carte senza email
(che non si possono attribuire a nessuno), restano persone distinte, come prima.

Risposte `200` (minimizzazione PII — mai un'email su match multiplo):
```json
{
  "found": 1,
  "email": "mario@example.com",
  "customerRef": "<serialNumber>",
  "cards": [
    { "customerRef": "<serialNumber>", "templateId": "cm...", "name": "Tessera Fedeltà", "cardType": "POINTS", "isDefault": true,
      "campaign": { "phase": "OPEN", "endsAt": "2026-11-30T22:59:59.999Z", "redeemUntil": "2026-12-15T22:59:59.999Z" } },
    { "customerRef": "<serialNumber>", "templateId": "cm...", "name": "Percorso Soci", "cardType": "PROGRESSION", "isDefault": false }
  ]
}
{ "found": 0 }
{ "found": 2 }   // più persone: nessuna email e nessuna carta finché non arriva una chiamata di conferma con `email`
```
Una persona che ha **solo carte archiviate** non viene trovata (`found: 0`): una carta archiviata è come eliminata,
quindi non compare mai in `cards[]`, non diventa mai il `customerRef` e non fa uscire l'email. Una persona con una carta
archiviata **e** una attiva viene trovata, e della carta archiviata non c'è traccia nella risposta.

Se il match unico esiste ma il cliente non ha un'email registrata su Tesserando, `email` è
`null` (il `customerRef` resta comunque utile per il fallback via seriale, vedi
[`POST /customers/resolve`](#post-customersresolve)).

`customerRef` resta com'era (compatibilità): con più carte è sempre la stessa, scelta così — la carta del
modello predefinito, altrimenti la più vecchia. È **sempre una carta attiva** (mai una archiviata). Per
**scegliere** una carta si legge `cards[]`.

**`cards[]` — campo additivo e opzionale, solo con `found: 1`** (mai su `found: 0` o `found ≥ 2`). Elenca tutte le
carte **attive** (non archiviate) di quella persona nell'attività: la stessa persona nel senso sopra, quindi una
carta di un'altra persona con la stessa email ma nome diverso non compare; se la persona non ha email, compaiono
solo le carte trovate dalla ricerca. Per ogni carta: `customerRef` (il suo `serialNumber`), `templateId`,
`name`, `cardType`, `isDefault` e, se la carta ha una fine campagna attiva, `campaign` (stessa forma di
[`GET /cards`](#get-cards)). **Nessun saldo né altro dato del cliente.** Ordine: prima il modello predefinito, poi
per nome. Con `found: 1` contiene sempre almeno la carta del `customerRef`: duplicati e carte di campagne o promo diverse
della stessa persona compaiono tutte, una per carta.

Relazione con `defaultTemplateId`: il filtro sul modello predefinito decide **chi viene trovato** (come prima:
con un predefinito si cerca solo tra le carte di quel modello), mentre `cards[]` elenca **tutte** le carte
attive di quella persona nell'attività, anche di altri modelli: un'integrazione con predefinito vede quindi le
altre carte della stessa persona e il software decide se usarle.

**Operare su una carta scelta.** Il collegamento (`resolve`) lega un `externalCustomerId` a **una** carta. Per
usare una carta di `cards[]`, si chiama [`POST /customers/resolve`](#post-customersresolve) con `linkCode` =
il `customerRef` di quella carta; se il cliente ha più carte da gestire, serve un `externalCustomerId`
**distinto per ciascuna** (es. `"<codice-gestionale>:<templateId>"`), perché un `externalCustomerId` già collegato
resta sulla sua carta.

Errori: `403 IDENTITY_LOOKUP_DISABLED` se il flag non è attivo per l'integrazione, `429
RATE_LIMITED` (rate limit dedicato, vedi [Note operative](#note-operative)); per il resto la
tassonomia standard.

### `POST /customers` — opt-in `allowCustomerCreation` (default off)
Crea una carta con anagrafica **già raccolta** e consenso **già acquisito** dal software esterno
(tipicamente dopo la firma di un modulo/waiver), senza il giro "email di invito →
auto-registrazione" di [`resolve`](#post-customersresolve). Richiede che l'integrazione abbia un
`defaultTemplateId` configurato (la carta viene creata su quel modello).

Body:
```json
{
  "externalCustomerId": "abc-123",
  "firstName": "Mario", "lastName": "Rossi",
  "email": "mario@example.com", "phone": "+39 333 1234567", "birthDate": "1990-05-12",
  "consent": {
    "reference": "3a7f...b21c",           // waiverTextHash: SHA-256 del modulo firmato
    "collectedAt": "2026-07-15T10:00:00Z",
    "source": "BaseCamp137"
  },
  "marketingConsent": false,
  "storeCode": "01"
}
```
- Obbligatori: `externalCustomerId`, `firstName`, `lastName`, `consent` (con `reference`,
  `collectedAt`, `source`). `email`, `phone`, `birthDate`, `marketingConsent`, `storeCode` sono opzionali.
- **`storeCode`** (opzionale): la carta risulta **registrata in quella sede** (come per l'iscrizione da QR o
  link per-negozio). Sconosciuto → `422 STORE_NOT_FOUND`, prima di ogni scrittura: nessuna carta viene creata.
  Su una chiamata idempotente (cliente già collegato) la sede non viene modificata.
- `consent.reference` è la **prova immutabile** del consenso a creare/tenere la carta (es. hash
  SHA-256 del modulo firmato): Tesserando non riceve né conserva il PDF, solo il suo hash.
  Distinto da `marketingConsent` (default `false`), che abilita push/promo e non ha nulla a che
  fare con la creazione della carta.
- **`consent.collectedAt` deve essere una data plausibile**: stringa parsabile come data, **non
  anteriore al 2020-01-01** e non nel futuro oltre **5 minuti** di tolleranza (orologio sfasato).
  Fuori da quella finestra la chiamata è `422 INVALID_PAYLOAD`: una data segnaposto tipo
  `1970-01-01` è quasi sempre un bug del software esterno, non un consenso reale, e qui viene
  respinta invece di finire a registro come prova.
- **Formato e limiti dei campi** (altrimenti `422 INVALID_PAYLOAD`): `externalCustomerId` al massimo
  **128** caratteri; `firstName` e `lastName` al massimo 100; `email` di forma plausibile (al massimo 254
  caratteri); `phone` al massimo 30; `consent.reference` al massimo 200 e `consent.source` al massimo 100.
  **`birthDate` solo nel formato `YYYY-MM-DD`**, una data reale compresa fra il 1900-01-01 e oggi (una
  data nel futuro, `12/05/1990` o con fuso orario come `1990-05-10T00:00:00+02:00` sono respinte). Viene
  salvata come giorno di calendario: gli auguri partono il giorno indicato.
- **Idempotente su `externalCustomerId`**: una seconda chiamata con lo stesso valore non crea una
  nuova carta, ritorna quella già collegata (stesso `passId`/`serialNumber`). Se invece quel
  cliente esterno ha un collegamento ambiguo aperto (`PENDING_REVIEW`, lasciato da un `resolve`
  precedente), la chiamata fallisce con `409 LINK_PENDING_REVIEW`: creare una carta nuova
  scavalcherebbe in silenzio i candidati in attesa e le eventuali transazioni accodate. Va prima
  risolto il collegamento dal pannello.
- **Duplicati**: un cliente può avere **più carte** nello stesso merchant. La chiamata è un duplicato
  — `409 ALREADY_EXISTS`, nessuna creazione né collegamento automatico — **solo se TUTTI i campi
  coincidono** con quelli di una carta esistente **dello stesso modello di tessera**
  (`defaultTemplateId` dell'integrazione): `firstName`, `lastName`, `email`, `phone`, `birthDate`.
  Il confronto è normalizzato: spazi ai bordi ignorati, maiuscole/minuscole ignorate (nome, cognome,
  email), telefono confrontato senza spazi/separatori e con o senza prefisso `+39`, `birthDate`
  confrontata sul giorno; un campo assente coincide solo con un campo assente. Basta **un campo
  diverso** (es. un altro telefono) o un **altro modello di tessera** per ammettere una nuova carta.
  La stessa regola vale per la registrazione pubblica da QR/link. I casi quasi identici compaiono
  nella sezione duplicati del pannello (**Impostazioni → Pulizia Duplicati**) per la risoluzione
  manuale del merchant. Due chiamate concorrenti con gli stessi dati si serializzano lato server: la
  seconda riceve `409 ALREADY_EXISTS`, mai due carte gemelle.
- **Rate limit dedicato**: 30 richieste/minuto per integrazione+IP, più stretto del limite
  generale, perché il `409 ALREADY_EXISTS` rivela l'esistenza di un cliente con quegli stessi dati nell'archivio
  del merchant (stessa classe di rischio di `lookup`).

`200`:
```json
{ "passId": "...", "serialNumber": "...", "status": "LINKED" }
```

Errori: `403 CUSTOMER_CREATION_DISABLED` se il flag non è attivo; `422 TEMPLATE_NOT_CONFIGURED`
se l'integrazione non ha un `defaultTemplateId` **o se la carta predefinita non esiste più**; `409 ALREADY_EXISTS` (duplicati, sopra); `409
LINK_PENDING_REVIEW` se quel cliente esterno ha un collegamento ambiguo da risolvere; `409
PLAN_LIMIT_REACHED` se il tenant ha raggiunto il limite clienti del piano (raro: le integrazioni
richiedono piano ULTRA, di norma senza limite, salvo un cap manuale impostato dall'admin); `409
CAMPAIGN_ENDED` se la carta predefinita ha una fine campagna ("La carta vale fino a…") già
passata: non si iscrivono più clienti su quella carta (un cliente già collegato riceve comunque la
sua carta, come sempre); `429 RATE_LIMITED` (limite dedicato, sopra); per il resto la tassonomia
standard.

### `POST /customers/register-link` — opt-in `allowCustomerCreation` (default off)
Variante "con conferma dell'utente" di [`POST /customers`](#post-customers): invece di creare la
carta server-to-server, restituisce il **link alla pagina pubblica di registrazione Tesserando**
su cui redirigere il browser del cliente finale — così il cliente conferma di persona e vede
subito i pulsanti "Aggiungi a Apple/Google Wallet", invece di ritrovarsi una tessera di cui non
sa nulla. Stesso flag opt-in di `POST /customers`, nessun consenso da trasmettere (lo raccoglie
la pagina di registrazione Tesserando).

Body:
```json
{
  "externalCustomerId": "abc-123",
  "firstName": "Mario", "lastName": "Rossi",
  "email": "mario@example.com"
}
```
- Obbligatori: `externalCustomerId`, `firstName`, `lastName`, `email` (qui l'email **non** è
  opzionale: viene salvata sull'invito). Opzionale: **`storeCode`** (sede, vedi Concetti): il link porta alla
  registrazione «per negozio» (aggiunge `store=<id>` all'indirizzo, lo stesso parametro dei QR e dei link
  per-negozio) e la carta risulta registrata in quella sede; sconosciuto → `422 STORE_NOT_FOUND`. Ogni altro
  campo del body è ignorato.
- **Idempotente**: se quel cliente esterno è già collegato a una carta viva, risponde
  `{"status": "LINKED", "passId": "..."}` e non serve alcun redirect. Se il link esiste ma la
  carta è stata cancellata (orfano), si riparte con un nuovo giro di registrazione.
- L'invito generato dura **7 giorni**; chiamate ripetute entro quella finestra **riusano lo
  stesso link** (nessun token nuovo a ogni chiamata) finché non viene consumato o scade. Il token
  è **monouso** e vincolato al tenant.
- A differenza di `resolve`, **non manda nessuna email**: il link torna nella risposta ed è il
  software esterno a portarci il cliente. Non consuma quindi la quota giornaliera di inviti.
- È il **token** a determinare il collegamento, non l'email: qualunque indirizzo il cliente
  inserisca nel form di registrazione, la carta creata viene collegata a quell'`externalCustomerId`.
- **La carta è quella predefinita dell'integrazione.** Se l'integrazione ha un `defaultTemplateId`, il link
  contiene `&campaign=<defaultTemplateId>` e la pagina offre **solo quella carta**; il server la impone
  anche se il modulo viene manomesso (il token dell'invito la determina). Senza `defaultTemplateId` la
  pagina offre tutte le carte ancora aperte. Un collegamento già esistente per quel cliente esterno
  (a un'altra carta viva, o in attesa di risoluzione) **non viene mai sovrascritto** dalla registrazione:
  il token si consuma e la carta nuova resta senza collegamento.
- **Rate limit dedicato**: 30 richieste/minuto per integrazione+IP (ogni chiamata crea o rigenera una riga di
  invito).
- `externalCustomerId` al massimo 128 caratteri, `firstName`/`lastName` al massimo 100, `email` di forma
  plausibile: altrimenti `422 INVALID_PAYLOAD`.

`200`:
```json
{ "status": "LINKED", "passId": "..." }
{ "status": "REGISTER_REQUIRED", "registerUrl": "https://www.tesserando.it/register/<tenantId>?linkToken=...&campaign=<defaultTemplateId>" }
```

> ⚠️ **La `registerUrl` restituita contiene solo il token: non porta con sé l'anagrafica.** La
> pagina di registrazione **accetta** i parametri di precompilazione `firstName`, `lastName`,
> `email`, `phone`, `birthDate` in query string, ma **è il software esterno a doverli aggiungere**
> alla URL ricevuta (URL-encoded) prima di redirigere il cliente — i valori mandati nel body di
> questa chiamata non finiscono nel link. Senza quel passaggio il cliente si trova un form vuoto
> e ridigita tutto:
> ```
> <registerUrl>&firstName=Mario&lastName=Rossi&email=mario%40example.com&phone=%2B393331234567
> ```
> Esiste anche `returnUrl`, per rimandare il cliente da qualche parte a registrazione finita, ma
> **accetta solo path relativi dello stesso dominio** (deve iniziare con una singola `/`): una URL
> assoluta verso il software esterno viene scartata e sostituita con `/`. Non è quindi utilizzabile
> per tornare al gestionale.

Errori: `403 CUSTOMER_CREATION_DISABLED` se il flag non è attivo; `409 LINK_PENDING_REVIEW` se
quel cliente esterno ha un collegamento ambiguo da risolvere dal pannello; `409 CAMPAIGN_ENDED` se
la carta predefinita ha una fine campagna già passata (il link porterebbe a una pagina che non può
iscrivere); `422 INVALID_PAYLOAD` se manca uno dei campi obbligatori; `422 TEMPLATE_NOT_CONFIGURED` se la
carta predefinita non esiste più; `429 RATE_LIMITED` (limite dedicato, sopra); per il resto la tassonomia
standard.

### `POST /customers/resolve`
Collega/risolve un cliente. Da chiamare prima di poter leggere o transare su quel cliente —
le altre due rotte rifiutano (`409 NOT_LINKED`) se il link non è ancora `LINKED`.

Body:
```json
{
  "externalCustomerId": "abc-123",   // obbligatorio
  "linkCode": "123456789012",         // opzionale: serialNumber della carta Tesserando
  "phone": "+39 333 1234567",         // opzionale: match automatico
  "email": "mario@example.com",       // opzionale: match automatico / invito
  "firstName": "Mario"                // opzionale, usato solo per l'eventuale email di invito
}
```

Cascata di risoluzione, fermandosi al primo esito:
1. **Link già esistente** per quell'`externalCustomerId` → ritorna il suo stato attuale
   (`LINKED` o `PENDING_REVIEW`). Se il collegamento è `LINKED` ma senza carta assegnata
   (cliente cancellato dopo il collegamento) non è un vicolo cieco: la cascata prosegue
   con i passi 2-5 (la carta nuova del cliente, se c'è, si ritrova e il collegamento si
   riscrive) e solo se non trova nulla risponde `ORPHANED`. Un `LINKED` con carta assegnata
   ritorna subito `LINKED`, **tranne** se quella carta appartiene a una campagna archiviata: allora
   è trattato come un orfano, la cascata riparte (le carte archiviate sono ignorate), ritrova la carta
   **attiva** dello stesso cliente e riscrive il collegamento, oppure risponde `ORPHANED` — mai un
   `LINKED` che poi darebbe `409 CARD_ARCHIVED` per sempre. È un **cambio di comportamento** rispetto a
   prima (un collegamento a una carta archiviata restava `LINKED`), deciso dal titolare il 4 ottobre 2026
   insieme alla regola sulle carte archiviate. Finché il software non chiama `resolve`,
   `GET /customers/{id}` e `POST /transactions` leggono il collegamento così com'è salvato:
   `cardArchived: true` e `409 CARD_ARCHIVED`. Una carta cancellata davvero (cliente cancellato su
   richiesta) risulta `ORPHANED` in lettura. Una carta **senza storico e senza tessere installate**
   si cancella davvero; con storico o tessere nei Wallet viene archiviata.
2. **`linkCode`** (serialNumber) valido per il tenant **e di una carta non archiviata** → collegamento esplicito, `LINKED`. Uso
   previsto: **fallback quando l'OTP non arriva** — se il flusso di login via
   [`POST /customers/lookup`](#post-customerslookup) è configurato ma l'email non arriva o
   l'utente non la trova, il software esterno può chiedere al cliente il numero di tessera
   stampato/mostrato sulla carta e passarlo come `linkCode`.
3. **Match esatto univoco** su telefono e/o email (scoping al `defaultTemplateId` se impostato; le carte
   archiviate non partecipano al confronto) → `LINKED`.
4. **Più candidati** corrispondenti → `PENDING_REVIEW`, in attesa che il merchant scelga dal
   pannello.
5. **Nessun match, ma email presente** e invito automatico attivo per l'integrazione → invia
   email di invito alla registrazione (throttle: un solo invito ogni 7 giorni per la stessa
   coppia integrazione/`externalCustomerId`) → `INVITE_SENT`. **Non parte se la campagna della carta
   predefinita è chiusa** (o la carta non esiste più): l'invito porterebbe a una pagina "Iscrizioni chiuse" e
   consumerebbe la quota; in quel caso → `NOT_FOUND`. Il link dell'invito porta alla carta predefinita
   (`campaign=<defaultTemplateId>`, vedi [`register-link`](#post-customersregister-link)).
6. Altrimenti → `NOT_FOUND` (o `ORPHANED`, se il collegamento esistente è orfano).

> ⚠️ **`NOT_FOUND` non significa solo "nessun match".** L'invio degli inviti ha una **quota
> anti-spam di 20 email al giorno per integrazione** (riusare un invito già spedito non la
> consuma). Esaurita la quota — o se l'invio fallisce lato mailer — il passo 5 non produce
> `INVITE_SENT` ma `NOT_FOUND`, **senza alcun campo che distingua i due casi**: il contratto v1 è
> congelato e non c'è un codice dedicato. Un'integrazione che importa molti clienti nuovi in
> blocco vedrà quindi i primi 20 come `INVITE_SENT` e i successivi come `NOT_FOUND`. L'invito non
> spedito non resta "bruciato": se l'invio fallisce (es. mailer non raggiungibile) la quota giornaliera
> **non viene consumata** e una `resolve` successiva riprova.

**Rate limit dedicato**: 30 richieste/minuto per integrazione+IP. Serve proprio a proteggere gli
inviti: variando l'`externalCustomerId` a ogni chiamata si aggirerebbe il throttle per-cliente.

Il telefono viene confrontato anche in varianti equivalenti (con/senza prefisso `+39`, con/senza
separatori); l'email è case-insensitive.

`200`:
```json
{ "status": "LINKED" | "PENDING_REVIEW" | "INVITE_SENT" | "ORPHANED" | "NOT_FOUND", "passId": "..." | null }
```

> `PENDING_REVIEW` **non** è un errore: il merchant sceglierà la carta giusta dal pannello
> (sezione "Collegamenti da risolvere"); richiama `resolve` più tardi per ottenere lo stato
> aggiornato. Se il merchant scarta tutti i candidati, il link viene **eliminato** (non solo
> marcato) e una `resolve` successiva ripartirà da zero la cascata — anche le eventuali
> transazioni accodate per quel link vengono scartate, vedi la nota dedicata su
> [`POST /transactions`](#post-transactions). `INVITE_SENT` non garantisce che il cliente verrà
> creato (potrebbe non completare la registrazione, o il tenant essere al limite del piano).

### `GET /customers/{externalCustomerId}`
Stato del cliente: saldo, premi disponibili, traguardi. Richiede un link `LINKED`.

`200` (LINKED):
```json
{
  "status": "LINKED",
  "customer": { "externalCustomerId", "passId", "serialNumber", "firstName", "lastName", "points", "pointsLabel" },
  "isProgression": false,
  "rewards":    [ { "id", "name", "cost", "source": "SPECIFIC|GLOBAL", "available": true, "expiresAt": "2026-11-12T22:59:59.999Z" } ],
  "milestones": [ { "id", "name", "targetValue", "redeemed": false, "available": true, "expiresAt": "2026-11-12T22:59:59.999Z" } ],
  "campaign": { "phase": "OPEN", "endsAt": "2026-11-30T22:59:59.999Z", "redeemUntil": "2026-12-15T22:59:59.999Z" }
}
```
`rewards[].id` (campo **additivo**) è l'[id stabile del premio](#concetti): lo stesso anche se il merchant lo rinomina o ne
cambia il costo, da passare come `rewardId` al `REDEEM`. Un premio salvato prima dell'introduzione dell'id esce senza
`id` finché il merchant non risalva la carta o il catalogo premi: per quel premio si usa `amount` (e `rewardName`).

Tutti i campi seguenti sono **additivi e opzionali**, **assenti (mai `null`)** quando non applicabili:

```json
{
  "coupons": [
    { "kind": "WINBACK",  "id": "cl...", "points": 20, "expiresAt": "2026-10-12T21:59:59.999Z" },
    { "kind": "BIRTHDAY", "id": "cl...", "points": 50, "expiresAt": "2026-10-07T21:59:59.999Z" }
  ],
  "birthdayPromo": { "available": true, "redeemedThisYear": false, "reward": "Una fetta di torta" },
  "happyHour": { "multiplier": 2, "endsAt": "2026-09-30T18:00:00.000Z" },
  "pointsExpiresAt": "2027-03-01T12:00:00.000Z"
}
```
- `coupons`: coupon **riscattabili adesso**, cioè quelli che il prossimo `EARN` incasserebbe davvero: non
  riscattati, non scaduti, al massimo uno per tipo (il più recente) e solo se la carta accetta ancora accrediti
  (con la campagna finita non compaiono, perché l'`EARN` verrebbe rifiutato). `kind` è `WINBACK` («Ci manchi»)
  o `BIRTHDAY` (regalo di compleanno in modalità coupon). `points` sono i punti bonus che aggiungerà al
  prossimo `EARN`. **Non c'è un riscatto separato**: lo incassa da solo il prossimo accredito, esattamente come
  dal pannello e dall'app. Presente solo se ce n'è almeno uno.
- `birthdayPromo`: il regalo di compleanno **a testo libero** della carta (la «Promo compleanno» del merchant).
  Presente solo se la promo è accesa sulla carta e il cliente ha la data di nascita **e** oggi è il suo
  compleanno (giorno di Roma; chi è nato il 29 febbraio lo festeggia il 1° marzo negli anni non bisestili) **o**
  l'ha già riscattata quest'anno. `available` è `true` se è riscattabile adesso; `redeemedThisYear` se è già
  stata consegnata quest'anno; `reward` è il testo scritto dal merchant, `null` se non lo ha compilato.
- `happyHour`: il moltiplicatore in corso (sempre maggiore di 1) e la fine della finestra, come mostra l'app
  alla scansione. Lo applica da solo l'`EARN` (`appliedMultiplier`). Come i coupon, non compare se la carta
  non accetta più accrediti.
- `pointsExpiresAt`: quando scadono i punti del cliente, se la carta usa la scadenza punti (mai sulle carte a
  Traguardi).

Il saldo (`customer.points`) può cambiare anche **senza movimenti del software esterno**: punti aggiornati dal
pannello del merchant (per esempio «Aggiorna punti da CSV»), addebiti di scadenza, annulli fatti dal pannello.
Non è una copia da tenere allineata dal lato del software: si rilegge sempre da qui.

Con la campagna **chiusa del tutto** (`campaign.phase: "EXPIRED"`) `rewards[].available` e
`milestones[].available` sono `false` anche se il saldo basta, perché un `REDEEM` risponderebbe
`409 CAMPAIGN_EXPIRED`; `birthdayPromo` non dipende dalla campagna e resta com'è. Un `externalCustomerId` con
percent-encoding malformato (es. `%E0%A4%A`) o oltre i 128 caratteri risponde `422 INVALID_PAYLOAD`.

`expiresAt` (su `rewards` e su `milestones`) e `expired` (solo su `milestones`) sono **campi additivi e
opzionali** della funzione "Premi da riscattare entro N giorni" (piani PRO e superiori; vedi
[Scadenza dei premi](#scadenza-dei-premi)): `expiresAt` è l'ultimo istante in cui quel premio (o
traguardo) si ritira, presente solo se il cliente lo ha raggiunto e la carta usa la funzione; un
traguardo con `"expired": true` è scaduto: ha `available: false` e un `REDEEM` risponde
`409 MILESTONE_EXPIRED`.

`campaign` è un campo **additivo e opzionale**: c'è solo se la carta del cliente ha una fine
campagna attiva ("La carta vale fino a…", funzione dei piani PRO e superiori), altrimenti manca.
`phase` vale `OPEN` (accumulo e riscatto ammessi), `REDEEM_ONLY` (la campagna è finita: non si
accumula più ma si possono ancora ritirare i premi, fino a `redeemUntil`) o `EXPIRED` (anche il
ritiro è chiuso). `endsAt` è l'ultimo istante di validità, `redeemUntil` l'ultimo istante in cui si
ritira un premio (uguale a `endsAt` se il merchant non ha dato giorni extra).

**Carta archiviata** (`cardArchived: true`, campo additivo): se la carta del cliente appartiene a
una campagna archiviata ("come eliminata") la chiamata risponde comunque `200`, con `customer.points: 0`
(il saldo residuo è decaduto e non compare più tra i totali) e `cardArchived: true`. Premi (`rewards`) e
traguardi (`milestones`) hanno `available: false`; i vantaggi non hanno una forma "non disponibile": `coupons`
è `[]` e `birthdayPromo`, `happyHour` e `pointsExpiresAt` sono `null`. Il link resta `LINKED`: la carta
esiste, è solo come eliminata.

> ⚠️ **Cambio di comportamento, non un'aggiunta neutra.** Il campo `cardArchived` è additivo, ma le regole
> sulle carte archiviate **cambiano l'esito di chiamate che prima riuscivano**: su una carta archiviata
> `POST /transactions` e `POST /customers` rispondono ora `409 CARD_ARCHIVED` (prima accreditavano o
> creavano la carta), e `GET /customers/{id}` mostra `points: 0` anche se prima mostrava il saldo. È una
> decisione del titolare (#23, ondata del 2 ottobre 2026; completata il 4 ottobre 2026) e va **comunicata agli
> integratori**: un software che non conosce `CARD_ARCHIVED` lo tratta come un errore `409` qualunque. Un
> retry con un `externalReference` già applicato prima dell'archiviazione risponde comunque
> `alreadyApplied: true` (l'idempotenza viene prima del controllo sulla carta archiviata), con `newPoints: 0`
> e il campo aggiuntivo `cardArchived: true`, come `GET /customers/{id}` e l'annullo.

`200 { "status": "PENDING_REVIEW" }` o `200 { "status": "ORPHANED" }` se il link esiste ma non
è (ancora, o più) utilizzabile.
`409 { "status": "NOT_LINKED", "error": "Customer is not linked", "code": "NOT_LINKED" }` se non
esiste alcun link per quell'`externalCustomerId` — va prima chiamato `resolve`.

### `POST /transactions`
Accredita/scala punti o riscatta un premio/traguardo. Richiede un link `LINKED` (altrimenti
`409 NOT_LINKED`) — eccetto il caso `PENDING_REVIEW`, che viene accodato automaticamente, vedi
la nota dedicata più sotto.

Body:
```json
{
  "externalCustomerId": "abc-123",  // obbligatorio
  "type": "EARN" | "REDEEM",         // obbligatorio
  "amount": 10,                       // NUMERO intero 1..1.000.000, punti/bollini; obbligatorio per EARN e REDEEM non-progression (facoltativo con rewardId)
  "milestoneId": "m1",               // obbligatorio per REDEEM su carte PROGRESSION (ignorato sulle altre)
  "externalReference": "pay_001",    // opzionale ma CONSIGLIATO: idempotenza (stringa non vuota, max 128 caratteri)
  "message": "Grazie per l'acquisto!", // opzionale: push personalizzata (solo se consenso marketing, max 200 caratteri)
  "storeCode": "01",                 // opzionale: sede (Store.externalCode), vedi Concetti
  "rewardId": "…",                   // opzionale, solo REDEEM punti/bollini: id del premio (rewards[].id di GET /customers/{id})
  "rewardName": "Buono 5 euro",      // opzionale, solo REDEEM punti/bollini: quale premio si ritira
  "operator": "Cassa 3"              // opzionale: cassa/cassiere (max 60 caratteri)
}
```

**Validazione dei campi** (tutto ciò che non rispetta queste regole è `422 INVALID_PAYLOAD`, prima di qualunque
modifica al saldo):
- **`amount`**: un **numero JSON** intero, da 1 a **1.000.000** (stesso tetto del pannello e dell'app). Non
  sono accettati stringhe (`"10"`), booleani, array, decimali, zero o negativi. Il tetto vale anche per le
  operazioni accodate (cliente `PENDING_REVIEW`): un importo che il server rifiuterebbe viene respinto subito,
  non accodato per essere scartato dopo.
- **`type`**: esattamente `"EARN"` o `"REDEEM"` (maiuscolo).
- **`externalCustomerId`**: stringa non vuota, al massimo 128 caratteri.
- **`externalReference`**: se presente, una stringa **non vuota** (dopo aver tolto gli spazi ai bordi) di al
  massimo 128 caratteri. Non può iniziare con **`birthday-promo:`**: quello spazio è riservato alla
  [promo compleanno](#post-customersexternalcustomeridbirthday-promoredeem).
- **`message`**: oltre i 200 caratteri viene **troncato** (non rifiutato).
- **`milestoneId`** vale solo per il `REDEEM` di una carta PROGRESSION: su una carta Punti/Bollini, o su un
  `EARN`, viene **ignorato** (non viene scritto sul movimento né cambia la scadenza del premio ritirato).

Campi **additivi e opzionali** (contratto v1 congelato):
- **`storeCode`**: attribuisce il movimento alla sede, con le stesse conseguenze delle operazioni da pannello e
  app (viste per negozio). Assente = nessuna sede, come prima. Sconosciuto → `422 STORE_NOT_FOUND`;
  malformato (non stringa, vuoto, oltre 64 caratteri) → `422 INVALID_PAYLOAD`. Vale anche per le operazioni
  accodate (cliente `PENDING_REVIEW`): la sede è verificata subito e riprodotta alla risoluzione; se nel
  frattempo la sede è stata eliminata, il movimento viene applicato **senza sede** (e registrato nei log di
  Tesserando) invece di restare attribuito a una sede che non esiste più.
- **`rewardId`**: sul `REDEEM` di una carta a Punti o Bollini, l'[id stabile del premio](#concetti) (`rewards[].id` di
  [`GET /customers/{id}`](#get-customersexternalcustomerid)). **Nome e costo si leggono dal premio**, non dal chiamante:
  `amount` diventa facoltativo e `rewardName` viene ignorato (conta il nome del premio). Se arrivano sia `rewardId` sia
  `amount`, `amount` deve essere **uguale al costo** del premio. Id non valido (non una stringa di 1-64 caratteri fra
  lettere, cifre, `-` e `_`), sconosciuto per la carta di quel cliente (premi della carta e catalogo globale) o
  `amount` diverso dal costo → `422 INVALID_PAYLOAD` e nessun movimento. **Un retry di un movimento già applicato
  non si rifiuta mai**: se l'`externalReference` è già noto la risposta è `alreadyApplied: true` anche se nel frattempo
  il merchant ha tolto il premio o ne ha cambiato il costo (con un riferimento nuovo, invece, vale il premio com'è
  adesso). Ignorato sull'`EARN` e sulle carte a Traguardi (lì vale `milestoneId`). Con un cliente `PENDING_REVIEW` il
  premio si cerca fra quelli dell'attività e in coda finiscono costo e nome.
- **`rewardName`**: sul `REDEEM` di una carta a Punti o Bollini indica **quale premio** si sta ritirando. È solo
  un riferimento (l'importo scalato resta `amount`): serve alla funzione «Premi da riscattare entro N giorni»
  per chiudere la scadenza di *quel* premio; senza, si abbina per importo = costo, e con due premi dello stesso
  costo può chiudere quella sbagliata. Come dashboard e app (max 200 caratteri, il resto è troncato).
- **`operator`**: cassa o cassiere. Compare accodato all'etichetta dell'integrazione nel nome operatore del
  movimento (es. «Gestionale Negozio · Cassa 3») in dashboard ed esportazioni. Solo un'annotazione: se non è una
  stringa valida viene ignorato, mai un errore.

**Carte a punti normali** (tipo `POINTS` o `STAMPS` sul modello di tessera): `EARN` accredita
`amount`; `REDEEM` lo scala, fallendo con `409 INSUFFICIENT_BALANCE` se il saldo è
insufficiente.
**Carte PROGRESSION**: `EARN` accredita `amount` allo stesso modo (il saldo è un contatore
cumulativo che **non scende mai, nemmeno riscattando un traguardo** (l'unica eccezione è
l'[annullo](#post-transactionsreverse) di un accredito fatto per errore) — es. partite giocate verso
più traguardi configurati sulla stessa carta, tipicamente 2-3, ognuno con la propria soglia e il
proprio premio, es. 3/6/10). `REDEEM` richiede `milestoneId` e **non scala il contatore**: si
limita a marcare quel traguardo come riscattato. Il conteggio continua ad accumulare per i
traguardi successivi, e più traguardi della stessa carta possono risultare riscattabili
**contemporaneamente** (se il cliente ha superato più soglie senza che nessuna sia ancora stata
riscattata) — vanno riscattati con chiamate `REDEEM` separate, una per `milestoneId`, in
qualsiasi ordine. I traguardi configurati per una carta (id, nome, soglia, `redeemed`,
`available`) si leggono da `milestones` su [`GET /customers/{id}`](#get-customersexternalcustomerid).

Sul `REDEEM` di una carta PROGRESSION, il server valida in ordine:
1. `milestoneId` presente → altrimenti `422 INVALID_PAYLOAD`.
2. `milestoneId` corrisponde a un traguardo configurato per quella carta → altrimenti
   `422 INVALID_PAYLOAD` (id inesistente, es. typo o traguardo rimosso dal merchant dopo la
   configurazione).
3. Quel traguardo non è già stato riscattato → altrimenti `409 ALREADY_REDEEMED`.
4. `currentPoints` ha raggiunto la `targetValue` del traguardo → altrimenti
   `409 MILESTONE_NOT_REACHED`, con `currentPoints` e `targetValue` nella risposta (vedi
   [Tassonomia errori](#tassonomia-errori)) così il software esterno sa subito quanto manca,
   senza bisogno di una `GET /customers/{id}` aggiuntiva solo per scoprirlo.
5. Il traguardo non è scaduto ("Premi da riscattare entro N giorni", piani PRO+) → altrimenti
   `409 MILESTONE_EXPIRED` (vedi [Scadenza dei premi](#scadenza-dei-premi)).

**Campagna a tempo** (la carta ha una fine, "La carta vale fino a…", piani PRO e superiori). Il
merchant può dare a una carta un ultimo giorno di validità e, in aggiunta, dei giorni extra in cui i
premi si ritirano ancora. Su `POST /transactions` questo si traduce in due errori **nuovi**
(l'aggiunta di codici è compatibile con il contratto v1):
- `409 CAMPAIGN_ENDED` su `EARN` dopo l'ultimo giorno di validità: l'evento non viene registrato.
  Se l'evento è finito in coda (cliente con collegamento `PENDING_REVIEW`) la data che conta è
  quella in cui è stato ricevuto, non quella in cui la coda viene svuotata.
- `409 CAMPAIGN_EXPIRED` su `REDEEM` dopo la fine dei giorni extra (`redeemUntil` in
  [`GET /customers/{id}`](#get-customersexternalcustomerid)); durante i giorni extra il `REDEEM`
  funziona normalmente.
Il campo `error` contiene lo stesso nome del codice. Lo stato (`campaign.phase`) si legge in anticipo
da `GET /customers/{id}`. Se il tenant scende a un piano senza questa funzione la data salvata non
viene più applicata: accumulo e riscatto tornano liberi.

#### Scadenza dei premi
Il merchant può dare a una carta (piani PRO e superiori) una regola "**Premi da riscattare entro N
giorni**": ogni premio o traguardo che il cliente raggiunge ha una scadenza propria, di N giorni da
quando lo ha raggiunto (al massimo fino alla fine della campagna, se la carta ne ha una). Cosa cambia
per un software esterno:
- **Punti e Bollini**: se un premio raggiunto non viene ritirato entro la scadenza, Tesserando scala
  dal saldo il costo di quel premio (mai sotto zero) e registra un movimento a nome del sistema. Il
  saldo che leggi con `GET /customers/{id}` è già quello aggiornato: non serve fare nulla, ma va
  messo in conto che può calare senza una tua chiamata. Sul `REDEEM` di un premio il server abbina
  la scadenza per importo (= costo del premio, la più vicina a scadere per prima).
  Dopo l'addebito **non parte una nuova scadenza per lo stesso premio finché il cliente non fa un
  nuovo movimento** (un `EARN` o un `REDEEM` fatti da un operatore, dall'app o da un software
  esterno, non i movimenti del sistema): un cliente inattivo con molti punti perde quel premio una
  volta sola, non ogni N giorni. Il movimento di addebito del sistema non compare in nessuna
  risposta dell'API esterna.
- **Traguardi**: alla scadenza il traguardo non è più riscattabile e un `REDEEM` risponde
  **`409 MILESTONE_EXPIRED`** (codice nuovo, additivo al contratto v1). Il contatore non scende mai.
- La data si legge in anticipo da `expiresAt` su `rewards` / `milestones` di
  [`GET /customers/{id}`](#get-customersexternalcustomerid). Il cliente riceve un promemoria sulla
  tessera 3 giorni prima e un avviso se il premio scade.
- Se il tenant scende a un piano senza questa funzione la regola salvata non viene più applicata: le
  scadenze si chiudono senza togliere nulla.

**Idempotenza**: passando lo stesso `externalReference` (univoco per integrazione) una seconda
volta, la transazione **non** viene applicata di nuovo: la risposta ha `alreadyApplied: true` e
riporta il `transactionId` della prima esecuzione e il saldo `newPoints` reale in quel momento.
Vale **anche se la seconda chiamata ha parametri diversi** (es. `amount` diverso):
l'`externalReference` è la chiave di idempotenza assoluta e **nessun ulteriore punto viene
accreditato o scalato**.

**Il riferimento si controlla per primo, prima di saldo, traguardi e campagna.** Un retry di
un'operazione già applicata risponde sempre `alreadyApplied: true` — non `409 INSUFFICIENT_BALANCE`,
`409 ALREADY_REDEEMED`, `409 CAMPAIGN_ENDED`/`CAMPAIGN_EXPIRED` o `409 MILESTONE_EXPIRED`, che il cassiere
avrebbe preso per un rifiuto di un riscatto che invece era andato a buon fine.

**Il retry non dipende dal `storeCode`.** Se l'`externalReference` è già un movimento applicato di questa
integrazione, il retry risponde `alreadyApplied: true` anche se nel frattempo il `storeCode` è cambiato o la sede è
stata eliminata (niente `422 STORE_NOT_FOUND`: il software esterno non deve credere che il movimento non sia
avvenuto). Con un riferimento nuovo il `storeCode` resta verificato come sempre.

**Un movimento poi annullato si vede nel retry.** Se il movimento di quel riferimento è stato annullato (da
[`POST /transactions/reverse`](#post-transactionsreverse) o dal Registro), il replay risponde ancora
`alreadyApplied: true` (nulla viene riapplicato) ma con in più **`reversed: true`** e **`reversedAt`** (ISO 8601;
campi additivi, presenti solo in quel caso): il saldo `newPoints` non lo conta più. Chi vuole rifare il movimento
deve usare un `externalReference` **nuovo**.

> ⚠️ **Su un replay `requestedAmount` e `finalAmount` sono quelli del movimento originale**
> (`newPoints` è il saldo di adesso e `transactionId` quello della prima esecuzione), anche se il retry
> manda un `amount` diverso. Resta un solo caso limite: due chiamate **simultanee** con lo stesso
> riferimento, dove la seconda può riecheggiare i propri importi; in quel caso fidati di
> `newPoints`/`transactionId`. Per conoscere l'importo davvero applicato la prima volta puoi rileggere il
> saldo con [`GET /customers/{id}`](#get-customersexternalcustomerid). Il modo semplice per non incappare
> nel caso: ritentare sempre con lo stesso payload, non solo con lo stesso riferimento.

**Senza** `externalReference` non c'è alcuna garanzia di idempotenza (semantica at-least-once: un
retry di rete può accreditare due volte) — usalo sempre per ogni operazione che può essere
ritentata (es. timeout, retry automatico del client HTTP).

> ⚠️ **Un riferimento per operazione, non per vendita.** Proprio perché il riferimento è la chiave
> assoluta, un `REDEEM` inviato con lo stesso `externalReference` di un `EARN` già registrato (per
> esempio il numero di scontrino usato per entrambi) risponde `alreadyApplied: true` con il
> `transactionId` dell'`EARN` **e non riscatta nulla**; nell'ordine inverso è l'accredito a non
> essere eseguito. Usa riferimenti distinti per ogni operazione (es. `V-000123-EARN` e
> `V-000123-REDEEM`). Il riscatto della promo compleanno ha uno spazio di riferimenti proprio e non
> collide con questi.

**Bonus automatici su `EARN`** (si applicano solo se configurati lato pannello per quel tenant/
carta, in aggiunta ad `amount`):
- `appliedMultiplier`: se è attiva un'happy hour per quel template, i punti accreditati sono
  `amount` moltiplicato per il moltiplicatore configurato (arrotondato).
- `winBackBonus`: se esiste un coupon win-back pendente e non scaduto per quella carta, il suo
  valore in punti viene aggiunto e il coupon marcato come riscattato.
- `birthdayBonus`: analogo per un coupon di compleanno pendente.

`finalAmount` è il totale realmente accreditato/scalato (`amount` con eventuale moltiplicatore,
più eventuali bonus); `requestedAmount` è il valore originale richiesto, utile per riconciliare
con il proprio gestionale.

L'accredito/riscatto invia automaticamente l'aggiornamento al wallet del cliente (come se
l'operazione fosse fatta dal pannello). Il campo `message`, se presente, manda in più una
notifica push personalizzata — **solo** se il cliente ha dato il consenso marketing; altrimenti
viene ignorato silenziosamente (nessun errore). Come ogni comunicazione ai clienti, passa dal registro di
consegna: parte solo verso le tessere **installate** nel Wallet (Apple con il telefono registrato, Google con
la tessera salvata), un errore temporaneo viene **ritentato** da Tesserando e il consenso viene ricontrollato a
ogni tentativo. Non parte su una chiamata già applicata (`alreadyApplied: true`).

**Cliente `PENDING_REVIEW`**: la chiamata risponde comunque `409 NOT_LINKED` come per qualsiasi
cliente non `LINKED` (il contratto v1 non cambia), ma se il payload è sintatticamente valido la
richiesta viene messa in coda internamente e applicata automaticamente non appena il merchant
risolve l'ambiguità dal pannello — il software esterno **non deve** ritentare per questo motivo
specifico (ritentare resta comunque inoffensivo, semplicemente non più necessario). La risposta
in questo caso include il campo aggiuntivo `queued`:
```json
409 { "error": "Customer is not linked", "code": "NOT_LINKED", "queued": true }
```
`queued: true` significa **"presa in carico", non "garantita applicata"**: un payload valido a
tempo di accodamento può comunque fallire silenziosamente alla riproduzione per motivi di
business scoperti solo quando il pass finale è noto (es. saldo insufficiente, traguardo non
valido per la carta scelta) — l'API è sincrona e non esiste un canale di callback in v1 per
notificarlo. Le eventuali promozioni automatiche (happy hour, win-back, compleanno) **non** si
applicano alle transazioni riprodotte dalla coda — solo ai punti/bollini base indicati — per
evitare di calcolarle sul giorno sbagliato rispetto a quello reale dell'evento. Se il merchant
scarta tutti i candidati invece di risolvere il link, le transazioni accodate per quel cliente
vengono scartate insieme al tentativo di collegamento. Una riga accodata che fallisce per un errore
**transitorio** (es. database non raggiungibile) resta in coda e viene ritentata alla risoluzione e, dopo,
**alla successiva `POST /transactions` di quel cliente** (in ordine di arrivo, prima del movimento nuovo);
quelle che falliscono per un motivo di business (saldo, traguardo) vengono scartate.

`200`:
```json
{
  "success": true,
  "alreadyApplied": false,
  "type": "EARN",
  "newPoints": 120,
  "requestedAmount": 10,
  "finalAmount": 12,
  "appliedMultiplier": 1.2,
  "winBackBonus": 5,
  "birthdayBonus": 10,
  "transactionId": "..."
}
```
`appliedMultiplier`, `winBackBonus` e `birthdayBonus` sono presenti solo quando applicabili
(assenti, non `null`, se non si applica nessun bonus per quella chiamata).

> Sul `REDEEM` di un traguardo di una carta PROGRESSION `requestedAmount` e `finalAmount` valgono sempre `1`
> (un segnaposto: il traguardo non scala il contatore e `amount` non serve) e il movimento viene salvato con
> importo 1. Ignorali: `newPoints` è il contatore, che non cambia.

### `GET /transactions`
**Riscontro di un movimento** per riferimento, senza rimandarlo. Rotta **additiva** del contratto v1, in sola lettura.

```bash
curl -s "https://www.tesserando.it/api/external/v1/transactions?externalReference=PV01-20260930-000123-EARN" \
  -H "Authorization: Bearer tsk_live_xxx"
```

Parametro `externalReference` (obbligatorio): quello passato a `POST /transactions`. Stesse regole di lunghezza (massimo
128 caratteri); non può iniziare con `birthday-promo:` (spazio riservato alla promo compleanno, che si annulla col suo
`transactionId`). Vede **solo i movimenti di questa integrazione**, uno alla volta: non esiste un elenco.

`200` se il movimento è arrivato e applicato:
```json
{ "status": "APPLIED", "transactionId": "...", "type": "EARN", "requestedAmount": 10, "finalAmount": 12,
  "receivedAt": "2026-09-30T14:32:10.000Z" }
```
- `requestedAmount` e `finalAmount` hanno lo stesso significato che in `POST /transactions` (richiesto prima di
  moltiplicatore e bonus, e realmente accreditato o scalato).
- `receivedAt` è il momento in cui **Tesserando ha ricevuto** il movimento, non una data dell'evento: l'API non ne ha una.
- Se il movimento è stato poi annullato la risposta ha in più `reversed: true` e `reversedAt` (ISO 8601).

`200 { "status": "QUEUED", "type": "EARN" }` se il cliente era in revisione (`PENDING_REVIEW`) e il movimento è in coda:
vedi la nota su [`POST /transactions`](#post-transactions) (presa in carico, non garantito applicato).

`404 TRANSACTION_NOT_FOUND` se nessun movimento di questa integrazione ha quel riferimento: **non è arrivato**, ed è il
caso in cui si può rimandare con lo stesso riferimento. `422 INVALID_PAYLOAD` se il parametro manca, è vuoto, oltre i
128 caratteri o con il prefisso riservato.

### `POST /transactions/reverse`
**Annulla per intero** un movimento fatto da questa integrazione: un reso, un accredito o un riscatto registrati
per errore, una promo compleanno consegnata al cliente sbagliato. Rotta **additiva** del contratto v1.

Body — esattamente **uno** fra `transactionId` ed `externalReference`:
```json
{ "externalReference": "PV01-20260930-000123-EARN", "reason": "Reso", "operator": "Cassa 3" }
```
- `transactionId`: quello restituito da `POST /transactions` o da `POST .../birthday-promo/redeem`.
- `externalReference`: quello passato a `POST /transactions` (il riscatto della promo compleanno si annulla col
  suo `transactionId`: un riferimento che inizia con `birthday-promo:` — spazio riservato — è `422 INVALID_PAYLOAD`;
  lunghezza massima 128 caratteri).
- `reason` (opzionale, max 200 caratteri; oltre il limite viene troncato, non rifiutato) compare nel Registro del merchant; `operator` come su
  `POST /transactions`.
- Si annullano **solo i movimenti di questa integrazione**: quelli fatti dal pannello, dall'app o da un'altra
  integrazione rispondono `404 TRANSACTION_NOT_FOUND`.
- **Solo entro 14 giorni** dalla registrazione del movimento (decisione del titolare del 4 ottobre 2026). Il
  limite è di **14×24 ore esatte** da quando il movimento è stato creato, non di giorni di calendario: a 14 giorni
  esatti si può ancora annullare, un secondo dopo no. Oltre, la risposta è `409 REVERSAL_WINDOW_EXPIRED` e non
  cambia niente. Vale anche per le carte archiviate. Un retry di un annullo **già fatto** risponde sempre
  `alreadyApplied: true`, anche dopo i 14 giorni. Dalla dashboard la regola è la stessa (il pulsante «Annulla» si
  spegne sui movimenti più vecchi).

Cosa succede:
- **Accredito (`EARN`)**: al cliente vengono tolti i punti/bollini accreditati da quel movimento, moltiplicatore
  e bonus coupon compresi. Se il cliente li ha **già spesi** l'annullo è rifiutato con
  `409 INSUFFICIENT_BALANCE`: prima si annulla il riscatto che li ha usati. Il saldo non va mai sotto zero. I
  coupon (win-back, compleanno) incassati da quell'accredito **tornano usabili** fino alla loro scadenza
  originale; se sono già scaduti restano chiusi. Su una carta a Traguardi il contatore scende, ma l'annullo è
  rifiutato con `409 REVERSAL_BELOW_REDEEMED_MILESTONE` se lo porterebbe sotto la soglia di un traguardo già
  ritirato (prima si annulla quel ritiro).
- **Riscatto (`REDEEM`)**: i punti/bollini tornano al cliente; su una carta a Traguardi il traguardo torna da
  ritirare.
- **Promo compleanno**: torna disponibile per l'anno in cui era stata consegnata.
- Non si annullano i movimenti del sistema (regalo di compleanno a punti, addebiti di scadenza) né un annullo.
  Un movimento di sistema non appartiene a nessuna integrazione, quindi da questa rotta risponde
  `404 TRANSACTION_NOT_FOUND`; `409 NOT_REVERSIBLE` arriva per una riga di annullo o per un cliente cancellato
  (anonimizzato).
- Solo **annullo totale**. Per un reso parziale: annulla il movimento e invia l'accredito corretto con un
  `externalReference` **nuovo**: il riferimento del movimento annullato resta legato a quel movimento, e
  riusarlo su `POST /transactions` risponde `alreadyApplied: true` senza accreditare nulla.
- Il movimento annullato resta nello storico del merchant, segnato come annullato, e **non conta più in
  nessuna statistica**; la tessera nel wallet si aggiorna da sola. L'ultima visita del cliente torna
  quella dell'ultimo movimento valido rimasto (un accredito annullato per errore non lo fa più risultare
  "attivo"); se annulli un riscatto che aveva azzerato i punti per scadenza, i punti tornati hanno una nuova
  scadenza (se la carta usa la scadenza punti). Le scadenze dei premi (piani PRO+) si
  riallineano al saldo nuovo.

- **Carta archiviata**: l'annullo resta ammesso ma **corregge solo statistiche e Registro** (decisione del
  titolare del 4 ottobre 2026). Il movimento risulta annullato e fuori da ogni statistica; il saldo, i coupon, le
  scadenze dei premi e il Wallet **non si toccano** (la tessera è già invalidata e il saldo residuo è
  decaduto), e non vale il rifiuto `INSUFFICIENT_BALANCE`: la risposta ha `newPoints: 0` e il campo additivo
  `cardArchived: true`. Restano i rifiuti `NOT_REVERSIBLE` e `TRANSACTION_NOT_FOUND`.

`200`:
```json
{ "success": true, "alreadyApplied": false, "reversalId": "...", "transactionId": "...", "type": "EARN",
  "amount": 12, "newPoints": 30, "restoredCoupons": ["WINBACK"] }
```
`type` è il tipo del movimento annullato; `amount` quanto è stato stornato; `restoredCoupons` (additivo, assente
se vuoto) i coupon tornati usabili; `cardArchived: true` (additivo, presente solo sulle carte archiviate, con
`newPoints: 0`). **Idempotente per natura**: un movimento si annulla una volta sola, un
secondo annullo risponde `alreadyApplied: true` con lo stesso `reversalId`.

Errori: `404 TRANSACTION_NOT_FOUND`; `409 NOT_REVERSIBLE`; `409 INSUFFICIENT_BALANCE`;
`409 REVERSAL_BELOW_REDEEMED_MILESTONE`; `409 REVERSAL_WINDOW_EXPIRED` (movimento di oltre 14 giorni);
`422 INVALID_PAYLOAD`; per il resto la tassonomia standard.

### `POST /customers/{externalCustomerId}/birthday-promo/redeem`
Registra la **consegna in cassa del regalo di compleanno** (la «Promo compleanno» della carta, a testo libero:
una fetta di torta, un caffè): è la stessa operazione del pulsante «Riscatta Premio» della dashboard, con lo
stesso registro (un movimento di tipo premio da 0 punti). Si legge in anticipo da `birthdayPromo` su
[`GET /customers/{id}`](#get-customersexternalcustomerid): questa chiamata si fa quando `available` è `true`.

Body (tutto opzionale, la chiamata può anche non avere body):
```json
{ "externalReference": "pv01-promo-0001", "storeCode": "01", "operator": "Cassa 3" }
```
- Ammessa **solo il giorno del compleanno** (giorno di Roma) e **una volta l'anno**, e solo se la promo è accesa
  sulla carta. Non modifica i punti del cliente.
- Richiede un link `LINKED`. **A differenza di `POST /transactions`, un cliente `PENDING_REVIEW` non viene
  accodato**: il regalo vale solo quel giorno, quindi risponde `409 NOT_LINKED`.
- **Idempotente** su `externalReference`, in uno **spazio di riferimenti proprio**: non collide con quelli di
  `POST /transactions`, quindi puoi usare lo stesso numero di vendita dell'accredito senza perdere né l'uno né
  l'altro. Una seconda chiamata con lo stesso riferimento per lo stesso cliente risponde `alreadyApplied: true`
  e non riscatta di nuovo (se quel riscatto è stato poi **annullato**, la risposta ha in più `reversed: true` e
  `reversedAt`: la promo risulta di nuovo da consegnare e il retry non la riscatta da solo); lo stesso riferimento già usato per la promo di **un altro cliente** risponde
  `409 REFERENCE_CONFLICT` (mai un falso "già fatto"). Senza riferimento, una seconda chiamata nello stesso
  anno risponde `409 ALREADY_REDEEMED`.
- `externalReference`, se presente, è una stringa non vuota di al massimo 128 caratteri; `externalCustomerId`
  (nel percorso) al massimo 128, con percent-encoding valido: altrimenti `422 INVALID_PAYLOAD`.
- Si annulla con [`POST /transactions/reverse`](#post-transactionsreverse) e il `transactionId` della risposta.

`200`:
```json
{ "success": true, "alreadyApplied": false, "transactionId": "..." }
```
`reversed` e `reversedAt` (ISO 8601) compaiono **solo** sul replay di un riscatto poi annullato (campi additivi).
L'anno della promo e il controllo «oggi è il compleanno» usano lo stesso istante (giorno di Roma): a cavallo della
mezzanotte del 31 dicembre non si contraddicono.
Errori: `409 NOT_LINKED`; `409 NOT_BIRTHDAY_TODAY` (oggi non è il suo compleanno, o manca la data di nascita);
`409 BIRTHDAY_PROMO_DISABLED` (la promo non è accesa su quella carta); `409 ALREADY_REDEEMED` (già riscattata
quest'anno); `409 REFERENCE_CONFLICT` (riferimento già usato per la promo di un altro cliente);
`422 STORE_NOT_FOUND` / `422 INVALID_PAYLOAD`; per il resto la tassonomia standard.

## Cambi di comportamento dal 1.9.1

Il contratto v1 resta compatibile (campi e codici nuovi sono solo additivi), ma **alcune chiamate che nella 1.9.1
riuscivano ora rispondono diversamente**. Chi ha un'integrazione già attiva controlli questo elenco.

**Validazione più stretta (`422 INVALID_PAYLOAD`, prima di qualunque modifica al saldo)**
- `type` va scritto esattamente `"EARN"` o `"REDEEM"`: `"earn"` minuscolo, prima accettato, ora è `422`.
- `amount` deve essere un **numero JSON** intero positivo: una stringa (`"10"`), un booleano o un array, prima
  convertiti in silenzio, ora sono `422`.
- `externalReference`: la stringa vuota (`""`), prima accettata **senza idempotenza**, ora è `422`; oltre 128
  caratteri è `422`; un riferimento che inizia con `birthday-promo:` (spazio riservato alla promo compleanno) è `422`.
- `externalCustomerId` oltre 128 caratteri è `422`; un percent-encoding malformato nel percorso di
  `GET /customers/{id}` (es. `%E0%A4%A`) è `422` invece di un `500`.
- `message` oltre i 200 caratteri viene **troncato** (non rifiutato).
- `POST /customers`: `birthDate` accettata solo in formato `YYYY-MM-DD` (calendario reale, dal 1900 a oggi). Formati
  come `12/05/1990` o con fuso orario, prima interpretati (anche sbagliando il giorno), ora sono `422`.

**Carte archiviate (una campagna archiviata è come eliminata)**
- `POST /transactions` su una carta archiviata risponde `409 CARD_ARCHIVED` dove prima accreditava o riscattava.
  Un retry di un movimento già applicato prima dell'archiviazione risponde comunque `alreadyApplied: true`.
- `GET /customers/{id}` su una carta archiviata mostra `points: 0` e `cardArchived: true` (prima mostrava il saldo).
- `POST /customers/resolve`: una carta archiviata non si collega più. Un collegamento già esistente verso una carta
  archiviata viene rivalutato: se il cliente ha una carta attiva si ricollega a quella, altrimenti la risposta è
  `ORPHANED` (prima restava `LINKED` verso una carta che poi rifiutava ogni movimento).
- `POST /customers/lookup`: le carte archiviate non vengono trovate né elencate; una persona che ha solo carte
  archiviate dà `found: 0`.

**Campagna a tempo**
- `GET /customers/{id}`: durante la fase `EXPIRED` della campagna (finiti anche i giorni extra per ritirare i
  premi) `rewards` e `milestones` hanno `available: false` anche se il saldo basterebbe (il `REDEEM` risponderebbe
  `409 CAMPAIGN_EXPIRED`).
- `POST /customers/resolve` non manda più l'email di invito (`INVITE_SENT`) se la campagna della carta predefinita
  è chiusa: risponde `NOT_FOUND` (o `ORPHANED`).

**Altro**
- `POST /customers/lookup`: `found` conta **persone**, non carte. Due carte con lo stesso nome+cognome e la stessa
  email sono `found: 1` (prima `found: 2`); le carte stanno in `cards[]`.
- `POST /transactions` svuota prima la coda dei movimenti rimasti in attesa per quel cliente (in ordine di
  arrivo) e solo dopo applica il movimento nuovo.
- Nome operatore: un'etichetta di integrazione o un campo `operator` che inizia con `Sistema (` viene
  neutralizzato (`Sistema - ...`): quel prefisso è riservato ai movimenti automatici del sistema.
- **Annullo (`POST /transactions/reverse`) solo entro 14 giorni** dalla registrazione del movimento: oltre,
  `409 REVERSAL_WINDOW_EXPIRED` (vedi [`POST /transactions/reverse`](#post-transactionsreverse)).

## Tassonomia errori

Risposte di errore: `{ "error": "...", "code": "..." }` (eccetto `409 NOT_LINKED` su
`GET /customers/{id}`, che include anche `"status": "NOT_LINKED"`, vedi sopra, e `409
MILESTONE_NOT_REACHED`, che include anche `"currentPoints"` e `"targetValue"`).

| HTTP | code | Significato |
|---|---|---|
| 401 | `UNAUTHORIZED` | Key mancante, malformata o non valida |
| 403 | `PLAN_REQUIRED` | Il tenant non è (più) ULTRA |
| 403 | `INTEGRATION_DISABLED` | Integrazione non attiva (disattivata dal tenant o forzata dall'admin) |
| 403 | `TENANT_SUSPENDED` | L'attività è sospesa: tutte le chiamate sono rifiutate finché non viene riattivata (codice additivo, i codici esistenti non cambiano) |
| 403 | `IP_NOT_ALLOWED` | IP del chiamante non in allowlist (solo se l'allowlist è configurata) |
| 403 | `IDENTITY_LOOKUP_DISABLED` | `POST /customers/lookup` chiamata su un'integrazione senza `allowIdentityLookup` |
| 403 | `CUSTOMER_CREATION_DISABLED` | `POST /customers` o `POST /customers/register-link` chiamata su un'integrazione senza `allowCustomerCreation` |
| 409 | `NOT_LINKED` | Cliente non collegato a una carta: chiamare prima `resolve`. Su `POST /transactions` con link `PENDING_REVIEW`, la risposta include anche `"queued": true` — vedi la nota dedicata |
| 409 | `ALREADY_REDEEMED` | Traguardo (PROGRESSION) già riscattato. Su `POST .../birthday-promo/redeem`: la promo compleanno di quest'anno è già stata riscattata |
| 404 | `TRANSACTION_NOT_FOUND` | `POST /transactions/reverse`: nessun movimento di questa integrazione con quel `transactionId`/`externalReference`. `GET /transactions`: nessun movimento di questa integrazione con quel `externalReference` (non è arrivato) |
| 409 | `NOT_REVERSIBLE` | `POST /transactions/reverse`: riga di annullo o cliente cancellato (i movimenti del sistema — regalo di compleanno a punti, addebito di scadenza — non sono di un'integrazione: rispondono `404 TRANSACTION_NOT_FOUND`) |
| 409 | `REVERSAL_WINDOW_EXPIRED` | `POST /transactions/reverse`: il movimento è stato registrato da più di 14 giorni (14×24 ore esatte) e non si può più annullare. Codice nuovo e additivo; un retry di un annullo già fatto risponde comunque `alreadyApplied: true` |
| 409 | `REVERSAL_BELOW_REDEEMED_MILESTONE` | `POST /transactions/reverse` su carta a Traguardi: il contatore scenderebbe sotto un traguardo già ritirato; annulla prima quel ritiro |
| 409 | `REFERENCE_CONFLICT` | `POST .../birthday-promo/redeem`: lo stesso `externalReference` è già stato usato per la promo di un altro cliente |
| 409 | `NOT_BIRTHDAY_TODAY` | `POST .../birthday-promo/redeem`: oggi (giorno di Roma) non è il compleanno del cliente, o manca la data di nascita |
| 409 | `BIRTHDAY_PROMO_DISABLED` | `POST .../birthday-promo/redeem`: la promo compleanno non è accesa sulla carta del cliente |
| 409 | `MILESTONE_NOT_REACHED` | Traguardo (PROGRESSION) valido ma `currentPoints` non ha ancora raggiunto la sua `targetValue`. Risposta estesa: `{ "error", "code", "currentPoints", "targetValue" }` |
| 409 | `INSUFFICIENT_BALANCE` | Saldo punti insufficiente per il riscatto. Su `POST /transactions/reverse`: il cliente ha già speso i punti dell'accredito da annullare |
| 409 | `MILESTONE_EXPIRED` | `POST /transactions` (`REDEEM` su carta PROGRESSION): il tempo per ritirare quel traguardo è scaduto ("Premi da riscattare entro N giorni", piani PRO+) |
| 409 | `CAMPAIGN_ENDED` | `POST /transactions` (`EARN`): la carta ha una fine campagna ("La carta vale fino a…") già passata, non si accumula più. `POST /customers` e `POST /customers/register-link`: la carta predefinita non accetta più iscrizioni |
| 409 | `CAMPAIGN_EXPIRED` | `POST /transactions` (`REDEEM`): anche i giorni extra per ritirare i premi sono finiti |
| 409 | `CARD_ARCHIVED` | `POST /transactions`, `POST /customers`, `POST /customers/register-link`, `POST .../birthday-promo/redeem`: la carta (o la carta predefinita dell'integrazione) appartiene a una campagna **archiviata**, che è come eliminata: non accetta movimenti, iscrizioni né riscatti. Codice nuovo (gli altri codici non cambiano), ma **cambia l'esito di chiamate che prima riuscivano** (decisione del titolare #23, da comunicare agli integratori); su `GET /customers/{id}` la carta archiviata risponde `200` con `points: 0` e il campo `cardArchived: true`. Un retry con un `externalReference` già applicato risponde `alreadyApplied: true`. L'annullo (`POST /transactions/reverse`) resta ammesso (vedi lì) |
| 409 | `ALREADY_EXISTS` | `POST /customers`: **tutti** i campi (nome, cognome, email, telefono, data di nascita) coincidono con un'altra carta **dello stesso modello di tessera** — nessuna creazione automatica; basta un campo diverso o un altro modello per ammettere una nuova carta |
| 409 | `LINK_PENDING_REVIEW` | `POST /customers` / `POST /customers/register-link`: quel cliente esterno ha un collegamento ambiguo in attesa che il merchant scelga la carta giusta dal pannello. Non viene mai scavalcato creando una carta nuova |
| 409 | `PLAN_LIMIT_REACHED` | `POST /customers`: il tenant ha raggiunto il limite clienti del piano |
| 422 | `INVALID_PAYLOAD` | Body assente/non-JSON, campo obbligatorio mancante, `amount` non valido (non un numero intero tra 1 e 1.000.000), `type` diverso da `EARN`/`REDEEM` (maiuscolo), `externalCustomerId`/`externalReference` vuoti o oltre i 128 caratteri, `externalReference` con il prefisso riservato `birthday-promo:`, `birthDate` non `YYYY-MM-DD`, `milestoneId` mancante su REDEEM PROGRESSION, o `milestoneId` che non corrisponde a nessun traguardo configurato per quella carta; `rewardId` non valido, sconosciuto per la carta del cliente o con un `amount` diverso dal costo del premio; `GET /transactions` senza `externalReference` valido |
| 422 | `STORE_NOT_FOUND` | `storeCode` non corrisponde a nessuna sede dell'attività (`POST /transactions`, `POST /customers`, `POST /customers/register-link`, `POST .../birthday-promo/redeem`) |
| 422 | `TEMPLATE_NOT_CONFIGURED` | `POST /customers` / `POST /customers/register-link` su un'integrazione senza `defaultTemplateId`, o con una carta predefinita che non esiste più |
| 429 | `RATE_LIMITED` | Limite di richieste superato (generale per API key, oppure il limite dedicato della singola rotta) — risposta con header `Retry-After`, vedi [Note operative](#note-operative) |
| 500 | `INTERNAL` | Errore interno (anche un'eccezione inattesa di qualunque rotta: la risposta è sempre questa, mai un 500 generico); riprovare con backoff, idealmente con lo stesso `externalReference` |

## Note operative

- **Logging**: ogni chiamata che arriva a un'integrazione riconosciuta viene loggata con `integrationId`,
  `endpoint`, esito (`ok`), `errorCode` (se fallita) e `durationMs`: compresi i rifiuti per integrazione
  disattivata (`INTEGRATION_DISABLED`), tenant sospeso (`TENANT_SUSPENDED`), piano (`PLAN_REQUIRED`) e IP
  (`IP_NOT_ALLOWED`). **Non** sono loggati il `401` (key non riconosciuta: non si sa di quale integrazione) e il
  `429` del limite generale (una raffica scriverebbe una riga per richiesta). **Nessun body né dato personale** viene salvato nei
  log. Il **tenant** vede le ultime 50 chiamate dell'integrazione (quando, endpoint, esito o
  codice d'errore, durata) espandendo **"Ultime chiamate API"** nella card dell'integrazione in
  Impostazioni → Integrazioni esterne — utile per capire al volo se il software esterno sta
  chiamando davvero e con che esito. Non esiste invece una UI lato admin.
- **Rate limiting**: limite generale **120 richieste/minuto per API key**, su tutte le rotte.
  Alcune rotte hanno **in aggiunta** un limite dedicato e più stretto, che si controlla **dopo** quello generale (vedi l'ordine in [Autenticazione](#autenticazione)):

  | Rotta | Limite dedicato | Perché |
  |---|---|---|
  | `POST /customers/lookup` | 20/min per integrazione+IP **e** 60/min per integrazione | Unica rotta che espone email: il secondo limite non è aggirabile distribuendo le richieste su più IP |
  | `POST /customers` | 30/min per integrazione+IP | Il `409 ALREADY_EXISTS` rivela l'esistenza di un contatto in archivio |
  | `POST /customers/resolve` | 30/min per integrazione+IP | È l'unica rotta che può far partire email di invito |
  | `POST /customers/register-link` | 30/min per integrazione+IP | Ogni chiamata crea o rigenera una riga di invito |

  Tutti rispondono `429 RATE_LIMITED` con header `Retry-After` (secondi).
- **Email di invito**: la manda **sempre Tesserando** (mittente e template gestiti dalla
  piattaforma); il software esterno passa solo l'indirizzo email nel body di `resolve`, non
  deve gestire l'invio. Tetto anti-spam: **20 inviti al giorno per integrazione**; oltre quello
  `resolve` risponde `NOT_FOUND` invece di `INVITE_SENT` (vedi la nota su
  [`POST /customers/resolve`](#post-customersresolve)). Chi vuole far registrare il cliente
  senza passare da un'email usa [`POST /customers/register-link`](#post-customersregister-link),
  che non consuma quota.
- **CORS**: tutte le rotte rispondono `204` su `OPTIONS` (preflight), ma **nessun header
  `Access-Control-Allow-Origin` viene impostato**. Le chiamate dirette da browser falliranno il
  preflight: l'uso previsto è server-to-server (dal backend del software esterno), non da
  JavaScript lato client.
