# API publique Node — référence complète

> **Ce fichier est fait pour être donné en entier à un assistant IA** (Claude, ChatGPT…).
> Il contient tout ce qu'il faut pour construire une intégration : chaque appel, chaque
> champ, chaque erreur, et les pièges qui coûtent une journée quand on les découvre en route.
>
> Version 1 · `https://app.nodegroupe.com/api/v1` · Mis à jour le 2026-09-02
> Spécification machine : `GET https://app.nodegroupe.com/api/v1/openapi.json` (libre, sans clé)

---

## SI TU ES UN ASSISTANT IA, LIS CECI D'ABORD

Sept règles. Les six premières sont la cause de presque tous les échecs d'intégration
sur cette API. Applique-les avant d'écrire la moindre ligne.

1. **`/api/v1` n'est PAS `/api/`.** Les routes internes de l'application changent à
   chaque déploiement et n'ont aucun contrat. N'appelle jamais autre chose que
   `/api/v1/…`, même si une clé y donne accès.

2. **Écrire un prix ou une disponibilité répond `202`, pas `200`.** Tu reçois un
   `job_id`. L'écriture n'est pas encore chez Airbnb/Booking. Suis-la sur
   `GET /api/v1/jobs/{job_id}`. Traiter le 202 comme un succès final est faux.

3. **Écris par LOT.** Une boucle jour par jour épuise le quota en quelques secondes.
   Groupe tes plages : jusqu'à 60 par appel.

4. **Une plage de dates qui commence dans le passé est refusée EN ENTIER**, partie
   future comprise. Découpe toujours à partir d'aujourd'hui.

5. **Pour être prévenu, utilise les webhooks.** N'interroge pas l'API en boucle.
   `updated_since` sert au rattrapage après une panne, pas à la détection continue.

6. **Tout `POST` exige un en-tête `Idempotency-Key`.** Sans lui : `400`. Génère un
   UUID par tentative logique.

7. **`GET /api/v1/me` en premier.** Il te dit si la clé marche et ce qu'elle a le
   droit de faire, sans toucher aucune donnée. Vérifie les portées AVANT de coder :
   découvrir un `403` en pleine boucle coûte bien plus cher.

**Modèle mental en une phrase :** Node est le système de gestion (PMS) d'une
conciergerie de location courte durée. Il détient les logements, les propriétaires,
les réservations venues des plateformes, le ménage et la facturation ; il pousse
prix et disponibilités vers les plateformes via un gestionnaire de canaux **au débit
partagé entre tous les clients de Node** — d'où l'asynchronisme des écritures.

---

## SOMMAIRE

**Fondamentaux**
- [1. Authentification](#1-authentification)
- [2. Autorisations (portées)](#2-autorisations-portées)
- [3. Conventions : réponses, erreurs, pagination, idempotence](#3-conventions)
- [4. Quotas](#4-quotas)

**Les 12 ressources**
- [5. `/me` — identité de la clé](#5-me)
- [6. `/properties` — logements](#6-properties)
- [7. `/owners` — propriétaires](#7-owners)
- [8. `/reservations` — réservations](#8-reservations) (et `/channels`)
- [9. `/availability` — calendrier](#9-availability)
- [10. `/rates` — prix et restrictions](#10-rates) (et `/calendar`, les deux d'un coup)
- [11. `/jobs` — suivi des écritures](#11-jobs)
- [12. `/cleaning/missions` — ménage](#12-cleaning)
- [13. `/invoices` — facturation](#13-invoices)
- [14. `/messages` — messagerie voyageur](#14-messages)
- [15. `/webhooks` — événements](#15-webhooks)
- [16. `/prospection` — CRM propriétaires](#16-prospection)

**Aller plus loin**
- [17. Recettes complètes](#17-recettes)
- [18. Ce que l'API ne fait pas](#18-limites)
- [19. Pièges connus](#19-pièges)
- [20. Applications partenaires (OAuth 2.0)](#20-oauth)

---

<a id="1-authentification"></a>
## 1. Authentification

En-tête sur chaque requête :

```
Authorization: Bearer node_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

| Préfixe | Nature | Portée |
|---|---|---|
| `node_sk_` | Clé personnelle d'une conciergerie | 1 compte |
| `node_at_` | Jeton OAuth d'une application partenaire | N comptes |

**Obtenir une clé** : dans Node → *Réglages* → onglet *API & MCP* → cocher les
autorisations → *Créer*. Le secret s'affiche **une seule fois**.

**L'API est incluse au Pack 2.** Un compte Pack 1 ne peut pas créer de nouvelle clé
(`403 PACK2_REQUIRED`). Exception : les clés créées **avant le 1ᵉʳ septembre 2026**
restent valables quel que soit le pack — une décision de tarification ne coupe pas
une intégration en service.

⚠️ **Serveur à serveur uniquement.** Ne mets jamais une clé dans du JavaScript de
navigateur : un secret dans une page est un secret public. Le CORS n'est pas ouvert.

---

<a id="2-autorisations-portées"></a>
## 2. Autorisations (portées)

Une clé ne peut faire que ce qui a été coché. Une portée d'écriture **implique** la
lecture correspondante (cocher `properties:write` suffit).

| Portée | Ouvre | Défaut |
|---|---|---|
| `properties:read` | Fiches logement, équipements, adresses | ✅ |
| `properties:write` | Créer et modifier des logements | — |
| `owners:read` / `owners:write` | Propriétaires et rattachements | — |
| `reservations:read` | Séjours, statuts, canaux, montants | ✅ |
| `reservations.planning:read` | Planning des séjours sans aucun montant (dates, heures, voyageurs, canal) : pour un prestataire | — |
| `reservations:write` | Réservation directe, blocage | — |
| `availability:read` / `availability:write` | Calendrier | lecture ✅ |
| `rates:read` / `rates:write` | Prix et restrictions | lecture ✅ |
| `messages:read` / `messages:send` | Conversations voyageur | — ⚠️ |
| `cleaning:read` / `cleaning:write` | Missions de ménage | — |
| `billing:read` | Factures propriétaire (lecture seule) | — |
| `guests.pii:read` | Fiches de police, pièces d'identité | — ⚠️ |
| `locks.codes:read` | Codes de serrure en clair | — ⚠️ |
| `webhooks:manage` | Points de réception d'événements | ✅ |
| `catalogue:propose` | Proposer une extension de son espace marque blanche au catalogue Node | — |
| `espace.donnees:read` | Lire les données enregistrées par les onglets de l'espace | — |
| `espace.donnees:write` | Créer, modifier, supprimer ces données (implique la lecture) | — |
| `espace.secrets:read` | Lire les clés confiées par la conciergerie à son espace, et ses domaines autorisés | — ⚠️ |
| `espace.automatisations:write` | Inscrire et lire le journal des automatisations de l'espace | — |
| `envois.email:send` | Envoyer un e-mail au nom de la conciergerie (200 par jour) | — |
| `prospection:read` | Dossiers propriétaires du CRM commercial | — ⚠️ |
| `prospection:write` | Créer et compléter des dossiers, demander une avancée | — |
| `validations:read` | Actes régulés en attente et journal des décisions (carte G) | — ⚠️ |
| `validations:decide` | Approuver ou refuser un acte régulé à la place du propriétaire | — ⚠️ |

⚠️ = sensible : jamais cochée par défaut, chaque accès est journalisé, et une
application partenaire ne l'obtient qu'avec un accord de sous-traitance signé.

**Restriction par logement** : une clé peut être limitée à certains biens. Hors de
son périmètre, l'API répond **`404`, jamais `403`** — un `403` confirmerait que la
ressource existe.

---

<a id="3-conventions"></a>
## 3. Conventions

### Forme des réponses

```jsonc
// Ressource unique
{ "data": { … } }

// Liste
{ "data": [ … ], "has_more": true, "next_cursor": "eyJ2Ijoi…" }

// Erreur
{ "error": { "code": "scope_missing", "message": "…", "field": "…", "request_id": "…" } }
```

Chaque réponse porte l'en-tête `Node-Request-Id`. **Cite-le dans toute demande de
support** : il retrouve l'appel exact, à la seconde près.

### Codes d'erreur

Les `code` sont **stables** — branche ta logique dessus, jamais sur le `message`,
qui peut être reformulé.

| `code` | HTTP | Signification | Que faire |
|---|---|---|---|
| `invalid_request` | 400 | Paramètre absent ou mal formé | Lire `field`, corriger |
| `unauthorized` | 401 | Clé absente, invalide, expirée, révoquée | Vérifier la clé |
| `scope_missing` | 403 | Portée non cochée (le message la nomme) | Cocher la portée |
| `forbidden` | 403 | Accès refusé (dont `PACK2_REQUIRED`) | Voir le message |
| `not_found` | 404 | Introuvable **ou** hors périmètre de la clé | Vérifier l'id |
| `conflict` | 409 | Requête concurrente sur la même clé d'idempotence | Réessayer plus tard |
| `idempotency_conflict` | 422 | Même clé, corps différent | Nouvelle clé |
| `unprocessable` | 422 | Valide mais inapplicable (logement non connecté) | Lire le message |
| `rate_limited` | 429 | Quota dépassé | Respecter `Retry-After` |
| `bad_gateway` | 502 | Panne amont (gestionnaire de canaux, OTA) | Réessayer |
| `service_unavailable` | 503 | Indisponibilité momentanée | Réessayer |
| `internal_error` | 500 | Erreur interne | Signaler avec le `request_id` |

### Pagination — par curseur, jamais par page

Un `?page=3` dérive dès qu'une ligne s'insère pendant la boucle : tu sauterais des
enregistrements sans le savoir.

```bash
GET /api/v1/reservations?limit=100
# → { "data": [...], "has_more": true, "next_cursor": "eyJ2Ijoi…" }

GET /api/v1/reservations?limit=100&cursor=eyJ2Ijoi…
```

Boucle tant que `has_more` est vrai. `limit` : 1 à 200 (défaut 50). Un curseur
invalide renvoie `400` — il n'est jamais ignoré en silence, sinon tu boucterais sur
la première page en croyant avancer.

### Idempotence

Obligatoire sur tout `POST`.

```
Idempotency-Key: 8f14e45f-ea1c-4b2f-9f8a-1d3c2b7e5a90
```

- **Même clé + même corps, sous 24 h** → la première réponse est renvoyée, avec
  `Idempotent-Replay: true`. Rien n'est créé en double.
- **Même clé + corps différent** → `422 idempotency_conflict`.
- **La requête a échoué** → la clé est libérée, réessaie avec la même.

Génère la clé **une fois par tentative logique**, pas par requête HTTP : c'est ce qui
protège du rejeu.

---

<a id="4-quotas"></a>
## 4. Quotas

Comptés **par clé**, par minute, en trois seaux indépendants.

| Palier | Lecture | Écriture | Écriture calendrier/tarifs |
|---|---|---|---|
| `standard` | 600 | 60 | **10 / min** |
| `partner` | 3 000 | 300 | 30 / min |

Le seau « écriture calendrier/tarifs » compte les appels `PUT /api/v1/rates` et
`PUT /api/v1/availability` **par clé** (pas par logement) ; un appel groupé
(`property_ids`, jusqu'à 50 logements) compte pour **un** appel. Ces écritures sont
mises en file : le seau borne ce que tu déposes, le débit réel vers les plateformes
reste celui du worker.

Chaque réponse porte `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`
(epoch en secondes). Un `429` porte `Retry-After` — **respecte-le**, réessayer tout
de suite aggrave la situation.

Le seau des écritures de distribution est minuscule par nécessité physique : le débit
vers les plateformes est partagé entre tous les clients de Node.

---

<a id="5-me"></a>
## 5. `/me` — identité de la clé

### `GET /api/v1/me`
**Portée : aucune.** Ne touche aucune donnée métier.

```bash
curl https://app.nodegroupe.com/api/v1/me \
  -H "Authorization: Bearer node_sk_..."
```

```json
{
  "data": {
    "account": { "id": "uuid", "name": "Ma Conciergerie", "email": "contact@x.fr" },
    "token": {
      "id": "uuid",
      "kind": "personal",
      "legacy": false,
      "scopes": ["properties:read", "reservations:read", "availability:read", "rates:read", "webhooks:manage"],
      "property_ids": null,
      "rate_tier": "standard"
    },
    "rate_limits": { "read": 600, "write": 60, "ari": 2 },
    "api_version": "v1"
  }
}
```

| Champ | Sens |
|---|---|
| `token.scopes` | Portées effectives, **lectures impliquées comprises** |
| `token.legacy` | `true` = clé créée avant les autorisations. Elle n'ouvre plus AUCUN accès (`scopes` vide, `403 SCOPE_MISSING` partout) : à recréer, ou à restreindre dans Réglages → API & MCP |
| `token.notice` | Présent seulement si `legacy` vaut `true` : la marche à suivre, en clair |
| `token.property_ids` | `null` = tout le parc ; sinon la liste des biens accessibles |

**Recommandation** : appelle-le au démarrage de ton intégration et vérifie que les
portées dont tu as besoin y figurent. Échoue vite et clairement plutôt qu'au milieu
d'un traitement.

### `GET /api/v1/me/modules`
**Portée : aucune.** Les modules de Node (onglets) et leur état pour ce compte. C'est
ce qu'un espace marque blanche (`<slug>.nodegroupe.com`) lit pour construire sa
navigation : un module éteint ici n'existe pas pour lui.

```json
{
  "data": {
    "modules": [
      { "key": "calendrier", "label": "Calendrier & synchro OTA", "enabled": true, "source": "core",
        "base": true, "pages": ["/calendar", "/availability", "/platforms"], "scopes": ["availability:read", "availability:write", "properties:read"] }
    ],
    "required_scopes": ["availability:read", "availability:write", "properties:read"],
    "instance": { "slug": "maconciergerie", "status": "active", "host": "maconciergerie.nodegroupe.com", "core_version": "2026.10.0", "studio_enabled": false }
  }
}
```

| Champ | Sens |
|---|---|
| `modules[].base` | Inclus dans la licence : toujours allumé |
| `modules[].source` | `core` (onglet de Node), `catalogue` (module partagé), `extension` (codé dans l'espace) |
| `required_scopes` | Portées nécessaires pour servir les modules allumés |
| `instance` | `null` si le compte n'a pas d'espace marque blanche |

---

<a id="6-properties"></a>
## 6. `/properties` — logements

### `GET /api/v1/properties` — portée `properties:read`

| Paramètre | Type | Notes |
|---|---|---|
| `limit` | 1–200 | défaut 50 |
| `cursor` | string | `next_cursor` de l'appel précédent |
| `search` | string | nom, ville ou nom interne |
| `city` | string | filtre ville |
| `ids` | UUID séparés par des virgules | 200 au plus ; un id hors périmètre ou inexistant est simplement absent |
| `updated_since` | date-heure ISO | modifiés depuis cet instant (synchronisation incrémentale) |
| `status` | `active` \| `inactive` | absent = les deux (les logements désactivés sont inclus par défaut) |

```json
{
  "data": [{
    "id": "uuid",
    "name": "Studio Vieux-Port",
    "internal_name": null,
    "status": "active",
    "address": { "line1": "12 rue Sainte", "postal_code": "13001", "city": "Marseille",
                 "country": "FR", "latitude": "43.2951", "longitude": "5.3745" },
    "capacity": { "max_guests": 4, "bedrooms": 1, "beds": 2, "bathrooms": "1.0", "surface_m2": 42 },
    "property_type": "apartment",
    "description": "…",
    "amenities": ["wifi", "kitchen"],
    "check_in_time": "16:00",
    "check_out_time": "11:00",
    "currency": "EUR",
    "base_price": "120.00",
    "timezone": "Europe/Paris",
    "image_url": "https://…",
    "contact_phone": "+33…",
    "contact_email": "…",
    "created_at": "2026-01-14T09:12:00.000Z",
    "updated_at": "2026-10-02T08:30:00.000Z",
    "deactivated_at": null
  }],
  "has_more": false,
  "next_cursor": null
}
```

- `status` vaut `inactive` quand le logement est désactivé dans Node (`deactivated_at`
  renseigné) : ne le tarife pas, il n'est plus distribué.
- `updated_at` retombe sur `created_at` pour une fiche jamais modifiée. Il avance à
  chaque modification faite dans Node ; certaines mises à jour techniques internes
  peuvent ne pas le faire avancer.

**Bloc `access` conditionnel** — n'apparaît **que** sous la portée `locks.codes:read` :

```json
"access": { "wifi_name": "MonWifi", "wifi_password": "…", "check_in_method": "keybox", "check_in_code": "4242" }
```

Traite-le comme un secret : un code de boîte à clés qui fuite, c'est un accès
physique au logement.

### `POST /api/v1/properties` — portée `properties:write` · `Idempotency-Key` requis

```json
{
  "name": "Studio Vieux-Port",
  "address": "12 rue Sainte", "postal_code": "13001", "city": "Marseille", "country": "FR",
  "latitude": "43.2951", "longitude": "5.3745",
  "timezone": "Europe/Paris",
  "contact_email": "contact@x.fr", "contact_phone": "+33612345678",
  "property_type": "apartment",
  "currency": "EUR", "base_price": "120.00",
  "max_guests": 4, "bedrooms": 1, "beds": 2, "surface_m2": 42,
  "description": "…",
  "amenities": ["wifi", "kitchen"],
  "check_in_time": "16:00", "check_out_time": "11:00"
}
```

Seul `name` est obligatoire. Réponse : `201` + le logement.

**Ce que ça déclenche vraiment** : géocodage de l'adresse, puis création du logement,
du type de chambre et du plan tarifaire chez le gestionnaire de canaux, puis import
des réservations déjà existantes. Compte quelques secondes.

⚠️ **Un logement peut être créé SANS être distribuable.** S'il manque l'adresse, les
coordonnées GPS, le téléphone ou l'e-mail, la partie « canaux » échoue mais le
logement existe dans Node. Vérifie ensuite avec `GET /api/v1/rates?property_id=…` :
si tu obtiens `422 unprocessable`, le bien n'est pas connecté.

**Valeurs de `property_type` acceptées par les canaux** : `apartment`, `house`,
`villa`, `chalet`, `studio`, `hotel`, `apart_hotel`, `guest_house`, `holiday_home`,
`riad`, `lodge`, `resort`, `hostel`, `boat`, `camping`, `farm_stay`, `tent`.
Une autre valeur est remplacée par `apartment` côté canaux.

### `GET /api/v1/properties/{id}` — portée `properties:read`
Même forme qu'un élément de la liste. `404` si l'id est inconnu **ou** hors du
périmètre de ta clé.

### `PATCH /api/v1/properties/{id}` — portée `properties:write`

Champs modifiables : `name`, `address`, `postal_code`, `city`, `country`,
`contact_email`, `contact_phone`, `max_guests`, `bedrooms`, `beds`, `surface_m2`,
`description`, `amenities`, `check_in_time`, `check_out_time`, `house_rules`,
`internal_name`.

⚠️ **`base_price` et `currency` ne sont PAS modifiables ici** : ils toucheraient la
distribution. Passe par `PUT /api/v1/rates`, qui est asynchrone et régulé.

---

<a id="7-owners"></a>
## 7. `/owners` — propriétaires

### `GET /api/v1/owners` — portée `owners:read`

Paramètres : `limit`, `cursor`, `search` (nom ou e-mail), `property_id`.

```json
{
  "data": [{
    "id": "uuid",
    "civility": "Mme",
    "first_name": "Marie", "last_name": "Durand",
    "email": "marie@exemple.fr",
    "phone": "+33612345678",
    "billing": { "address": "…", "postal_code": "13001", "city": "Marseille", "country": "FR" },
    "portal_enabled": true,
    "property_ids": ["uuid", "uuid"],
    "created_at": "2026-01-14T09:12:00.000Z"
  }]
}
```

**Jamais exposés** : taux de commission (négociation commerciale), mot de passe,
identifiants Stripe, mandat SEPA.

### `POST /api/v1/owners` — portée `owners:write` · `Idempotency-Key` requis

```json
{
  "civility": "Mme",
  "first_name": "Marie", "last_name": "Durand",
  "email": "marie@exemple.fr",
  "phone_country": "+33", "phone_number": "612345678",
  "billing": { "address": "1 rue X", "postal_code": "13001", "city": "Marseille", "country": "FR" },
  "property_ids": ["uuid"]
}
```

`property_ids` est **obligatoire, au moins un** : un propriétaire sans bien n'a aucun
sens métier. Le propriétaire et ses rattachements sont créés dans la même
transaction — pas d'enregistrement orphelin possible.

---

<a id="8-reservations"></a>
## 8. `/reservations` — réservations

### `GET /api/v1/reservations` — portée `reservations:read` (ou `reservations.planning:read`, sans les montants)

| Paramètre | Notes |
|---|---|
| `property_id` | UUID |
| `property_ids` | UUID séparés par des virgules, 200 au plus (chacun contrôlé : 404 au premier hors périmètre) |
| `status` | `confirmed`, `modified`, `cancelled`, `completed` |
| `channel` | `Airbnb`, `Booking`, `Direct`, `Blocage`… — liste complète : `GET /api/v1/channels` |
| `arrival_from` / `arrival_to` | `AAAA-MM-JJ` |
| `departure_from` / `departure_to` | `AAAA-MM-JJ`, bornes incluses |
| `updated_since` | date-heure ISO — **rattrapage uniquement** |
| `limit`, `cursor` | pagination |

Tri : date d'arrivée décroissante.

```json
{
  "data": [{
    "id": "uuid",
    "property_id": "uuid",
    "status": "confirmed",
    "channel": "Airbnb",
    "arrival_date": "2026-09-12",
    "departure_date": "2026-09-15",
    "nights": 3,
    "guest": { "name": "Marie D.", "email": null, "adults": 2, "children": 0, "infants": 0 },
    "amounts": {
      "currency": "EUR", "night_price": "120.00", "gross_revenue": "360.00",
      "platform_fees": "54.00", "cleaning_fee": "50.00", "city_tax": "6.00", "net_owner": "306.00"
    },
    "check_in_time": null, "check_out_time": null,
    "external_refs": { "ota_reservation_code": "HMABCDEF", "channex_booking_id": "uuid" },
    "created_at": "2026-08-01T10:00:00.000Z",
    "status_detail": "confirmed",
    "is_block": false,
    "booked_at": "2026-07-28T19:42:00.000Z",
    "booked_at_source": "channex",
    "updated_at": "2026-08-01T10:00:00.000Z",
    "pricing": {
      "currency": "EUR", "accommodation": "330.00", "ota_commission": "54.00",
      "tourist_tax": "6.00", "cleaning_fee": "50.00", "net_owner": "306.00",
      "base": "330.00",
      "fees": [{ "type": "PASS_THROUGH_CLEANING_FEE", "label": "Frais de ménage", "amount": "30.00" }]
    }
  }]
}
```

**Champs pour un gestionnaire de revenus :**
- `booked_at` = date à laquelle le voyageur a **réservé** (≠ `created_at`, date
  d'entrée dans Node : un import de masse les écarte de plusieurs mois).
  `booked_at_source` : `channex` ou `direct` = exacte ; `channex-approx` ou
  `revision-approx` = borne haute (réservé au plus tard à cette date) ; `null` = inconnue.
- `is_block: true` = blocage de calendrier, **pas une vente** : exclus-le du revenu et
  de l'occupation vendue. `status_detail` vaut alors `blocked` (sauf blocage annulé) ;
  `status` ne change pas. Un blocage posé depuis le calendrier de Node porte
  `channel: "Direct"` : seul `is_block` fait foi.
- `pricing` (même droit que `amounts` : `null` sans `reservations:read`) :
  `accommodation` = nuits seules (`base` si le canal l'a ventilé, sinon le brut),
  `ota_commission` = commission annoncée par l'OTA, à défaut les frais de plateforme,
  `fees` = frais ventilés par le canal (aujourd'hui Airbnb seulement, `[]` sinon).
- `net_owner` (dans `amounts` et `pricing`) = **montant reçu de la plateforme** :
  `gross_revenue − platform_fees`, **avant ménage et commission de la conciergerie**.
  Ce n'est PAS le net reversé au propriétaire : celui-ci (après ménage, commission et
  dépenses) n'existe que sur la facture propriétaire.
- 🔴 **Node ne suit PAS** — et ne renvoie donc jamais : frais de gestion de la
  conciergerie, TVA, frais de service facturés au voyageur par l'OTA, remboursements,
  frais bébé, promotions, montant des frais de paiement. Un champ absent est
  **inconnu**, pas nul.

### `GET /api/v1/channels` — portée `reservations.planning:read`

Liste statique des valeurs que Node écrit dans `channel` :
`Airbnb`, `Booking`, `Expedia`, `Vrbo`, `Agoda`, `TripAdvisor`, `Google`, `Direct`,
`LeReseau`, `Owner`, `iCal`, `Longue durée`, `Blocage`.

```json
{ "data": { "channels": [
  { "value": "Airbnb", "label": "Airbnb", "kind": "ota", "revenue": true, "sources": ["channel_manager", "import", "manual"] }
], "notice": "…" } }
```

⚠️ Un canal inconnu de Node est **conservé sous son nom d'origine** : traite toute
valeur hors liste comme « autre canal », ne la rejette pas. Compare **sans tenir
compte de la casse** (données historiques `airbnb`, `booking.com`).

**Points importants :**
- `guest.email` est `null` sans la portée `guests.pii:read`. Le **nom** reste : il
  figure déjà sur le calendrier de tout le monde.
- Les montants sont des **chaînes décimales**. Ne les convertis pas en nombre
  flottant : tu perdrais des centimes. Utilise un type décimal.
- `external_refs` est en **lecture seule** — jamais accepté en entrée.
- `nights` est calculé (départ − arrivée).

### `POST /api/v1/reservations` — portée `reservations:write` · `Idempotency-Key` requis

```json
{
  "property_id": "uuid",
  "arrival_date": "2026-10-01",
  "departure_date": "2026-10-05",
  "guest_name": "Marie Durand",
  "guest_email": "marie@exemple.fr",
  "adults": 2, "children": 0,
  "gross_revenue": "480.00",
  "cleaning_fee": "50.00",
  "notes": "Arrivée tardive",
  "kind": "booking"
}
```

`kind` : `"booking"` (réservation directe, défaut) ou `"block"` (blocage de dates,
sans voyageur).

**Effet de bord** : la création ferme les dates chez les plateformes et déclenche la
mission de ménage. C'est voulu.

🔴 **Aucune suppression n'existe en v1.** Supprimer un séjour lève les dates chez le
gestionnaire de canaux hors file et sans trace. Pour libérer des dates, passe le
statut à `cancelled` : c'est tracé et réversible.

### `GET /api/v1/reservations/{id}` — portée `reservations:read` (ou `reservations.planning:read`, sans les montants)

Une réservation par son identifiant. Même format de sortie que la liste.

C'est le point d'entrée à utiliser **après un webhook** : les événements de
réservation portent un payload volontairement partiel (`reservation_id`,
`property_id`, `status`, `channel`, les dates, le code OTA), parce qu'un webhook
traverse des points de réception dont le niveau de protection nous échappe. Le
détail se récupère ici, avec la portée qui va bien.

```
GET /api/v1/reservations/3f2b1c8e-…
```

- `404` si la réservation n'existe pas **ou** n'appartient pas au compte : on ne
  distingue pas les deux cas, sinon un jeton curieux apprendrait qu'une
  réservation existe ailleurs dans la flotte.
- Un jeton restreint à certains logements ne peut pas lire une réservation d'un
  autre bien, même en la nommant directement.
- `guest.email` reste conditionné à `guests.pii:read`, comme dans la liste.

---

<a id="9-availability"></a>
## 9. `/availability` — calendrier

### `GET /api/v1/availability` — portée `availability:read`

Paramètres **obligatoires** : `property_id`, `from`, `to` (`AAAA-MM-JJ`).

```json
{
  "data": {
    "property_id": "uuid",
    "source": "cache",
    "days": [
      { "date": "2026-09-01", "available": true,  "booked": false, "cached_at": "2026-09-01T08:12:00.000Z" },
      { "date": "2026-09-02", "available": false, "booked": true,  "cached_at": "2026-09-01T08:12:00.000Z" }
    ]
  }
}
```

`source: "cache"` est explicite et important : tu lis un instantané local rafraîchi
par cycles, **pas l'état en direct de la plateforme**. Interroger la plateforme à
chaque lecture publique brûlerait le débit partagé de toute la flotte.
`cached_at` te donne l'âge de la donnée. `booked` distingue « fermé manuellement »
de « réservé ».

### `PUT /api/v1/availability` — portée `availability:write` → **`202`**

```json
{
  "property_id": "uuid",
  "ranges": [
    { "from": "2026-10-01", "to": "2026-10-15", "available": false },
    { "from": "2026-10-20", "to": "2026-10-31", "available": true }
  ]
}
```

```json
{
  "data": {
    "job_id": "uuid",
    "status": "pending",
    "operations": 6,
    "message": "Accepté. Les dates seront poussées aux canaux par lots…"
  }
}
```

- **60 plages maximum** par appel. Groupe.
- Les bornes `from` et `to` sont **incluses**.
- Une plage commençant dans le passé → `400`, plage entière refusée.
- Horizon maximum : 730 jours.
- `422 unprocessable` = le logement n'est pas connecté aux canaux.

**Écriture groupée** — `property_ids` (1 à 50 logements) **au lieu de** `property_id`,
mêmes plages appliquées à chacun, un travail par logement :

```json
{ "property_ids": ["uuid-1", "uuid-2"], "ranges": [{ "from": "2026-12-24", "to": "2026-12-26", "available": false }] }
```

```json
{ "data": { "status": "pending", "operations": 8,
  "jobs": [{ "property_id": "uuid-1", "job_id": "uuid", "operations": 4 },
           { "property_id": "uuid-2", "job_id": "uuid", "operations": 4 }],
  "message": "…" } }
```

Tout ou rien : chaque logement est vérifié (périmètre de la clé, connexion aux
canaux) **avant** le moindre dépôt. Un seul logement non connecté → `422` qui le
nomme, et rien n'est écrit. Avec `property_id`, la réponse garde sa forme historique.

---

<a id="10-rates"></a>
## 10. `/rates` — prix et restrictions

### `GET /api/v1/rates` — portée `rates:read`

Paramètres : `property_id`, `from`, `to` obligatoires ; `rate_plan_id` optionnel.

```json
{
  "data": {
    "property_id": "uuid",
    "source": "cache",
    "notice": "`price_on_channel` est ce que le canal AFFICHE…",
    "rate_plans": [
      { "id": "uuid-channex", "title": "Standard", "currency": "EUR", "primary": true }
    ],
    "days": [{
      "date": "2026-09-01",
      "rate_plan_id": "uuid-channex",
      "price_on_channel": 132.0,
      "price_pushed": 120.0,
      "min_stay_arrival": 2,
      "min_stay_through": 2,
      "max_stay": null,
      "closed_to_arrival": false,
      "closed_to_departure": false,
      "stop_sell": false,
      "cached_at": "…", "pushed_at": "…"
    }]
  }
}
```

🔴 **`price_on_channel` ≠ `price_pushed`, et c'est normal.**
Le premier est ce que la plateforme **affiche**, sa majoration de canal comprise ;
le second est ce que Node a **envoyé**. Sur un canal qui majore, les deux ne
coïncident jamais.

**Ne compare jamais `price_on_channel` à ce que tu voulais pousser pour décider s'il
faut re-pousser.** Ça donne 0 % de correspondance et déclenche un ré-envoi permanent
de tout le calendrier. Compare à `price_pushed`.

### `PUT /api/v1/rates` — portée `rates:write` → **`202`**

```json
{
  "property_id": "uuid",
  "rate_plan_ids": ["uuid-channex"],
  "ranges": [
    { "from": "2026-12-20", "to": "2027-01-03", "price": "180.00", "min_stay": 3 },
    { "from": "2026-11-01", "to": "2026-11-30", "price": "95.00" },
    { "from": "2026-12-24", "to": "2026-12-26", "closed_to_arrival": true }
  ]
}
```

| Champ de plage | Type | Notes |
|---|---|---|
| `from`, `to` | `AAAA-MM-JJ` | bornes incluses, obligatoires |
| `price` | chaîne `"129.00"` | prix par nuit, devise du plan |
| `min_stay` | 1–365 | applique arrivée **et** séjour |
| `max_stay` | 1–365 | |
| `closed_to_arrival` | booléen | interdit d'arriver ce jour-là |
| `closed_to_departure` | booléen | interdit de partir ce jour-là |

**Au moins un champ** parmi ceux-là par plage, sinon `400`.

`rate_plan_ids` absent = **tous** les plans du logement. Un identifiant inconnu est
refusé (`400`) plutôt qu'ignoré — sinon tu croirais avoir écrit sur un plan qui n'a
jamais reçu ta valeur, et le calendrier mentirait.

**Écriture groupée** : `property_ids` (1 à 50) au lieu de `property_id`, comme pour
`PUT /api/v1/availability` (même réponse `jobs: [{ property_id, job_id, operations,
rate_plan_ids }]`). `rate_plan_ids` est alors **refusé** (`400`) : un plan appartient
à un seul logement ; tous les plans de chaque logement sont écrits.

**Le moteur de prix de Node ne réécrit pas tes prix.** Chaque date écrite par cette
route est inscrite comme un réglage manuel (même registre que l'onglet Tarifs) : le
moteur la saute. La conciergerie la voit dans « Règles enregistrées » et peut la
réinitialiser — c'est alors le moteur qui reprend la main.

⚠️ C'est la portée la plus lourde de conséquences du catalogue. Seau de quota
dédié : **10 appels/minute par clé** en palier standard, 30 en partenaire.

### `GET /api/v1/calendar` — portées `availability:read` **et** `rates:read`

Disponibilités **et** tarifs de plusieurs logements, jour par jour, en un appel.
Même source que `/availability` et `/rates` (cache local + séjours de Node).

| Paramètre | Notes |
|---|---|
| `property_ids` | **obligatoire** — UUID séparés par des virgules, **50 au plus** |
| `from`, `to` | **obligatoires** — `AAAA-MM-JJ`, bornes incluses, **400 jours au plus** |
| `updated_since` | date-heure ISO — seulement les jours modifiés depuis |
| `include_rate_plans` | `true` = détail de chaque plan tarifaire, jour par jour |

```json
{
  "data": {
    "source": "cache", "from": "2026-12-01", "to": "2026-12-31", "updated_since": null,
    "notice": "…",
    "properties": [{
      "property_id": "uuid",
      "currency": "EUR",
      "primary_rate_plan_id": "uuid-channex",
      "rate_plans": [{ "id": "uuid-channex", "title": "Standard", "currency": "EUR", "primary": true }],
      "days": [{
        "date": "2026-12-01", "available": true, "booked": false, "blocked": false,
        "rate_plan_id": "uuid-channex", "price": 120.0, "price_pushed": 120.0, "currency": "EUR",
        "min_stay": 2, "min_stay_through": 2, "max_stay": null,
        "closed_to_arrival": false, "closed_to_departure": false, "stop_sell": false,
        "updated_at": "2026-11-30T23:10:00.000Z"
      }]
    }]
  }
}
```

- **Plusieurs plans tarifaires** : les valeurs du jour sont celles du plan
  **principal** (`primary_rate_plan_id` : plan marqué principal, sinon plan créé avec
  le logement, sinon le premier) — celui que les plateformes lisent par défaut et sur
  lequel `PUT /api/v1/rates` écrit en premier. `include_rate_plans=true` ajoute
  `rate_plans` dans chaque jour.
- `price` = valeur du plan principal dans le gestionnaire de canaux (=
  `price_on_channel` de `/rates`) ; `price_pushed` = ce que Node a envoyé. Pour
  décider d'un re-push, compare à `price_pushed`.
- `booked` = nuit vendue ; `blocked` = nuit fermée par un blocage (pas une vente).
  Une nuit occupée n'est jamais `available`. `available: null` = jour pas encore lu
  par le cache.
- `updated_since` : `updated_at` avance aussi quand le cache est simplement **relu**
  (cycle périodique). Le résultat est donc un **sur-ensemble** des jours réellement
  modifiés : compare les valeurs de ton côté.
- Pas de pagination : découpe par lots de 50 logements et fenêtres de 400 jours.

### Recette — gestionnaire de revenus (import initial + delta nocturne)

1. Import : `GET /api/v1/properties` (paginé) → `GET /api/v1/channels` →
   `GET /api/v1/reservations?property_ids=…&departure_from=…` (paginé) →
   `GET /api/v1/calendar?property_ids=…&from=…&to=…` par fenêtres de 400 jours.
2. Chaque nuit : mêmes appels avec `updated_since` = l'heure de **début** de la passe
   précédente (pas de fin : ce qui change pendant la passe est repris la fois suivante).
3. En continu : webhooks `reservation.created`, `reservation.updated`,
   `reservation.cancelled`, `availability.updated`, `rates.updated`,
   `property.updated` — puis relecture ciblée.
4. Pilotage : `PUT /api/v1/rates` avec `property_ids` et des plages groupées.

---

<a id="11-jobs"></a>
## 11. `/jobs` — suivi des écritures

### `GET /api/v1/jobs/{job_id}` — portée : aucune

```json
{
  "data": {
    "id": "uuid",
    "kind": "rates.push",
    "property_id": "uuid",
    "status": "processing",
    "operations": { "total": 6, "completed": 4, "failed": 0 },
    "summary": { "ranges": 2, "rate_plans": 3 },
    "created_at": "2026-09-01T10:00:00.000Z"
  }
}
```

| `status` | Sens |
|---|---|
| `pending` | En file, rien n'est encore parti |
| `processing` | Au moins une opération est passée |
| `completed` | **Toutes** les opérations ont abouti |
| `failed` | Au moins une a échoué définitivement |

🔴 **Un seul échec suffit à marquer `failed`.** Annoncer « terminé » alors qu'une
partie des dates n'est pas partie serait le pire des mensonges pour un calendrier.

**Rythme d'interrogation recommandé : toutes les 10 à 30 secondes.** Un travail
volumineux met plusieurs minutes — c'est normal, le drainage suit le rythme que le
débit partagé autorise. Interroger plus souvent ne l'accélère pas et consomme ton
quota de lecture.

---

<a id="12-cleaning"></a>
## 12. `/cleaning/missions` — ménage

### `GET /api/v1/cleaning/missions` — portée `cleaning:read`

Paramètres : `property_id`, `status`, `date_from`, `date_to`, `limit`, `cursor`.

```json
{
  "data": [{
    "id": "uuid",
    "property_id": "uuid",
    "stay_id": "uuid",
    "type": "menage",
    "date": "2026-09-15",
    "start_time": "11:00",
    "duration_min": 120,
    "status": "Assignée",
    "acceptance_status": "accepted",
    "assigned_to": "uuid",
    "notes": null,
    "completed_at": null
  }]
}
```

Valeurs de `status` : `Assignée`, `En cours`, `Terminée`, `Sans ménage`.
Valeurs d'`acceptance_status` : `null` (validation non requise), `pending`,
`accepted`, `unfilled`.

**Jamais exposés** : la rémunération de l'aide-ménagère et le jeton public de la
mission (c'est une clé d'authentification).

### `POST /api/v1/cleaning/missions` — portée `cleaning:write` · `Idempotency-Key` requis

```json
{
  "property_id": "uuid",
  "date": "2026-09-15",
  "type": "menage",
  "start_time": "11:00",
  "duration_min": 120,
  "notes": "Linge à changer"
}
```

`type` : `menage` (défaut), `linge`, `verification`, `autre`.

Une mission créée par l'API est marquée « date fixée à la main » : la synchronisation
automatique des séjours ne la redatera pas dans ton dos.

### `GET /api/v1/incidents` — portée `cleaning:read`

Lecture seule. Les incidents (voyageur, ménage, inspection, saisis à la main), du
plus récent au plus ancien.

Paramètres : `property_id`, `status` (`ouvert`, `en_cours`, `resolu`), `priority`
(`basse`, `moyenne`, `haute`), `source`, `inspection_id`, `created_from`,
`created_to` (AAAA-MM-JJ, bornes incluses), `limit`, `cursor`.

```json
{
  "data": [{
    "id": "uuid",
    "property_id": "uuid",
    "stay_id": null,
    "inspection_id": null,
    "linked_mission_id": null,
    "source": "voyageur",
    "category": "equipement",
    "title": "Incident voyageur — Équipement cassé",
    "description": "La bouilloire fait sauter le courant.",
    "priority": "moyenne",
    "status": "ouvert",
    "photos": ["https://…"],
    "comments": [{ "author": "Rémi", "body": "Vu, je passe demain", "at": "2026-11-06T10:00:00.000Z" }],
    "assignee_ids": ["uuid"],
    "reporter_name": "DB Systems Green",
    "menage_instructions": null,
    "resolved_at": null,
    "created_at": "2026-10-08T09:00:00.000Z",
    "updated_at": "2026-10-08T09:00:00.000Z"
  }]
}
```

---

<a id="13-invoices"></a>
## 13. `/invoices` — facturation

### `GET /api/v1/invoices` — portée `billing:read`

**Lecture seule, définitivement.** Commissions, TVA et taxe de séjour s'y croisent :
une erreur d'intégration deviendrait une erreur comptable chez un tiers.

Paramètres : `owner_id`, `property_id`, `status`, `date_from`, `date_to`, `limit`, `cursor`.

```json
{
  "data": [{
    "id": "uuid",
    "owner_id": "uuid",
    "property_id": "uuid",
    "number": "F2026-0042",
    "kind": "invoice",
    "type": "Relevé mensuel 2026-08",
    "invoice_date": "2026-09-01",
    "due_date": "2026-09-15",
    "amount": "1250.00",
    "status": "transmise",
    "sent_at": "2026-09-01T09:00:00.000Z",
    "paid_at": null
  }]
}
```

Workflow de `status` : `en attente` → `transmise` → `virement en cours` → `payé`.
`kind` : `invoice` ou `credit_note` (avoir).

⚠️ Une facture **groupée** (tous les logements d'un propriétaire) a
`property_id: null`. Si ta clé est restreinte à certains biens, ces factures ne te
sont pas renvoyées — elles porteraient sur des logements hors de ton périmètre.

---

<a id="14-messages"></a>
## 14. `/messages` — messagerie voyageur

### `GET /api/v1/messages` — portée `messages:read`

**Sans `conversation_id`** → liste les conversations :

```json
{ "data": [{ "id": "uuid", "property_id": "uuid", "reservation_id": "uuid",
             "channel": "airbnb", "guest_name": "Marie D.",
             "last_message_at": "2026-09-01T10:00:00.000Z" }] }
```

**Avec `conversation_id`** → liste les messages du fil :

```json
{ "data": [{ "id": "uuid", "conversation_id": "uuid", "direction": "inbound",
             "sender": "guest", "sender_name": null, "body": "Bonjour…",
             "attachments": [{ "id": "…", "filename": "contrat.pdf",
                               "type": "application/pdf",
                               "url": "https://…", "url_expires_in": 3600 }],
             "edited_at": null, "deleted_at": null,
             "created_at": "2026-09-01T10:00:00.000Z" }] }
```

`direction` : `inbound` (voyageur → toi) ou `outbound`. `sender` : `guest`,
`property`, `system`.

**`message_id`** — pour récupérer **un message précis**, celui dont l'identifiant
vient d'arriver par `message.received` ou `message.sent` :

```
GET /api/v1/messages?conversation_id=…&message_id=…
```

Toujours combiné à `conversation_id` : un `message_id` seul permettrait de sonder
l'existence d'un message d'un autre fil.

**`edited_at` / `deleted_at`.** Non nuls quand le message a bougé chez l'OTA
depuis sa réception — c'est la date à laquelle **nous** l'avons constaté, celle
qui dit depuis quand ta copie est périmée. Un message supprimé **reste rendu**,
marqué : le faire disparaître de la liste te priverait du moyen de le retirer de
ta propre copie. Écoute `message.updated` pour être prévenu sans sonder.

⚠️ Les **réactions** et les **statuts de lecture** ne sont pas couverts : le
gestionnaire de canaux ne nous les transmet pas. Ce n'est pas un choix de notre
part, l'information n'existe pas de notre côté.

**Pièces jointes.** Le lien est **signé à la lecture**, valable
`url_expires_in` secondes (1 h) — il ne demande **pas** ton jeton OAuth, mais il
expire. Ne le stocke pas : rappelle l'endpoint pour en obtenir un nouveau.
`url_expires_in: null` signale un lien historique non signé, dont on ne connaît
pas l'échéance et qui peut être mort.

### `POST /api/v1/messages` — portée `messages:send` · `Idempotency-Key` requis

```json
{ "conversation_id": "uuid", "body": "Bonjour, votre code d'accès est le 4242." }
```

🔴 **Le message ne part pas « de Node ».** Il transite par Airbnb ou Booking, qui
appliquent leurs propres règles anti-spam. Un envoi excessif fait suspendre **le
compte OTA de ton client**, pas celui de Node.

La clé d'idempotence sert deux fois : au poste de garde et à la déduplication de
l'envoi. Un rejeu enverrait un doublon au voyageur, qui le lit.

`502 bad_gateway` = le canal a refusé le message. Ne prétends jamais qu'il est parti.

---

<a id="15-webhooks"></a>
## 15. `/webhooks` — événements

### Pourquoi les utiliser

Sans webhook, savoir qu'une réservation est arrivée impose d'interroger l'API en
boucle. Multiplié par tes clients, c'est une charge permanente — et tu ne seras
jamais prévenu plus tôt qu'avec un webhook.

### `GET /api/v1/webhooks` — portée `webhooks:manage`

Renvoie tes points de réception **et le catalogue complet des événements** avec la
portée que chacun exige. Tu peux construire ton écran d'abonnement à partir de cette
seule réponse.

### `POST /api/v1/webhooks` — portée `webhooks:manage` · `Idempotency-Key` requis

```json
{
  "url": "https://mon-service.fr/hooks/node",
  "events": ["reservation.created", "reservation.updated", "reservation.cancelled"],
  "description": "Synchro CRM"
}
```

```json
{ "data": { "id": "uuid", "url": "…", "events": [...], "enabled": true,
            "secret_prefix": "node_whsec_AbC",
            "secret": "node_whsec_xxxxxxxxxxxxxxxxxxxx" } }
```

🔴 **Le `secret` n'est affiché qu'une seule fois.** Il est chiffré en base ; Node ne
peut pas te le réafficher. Perdu → recrée le point de réception.

Contraintes : URL **HTTPS**, hôte **public** (les adresses privées sont refusées).
S'abonner à un événement exige la **portée de lecture correspondante** — sinon
`webhooks:manage` serait une porte dérobée vers des données qu'on n'a pas le droit
de lire.

### Les 20 événements

| Type | Quand | Portée requise |
|---|---|---|
| `reservation.created` | Une réservation entre, tout canal | `reservations:read` |
| `reservation.updated` | Dates, montants ou occupation changent | `reservations:read` |
| `reservation.cancelled` | Annulation | `reservations:read` |
| `stay.checked_in` | Le voyageur valide son arrivée | `reservations:read` |
| `stay.checked_out` | Le voyageur annonce son départ | `reservations:read` |
| `message.received` | Un voyageur écrit | `messages:read` |
| `message.sent` | Un message part vers le voyageur — conciergerie, équipe, ou écrit depuis l'extranet de l'OTA | `messages:read` |
| `message.updated` | Un message a été **modifié** ou **supprimé** chez l'OTA (`change`: `edited` \| `deleted`) | `messages:read` |
| `cleaning.mission.assigned` | Mission attribuée | `cleaning:read` |
| `cleaning.mission.completed` | Mission terminée | `cleaning:read` |
| `invoice.issued` | Facture émise (numérotée + envoyée) | `billing:read` |
| `invoice.paid` | Facture payée | `billing:read` |
| `property.created` | Logement créé | `properties:read` |
| `property.updated` | Logement modifié (avec `changed_fields`) | `properties:read` |
| `property.deleted` | Logement supprimé (corps : `property_id` seul) | `properties:read` |
| `availability.updated` | Dates ouvertes, fermées ou réservées. Corps : `property_id`, `start_date`, `end_date`. Regroupé par logement : envoyé ~30 s après le dernier changement (5 min max), relire `/v1/availability` sur la plage | `availability:read` |
| `rates.updated` | Prix ou règles de séjour modifiés. Même corps et même regroupement, relire `/v1/rates` sur la plage | `rates:read` |
| `incident.reported` | Incident signalé (4 origines, voir `source`) | `cleaning:read` |
| `validation.pending` | Un acte régulé attend une validation | `validations:read` |
| `validation.decided` | Acte approuvé ou refusé (voir `decision`) | `validations:read` |
| `validation.enacted` | Acte appliqué, et poussé vers l'OTA le cas échéant | `validations:read` |
| `validation.expired` | Échéance atteinte sans décision | `validations:read` |
| `validation.cancelled` | La conciergerie a retiré sa demande avant la décision | `validations:read` |

### Forme d'un événement

```
POST https://mon-service.fr/hooks/node
Node-Signature: t=1756713600,v1=5257a869e7ec…
Node-Event-Id: 8f14e45f-ea1c-4b2f-9f8a-1d3c2b7e5a90
Node-Event-Type: reservation.created
Node-Delivery-Attempt: 1
Content-Type: application/json
```

```json
{
  "id": "8f14e45f-…",
  "type": "reservation.created",
  "created_at": "2026-09-01T10:00:00.000Z",
  "api_version": "v1",
  "data": {
    "reservation_id": "uuid", "property_id": "uuid", "status": "confirmed",
    "channel": "Airbnb", "arrival_date": "2026-09-12", "departure_date": "2026-09-15",
    "ota_reservation_code": "HMABCDEF"
  }
}
```

⚠️ **Les événements ne contiennent jamais le corps d'un message, la description d'un
incident, ni l'e-mail du voyageur.** Les points de réception ne sont pas tous au même
niveau de protection. Va lire ces contenus sur l'API, sous la portée qui convient.

### Vérifier la signature — À FAIRE, TOUJOURS

Ton URL est publique : elle finit dans des logs, des captures, des tickets. La
signature est la seule chose qui distingue un événement de Node d'un faux.

```js
const crypto = require("crypto");

function verifyNodeWebhook(rawBody, header, secret, toleranceS = 300) {
  const parts = Object.fromEntries(header.split(",").map(kv => kv.split("=", 2)));
  const t = parseInt(parts.t, 10);
  if (!Number.isFinite(t) || !parts.v1) return false;
  // L'horodatage est DANS la valeur signée : sans cette vérification, un
  // événement légitime capté une fois pourrait être rejoué indéfiniment.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceS) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

```python
import hmac, hashlib, time

def verify_node_webhook(raw_body: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts.get("t", 0))
    if abs(int(time.time()) - t) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

🔴 **Signe le corps BRUT**, avant tout parsing. Un `JSON.parse` puis `JSON.stringify`
réordonne les clés et invalide la signature.

### Livraison

- **Au moins une livraison, jamais exactement une.** Un envoi peut aboutir chez toi
  et se perdre au retour. **Dédoublonne sur `Node-Event-Id`**, stable entre les
  tentatives.
- **Réponds `2xx` en moins de 10 secondes.** Fais ton traitement en tâche de fond ;
  Node ne lit pas le corps de ta réponse.
- **Reprises** : 1 min, 5 min, 30 min, 2 h, 6 h, 24 h. Après six échecs → `dead`.
- **20 échecs consécutifs coupent le point de réception.** Aucun e-mail n'est envoyé : surveillez `disabled_at` sur `GET /api/v1/webhooks/{id}`, puis réactivez avec `PATCH` et `{"enabled": true}`.
  Une réussite remet le compteur à zéro.

### `GET /api/v1/webhooks/{id}` — diagnostic

Renvoie l'état et les **20 dernières livraisons** avec code de réponse, erreur,
nombre de tentatives et prochaine reprise. **C'est la première chose à regarder**
avant d'ouvrir un ticket « je ne reçois rien ».

### `PATCH /api/v1/webhooks/{id}` — modifier un abonnement

Ajoute ou retire des événements **sans changer de secret**, modifie la description,
ou réactive un point de réception coupé.

```
PATCH /api/v1/webhooks/{id}
{ "events": ["reservation.created", "availability.updated", "rates.updated"] }
```

- `events` **remplace la liste entière**. Pour ajouter un événement, renvoyez
  la liste actuelle (lue sur `GET /api/v1/webhooks/{id}`) plus le nouveau.
- Chaque événement exige sa portée de lecture, comme à la création.
- `enabled: true` réactive un point de réception coupé après 20 échecs et remet
  le compteur à zéro. `enabled: false` le suspend.
- `description` : texte libre, `null` pour l'effacer.
- L'URL et le secret ne sont pas modifiables. Pour en changer, créez un nouveau
  point de réception.
- Un jeton ne peut modifier que les points de réception **qu'il a créés** (ou ceux
  créés depuis Node). Sinon : `403 FORBIDDEN`.

### `DELETE /api/v1/webhooks/{id}`

Retire le point de réception. Seul `DELETE` de toute la v1 : il ne détruit aucune
donnée métier, seulement un abonnement.

---

<a id="16-prospection"></a>
## 16. `/prospection` — CRM propriétaires

Le pipeline commercial : des dossiers propriétaires, de la première demande
jusqu'au mandat signé. C'est par ici qu'on fait remonter les leads d'un site web
ou d'une campagne publicitaire.

### ⛔ `stage` ne s'écrit NULLE PART

C'est la règle qui commande tout le reste de cette section, et celle qu'un
assistant doit retenir avant d'écrire une ligne.

L'étape du pipeline est **calculée** à partir de ce qui est réellement
renseigné : un rendez-vous existe-t-il, la qualification est-elle faite, la
proposition a-t-elle été envoyée. Elle n'est **jamais** un champ qu'on pose.

Envoyer `stage` dans un `POST` ou un `PATCH` ne produit **aucune erreur** : le
champ est simplement ignoré. C'est pourquoi il faut le savoir en lisant. Pour
faire avancer un dossier, on **demande** : `POST /prospection/leads/{id}/advance`.

Raison : poser l'étape à la main ferait apparaître un dossier en « proposition
envoyée » sans qu'aucune proposition existe. Tous les indicateurs de conversion
deviendraient faux, et rien ne le signalerait.

### `GET /api/v1/prospection/leads` — portée `prospection:read`

Filtres : `stage`, `source`, `assigned_to`, `search` (nom, e-mail, téléphone,
ville), `updated_since` (ISO 8601) pour une synchronisation incrémentale.
Pagination par curseur, comme partout ailleurs.

```bash
curl "https://app.nodegroupe.com/api/v1/prospection/leads?updated_since=2026-10-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer node_sk_..."
```

```json
{
  "data": [{
    "id": "7f1c…",
    "source": "site-web",
    "stage": "qualifie",
    "name": "Marie Dupont",
    "email": "marie@exemple.fr",
    "phone": "+33612345678",
    "city": "Biarritz",
    "property": { "type": "appartement", "capacity": 4, "bedrooms": 2,
                  "surface_m2": 48, "address": null, "notes": null },
    "reading": { "eligibility": "vert", "potential": "fort", "maturity": "chaud" },
    "qualification": { "…": "les dix questions" },
    "rdv_at": "2026-10-12T14:00:00Z",
    "created_at": "2026-10-05T09:12:00Z"
  }],
  "has_more": false,
  "next_cursor": null
}
```

**Jamais exposés** : l'estimation de revenus, l'offre et ses scénarios de
commission, le jeton du propriétaire, la timeline interne. Le premier est
l'argument commercial de la conciergerie ; le sortir le rendrait récupérable
par un partenaire, donc par un concurrent.

### `POST /api/v1/prospection/leads` — portée `prospection:write` · `Idempotency-Key` requis

Crée un dossier. **C'est la porte d'entrée des leads.**

Au moins un `email` **ou** un `phone` est exigé : un dossier que personne ne
peut rappeler n'est pas un lead, c'est une ligne morte dans le pipeline.

```bash
curl -X POST https://app.nodegroupe.com/api/v1/prospection/leads \
  -H "Authorization: Bearer node_sk_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: formulaire-2026-10-05-abc123" \
  -d '{
    "name": "Marie Dupont",
    "email": "marie@exemple.fr",
    "phone": "+33 6 12 34 56 78",
    "city": "Biarritz",
    "property_type": "appartement",
    "bedrooms": 2,
    "surface_m2": 48,
    "source": "site-web",
    "utm": { "utm_source": "google", "utm_campaign": "conciergerie-biarritz" },
    "consents": [{
      "kind": "rappel",
      "granted": true,
      "text": "Je souhaite être rappelé au sujet de ma demande."
    }]
  }'
```

**Dédoublonnage — lire ceci avant de traiter la réponse.** `Idempotency-Key`
protège du rejeu *technique* (double clic, reprise d'une automatisation). Elle
ne protège pas du même propriétaire qui remplit le formulaire deux fois à deux
heures d'écart, cas courant sur une campagne publicitaire.

Si le même e-mail ou le même téléphone existe déjà sur **30 jours**, rien n'est
créé : la réponse est **`200`** (et non `201`) et contient le dossier
**existant**, avec `"deduplicated": true`. Ce n'est pas un échec — l'appelant
peut l'enrichir par un `PATCH`.

Les numéros sont rapprochés sur leurs **9 derniers chiffres** : `+33 6 12 34 56 78`
et `06 12 34 56 78` sont le même numéro. Comparer les chaînes brutes créerait
des doublons certains.

**Consentements.** `text` doit contenir la phrase **exacte** affichée au
visiteur ce jour-là. Sans elle, on sait qu'il a coché, pas à quoi il a dit oui —
et une preuve sans la formulation ne vaut rien. L'absence de case n'est pas un
refus : « il n'a pas coché » et « on ne lui a jamais demandé » se distinguent,
d'où un tableau facultatif plutôt qu'un booléen qui vaudrait `false` par défaut.

**`source`** est figée 24 h après la création. Au-delà, elle ne change plus :
sinon « coût par logement signé, par source » se réécrirait après coup et ne
voudrait plus rien dire.

### `GET /api/v1/prospection/leads/{id}` — portée `prospection:read`

Le dossier, même forme que dans la liste.

### `PATCH /api/v1/prospection/leads/{id}` — portée `prospection:write`

Compléter des **faits** : coordonnées, caractéristiques du bien, date de
rendez-vous, affectation. L'étape s'en déduit.

`qualification` est **fusionnée**, jamais remplacée : envoyer une seule des dix
réponses n'efface pas les neuf autres. C'est l'erreur qu'on ne verrait qu'au
moment où le dossier redescend d'étape, bien plus tard.

```bash
curl -X PATCH https://app.nodegroupe.com/api/v1/prospection/leads/{id} \
  -H "Authorization: Bearer node_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "bedrooms": 3, "rdv_at": "2026-10-12T14:00:00Z", "rdv_type": "visite" }'
```

### `POST /api/v1/prospection/leads/{id}/advance` — portée `prospection:write`

Demande une avancée. Le dossier monte jusqu'à l'étape maximale que les faits
renseignés permettent d'atteindre. **C'est l'unique chemin d'écriture de
l'étape.**

```json
{
  "moved": false,
  "from": "qualifie",
  "to": "qualifie",
  "blocking": [
    { "key": "rdv_planifie", "level": "bloquant",
      "label": "Aucun rendez-vous n'est fixé", "waivable": false }
  ]
}
```

Quand `moved` vaut `false`, `blocking` dit **pourquoi**, verrou par verrou, avec
un libellé lisible par un commercial. C'est ce qui rend le parcours délégable à
un outil externe sans qu'il puisse inventer une progression.

### Recette — faire remonter les leads d'un site web

1. Créer une clé qui ne coche **que** `prospection:write`. Elle crée, elle ne
   lit pas : si elle fuite, elle ne donne accès à **aucun** dossier existant.
2. ⛔ **Ne jamais mettre la clé dans la page.** Tout ce qui part dans le
   navigateur est lisible par le visiteur : une clé posée dans le JavaScript
   d'un formulaire est publique dès la première visite. Le formulaire envoie à
   **votre** serveur, qui relaie à Node.
3. Utiliser l'identifiant de la soumission comme `Idempotency-Key`.

### Recette — faire remonter les leads d'une campagne publicitaire

Les formulaires Meta (Facebook, Instagram) et Google Ads ne savent pas appeler
une API protégée : il faut un relais (Zapier, Make, n8n).

1. Déclencheur « nouveau lead de formulaire » sur le compte publicitaire.
2. Action « requête HTTP » vers `POST /api/v1/prospection/leads`, clé dans
   l'en-tête `Authorization`.
3. Mettre le nom de la campagne dans `source` (ou dans `utm`) — c'est ce qui
   permettra de calculer un coût par logement signé, par campagne.
4. Utiliser l'identifiant du lead fourni par la plateforme comme
   `Idempotency-Key` : ces connecteurs rejouent volontiers en cas d'erreur
   réseau.

---

<a id="17-recettes"></a>
## 17. `/validations` — conformité carte G

En France, une conciergerie qui n'est pas titulaire de la **carte professionnelle G**
(loi Hoguet) ne peut pas décider seule d'un prix, d'un calendrier ou d'une
annonce. Node retient alors l'acte et le soumet à un décideur : le
**propriétaire** par défaut, ou un **tiers titulaire de la carte** mandaté par
lui. Ces routes sont la file de ce tiers.

### Ce que ces routes exposent, et ce qu'elles n'exposent pas

**Seuls les actes routés vers un validateur** sortent ici. Les actes qui
attendent le propriétaire ne sont pas l'affaire du validateur, et les exposer
serait lui donner à lire des décisions qu'il n'a pas à prendre. Conséquence à
connaître : un acte dont le **mandat a expiré** repart au propriétaire et
**disparaît** donc de la liste. C'est voulu, ce n'est pas une perte.

**`changes` est la seule chose à lire pour décider.** C'est la liste exacte des
champs qui bougeront, et rien d'autre ne sera appliqué à l'approbation. Le
`payload_after` brut n'est jamais exposé : il contient le corps de la
modification d'origine, donc des champs qui ne décrivent pas la décision.

### `GET /api/v1/validations` — portée `validations:read`

Tri par **échéance croissante** : l'ordre dans lequel les actes doivent être
traités, pas celui dans lequel ils sont arrivés. Les actes sans échéance
passent en dernier.

| Paramètre | Valeurs |
|---|---|
| `status` | `pending` · `approved` · `refused` · `enacted` · `cancelled` · `expired` |
| `action_type` | `price` · `pricelabs` · `availability` · `listing` · `mapping` · `booking` |
| `property_id` | UUID |
| `due_before` | ISO 8601 — « ce qui expire avant X », le filtre des urgences |
| `limit` / `cursor` | pagination par curseur, comme partout ailleurs |

### `GET /api/v1/validations/{id}` — portée `validations:read`

```json
{
  "data": {
    "id": "…",
    "action_type": "price",
    "status": "pending",
    "summary": "Prix / nuit 95 € → 110 €",
    "changes": [{ "label": "Prix / nuit", "from": "95 €", "to": "110 €" }],
    "property":  { "id": "…", "name": "Studio Marignan" },
    "concierge": { "id": "…", "name": "…" },
    "decider_kind": "validator",
    "mandate_id": "…",
    "fingerprint": "9f2c1b…",
    "proposed_at": "2026-10-11T14:02:11Z",
    "expires_at": "2026-10-18T14:02:11Z",
    "decision": null
  }
}
```

Un acte **inexistant** et un acte **hors de votre périmètre** rendent la même
réponse `not_found`. C'est délibéré : distinguer les deux confirmerait
l'existence de l'acte d'un tiers.

#### `fingerprint` — le sceau de ce qu'on vous soumet

SHA-256 de la charge utile canonique de l'acte : les champs qui seront
appliqués **et** la liste `changes` qui vous est montrée. Posé à la
proposition, revérifié par Node **avant** toute application.

Il vous est exposé pour une raison précise : c'est **votre** carte
professionnelle qui est engagée. Notez-le de votre côté au moment de la
décision, et vous pourrez vérifier plus tard que l'acte qu'on vous montre est
bien celui que vous avez tranché — sans avoir à nous croire sur parole.

Il est `null` sur les actes antérieurs à sa mise en place : rien ne les a
scellés, et prétendre le contraire serait pire que l'absence.

#### `decision.not_enacted_reason` — approuvé, mais pas appliqué

Non `null` quand un acte que vous avez **approuvé** n'a délibérément pas été
appliqué : sa charge utile ne correspond plus à son sceau, ou elle porte un
champ étranger à sa famille d'actes. Node refuse alors d'appliquer plutôt que
d'appliquer autre chose que ce qui vous a été montré.

L'acte reste `approved`, et c'est voulu : il est **visible et bloqué**, pas
silencieusement terminé. Il n'y a **aucune reprise automatique** — une charge
utile qui a dérivé demande un œil, pas un nouvel essai. Ne redécidez pas
l'acte : signalez-le à la conciergerie, qui reproposera.

### `POST /api/v1/validations/{id}/decision` — portée `validations:decide` · `Idempotency-Key` requis

```json
{
  "decision": "approved",
  "comment": "Dans la fourchette convenue.",
  "decided_by": "hugo@exemple.fr"
}
```

`decided_by` est **votre** référence : elle est stockée telle quelle et Node ne
prétend pas l'avoir vérifiée. Elle sert à votre traçabilité, pas à
l'autorisation — celle-ci tient au jeton et au périmètre.

La réponse ne vaut succès qu'après écriture **et** application :
`{"id": "…", "status": "enacted", "enacted": true}`. Un refus rend
`status: "refused"`. Un échec de poussée vers le canal remonte en
`bad_gateway` — l'acte reste **décidé**, ne le redécidez pas.

### Les échéances, et pourquoi elles comptent

| Acte | Échéance | Pourquoi |
|---|---|---|
| `booking` | **20 heures** | Une demande de réservation vit 24 h chez le canal. On expire **avant** lui, sinon Node afficherait « en attente » un acte que l'OTA a déjà tranché. |
| tous les autres | 7 jours | |

Un acte expiré **n'est plus décidable** : la décision rend `conflict`. La
conciergerie doit le reproposer, ce qui crée une nouvelle ligne au journal.

### Retrait par la conciergerie

Une conciergerie peut **retirer** une demande tant qu'elle est `pending` :
l'acte passe en `cancelled` et `validation.cancelled` part vers vos
intégrations. Sans cet événement, l'acte disparaîtrait de votre file sans
explication — abonnez-vous-y au même titre qu'à `validation.pending`.

Rien n'est **supprimé**. La ligne reste au journal, horodatée, avec l'auteur du
retrait : un journal qu'on peut vider n'est pas opposable, et c'est précisément
ce sur quoi vous engagez votre carte. Un acte déjà tranché ne peut plus être
retiré.

### Pièges

**Ne sondez pas cette route en boucle.** Une demande de réservation vit
24 heures : abonnez-vous à `validation.pending`. Le sondage n'ajoute que du
retard, et `due_before` existe pour rattraper après une panne, pas pour
remplacer les événements.

**Vérifiez la signature de chaque événement** (voir §16) et **dédoublonnez sur
`id`** : une livraison peut arriver deux fois, c'est le prix de la reprise sur
échec.

**Aucun validateur actif ⇒ liste vide.** Pas « tout le parc ». Si vous recevez
une liste vide en étant sûr du contraire, vérifiez d'abord que la conciergerie
a bien activé la validation déléguée et signé le mandat.

### `POST /api/v1/catalogue/propositions` — portée `catalogue:propose` · `Idempotency-Key` requis
**Espaces marque blanche uniquement.** Propose au catalogue Node une extension de
l'espace de la conciergerie (`extensions/<cle>/`). Node lit LUI-MÊME le code sur
la branche `main` du dépôt de l'espace : on n'envoie pas de fichiers, seulement
la clé. Le code est contrôlé (mêmes règles que la CI de l'espace : pas de
secrets, pas de réseau hors API, pas de shell, pas de HTML injecté) puis relu par
Node avant toute publication.

```json
{ "extension": "fins-de-mandat", "titre": "Fins de mandat", "description": "Les mandats qui arrivent à échéance dans les 90 jours." }
```

Réponse : `201` (nouvelle proposition) ou `200` (nouvelle version).
`{ "data": { "id": "uuid", "cle": "catalogue.fins-de-mandat", "statut": "propose", "version": 1, "nouvelle_version_en_attente": false, "portees": ["owners:read"] } }`

| Erreur | Quand |
|---|---|
| `409 conflict` | Pas d'espace marque blanche actif |
| `404 not_found` | Extension absente de `main` |
| `422 unprocessable` | Code non conforme aux règles des extensions |

Un module **déjà publié** n'est jamais remplacé à la volée : la nouvelle version
attend sa revue, et c'est la version relue qui reste distribuée.

### Données de l'espace — portées `espace.donnees:read` / `espace.donnees:write`
Le stockage propre des onglets d'un espace : articles, comptages, listes,
formulaires… Des documents JSON libres, rangés par **extension** (la clé de
l'onglet) puis par **collection** (`articles`, `comptages`…). Ils n'appartiennent
qu'à la conciergerie du jeton et ne touchent **jamais** aux réservations, aux
prix ni aux plateformes. Quotas : 100 000 documents, 200 collections, 64 Ko par
document.

#### `GET /api/v1/espace/donnees` — inventaire
Les collections par extension, leur nombre de documents, les totaux et les quotas.
C'est le point de départ d'un export complet.

#### `GET /api/v1/espace/donnees/{collection}?extension=stocks` — lister
Tri du plus récent au plus ancien, pagination `limit` / `cursor`. Tout autre
paramètre filtre sur une clé de premier niveau : `?extension=stocks&categorie=salle-de-bain`
(égalité en texte, 5 filtres au plus).

#### `POST /api/v1/espace/donnees/{collection}?extension=stocks` — créer · `Idempotency-Key` requis
```json
{ "data": { "nom": "Savon", "unite": "pièce", "seuil": 10 } }
```
Réponse `201` : `{ "data": { "id": "uuid", "extension": "stocks", "collection": "articles", "data": { … }, "created_at": "…", "updated_at": "…" } }`.
`422` si un quota est atteint.

#### `GET /api/v1/espace/donnees/{collection}/{id}?extension=stocks` — lire
#### `PATCH /api/v1/espace/donnees/{collection}/{id}?extension=stocks` — modifier
Fusion superficielle : `{ "data": { "seuil": 15 } }` ; une clé à `null` est retirée.
#### `DELETE /api/v1/espace/donnees/{collection}/{id}?extension=stocks` — supprimer

Un document d'une autre conciergerie, extension ou collection répond `404`.

### Services externes — portée `espace.secrets:read`
Le coffre à secrets de l'espace : les clés API que la conciergerie confie à son
espace (compta, SMS, tableur…), saisies dans Node (Réglages → Espace) et jamais
réaffichées en clair à l'écran.

#### `GET /api/v1/espace/secrets/{nom}` — lire un secret
`{ "data": { "nom": "CLE_API_PENNYLANE", "valeur": "…" } }` — à lire côté SERVEUR
uniquement. Absent → `404`.

#### `GET /api/v1/espace/domaines` — domaines autorisés
`{ "data": { "autorises": ["api.pennylane.com"], "en_attente": ["sheets.googleapis.com"] } }`.
Un domaine est utilisable s'il est **déclaré** par le code de l'espace (`domaines`
d'une extension ou d'une automatisation) **et accepté** par la conciergerie.

### Automatisations — portée `espace.automatisations:write`
#### `GET /api/v1/espace/automatisations` — état et journal
#### `POST /api/v1/espace/automatisations/journal` — inscrire une exécution
```json
{ "automatisation": "relance-menage", "declencheur": "reservation.created", "statut": "succes", "duree_ms": 840 }
```
`statut` : `succes` | `echec` | `delai` | `ignoree`. Après 5 échecs consécutifs, l'automatisation
est **désactivée** (`desactivee: true`) ; la conciergerie la réactive depuis Node.

### E-mails — portée `envois.email:send`
#### `POST /api/v1/envois/email` — envoyer · `Idempotency-Key` requis
```json
{ "a": "proprietaire@exemple.fr", "sujet": "Relevé d'octobre", "texte": "Bonjour…" }
```
Expéditeur « <Conciergerie> via Node », réponses vers l'adresse de la conciergerie,
mention d'origine en pied. `html` facultatif (assaini : pas de script ni d'image
distante). Quota : 200 par 24 heures (`409` au-delà). Réponse `202`.
**WhatsApp** : pas d'envoi libre (WhatsApp n'autorise hors conversation que des
modèles approuvés) — pour écrire à un voyageur, `POST /api/v1/messages`.

## 18. Recettes complètes

### A. Synchroniser les réservations dans un outil tiers

```
1. POST /v1/webhooks  { events: ["reservation.created","reservation.updated","reservation.cancelled"] }
   → temps réel, c'est la voie principale.

2. Un rattrapage quotidien pour ce que le webhook aurait manqué :
   GET /v1/reservations?updated_since=<hier ISO>&limit=200
   puis boucler sur next_cursor.

3. Dédoublonner sur reservation_id côté outil.
```

### B. Appliquer une grille tarifaire saisonnière

```jsonc
// ✅ UN appel, plusieurs plages
PUT /v1/rates
{ "property_id": "…", "ranges": [
    { "from": "2027-07-01", "to": "2027-08-31", "price": "180.00", "min_stay": 5 },
    { "from": "2027-09-01", "to": "2027-10-31", "price": "120.00", "min_stay": 2 },
    { "from": "2027-11-01", "to": "2027-12-19", "price": "95.00" } ] }

// ❌ JAMAIS ça : quota épuisé en secondes, envois rejetés
for (const date of dates) PUT /v1/rates { ranges: [{ from: date, to: date, ... }] }
```

### C. Attendre la fin d'une écriture

```js
async function waitForJob(jobId, token) {
  for (let i = 0; i < 60; i++) {                       // ~10 minutes de patience
    const r = await fetch(`https://app.nodegroupe.com/api/v1/jobs/${jobId}`,
                          { headers: { Authorization: `Bearer ${token}` } });
    const { data } = await r.json();
    if (data.status === "completed") return data;
    if (data.status === "failed") throw new Error(`Travail échoué : ${data.operations.failed} opération(s)`);
    // 10 s : le drainage suit le débit partagé, interroger plus souvent
    // ne l'accélère pas et consomme ton quota de lecture.
    await new Promise(res => setTimeout(res, 10_000));
  }
  throw new Error("Toujours en cours après 10 minutes");
}
```

### D. Un client HTTP qui respecte le quota

```js
async function callNode(path, token, opts = {}) {
  const res = await fetch(`https://app.nodegroupe.com/api/v1${path}`, {
    ...opts,
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...opts.headers },
  });

  if (res.status === 429) {
    // Retry-After est le temps RÉELLEMENT restant. Le respecter est la seule
    // façon de sortir du 429 ; réessayer tout de suite le prolonge.
    const wait = parseInt(res.headers.get("Retry-After") ?? "60", 10);
    await new Promise(r => setTimeout(r, wait * 1000));
    return callNode(path, token, opts);
  }

  // Anticiper plutôt que subir : ralentir avant d'atteindre zéro.
  if (parseInt(res.headers.get("RateLimit-Remaining") ?? "99", 10) < 5) {
    await new Promise(r => setTimeout(r, 2000));
  }

  if (!res.ok) {
    const { error } = await res.json().catch(() => ({ error: {} }));
    throw new Error(`${error.code ?? res.status} — ${error.message ?? ""} [${res.headers.get("Node-Request-Id")}]`);
  }
  return res.json();
}
```

### E. Parcourir toutes les pages

```js
async function* paginate(path, token) {
  let cursor = null;
  do {
    const sep = path.includes("?") ? "&" : "?";
    const url = `${path}${sep}limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
    const page = await callNode(url, token);
    yield* page.data;
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
}
```

### F. Fermer des dates pour travaux, proprement

```js
// 1. Vérifier que le bien est distribuable
const cal = await callNode(`/availability?property_id=${id}&from=${from}&to=${to}`, token);

// 2. Fermer (asynchrone)
const { data } = await callNode("/availability", token, {
  method: "PUT",
  body: JSON.stringify({ property_id: id, ranges: [{ from, to, available: false }] }),
});

// 3. Attendre la confirmation réelle avant d'annoncer quoi que ce soit au client
await waitForJob(data.job_id, token);
```

---

<a id="18-limites"></a>
## 19. Ce que l'API ne fait pas — et pourquoi

| Capacité | Raison |
|---|---|
| Supprimer une réservation ou un logement | La suppression lève les dates chez les plateformes sans trace. Utilise `cancelled` / l'archivage. |
| Écrire sur la facturation ou les paiements | Commissions, TVA, taxe de séjour : une erreur deviendrait une erreur comptable. Lecture seule. |
| Écrire prix/dispos en synchrone | Le débit vers les plateformes est partagé entre tous les clients de Node. |
| Lire les identifiants OTA du compte | Clés du gestionnaire de canaux, jetons Airbnb/Vrbo/Tuya/Nuki. Personne n'y accède. |
| Accéder à un autre compte | Aucune portée d'administration, aucun accès inter-comptes. |
| Relayer l'API du gestionnaire de canaux | Node est un PMS certifié, pas un revendeur de connectivité. |
| Utiliser une clé depuis un navigateur | Un secret dans une page est public. Pas de CORS ouvert. |

---

<a id="19-pièges"></a>
## 20. Pièges connus — la liste à relire avant de livrer

1. **Traiter un `202` comme un succès final.** Ce n'est pas fait tant que le travail
   n'est pas `completed`.
2. **Comparer `price_on_channel` à ton prix voulu.** Compare à `price_pushed`.
3. **Boucler date par date** sur `/rates` ou `/availability`. Groupe en plages.
4. **Envoyer une plage commençant hier.** Refusée en entier, y compris la partie
   future. Découpe à partir d'aujourd'hui.
5. **Oublier `Idempotency-Key`** sur un `POST` → `400`. Et la réutiliser avec un
   corps différent → `422`.
6. **Convertir les montants en flottant.** Ce sont des chaînes décimales, garde-les
   telles quelles ou passe par un type décimal.
7. **Ne pas dédoublonner les webhooks** sur `Node-Event-Id`. Tu compteras des
   réservations deux fois.
8. **Parser le JSON du webhook avant de vérifier la signature.** Signe le corps brut.
9. **Ignorer `Retry-After`** sur un `429`. Tu prolonges ton propre blocage.
10. **Interroger `/jobs` toutes les secondes.** Toutes les 10 à 30 s suffit et
    n'épuise pas ton quota de lecture.
11. **Supposer qu'un logement créé est distribuable.** Vérifie l'absence de `422`
    sur `/rates`.
12. **Utiliser `updated_since` comme détecteur temps réel.** C'est un rattrapage.

---

<a id="20-oauth"></a>
## 21. Applications partenaires (OAuth 2.0)

Uniquement pour un éditeur tiers qui branche **plusieurs** conciergeries. Pour une
intégration mono-compte, une clé personnelle suffit — n'utilise pas OAuth.

Flux : **code d'autorisation + PKCE**. `implicit` et `password` sont refusés (le
premier expose le jeton dans l'URL, le second te ferait manipuler les identifiants
de ton client).

```
1. code_verifier = 43 à 128 caractères aléatoires
   code_challenge = base64url(sha256(code_verifier))

2. Rediriger le client vers :
   https://app.nodegroupe.com/oauth/consent
     ?client_id=…&redirect_uri=…&response_type=code
     &scope=properties:read reservations:read
     &state=<anti-CSRF>&code_challenge=…&code_challenge_method=S256

3. Retour sur redirect_uri?code=…&state=…   → VÉRIFIER que `state` est le tien.

4. POST https://app.nodegroupe.com/api/oauth/token
   Content-Type: application/x-www-form-urlencoded
   grant_type=authorization_code&code=…&redirect_uri=…
   &code_verifier=…&client_id=…&client_secret=…
```

```json
{ "access_token": "node_at_…", "token_type": "Bearer", "expires_in": 7200,
  "refresh_token": "…", "scope": "properties:read reservations:read" }
```

Renouvellement : `grant_type=refresh_token`. **Le jeton de rafraîchissement tourne à
chaque usage** — remplace-le, l'ancien cesse de fonctionner.

⚠️ **La conciergerie peut retirer ton accès à tout moment, avec effet immédiat.**
Les jetons déjà émis cessent de fonctionner sans attendre leur expiration. Traite un
`401` comme une révocation possible, pas seulement comme une expiration.

Les portées sensibles exigent un **accord de sous-traitance signé** avant que ton
application puisse les demander.

---

## Support

Ouvre un ticket depuis Node en citant :
1. le `Node-Request-Id` de la réponse (ou le `Node-Event-Id` pour un webhook) ;
2. l'horodatage ;
3. le début de ta clé (`node_sk_AbC1…`) — **jamais le secret complet**.

Ces trois éléments suffisent à retrouver l'appel exact dans le journal.
