# Yasmine API — full documentation > Concaténation auto-générée des sources de doc reseller. Source de vérité = fichiers individuels (cf liens ci-dessous). Régénéré automatiquement — ne pas éditer à la main, le fichier sera écrasé. Sommaire (8 sources concaténées dans cet ordre) : - `docs/getting-started.md` — Getting Started - `docs/examples.md` — Examples - `docs/webhooks.md` — Webhooks - `docs/errors.md` — Errors - `docs/versioning.md` — Versioning - `docs/integrations.md` — Integrations - `docs/openapi.yaml` — OpenAPI spec - `CHANGELOG.md` — Changelog --- # === docs/getting-started.md === # Getting Started — API Yasmine Yasmine est une API qui déclenche des **appels vocaux IA sortants** (confirmation de commande, relance, livraison, annulation) vers vos clients finaux. Vous envoyez un `POST /v1/calls`, un agent conversationnel appelle le numéro, et vous recevez le résultat. --- ## 1. Prérequis ### Obtenir une clé API **Bootstrap de la 1ère clé** : le self-service `POST /v1/me/api-keys` est live mais il exige une clé existante pour s'authentifier. La 1ère clé se demande donc manuellement — contactez `contact@akidly.com` en précisant : - Nom de votre société / projet - Volume estimé d'appels par mois - URL d'endpoint webhook si vous en avez déjà un prêt (optionnel) Vous recevrez : - Une **clé API** au format `yk_...`. À traiter comme un mot de passe — elle n'est **jamais** retransmise. - Un **crédit initial** en secondes pour vos tests. **Ensuite, self-service complet** : dès que vous avez cette 1ère clé en main, vous gérez vous-même vos clés supplémentaires (CI, rotation, backup) via `POST /v1/me/api-keys`, `GET /v1/me/api-keys`, `DELETE /v1/me/api-keys/{key_id}`. Voir `§4.8` ci-dessous et la recette complète dans `docs/examples.md §3.2`. ### Base URL ``` https://api.yasmine.akidly.com ``` Toutes les requêtes passent par HTTPS. Le certificat est Let's Encrypt, valide. ### Authentification Toutes les routes sous `/v1/*` exigent un header `Authorization: Bearer ` : ```bash curl https://api.yasmine.akidly.com/v1/calls \ -H "Authorization: Bearer yk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` Sans clé ou avec une clé invalide → **401** au format RFC 7807 (`application/problem+json`). --- ## 2. Premier appel de bout en bout > ⚠️ Un `POST /v1/calls` déclenche un **vrai appel téléphonique** vers le numéro indiqué et **consomme du crédit réel**. Pour vos premiers tests, utilisez **votre propre numéro**. Remplacez la clé et le numéro, puis collez : ```bash curl -X POST https://api.yasmine.akidly.com/v1/calls \ -H "Authorization: Bearer yk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "shop_external_id": "ma-boutique-test", "shop_info": { "name": "Ma Boutique Test" }, "order": { "delivery_address": "12 Rue du Test, Casablanca", "items_text": "Crème hydratante visage 50ml", "amount": "249.00", "currency": "MAD" }, "country": "MA", "call_params": { "purpose": "confirmation" } }' ``` Les sous-objets `customer`, `shop_info`, `order`, `call_params` acceptent d'autres champs facultatifs (genre client, politiques SAV, delivery_method, voice_id, etc.). La liste complète et leurs contraintes sont dans `docs/openapi.yaml` (schémas `CustomerInput`, `ShopInfoInput`, `OrderInput`, `CallParamsInput`). **Détail commande — deux formats au choix** : - **`order.items_text`** (chaîne libre, max 500 caractères) — recommandé pour les commandes à article unique, ou quand le détail n'est pas découpé côté reseller (ex. "t-shirt blanc et jean noir XXL"). - **`order.items`** (tableau structuré, max 50 entrées avec `name` requis + `quantity`/`variants`/`description`/`unit_price` optionnels — `variants` est une liste de paires nom/valeur, max 3, ex. `{"name": "Taille", "value": "XL"}`) — recommandé quand les articles sont déjà découpés côté reseller et que vous voulez que l'agent les cite un par un et sache répondre aux questions produit via `description`. Les deux peuvent cohabiter ; si les deux sont fournis, la liste structurée prime au moment de l'appel. Recette curl complète pour la liste structurée dans [`docs/examples.md §1.bis`](https://docs.yasmine.akidly.com/examples.md). Réponse attendue (HTTP **201 Created**) : ```json { "id": "dcaebcdd-a81a-4386-a0df-85d9aaafe862", "status": "queued", "created_at": "2026-04-19T18:36:09.496546Z", "shop_id": "2671d5d2-efff-4e14-ba2a-3bfe80bd7840", "customer_phone_masked": "+212 6** *** *53", "purpose": "confirmation", "amount": "249.00", "currency": "MAD", "country": "MA", "metadata": null } ``` Header `Location: /v1/calls/dcaebcdd-a81a-4386-a0df-85d9aaafe862` en prime. --- ## 3. Comprendre la réponse 201 - **`id`** : identifiant UUID de l'appel. À conserver pour le retrouver plus tard. - **`status`** : `queued` au moment du 201. L'appel est asynchrone — l'agent va composer, sonner, converser, puis raccrocher (typiquement ~60 secondes). Les statuts traversés : `queued → dialing → ringing → in_progress → ended` (ou `failed`). - **`shop_id`** : notre identifiant de la boutique, retrouvée depuis votre `shop_external_id`. Si cet identifiant existait déjà sur votre compte, c'est le même ID — sinon une nouvelle boutique a été créée. Pour la déclarer explicitement, avec sa fiche complète : `POST /v1/shops`. - **`customer_phone_masked`** : **jamais le numéro brut** dans nos réponses (principe anti-BOPLA). Le numéro complet reste disponible côté opérations pour debug. - **`amount`** est une **string** pour préserver la précision décimale (évite les pièges de `Number` en JavaScript). **Le statut final et le résultat** (confirmé / annulé / pas de réponse) seront disponibles via `GET /v1/calls/{id}` et les webhooks sortants — **planned M4**. En attendant, contactez l'équipe si vous avez besoin d'exploiter les résultats côté dashboard Yasmine. --- ## 4. Gérer les erreurs Toutes les erreurs `/v1/*` sont renvoyées au format RFC 7807 `application/problem+json`. Routez côté client sur le champ `type` (URI stable), **pas** sur le `status` seul. ### 401 — Clé invalide ou absente ```json { "type": "https://docs.yasmine.akidly.com/errors/invalid_api_key", "title": "Unauthorized", "status": 401, "detail": "Authorization: Bearer requis", "instance": "/v1/calls", "request_id": "f7d9a06a" } ``` → Vérifiez le header `Authorization` côté client. Si la clé a été générée en M1, elle reste valide. ### 402 — Solde insuffisant ```json { "type": "https://docs.yasmine.akidly.com/errors/insufficient_balance", "title": "Payment Required", "status": 402, "detail": "Solde insuffisant (minimum 10 secondes requis).", "instance": "/v1/calls", "request_id": "b3c4d5e6" } ``` → Contactez l'équipe pour un top-up. Vous pouvez consulter votre solde courant via `GET /v1/me/balance` (live). ### 422 — Validation ```json { "type": "https://docs.yasmine.akidly.com/errors/validation_error", "title": "Unprocessable Entity", "status": 422, "detail": "Un ou plusieurs champs du payload sont invalides.", "instance": "/v1/calls", "request_id": "5312821c", "errors": [ { "loc": ["body", "customer", "phone_number"], "msg": "Value error, phone_number invalide — format attendu E.164 (ex: +212612345678)", "type": "value_error" }, { "loc": ["body", "order", "amount"], "msg": "Field required", "type": "missing" } ] } ``` → Lisez `errors[]` pour retrouver les champs fautifs avec leur `loc` (chemin dans le payload). ### Liste complète des codes d'erreur Voir [`docs/errors.md`](https://docs.yasmine.akidly.com/errors.md) pour la table complète des slugs stables (auth, balance, validation, rate-limit, call_not_found, webhook_*, etc.). Un `request_id` (8 caractères) est toujours présent — **incluez-le dans tout ticket support**. --- ### 429 — Rate limit dépassé L'API applique des quotas **par clé API** sur tous les endpoints `/v1/*` : | Endpoint | Seuil | |---|---| | `POST /v1/calls` | 60 / minute | | `POST /v1/calls/{id}/cancel` | 120 / minute | | `GET /v1/calls/{id}`, `GET /v1/me/balance`, `GET /v1/me/webhooks` (reads `/v1/*`) | 600 / minute | | `POST` / `DELETE /v1/me/webhooks` | 10 / minute | Chaque réponse inclut les headers : - `X-RateLimit-Limit` : plafond appliqué. - `X-RateLimit-Remaining` : compteur restant dans la fenêtre courante. - `X-RateLimit-Reset` : timestamp Unix du prochain reset. Sur dépassement : **429 Too Many Requests** (RFC 7807 slug `rate_limit_exceeded`) avec header `Retry-After` en secondes + champ `retry_after` dans le body. **Règle côté client** : respecter `Retry-After` avec backoff exponentiel si plusieurs 429 consécutifs. ```python import time, httpx def call_with_retry(method, url, **kwargs): for attempt in range(5): r = httpx.request(method, url, **kwargs) if r.status_code != 429: return r wait = int(r.headers.get("Retry-After", "1")) * (2 ** attempt) time.sleep(min(wait, 300)) return r # dernière tentative ``` Les webhooks Meta entrants (`POST /webhooks/whatsapp`) et les probes santé (`GET /healthz`) **ne sont jamais rate-limités**. --- ## 4.4. Tracer vos requêtes avec `X-Request-ID` Chaque réponse Yasmine inclut un header `X-Request-ID` (UUID v4) unique à la requête. Ce même identifiant apparaît dans le body des erreurs RFC 7807 (champ `request_id`) et dans nos logs serveurs. **Attribuer votre propre ID** (optionnel) : ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "X-Request-ID: $(uuidgen)" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{...}' # Response : X-Request-ID echoed tel quel si format UUID v4 strict valide. ``` Si vous envoyez un ID non-UUID v4 (format invalide, chaîne arbitraire), Yasmine en régénère un et renvoie le nouvel ID — jamais la valeur que vous avez soumise (protection anti-log-injection). **Pourquoi c'est utile** : - **Corréler vos logs client avec les nôtres** : envoyez un UUID corrélable à votre trace métier interne (ordre, job ID hashé, etc.) et retrouvez-le instantanément dans nos logs. - **Tickets support** : joignez le `request_id` → nous retraçons la requête exacte côté serveur sans besoin de timestamps ou de payload copy. - **Fonctionne même sur les 401** : le middleware tourne avant l'authentification, donc les erreurs d'auth ont aussi un `request_id`. --- ## 4.8. Gérer vos clés API CRUD self-service : créer une clé pour votre CI, en révoquer une après fuite, suivre l'usage via `last_used_at` et `last_used_ip` : ```bash # Créer une clé — secret visible UNE SEULE FOIS. curl -X POST -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{"label":"ci-prod"}' \ "$BASE/v1/me/api-keys" # Lister (sans secret). curl -H "Authorization: Bearer $YK" "$BASE/v1/me/api-keys" # Révoquer (soft delete, 204). curl -X DELETE -H "Authorization: Bearer $YK" "$BASE/v1/me/api-keys/" ``` Rate-limit création : 5/min/reseller. Recette complète dans `docs/examples.md §3.2`. --- ## 4.7. Intégrer vos webhooks Flow recommandé pour votre intégration webhook : 1. **Configurer l'URL** : `POST /v1/me/webhooks` avec votre URL publique HTTPS. Stockez le `secret` retourné — il sert à vérifier la signature HMAC-SHA256 (`X-Yasmine-Signature: sha256=`). 2. **Tester l'endpoint** : `POST /v1/me/webhooks/test` → POST synchrone vers votre URL avec `data.test=true`. Retourne `delivered: true/false`, `http_status`, `latency_ms` immédiatement. Pas besoin d'attendre un vrai event. 3. **Auditer en continu** : `GET /v1/me/webhooks/deliveries?status=failed` pour repérer les échecs et debug. Chaque ligne expose `error_message` (tronqué 200 chars), `attempt_count`, `next_retry_at`. Détails complets dans `docs/webhooks.md`. --- ## 4.6. Suivre mon compte Trois endpoints self-service pour la supervision : - **`GET /v1/me`** : identité du compte + profil société (propriétaire, langue par défaut, pays, raison sociale, identifiant fiscal, adresse de facturation, archétype, etc.). Liste complète : voir schéma `MeOut` dans `docs/openapi.yaml`. - **`GET /v1/me/transactions`** : historique paginé du ledger (mêmes `next_cursor`/`has_more` que `/v1/calls`). Filtres `?since=&until=`. - **`GET /v1/me/usage?period=YYYY-MM`** : consommation mensuelle (défaut = mois courant UTC). Breakdown par `call_status` (`completed`/`failed`/`cancelled`). Rappel : le solde vit sur `/v1/me/balance`, séparé pour des raisons anti-BOPLA. Recette curl complète dans `docs/examples.md §3.3`. --- ## 4.5. Réconciliation après webhook perdu Si un webhook sortant est perdu (crash mid-retry — edge case accepté produit), polling `GET /v1/calls/{id}` quelques secondes plus tard donne **l'état complet** de l'appel — y compris la classification métier (`result` + `result_detail` + `customer_mood` + `flags` + `preferences` + `next_action` + `summary`) et la durée (`call_duration_seconds`, `billable_duration_seconds`) — sans dépendre du webhook. Cet endpoint est scopé tenant : un 404 `call_not_found` est renvoyé avec un body **byte-identique** que le call soit inexistant, propriété d'un autre reseller, ou que le path contienne un UUID syntaxiquement invalide. ```bash curl -H "Authorization: Bearer $YK" "$BASE/v1/calls/$CALL_ID" \ | jq '{call_status, result, result_detail, customer_mood, flags, preferences, next_action, summary, ended_at, call_duration_seconds, billable_duration_seconds, failure_reason, meta_error_code, meta_error_title}' ``` Champs clés à attendre une fois le call terminé : - `call_status` parmi `ended` / `failed` / `cancelled` (rail APPEL). - `result` parmi `confirmed` / `cancelled` / `requires_action` — résultat principal de la conversation. `null` tant que l'appel n'est pas terminé. - `result_detail` — nuance fine (`modified`, `wrong_number`, `denied_order`, `human_requested`, `price_dispute`, `postponed`, `callback`, `unconfirmed`, `unclear`, `no_answer`, `failed`). Permet par exemple de distinguer un client qui veut être rappelé jeudi d'un audio inaudible — les deux étant `requires_action`. - `customer_mood`, `flags`, `preferences`, `next_action`, `summary` — détail conversationnel (cf §4.7 webhooks pour la sémantique). - `recording_url` — URL relative pour télécharger l'enregistrement audio (cf §4.6 ci-dessous). `null` tant que l'appel n'est pas terminé ou si l'audio a été purgé. - `failure_reason` + (si `result=requires_action` avec `result_detail=failed`) `meta_error_code` / `meta_error_title` — diagnostic technique côté opérateur de messagerie. **Comment interpréter le résultat** : | `result` | À faire côté boutique | |--------------------|----------------------------------------------------------------------------------------------------| | `confirmed` | Commande à expédier. Si `result_detail=modified`, appliquer la modification décrite dans `summary` / `preferences`. | | `cancelled` | Commande annulée. Si `result_detail=wrong_number` ou `denied_order`, vérifier la fiche client (numéro / fraude potentielle). | | `requires_action` | Le marchand doit traiter manuellement. Le `result_detail` indique pourquoi (rappel demandé, audio inaudible, demande humaine, no_answer, etc.). | --- ## 4.6. Récupérer l'enregistrement audio d'un appel Une fois l'appel terminé, le `CallOut` retourne un champ `recording_url` qui pointe vers l'audio mixte (client + agent) au format WAV. Authentification Bearer identique aux autres endpoints `/v1`. ```bash # 1. Lire l'état de l'appel + récupérer l'URL relative RECORDING_PATH=$(curl -s -H "Authorization: Bearer $YK" \ "$BASE/v1/calls/$CALL_ID" | jq -r '.recording_url') # 2. Télécharger le WAV (si recording_url ≠ null) curl -H "Authorization: Bearer $YK" \ -o "$CALL_ID.wav" \ "$BASE$RECORDING_PATH" ``` **Format** : WAV mono 16 kHz 16-bit PCM. ~32 KB/s, soit ~2 MB pour 60s d'appel. Header `Content-Disposition: attachment; filename=".wav"`. **Disponibilité** : `recording_url` est `null` tant que l'appel n'est pas dans un état terminal (`call_status=ended`). Le webhook `call.ended` contient aussi `recording_url` dans son payload `data` — pratique pour traiter l'audio dès la fin de l'appel sans polling. **Rétention** : 30 jours. Au-delà, l'endpoint renvoie `410 Gone` (slug `recording_gone`) et le champ `recording_url` repasse à `null` côté `CallOut`. Si vous avez besoin d'archiver les enregistrements plus longtemps, téléchargez-les côté reseller dès réception du `call.ended` et stockez-les chez vous. **Rate limit** : 60 req/min sur cet endpoint (plus restrictif que les endpoints JSON, audio plus lourd). Cacher localement après le premier téléchargement. --- ## 4.9. CORS — connecter un front web Vous pouvez appeler `api.yasmine.akidly.com` depuis un navigateur (front JavaScript) **uniquement si l'URL de votre site est dans notre liste blanche CORS**. Par défaut, aucune origine n'est autorisée — il faut nous demander l'ajout via le support. **Comment demander l'ajout** : envoyez-nous l'URL exacte (scheme + host + port si non standard) de votre front, par exemple `https://portal.monreseller.com` ou `https://dashboard.example.com:8443`. Nous ajoutons l'origine à la liste blanche côté serveur (mise en service sans interruption de l'API). Compte typique : 1 origine par environnement (prod / staging). **Contrat technique** pour votre front une fois l'origine ajoutée : - Méthodes autorisées : `GET`, `POST`, `DELETE`, `OPTIONS`. - Headers client : `Authorization` (Bearer), `Content-Type`, `Idempotency-Key`, `X-Request-ID`. - Headers lisibles par votre JS : `X-Request-ID`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`. Utile pour pacer vos requêtes (éviter 429) et tracer les tickets support. - `credentials: "include"` requis côté `fetch()` pour envoyer le Bearer cross-origin. - Preflight `OPTIONS` mis en cache 10 min côté navigateur. Exemple d'appel depuis un front React/Vue/etc. : ```javascript const res = await fetch("https://api.yasmine.akidly.com/v1/me/balance", { method: "GET", headers: { "Authorization": `Bearer ${yk}` }, credentials: "include", }); // Lire les headers de rate-limit pour éviter un 429 imminent. const remaining = res.headers.get("X-RateLimit-Remaining"); ``` ## 4.10. Alléger vos demandes d'appel — boutiques et catalogue L'exemple ci-dessus transmet tout à chaque appel. Ce n'est pas obligatoire. Déclarez la boutique **une fois** (`POST /v1/shops`) et ses produits une fois (`POST /v1/shops/{shop_external_id}/products`) : une demande d'appel se réduit alors à qui appeler, quoi référencer, et combien. ```json { "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "shop_external_id": "shop-4471", "order": { "external_id": "CMD-2026-8891", "amount": "487.00", "items": [ { "product_external_id": "TSHIRT-BLANC", "variant_external_id": "TSH-XL", "quantity": 1 } ] } } ``` Le profil de la boutique, la devise, le nom et le prix de chaque article sont relus depuis ce que vous avez déclaré. **Ce que vous envoyez malgré tout l'emporte** : un prix promotionnel pour une commande donnée reste possible, et une ligne décrite sur place avec `name` reste valide — c'est la voie pour un article sur mesure ou des frais de port. Recettes complètes : `docs/examples.md` §0, §0.bis et §0.ter. --- ## 5. Prochaines étapes - [`docs/examples.md`](https://docs.yasmine.akidly.com/examples.md) — recettes pratiques : boutiques, catalogue produits, appel France, metadata reseller, gestion fine des erreurs. - [`docs/webhooks.md`](https://docs.yasmine.akidly.com/webhooks.md) — catalogue des events sortants (planned M4+ — spec cible à lire pour préparer votre intégration). - [`docs/errors.md`](https://docs.yasmine.akidly.com/errors.md) — table complète des erreurs. - [`docs/versioning.md`](https://docs.yasmine.akidly.com/versioning.md) — politique de versioning de l'API. ## FAQ courte **Y a-t-il un environnement de test / sandbox ?** Pas encore. Chaque appel consomme du crédit réel et déclenche un appel téléphonique. **`Idempotency-Key` est-il obligatoire ?** Oui — le header est obligatoire sur `POST /v1/calls`, sinon `400 missing_idempotency_key`. Format libre, 1-255 chars (UUID v4 recommandé), TTL 24 h, scope par reseller. Une 2e requête identique (même clé + même body) renvoie la réponse stockée bit-for-bit avec `X-Idempotent-Replay: true`. Une 2e requête avec la même clé mais un body différent retourne `409 idempotency_key_conflict`. Voir `docs/examples.md §4`. **Quels pays sont supportés ?** Pour l'instant : `MA` (Maroc), `DZ` (Algérie), `TN` (Tunisie), `FR` (France). **Quelle langue parle Yasmine pendant l'appel ?** Le pays détermine une **langue par défaut** : `MA`/`DZ`/`TN` → arabe (`ar`), `FR` → français (`fr`). Cette langue est transparente pour vous si votre client préfère la langue locale du pays — pas besoin d'envoyer le champ `language` dans le payload. **Comment imposer une langue différente du défaut pays ?** Ajouter le champ optionnel `language` à la racine du payload `POST /v1/calls`, avec la valeur `ar` ou `fr`. Exemple : un client marocain qui préfère le français → `country: "MA", language: "fr"`. Combinaisons supportées : `MA`/`DZ`/`TN` avec `ar` ou `fr`, `FR` uniquement avec `fr`. La combinaison `country=FR, language=ar` est rejetée en 422 (`language_not_supported_for_country`). Les resellers existants qui n'envoient pas `language` continuent à fonctionner exactement comme avant — la langue locale du pays est appliquée par défaut. **Comment suis-je facturé ?** En **secondes billables** = `max(duration_s, 10)`. Chaque appel consomme au minimum 10 secondes même s'il est plus court. --- # === docs/examples.md === # Recettes curl — API Yasmine Tous les exemples utilisent : ```bash export YK="yk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" export BASE="https://api.yasmine.akidly.com" ``` Remplacez `$YK` par votre clé et collez directement dans un terminal. > Les endpoints marqués `planned` renvoient **404** aujourd'hui — ils seront disponibles après M4. Le code côté reseller peut être écrit contre le contrat dès maintenant. --- ## 0. Gérer ses boutiques — les déclarer une fois Sans cet endpoint, il faut renvoyer le profil complet de la boutique (`shop_info`) dans **chaque** demande d'appel. En la déclarant une fois, les appels suivants n'ont plus qu'à la référencer. ### Créer une boutique Trois champs suffisent. Tout le reste s'ajoute ensuite. ```bash curl -X POST https://api.yasmine.akidly.com/v1/shops \ -H "Authorization: Bearer $YASMINE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "shop-4471", "name": "GlowArt Casa", "country": "MA" }' ``` `external_id` est **votre** identifiant : celui de votre base, pas le nôtre. C'est aussi l'adresse de la boutique dans l'URL, d'où le jeu de caractères restreint (`[a-zA-Z0-9_.-]`). Il est **immuable** — il porte tout l'historique d'appels de la boutique. ### Enrichir la fiche ```bash curl -X PATCH https://api.yasmine.akidly.com/v1/shops/shop-4471 \ -H "Authorization: Bearer $YASMINE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "0522123456", "whatsapp": "0661234567", "email": "contact@glowart.ma", "address": "12 Rue des Fleurs, Casablanca", "customer_service_hours": "Lun-Sam 9h-18h", "sector": "Cosmétique bio artisanale", "has_physical_store": true, "delivery_delay": "24-48h", "payment_policy": "Paiement à la livraison ou carte bancaire", "return_policy": "Retour accepté sous 14 jours", "inspection_before_payment": true }' ``` **Le téléphone accepte le format local.** `0522123456` sur une boutique marocaine est valide et vous est renvoyé normalisé en `+212522123456`. En revanche un numéro tunisien sur une boutique marocaine est refusé (422) : le numéro doit appartenir au pays déclaré. ### Modifier, et effacer un champ Trois cas à distinguer dans le corps d'un `PATCH` : | Dans le corps | Effet | |---|---| | Champ **absent** | Inchangé | | Champ à **`null`** | **Effacé** | | Champ à **`""`** | Rejeté (422) | ```bash curl -X PATCH https://api.yasmine.akidly.com/v1/shops/shop-4471 \ -H "Authorization: Bearer $YASMINE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "delivery_delay": "48-72h", "current_promotions": null }' ``` Ici le délai est modifié, la promotion effacée, et les vingt-sept autres champs restent tels quels. > ⚠️ Cette sémantique **diffère de `shop_info`** dans `POST /v1/calls`, où > un champ absent ou `null` n'efface jamais rien. C'est délibéré : une > demande d'appel est du passage, elle ne détruit rien ; un `PATCH` est un > geste explicite. **Si vous gérez vos boutiques par cet endpoint, cessez > d'envoyer `shop_info` dans vos demandes d'appel** — sinon les deux se > marchent dessus. ### Regrouper des boutiques sous un même commerçant `merchant_external_id` porte l'identifiant du commerçant propriétaire. Plusieurs boutiques partagent la même valeur — c'est son objet. ```bash # À la création ou par PATCH curl -X PATCH https://api.yasmine.akidly.com/v1/shops/shop-4471 \ -H "Authorization: Bearer $YASMINE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"merchant_external_id": "M-4471"}' # Puis : combien de boutiques possède ce commerçant ? curl "https://api.yasmine.akidly.com/v1/shops?merchant_external_id=M-4471" \ -H "Authorization: Bearer $YASMINE_API_KEY" ``` ### Lister, paginer ```bash curl "https://api.yasmine.akidly.com/v1/shops?limit=50" \ -H "Authorization: Bearer $YASMINE_API_KEY" ``` ```json { "data": [ { "external_id": "shop-4471", "name": "GlowArt Casa", "status": "active" } ], "next_cursor": "eyJpZCI6...", "has_more": true } ``` Passez `next_cursor` en `?cursor=` pour la page suivante. Le curseur est valable 24 h ; au-delà, refaites la première requête sans `cursor`. Par défaut seules les boutiques actives sont renvoyées. `?status=archived` ne montre que les archivées, `?status=all` ne filtre pas. ### Archiver ```bash curl -X DELETE https://api.yasmine.akidly.com/v1/shops/shop-4471 \ -H "Authorization: Bearer $YASMINE_API_KEY" # 204 No Content ``` **La boutique n'est pas supprimée.** Elle disparaît de la liste par défaut et n'accepte plus de modification, mais son historique d'appels reste intact et consultable. Pour la retrouver : `?status=archived`. ### Les deux erreurs à gérer | Code | Slug | Quand | |---|---|---| | 409 | `shop_external_id_already_exists` | `POST` avec un `external_id` déjà pris. Utilisez `PATCH` pour modifier | | 404 | `shop_not_found` | Boutique inexistante, archivée, ou d'un autre compte — **corps identique dans les trois cas** | Le 404 est volontairement indifférencié : une réponse distincte permettrait de deviner quelles boutiques existent chez d'autres comptes. ## 0.bis Le catalogue — déclarer un produit, puis le commander par son nom Même principe qu'une boutique, un niveau plus bas. Un produit déclaré ici se commande ensuite en le citant : son nom, son prix et ses déclinaisons n'ont plus à être répétés dans chaque demande d'appel. ### Un produit sans déclinaison ```bash curl -X POST "$BASE/v1/shops/shop-4471/products" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{ "external_id": "SERUM-VITC", "name": "Sérum vitamine C", "description": "Flacon 30 ml, concentration 15%", "unit_price": "320.00" }' ``` Vous envoyez un prix, vous relisez un prix. En interne le produit reçoit une déclinaison unique qui le porte — vous n'avez pas à le savoir, mais c'est pour cela que `variants` contient une entrée dans la réponse, avec un `external_id` à `null` : vous ne l'avez pas nommée, et vous n'avez pas à le faire. ### Un produit à plusieurs déclinaisons Le prix vit sur la déclinaison : le XL peut coûter plus cher que le S. ```bash curl -X POST "$BASE/v1/shops/shop-4471/products" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{ "external_id": "TSHIRT-BLANC", "name": "T-shirt blanc en coton", "description": "Coton bio 180g, coupe droite", "variants": [ { "external_id": "TSH-S", "options": {"Taille": "S"}, "unit_price": "149.00" }, { "external_id": "TSH-M", "options": {"Taille": "M"}, "unit_price": "149.00" }, { "external_id": "TSH-XL", "options": {"Taille": "XL"}, "unit_price": "169.00", "compare_at_price": "199.00", "stock_quantity": 0 } ] }' ``` **Dès qu'il y a plusieurs déclinaisons, chacune doit porter son `external_id`** — sans quoi une commande ne pourrait pas désigner laquelle. Le refus est un 422 qui nomme les entrées fautives. `compare_at_price` est le prix barré, celui qui permet de dire « c'était 199, vous l'avez à 169 ». Il doit être **strictement supérieur** au prix : l'inverse serait forcément une erreur de saisie. `stock_quantity` est la quantité en stock de la déclinaison, si vous la suivez. Absent : l'agent n'en dit rien. **`0` : épuisée** — l'agent ne confirme pas un article qu'on ne peut pas livrer et propose une autre déclinaison s'il en reste. Pour un produit sans choix, le champ se pose au niveau du produit, à côté de `unit_price`. Trois axes d'options au maximum (`{"Taille": "XL", "Couleur": "rouge"}`). ### Ce qui prime sur la boutique Trois champs du produit l'emportent sur ceux de la boutique, quand ils sont renseignés : ```bash curl -X PATCH "$BASE/v1/products/SERUM-VITC" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{ "max_discount": "20.00", "return_policy": "Produit d’hygiène : aucun retour une fois ouvert", "current_promotions": "Deuxième flacon à -50%" }' ``` | Champ | Règle | |---|---| | `return_policy` | **Remplace** celle de la boutique pour ce produit | | `current_promotions` | **S'ajoute** à celle de la boutique — les deux sont vraies | | `max_discount` | Sur une commande à plusieurs produits, c'est **la plus haute** qui vaut. Une valeur à `0` ne bloque rien : elle dit seulement que ce produit-là n'apporte pas de marge | | `description` | Sert à répondre aux questions sur l'article, n'est jamais récitée d'office | ### Modifier les déclinaisons ⚠️ **`variants` remplace la liste entière.** Une déclinaison absente de la nouvelle liste est supprimée du catalogue. ```bash # Retirer la taille S : on renvoie la liste sans elle. curl -X PATCH "$BASE/v1/products/TSHIRT-BLANC" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{ "variants": [ { "external_id": "TSH-M", "options": {"Taille": "M"}, "unit_price": "149.00" }, { "external_id": "TSH-XL", "options": {"Taille": "XL"}, "unit_price": "169.00" } ] }' ``` Pour n'en changer qu'une : relisez le produit, modifiez l'entrée, renvoyez la liste complète. **Ne pas envoyer `variants` du tout les laisse intactes** — renommer un produit n'efface pas ses déclinaisons. Retirer une déclinaison qu'une commande ouverte, non tranchée, attend encore est refusé (`409 product_in_pending_orders`). Les appels déjà passés, eux, ne bougent pas : chacun a retenu ce qu'il a annoncé au client (`order_snapshot`). ### Lister et effacer ```bash curl "$BASE/v1/products?limit=50&shop_external_id=shop-4471" \ -H "Authorization: Bearer $YK" curl -X DELETE "$BASE/v1/products/TSHIRT-BLANC" \ -H "Authorization: Bearer $YK" # 204 No Content ``` `DELETE` **efface** le produit, avec ses déclinaisons. Les appels déjà passés n'en ont pas besoin : chacun a photographié ce qu'il a annoncé au client (`order_snapshot` sur la fiche de l'appel). Une référence effacée redevient libre — la redéclarer crée un produit neuf. Refusé tant qu'une commande ouverte et non tranchée référence le produit (`409 product_in_pending_orders`) : le `detail` nomme les commandes. Même règle pour un `PATCH` qui retirerait une déclinaison encore attendue. ### Les erreurs à gérer | Code | Slug | Quand | |---|---|---| | 409 | `product_external_id_already_exists` | `external_id` déjà pris **sur votre compte**, toutes boutiques confondues. Une référence effacée redevient libre | | 409 | `product_in_pending_orders` | Une commande ouverte, non tranchée, référence encore le produit (ou la déclinaison retirée) ; le `detail` la nomme | | 404 | `product_not_found` | Produit inexistant, effacé, ou d'un autre compte — corps identique | | 404 | `shop_not_found` | La boutique du chemin n'existe pas | | 422 | `validation_error` | prix manquant (sur le produit sans choix, ou sur une déclinaison), `unit_price: null` sur un `PATCH`, `external_id` de déclinaison manquant ou en double, prix barré inférieur au prix, plus de trois axes d'options, `unit_price` sur un produit à plusieurs déclinaisons | ## 0.ter Commander depuis le catalogue — la demande d'appel allégée Une fois la boutique et les produits déclarés, une demande d'appel se réduit à qui appeler, quoi référencer, et combien. ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "shop_external_id": "shop-4471", "order": { "external_id": "CMD-2026-8891", "amount": "487.00", "items": [ { "product_external_id": "TSHIRT-BLANC", "variant_external_id": "TSH-XL", "quantity": 1 }, { "product_external_id": "SERUM-VITC", "quantity": 1 } ] }, "call_params": { "purpose": "confirmation" } }' ``` Ce qui est **déduit** et n'a plus à être transmis : | Ce qui n'est plus envoyé | D'où cela vient | |---|---| | `shop_info` | La fiche déclarée par `POST /v1/shops` | | `country` | **L'indicatif du numéro du client**, jamais le pays de la boutique — une boutique vend à plusieurs pays, et se tromper de pays c'est se tromper de dialecte | | `currency` | Le pays de la boutique (`MA` → MAD, `DZ` → DZD, `TN` → TND) | | `name`, `description`, `unit_price` de chaque ligne | La fiche produit et la déclinaison référencées | | `max_discount` | Le plus haut parmi les produits commandés, sinon celui de la boutique | Deux nuances valent d'être connues. Le pays se lit sur le numéro appelé : `+212…` est marocain, et cela reste vrai même si la boutique est tunisienne. Envoyez `country` explicitement pour un Marocain expatrié joignable sur un `+33` que vous voulez faire appeler en darija — une valeur fournie gagne toujours. La devise, elle, suit le pays de la boutique, parce qu'un montant est ce que le commerçant encaisse. `variant_external_id` est inutile sur un produit qui n'a qu'une déclinaison — elle est retenue automatiquement. S'il en a plusieurs et qu'aucune n'est désignée, la requête est refusée (`order_item_variant_required`) en listant celles qui existent. Aucune n'est choisie au hasard : l'appel annoncerait au client un article qu'il n'a pas commandé. **Ce que vous envoyez malgré tout l'emporte.** Un prix promotionnel valable pour cette commande-là se transmet sur la ligne, et il prime sur le catalogue : ```json { "product_external_id": "SERUM-VITC", "quantity": 2, "unit_price": "280.00" } ``` **Mélanger les deux formes est permis.** Une ligne sans référence, décrite sur place avec `name`, reste valide — c'est la voie pour un article sur mesure, des frais de port ou un cadeau : ```json "items": [ { "product_external_id": "TSHIRT-BLANC", "variant_external_id": "TSH-XL", "quantity": 1 }, { "name": "Frais de livraison", "quantity": 1, "unit_price": "30.00" } ] ``` Un produit absent du catalogue ne doit jamais bloquer un appel. ## 0.ter Les commandes — déclarer, puis appeler par référence Une commande se déclare comme un produit : sous sa boutique, avec **votre** identifiant. Elle s'appelle ensuite par cette seule référence, autant de fois qu'il faut — les appels s'empilent sur elle, il n'y a plus de doublon de commande pour relancer un client. ### Déclarer ```bash curl -X POST "$BASE/v1/shops/shop-4471/orders" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{ "external_id": "CMD-2026-8891", "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "amount": "487.00", "delivery_address": "12 rue des Orangers, Casablanca", "call_hours": "Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00", "items": [ { "product_external_id": "TSHIRT-BLANC", "variant_external_id": "TSH-XL", "quantity": 1 }, { "product_external_id": "SERUM-VITC", "quantity": 1 } ] }' ``` Les articles sont **vivants jusqu'à l'appel** : une ligne qui référence le catalogue est relue au catalogue du moment, à chaque lecture et à chaque appel. Un prix corrigé après la déclaration est celui qui sera annoncé. Ce que l'appel a réellement dit se lit ensuite sur la fiche de l'appel, dans `order_snapshot`. `call_hours` est la **plage d'appel** : les créneaux pendant lesquels ce client peut être appelé, au format des horaires OpenStreetMap — jours en anglais abrégés, heures sur 24 h. Ici du lundi au vendredi matin et après-midi, le samedi après-midi, jamais le dimanche. Jamais avant 9 h ni après 22 h : une plage qui en sort est refusée. Absente, c'est celle de la boutique (`call_hours` sur `POST /v1/shops`) qui vaut, sinon tous les du lundi au samedi de 10 h à 21 h et le dimanche de 11 h à 20 h. Un appel lancé hors plage est accepté et part à la prochaine ouverture. Une ligne décrite sur place avec `name` reste valide (frais de port, cadeau, article sur mesure). La réponse porte `order_status` (`PENDING` tant qu'aucun appel n'a abouti), la liste `calls` (vide pour l'instant) et `call_in_progress`. ### Appeler ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "shop_external_id": "shop-4471", "order_external_id": "CMD-2026-8891", "call_params": { "purpose": "confirmation" } }' ``` Trois champs. Le client, les articles et les montants viennent de la commande ; `customer` ne se fournit pas — il est celui de la commande, et se change par `PATCH` sur elle. Le nombre de tentatives précédentes n'est plus à déclarer : le serveur compte les appels déjà passés. Relancer le client plus tard : **la même requête**, avec une nouvelle `Idempotency-Key`. Tant qu'un appel n'est pas terminé, un second est refusé (`409 order_call_in_progress`). ### Relire, corriger, archiver ```bash curl "$BASE/v1/orders/CMD-2026-8891" -H "Authorization: Bearer $YK" curl -X PATCH "$BASE/v1/orders/CMD-2026-8891" \ -H "Authorization: Bearer $YK" -H "Content-Type: application/json" \ -d '{ "delivery_address": "14 rue des Orangers, Casablanca", "amount": "450.00" }' curl -X DELETE "$BASE/v1/orders/CMD-2026-8891" -H "Authorization: Bearer $YK" ``` Le `PATCH` suit la règle des produits : absent = inchangé, `null` = effacé, `""` = 422. `customer`, `amount` et `items` ne s'effacent pas. `items` remplace la liste entière des lignes. Modifier ou archiver **pendant un appel en cours** est refusé. | Code | Slug | Quand | |---|---|---| | 409 | `order_external_id_already_exists` | `external_id` déjà pris **sur votre compte**, toutes boutiques confondues | | 404 | `order_not_found` | Commande inexistante, archivée, ou d'une autre boutique — corps identique | | 409 | `order_call_in_progress` | Un appel n'est pas terminé : modifier, archiver ou rappeler attendra | | 422 | `validation_error` | ni `items` ni `items_text`, `customer`/`amount`/`items` à `null`, `external_id` différent de l'URL | | 409 | `order_closed` | `POST /v1/calls` sur une commande fermée : la rouvrir d'abord | ### Fermer, rouvrir Le statut d'appel dit ce que le client a répondu. Il ne dit pas si **vous** en avez fini avec la commande — livrée, retournée, annulée de votre côté. Comme chez Shopify, c'est un axe à part : `closed`. ```bash # Le colis est arrivé : le dossier est clos. curl -X POST "$BASE/v1/orders/CMD-2026-8891/close" \ -H "Authorization: Bearer $YK" -H "Content-Type: application/json" \ -d '{ "reason": "delivered" }' # Les commandes fermées ne sont plus listées par défaut ; les retrouver : curl "$BASE/v1/orders?lifecycle=closed" -H "Authorization: Bearer $YK" # Vous vous êtes trompé de commande : la rouvrir. curl -X POST "$BASE/v1/orders/CMD-2026-8891/reopen" -H "Authorization: Bearer $YK" ``` `reason` est obligatoire : `delivered`, `returned`, `cancelled` (annulée par vous — le client qui refuse au téléphone, c'est `order_status`) ou `other`. La fiche renvoie `closed`, `closed_at` et `closed_reason`. Une commande fermée **ne peut plus être appelée** (`409 order_closed`) tant qu'elle n'est pas rouverte. Fermer pendant un appel en cours est refusé. ## 1. Appel simple Maroc — commande à article unique (texte libre) La majorité des commandes contiennent un seul article. Le moyen le plus simple de transmettre le détail est un résumé libre via `order.items_text` (max 500 caractères). Yasmine cite ce résumé tel quel auprès du client. ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "shop_external_id": "boutique-casa-01", "shop_info": { "name": "GlowArt Casa" }, "order": { "delivery_address": "12 Rue X, Casablanca", "items_text": "Crème hydratante visage 50ml", "amount": "249.00", "currency": "MAD" }, "country": "MA", "call_params": { "purpose": "confirmation" } }' ``` Réponse (HTTP **201**) : ```json { "id": "dcaebcdd-a81a-4386-a0df-85d9aaafe862", "status": "queued", "created_at": "2026-04-19T18:36:09.496546Z", "shop_id": "2671d5d2-efff-4e14-ba2a-3bfe80bd7840", "customer_phone_masked": "+212 6** *** *78", "purpose": "confirmation", "amount": "249.00", "currency": "MAD", "country": "MA", "metadata": null } ``` L'agent parle en darija marocaine. Durée typique : 40-60 secondes. Le sous-objet `customer` accepte aussi `gender`, `email`, `notes`, `language` — voir le schéma `CustomerInput` dans `docs/openapi.yaml` pour la liste complète des champs. `items_text` est facultatif : un POST sans `items_text` ni `items` reste valide (Yasmine demandera alors au client de préciser ou s'appuiera uniquement sur `amount`). --- ## 1.bis Commande à plusieurs articles — liste structurée Quand la commande contient plusieurs articles distincts à citer un par un, utilisez `order.items` (tableau, max 50 entrées). Chaque article expose `name` (requis), puis en optionnel : `quantity` (défaut 1), `variants` (paires nom/valeur, max 3 — ex. `{"name": "Taille", "value": "XXL"}`), `description` (texte libre ≤ 500, sert à répondre aux questions du client sur l'article) et `unit_price`. ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "shop_external_id": "boutique-casa-01", "shop_info": { "name": "GlowArt Casa" }, "order": { "external_id": "CMD-2026-00790", "delivery_address": "12 Rue X, Casablanca", "items": [ { "name": "T-shirt blanc en coton", "quantity": 2, "variants": [{ "name": "Taille", "value": "XXL" }, { "name": "Couleur", "value": "blanc" }], "description": "T-shirt col rond 100% coton, coupe droite.", "unit_price": "120.00" }, { "name": "Jean noir slim", "quantity": 1, "unit_price": "320.00" } ], "amount": "560.00", "currency": "MAD" }, "country": "MA", "call_params": { "purpose": "confirmation" } }' ``` **Choix entre les deux formats** : | Cas | Format recommandé | |---|---| | Commande à article unique | `items_text` (chaîne libre) | | Plusieurs articles déjà découpés côté reseller (nom, variante, prix unitaire) | `items` (tableau structuré) | | Plusieurs articles non découpés (ex. "t-shirt blanc et jean noir XXL") | `items_text` | | Aucune des deux infos disponible | omettre les deux | `items` et `items_text` peuvent cohabiter dans la même requête : si les deux sont fournis, la liste structurée prime au moment de l'appel. --- ## 2. Appel France avec fiche client enrichie ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Claire Dupont", "phone_number": "+33612345678", "gender": "female", "email": "claire@example.com", "notes": "VIP, horaires bureau uniquement" }, "shop_external_id": "boutique-paris-01", "shop_info": { "name": "GlowArt Paris", "sector": "beauté", "return_policy": "retour gratuit sous 14 jours" }, "order": { "delivery_address": "25 Av des Champs-Élysées, 75008 Paris", "amount": "49.90", "currency": "EUR", "delivery_method": "express", "delivery_zone": "urban", "source": "web" }, "country": "FR", "call_params": { "purpose": "retry", "tone": "friendly" } }' ``` La `country` bascule automatiquement le serveur vers le profil conversationnel français. Les champs facultatifs sont persistés côté serveur et réutilisés lors des appels suivants pour le même `(customer, boutique)` (COALESCE stickiness — un POST ultérieur sans ces champs n'écrase pas les valeurs existantes côté customer + boutique). --- ## 2.5. Forcer la langue d'un appel (override du défaut pays) Le pays détermine une langue par défaut (`MA`/`DZ`/`TN` → `ar`, `FR` → `fr`). Si votre client préfère une autre langue parmi celles supportées, ajoutez le champ optionnel `language` à la racine du payload. Exemple : un client marocain qui préfère le français. ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Sophie El Idrissi", "phone_number": "+212661111222" }, "shop_external_id": "boutique-casa-01", "shop_info": {"name": "GlowArt Casa"}, "order": { "delivery_address": "12 Rue X, Casablanca", "amount": "249.00", "currency": "MAD" }, "country": "MA", "language": "fr", "call_params": {"purpose": "confirmation"} }' ``` Combinaisons supportées : `MA`/`DZ`/`TN` avec `ar` ou `fr`, `FR` uniquement avec `fr`. La combinaison `country=FR` + `language=ar` est rejetée en `422 language_not_supported_for_country`. Si vous omettez `language`, la langue locale du pays est appliquée — comportement strictement identique aux versions précédentes de l'API (rétrocompat garantie). La langue effective est ré-écho dans le champ `language` de la réponse. --- ## 3. Avec metadata reseller Utilisez `metadata` pour transporter votre identifiant de commande interne, référence facture, ou tout contexte dont vous aurez besoin plus tard. Limite : **2 KB** sérialisé en JSON compact. ```bash curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "shop_external_id": "boutique-casa-01", "shop_info": {"name": "GlowArt Casa"}, "order": { "external_id": "CMD-2026-00789", "delivery_address": "12 Rue X, Casa", "amount": "180.00", "currency": "MAD" }, "country": "MA", "call_params": {"purpose": "followup_delivery"}, "metadata": { "invoice_ref": "INV-2026-1042", "source_crm": "shopify", "custom_tags": ["vip", "repeat_customer"] } }' ``` Cette `metadata` sera conservée côté serveur et vous sera renvoyée telle quelle dans la réponse et (à terme) dans les webhooks sortants. --- ## 3.2. Gérer mes clés API Trois endpoints self-service pour créer, lister et révoquer vos clés API. ```bash # Créer une nouvelle clé — secret_key visible UNE SEULE FOIS. curl -X POST -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{"label":"ci-prod"}' \ "$BASE/v1/me/api-keys" # { # "id": "3f8e9d12-...", # "key_prefix": "yk_Xa7bC3dE5f", # "label": "ci-prod", # "created_at": "2026-04-21T12:00:00Z", # "secret_key": "yk_Xa7bC3dE5fG9hI1jK2lM3nO4pQ5rS6tU7vW8xY9zA" # } # Stockez immédiatement `secret_key` côté reseller — aucune autre route # ne la ré-expose. Si vous la perdez : révocation + nouvelle création. # Lister toutes les clés (sans secret, jamais). curl -H "Authorization: Bearer $YK" "$BASE/v1/me/api-keys" # { "data": [{ id, key_prefix, label, created_at, last_used_at, # last_used_ip, revoked_at, status }] } # Révoquer une clé (soft delete, historique préservé avec status=revoked). curl -X DELETE -H "Authorization: Bearer $YK" \ "$BASE/v1/me/api-keys/3f8e9d12-4b5a-4c6d-8e7f-123456789abc" # → 204 No Content ``` **Rate-limit création** : 5/min/reseller. Au-delà → 429. Empêche un script mal configuré de générer des milliers de clés par erreur. **Anti-énumération** : un `DELETE` sur un UUID qui n'existe pas **ou** qui appartient à un autre reseller renvoie un 404 strictement identique — pas moyen de sonder l'existence des clés des autres. **Auto-révocation safe** : si vous révoquez la clé que vous êtes en train d'utiliser, la requête courante termine normalement (204), la suivante retourne 401. Pas de besoin de re-authentifier manuellement. --- ## 3.2.1. Journal d'audit d'une clé API Le serveur enregistre automatiquement trois types d'événements par clé : `created`, `revoked`, `rate_limited`. Utile pour détecter qu'une clé a été spamée (429 multiples) ou pour auditer qui a créé/révoqué quoi et quand. ```bash # Lister les events d'une clé (paginé curseur, limit max 50). KEY_ID="3f8e9d12-4b5a-4c6d-8e7f-123456789abc" curl -H "Authorization: Bearer $YK" \ "$BASE/v1/me/api-keys/$KEY_ID/events?limit=50" # { # "data": [ # { "id":"...", "event_type":"revoked", "ip":"1.2.3.4", # "user_agent":"curl/8.4.0", "metadata":null, # "created_at":"2026-04-21T14:30:00Z" }, # { "id":"...", "event_type":"rate_limited", "ip":"1.2.3.4", # "user_agent":"curl/8.4.0", # "metadata":{"path":"/v1/calls","limit":"600 per 1 minute"}, # "created_at":"2026-04-21T13:15:22Z" }, # { "id":"...", "event_type":"created", "ip":"1.2.3.4", # "user_agent":"curl/8.4.0", "metadata":{"label":"ci-prod"}, # "created_at":"2026-04-21T12:00:00Z" } # ], # "next_cursor": null, # "has_more": false # } # Page suivante avec le next_cursor de la réponse précédente. curl -H "Authorization: Bearer $YK" \ "$BASE/v1/me/api-keys/$KEY_ID/events?cursor=" ``` **Dédup `rate_limited`** : pour éviter de remplir le journal si un script mal configuré se fait 429 en boucle, un seul event `rate_limited` est écrit par clé toutes les 60 s. Un burst de 1000 requêtes rate-limitées en 10 s → 1 event. **Anti-énumération** : un GET sur un `key_id` qui n'existe pas **ou** qui appartient à un autre reseller → 404 `api_key_not_found` strictement identique au DELETE. --- ## 3.3. Suivre mon compte Trois endpoints de lecture self-service. ```bash # 1. Identité + profil société (anti-BOPLA : pas de balance ni status). # Les champs profil (owner_name, country, tax_id, business_type, …) sont # `null` tant que la fiche société n'a pas été renseignée côté ops. # Schéma complet : voir `MeOut` dans `docs/openapi.yaml`. curl -H "Authorization: Bearer $YK" "$BASE/v1/me" # 2. Solde. curl -H "Authorization: Bearer $YK" "$BASE/v1/me/balance" # {"balance_seconds":3600,"currency":null,"updated_at":"..."} # 3. Historique ledger paginé (topup, debit_call, refund, adjustment). curl -H "Authorization: Bearer $YK" "$BASE/v1/me/transactions?limit=10" # Page suivante : passer next_cursor tel quel. curl -H "Authorization: Bearer $YK" "$BASE/v1/me/transactions?cursor=eyJkaXIi..." # 4. Consommation mensuelle (défaut = mois courant UTC). curl -H "Authorization: Bearer $YK" "$BASE/v1/me/usage" # Mois spécifique : curl -H "Authorization: Bearer $YK" "$BASE/v1/me/usage?period=2026-04" # { # "period":"2026-04", # "period_start":"2026-04-01T00:00:00+00:00", # "period_end":"2026-05-01T00:00:00+00:00", # "total_calls":152, # "total_seconds":2340, # "breakdown":{ # "completed_calls":138, "completed_seconds":2280, # "failed_calls":10, "cancelled_calls":4 # } # } ``` Note sur la facturation : `total_seconds` inclut tous les calls du mois (y compris in-flight). Pour la facturation effective, utiliser `breakdown.completed_seconds` (calls en `call_status = 'ended'`). Pas de 404 sur période sans appel — la réponse est 200 avec tous les compteurs à zéro. --- ## 3.4. Lister les appels avec pagination `GET /v1/calls` renvoie une **enveloppe paginée** `{data, next_cursor, has_more}`. Tri `created_at DESC` avec tie-break stable `id DESC`. ```bash # Page 1 — défaut 50 items, ou configurer avec ?limit= (max 200). curl -H "Authorization: Bearer $YK" "$BASE/v1/calls?limit=10" # { # "data": [{...}, {...}, ...], # "next_cursor": "eyJkaXIiOiJuZXh0IiwiaWQiOiIuLi4iLCJ0cyI6Ii4uLiJ9.XDg4Zjg...", # "has_more": true # } # Page suivante — passer next_cursor tel quel. curl -H "Authorization: Bearer $YK" "$BASE/v1/calls?limit=10&cursor=eyJkaXIi..." ``` **Filtres combinables** : ```bash # Appels du boutique `boutique-casa-01` terminés dans la dernière semaine. curl -H "Authorization: Bearer $YK" \ "$BASE/v1/calls?status=ended&shop_id=8f3a...&since=2026-04-14T00:00:00Z&limit=100" ``` **Rotation du curseur** : le curseur est signé HMAC-SHA256 avec une clé serveur. Si l'équipe Yasmine rotate cette clé, tous les curseurs émis avant deviennent invalides (`400 invalid_cursor`) — refaire la 1re requête sans `cursor`. Même comportement après 24 h (`400 cursor_expired`). --- ## 3.5. Récupérer un appel + consulter son solde ```bash # Lecture d'un appel — scoped tenant, 404 anti-énum si autre reseller / inconnu / UUID invalide curl -H "Authorization: Bearer $YK" \ "$BASE/v1/calls/dcaebcdd-a81a-4386-a0df-85d9aaafe862" \ | jq '{call_status, result, result_detail, customer_mood, flags, preferences, next_action, summary, recording_url, ended_at, call_duration_seconds, billable_duration_seconds, failure_reason, meta_error_code, meta_error_title}' # { # "call_status": "ended", # "result": "confirmed", # "result_detail": "modified", # "customer_mood": "positive", # "flags": [], # "preferences": ["t-shirt couleur bleue", "livraison mardi 14h"], # "next_action": null, # "summary": "La cliente confirme la commande mais demande le t-shirt en bleu et la livraison mardi 14h.", # "recording_url": "/v1/calls/dcaebcdd-a81a-4386-a0df-85d9aaafe862/recording", # "ended_at": "2026-04-21T14:31:57Z", # "call_duration_seconds": 37, # "billable_duration_seconds": 37, # "failure_reason": null, # "meta_error_code": null, # "meta_error_title": null # } # Télécharger l'enregistrement audio (mix client + agent, WAV mono 16 kHz) curl -H "Authorization: Bearer $YK" \ -o "dcaebcdd.wav" \ "$BASE/v1/calls/dcaebcdd-a81a-4386-a0df-85d9aaafe862/recording" # → fichier WAV ~2 MB pour 60s. Rétention 30 jours. # 410 Gone si l'audio a été purgé après rétention (slug `recording_gone`). # Filtrer la liste : commandes confirmées d'une boutique curl -H "Authorization: Bearer $YK" \ "$BASE/v1/calls?status=ended&result=confirmed&shop_id=$MID&limit=50" # Consulter le solde courant curl -H "Authorization: Bearer $YK" "$BASE/v1/me/balance" # {"balance_seconds":3540,"currency":null,"updated_at":"2026-04-21T14:32:11Z"} ``` `GET /v1/calls/{id}` est utile pour **réconcilier** après un webhook perdu : le crash mid-retry est un edge case accepté produit (`CHANGELOG.md` C6 Phase 2). Si vous voyez un appel `accepted` mais jamais `ended` côté votre webhook, polling cet endpoint quelques secondes plus tard donne **l'état terminal complet** : `call_status` (`ended`/`failed`/`cancelled`), classification métier (`result`/`result_detail`/`customer_mood`/`flags`/`preferences`/`next_action`/`summary`), `call_duration_seconds`, `billable_duration_seconds`, `failure_reason` et — pour `result=requires_action` avec `result_detail=failed` issu d'un échec opérateur — `meta_error_code` + `meta_error_title`. Le 404 `call_not_found` ne distingue PAS un appel inexistant d'un appel appartenant à un autre reseller — anti-énumération volontaire. --- ## 4. Idempotency-Key — obligatoire, se protéger des retries réseau Le header `Idempotency-Key` est **obligatoire** sur `POST /v1/calls` (sans lui : `400 missing_idempotency_key`). Format libre, 1-255 chars (UUID v4 recommandé). Scope par reseller, TTL 24 h. ```bash # Générez la clé UNE fois pour votre retry loop (ou reutilisez votre order_id stable) IDEMP=$(uuidgen) # Tentative 1 curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMP" \ -d '{ "customer": { "name": "X", "phone_number": "+212612345678" }, "shop_external_id": "x", "shop_info": {"name": "X Shop"}, "order": {"delivery_address": "Rue X, Casa", "amount": "100", "currency": "MAD"}, "country": "MA", "call_params": {"purpose": "confirmation"} }' # Si timeout / erreur réseau, retry avec LA MÊME clé + LE MÊME body curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMP" \ -d '{ ... même payload ... }' ``` La 2e requête retourne **bit-for-bit la même réponse** que la 1ère, avec en plus le header `X-Idempotent-Replay: true`. Aucun nouvel appel n'est déclenché, aucun nouveau débit, aucun nouveau template Meta. **Conflit** : si vous réutilisez la même clé avec un body différent, vous obtenez `409 idempotency_key_conflict` (le body original n'est jamais ré-exposé). Générez une nouvelle clé dès que la requête change. **Choix de clé** : - UUID v4 généré côté client (`uuidgen`, `crypto.randomUUID()`, `uuid.uuid4()`). - Identifiant business stable par requête (ex. `order_id` + `purpose`) si vous voulez une dédup naturelle sur votre logique métier. - Évitez les clés réutilisées sur plusieurs jours sans précaution : la fenêtre est de 24 h, après quoi la clé peut être réutilisée pour une nouvelle requête. --- ## 5. Parser un 422 côté client Un payload invalide renvoie un `422` RFC 7807 avec `errors[]` listant chaque champ fautif : ```bash # payload volontairement invalide — il sert a montrer la forme du 422 curl -X POST "$BASE/v1/calls" \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -d '{ "customer": { "name": "", "phone_number": "+0000000000" }, "shop_external_id": "ma boutique", "shop_info": {"name": ""}, "order": {"delivery_address": "", "amount": "-10", "currency": "mad"}, "country": "XX", "call_params": {"purpose": "invalid_enum"} }' ``` Réponse (HTTP **422**) : ```json { "type": "https://docs.yasmine.akidly.com/errors/validation_error", "title": "Unprocessable Entity", "status": 422, "detail": "Un ou plusieurs champs du payload sont invalides.", "instance": "/v1/calls", "request_id": "a1b2c3d4", "errors": [ { "loc": ["body", "customer", "phone_number"], "msg": "Value error, phone_number invalide — format attendu E.164 (ex: +212612345678)", "type": "value_error" }, { "loc": ["body", "customer", "name"], "msg": "String should have at least 1 character", "type": "string_too_short" }, { "loc": ["body", "shop_external_id"], "msg": "String should match pattern '^[a-zA-Z0-9_.-]+$'", "type": "string_pattern_mismatch" }, { "loc": ["body", "shop_info", "name"], "msg": "String should have at least 1 character", "type": "string_too_short" }, { "loc": ["body", "order", "delivery_address"], "msg": "String should have at least 1 character", "type": "string_too_short" }, { "loc": ["body", "order", "amount"], "msg": "Decimal input should be greater than 0", "type": "greater_than" }, { "loc": ["body", "order", "currency"], "msg": "Input should be 'MAD','EUR','USD','TND' or 'DZD'", "type": "literal_error" }, { "loc": ["body", "country"], "msg": "Input should be 'MA', 'DZ', 'TN' or 'FR'", "type": "literal_error" }, { "loc": ["body", "call_params", "purpose"], "msg": "Input should be one of the 5 enum values", "type": "literal_error" } ] } ``` ### Champs inconnus → 422 `extra_forbidden` Tout champ qui ne fait pas partie du schéma `CallCreate` ou `CustomerInput` est rejeté. Un typo à la racine (par exemple `custommer` au lieu de `customer`) ou à l'intérieur de `customer` (par exemple `phonne_number`) ne passe pas silencieusement — le serveur renvoie **422** avec le chemin exact du champ fautif : ```json { "type": "https://docs.yasmine.akidly.com/errors/validation_error", "title": "Unprocessable Entity", "status": 422, "instance": "/v1/calls", "request_id": "f1b14110-...", "detail": "Un ou plusieurs champs du payload sont invalides.", "errors": [ { "loc": ["body", "customer", "phonne_number"], "msg": "Extra inputs are not permitted", "type": "extra_forbidden" } ] } ``` Parser côté client : si `errors[].type === "extra_forbidden"`, c'est un champ en trop — vérifier l'orthographe ou supprimer du body. La liste des champs autorisés est dans la [spec OpenAPI](https://docs.yasmine.akidly.com/#tag/Calls). Exemple de parsing JavaScript côté reseller : ```javascript async function createCall(payload) { const res = await fetch(`${BASE}/v1/calls`, { method: "POST", headers: { "Authorization": `Bearer ${YK}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify(payload), }); if (res.status === 422) { const problem = await res.json(); // Mappez errors[].loc vers vos champs de formulaire UI const fieldErrors = {}; for (const err of problem.errors || []) { const field = err.loc[err.loc.length - 1]; fieldErrors[field] = err.msg; } throw new ValidationError(fieldErrors, problem.request_id); } if (!res.ok) { const problem = await res.json(); // Routez sur problem.type (URI stable), pas sur status seul throw new ApiError(problem.type, problem.detail, problem.request_id); } return await res.json(); } ``` --- ## 6. Gérer un 402 — solde insuffisant ```json { "type": "https://docs.yasmine.akidly.com/errors/insufficient_balance", "title": "Payment Required", "status": 402, "detail": "Solde insuffisant (minimum 10 secondes requis).", "instance": "/v1/calls", "request_id": "b3c4d5e6" } ``` **Retry aveugle : mauvaise idée.** Un 402 signifie que le solde reseller est vide — retenter immédiatement donnera le même 402 et ne crée pas l'appel. Logique recommandée côté reseller : ```python def create_call_with_balance_check(payload): res = requests.post(f"{BASE}/v1/calls", json=payload, headers={ "Authorization": f"Bearer {YK}", "Idempotency-Key": str(uuid.uuid4()), }) if res.status_code == 402: problem = res.json() # 1. Ne PAS retry. # 2. Alerter l'équipe ops (pour top-up). # 3. Mettre la commande en file de relance (side-queue). logger.error( "Solde Yasmine insuffisant, request_id=%s", problem.get("request_id"), ) queue_for_retry_after_topup(payload) return None res.raise_for_status() return res.json() ``` Le self-service `GET /v1/me/balance` arrive en M4 — vous pourrez surveiller votre solde en amont et éviter les 402 en prod. --- ## 7. Annuler un appel en cours `POST /v1/calls/{id}/cancel` coupe immédiatement un appel en cours, que le rail APPEL soit en `dialing`, `ringing` ou `connected`. ```bash CALL_ID="dcaebcdd-a81a-4386-a0df-85d9aaafe862" curl -X POST "$BASE/v1/calls/$CALL_ID/cancel" \ -H "Authorization: Bearer $YK" ``` Réponse (HTTP **200**) : ```json { "id": "dcaebcdd-a81a-4386-a0df-85d9aaafe862", "status": "cancelled", "cancelled_state": "ringing", "billed_seconds": 0, "cancelled_at": "2026-04-20T14:00:16.123456Z" } ``` **Facturation** : `billed_seconds = 0` si annulé avant `connected`, sinon `ceil(now - accepted_at)` avec plancher 10 s. Un event webhook `call.cancelled` est émis sur cancel effectif (cf `docs/webhooks.md` §7). **Idempotent** : rejouer le cancel sur un call déjà terminal (`ended`, `failed`, `cancelled`) retourne `200` avec le snapshot courant, **sans** re-déclencher d'event webhook ni de débit. --- ## 8. Header `X-API-Version` Chaque réponse contient désormais le header `X-API-Version` — exemple : ```http HTTP/1.1 201 Created X-API-Version: v1 Content-Type: application/json ``` Si vous recevez une valeur inattendue (ex. `v2`), log + alerte — votre SDK a probablement besoin d'une mise à jour. --- # === docs/webhooks.md === # Webhooks Yasmine Guide pour configurer et recevoir les notifications sortantes Yasmine. > Ce document décrit les webhooks **sortants** émis par Yasmine vers l'endpoint reseller. Les webhooks entrants (opérateurs de messagerie vers Yasmine) sont une implémentation interne et ne sont pas documentés ici. > **Statut** : > - **Phase 1 livrée (2026-04-20)** : `POST/GET/DELETE /v1/me/webhooks` self-service. Un reseller peut enregistrer son endpoint et récupérer son secret HMAC. > - **Phase 2 livrée (2026-04-20)** : émission réelle des events (initialement 11 events `call.*`) via dispatcher async + retry 3× (0s / 30s / 5min), timeout 10 s, SSRF re-check à chaque POST. > - **Phase 3 livrée (2026-04-26)** : enrichissement à **18 events `call.*`** — ajout du rail TEMPLATE (cycle de vie du message WhatsApp), distinction `permission_revoked` (manuelle) / `permission_auto_revoked` (système) / `permission_refused` (clic Refuser), nouveau `service_unavailable` qui isole les problèmes système Yasmine du `call.failed` générique. --- ## 1. Configuration (live) ### 1.1 Créer ```bash curl -X POST https://api.yasmine.akidly.com/v1/me/webhooks \ -H "Authorization: Bearer yk_..." \ -H "Content-Type: application/json" \ -d '{"url":"https://monshop.com/yasmine-webhooks"}' ``` Réponse `201` : ```json { "url": "https://monshop.com/yasmine-webhooks", "secret": "AbCdEfGhIj...1234", "created_at": "2026-04-20T16:00:00Z" } ``` **Le `secret` n'est affiché qu'une seule fois.** Le stocker immédiatement — aucune route ne permet de le récupérer ensuite (cf `GET` ci-dessous qui n'expose qu'un préfixe). ### 1.2 Consulter ```bash curl https://api.yasmine.akidly.com/v1/me/webhooks \ -H "Authorization: Bearer yk_..." ``` Réponse `200` : ```json { "url": "https://monshop.com/yasmine-webhooks", "secret_prefix": "AbCdEf…", "active": true, "created_at": "2026-04-20T16:00:00Z" } ``` ### 1.3 Désactiver ```bash curl -X DELETE https://api.yasmine.akidly.com/v1/me/webhooks \ -H "Authorization: Bearer yk_..." ``` Réponse `204 No Content`. Soft-delete : la config passe en `active=false`, l'historique `webhook_deliveries` reste intact. ### 1.4 Rotation du secret Utiliser l'endpoint dédié — **pas besoin** de delete/recreate : ```bash curl -X POST https://api.yasmine.akidly.com/v1/me/webhooks/rotate-secret \ -H "Authorization: Bearer yk_..." ``` Réponse `200` : ```json { "secret": "whsec_tAunISmhee9BvK-PpHE5GfXZp_0WnMJwZB6SRbZUpng", "secret_prefix": "whsec_tAunIS…", "rotated_at": "2026-04-21T12:00:00.000Z" } ``` **Caractéristiques** : - Format secret : `whsec_` + 43 chars base64url (entropie 256 bits). Visible **une seule fois** dans la réponse — aucun endpoint ne la ré-expose. Perte = re-rotation. - Atomique : l'ancien secret ne signe plus aucun event dès le retour 200. Le dispatcher relit `webhook.secret` à chaque POST, pas de cache in-memory. - `404 webhook_not_configured` si aucun webhook actif (configurer d'abord via `POST /v1/me/webhooks`). - Rate-limit **5/min** (aligné sur `POST /v1/me/api-keys`) — au-delà → 429 RFC 7807. **Legacy** : si vous préférez le pattern delete/recreate historique, il reste supporté : ```bash curl -X DELETE https://api.yasmine.akidly.com/v1/me/webhooks -H "Authorization: Bearer yk_..." curl -X POST https://api.yasmine.akidly.com/v1/me/webhooks -H "Authorization: Bearer yk_..." \ -H "Content-Type: application/json" -d '{"url":"https://monshop.com/yasmine-webhooks"}' ``` mais `rotate-secret` est préféré (sans indisponibilité entre les 2 appels, préserve l'URL, pas de 409 à gérer). ### 1.5 URLs rejetées (SSRF guard) Retour `400 webhook_url_rejected` pour : - scheme autre que `https` (en prod) ; - `localhost`, `*.local` ; - IPs privées (RFC 1918 `10.*`/`172.16-31.*`/`192.168.*`, loopback `127.*`, link-local `169.254.*`, IPv6 `::1`/`fe80::/10`/`fc00::/7`) ; - hostnames qui ne résolvent pas DNS. Le payload d'erreur inclut `reason` parmi : `invalid_url`, `scheme_not_allowed`, `localhost_rejected`, `dns_resolution_failed`, `private_ip_rejected`. --- ## 2. Modèle d'emission (Phase 2) À chaque événement, Yasmine enverra une requête `POST` vers l'URL configurée : ```http POST /your/webhook/endpoint HTTP/1.1 Host: your-app.example.com Content-Type: application/json User-Agent: Yasmine-Webhooks/1.0 X-Yasmine-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd X-Yasmine-Event: call.ended X-Yasmine-Event-Id: evt_01HTYE9Q4QK3M1B5X7Z2V8W6RF { "id": "evt_01HTYE9Q4QK3M1B5X7Z2V8W6RF", "type": "call.ended", "created_at": "2026-04-19T14:40:00.123Z", "livemode": true, "data": { "call_id": "d5a97d2b-1f3a-4c8b-9d51-2c3a4b5c6d7e", "result": "confirmed", "result_detail": null, "customer_mood": "positive", "flags": [], "preferences": [], "next_action": null, "summary": "Le client confirme la commande, livraison normale.", "duration_s": 37, "billable_s": 37, "recording_url": "/v1/calls/d5a97d2b-1f3a-4c8b-9d51-2c3a4b5c6d7e/recording" } } ``` **Réponse attendue** : `2xx` dans les **10 secondes**. Tout autre code ou timeout déclenche un retry (cf §4). --- ## 3. Enveloppe standard | Champ | Type | Description | |---|---|---| | `id` | `string` | ID unique de l'event. Préfixé `evt_`. ULID. | | `type` | `string` | Nom de l'event (cf catalogue Phase 2). | | `created_at` | `string` (ISO 8601) | Date d'émission côté Yasmine. | | `livemode` | `boolean` | `true` en prod. Sandbox séparé = dette future (P1). | | `data` | `object` | Contenu spécifique au `type` de l'event — voir §7 Catalogue d'événements pour le schéma exact des champs par type. | L'ordre d'arrivée **n'est pas garanti** (retries réseau). Utilise `created_at` + `id` pour ordonner côté reseller. --- ## 4. Politique de retry (Phase 2) Décision produit : 3 tentatives, cadence alignée Stripe/Twilio. | Tentative | Délai depuis la précédente | Timeout HTTP | |---|---|---| | 1 | immédiat (à la transition d'état) | 10 s | | 2 | +30 s | 10 s | | 3 | +5 min | 10 s | Après **3 échecs consécutifs**, l'event est abandonné. Le champ `error` de la dernière ligne `webhook_deliveries` reflète la raison du dernier échec — slugs possibles : `timeout`, `connect_error:*`, `http_error:*`, `unknown:*` (le suffixe après `:` détaille le type d'exception ou le message d'erreur, tronqué à 200 caractères). Un endpoint de replay manuel est prévu en P2. Un event est considéré **livré** dès qu'un `2xx` est reçu. Tout `3xx`, `4xx`, `5xx`, timeout, ou erreur DNS/TLS = échec → retry. ### Tester votre endpoint Avant d'attendre un vrai event en prod, vous pouvez forcer Yasmine à POSTer un `webhook.test` synchrone vers votre URL : ```bash curl -X POST -H "Authorization: Bearer $YK" \ https://api.yasmine.akidly.com/v1/me/webhooks/test # { # "test_id": "...", # "delivered": true, # "http_status": 200, # "latency_ms": 142, # "target_url": "https://votre-endpoint.com/yasmine", # "attempted_at": "2026-04-21T12:00:00Z", # "error_message": null # } ``` Le payload reçu côté votre endpoint : ```json { "id": "evt_test_", "type": "webhook.test", "created_at": "2026-04-21T12:00:00Z", "livemode": true, "data": { "test": true, "test_id": "...", "request_id": "..." } } ``` Filtrez côté votre code sur `data.test === true` pour ignorer ces événements en logique métier. **Timeout 5 s, pas de retry**, **rate-limit 10/min** (cumulé avec 600/min global). Toutes les tentatives sont tracées dans `webhook_deliveries` avec `event_type='webhook.test'` (visibles via `GET /v1/me/webhooks/deliveries`). ### Auditer vos livraisons ```bash # Liste paginée des dernières livraisons (tous events, défaut 50, max 200). curl -H "Authorization: Bearer $YK" \ "https://api.yasmine.akidly.com/v1/me/webhooks/deliveries?limit=20" # Filtrer sur les échecs uniquement. curl -H "Authorization: Bearer $YK" \ "...?status=failed" # Sur un type d'event spécifique. curl -H "Authorization: Bearer $YK" \ "...?event_type=call.ended&since=2026-04-01T00:00:00Z" # Page suivante. curl -H "Authorization: Bearer $YK" \ "...?cursor=eyJkaXIi..." ``` 11 champs exposés par ligne : `delivery_id`, `event_type`, `target_url`, `http_status`, `latency_ms`, `attempt_count`, `status` (`delivered`/`failed`/`pending`), `next_retry_at`, `created_at`, `last_attempted_at`, `error_message` (tronqué 200 chars). Anti-BOPLA strict : ni `response_body`, ni `signature_sent`, ni headers internes. ### Garde-fous techniques côté Yasmine Transparent pour le reseller — documenté pour information. - **Pas de suivi de redirection**. Un `3xx` est traité comme un échec → retry. Si votre endpoint répond en 3xx vers une autre URL, configurez l'URL finale directement via `POST /v1/me/webhooks` au lieu d'attendre que Yasmine la suive. - **Body response borné à 1024 bytes**. Yasmine lit votre réponse en streaming et coupe le flux dès 1024 B reçus. Inutile de renvoyer du contenu volumineux — un `200 OK` vide suffit. Le body tronqué est stocké dans l'historique de livraison côté Yasmine pour debug ops. - **Anti-GC sur retries** : nos tentatives 2/3 (à +30 s / +5 min) sont protégées par un `set[asyncio.Task]` module-level, donc elles arrivent même si le process est sous charge. --- ## 5. Signature HMAC Yasmine signe chaque webhook avec le secret généré à la création. ### Header ``` X-Yasmine-Signature: sha256= ``` Où `` = `HMAC_SHA256(secret, raw_body_bytes)`, lowercase hex. ### Vérification — Python ```python import hashlib import hmac WEBHOOK_SECRET = "..." # récupéré une seule fois via POST /v1/me/webhooks def verify(secret: str, body: bytes, sig_header: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, sig_header) # FastAPI from fastapi import FastAPI, Request, HTTPException app = FastAPI() @app.post("/webhooks/yasmine") async def receive(request: Request): raw = await request.body() sig = request.headers.get("X-Yasmine-Signature", "") if not verify(WEBHOOK_SECRET, raw, sig): raise HTTPException(status_code=401, detail="invalid signature") # traiter l'event de façon idempotente (cf §6) return {"received": True} ``` ### Vérification — Node.js ```javascript import crypto from "node:crypto"; const WEBHOOK_SECRET = "..."; function verify(secret, bodyRaw, sigHeader) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(bodyRaw).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(sigHeader); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express — attention au raw body import express from "express"; const app = express(); app.post( "/webhooks/yasmine", express.raw({ type: "application/json" }), (req, res) => { const sig = req.headers["x-yasmine-signature"] || ""; if (!verify(WEBHOOK_SECRET, req.body, sig)) { return res.status(401).send("invalid signature"); } const event = JSON.parse(req.body); // traiter de façon idempotente res.json({ received: true }); } ); ``` --- ## 6. Idempotence côté reseller (dédupliquer les retries) Un event **peut arriver plusieurs fois** en cas de timeout/5xx côté votre endpoint : Yasmine retente jusqu'à 3 fois (backoff 0 s / 30 s / 5 min, cf §4). **Les retries portent le même `X-Yasmine-Event-Id`** — utilisez-le pour dédupliquer. L'`id` dans le body est la source de vérité (signé par HMAC §5, non-altérable par un MITM) ; le header `X-Yasmine-Event-Id` est la même valeur, répliquée pour les outils qui ne veulent pas parser le JSON. **Option Redis** (production-safe, O(1)) : ```python def handle_webhook(request): event_id = request.headers["X-Yasmine-Event-Id"] # SET NX EX 86400 : 1re écriture réussit, les suivantes retournent None. if redis.set(f"yasmine:event:{event_id}", "1", nx=True, ex=86400) is None: return Response(status=200) # déjà traité, idempotent, pas de retraitement # ... traitement métier une seule fois ``` **Option DB** (sans Redis) — UNIQUE INDEX sur la colonne `event_id` : ```sql CREATE UNIQUE INDEX ON webhook_events (event_id); ``` ```python try: db.insert(webhook_events, event_id=event_id, received_at=now()) except UniqueViolation: return Response(status=200) # déjà reçu ``` **Toujours répondre `200 OK` rapidement** sur un duplicate — sinon Yasmine continuera de retenter et polluera vos logs. TTL recommandé : 24 h (> fenêtre maximale de retry 5 min × 3 tentatives, avec marge). --- ## 7. Catalogue d'événements (live depuis Phase 2, enrichi en Phase 3) **18 events `call.*`**. Organisés en 3 rails : DEMANDE (7), TEMPLATE (4 — Phase 3), APPEL (7 — dont service_unavailable). Plus l'event utilitaire `webhook.test` (déclenché par `POST /v1/me/webhooks/test`, payload détaillé en fin de §7). Payloads `data.*` ci-dessous. Envelope standard cf §3. ### Rail DEMANDE (vie de l'autorisation utilisateur) #### `call.request.accepted` Déclenché quand l'utilisateur a cliqué **Autoriser** sur le bouton du template d'autorisation reçu par WhatsApp. La permission est acquise, l'appel va partir. ```json { "call_id": "d5a97d2b-...", "permission_type": "permanent", "expires_at": null } ``` - `permission_type` : `permanent` (autorisation permanente) ou `temporary` (7 j glissants). - `expires_at` : ISO 8601 Z si `temporary`, `null` si `permanent`. #### `call.request.refused` L'utilisateur a cliqué **Ne pas autoriser** sur le bouton du template. Aucune facturation. Aucun event rail APPEL ne suivra. ```json { "call_id": "d5a97d2b-..." } ``` #### `call.request.expired` Timeout template (180 s par défaut) sans réponse utilisateur. Aucun event rail APPEL ne suivra. ```json { "call_id": "d5a97d2b-...", "reason": "user_timeout" } ``` #### `call.request.quota_blocked` Yasmine a bloqué l'envoi du template d'autorisation pour protéger la qualité du canal WhatsApp (guards proactifs §4.2 / §4.3). ```json { "call_id": "d5a97d2b-...", "reason": "recipient_not_authorized", "limit_type": "1_per_24h" } ``` - `limit_type` parmi : - `1_per_24h` : 1 demande d'autorisation déjà envoyée au même `wa_id` dans les 24 h. - `2_per_7d` : 2 dans les 7 j glissants. - `4_no_answer_revoked` : 4 `NO_ANSWER` consécutifs → Yasmine anticipe la révocation système. **Contrainte privacy** : ce payload ne leak jamais quel marchand a consommé le quota, ni l'énumération des calls précédents. C'est une info interne Yasmine. Si le reseller demande pourquoi, la réponse est générique : « ce client n'a pas autorisé les appels automatisés sur notre réseau pour le moment ». #### `call.request.permission_granted_late` Cas spécial : l'utilisateur a cliqué **Autoriser** APRÈS que le rail DEMANDE soit déjà terminal (expired / refused / cancelled). L'appel actuel ne reprend pas, mais la permission est désormais acquise. ```json { "call_id": "d5a97d2b-...", "permission_type": "temporary", "expires_at": "2026-04-27T14:40:00.000000Z", "hint": "re-submit POST /v1/calls for same customer to dial immediately" } ``` #### `call.request.permission_revoked` **Phase 3 (2026-04-26)**. Le client a révoqué manuellement l'autorisation depuis les paramètres WhatsApp (chat info → autorisations d'appel) — pas via un clic sur un template Yasmine. Pas de `call_id` car l'action n'est pas liée à un appel précis. Permet au reseller d'arrêter de tenter des appels sur ce client tant qu'il n'a pas re-consenti. ```json { "wa_id_masked": "wa_8b6832c463e5", "at": "2026-04-26T10:00:00.123456Z" } ``` - `wa_id_masked` : identifiant WhatsApp anonymisé (hash stable, pas de PII). - `at` : ISO 8601 Z, moment de la révocation côté Yasmine. **Note** : si plusieurs resellers ont récemment contacté ce client, seul le dernier reseller actif est notifié (résolution heuristique via dernier envoi de template). #### `call.request.permission_auto_revoked` **Phase 3 (2026-04-26)**. La plateforme WhatsApp a révoqué automatiquement l'autorisation du client après 4 appels sans réponse consécutifs. Yasmine reçoit ce signal du système — la prochaine tentative passera obligatoirement par une nouvelle demande d'autorisation. Pas de `call_id`. ```json { "wa_id_masked": "wa_8b6832c463e5", "at": "2026-04-26T10:00:00.123456Z" } ``` Distinct de `permission_revoked` (action manuelle utilisateur). Sémantiquement plus dur : le client a probablement perdu confiance ou n'utilise plus WhatsApp activement. ### Rail TEMPLATE (vie du message d'autorisation WhatsApp) **Nouveau Phase 3 (2026-04-26)**. Permet au reseller de suivre la chaîne de livraison du message d'autorisation envoyé au client, depuis l'expédition jusqu'à la lecture. Utile pour comprendre où en est le client avant de prendre des décisions (annulation prématurée d'une commande, relance manuelle, etc.). Émission progressive : un template envoyé à un client en ligne déclenchera typiquement `template_sent` → `template_delivered` → `template_read` en quelques secondes, puis `request.accepted` ou `request.refused` selon le clic. #### `call.request.template_sent` Yasmine vient d'envoyer le message d'autorisation au client via WhatsApp. Confirmation d'expédition côté plateforme. ```json { "call_id": "d5a97d2b-...", "at": "2026-04-26T10:00:00.123456Z" } ``` #### `call.request.template_delivered` Le téléphone du client a reçu le message (livraison réseau confirmée par la plateforme). Le client n'a pas encore ouvert WhatsApp. ```json { "call_id": "d5a97d2b-...", "at": "2026-04-26T10:00:02.456789Z" } ``` #### `call.request.template_read` Le client a ouvert le chat dans WhatsApp et a vu le message. Pas encore cliqué sur les boutons. Bonne indication d'attente côté reseller : le client est probablement actif. ```json { "call_id": "d5a97d2b-...", "at": "2026-04-26T10:00:15.789012Z" } ``` #### `call.request.template_delivery_failed` La plateforme WhatsApp signale qu'elle n'a pas pu livrer le message au client : numéro pas inscrit sur WhatsApp, version trop ancienne, conditions d'utilisation non acceptées, ou opt-out marketing utilisateur. Distinct des problèmes côté Yasmine (cf `service_unavailable` ci-dessous). ```json { "call_id": "d5a97d2b-...", "at": "2026-04-26T10:01:00.000000Z", "reason": "delivery_unavailable" } ``` - `reason` parmi : `delivery_unavailable` (cas standard), `unknown_error` (cause non parsable côté Yasmine). Aucun event rail APPEL ne suivra. Le reseller peut basculer sur un autre canal (SMS classique, appel manuel) ou désactiver les notifications pour ce client. ### Rail APPEL (vie de l'appel téléphonique) > **Note** : seuls les **trois events terminaux** du rail APPEL portent les champs `country` (ISO) et `language` (`ar`/`fr`) — `call.ended`, `call.cancelled`, `call.failed`. Les events intermédiaires (`call.started`, `call.ringing`, `call.connected`) ne les portent **pas** — ils portent en revanche vos références `shop_external_id` / `order_external_id`, comme tous les events d'appel : intentionnel, le reseller a déjà ces deux informations depuis sa propre requête `POST /v1/calls`. Idem pour les events `call.request.*` du rail DEMANDE et pour `call.request.permission_revoked` / `call.request.permission_auto_revoked` (scope `wa_id`, pas d'appel précis associé). #### `call.started` Transition `call_status → dialing`. La plateforme a accepté la requête d'appel, la composition est en cours. ```json { "call_id": "d5a97d2b-..." } ``` #### `call.ringing` Transition `call_status → ringing`. Le téléphone du client sonne. ```json { "call_id": "d5a97d2b-...", "shop_external_id": "BOUTIQUE-01", "order_external_id": "CMD-2026-8891" } ``` - Vos références y figurent, comme sur les events terminaux : vous reconnaissez la commande concernée sans rien avoir mémorisé. #### `call.connected` Transition `call_status → connected`. Le client a décroché, le pipeline IA démarre. ```json { "call_id": "d5a97d2b-...", "accepted_at": "2026-04-20T16:28:20.485030Z" } ``` #### `call.ended` Transition `call_status → ended`. Fin d'appel normale. Facturation appliquée. ```json { "call_id": "d5a97d2b-...", "shop_external_id": "BOUTIQUE-01", "order_external_id": "CMD-2026-8891", "result": "confirmed", "result_detail": "modified", "customer_mood": "positive", "flags": ["confirmed_by_relative"], "preferences": ["livraison mardi 14h", "appeler avant"], "next_action": "rappeler mardi 13h45", "summary": "La cliente confirme la commande, demande la livraison mardi 14h et un appel préalable.", "duration_s": 37, "billable_s": 37, "recording_url": "/v1/calls/d5a97d2b-.../recording", "country": "DZ", "language": "ar" } ``` - `shop_external_id` / `order_external_id` : **vos** références, celles que vous nous avez données en déclarant la boutique et la commande. Elles vous évitent de tenir une table de correspondance avec nos identifiants : vous retrouvez la commande concernée sans rien avoir eu à mémoriser au lancement. `call_id` reste utile pour l'enregistrement et pour dédupliquer un retry. - `result` parmi `confirmed` / `cancelled` / `requires_action`. `confirmed` = la commande est confirmée (à facturer normalement). `cancelled` = la commande est annulée. `requires_action` = aucune décision automatique, le marchand traite manuellement. - `result_detail` : nuance fine. Slugs courants : `modified` (commande confirmée avec une modification — cas type « oui mais en bleu », à facturer comme confirmée), `wrong_number`, `denied_order`, `human_requested`, `price_dispute`, `postponed`, `callback`, `unconfirmed`, `unclear`, `no_answer`, `failed`. `null` quand `result` est `confirmed` ou `cancelled` sans nuance particulière. - `customer_mood` parmi `positive` / `neutral` / `negative` / `frustrated` / `unclear`, ou `null` si non détectable. `unclear` signale que l'audio était globalement incompréhensible (corrélé généralement avec `result_detail=unclear`). - `flags` : drapeaux qualitatifs levés pendant l'appel (liste, possiblement vide). Valeurs courantes : `wrong_number`, `human_requested`, `price_dispute`, `address_incomplete`, `denied_order`, `audio_quality_bad`, `confirmed_by_relative`. - `preferences` : préférences ou demandes spécifiques exprimées par le client (date de livraison souhaitée, créneau, modification produit, numéro alternatif…). Liste de chaînes courtes, possiblement vide. - `next_action` : action de suivi suggérée pour la boutique, ou `null`. - `summary` : résumé textuel de la conversation (1-3 phrases courtes). Peut contenir des informations PII du client (nom, adresse) telles qu'évoquées pendant l'appel. - `duration_s` : durée totale de l'appel côté plateforme. - `billable_s` : secondes décomptées du solde reseller. - `recording_url` : URL **relative** pour télécharger l'enregistrement audio mixte (client + agent) au format WAV mono 16 kHz. Auth Bearer requise comme les autres endpoints `/v1`. Préfixer avec votre base URL d'API (`https://api.yasmine.akidly.com`). `null` si l'audio n'est pas (encore) disponible (cas rare : crash mid-write côté Yasmine). Rétention 30 jours, puis l'endpoint renvoie `410 Gone` (slug `recording_gone`). - `country` (depuis le Lot 2 chantier pays × langue) : code ISO 3166-1 alpha-2 (`MA`/`DZ`/`TN`/`FR`), echo de la valeur fournie à la création. Absent sur les appels antérieurs au Lot 2. - `language` (depuis le Lot 2 chantier pays × langue) : langue effectivement utilisée (`ar` ou `fr`). Pratique quand vous n'aviez pas spécifié `language` à la création — ce champ vous renvoie la langue locale du pays appliquée par défaut. Absent sur les appels antérieurs au Lot 2. #### `call.cancelled` Émis quand le reseller appelle `POST /v1/calls/{id}/cancel`. Distinct de `call.ended` (fin naturelle) et `call.failed` (échec infra). Facturation ajustée sur le payload même. ```json { "call_id": "d5a97d2b-...", "shop_external_id": "BOUTIQUE-01", "order_external_id": "CMD-2026-8891", "cancelled_at": "2026-04-20T14:00:16.123456Z", "cancelled_state": "connected", "billed_seconds": 25, "shop_id": "a5797a0a-...", "country": "MA", "language": "fr" } ``` - `shop_external_id` / `order_external_id` : vos références, mêmes sémantiques que pour `call.ended`. - `cancelled_state` : `call_status` au moment où le cancel a été traité (`not_started`/`dialing`/`ringing`/`connected`). Permet au reseller de savoir à quel stade l'appel a été coupé. - `billed_seconds` : 0 si cancel avant `connected` ; sinon `ceil(now - accepted_at)` avec plancher 10 s. - `country` / `language` (depuis le Lot 2 chantier pays × langue) : mêmes sémantiques que pour `call.ended`. Absents sur les cancels d'appels antérieurs au Lot 2. **Pas d'event ré-émis** si le cancel est idempotent (appel déjà terminal au moment de la requête). #### `call.failed` Transition `call_status → failed`. Échec infra côté plateforme ou côté client (téléphone pas WhatsApp, opt-out, refus avant accept). **Aucune facturation**. ```json { "call_id": "d5a97d2b-...", "shop_external_id": "BOUTIQUE-01", "order_external_id": "CMD-2026-8891", "failure_reason": "meta_400:131030", "country": "FR", "language": "fr" } ``` - `shop_external_id` / `order_external_id` : vos références, mêmes sémantiques que pour `call.ended`. - `failure_reason` parmi (non exhaustif) : - `client_rejected` : client a refusé avant d'accepter l'appel (webhook plateforme). - `meta_[:]` : erreur côté plateforme avec code spécifique (ex. `meta_400:131030`, `meta_500`). **Phase 3 (2026-04-26)** : le code numérique est désormais propagé systématiquement quand disponible — auparavant le slug était `meta_` seul, le code étant uniquement visible en logs internes. - `run_origination_unexpected:` : crash non-prévu dans le handler origination. - `resumed_after_restart` : call marqué échoué au redémarrage (process a redémarré pendant que ce call était en cours). - `janitor_projection` : le janitor a réconcilié un état d'échec via un poll plateforme. - `country` / `language` (depuis le Lot 2 chantier pays × langue) : mêmes sémantiques que pour `call.ended`. Absents sur les échecs d'appels antérieurs au Lot 2. **Phase 3** : pour les codes liés à un problème système Yasmine (compte bloqué, template paused, rate limit Yasmine, etc.), Yasmine ne dispatche plus `call.failed` mais le nouveau `call.request.service_unavailable` (cf ci-dessous) — pour que le reseller distingue clairement un échec définitif côté client d'un problème transitoire côté Yasmine. ### Service indisponible (Phase 3, 2026-04-26) #### `call.request.service_unavailable` Yasmine a rencontré un problème côté son système (compte WhatsApp momentanément bloqué, template en révision, saturation passagère, token à renouveler, etc.). Distinct de `call.failed` (échec définitif côté client). Le reseller peut retenter après le délai suggéré. ```json { "call_id": "d5a97d2b-...", "reason": "service_temporarily_unavailable", "retry_after_hint_minutes": 60 } ``` - `reason` parmi : - `service_temporarily_unavailable` : problème système Yasmine (compte / template / token). - `rate_limit_or_throughput` : saturation passagère (rate limit, throughput). - `retry_after_hint_minutes` : délai suggéré avant retry, en minutes (entier). Quelques exemples : - `1` à `5` : saturation passagère, retry rapide possible. - `60` : problème système modéré (token à renouveler, etc.). - `240` à `1440` : problème lourd (compte bloqué, template à corriger côté Yasmine), retry après plusieurs heures voire 24 h. **Recommandation reseller** : implémenter un backoff exponentiel borné par `retry_after_hint_minutes`. Ne pas spammer le client avec des retentatives immédiates. Si le `retry_after_hint_minutes` est élevé (≥ 60), considérer une notification interne (ops Yasmine alerté, logs du reseller à surveiller). Le même event peut être dispatché aussi bien au moment de l'envoi (échec immédiat côté plateforme avec un code système) qu'après livraison (la plateforme rejette tardivement le message pour une raison côté Yasmine — vu en prod 2026-04-25 lors d'un blocage temporaire du compte business). ### Utilitaire — `webhook.test` #### `webhook.test` Émis **uniquement** quand le reseller appelle `POST /v1/me/webhooks/test` (cf §4 « Tester votre endpoint »). Permet de valider l'intégration côté reseller sans attendre un appel réel. ```json { "test": true, "test_id": "01HVZ4QJ9P3K5W7Y8Z2N4F6M8R", "request_id": "3f8e9d12-4b5a-4c6d-8e7f-123456789abc" } ``` - `test` : toujours `true`. Distingue le payload d'un event métier — un handler reseller peut court-circuiter sa logique commerciale sur cette clé. - `test_id` : ULID unique de la tentative de test. Permet de corréler avec la response synchrone de `POST /v1/me/webhooks/test` (champ `test_id`) et avec la row `webhook_deliveries` (consultable via `GET /v1/me/webhooks/deliveries?event_type=webhook.test`). - `request_id` : UUID v4 propagé depuis le header `X-Request-ID` de la requête `POST /v1/me/webhooks/test` (ou généré si absent). Pratique pour relier le test côté logs reseller. L'envelope reste standard (`{id: "evt_...", type: "webhook.test", created_at, livemode: true, data: {...}}`). **Distinct des events `call.*`** : non lié à un appel téléphonique, pas de `call_id` dans `data`. Pas de retry automatique sur `webhook.test` — une seule tentative synchrone (timeout 5 s), le résultat est renvoyé directement par `POST /v1/me/webhooks/test`. --- ## 8. Règle d'ordonnancement ### Cycle complet typique d'un appel réussi ``` call.request.template_sent → call.request.template_delivered → call.request.template_read → call.request.accepted → call.started → call.ringing → call.connected → call.ended (result=confirmed|cancelled|requires_action) ``` Les events du rail TEMPLATE (`template_sent` / `template_delivered` / `template_read`) sont émis progressivement selon les signaux de la plateforme WhatsApp. Le `template_delivered` peut arriver collapsé avec `template_read` si le client est déjà dans le chat ouvert. ### Si le rail DEMANDE termine en échec Si le rail DEMANDE termine en échec (`call.request.{refused,expired,quota_blocked,permission_revoked,permission_auto_revoked,template_delivery_failed,service_unavailable}`), **aucun event** du rail APPEL ne sera émis — la demande ne s'est jamais traduite en composition d'appel. Respecté structurellement côté Yasmine. ### Distinction `call.cancelled` vs `call.ended` vs `call.failed` vs `service_unavailable` - `call.ended` = l'appel s'est terminé naturellement (client a raccroché, IA a conclu, timeout normal). `result` défini (`confirmed` / `cancelled` / `requires_action`). - `call.cancelled` = le reseller a explicitement appelé `POST /v1/calls/{id}/cancel`. Toujours volontaire côté Yasmine. - `call.failed` = échec côté plateforme ou côté client (5xx réseau, reclaim lifespan, projection janitor, crash origination, refus client avant accept). Aucune facturation. - `call.request.service_unavailable` = problème transitoire côté Yasmine (compte / template / saturation). Aucune facturation. Distinct de `call.failed` pour aider le reseller à savoir s'il peut retenter ou non. Un call ne peut émettre **qu'un seul** de ces 4 events — ils sont mutuellement exclusifs (états terminaux distincts). --- ## 9. Adresses sources Les webhooks sont émis depuis l'IP du VPS Yasmine. Recommandation : whitelister cette IP en entrée côté reseller (IP communiquée à la mise en service). --- # === docs/errors.md === # Format d'erreur API — RFC 7807 > 💡 **Référence détaillée** : chaque slug est documenté en détail (causes, > correctifs, exemples) sur la page [errors.html](https://docs.yasmine.akidly.com/errors). > Les liens `type` dans les réponses d'erreur pointent directement sur la bonne > section (ex. `https://docs.yasmine.akidly.com/errors/insufficient_balance` > redirige sur `#insufficient_balance`). Ce fichier-ci reste la **référence > rapide** en Markdown (une ligne par slug). Toutes les erreurs des endpoints `/v1/*` sont renvoyées au format [RFC 7807 — Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc7807) avec `Content-Type: application/problem+json`. Chaque erreur identifie sa **classe** via un champ `type` stable — le SDK reseller route là-dessus, pas sur le code HTTP seul. ## Exemple de payload ```http HTTP/1.1 402 Payment Required Content-Type: application/problem+json { "type": "https://docs.yasmine.akidly.com/errors/insufficient_balance", "title": "Payment Required", "status": 402, "detail": "Solde disponible 3s < minimum facturable 10s.", "instance": "/v1/calls", "request_id": "a1b2c3d4", "balance_seconds": 3, "required_seconds": 10 } ``` ## Champs standards | Champ | Description | |---|---| | `type` | URI stable identifiant la classe d'erreur. Forme : `https://docs.yasmine.akidly.com/errors/{slug}`. Le slug est stable entre versions — router dessus côté SDK. | | `title` | Titre court, lisible par un humain. | | `status` | Code HTTP (identique à celui de la réponse). | | `detail` | Message spécifique à cette occurrence. Peut inclure des valeurs réelles. | | `instance` | Chemin URL de la requête qui a échoué. | | `request_id` | Identifiant de la requête (8 caractères). À inclure dans tout ticket de support. | ## Extensions (champs optionnels par type) Certains `type` ajoutent des champs dédiés : - `validation_error` : `errors` = liste des erreurs de validation `[{loc, msg, type}]`. - `insufficient_balance` : `balance_seconds`, `required_seconds`. - `rate_limit_exceeded` : header `Retry-After` + champ `retry_after` (secondes). Aussi `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, `X-RateLimit-Reset` (unix timestamp). ## Table des `type` actuels | Slug | Status | Déclenché quand | |---|---|---| | `bad_request` | 400 | Slug par défaut pour tout `400` qui n'override pas son type. En pratique : la plupart des 400 spécifiques utilisent un slug ciblé (`missing_idempotency_key`, `idempotency_key_*`, `invalid_cursor`, etc.). Si vous voyez `bad_request` brut, c'est un cas non-mappé — lire `detail` pour le diagnostic. | | `invalid_api_key` | 401 | Header `Authorization` absent, malformé, ou clé invalide/révoquée. Le header `WWW-Authenticate: Bearer` est propagé. | | `insufficient_balance` | 402 | Solde reseller < minimum facturable (10 s). | | `forbidden` | 403 | Clé valide mais sans droit sur la ressource. | | `not_found` | 404 | Ressource absente ou non accessible au reseller courant. | | `conflict` | 409 | Conflit générique (cf slugs spécifiques `idempotency_key_conflict`, `webhook_already_configured`). | | `missing_idempotency_key` | 400 | Header `Idempotency-Key` absent sur `POST /v1/calls`. **Obligatoire**. Générer une clé unique par requête (UUID v4 recommandé). | | `idempotency_key_empty` | 400 | Header `Idempotency-Key` présent mais vide. Doit faire au moins 1 char. | | `idempotency_key_too_long` | 400 | Header `Idempotency-Key` > 255 chars. Limite stricte côté serveur. | | `idempotency_key_conflict` | 409 | Même `Idempotency-Key` réutilisée avec un body différent dans la fenêtre de 24 h. Générer une nouvelle clé pour une requête différente. Le body original n'est jamais ré-exposé. | | `order_external_id_already_exists` | 409 | `POST /v1/shops/{id}/orders`, ou `POST /v1/calls` avec `order.external_id`, sur un identifiant déjà utilisé **sur votre compte** — l'unicité vaut toutes boutiques confondues. La création est stricte. Pour rappeler le client sur la même commande : `POST /v1/calls` avec `order_external_id` — les appels s'empilent sur la commande. Pour une commande distincte, une `external_id` différente. | | `order_item_unknown_product` | 422 | `POST /v1/calls` : une ligne référence un produit ou une variante qui n'existe pas dans la boutique de l'appel. Le déclarer, ou décrire l'article sur place — une ligne avec `name` et `unit_price` reste valide, un produit non déclaré ne doit jamais bloquer un appel. | | `order_item_variant_required` | 422 | `POST /v1/calls` : la ligne référence un produit à plusieurs déclinaisons sans préciser laquelle. Le `detail` **liste celles qui existent**. Aucun choix n'est fait au hasard : l'agent annoncerait un article non commandé. | | `order_not_found` | 404 | `GET`/`PATCH`/`DELETE` `/v1/orders/{external_id}`, ou `POST /v1/calls` avec `order_external_id`, sur une commande inexistante, archivée, ou d'un autre compte. **Corps identique dans les trois cas.** | | `order_closed` | 409 | `POST /v1/calls` avec `order_external_id` sur une commande que vous avez **fermée** (`POST /v1/orders/{id}/close`). Le dossier est clos : la rouvrir (`POST /v1/orders/{id}/reopen`) avant de rappeler le client. | | `order_call_in_progress` | 409 | Un appel n'est pas terminé sur cette commande : la modifier, l'archiver ou lancer un autre appel est refusé. Attendre la fin de l'appel — `call_in_progress` sur la fiche de la commande dit quand c'est possible. | | `product_not_found` | 404 | `GET`/`PATCH`/`DELETE` sur un produit inexistant, effacé, ou appartenant à un autre compte. **Corps identique dans les trois cas.** L'identifiant d'un produit est unique sur tout votre compte, toutes boutiques confondues. | | `product_external_id_already_exists` | 409 | `POST` d'un produit dont l'`external_id` est déjà pris **sur votre compte**, toutes boutiques confondues. La création est stricte : utiliser `PATCH` pour modifier. Une référence effacée redevient libre. | | `product_in_pending_orders` | 409 | `DELETE /v1/products/{external_id}`, ou `PATCH` qui retire une déclinaison : une ou plusieurs commandes **ouvertes et non tranchées** (ni confirmées, ni annulées) référencent encore le produit ou la déclinaison — il deviendrait impossible d'appeler ces commandes. Le `detail` nomme la déclinaison et les commandes (les dix premières, et leur nombre total). Fermer ou appeler ces commandes, ou retirer l'article de leurs `items` (`PATCH` de la commande), puis réessayer. | | `shop_not_found` | 404 | `GET`/`PATCH`/`DELETE` `/v1/shops/{external_id}` sur une boutique inexistante, archivée, ou appartenant à un autre compte. **Corps identique dans les trois cas** — aucune fuite d'existence. Lister avec `GET /v1/shops?status=all` pour retrouver une boutique archivée. | | `shop_external_id_already_exists` | 409 | `POST /v1/shops` avec un `external_id` déjà utilisé sur ce compte. La création est stricte, jamais une mise à jour implicite : utiliser `PATCH` pour modifier, ou un autre identifiant pour créer une boutique distincte. | | `api_key_not_found` | 404 | `DELETE /v1/me/api-keys/{key_id}` sur une clé inexistante OU appartenant à un autre reseller. Body byte-identique sur les 2 cas — anti-énumération. | | `invalid_key_id` | 400 | `DELETE /v1/me/api-keys/{key_id}` avec UUID malformé. Se distingue du 404 pour aider le client à diagnostiquer un bug local. | | `webhook_url_not_configured` | 400 | `POST /v1/me/webhooks/test` sans webhook actif. Configurer d'abord via `POST /v1/me/webhooks`. | | `invalid_status_filter` | 400 | `GET /v1/me/webhooks/deliveries?status=...` avec valeur hors `delivered`/`failed`/`pending`. Le `detail` liste les valeurs acceptées. | | `invalid_period_format` | 400 | `GET /v1/me/usage?period=...` avec format non-`YYYY-MM` ou mois hors 01-12. Exemple : `?period=abc`, `?period=2026-13`, `?period=2026-00`. Remédiation : utiliser le format strict ou omettre (défaut = mois courant UTC). | | `invalid_cursor` | 400 | `GET /v1/calls?cursor=...` ou `GET /v1/me/transactions?cursor=...` avec curseur malformé, signature invalide, ou clé de signature tournée côté serveur. Le client refait la 1re requête sans `cursor`. | | `cursor_expired` | 400 | Curseur > 24 h (TTL fixe). Même remédiation : refaire la 1re requête sans `cursor`. | | `payload_too_large` | 413 | Émis sur `POST /webhooks/whatsapp` quand le corps dépasse 256 KiB. En pratique Meta envoie ~50 Ko. Si vous voyez ce code lors d'une rafale de retries Meta, contactez le support — il s'agit probablement d'un payload falsifié ou d'une anomalie côté Meta. **Pas applicable aux endpoints `/v1/*`**, qui ont leur propre validation. | | `validation_error` | 422 | Payload mal formé. Voir `errors`. | | `language_not_supported_for_country` | 422 | `POST /v1/calls` avec une combinaison `(country, language)` non disponible. Aujourd'hui : `country=FR` + `language=ar`. Combinaisons supportées : `MA`/`DZ`/`TN` avec `ar` ou `fr`, `FR` uniquement avec `fr`. Omettre `language` pour appliquer la langue par défaut du pays. | | `channel_not_available` | 422 | `POST /v1/calls` avec un `call_params.channel` fermé sur cette installation. Le `detail` nomme les canaux ouverts et celui par défaut. Omettre `channel` sert le canal par défaut, ce qui convient dans la quasi-totalité des cas. Le refus est explicite à dessein : un appel re-routé en silence vers un autre canal serait indétectable côté intégrateur. | | `rate_limit_exceeded` | 429 | Rate-limit par clé API dépassé. Seuils par endpoint : 60/min POST calls, 120/min cancel, 600/min reads, 10/min config webhooks. Respecter `Retry-After` avant de retenter. Cf `docs/getting-started.md` §Rate limits. | | `rate_limited` | 429 | Slug par défaut pour les 429 qui ne passent pas par le rate-limiter principal `/v1/*` (cas marginal — un 429 émis directement via `HTTPException(429)` sans override). En pratique vous verrez surtout `rate_limit_exceeded`. Même remédiation : respecter `Retry-After`. | | `call_not_found` | 404 | POST `/v1/calls/{id}/cancel` sur un `call_id` inexistant ou appartenant à un autre reseller . Pas de fuite d'existence. Émis aussi par GET `/v1/calls/{id}/recording` quand l'audio n'a pas été produit (call jamais finalisé) ou pour les mêmes raisons de scoping. | | `recording_gone` | 410 | GET `/v1/calls/{id}/recording` sur un appel dont l'enregistrement audio a été purgé après la fenêtre de rétention (30 jours). Distinct de `call_not_found` pour permettre au reseller de différencier « jamais existé » vs « a existé mais purgé ». | | `webhook_url_rejected` | 400 | POST `/v1/me/webhooks` avec URL rejetée par le SSRF guard. Champ `reason` parmi `invalid_url`/`scheme_not_allowed`/`localhost_rejected`/`dns_resolution_failed`/`private_ip_rejected`. | | `webhook_already_configured` | 409 | POST `/v1/me/webhooks` sur reseller déjà configuré. DELETE d'abord pour rotate. | | `webhook_not_configured` | 404 | GET/DELETE `/v1/me/webhooks` sans config active. | | `service_unavailable` | 503 | Dépendance externe indisponible ou config manquante côté Yasmine. | | `internal_error` | 500 | Erreur inattendue (bug). Toujours loguée côté Yasmine avec `request_id`. | | `http_error` | autres 4xx/5xx | Fallback générique si le code HTTP n'est pas mappé. Ne pas dépendre de ce slug. | ## Compatibilité Les URLs `https://docs.yasmine.akidly.com/errors/{slug}` sont **résolvables** : elles redirigent (302) vers la bonne section de la page [errors.html](https://docs.yasmine.akidly.com/errors). Le slug reste l'identifiant stable à utiliser côté SDK pour router une classe d'erreur — l'URL humaine est un bonus. ## Hors `/v1/` Les endpoints hors `/v1/*` (actuellement uniquement les webhooks Meta `POST /webhooks/whatsapp`) gardent un format d'erreur historique : ```json {"detail": "invalid signature"} ``` avec `Content-Type: application/json`. Ce format ne bouge pas — Meta n'interprète pas le body, seul le code HTTP compte. Le changement RFC 7807 est **scopé** au préfixe `/v1/` pour ne pas casser les webhooks déjà en prod. ## Slugs enrichis pour usage agent (MCP `explain_error`) Sections détaillées pour les 5 slugs les plus rencontrés côté reseller. Le tool MCP `explain_error` parse ce bloc en priorité, et tombe sur la ligne brute du tableau ci-dessus pour les autres slugs. Format stable, parsé automatiquement. ### validation_error **Status** : 422 **Causes** : - Champ obligatoire manquant dans le body JSON. - Type incorrect (ex. `amount` envoyé en number au lieu de string). - Valeur hors enum (ex. `country: "ZZ"` alors que seuls `MA`, `DZ`, `TN`, `FR` sont autorisés). - Format incorrect (UUID malformé, ISO date invalide, regex pattern non matché). **Remediation** : - Valider le body côté client avant envoi (générateur OpenAPI, Pydantic, JSON Schema). - Lire le tableau `errors[]` dans la réponse pour le détail par champ : `errors[i].loc` = chemin (ex. `["body", "customer", "phone_number"]` pour un champ nested), `errors[i].msg` = message lisible, `errors[i].type` = code de la règle violée. - Sur les enums, prendre la valeur exacte depuis `docs/openapi.yaml` (les `enum:` sont normatifs). **Example** : ```json { "type": "https://docs.yasmine.akidly.com/errors/validation_error", "title": "Unprocessable Entity", "status": 422, "detail": "Un ou plusieurs champs du payload sont invalides.", "instance": "/v1/calls", "request_id": "a1b2c3d4", "errors": [ {"loc": ["body", "customer", "phone_number"], "msg": "field required", "type": "missing"}, {"loc": ["body", "country"], "msg": "value is not a valid enumeration member", "type": "enum"} ] } ``` ### rate_limit_exceeded **Status** : 429 **Causes** : - Limite par minute dépassée sur la clé API. Seuils par groupe d'endpoint : `60/min` sur `POST /v1/calls`, `120/min` sur `POST /v1/calls/{id}/cancel`, `600/min` sur les reads (`GET /v1/calls`, `GET /v1/me/*`), `10/min` sur les configs webhooks. - Bursts non lissés côté client (script qui itère sans pause). - Plusieurs services partagent la même clé sans coordination. **Remediation** : - Respecter le header `Retry-After` (secondes) avant de retenter — c'est la valeur autoritaire. - Lire `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (timestamp Unix) à chaque réponse 2xx pour anticiper. - Si la clé est partagée par plusieurs processus de votre côté, créer une clé par processus (`POST /v1/me/api-keys`) — chaque clé a son bucket indépendant. - Voir `docs/getting-started.md §Rate limits`. **Example** : ```json { "type": "https://docs.yasmine.akidly.com/errors/rate_limit_exceeded", "title": "Too Many Requests", "status": 429, "detail": "Limite de frequence depassee (60 per 1 minute). Reessayer dans 23 seconde(s).", "instance": "/v1/calls", "request_id": "a1b2c3d4", "retry_after": 23 } ``` ### insufficient_balance **Status** : 402 **Causes** : - Solde reseller en secondes < minimum facturable (10 s). - Premier appel après expiration d'un crédit. - Top-up oublié sur compte de prod. **Remediation** : - **Ne pas retry aveuglément** : le solde reste vide, le 402 sera identique. Aucun appel n'est créé, donc pas de double-facturation à craindre — mais bouclage inutile. - Consulter le solde courant : `GET /v1/me/balance` (live). - Top-up via le support Yasmine. - La réponse contient les champs étendus `balance_seconds` et `required_seconds` pour logger le delta exact. **Example** : ```json { "type": "https://docs.yasmine.akidly.com/errors/insufficient_balance", "title": "Payment Required", "status": 402, "detail": "Solde disponible 3s < minimum facturable 10s.", "instance": "/v1/calls", "request_id": "b3c4d5e6", "balance_seconds": 3, "required_seconds": 10 } ``` ### order_external_id_already_exists **Status** : 409 **Causes** : - `POST /v1/calls` avec un `order.external_id` déjà utilisé **sur votre compte**, quelle que soit la boutique. Les commandes sont créées en insertion stricte, sans écrasement. - Retry d'une commande sans réutiliser la même `Idempotency-Key` (ce qui aurait déclenché un replay de la réponse stockée). - Deux services du reseller qui partagent le même pool d'`external_id` sans coordination. **Remediation** : - Pour **retenter sur la commande existante** : soumettre un nouveau POST avec une nouvelle `Idempotency-Key` + incrémenter `order.previous_attempts`. La même `external_id` reste interdite — à l'avenir une route `POST /v1/calls/retry?order_id=...` dédiée est prévue (M5). - Pour **créer une commande distincte** : générer une nouvelle `external_id` unique côté reseller. - Pour un **retry réseau pur** : garder la **même** `Idempotency-Key` — déclenche un replay (bit-for-bit) sans nouvelle INSERT. **Example** : ```json { "type": "https://docs.yasmine.akidly.com/errors/order_external_id_already_exists", "title": "Order external_id already exists", "status": 409, "detail": "An order with this external_id already exists for this boutique.", "instance": "/v1/calls", "request_id": "a1b2c3d4", "hint": "To retry reaching the customer on this existing order, submit a new POST with a fresh Idempotency-Key and increment order.previous_attempts. To create a distinct order, use a unique external_id." } ``` ### idempotency_key_conflict **Status** : 409 **Causes** : - Même valeur `Idempotency-Key` réutilisée dans la fenêtre 24 h avec un body JSON différent. - Clé hardcodée par erreur dans le code (ex. `"order-2026-001"` réutilisée pour tous les appels). - Retry après modification du payload (changement de numéro, de montant) sans changer la clé. **Remediation** : - Générer une **nouvelle UUID v4** par requête fonctionnellement différente (`uuid.uuid4()` ou équivalent). - Pour un retry réseau pur (timeout, 504 Cloudflare), **garder la même clé** — c'est exactement le cas d'usage idempotence (renvoie la réponse stockée bit-for-bit avec `X-Idempotent-Replay: true`). - Le body original n'est jamais ré-exposé pour des raisons de sécurité (évite leak entre requêtes différentes). **Example** : ```json { "type": "https://docs.yasmine.akidly.com/errors/idempotency_key_conflict", "title": "Conflict", "status": 409, "detail": "Idempotency-Key 'order-cmd-2026-789' a deja ete utilisee avec un body different. Generer une nouvelle cle pour une requete differente.", "instance": "/v1/calls", "request_id": "a1b2c3d4" } ``` ### invalid_cursor **Status** : 400 **Causes** : - Curseur opaque malformé (caractères tronqués, copy-paste partiel). - Signature HMAC tampered (curseur modifié manuellement côté client). - clé de signature des curseurs rotée côté serveur (tous les curseurs antérieurs invalidés). - Confusion entre `invalid_cursor` (format) et `cursor_expired` (TTL > 24 h dépassé) — slug différent, même remédiation. **Remediation** : - Refaire la **première requête sans paramètre `cursor`** pour repartir d'un curseur frais. - Ne **jamais parser** le contenu du curseur côté client — c'est un opaque token signé HMAC-SHA256. - Si l'erreur arrive systématiquement après une certaine date, c'est probablement une rotation de clé serveur — re-pagineriser tout l'historique. **Example** : ```json { "type": "https://docs.yasmine.akidly.com/errors/invalid_cursor", "title": "Bad Request", "status": 400, "detail": "Cursor payload corrupted: invalid signature", "instance": "/v1/calls", "request_id": "a1b2c3d4" } ``` --- # === docs/versioning.md === # Politique de versioning — API Yasmine ## 1. Version dans l'URL L'API utilise un segment de version **majeur** dans le path : ``` https://api.yasmine.akidly.com/v1/calls https://api.yasmine.akidly.com/v2/calls # futur ``` - Une montée de majeure (`/v1` → `/v2`) signale une **breaking change** (changement de schéma, de code HTTP, de sémantique). - Les évolutions **non-breaking** (nouveau champ optionnel, nouveau endpoint, nouveau code d'erreur documenté) restent sous `/v1`. - Pas de versioning mineur dans l'URL. Les changements mineurs sont tracés dans `CHANGELOG.md`. ## 2. Header `X-API-Version` Chaque réponse API expose la version qui l'a servie : ```http HTTP/1.1 200 OK X-API-Version: v1 Content-Type: application/json ``` Permet à un SDK reseller de vérifier contre quoi il parle, et de logger en cas de changement inattendu. ## 3. Semver appliqué au contrat API Le contrat API suit un semver relâché, distinct de la version produit : | Incrément | Déclenche | Exemple | |---|---|---| | MAJOR | Breaking : renommage/suppression de champ, code HTTP modifié, ressource retirée | `v1 → v2` | | MINOR | Ajout rétro-compatible : nouveau champ optionnel, nouvel endpoint, nouvel event webhook | `[0.1.0] → [0.2.0]` dans `CHANGELOG.md` | | PATCH | Correction de bug sans changement de contrat, clarification de docs | `[0.1.0] → [0.1.1]` | ## 4. Dépréciation et sunset Les dépréciations suivent **RFC 8594** (`Sunset` header). ### Cycle de vie 1. **Annonce** : entrée `CHANGELOG.md` avec date de `Deprecated` + date cible de `Removed`. 2. **Phase deprecated** : l'endpoint répond normalement mais inclut : ```http HTTP/1.1 200 OK Deprecation: true Sunset: Wed, 19 Oct 2026 00:00:00 GMT Link: ; rel="deprecation" ``` 3. **Preview minimum 6 mois** entre l'annonce et le `Sunset`. Aucune suppression plus rapide, quelle que soit la pression interne. 4. **Retrait** : après la date `Sunset`, l'endpoint répond `410 Gone` avec un body RFC 7807 pointant vers la nouvelle version. ### Notifications - Entrée dans `CHANGELOG.md` sous `## [X.Y.Z] — Deprecated`. - Event webhook `api.deprecation_notice` pour tous les resellers qui appellent l'endpoint déprécié dans les 30 jours (planned, roadmap P2). ## 5. Champ `x-status` dans OpenAPI La spec `docs/openapi.yaml` annote chaque endpoint : - `x-status: live` — implémenté et stable, appelable en prod aujourd'hui. - `x-status: internal` — route technique hors SLA `/v1/*` (probe santé). Si un nouvel endpoint est annoncé en preview avant implémentation, il sera marqué `x-status: planned` à ce moment-là — la valeur n'a pas d'occurrence dans la spec actuelle. ## 6. Statut de `/v1` en production `/v1` est **live** et **commercialisable** depuis le 2026-04-19 — `POST /v1/calls` est le premier endpoint reseller mis en prod. Les livraisons ultérieures ont étendu la surface **live** : - `GET /v1/calls/{call_id}` (réconciliation scopée reseller, anti-énumération). - `/v1/me/*` (identité, balance, transactions, usage, webhooks, api-keys) — 7 routes self-service. - Webhooks sortants signés HMAC-SHA256 (`call.*` events). L'intégralité des endpoints documentés dans `docs/openapi.yaml` est désormais `x-status: live` (à l'exception de `/healthz` annoté `x-status: internal`, hors SLA `/v1/*`). ## 7. Environnements | Env | URL | Statut | |---|---|---| | Prod | `https://api.yasmine.akidly.com` | live | | Sandbox | `https://api-sandbox.yasmine.akidly.com` | planned — à voir plus tard | Tant que la sandbox n'existe pas, tous les appels de test consomment du crédit réel. Les resellers le savent via `docs/getting-started.md`. --- # === docs/integrations.md === # Intégrer Yasmine Yasmine déclenche des **appels vocaux IA sortants** pour confirmer ou relancer des commandes clients (marchés MA/DZ/TN/FR). Vous envoyez une requête, un agent conversationnel appelle votre client, vous recevez le résultat via API ou webhook. Trois chemins d'intégration au choix — ils partagent la même API sous-jacente, exposée différemment selon votre contexte : - **[Mode A — API REST directe](#mode-a--api-rest-directe)** — pour un backend serveur ou un workflow n8n/Zapier. - **[Mode B — MCP server](#mode-b--mcp-server)** — pour un agent IA (Claude Desktop, ChatGPT Desktop MCP, ou autre client compatible MCP). - **[Mode C — Plugin Claude Code](#mode-c--plugin-claude-code)** — pour un·e dev·e qui travaille avec Claude Code et veut l'outil intégré dans son IDE. --- ## Mode A — API REST directe Usage typique : déclencher des appels depuis votre backend e-commerce (Shopify, WooCommerce, Magento, custom). - **Base URL** : `https://api.yasmine.akidly.com` - **Auth** : header `Authorization: Bearer yk_` sur toutes les routes `/v1/*`. - **Format erreurs** : [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) `application/problem+json` — routez sur le champ `type` (URI stable), pas sur le code HTTP seul. ### Exemple minimum — déclencher un appel ```bash curl -X POST https://api.yasmine.akidly.com/v1/calls \ -H "Authorization: Bearer yk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Ahmed Bennani", "phone_number": "+212612345678" }, "shop_external_id": "ma-boutique-test", "shop_info": {"name": "Ma Boutique Test"}, "order": { "delivery_address": "12 Rue X, Casablanca", "amount": "249.00", "currency": "MAD" }, "country": "MA", "call_params": {"purpose": "confirmation"} }' ``` Réponse `201 Created` avec `id`, `status: queued`, `created_at`, `customer_phone_masked`. L'agent compose, sonne, converse, raccroche (~40-60 secondes). Suivi via `GET /v1/calls/{id}` ou webhook sortant. ### Surface complète 25 opérations au total, réparties par tag OpenAPI : | Tag | Endpoints principaux | |---|---| | `Calls` | `POST /v1/calls`, `GET /v1/calls`, `GET /v1/calls/{id}`, `POST /v1/calls/{id}/cancel` | | `Account` | `GET /v1/me`, `GET /v1/me/balance`, `GET /v1/me/transactions`, `GET /v1/me/usage` | | `API Keys` | `POST/GET /v1/me/api-keys`, `DELETE /v1/me/api-keys/{id}` | | `Webhooks` | `POST/GET/DELETE /v1/me/webhooks`, `POST /v1/me/webhooks/test`, `POST /v1/me/webhooks/rotate-secret`, `GET /v1/me/webhooks/deliveries` | | `Merchants` | Planifié (M4+) | Spec complète et interactive : **[docs.yasmine.akidly.com](https://docs.yasmine.akidly.com)**. --- ## Mode B — MCP server Usage typique : piloter Yasmine depuis un agent IA (Claude Desktop, Cursor, ou tout client compatible [Model Context Protocol](https://modelcontextprotocol.io)). - **URL endpoint** : `https://mcp.yasmine.akidly.com/mcp/` - **Transport** : Streamable HTTP (spec MCP `2025-11-25`) - **Auth** : header `Authorization: Bearer yk_` (la même clé que l'API REST directe). ### Tools exposés 8 tools sous le namespace `yasmine` : **Actions sur votre compte** (pass-through API) - `get_account` — infos reseller, solde, rate limits - `create_call` — déclencher un appel - `get_call` — statut + transcription d'un appel - `list_calls` — historique paginé de vos appels **Introspection** (lecture des docs directement par l'agent) - `list_endpoints` — surface complète de l'API - `get_endpoint_spec` — spec OpenAPI d'une opération précise - `get_changelog` — évolutions récentes - `explain_error` — aide structurée sur un slug RFC 7807 ### Config Claude Desktop Éditer `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) ou `%APPDATA%\Claude\claude_desktop_config.json` (Windows) : ```json { "mcpServers": { "yasmine": { "type": "http", "url": "https://mcp.yasmine.akidly.com/mcp/", "headers": { "Authorization": "Bearer yk_your_key_here" } } } } ``` Redémarrer Claude Desktop. Le serveur `yasmine` apparaît dans les serveurs actifs, les 8 tools sont appelables via langage naturel. ### Config autres clients MCP Tout client qui implémente la spec MCP 2025-11-25 avec transport Streamable HTTP peut se brancher. Le contrat réseau est standard : POST JSON-RPC vers l'endpoint, Bearer dans les headers. --- ## Mode C — Plugin Claude Code Usage typique : vous développez votre intégration Yasmine dans Claude Code (CLI ou IDE), et vous voulez l'outil + un guide d'usage (skill) directement dans votre session. ### Install Une fois la clé API obtenue (cf §[Authentification](#authentification)), exporter comme variable d'environnement **avant** de lancer Claude Code : **macOS / Linux** (bash/zsh) ```bash export YASMINE_API_TOKEN="yk_your_key_here" ``` **Windows** (PowerShell, persistent) ```powershell [Environment]::SetEnvironmentVariable('YASMINE_API_TOKEN', 'yk_your_key_here', 'User') ``` Puis **relancer Claude Code** (fermer toutes les instances) et installer le plugin : ``` claude plugin marketplace add akidly/yasmine claude plugin install yasmine@akidly ``` ### Ce que vous obtenez - Les **8 tools MCP** (identiques au Mode B, mais chargés automatiquement par Claude Code). - Un **skill `yasmine-api`** — guide de référence chargé automatiquement quand Claude Code détecte une question Yasmine : auth, codes ISO pays, format E.164, idempotence, slugs d'erreur courants. Source publique : [github.com/akidly/yasmine](https://github.com/akidly/yasmine). Versions : SemVer, `v0.1.3` actuellement. --- ## Authentification Tous les modes utilisent la même clé API format `yk_<40 chars>`. À traiter comme un mot de passe — jamais retransmise après émission. **Obtenir une clé** : contactez l'équipe Yasmine à `contact@akidly.com` en précisant : - Nom de votre société / projet - Volume estimé d'appels par mois - URL de webhook prête si applicable (optionnel) Vous recevrez la clé + un crédit initial en secondes pour vos tests. Le self-service via `POST /v1/me/api-keys` est disponible dès que vous avez une première clé (création de clés CI/CD supplémentaires, révocation, audit — cf §[Ressources](#ressources)). --- ## Quickstart 5 minutes 1. Obtenez une clé (voir ci-dessus). 2. Copiez-collez dans un terminal (remplacer la clé + votre numéro) : ```bash export YK="yk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" curl -X POST https://api.yasmine.akidly.com/v1/calls \ -H "Authorization: Bearer $YK" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "customer": { "name": "Test", "phone_number": "+212612345678" }, "shop_external_id": "test", "shop_info": {"name": "Test Shop"}, "order": { "delivery_address": "Rue Test, Casa", "amount": "100.00", "currency": "MAD" }, "country": "MA", "call_params": {"purpose": "confirmation"} }' ``` > ⚠️ Un `POST /v1/calls` déclenche un **vrai appel téléphonique** et consomme du crédit réel. Pour votre premier test, utilisez **votre propre numéro**. Réponse `201` en < 1 seconde. Appel effectif en 40-60 secondes. --- ## Ressources - **Portail API complet (Scalar)** : [docs.yasmine.akidly.com](https://docs.yasmine.akidly.com) - **Onboarding détaillé** : [docs.yasmine.akidly.com/getting-started.md](https://docs.yasmine.akidly.com/getting-started.md) - **Recettes curl** : [docs.yasmine.akidly.com/examples.md](https://docs.yasmine.akidly.com/examples.md) - **Catalogue erreurs RFC 7807** : [docs.yasmine.akidly.com/errors](https://docs.yasmine.akidly.com/errors) - **Webhooks sortants** : [docs.yasmine.akidly.com/webhooks.md](https://docs.yasmine.akidly.com/webhooks.md) - **Politique de versioning** : [docs.yasmine.akidly.com/versioning.md](https://docs.yasmine.akidly.com/versioning.md) - **Plugin Claude Code (source)** : [github.com/akidly/yasmine](https://github.com/akidly/yasmine) - **Support** : `contact@akidly.com` — joignez toujours le `request_id` (8 chars) renvoyé dans les headers / bodies d'erreur pour un retour rapide. --- ## Bon à savoir - **Pays supportés** : `MA` (Maroc, darija), `DZ` (Algérie, darija), `TN` (Tunisie, darija), `FR` (France, français). Le profil conversationnel est choisi côté serveur via le champ `country`. - **Numéros** : format E.164 strict uniquement (`+212612345678`, `+33612345678`). - **Facturation** : en secondes billables `= max(duration, 10)`. Minimum 10 secondes par appel. - **Idempotence** : header `Idempotency-Key` obligatoire sur `POST /v1/calls`. Même clé + même body dans les 24 h = réponse rejouée sans re-déclencher l'appel (`X-Idempotent-Replay: true`). Même clé + body différent = `409 idempotency_key_conflict`. - **Rate limits** : par clé API — 60/min sur `POST /v1/calls`, 600/min sur les reads, headers `X-RateLimit-*` + `Retry-After` sur 429. - **Sandbox** : pas de sandbox aujourd'hui. Chaque appel = crédit réel. Utilisez votre propre numéro pour tester. --- # === docs/openapi.yaml === openapi: 3.1.0 info: title: Yasmine API version: 1.1.0 summary: Agent vocal IA pour confirmation de commandes — API reseller. description: | API Yasmine — déclenche des appels IA sortants pour confirmer des commandes, et gère crédits, webhooks, clés API en self-service. **Statut des endpoints** : - `x-status: live` — implémenté et appelable en prod aujourd'hui. - `x-status: internal` — route technique, pas sous le SLA `/v1/*`. **Format d'erreur** : toutes les erreurs sous `/v1/*` sont au format RFC 7807 `application/problem+json` — cf `docs/errors.md`. **Authentification** : Bearer token `yk_...` sur tous les endpoints `/v1/*`. Cf `docs/getting-started.md`. **Traçabilité** : chaque requête reçoit un header response `X-Request-ID` (UUID v4). Vous pouvez envoyer votre propre UUID v4 via le header request de même nom — Yasmine le respecte si format strict valide, sinon régénère. Le `request_id` apparaît aussi dans le body des réponses d'erreur RFC 7807 et dans nos logs serveurs. Joignez-le à tout ticket support. **Headers response transverses** (émis sur toutes les responses `/v1/*`, succès comme erreur — non répétés par endpoint pour lisibilité) : - `X-Request-ID` (UUID v4) — cf bloc Traçabilité ci-dessus. Schéma : `#/components/headers/RequestIdResponse`. - `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` — émis sur les 2xx des endpoints rate-limités et aussi sur les 429. Schémas : `#/components/headers/RateLimitLimit`, `RateLimitRemaining`, `RateLimitReset`. - `Retry-After` (RFC 7231) — émis uniquement sur 429, voir `#/components/responses/RateLimited`. - `X-Idempotent-Replay` — uniquement sur `POST /v1/calls` quand la réponse est un replay, voir la route. Chaque endpoint `/v1/*` déclare son propre plafond rate-limit via l'extension `x-rate-limit: "N/minute"` (valeur source code, cf compteur par clé API). Voir aussi : `/docs/getting-started.md`, `/docs/examples.md`, `/docs/webhooks.md`, `/docs/errors.md`, `/docs/versioning.md`. x-common-headers: description: | Headers response communs — documentés globalement dans `info.description` pour éviter la duplication sur chaque response. items: - "$ref": "#/components/headers/RequestIdResponse" - "$ref": "#/components/headers/RateLimitLimit" - "$ref": "#/components/headers/RateLimitRemaining" - "$ref": "#/components/headers/RateLimitReset" contact: name: Yasmine API email: contact@akidly.com url: https://docs.yasmine.akidly.com license: name: Proprietary servers: - url: https://api.yasmine.akidly.com description: Production tags: - name: Calls description: Déclencher et consulter les appels IA sortants. - name: Account description: Profil reseller, solde, consommation, transactions, clés API, webhooks sortants. - name: Webhooks description: | Configuration self-service du webhook sortant unique par reseller. Émission des events (catalogue `call.*`) cf `docs/webhooks.md`. - name: API Keys description: | Gestion self-service des clés API du reseller. CRUD simple : liste, création (secret visible **une seule fois**), révocation soft. Pas de scopes, pas d'expiration automatique — la seule bascule sécurité est la révocation manuelle. - name: Shops description: | Fiches boutiques : déclarer, consulter, modifier, archiver. Déclarer une boutique ici évite de retransmettre son profil à chaque demande d'appel. - name: Products description: | Catalogue d'une boutique : produits et leurs declinaisons. Un produit declare ici se commande ensuite par simple reference — son nom, son prix et ses variantes n'ont plus a etre repetes a chaque commande. - name: Orders description: | Les commandes d'une boutique, declarees avec votre identifiant puis appelees par reference — autant de fois qu'il faut, les appels s'empilent sur la commande. Les articles restent vivants jusqu'a l'appel ; chaque appel garde la photo de ce qu'il a annonce. - name: Internal description: | Routes techniques hors SLA `/v1/*` (probe santé). Ne sont **pas** destinées à l'intégration reseller — documentées pour transparence opérationnelle uniquement. security: - bearerAuth: [] paths: # ------------------------------------------------------------------------- # Calls — reseller-facing # ------------------------------------------------------------------------- /v1/calls: post: operationId: createCall summary: Déclencher un appel tags: [Calls] x-status: live x-rate-limit: "60/minute" description: | Crée un appel IA sortant. **Le canal est choisi pour vous.** Omettez `call_params.channel` : le canal par défaut de votre installation s'applique, et c'est le bon dans la quasi-totalité des cas. Le préciser n'a d'intérêt que si vous avez une raison de forcer une voie particulière — et tous les canaux ne sont pas ouverts partout, un canal fermé renvoyant `422 channel_not_available`. **Deux façons de désigner la commande.** Décrite sur place, avec `customer` et `order` — la forme historique, inchangée. Ou **par référence**, avec `order_external_id` seul : la commande a été déclarée via `POST /v1/shops/{id}/orders`, le serveur relit le client, les articles — relus au catalogue du moment — et les montants, et rattache l'appel à la commande. C'est la forme à privilégier : relancer le client se fait avec la même référence, et « toutes les commandes de ce produit » a une réponse. **L'appel part dans la plage d'appel.** Celle de la commande, sinon celle de la boutique, sinon du lundi au samedi de 10 h à 21 h et le dimanche de 11 h à 20 h, dans le fuseau de la boutique. Une demande faite hors plage est acceptée et part à la prochaine ouverture ; une relance y est recalée de la même façon. Aucune plage ne peut sortir de 9 h – 22 h. **Idempotency-Key obligatoire** : header `Idempotency-Key` requis (1-255 chars, UUID v4 recommandé). Scope par reseller, TTL 24 h. Un retry réseau (504 Cloudflare, time-out client) avec la même clé renvoie la réponse stockée bit-for-bit (`X-Idempotent- Replay: true`) sans déclencher d'appel supplémentaire. Réutiliser la même clé avec un body différent retourne `409 idempotency_key_conflict`. parameters: - $ref: "#/components/parameters/IdempotencyKey" - $ref: "#/components/parameters/RequestIdHeader" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CallCreate" examples: maroc_basic: summary: Appel simple Maroc (commande à article unique, texte libre) value: customer: name: "Ahmed Bennani" phone_number: "+212612345678" shop_external_id: "boutique-casa-01" shop_info: name: "GlowArt Casa" order: external_id: "CMD-2026-00789" delivery_address: "12 Rue X, Casablanca" items_text: "Crème hydratante visage 50ml" amount: "249.00" currency: "MAD" country: "MA" call_params: purpose: "confirmation" maroc_with_items: summary: Appel Maroc avec liste structurée d'articles value: customer: name: "Ahmed Bennani" phone_number: "+212612345678" shop_external_id: "boutique-casa-01" shop_info: name: "GlowArt Casa" order: external_id: "CMD-2026-00790" delivery_address: "12 Rue X, Casablanca" items: - name: "T-shirt blanc en coton" quantity: 2 variants: - name: "Taille" value: "XXL" - name: "Couleur" value: "blanc" available_variants: - - name: "Taille" value: "XXL" - name: "Couleur" value: "blanc" - - name: "Taille" value: "XL" - name: "Couleur" value: "blanc" - - name: "Taille" value: "M" - name: "Couleur" value: "noir" description: "T-shirt col rond 100% coton, coupe droite." unit_price: "120.00" - name: "Jean noir slim" quantity: 1 unit_price: "320.00" amount: "560.00" currency: "MAD" country: "MA" call_params: purpose: "confirmation" maroc_french_speaker: summary: Appel Maroc avec client francophone (override de la langue par défaut) value: customer: name: "Sophie El Idrissi" phone_number: "+212661111222" shop_external_id: "boutique-casa-01" shop_info: name: "GlowArt Casa" order: external_id: "CMD-2026-00791" delivery_address: "12 Rue X, Casablanca" items_text: "Crème hydratante visage 50ml" amount: "249.00" currency: "MAD" country: "MA" language: "fr" call_params: purpose: "confirmation" france_full: summary: Appel France, profil client + shop + order complets value: customer: name: "Claire Dupont" phone_number: "+33612345678" gender: "female" email: "claire@example.com" notes: "VIP, horaires bureau uniquement" shop_external_id: "boutique-paris-01" shop_info: name: "GlowArt Paris" sector: "beauté" return_policy: "retour gratuit sous 14 jours" order: external_id: "CMD-2026-00123" delivery_address: "25 Av des Champs-Élysées, 75008 Paris" items_text: "sérum éclat 30ml + crème de jour" amount: "49.90" currency: "EUR" delivery_method: "express" delivery_zone: "urban" source: "web" country: "FR" call_params: purpose: "retry" tone: "friendly" voice_id: "coral" metadata: invoice_ref: "INV-789" responses: "201": description: | Appel créé et mis en file. Si `X-Idempotent-Replay: true` est présent dans les headers, la réponse est un replay d'une requête antérieure identique (même clé d'idempotence + même body), aucun nouvel appel n'a été déclenché. headers: Location: schema: { type: string } example: /v1/calls/dcaebcdd-a81a-4386-a0df-85d9aaafe862 X-Idempotent-Replay: description: | Présent uniquement sur un replay idempotent. Vaut `"true"`. Absent sur la première occurrence d'une clé. schema: { type: string, enum: ["true"] } content: application/json: schema: $ref: "#/components/schemas/CallOut" "400": { $ref: "#/components/responses/IdempotencyKeyError" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/InsufficientBalance" } "409": { $ref: "#/components/responses/IdempotencyKeyConflict" } "422": { $ref: "#/components/responses/ValidationError" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } get: operationId: listCalls summary: Lister les appels tags: [Calls] x-status: live x-rate-limit: "600/minute" description: | Liste paginée des appels du reseller authentifié. Tri `created_at DESC` avec tie-break stable `id DESC`. **Curseur opaque** : passer la valeur `next_cursor` de la page précédente en `?cursor=...`. Le curseur est signé HMAC-SHA256 côté serveur (TTL 24 h) — format opaque pour le client, pas de structure à parser. **Filtres combinables** : `status`, `shop_id`, `since`, `until`. Tous optionnels. **Erreurs de curseur** : `400 invalid_cursor` (format invalide, signature tampered, ou clé serveur tournée) ou `400 cursor_expired` (> 24 h). Le client refait la 1re requête sans `cursor`. parameters: - in: query name: cursor required: false schema: { type: string } description: | Curseur opaque retourné par `next_cursor` de la page précédente. Absent pour la première page. - in: query name: limit required: false schema: type: integer minimum: 1 maximum: 200 default: 50 description: Nombre max d'items par page. > 200 → 422. - in: query name: status required: false schema: type: string enum: [queued, dialing, in_progress, ended, failed] description: | Filtre exact sur le statut de l'appel, dérivé de `request_status` et `call_status`. Mapping : - `queued` — en attente de permission (`request_status` parmi `queued` / `waiting_user_permission`). - `dialing` — permission acceptée, numérotation en cours (`call_status` parmi `not_started`/`dialing`/`ringing`). - `in_progress` — client décroché (`call_status = connected`). - `ended` — fin naturelle (`call_status = ended`). - `failed` — tout état terminal non-nominal : permission refusée, expirée, quota bloqué, annulé reseller, ou tout autre échec non mappé. Un cancel reseller termine ici et non dans un `cancelled` distinct côté statut agrégé. - in: query name: result required: false schema: type: string enum: [confirmed, cancelled, requires_action] description: | Filtre exact sur le résultat principal métier. Exclut les appels en cours (`result` `null`). Combinable avec `status` pour cibler par exemple `?status=ended&result=confirmed` (commandes confirmées sans souci). - in: query name: shop_id required: false schema: { type: string, format: uuid } - in: query name: since required: false schema: { type: string, format: date-time } description: Minimum `created_at` (inclusif). - in: query name: until required: false schema: { type: string, format: date-time } description: Maximum `created_at` (inclusif). responses: "200": description: Page paginée (enveloppe `data`/`next_cursor`/`has_more`). content: application/json: schema: { $ref: "#/components/schemas/PaginatedCalls" } "400": description: | Curseur invalide/expiré (slug `invalid_cursor` ou `cursor_expired`). Refaire la 1re requête sans `cursor`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "422": { $ref: "#/components/responses/ValidationError" } "429": { $ref: "#/components/responses/RateLimited" } "503": description: | clé de signature absente côté serveur (fail-closed). Contacter le support. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/calls/{call_id}: get: operationId: getCall summary: Récupérer un appel tags: [Calls] x-status: live x-rate-limit: "600/minute" description: | Lit l'état courant d'un appel scopé au reseller authentifié Utile pour réconcilier après un webhook perdu ou un crash mid-retry. **Anti-énumération** : trois cas renvoient un 404 `call_not_found` byte-identique : - UUID syntaxiquement invalide (`/v1/calls/not-a-uuid`). - UUID valide mais inconnu. - UUID propriété d'un autre reseller. Aucun champ ne fuite l'existence ou le propriétaire. parameters: - $ref: "#/components/parameters/CallId" responses: "200": description: Détails de l'appel. content: application/json: schema: { $ref: "#/components/schemas/CallOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/CallNotFound" } "429": { $ref: "#/components/responses/RateLimited" } /v1/calls/{call_id}/recording: get: operationId: getCallRecording summary: Télécharger l'enregistrement audio d'un appel tags: [Calls] x-status: live x-rate-limit: "60/minute" description: | Stream le mix audio (client + agent) d'un appel terminé en WAV mono 16 kHz, 16-bit PCM. **Disponibilité** : seulement après que l'appel a atteint un état terminal (`call_status=ended`). Le champ `recording_url` du `CallOut` (cf `GET /v1/calls/{call_id}`) indique la disponibilité — `null` si l'audio n'est pas (encore) prêt. **Rétention** : 30 jours après la fin de l'appel. Au-delà, le fichier est purgé et l'endpoint renvoie `410 Gone` avec le slug `recording_gone` (distinct du `404 call_not_found` pour permettre au reseller de différencier « jamais existé » vs « purgé »). **Anti-énumération** : quatre cas renvoient un 404 `call_not_found` byte-identique : - UUID syntaxiquement invalide - UUID inconnu - UUID propriété d'un autre reseller - artifact pas encore créé (call jamais finalisé) **Format** : WAV non compressé, mono, 16 kHz. ~32 KB/s soit ~2 MB pour 60s. Header `Content-Disposition: attachment; filename=".wav"`. **Rate limit** : 60 req/min — plus restrictif que les endpoints JSON (audio plus lourd, on protège la bande passante). Cacher localement côté reseller. parameters: - $ref: "#/components/parameters/CallId" responses: "200": description: Stream WAV (mono, 16 kHz, 16-bit PCM). headers: Content-Disposition: schema: { type: string } description: 'attachment; filename=".wav"' content: audio/wav: schema: type: string format: binary "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/CallNotFound" } "410": description: | L'enregistrement a été purgé après la fenêtre de rétention (30 jours). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } /v1/calls/{call_id}/cancel: post: operationId: cancelCall summary: Annuler un appel en cours tags: [Calls] x-status: live x-rate-limit: "120/minute" description: | Annule un appel **en cours** (rail APPEL en `dialing`/`ringing`/ `connected`, ou demande encore `queued`). Pattern Stripe-like sur ressource vivante. **Facturation** : - Avant `connected` → `billed_seconds = 0`, aucun débit. - `connected` → `billed_seconds = ceil(now - accepted_at)` avec plancher minimum 10 s (cf `docs/pricing.md`). Débit immédiat. **Idempotence** : si l'appel est déjà terminal (`ended`/`failed`/ `cancelled`), réponse `200 OK` no-op : pas de mutation DB, pas d'event webhook ré-émis, pas de débit. **Event sortant** : `call.cancelled` émis une seule fois sur cancel effectif. Distinct de `call.ended` (fin naturelle) — cf `docs/webhooks.md` §7. **Scoping** : si le `call_id` appartient à un autre reseller, 404 `call_not_found` (pas de leak d'existence). parameters: - $ref: "#/components/parameters/CallId" responses: "200": description: Appel annulé (ou déjà terminal, idempotent). content: application/json: schema: { $ref: "#/components/schemas/CallCancelOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/CallNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ------------------------------------------------------------------------- # Account — profil, solde, usage, clés, webhooks sortants # ------------------------------------------------------------------------- /v1/me: get: operationId: getMe summary: Identité du reseller courant tags: [Account] x-status: live x-rate-limit: "600/minute" description: | Retourne les 5 champs d'identité du reseller authentifié. Anti-BOPLA : pas de `balance_seconds` (voir `/v1/me/balance`), pas de `status` interne, pas de compteurs. responses: "200": description: Identité complète. content: application/json: schema: { $ref: "#/components/schemas/MeOut" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } /v1/me/transactions: get: operationId: listTransactions summary: Ledger paginé (topup, debit_call, refund, adjustment) tags: [Account] x-status: live x-rate-limit: "600/minute" description: | Historique paginé du ledger `credit_transactions`. Tri `created_at DESC, id DESC` (tie-break stable). Curseur opaque HMAC-SHA256 (TTL 24 h) — même contrat d'erreur `invalid_cursor` / `cursor_expired`. `seconds` est **signé** : positif = crédit (topup/refund), négatif = débit (debit_call). Pas de `currency` — la devise métier est "secondes facturables" aujourd'hui. parameters: - in: query name: cursor required: false schema: { type: string } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: since required: false schema: { type: string, format: date-time } - in: query name: until required: false schema: { type: string, format: date-time } responses: "200": description: Page paginée. content: application/json: schema: { $ref: "#/components/schemas/PaginatedTransactions" } "400": description: | Curseur invalide/expiré (`invalid_cursor` / `cursor_expired`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "422": { $ref: "#/components/responses/ValidationError" } "429": { $ref: "#/components/responses/RateLimited" } "503": description: | clé de signature absente côté serveur. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/me/usage: get: operationId: getUsage summary: Consommation mensuelle agrégée tags: [Account] x-status: live x-rate-limit: "600/minute" description: | Agrégats sur la période `YYYY-MM`. Défaut = mois courant UTC si `?period` absent. Fenêtre semi-open `[period_start, period_end)` sur la date de création de l'appel (date de soumission, pas de fin). **Ventilation** basée sur `call_status` : - `ended` → `breakdown.completed_*` - `failed` → `breakdown.failed_calls` - `cancelled` → `breakdown.cancelled_calls` - in-flight (`queued/dialing/ringing/connected/not_started`) comptés uniquement dans `total_calls`. Période sans appel → **200 avec zéros partout** (pas 404). parameters: - in: query name: period required: false schema: type: string pattern: "^\\d{4}-(0[1-9]|1[0-2])$" example: "2026-04" description: Défaut = mois courant UTC si absent. responses: "200": description: Agrégats. content: application/json: schema: { $ref: "#/components/schemas/UsageOut" } "400": description: | Format `period` invalide (`invalid_period_format`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } /v1/me/api-keys: get: operationId: listApiKeys summary: Lister les clés API (sans le secret) tags: [API Keys] x-status: live x-rate-limit: "600/minute" description: | Retourne les clés API du reseller courant. Inclut les clés actives ET révoquées (historique complet). Tri `created_at DESC`. Jamais de pagination — en pratique un reseller a < 10 clés. **Anti-BOPLA strict** : ni `key_hash`, ni `secret_key`, seul le `key_prefix` (12 chars) est exposé. responses: "200": description: Liste des clés. content: application/json: schema: { $ref: "#/components/schemas/ApiKeyListOut" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } post: operationId: createApiKey summary: Créer une nouvelle clé API tags: [API Keys] x-status: live x-rate-limit: "5/minute" description: | Génère une nouvelle clé API. Pattern Stripe/GitHub : la `secret_key` est **visible UNE SEULE FOIS** dans la réponse 201. Le serveur n'en persiste que le hash SHA-256. **Rate-limit spécifique 5/min** (cumulé avec 600/min global). requestBody: required: false content: application/json: schema: { $ref: "#/components/schemas/ApiKeyCreateIn" } example: label: "ci-prod" responses: "201": description: | Clé créée. `secret_key` visible UNE FOIS ici — à stocker immédiatement côté reseller. content: application/json: schema: { $ref: "#/components/schemas/ApiKeyCreatedOut" } example: id: "3f8e9d12-4b5a-4c6d-8e7f-123456789abc" key_prefix: "yk_Xa7bC3dE5f" label: "ci-prod" created_at: "2026-04-21T12:00:00Z" secret_key: "yk_Xa7bC3dE5fG9hI1jK2lM3nO4pQ5rS6tU7vW8xY9zA" "401": { $ref: "#/components/responses/Unauthorized" } "422": { $ref: "#/components/responses/ValidationError" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/me/api-keys/{key_id}: delete: operationId: revokeApiKey summary: Révoquer une clé API tags: [API Keys] x-status: live x-rate-limit: "600/minute" description: | Soft-delete : pose `revoked_at = NOW()` sur la clé. La clé apparaîtra toujours dans `GET /v1/me/api-keys` avec `status=revoked` (historique préservé). **Anti-énumération** : 404 `api_key_not_found` byte-identique sur 3 cas : UUID malformé, clé inexistante, clé appartenant à un autre reseller. Auto-révocation safe : si le reseller révoque la clé qu'il est en train d'utiliser, la requête courante se termine normalement, la suivante retournera 401. parameters: - in: path name: key_id required: true schema: { type: string } description: | UUID v4 de la clé. Format invalide → 400 `invalid_key_id`. responses: "204": description: Clé révoquée. "400": description: | `key_id` n'est pas un UUID valide (`invalid_key_id`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: | Clé inexistante OU appartenant à un autre reseller (`api_key_not_found`, body byte-identique — anti-énum). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/me/api-keys/{key_id}/events: get: operationId: listApiKeyEvents summary: Journal d'audit d'une clé API tags: [API Keys] x-status: live x-rate-limit: "600/minute" description: | Renvoie les événements associés à une clé API. Trois types d'events sont émis par le serveur aujourd'hui : - `created` — à la création via `POST /v1/me/api-keys`. - `revoked` — à la révocation via `DELETE /v1/me/api-keys/{id}` (jamais fire si la révocation retourne 404). - `rate_limited` — à chaque réponse 429 de l'API. **Dédupliqué côté serveur** : un seul event par clé par fenêtre de 60 s (garde-fou contre un attaquant qui saturerait l'endpoint). **Pagination** (curseur opaque HMAC-SHA256, TTL 24 h, forward-only). Tri `created_at DESC, id DESC`. `limit` max 50. **Anti-énumération** : 404 `api_key_not_found` byte-identique si la clé n'existe pas OU appartient à un autre reseller. **Anti-BOPLA** : `api_key_id` et `reseller_id` ne sont pas exposés dans la réponse (déjà dans l'URL et le scope tenant). parameters: - in: path name: key_id required: true schema: { type: string } description: UUID v4 de la clé. Format invalide → 400 `invalid_key_id`. - in: query name: cursor required: false schema: { type: string } description: Opaque, obtenu via `next_cursor` de la page précédente. - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 50, default: 50 } responses: "200": description: Page d'events. content: application/json: schema: { $ref: "#/components/schemas/ApiKeyEventListOut" } "400": description: | `key_id` invalide (`invalid_key_id`) OU curseur corrompu/expiré (`invalid_cursor` / `cursor_expired`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: | Clé inexistante OU appartenant à un autre reseller (`api_key_not_found`, body byte-identique — anti-énum). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "503": description: "Clé de signature des curseurs non configurée côté serveur." content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/me/balance: get: operationId: getBalance summary: Solde courant du reseller tags: [Account] x-status: live x-rate-limit: "600/minute" description: | Solde du reseller authentifié, exprimé en **secondes facturables** Un appel coûte son `billed_seconds` (`max(duration_s, 10)`). Le solde peut être négatif dans la limite d'un découvert autorisé (300 s par défaut) — protection anti-clé volée. `currency` est `null` aujourd'hui (balance en secondes pures), réservé pour une future facturation en monnaie. `updated_at` est l'horodatage du dernier mouvement ledger. responses: "200": description: Solde courant. content: application/json: schema: { $ref: "#/components/schemas/BalanceOut" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } /v1/me/webhooks/test: post: operationId: triggerWebhookTest summary: Tester son webhook (synchrone) tags: [Webhooks] x-status: live x-rate-limit: "10/minute" description: | Déclenche un `webhook.test` synchrone vers l'URL configurée du reseller. Retourne immédiatement le résultat (HTTP status, latence, erreur éventuelle). **Timeout 5 s, pas de retry** (contrairement aux events réels du dispatcher). Signature HMAC-SHA256 identique aux webhooks réels : `X-Yasmine-Signature: sha256=`. Le payload contient `data.test=true` pour permettre au reseller de filtrer ces événements en dev. **Rate-limit 10/min** spécifique (cumulé avec 600/min global). Toute tentative est tracée dans `webhook_deliveries` avec `event_type='webhook.test'` (visible via `GET /webhooks/deliveries`). responses: "200": description: Test exécuté (succès ou échec — voir `delivered`). content: application/json: schema: { $ref: "#/components/schemas/WebhookTestOut" } "400": description: | `webhook_url_not_configured` — pas de webhook actif. Configurer d'abord via `POST /v1/me/webhooks`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/me/webhooks/deliveries: get: operationId: listWebhookDeliveries summary: Historique paginé des livraisons webhook tags: [Webhooks] x-status: live x-rate-limit: "600/minute" description: | Audit des livraisons webhook sortantes du reseller. Tri `attempted_at DESC, id DESC`. Curseur opaque. **Anti-BOPLA strict** : ni `response_body`, ni `signature_sent`, ni `event_id` brut. `error_message` tronqué à 200 chars. Statuts disponibles via `?status=` : - `delivered` : `delivered_at IS NOT NULL` (2xx reçu). - `failed` : `error IS NOT NULL` ou status_code non-2xx. - `pending` : ni l'un ni l'autre (edge case). parameters: - in: query name: cursor required: false schema: { type: string } - in: query name: limit required: false schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: status required: false schema: type: string enum: [delivered, failed, pending] - in: query name: event_type required: false schema: { type: string } example: "call.ended" - in: query name: since required: false schema: { type: string, format: date-time } - in: query name: until required: false schema: { type: string, format: date-time } responses: "200": description: Page paginée. content: application/json: schema: { $ref: "#/components/schemas/PaginatedDeliveries" } "400": description: | `invalid_status_filter` (status hors `delivered`/`failed`/ `pending`), `invalid_cursor`, ou `cursor_expired`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "422": { $ref: "#/components/responses/ValidationError" } "429": { $ref: "#/components/responses/RateLimited" } "503": description: | clé de signature absente côté serveur. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/me/webhooks: post: operationId: createWebhook summary: Configurer le webhook sortant tags: [Webhooks] x-status: live x-rate-limit: "10/minute" description: | Enregistre l'URL du webhook sortant et génère un secret HMAC-SHA256. Le **`secret` est affiché UNE SEULE FOIS** dans cette réponse. Il sert à vérifier la signature `X-Yasmine-Signature` des payloads sortants (cf `docs/webhooks.md` §5). À stocker immédiatement côté reseller — aucune route ne permet de le récupérer ensuite. 1 seul webhook actif par reseller. Pour rotate le secret : `DELETE /v1/me/webhooks` puis `POST` à nouveau. **SSRF guard** : l'URL est rejetée 400 si elle pointe vers `localhost`, un TLD `.local`, une IP privée (RFC 1918, loopback, link-local, ULA IPv6) ou utilise un scheme autre que `https`. **Note** : l'émission réelle des events (`call.ended`, etc.) est livrée en Phase 2 de C6. Configurer le webhook aujourd'hui ne déclenche encore aucun trafic. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/WebhookCreateInput" } examples: prod: summary: Endpoint HTTPS public value: url: "https://monshop.com/yasmine-webhooks" responses: "201": description: Webhook créé — `secret` affiché UNE fois en clair. content: application/json: schema: { $ref: "#/components/schemas/WebhookCreated" } "400": description: URL rejetée par le SSRF guard. content: application/problem+json: schema: { $ref: "#/components/schemas/WebhookUrlRejectedProblem" } "401": { $ref: "#/components/responses/Unauthorized" } "409": description: Webhook déjà configuré. DELETE d'abord pour rotate. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } example: type: https://docs.yasmine.akidly.com/errors/webhook_already_configured title: Conflict status: 409 detail: Ce reseller a deja un webhook actif. DELETE d'abord pour rotate volontairement le secret, puis POST a nouveau. instance: /v1/me/webhooks request_id: a1b2c3d4 "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } get: operationId: getWebhook summary: Consulter la config webhook tags: [Webhooks] x-status: live x-rate-limit: "600/minute" description: | Retourne la config active. Le `secret` n'est jamais renvoyé — seul `secret_prefix` (6 chars + `…`) est exposé pour permettre au reseller de repérer quel secret est en place. responses: "200": description: Config active du reseller. content: application/json: schema: { $ref: "#/components/schemas/WebhookGet" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Aucun webhook configuré. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } example: type: https://docs.yasmine.akidly.com/errors/webhook_not_configured title: Not Found status: 404 detail: Aucun webhook configure pour ce reseller. instance: /v1/me/webhooks request_id: a1b2c3d4 "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteWebhook summary: Désactiver le webhook tags: [Webhooks] x-status: live x-rate-limit: "10/minute" description: | Soft-delete : la config passe en `active=false`. L'historique `webhook_deliveries` reste intact (pour audit). responses: "204": { description: Webhook désactivé. } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Aucun webhook configuré. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } example: type: https://docs.yasmine.akidly.com/errors/webhook_not_configured title: Not Found status: 404 detail: Aucun webhook configure pour ce reseller. instance: /v1/me/webhooks request_id: a1b2c3d4 "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/me/webhooks/rotate-secret: post: operationId: rotateWebhookSecret summary: Rotate le signing secret HMAC tags: [Webhooks] x-status: live x-rate-limit: "5/minute" description: | Rotate atomiquement le `signing_secret` HMAC-SHA256 du webhook actif. Pattern Stripe : nouveau secret au format `whsec_<43 chars base64url>`, visible **une seule fois** dans la réponse. L'ancien secret ne signe plus aucun event dès le retour 200 (le dispatcher relit `webhook.secret` à chaque POST). Préféré au legacy `DELETE + POST` : sans indisponibilité entre les 2 appels, préserve l'URL, pas de 409 à gérer. `404 webhook_not_configured` si aucun webhook actif. Rate-limit **5/min** (aligné sur `POST /v1/me/api-keys`). responses: "200": description: Nouveau secret. content: application/json: schema: { $ref: "#/components/schemas/WebhookSecretRotated" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: | Aucun webhook actif à rotate (`webhook_not_configured`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } example: type: https://docs.yasmine.akidly.com/errors/webhook_not_configured title: Not Found status: 404 detail: Aucun webhook actif a rotate. Configurer d'abord via POST /v1/me/webhooks. instance: /v1/me/webhooks/rotate-secret request_id: a1b2c3d4 "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ------------------------------------------------------------------------- # Ops — probe santé (hors SLA /v1/*) # ------------------------------------------------------------------------- /v1/shops: post: operationId: createShop summary: Déclarer une boutique tags: [Shops] x-status: live x-rate-limit: "120/minute" description: | Enregistre une boutique sur votre compte. **Minimum : `external_id`, `name`, `country`** — tout le reste s'ajoute ensuite par `PATCH`. Déclarer la boutique ici vous dispense de retransmettre son profil (`shop_info`) à chaque demande d'appel. **Création stricte** : un `external_id` déjà utilisé renvoie un 409, jamais une mise à jour implicite. Pour modifier, utilisez `PATCH`. Une référence ne se réutilise pas, même après archivage : une boutique recréée porte un nouvel `external_id`. L'historique de la boutique archivée reste filtrable par son ancienne référence (`GET /v1/orders?shop_external_id=…`, `GET /v1/products?…`). **Pas d'`Idempotency-Key` requise**, contrairement à `POST /v1/calls` : l'unicité de l'`external_id` rend déjà la création rejouable sans risque de doublon. Un rejeu après timeout réseau renvoie un 409, qui confirme que la création initiale a abouti. **Téléphones** : `phone` et `whatsapp` sont validés contre le `country` déclaré. Format local (`0522123456`) ou international (`+212522123456`) au choix — les deux sont acceptés tant que le numéro appartient bien à ce pays. Ils sont renvoyés normalisés en E.164. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ShopCreate" } examples: minimal: summary: Le strict nécessaire value: external_id: "shop-4471" name: "GlowArt Casa" country: "MA" complet: summary: Fiche renseignée value: external_id: "shop-4471" name: "GlowArt Casa" country: "MA" merchant_external_id: "M-4471" phone: "0522123456" whatsapp: "0661234567" email: "contact@glowart.ma" address: "12 Rue des Fleurs, Casablanca" customer_service_hours: "Lun-Sam 9h-18h" sector: "Cosmétique bio artisanale" has_physical_store: true delivery_policy: "Livraison 30 MAD, offerte dès 500 MAD" delivery_delay: "24-48h" payment_policy: "Paiement à la livraison ou carte bancaire" return_policy: "Retour accepté sous 14 jours" exchange_policy: "Échange sous 30 jours, produit non ouvert" inspection_before_payment: true delivery_companies: ["Amana", "CTM"] city_coverage: ["Casablanca", "Rabat", "Marrakech"] responses: "201": description: Boutique créée. content: application/json: schema: { $ref: "#/components/schemas/ShopOut" } "401": { $ref: "#/components/responses/Unauthorized" } "409": description: | `external_id` déjà utilisé sur ce compte (`shop_external_id_already_exists`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | Payload invalide (`validation_error`) : champ manquant, trop long, chaîne vide, champ inconnu, ou numéro de téléphone qui n'appartient pas au pays déclaré. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } get: operationId: listShops summary: Lister les boutiques tags: [Shops] x-status: live x-rate-limit: "600/minute" description: | Liste paginée des boutiques du compte, triée de la plus récente à la plus ancienne. **Pagination** : curseur opaque, valable 24 h. Passez le `next_cursor` de la réponse en `?cursor=` pour la page suivante. Défaut 50 résultats, maximum 200. **`?merchant_external_id=`** ne garde que les boutiques d'un même commerçant — c'est ainsi que l'on sait combien de boutiques il possède. **`?status=`** vaut `active` par défaut : les boutiques archivées sont masquées. `archived` ne montre qu'elles, `all` ne filtre pas. parameters: - in: query name: cursor schema: { type: string } description: Curseur de la page suivante, tel que renvoyé par `next_cursor`. - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: status schema: type: string enum: [active, archived, all] default: active - in: query name: merchant_external_id schema: { type: string, maxLength: 128 } description: Ne renvoie que les boutiques de ce commerçant. responses: "200": description: Page de résultats. content: application/json: schema: { $ref: "#/components/schemas/PaginatedShops" } "400": description: | Curseur malformé, altéré ou expiré (`invalid_cursor`, `cursor_expired`). Refaire la première requête sans `cursor`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/shops/{shop_external_id}: get: operationId: getShop summary: Consulter une boutique tags: [Shops] x-status: live x-rate-limit: "600/minute" description: | Renvoie la fiche complète d'une boutique, adressée par **votre** identifiant. parameters: - $ref: "#/components/parameters/ShopExternalId" responses: "200": description: Fiche de la boutique. content: application/json: schema: { $ref: "#/components/schemas/ShopOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateShop summary: Modifier une boutique tags: [Shops] x-status: live x-rate-limit: "120/minute" description: | Modification partielle. **Trois cas à distinguer :** | Dans le corps | Effet | |---|---| | Champ **absent** | Inchangé | | Champ à **`null`** | **Effacé** | | Champ à **`""`** | Rejeté (422) | La chaîne vide est refusée volontairement : sans cela, « effacer » et « mettre une valeur vide » seraient indistinguables. ⚠️ Cette sémantique **diffère de `shop_info`** sur `POST /v1/calls`, où un champ absent ou `null` n'efface jamais rien. C'est délibéré : une demande d'appel est du passage, elle ne détruit rien ; un `PATCH` est un geste explicite. Si vous gérez vos boutiques par cet endpoint, cessez d'envoyer `shop_info` dans vos demandes d'appel. **`external_id` ne peut pas être modifié** — il porte l'historique des appels de la boutique. L'envoyer produit un 422. **Téléphones** : validés contre le pays *effectif* — celui du corps s'il est fourni, sinon celui déjà enregistré. Changer le pays d'une boutique revalide donc ses numéros. parameters: - $ref: "#/components/parameters/ShopExternalId" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ShopPatch" } examples: partielle: summary: Deux champs modifiés, un effacé value: delivery_delay: "24-48h" payment_policy: "Paiement à la livraison uniquement" current_promotions: null responses: "200": description: Fiche mise à jour. content: application/json: schema: { $ref: "#/components/schemas/ShopOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "422": description: | Payload invalide (`validation_error`) : chaîne vide, champ inconnu, `external_id` fourni, ou numéro de téléphone qui n'appartient pas au pays de la boutique. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: archiveShop summary: Archiver une boutique tags: [Shops] x-status: live x-rate-limit: "120/minute" description: | **La boutique n'est pas supprimée, elle est archivée.** Elle disparaît de la liste par défaut et n'accepte plus de modification, mais tout son historique d'appels reste intact et consultable. Pour la retrouver : `GET /v1/shops?status=archived`. Archiver une boutique déjà archivée renvoie un 404 — indistinguable d'une boutique inexistante. Une boutique archivée n'est pas un cul-de-sac : `POST /v1/shops/{shop_external_id}/restore` la rend au service. parameters: - $ref: "#/components/parameters/ShopExternalId" responses: "204": description: Boutique archivée. "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/shops/{shop_external_id}/restore: post: operationId: restoreShop summary: Rendre une boutique archivée au service tags: [Shops] x-status: live x-rate-limit: "120/minute" description: | La boutique reparaît dans la liste et accepte de nouveau les modifications et les appels. **C'est la même boutique qui reprend**, sous la même référence : son catalogue, ses clients et son historique d'appels n'avaient jamais bougé. Rien n'est recréé, rien n'est dupliqué. Le cas courant est le marchand qui se réinscrit après être parti : sans ce chemin, il fallait ouvrir une deuxième boutique et abandonner l'historique de la première. Le contrat est « qu'elle soit active » : restaurer une boutique déjà active la renvoie telle quelle. Seule une référence inconnue renvoie un 404. parameters: - $ref: "#/components/parameters/ShopExternalId" responses: "200": description: Boutique active. content: application/json: schema: { $ref: "#/components/schemas/ShopOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/shops/{shop_external_id}/products: post: operationId: createProduct summary: Déclarer un produit tags: [Products] x-status: live x-rate-limit: "120/minute" description: | Ajoute un produit au catalogue d'une boutique. **Minimum : `external_id` et `name`** — tout le reste s'ajoute par `PATCH`. Déclarer un produit ici vous dispense de le décrire à chaque commande : une ligne qui porte `product_external_id` suffit, et `POST /v1/calls` relit le nom, la description, le prix et les déclinaisons depuis ce catalogue. **Deux niveaux, comme partout dans le commerce en ligne** : le produit porte l'identité, la variante porte le prix. Un produit sans choix n'y échappe pas — si vous n'envoyez pas de `variants`, une variante unique est créée pour lui, sans identifiant, et `unit_price` s'y dépose. Vous n'avez rien à savoir de tout cela : envoyez un prix, relisez un prix. **Le prix est obligatoire.** `unit_price` pour un produit sans choix, ou celui de chaque entrée de `variants` : un produit sans prix est refusé (422), parce qu'un agent qui ne sait pas répondre à « ça coûte combien ? » ne confirme rien. **Dès qu'il y a plusieurs variantes, chacune doit porter son `external_id`** — sans quoi une commande ne pourrait pas désigner laquelle. Le refus est un 422 qui nomme les entrées fautives. **Création stricte** : un `external_id` déjà utilisé dans cette boutique renvoie un 409, jamais une mise à jour implicite. Le même identifiant reste libre dans une autre boutique. **Pas d'`Idempotency-Key` requise** : l'unicité de l'`external_id` rend déjà la création rejouable sans risque de doublon. parameters: - $ref: "#/components/parameters/ShopExternalId" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProductCreate" } examples: sans_choix: summary: Produit sans déclinaison — un prix, rien d'autre value: external_id: "TSHIRT-BLANC" name: "T-shirt blanc en coton" unit_price: "149.00" avec_variantes: summary: Trois tailles, deux prix value: external_id: "TSHIRT-BLANC" name: "T-shirt blanc en coton" description: "Coton bio 180g, coupe droite" product_url: "https://boutique.example/p/tshirt-blanc" variants: - external_id: "TSH-S" options: { Taille: "S" } unit_price: "149.00" - external_id: "TSH-M" options: { Taille: "M" } unit_price: "149.00" - external_id: "TSH-XL" options: { Taille: "XL" } unit_price: "169.00" compare_at_price: "199.00" catalogue_riche: summary: Fiche complète, avec ce qui prime sur la boutique value: external_id: "SERUM-VITC" name: "Sérum vitamine C" description: "Flacon 30 ml, concentration 15%. Ne pas utiliser sur peau lésée, conserver à l'abri de la lumière." max_discount: "20.00" return_policy: "Produit d'hygiène : aucun retour une fois ouvert" current_promotions: "Deuxième flacon à -50%" unit_price: "320.00" responses: "201": description: Produit créé. content: application/json: schema: { $ref: "#/components/schemas/ProductOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "409": description: | `external_id` déjà utilisé dans cette boutique (`product_external_id_already_exists`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | Payload invalide (`validation_error`) : champ manquant, trop long, chaîne vide, champ inconnu, `external_id` de variante manquant ou en double, `compare_at_price` qui n'est pas supérieur au prix, ou plus de trois axes d'options. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /healthz: get: operationId: healthz summary: Probe santé applicatif tags: [Internal] x-status: internal security: [] description: | Probe santé pour monitoring / load balancer. **Route interne** — pas sous le SLA `/v1/*`, pas de rate-limit, pas d'auth, pas de versioning. **Ne pas utiliser pour orchestration billing** ni comme signal de disponibilité métier — elle renvoie `200 OK` dès que le process Python répond, indépendamment de l'état de la DB ou des dépendances externes. Pour connaître l'état métier, se reporter à `GET /v1/me` (auth requise). responses: "200": description: Process vivant. content: application/json: schema: type: object properties: status: { type: string, example: ok } /v1/shops/{shop_external_id}/orders: post: operationId: createOrder summary: Déclarer une commande tags: [Orders] x-status: live x-rate-limit: "120/minute" description: | Déclare une commande sous une boutique, avec **votre** identifiant. Minimum : `external_id`, `customer`, `amount`, et de quoi décrire le contenu — `items` ou `items_text`. Une commande déclarée s'appelle ensuite **par référence** : `POST /v1/calls` avec `order_external_id`, et rien d'autre que les paramètres de l'appel. Le serveur relit le client, les articles et les montants. Relancer le client se fait avec la même référence : les appels s'empilent sur la commande, jamais de doublon. **Les articles sont vivants jusqu'à l'appel.** Une ligne qui référence un produit du catalogue (`product_external_id`, plus `variant_external_id` s'il a plusieurs variantes) est vérifiée à la déclaration et relue au catalogue **du moment** à chaque lecture et à chaque appel : un prix corrigé après la déclaration est celui qui sera annoncé. Ce que l'appel a réellement dit se lit ensuite sur la fiche de l'appel (`order_snapshot`). Une ligne décrite sur place avec `name` reste valide : article sur mesure, frais de port, cadeau. **Le statut ne s'écrit pas.** `order_status` vient du résultat des appels : `PENDING`, puis `CONFIRMED`, `CANCELLED`, `UNREACHABLE`… Il se lit. **Création stricte** : un `external_id` déjà pris dans cette boutique renvoie un 409, jamais une mise à jour implicite. parameters: - $ref: "#/components/parameters/ShopExternalId" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/OrderDeclare" } examples: par_reference: summary: Deux articles du catalogue, un client, un montant value: external_id: "CMD-2026-8891" customer: name: "Ahmed Bennani" phone_number: "+212612345678" amount: "487.00" delivery_address: "12 rue des Orangers, Casablanca" items: - product_external_id: "TSHIRT-BLANC" variant_external_id: "TSH-XL" quantity: 1 - product_external_id: "SERUM-VITC" quantity: 1 avec_frais_de_port: summary: Un article du catalogue et une ligne décrite sur place value: external_id: "CMD-2026-8892" customer: name: "Salma Idrissi" phone_number: "+212661234567" gender: "female" amount: "179.00" shipping_cost: "30.00" estimated_delivery_date: "2026-09-05" order_notes: "Cadeau : ne pas mentionner le prix" items: - product_external_id: "TSHIRT-BLANC" variant_external_id: "TSH-M" quantity: 1 - name: "Frais de livraison" quantity: 1 unit_price: "30.00" responses: "201": description: Commande déclarée, articles lus au catalogue du moment. content: application/json: schema: { $ref: "#/components/schemas/OrderOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "409": description: | `external_id` déjà utilisé dans cette boutique (`order_external_id_already_exists`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | Payload invalide (`validation_error`) : champ manquant, trop long, chaîne vide, champ inconnu, ni `items` ni `items_text`. Ou une ligne qui référence un produit inconnu (`order_item_unknown_product`) ou une variante non précisée (`order_item_variant_required`, le `detail` liste celles qui existent). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/shops/{shop_external_id}/products/batch: post: operationId: batchProducts summary: Poser jusqu'à 100 produits d'un coup tags: [Products] x-status: live x-rate-limit: "60/minute" description: | **Le chemin de l'import.** Crée ce qui manque, met à jour ce qui existe. Rejouer un catalogue entier sans savoir ce qui s'y trouve déjà est le cas normal, pas une erreur : ici, une référence déjà prise n'est donc **pas un conflit**, c'est une mise à jour. La déclaration à l'unité (`POST .../products`) garde son refus strict — deux intentions différentes, deux chemins. Chaque entrée a la forme exacte d'une déclaration à l'unité. **Une ligne fautive ne fait pas tomber les autres.** Chacune est posée pour elle-même, et la réponse rend **un résultat par entrée, dans l'ordre d'envoi**, avec la raison du refus quand il y en a un. La réponse est donc `200` même si tout a échoué : la requête, elle, a bien été traitée — c'est le détail qui dit ce qui est passé. **Le gain** : un lot de 100 compte pour **une** requête face à la limite de débit. Déclarer 500 produits passe de 500 appels à 5. Au-delà de 100 entrées, le lot entier est refusé (`422`) : mieux vaut un refus net qu'une troncature silencieuse. C'est **le seul cas** où le lot est rejeté en bloc — une entrée mal remplie, elle, est toujours un refus de ligne. parameters: - $ref: "#/components/parameters/ShopExternalId" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProductBatchIn" } examples: deux_produits: summary: Un produit simple et un produit à déclinaisons value: items: - external_id: "BOUGIE-01" name: "Bougie parfumée ambre" unit_price: "90.00" - external_id: "TSHIRT-BLANC" name: "T-shirt blanc en coton" variants: - external_id: "TSH-S" options: { Taille: "S" } unit_price: "149.00" - external_id: "TSH-M" options: { Taille: "M" } unit_price: "149.00" responses: "200": description: | Le lot a été traité. Le détail dit ce qui est passé et ce qui ne l'est pas — un `200` ne signifie pas que tout a réussi. content: application/json: schema: { $ref: "#/components/schemas/ProductBatchOut" } examples: melange: summary: Un créé, un mis à jour, un refusé value: created: 1 updated: 1 failed: 1 results: - external_id: "BOUGIE-01" status: "created" - external_id: "TSHIRT-BLANC" status: "updated" - external_id: "SAC-CUIR" status: "failed" error: type: "https://docs.yasmine.akidly.com/errors/validation_error" title: "Unprocessable Entity" detail: "Prix absent ou hors limites." "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "422": description: | Le lot lui-même est irrecevable : vide, ou au-delà de 100 entrées. Rien n'a été enregistré. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/products: get: operationId: listProducts summary: Lister les produits d'une boutique tags: [Products] x-status: live x-rate-limit: "600/minute" description: | Liste paginée du catalogue d'une boutique, du produit le plus récent au plus ancien. Chaque fiche est renvoyée complète, variantes comprises. **Pagination** : curseur opaque, valable 24 h. Passez le `next_cursor` de la réponse en `?cursor=` pour la page suivante. Défaut 50 résultats, maximum 200. Un produit effacé n'y figure plus : il n'existe plus. parameters: - $ref: "#/components/parameters/ShopExternalIdFiltre" - in: query name: cursor schema: { type: string } description: Curseur de la page suivante, tel que renvoyé par `next_cursor`. - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } responses: "200": description: Page de résultats. content: application/json: schema: { $ref: "#/components/schemas/PaginatedProducts" } "400": description: | Curseur malformé, altéré ou expiré (`invalid_cursor`, `cursor_expired`). Refaire la première requête sans `cursor`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/products/{product_external_id}: get: operationId: getProduct summary: Consulter un produit tags: [Products] x-status: live x-rate-limit: "600/minute" description: | Renvoie la fiche complète d'un produit, variantes comprises, adressée par **vos** identifiants. `unit_price` n'est présent que si le produit n'a qu'une variante ; sinon, les prix vivent dans `variants`, un par déclinaison. parameters: - $ref: "#/components/parameters/ProductExternalId" responses: "200": description: Fiche du produit. content: application/json: schema: { $ref: "#/components/schemas/ProductOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ProductNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateProduct summary: Modifier un produit tags: [Products] x-status: live x-rate-limit: "120/minute" description: | Modification partielle. **Trois cas à distinguer :** | Dans le corps | Effet | |---|---| | Champ **absent** | Inchangé | | Champ à **`null`** | **Effacé** | | Champ à **`""`** | Rejeté (422) | ⚠️ **`variants` ne se modifie pas ligne à ligne : la liste envoyée remplace entièrement l'ancienne.** Une variante absente de la nouvelle liste est supprimée du catalogue. Pour n'en changer qu'une, relisez le produit, modifiez l'entrée, renvoyez la liste complète. **Retirer une déclinaison qu'une commande ouverte, non tranchée, attend encore est refusé** (`409 product_in_pending_orders`) — comme l'effacement du produit ; le `detail` nomme la déclinaison et les commandes. Les lignes des commandes tranchées suivent le catalogue (une déclinaison recréée sous le même identifiant les rattrape) ; ce que chaque appel a annoncé reste sur l'appel (`order_snapshot`). Ne pas envoyer `variants` du tout les laisse intactes : renommer un produit n'efface pas ses déclinaisons. `unit_price` à la racine ne vaut que pour un produit qui n'a qu'une variante : avec plusieurs, on ne saurait pas laquelle reprendre et la demande est refusée. Pour **ramener un produit à variantes vers un produit simple**, envoyer `variants: []` avec `unit_price` : la liste vide remplace toutes les variantes, et la variante unique renaît avec ce prix. **`unit_price` seul** ne vaut que pour un produit à variante unique. Sur un produit qui en a plusieurs, il est ambigu et refusé (422) : c'est dans `variants` que le prix se pose. **`unit_price: null` est refusé** dans tous les cas : un prix s'édite, il ne s'efface pas. **`external_id` ne peut pas être modifié** — il porte l'historique des commandes du produit. L'envoyer produit un 422. parameters: - $ref: "#/components/parameters/ProductExternalId" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProductPatch" } examples: fiche: summary: Deux champs modifiés, un effacé value: description: "Coton bio 180g, coupe droite, col rond" max_discount: "15.00" current_promotions: null prix_simple: summary: Nouveau prix d'un produit sans déclinaison value: unit_price: "159.00" variantes: summary: La liste complète, une taille en moins value: variants: - external_id: "TSH-M" options: { Taille: "M" } unit_price: "149.00" - external_id: "TSH-XL" options: { Taille: "XL" } unit_price: "169.00" responses: "200": description: Fiche mise à jour. content: application/json: schema: { $ref: "#/components/schemas/ProductOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ProductNotFound" } "409": description: | `product_in_pending_orders` — la liste `variants` envoyée retire une déclinaison qu'une ou plusieurs commandes ouvertes, non tranchées, référencent encore. Le `detail` nomme la déclinaison et les commandes (les dix premières, et leur nombre total). Rien n'est écrit. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | Payload invalide (`validation_error`) : chaîne vide, champ inconnu, `external_id` fourni, `unit_price` sur un produit à plusieurs variantes, ou liste de variantes incohérente. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteProduct summary: Effacer un produit tags: [Products] x-status: live x-rate-limit: "120/minute" description: | **Le produit est effacé pour de bon**, avec ses déclinaisons. Les appels passés n'en ont pas besoin : chacun porte la photo de ce qu'il a annoncé (`order_snapshot` sur `GET /v1/calls/{call_id}`). **Refusé tant qu'une commande ouverte, non tranchée, référence le produit** (`409 product_in_pending_orders`) : elle deviendrait impossible à appeler. Le `detail` nomme les commandes à fermer, à appeler, ou dont il faut retirer l'article (`PATCH` de leurs `items`). Même refus pour un `PATCH` du produit qui retirerait une déclinaison encore attendue. Une référence effacée redevient libre : la redéclarer crée un produit neuf. Une commande qui le référence après coup est refusée (`order_item_unknown_product`). Effacer un produit inexistant renvoie `404`. parameters: - $ref: "#/components/parameters/ProductExternalId" responses: "204": description: Produit effacé. "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ProductNotFound" } "409": description: | `product_in_pending_orders` — une ou plusieurs commandes ouvertes, non tranchées, référencent encore ce produit. Le `detail` liste leurs `external_id` (les dix premières, et leur nombre total). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/orders: get: operationId: listOrders summary: Lister les commandes d'une boutique tags: [Orders] x-status: live x-rate-limit: "600/minute" description: | Liste paginée des commandes d'une boutique, de la plus récente à la plus ancienne. Chaque fiche est renvoyée complète, appels compris. **Pagination** : curseur opaque, valable 24 h. Passez le `next_cursor` de la réponse en `?cursor=` pour la page suivante. Défaut 50 résultats, maximum 200. **`?status=`** vaut `active` par défaut : les commandes archivées sont masquées. `archived` ne montre qu'elles, `all` ne filtre pas. **`?order_status=`** ne garde que les commandes dans cet état (`PENDING`, `CONFIRMED`…). **`?lifecycle=`** vaut `open` par défaut : les commandes que vous avez fermées (`POST …/close`) sont masquées. `closed` ne montre qu'elles, `all` ne filtre pas. Cet axe se cumule avec `status`. parameters: - $ref: "#/components/parameters/ShopExternalIdFiltre" - in: query name: cursor schema: { type: string } description: Curseur de la page suivante, tel que renvoyé par `next_cursor`. - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query name: status schema: type: string enum: [active, archived, all] default: active - in: query name: order_status schema: { type: string, maxLength: 32 } description: Ne garder que les commandes dans cet état (`PENDING`, `CONFIRMED`…). - in: query name: lifecycle schema: type: string enum: [open, closed, all] default: open description: "`open` : celles dont vous n'avez pas fini ; `closed` : celles que vous avez fermées ; `all` : les deux." responses: "200": description: Page de résultats. content: application/json: schema: { $ref: "#/components/schemas/PaginatedOrders" } "400": description: | Curseur malformé, altéré ou expiré (`invalid_cursor`, `cursor_expired`). Refaire la première requête sans `cursor`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ShopNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/orders/{order_external_id}: get: operationId: getOrder summary: Consulter une commande tags: [Orders] x-status: live x-rate-limit: "600/minute" description: | Renvoie la fiche complète d'une commande, adressée par **vos** identifiants : client, articles lus au catalogue du moment, montants, `order_status`, et la liste de ses appels avec leur résultat. `call_in_progress` dit si un appel n'est pas terminé — la commande ne peut alors ni être modifiée, ni archivée, ni rappelée. parameters: - $ref: "#/components/parameters/OrderExternalId" responses: "200": description: Fiche de la commande. content: application/json: schema: { $ref: "#/components/schemas/OrderOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/OrderNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateOrder summary: Modifier une commande tags: [Orders] x-status: live x-rate-limit: "120/minute" description: | Modification partielle. **Trois cas à distinguer :** | Dans le corps | Effet | |---|---| | Champ **absent** | Inchangé | | Champ à **`null`** | **Effacé** | | Champ à **`""`** | Rejeté (422) | Trois champs ne s'effacent pas — `customer`, `amount`, `items` : une commande a toujours un client, un montant et un contenu. Les envoyer à `null` produit un 422. ⚠️ **`items` remplace entièrement la liste** des lignes — même règle que les variantes d'un produit. Ne pas envoyer `items` la laisse intacte. **Refusé pendant un appel en cours** (`409 order_call_in_progress`) : l'agent est en ligne avec ce qu'il a lu. Attendre la fin de l'appel. **`external_id` ne peut pas être modifié** — il porte l'historique des appels. L'envoyer avec une autre valeur produit un 422. parameters: - $ref: "#/components/parameters/OrderExternalId" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/OrderPatch" } examples: adresse_et_montant: summary: L'adresse corrigée, une remise consentie value: delivery_address: "14 rue des Orangers, Casablanca" amount: "450.00" discount: "37.00" articles: summary: La liste complète, un article en moins value: items: - product_external_id: "SERUM-VITC" quantity: 1 amount: "320.00" responses: "200": description: Fiche mise à jour. content: application/json: schema: { $ref: "#/components/schemas/OrderOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/OrderNotFound" } "409": description: | Un appel est en cours sur cette commande (`order_call_in_progress`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | Payload invalide (`validation_error`) : chaîne vide, champ inconnu, `external_id` différent de l'URL, `customer`, `amount` ou `items` à `null` ; ou une ligne qui référence un produit inconnu (`order_item_unknown_product`) ou une variante non précisée (`order_item_variant_required`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: archiveOrder summary: Archiver une commande tags: [Orders] x-status: live x-rate-limit: "120/minute" description: | Archive la commande — **elle n'est pas supprimée**. Elle disparaît de la liste par défaut et ne peut plus être appelée ; ses appels et son historique restent lisibles (`?status=archived`). **Refusé pendant un appel en cours** (`409 order_call_in_progress`). parameters: - $ref: "#/components/parameters/OrderExternalId" responses: "204": description: Commande archivée. Aucun corps. "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/OrderNotFound" } "409": description: | Un appel est en cours sur cette commande (`order_call_in_progress`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/orders/{order_external_id}/close: post: operationId: closeOrder summary: Fermer une commande tags: [Orders] x-status: live x-rate-limit: "120/minute" description: | Vous en avez fini avec la commande — livrée, retournée, annulée par vous, ou autre. Comme chez Shopify, c'est **un axe à part** du statut d'appel : `order_status` continue de dire ce que le client a répondu, `closed` dit que vous avez clos le dossier. Une commande fermée **sort de la liste par défaut** (`?lifecycle=closed` la retrouve) et **ne peut plus être appelée** (`409 order_closed` sur `POST /v1/calls`) tant qu'elle n'est pas rouverte. Déjà fermée : la raison est mise à jour, la date de fermeture reste celle de la première fois. **Refusé pendant un appel en cours** (`409 order_call_in_progress`). parameters: - $ref: "#/components/parameters/OrderExternalId" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/OrderClose" } examples: livree: summary: Le colis est arrivé value: { reason: "delivered" } retournee: summary: Le client a refusé le colis value: { reason: "returned" } responses: "200": description: Fiche de la commande, fermée. content: application/json: schema: { $ref: "#/components/schemas/OrderOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/OrderNotFound" } "409": description: | Un appel est en cours sur cette commande (`order_call_in_progress`). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: "`reason` absente ou hors liste (`validation_error`)." content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /v1/orders/{order_external_id}/reopen: post: operationId: reopenOrder summary: Rouvrir une commande tags: [Orders] x-status: live x-rate-limit: "120/minute" description: | Annule la fermeture : la commande revient dans la liste par défaut et peut de nouveau être appelée. `closed_at` et `closed_reason` repassent à `null`. Déjà ouverte : rien ne change, la fiche est renvoyée telle quelle. parameters: - $ref: "#/components/parameters/OrderExternalId" responses: "200": description: Fiche de la commande, ouverte. content: application/json: schema: { $ref: "#/components/schemas/OrderOut" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/OrderNotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: yk_... (38 chars) description: | Clé API au format `yk_...`. Fournie manuellement par l'équipe Yasmine (bootstrap). Self-service `POST /v1/me/api-keys` à venir en M4. parameters: ShopExternalIdFiltre: in: query name: shop_external_id required: false schema: { type: string, maxLength: 128, pattern: "^[a-zA-Z0-9_.-]+$" } description: | Restreint la liste à une boutique. Absent, la liste couvre tout votre compte — un revendeur multi-boutiques n'a plus à les interroger une par une, ni à fusionner et repaginer lui-même. Une boutique inconnue répond `404 shop_not_found`. example: "shop-4471" ShopExternalId: in: path name: shop_external_id required: true schema: { type: string, maxLength: 128, pattern: "^[a-zA-Z0-9_.-]+$" } description: | Identifiant de la boutique **dans votre système**, celui fourni à la création. Jamais notre UUID interne. example: "shop-4471" ProductExternalId: in: path name: product_external_id required: true schema: { type: string, maxLength: 128, pattern: "^[a-zA-Z0-9_.-]+$" } description: | Identifiant du produit **dans votre systeme**, celui fourni a la creation. Jamais notre UUID interne. example: "TSHIRT-01" OrderExternalId: in: path name: order_external_id required: true schema: { type: string, maxLength: 128, pattern: "^[a-zA-Z0-9_.-]+$" } description: | Identifiant de la commande **dans votre systeme**, celui fourni a la declaration. Jamais notre UUID interne. example: "CMD-2026-8891" RequestIdHeader: in: header name: X-Request-ID required: false schema: type: string format: uuid description: | UUID v4 que vous souhaitez attribuer à cette requête. - Si présent et format UUID v4 strict valide (RFC 4122) : Yasmine le respecte et le retourne tel quel en header response. - Si absent, vide, ou format invalide : Yasmine en génère un nouveau (rejet sécurisé contre la log injection / path traversal). Toutes les responses incluent ce header. Les erreurs RFC 7807 incluent le même `request_id` dans le body. Joignez-le à tout ticket support pour tracer une requête côté serveur. IdempotencyKey: in: header name: Idempotency-Key required: true schema: type: string minLength: 1 maxLength: 255 description: | Clé d'idempotence **obligatoire**. Format libre, 1-255 chars (UUID v4 recommandé). Scope par reseller, TTL 24 h. - **Replay** (même clé + même body) → la réponse stockée est rejouée bit-for-bit avec `X-Idempotent-Replay: true`. Aucun nouvel appel, aucun nouveau débit. - **Conflit** (même clé + body différent) → `409 idempotency_key_conflict`. Générer une nouvelle clé pour une requête différente. - **Header absent / vide / > 255 chars** → `400` avec slug `missing_idempotency_key` / `idempotency_key_empty` / `idempotency_key_too_long`. Limit: in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } Cursor: in: query name: cursor schema: { type: string } description: Opaque. Fourni par `next_cursor` de la page précédente. Sort: in: query name: sort schema: { type: string, default: "-created_at" } description: Champ + signe. `-` pour DESC. Défaut `-created_at`. CallId: in: path name: call_id required: true # On accepte n'importe quelle string en path. Un UUID # syntaxiquement invalide est traite cote serveur comme 404 # `call_not_found` (pas 422), pour homogeneiser l'anti-enum. schema: { type: string } headers: RequestIdResponse: description: | UUID v4 qui identifie cette requête côté serveur. Émis sur **toutes** les responses, succès comme erreur. Respecte la valeur fournie par le client via le header `X-Request-ID` si format UUID v4 strict valide, sinon régénère. À joindre à tout ticket support pour tracer une requête. schema: type: string format: uuid example: 3f8e9d12-4b5a-4c6d-8e7f-123456789abc RateLimitLimit: description: | Plafond rate-limit appliqué à cet endpoint pour la clé API courante, exprimé en requêtes par minute. Identique quelle que soit l'issue de la requête (2xx, 4xx, 5xx). schema: { type: integer } example: 600 RateLimitRemaining: description: | Nombre de requêtes restant dans la fenêtre courante. Vaut `0` sur une 429. schema: { type: integer } example: 597 RateLimitReset: description: | Timestamp Unix (secondes depuis l'epoch) du prochain reset de la fenêtre rate-limit. schema: { type: integer } example: 1714744860 responses: ShopNotFound: description: | Aucune boutique active avec cet identifiant sur votre compte (`shop_not_found`). **Corps identique dans trois cas** — elle n'existe pas, elle est archivée, ou elle appartient à un autre compte. Une réponse différenciée permettrait de deviner quelles boutiques existent ailleurs. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } ProductNotFound: description: | Rien a cette adresse. **Deux slugs possibles**, et le corps ne dit rien de plus : - `shop_not_found` — la boutique du chemin est introuvable ; - `product_not_found` — la boutique existe, le produit non. Chaque slug couvre **trois situations au corps identique** : cela n'existe pas, c'est efface, ou cela appartient a un autre compte. Une reponse differenciee permettrait de deviner ce qui existe ailleurs. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } OrderNotFound: description: | Rien a cette adresse. **Deux slugs possibles**, et le corps ne dit rien de plus : - `shop_not_found` — la boutique du chemin est introuvable ; - `order_not_found` — la boutique existe, la commande non. Chaque slug couvre **trois situations au corps identique** : cela n'existe pas, c'est archive, ou cela appartient a un autre compte. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Unauthorized: description: Clé API absente, invalide ou révoquée. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } CallNotFound: description: | Appel introuvable. **Body byte-identique** sur les 3 cas possibles (UUID invalide / inconnu / autre tenant) — anti-énum. Slug stable `call_not_found`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } example: type: https://docs.yasmine.akidly.com/errors/call_not_found title: Not Found status: 404 detail: Aucun appel trouve pour cet identifiant. instance: /v1/calls/00000000-0000-0000-0000-000000000000 request_id: a1b2c3d4 NotFound: description: Ressource introuvable ou non accessible au reseller courant. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } ValidationError: description: Payload mal formé — détails dans `errors[]`. content: application/problem+json: schema: allOf: - $ref: "#/components/schemas/Problem" - type: object properties: errors: type: array items: type: object properties: loc: { type: array, items: { type: string } } msg: { type: string } type: { type: string } IdempotencyKeyError: description: | Header `Idempotency-Key` invalide. Trois slugs possibles : `missing_idempotency_key` (absent), `idempotency_key_empty` (chaîne vide), `idempotency_key_too_long` (> 255 chars). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } examples: missing: summary: Header absent value: type: https://docs.yasmine.akidly.com/errors/missing_idempotency_key title: Bad Request status: 400 detail: Le header Idempotency-Key est obligatoire sur POST /v1/calls. instance: /v1/calls request_id: a1b2c3d4 empty: summary: Header vide value: type: https://docs.yasmine.akidly.com/errors/idempotency_key_empty title: Bad Request status: 400 detail: Idempotency-Key ne peut pas etre vide. instance: /v1/calls request_id: a1b2c3d4 too_long: summary: Header > 255 chars value: type: https://docs.yasmine.akidly.com/errors/idempotency_key_too_long title: Bad Request status: 400 detail: "Idempotency-Key ne peut pas depasser 255 caracteres (recu : 312)." instance: /v1/calls request_id: a1b2c3d4 IdempotencyKeyConflict: description: | Idempotency-Key déjà utilisée avec un body différent. Générer une nouvelle clé pour une requête différente. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } example: type: https://docs.yasmine.akidly.com/errors/idempotency_key_conflict title: Conflict status: 409 detail: Idempotency-Key 'order-cmd-2026-789' a deja ete utilisee avec un body different. instance: /v1/calls request_id: a1b2c3d4 InsufficientBalance: description: Solde insuffisant pour déclencher l'appel. content: application/problem+json: schema: allOf: - $ref: "#/components/schemas/Problem" - type: object properties: balance_seconds: { type: integer } required_seconds: { type: integer } RateLimited: description: | Rate-limit par clé API dépassé. Slug `rate_limit_exceeded`. Respecter `Retry-After` (secondes) avant de retenter. headers: Retry-After: description: Secondes avant le prochain reset du bucket. schema: { type: integer } X-RateLimit-Limit: description: Plafond appliqué à cet endpoint (requêtes par minute). schema: { type: integer } X-RateLimit-Remaining: description: Compteur restant dans la fenêtre courante. Vaut `0` sur 429. schema: { type: integer } X-RateLimit-Reset: description: Timestamp Unix du prochain reset. schema: { type: integer } content: application/problem+json: schema: allOf: - $ref: "#/components/schemas/Problem" - type: object properties: retry_after: type: integer description: Secondes avant le prochain reset (identique au header). InternalError: description: | Erreur interne inattendue côté Yasmine (slug `internal_error`). Toujours loguée côté serveur avec le `request_id` correspondant. Le client doit retenter (idempotent), ou contacter le support en joignant `request_id`. Le détail de la stack n'est jamais exposé. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } example: type: https://docs.yasmine.akidly.com/errors/internal_error title: Internal Server Error status: 500 detail: Une erreur interne est survenue. Contacter le support avec la valeur de request_id. instance: /v1/calls request_id: a1b2c3d4 schemas: # --- Boutiques ------------------------------------------------------- # ⚠️ Ces schemas sont GENERES depuis le contrat livre. Ne pas les # editer a la main : la spec et le service divergeraient, exactement # le defaut trouve le 2026-09-02 sur `inspection_before_payment`, # annonce par la documentation mais refuse en 422. Un controle # automatique compare desormais les deux et echoue en cas d'ecart. ShopCreate: additionalProperties: false description: 'Payload `POST /v1/shops`. Minimum : `external_id`, `name`, `country`.' properties: name: maxLength: 200 minLength: 1 type: string country: enum: - MA - DZ - TN type: string default_language: enum: - ar - fr type: string nullable: true description: 'Registre habituel : `ar` (darija locale) ou `fr`. Sert de defaut quand une demande d''appel ne precise pas la langue. Ce n''est PAS un choix de dialecte : celui-ci se deduit du pays du client appele.' merchant_external_id: maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string nullable: true description: Identifiant du commercant proprietaire, tel qu'il existe chez vous. Regroupe plusieurs boutiques sous un meme commercant. A ne pas confondre avec `external_id`, qui identifie LA boutique. example: M-4471 phone: maxLength: 30 minLength: 1 type: string nullable: true description: Telephone de la boutique. Format local ou international accepte, mais le numero doit appartenir au pays declare. Renvoye normalise en E.164. example: '0522123456' whatsapp: maxLength: 30 minLength: 1 type: string nullable: true description: Numero WhatsApp, memes regles que `phone`. email: maxLength: 50 minLength: 1 pattern: ^[^@\s]+@[^@\s]+\.[^@\s]+$ type: string nullable: true description: Email de contact. Normalise en minuscules. address: maxLength: 200 minLength: 1 type: string nullable: true customer_service_hours: maxLength: 200 minLength: 1 type: string nullable: true description: Horaires du service client, texte libre. example: Lun-Sam 9h-18h website_url: maxLength: 255 minLength: 1 pattern: ^https?://[^\s]+$ type: string nullable: true sector: maxLength: 80 minLength: 1 type: string nullable: true description: Secteur d'activite, texte libre. « Boutique de cosmetique bio artisanale » informe mieux qu'un code ferme. has_physical_store: type: boolean nullable: true description: 'Point de vente ou le client peut se presenter ? `true` oui, `false` vente a distance uniquement. Absent ou `null` vaut `false` : le troisieme etat « non declare » a ete retire le 2026-09-03. Repond a « puis-je venir ? », question que `address` ne tranche pas.' delivery_policy: maxLength: 250 minLength: 1 type: string nullable: true delivery_delay: maxLength: 100 minLength: 1 type: string nullable: true payment_policy: maxLength: 250 minLength: 1 type: string nullable: true return_policy: maxLength: 250 minLength: 1 type: string nullable: true exchange_policy: maxLength: 250 minLength: 1 type: string nullable: true warranty_policy: maxLength: 250 minLength: 1 type: string nullable: true inspection_before_payment: type: boolean nullable: true description: Le client peut-il ouvrir le colis avant de payer ? Absent ou `null` vaut `false` — le troisieme etat « non declare » a ete retire le 2026-09-03. faq_free: maxLength: 500 minLength: 1 type: string nullable: true current_promotions: maxLength: 250 minLength: 1 type: string nullable: true max_discount: anyOf: - maximum: 10000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: 'Remise maximale que l''agent pourra accorder. Interpretee dans la devise du pays de la boutique (MA -> MAD, DZ -> DZD, TN -> TND). Plafond ramene de 1 000 000 a 10 000 le 2026-09-03 : au-dela, ce n''est plus une remise negociable au telephone. La remise d''UNE commande garde son propre plafond, plus haut.' nullable: true delivery_companies: items: maxLength: 60 minLength: 1 type: string maxItems: 10 type: array nullable: true description: Transporteurs avec lesquels la boutique travaille. city_coverage: items: maxLength: 80 minLength: 1 type: string maxItems: 200 type: array nullable: true description: 'Villes livrees. **Present = liste fermee** : l''agent annonce la non-livraison ailleurs. Absent = aucune restriction.' tone: enum: - formal - friendly - default type: string nullable: true agent_name: maxLength: 20 minLength: 1 pattern: ^[A-Za-zÀ-ÿ؀-ۿ][A-Za-zÀ-ÿ؀-ۿ \.'\-]*$ type: string nullable: true description: 'Prenom de l''agent. Defaut : Yasmine.' voice_id: maxLength: 32 minLength: 1 type: string nullable: true max_call_duration_seconds: maximum: 600 minimum: 30 type: integer nullable: true call_hours: maxLength: 200 type: string nullable: true description: 'Plage d''appel : les creneaux pendant lesquels le client peut etre appele, au format des horaires OpenStreetMap — jours en anglais abreges, heures sur 24 h, plusieurs creneaux par jour separes par une virgule, plusieurs regles par un point-virgule. Exemple : `Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00` (dimanche : jamais). **Jamais avant 9 h ni apres 22 h**, dans le fuseau de la boutique : une plage qui en sort est refusee (422). Un appel demande hors plage est accepte et part a la prochaine ouverture. Vaut pour toutes les commandes de la boutique qui n''en declarent pas une propre. Absente : du lundi au samedi de 10 h a 21 h et le dimanche de 11 h a 20 h.' example: Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00 external_id: description: Identifiant de la boutique dans VOTRE systeme. Sert d'adresse dans l'URL, d'ou le jeu de caracteres restreint. **Immuable** apres creation. maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string example: shop-4471 required: - name - country - external_id type: object ShopPatch: additionalProperties: false description: 'Payload `PATCH /v1/shops/{external_id}` — modification partielle. Semantique, differente de l''upsert des demandes d''appel : - champ **absent** -> inchange - champ a **`null`** -> **efface** - champ a **`""`** -> 422 `external_id` est **accepte mais jamais ecrit** : il identifie la boutique, il ne se modifie pas. Un integrateur l''envoie naturellement pour dire de quelle boutique il parle — le refuser transformait ce reflexe en 422 opaque (defaut vecu le 2026-09-03). S''il est fourni et qu''il differe de celui de l''URL, la requete est refusee : deux identifiants qui se contredisent ne se tranchent pas en silence.' properties: name: maxLength: 200 minLength: 1 type: string nullable: true description: Nom commercial, prononce par l'agent au telephone. example: GlowArt Casa country: enum: - MA - DZ - TN type: string nullable: true description: Pays d'etablissement de la boutique. Determine le format attendu de `phone` et `whatsapp`, le fuseau horaire, et la devise dans laquelle `max_discount` est interprete. example: MA default_language: enum: - ar - fr type: string nullable: true description: 'Registre habituel : `ar` (darija locale) ou `fr`. Sert de defaut quand une demande d''appel ne precise pas la langue. Ce n''est PAS un choix de dialecte : celui-ci se deduit du pays du client appele.' merchant_external_id: maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string nullable: true description: Identifiant du commercant proprietaire, tel qu'il existe chez vous. Regroupe plusieurs boutiques sous un meme commercant. A ne pas confondre avec `external_id`, qui identifie LA boutique. example: M-4471 phone: maxLength: 30 minLength: 1 type: string nullable: true description: Telephone de la boutique. Format local ou international accepte, mais le numero doit appartenir au pays declare. Renvoye normalise en E.164. example: '0522123456' whatsapp: maxLength: 30 minLength: 1 type: string nullable: true description: Numero WhatsApp, memes regles que `phone`. email: maxLength: 50 minLength: 1 pattern: ^[^@\s]+@[^@\s]+\.[^@\s]+$ type: string nullable: true description: Email de contact. Normalise en minuscules. address: maxLength: 200 minLength: 1 type: string nullable: true customer_service_hours: maxLength: 200 minLength: 1 type: string nullable: true description: Horaires du service client, texte libre. example: Lun-Sam 9h-18h website_url: maxLength: 255 minLength: 1 pattern: ^https?://[^\s]+$ type: string nullable: true sector: maxLength: 80 minLength: 1 type: string nullable: true description: Secteur d'activite, texte libre. « Boutique de cosmetique bio artisanale » informe mieux qu'un code ferme. has_physical_store: type: boolean nullable: true description: 'Point de vente ou le client peut se presenter ? `true` oui, `false` vente a distance uniquement. Absent ou `null` vaut `false` : le troisieme etat « non declare » a ete retire le 2026-09-03. Repond a « puis-je venir ? », question que `address` ne tranche pas.' delivery_policy: maxLength: 250 minLength: 1 type: string nullable: true delivery_delay: maxLength: 100 minLength: 1 type: string nullable: true payment_policy: maxLength: 250 minLength: 1 type: string nullable: true return_policy: maxLength: 250 minLength: 1 type: string nullable: true exchange_policy: maxLength: 250 minLength: 1 type: string nullable: true warranty_policy: maxLength: 250 minLength: 1 type: string nullable: true inspection_before_payment: type: boolean nullable: true description: Le client peut-il ouvrir le colis avant de payer ? Absent ou `null` vaut `false` — le troisieme etat « non declare » a ete retire le 2026-09-03. faq_free: maxLength: 500 minLength: 1 type: string nullable: true current_promotions: maxLength: 250 minLength: 1 type: string nullable: true max_discount: anyOf: - maximum: 10000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: 'Remise maximale que l''agent pourra accorder. Interpretee dans la devise du pays de la boutique (MA -> MAD, DZ -> DZD, TN -> TND). Plafond ramene de 1 000 000 a 10 000 le 2026-09-03 : au-dela, ce n''est plus une remise negociable au telephone. La remise d''UNE commande garde son propre plafond, plus haut.' nullable: true delivery_companies: items: maxLength: 60 minLength: 1 type: string maxItems: 10 type: array nullable: true description: Transporteurs avec lesquels la boutique travaille. city_coverage: items: maxLength: 80 minLength: 1 type: string maxItems: 200 type: array nullable: true description: 'Villes livrees. **Present = liste fermee** : l''agent annonce la non-livraison ailleurs. Absent = aucune restriction.' tone: enum: - formal - friendly - default type: string nullable: true agent_name: maxLength: 20 minLength: 1 pattern: ^[A-Za-zÀ-ÿ؀-ۿ][A-Za-zÀ-ÿ؀-ۿ \.'\-]*$ type: string nullable: true description: 'Prenom de l''agent. Defaut : Yasmine.' voice_id: maxLength: 32 minLength: 1 type: string nullable: true max_call_duration_seconds: maximum: 600 minimum: 30 type: integer nullable: true call_hours: maxLength: 200 type: string nullable: true description: 'Plage d''appel : les creneaux pendant lesquels le client peut etre appele, au format des horaires OpenStreetMap — jours en anglais abreges, heures sur 24 h, plusieurs creneaux par jour separes par une virgule, plusieurs regles par un point-virgule. Exemple : `Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00` (dimanche : jamais). **Jamais avant 9 h ni apres 22 h**, dans le fuseau de la boutique : une plage qui en sort est refusee (422). Un appel demande hors plage est accepte et part a la prochaine ouverture. Vaut pour toutes les commandes de la boutique qui n''en declarent pas une propre. Absente : du lundi au samedi de 10 h a 21 h et le dimanche de 11 h a 20 h.' example: Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00 external_id: type: string nullable: true description: 'Facultatif, et purement redondant : la boutique est deja designee par l''URL. Accepte parce qu''un client l''envoie naturellement comme cle. **Jamais ecrit** — l''identifiant est immuable. S''il differe de celui de l''URL, la requete est refusee (422 `validation_error`).' type: object ShopOut: additionalProperties: false description: Fiche boutique renvoyee par `/v1/shops`. properties: id: description: Identifiant interne Yasmine, a citer au support. format: uuid type: string external_id: type: string name: type: string country: type: string nullable: true default_language: type: string nullable: true merchant_external_id: type: string nullable: true phone: type: string nullable: true whatsapp: type: string nullable: true email: type: string nullable: true address: type: string nullable: true customer_service_hours: type: string nullable: true website_url: type: string nullable: true sector: type: string nullable: true has_physical_store: default: false type: boolean delivery_policy: type: string nullable: true delivery_delay: type: string nullable: true payment_policy: type: string nullable: true return_policy: type: string nullable: true exchange_policy: type: string nullable: true warranty_policy: type: string nullable: true inspection_before_payment: default: false type: boolean faq_free: type: string nullable: true current_promotions: type: string nullable: true max_discount: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true delivery_companies: items: type: string type: array nullable: true city_coverage: items: type: string type: array nullable: true tone: type: string nullable: true agent_name: type: string nullable: true voice_id: type: string nullable: true max_call_duration_seconds: type: integer nullable: true call_hours: type: string nullable: true status: description: Calcule depuis `archived_at`. enum: - active - archived type: string created_at: format: date-time type: string updated_at: format: date-time type: string archived_at: format: date-time type: string nullable: true required: - id - external_id - name - status - created_at - updated_at type: object PaginatedShops: additionalProperties: false description: Enveloppe paginee `GET /v1/shops`. properties: data: items: $ref: '#/components/schemas/ShopOut' type: array next_cursor: type: string nullable: true has_more: type: boolean required: - data - has_more type: object ProductVariantInput: additionalProperties: false description: 'Une variante declaree dans un produit. `external_id` est **conditionnellement requis** : facultatif si le produit n''a qu''une variante, obligatoire des qu''il en a plusieurs — sinon une commande ne pourrait pas designer laquelle. La verification porte sur la liste entiere, pas sur une entree isolee.' properties: external_id: maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string nullable: true description: Identifiant de la variante dans VOTRE systeme. Facultatif si le produit n'en a qu'une, obligatoire des qu'il en a plusieurs. example: TSH-XL options: additionalProperties: type: string type: object nullable: true description: 'Ce qui distingue cette variante : `{"Taille": "XL"}`. Trois axes au maximum. Vide ou absent pour un produit sans choix.' example: Couleur: rouge Taille: XL unit_price: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' description: 'Prix de CETTE variante, dans la devise du pays de la boutique. **Obligatoire** : une variante sans prix ne se vend pas, et l''agent ne saurait pas repondre a « ca coute combien ? ». C''est ici que vit le prix — le XL peut couter plus cher que le S.' compare_at_price: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: 'Prix barre, pour valoriser une remise : « c''etait 300, vous l''avez a 250 ». **Doit etre superieur a `unit_price`** — l''inverse serait forcement une erreur de saisie.' nullable: true sku: maxLength: 50 minLength: 1 type: string nullable: true description: Reference de stock, a votre usage. Conservee telle quelle et renvoyee dans les lectures ; jamais prononcee au client. example: TSH-BLC-XL stock_quantity: maximum: 1000000 minimum: 0 type: integer nullable: true description: 'Quantite en stock de cette variante, si vous la suivez. Absent ou `null` : inconnue, l''agent n''en dit rien. **`0` : epuisee** — l''agent ne confirme pas un article qu''on ne peut pas livrer, et propose une autre variante s''il en reste.' example: 12 required: - unit_price type: object ProductCreate: additionalProperties: false description: 'Payload `POST /v1/shops/{id}/products`. Minimum : `external_id`, `name`, et un prix — `unit_price` pour un produit sans choix, ou celui de chaque entree de `variants`.' properties: name: description: Nom du produit tel qu'il sera prononce au client. Ecrivez-le comme on le dit, pas comme on le reference en base. maxLength: 150 minLength: 1 type: string example: T-shirt blanc en coton description: maxLength: 500 minLength: 1 type: string nullable: true description: Matiere, contenu, usage. Sert a repondre aux questions du client sur l'article ; n'est pas recitee d'office. product_url: maxLength: 300 minLength: 1 pattern: ^https?://[^\s]+$ type: string nullable: true description: Lien vers la fiche produit sur votre site. Conserve pour vos propres besoins et vos exports ; jamais lu au telephone. example: https://boutique.example/produits/tshirt-blanc max_discount: anyOf: - maximum: 10000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: 'Remise maximale sur ce produit. Absent : celle de la boutique s''applique, dans la limite de 10 000. Sur une commande a plusieurs produits, c''est **la plus haute** qui vaut — et une valeur a `0` ne bloque rien, elle signifie seulement que ce produit n''apporte pas de marge.' nullable: true return_policy: maxLength: 250 minLength: 1 type: string nullable: true description: 'Politique de retour propre a ce produit — hygiene, sur-mesure. Absent : celle de la boutique s''applique.' current_promotions: maxLength: 250 minLength: 1 type: string nullable: true description: 'Promotion sur ce produit. S''ajoute a celle de la boutique, elle ne la remplace pas : les deux sont vraies.' unit_price: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: '**Le prix d''un produit sans choix.** Il est pose sur la variante unique, creee automatiquement. **Obligatoire a la creation** si `variants` est absent : un produit sans prix n''existe pas. En lecture, ce champ renvoie ce prix — mais seulement si le produit n''a qu''une variante ; sinon il est absent et il faut lire `variants`. Jamais `null` sur un `PATCH` : un prix s''edite, il ne s''efface pas.' nullable: true stock_quantity: maximum: 1000000 minimum: 0 type: integer nullable: true description: '**Le stock d''un produit sans choix**, pose sur sa variante unique — meme raccourci que `unit_price`. Absent : inconnu, l''agent n''en dit rien. `0` : epuise. Sur un `PATCH`, `null` efface la quantite (le stock redevient inconnu). En lecture, renvoye seulement si le produit n''a qu''une variante ; sinon lire `variants`.' example: 12 variants: items: $ref: '#/components/schemas/ProductVariantInput' maxItems: 60 type: array nullable: true description: 'Les declinaisons vendables. **Absent : le produit recoit une variante unique** portant `unit_price` — vous n''avez rien a declarer pour un produit sans choix. Sur un `PATCH`, la liste envoyee **remplace entierement** l''ancienne ; une variante qui n''y figure plus est supprimee — refuse (`409 product_in_pending_orders`) si une commande ouverte, non tranchee, l''attend encore. Ce que chaque appel a annonce reste sur l''appel (`order_snapshot`).' external_id: description: Identifiant du produit dans VOTRE systeme, **unique sur tout votre compte** — toutes boutiques confondues. Sert d'adresse dans l'URL, d'ou le jeu de caracteres restreint. **Immuable** apres creation. maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string example: TSHIRT-01 required: - name - external_id type: object ProductPatch: additionalProperties: false description: 'Payload `PATCH` — modification partielle. Champ **absent** = inchange, champ a **`null`** = efface, chaine vide = 422. Meme semantique que sur les boutiques. `external_id` est **accepte comme cle de confirmation** : un logiciel qui pousse une mise a jour envoie naturellement l''identifiant du produit qu''il modifie. Il doit valoir celui de l''URL — sinon 422 — et n''est jamais ecrit : un produit deja commande ne se renomme pas.' properties: name: maxLength: 150 minLength: 1 type: string nullable: true description: Nom du produit tel qu'il sera prononce au client. Ecrivez-le comme on le dit, pas comme on le reference en base. example: T-shirt blanc en coton description: maxLength: 500 minLength: 1 type: string nullable: true description: Matiere, contenu, usage. Sert a repondre aux questions du client sur l'article ; n'est pas recitee d'office. product_url: maxLength: 300 minLength: 1 pattern: ^https?://[^\s]+$ type: string nullable: true description: Lien vers la fiche produit sur votre site. Conserve pour vos propres besoins et vos exports ; jamais lu au telephone. example: https://boutique.example/produits/tshirt-blanc max_discount: anyOf: - maximum: 10000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: 'Remise maximale sur ce produit. Absent : celle de la boutique s''applique, dans la limite de 10 000. Sur une commande a plusieurs produits, c''est **la plus haute** qui vaut — et une valeur a `0` ne bloque rien, elle signifie seulement que ce produit n''apporte pas de marge.' nullable: true return_policy: maxLength: 250 minLength: 1 type: string nullable: true description: 'Politique de retour propre a ce produit — hygiene, sur-mesure. Absent : celle de la boutique s''applique.' current_promotions: maxLength: 250 minLength: 1 type: string nullable: true description: 'Promotion sur ce produit. S''ajoute a celle de la boutique, elle ne la remplace pas : les deux sont vraies.' unit_price: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: '**Le prix d''un produit sans choix.** Il est pose sur la variante unique, creee automatiquement. **Obligatoire a la creation** si `variants` est absent : un produit sans prix n''existe pas. En lecture, ce champ renvoie ce prix — mais seulement si le produit n''a qu''une variante ; sinon il est absent et il faut lire `variants`. Jamais `null` sur un `PATCH` : un prix s''edite, il ne s''efface pas.' nullable: true stock_quantity: maximum: 1000000 minimum: 0 type: integer nullable: true description: '**Le stock d''un produit sans choix**, pose sur sa variante unique — meme raccourci que `unit_price`. Absent : inconnu, l''agent n''en dit rien. `0` : epuise. Sur un `PATCH`, `null` efface la quantite (le stock redevient inconnu). En lecture, renvoye seulement si le produit n''a qu''une variante ; sinon lire `variants`.' example: 12 variants: items: $ref: '#/components/schemas/ProductVariantInput' maxItems: 60 type: array nullable: true description: 'Les declinaisons vendables. **Absent : le produit recoit une variante unique** portant `unit_price` — vous n''avez rien a declarer pour un produit sans choix. Sur un `PATCH`, la liste envoyee **remplace entierement** l''ancienne ; une variante qui n''y figure plus est supprimee — refuse (`409 product_in_pending_orders`) si une commande ouverte, non tranchee, l''attend encore. Ce que chaque appel a annonce reste sur l''appel (`order_snapshot`).' external_id: maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string nullable: true description: 'Facultatif, et purement confirmatif : s''il est present il doit valoir celui de l''URL. L''identifiant d''un produit ne se modifie pas.' example: TSHIRT-01 type: object ProductVariantOut: additionalProperties: false description: Une variante, telle que renvoyee. properties: id: format: uuid type: string external_id: type: string nullable: true description: '`null` sur la variante d''un produit sans choix : vous ne l''avez pas nommee, et vous n''avez pas a le faire.' options: additionalProperties: type: string type: object unit_price: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' description: Prix de cette variante. Toujours present. compare_at_price: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true sku: type: string nullable: true stock_quantity: type: integer nullable: true description: 'Quantite en stock, `null` si vous ne la suivez pas. `0` : epuisee.' position: description: 'Rang de la variante dans le produit, a partir de 0 — celui de la liste que vous avez envoyee. Il rend les lectures stables : deux appels successifs renvoient les variantes dans le meme ordre.' type: integer required: - id - options - unit_price - position type: object ProductBatchIn: type: object additionalProperties: false required: [items] description: | Un lot de produits à poser d'un coup. Chaque entrée a la forme exacte d'une déclaration à l'unité. properties: items: type: array minItems: 1 maxItems: 100 description: | Les produits du lot, 100 au maximum. Au-delà, découpez : un lot plus gros est refusé en entier. Chaque entrée a la forme d'une déclaration à l'unité, et elle est vérifiée **pour elle-même** : une entrée qui ne s'y conforme pas est un refus de ligne, jamais un refus de lot. items: { $ref: "#/components/schemas/ProductCreate" } ProductBatchItemOut: type: object additionalProperties: false required: [external_id, status] description: Le sort d'une entrée du lot. properties: external_id: type: string description: La référence envoyée, pour vous y retrouver. status: type: string enum: [created, updated, failed] description: | `created` : le produit n'existait pas. `updated` : il existait, sa fiche est remplacée. `failed` : refusé, la raison est dans `error`. product: allOf: [{ $ref: "#/components/schemas/ProductOut" }] nullable: true description: La fiche telle qu'elle est désormais. Absente sur un refus. error: type: object nullable: true description: | Le refus, dans la même forme que nos erreurs habituelles (`type`, `title`, `detail`). Absent sur un succès. ProductBatchOut: type: object additionalProperties: false required: [created, updated, failed, results] description: Le compte rendu d'un lot, ligne par ligne. properties: created: { type: integer, description: Produits créés. } updated: { type: integer, description: Produits mis à jour. } failed: { type: integer, description: Entrées refusées. } results: type: array description: | Un résultat par entrée envoyée, **dans le même ordre**. Aucune ligne n'est omise, même refusée. items: { $ref: "#/components/schemas/ProductBatchItemOut" } ProductOut: additionalProperties: false description: Fiche produit complete, variantes comprises. properties: id: format: uuid type: string external_id: type: string name: type: string description: type: string nullable: true product_url: type: string nullable: true max_discount: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true return_policy: type: string nullable: true current_promotions: type: string nullable: true unit_price: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: Prix de la variante unique. **Absent si le produit a plusieurs variantes** — lire `variants` dans ce cas. nullable: true stock_quantity: type: integer nullable: true description: 'Stock de la variante unique. **Absent si le produit a plusieurs variantes** — lire `variants` dans ce cas ; `null` si vous ne le suivez pas.' variants: items: $ref: '#/components/schemas/ProductVariantOut' type: array created_at: format: date-time type: string updated_at: format: date-time type: string required: - id - external_id - name - variants - created_at - updated_at type: object PaginatedProducts: additionalProperties: false description: Enveloppe paginee `GET /v1/shops/{id}/products`. properties: data: items: $ref: '#/components/schemas/ProductOut' type: array next_cursor: type: string nullable: true has_more: type: boolean required: - data - has_more type: object OrderDeclare: additionalProperties: false description: 'Payload `POST /v1/shops/{id}/orders`. Minimum : `external_id`, `customer`, `amount`, et de quoi decrire le contenu — `items` ou `items_text`.' properties: customer: $ref: '#/components/schemas/CustomerInput' description: 'Le client a appeler : `name` + `phone_number` requis, genre, email, notes et langue facultatifs.' items: items: $ref: '#/components/schemas/OrderItem' maxItems: 50 minItems: 1 type: array nullable: true description: 'Les articles. Une ligne reference un produit declare (`product_external_id`, plus `variant_external_id` s''il a plusieurs variantes) ou se decrit sur place (`name`…). **Vivants jusqu''a l''appel** : une ligne referencee est relue au catalogue du moment a chaque lecture et a chaque appel — un prix corrige apres la declaration est celui annonce. Ce qui a ete annonce se lit ensuite sur l''appel (`order_snapshot`). Sur un `PATCH`, la liste envoyee remplace l''ancienne.' items_text: maxLength: 500 minLength: 1 type: string nullable: true description: Resume libre du contenu, cite tel quel au client. Requis si `items` est absent. delivery_address: maxLength: 500 minLength: 1 type: string nullable: true description: 'Adresse de livraison. Absente : l''agent la collecte aupres du client pendant l''appel.' amount: anyOf: - exclusiveMinimum: 0.0 maximum: 1000000.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' description: Montant total annonce au client, dans la devise de la commande. currency: enum: - MAD - EUR - USD - TND - DZD type: string nullable: true description: 'Devise ISO 4217. Absente : celle du pays de la boutique.' order_date: format: date type: string nullable: true subtotal: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true shipping_cost: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true discount: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true taxes: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true delivery_method: enum: - standard - express - pickup type: string nullable: true delivery_zone: enum: - urban - suburban - rural type: string nullable: true estimated_delivery_date: format: date type: string nullable: true description: Date de livraison estimee, que l'agent peut annoncer. order_notes: maxLength: 300 minLength: 1 type: string nullable: true description: 'Ce que l''agent doit savoir sur cette commande : « cadeau, ne pas mentionner le prix ».' source: enum: - web - pos - ads - other type: string nullable: true promo_code: maxLength: 50 minLength: 1 type: string nullable: true max_discount: anyOf: - maximum: 10000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: Remise maximale accordable sur cette commande — le niveau le plus precis de la cascade commande > produits > boutique. nullable: true metadata: additionalProperties: true type: object nullable: true description: Metadata libre du reseller. Max 2 KB serialise JSON. call_hours: maxLength: 200 type: string nullable: true description: 'Plage d''appel : les creneaux pendant lesquels le client peut etre appele, au format des horaires OpenStreetMap — jours en anglais abreges, heures sur 24 h, plusieurs creneaux par jour separes par une virgule, plusieurs regles par un point-virgule. Exemple : `Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00` (dimanche : jamais). **Jamais avant 9 h ni apres 22 h**, dans le fuseau de la boutique : une plage qui en sort est refusee (422). Un appel demande hors plage est accepte et part a la prochaine ouverture. Absente : la plage de la boutique, sinon du lundi au samedi de 10 h a 21 h et le dimanche de 11 h a 20 h. `null` sur un PATCH revient a celle de la boutique.' example: Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00 external_id: description: Identifiant de la commande dans VOTRE systeme, **unique sur tout votre compte** — toutes boutiques confondues, pour qu'une reference designe une commande et une seule. Sert d'adresse dans l'URL et de reference pour lancer l'appel. **Immuable** apres creation. maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string example: CMD-2026-8891 required: - customer - amount - external_id type: object OrderClose: additionalProperties: false description: 'Corps de `POST /v1/orders/{id}/close` : la raison, obligatoire.' properties: reason: description: 'Pourquoi vous fermez la commande : `delivered` (livree), `returned` (retournee), `cancelled` (annulee par vous) ou `other`.' enum: - delivered - returned - cancelled - other type: string required: - reason type: object OrderPatch: additionalProperties: false description: 'Payload `PATCH` — modification partielle. Champ **absent** = inchange, champ a **`null`** = efface, chaine vide = 422. Trois champs ne s''effacent pas — `customer`, `amount`, `items` : une commande a toujours un client, un montant et un contenu.' properties: customer: $ref: '#/components/schemas/CustomerInput' nullable: true description: 'Le client a appeler : `name` + `phone_number` requis, genre, email, notes et langue facultatifs. C''est lui que chaque appel sur cette commande joindra.' items: items: $ref: '#/components/schemas/OrderItem' maxItems: 50 minItems: 1 type: array nullable: true description: 'Les articles. Une ligne reference un produit declare (`product_external_id`, plus `variant_external_id` s''il a plusieurs variantes) ou se decrit sur place (`name`…). **Vivants jusqu''a l''appel** : une ligne referencee est relue au catalogue du moment a chaque lecture et a chaque appel — un prix corrige apres la declaration est celui annonce. Ce qui a ete annonce se lit ensuite sur l''appel (`order_snapshot`). Sur un `PATCH`, la liste envoyee remplace l''ancienne.' items_text: maxLength: 500 minLength: 1 type: string nullable: true description: Resume libre du contenu, cite tel quel au client. Requis si `items` est absent. delivery_address: maxLength: 500 minLength: 1 type: string nullable: true description: 'Adresse de livraison. Absente : l''agent la collecte aupres du client pendant l''appel.' amount: anyOf: - exclusiveMinimum: 0.0 maximum: 1000000.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: 'Montant total annonce au client, dans la devise de la commande. C''est ce que l''agent dit : les articles servent a detailler, pas a le calculer.' nullable: true currency: enum: - MAD - EUR - USD - TND - DZD type: string nullable: true description: 'Devise ISO 4217. Absente : celle du pays de la boutique.' order_date: format: date type: string nullable: true subtotal: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true shipping_cost: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true discount: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true taxes: anyOf: - maximum: 1000000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true delivery_method: enum: - standard - express - pickup type: string nullable: true delivery_zone: enum: - urban - suburban - rural type: string nullable: true estimated_delivery_date: format: date type: string nullable: true description: Date de livraison estimee, que l'agent peut annoncer. order_notes: maxLength: 300 minLength: 1 type: string nullable: true description: 'Ce que l''agent doit savoir sur cette commande : « cadeau, ne pas mentionner le prix ».' source: enum: - web - pos - ads - other type: string nullable: true promo_code: maxLength: 50 minLength: 1 type: string nullable: true max_discount: anyOf: - maximum: 10000.0 minimum: 0.0 type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' description: Remise maximale accordable sur cette commande — le niveau le plus precis de la cascade commande > produits > boutique. nullable: true metadata: additionalProperties: true type: object nullable: true description: Metadata libre du reseller. Max 2 KB serialise JSON. call_hours: maxLength: 200 type: string nullable: true description: 'Plage d''appel : les creneaux pendant lesquels le client peut etre appele, au format des horaires OpenStreetMap — jours en anglais abreges, heures sur 24 h, plusieurs creneaux par jour separes par une virgule, plusieurs regles par un point-virgule. Exemple : `Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00` (dimanche : jamais). **Jamais avant 9 h ni apres 22 h**, dans le fuseau de la boutique : une plage qui en sort est refusee (422). Un appel demande hors plage est accepte et part a la prochaine ouverture. Absente : la plage de la boutique, sinon du lundi au samedi de 10 h a 21 h et le dimanche de 11 h a 20 h. `null` sur un PATCH revient a celle de la boutique.' example: Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00 external_id: maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_.-]+$ type: string nullable: true description: 'Facultatif, et purement confirmatif : s''il est present il doit valoir celui de l''URL. L''identifiant d''une commande ne se modifie pas.' type: object OrderOut: additionalProperties: false description: Fiche complete d'une commande declaree. properties: id: format: uuid type: string external_id: type: string customer: $ref: '#/components/schemas/OrderCustomerOut' nullable: true items: items: $ref: '#/components/schemas/OrderItemOut' type: array items_text: type: string nullable: true delivery_address: type: string nullable: true amount: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true currency: type: string nullable: true order_date: format: date type: string nullable: true subtotal: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true shipping_cost: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true discount: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true taxes: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true delivery_method: type: string nullable: true delivery_zone: type: string nullable: true estimated_delivery_date: format: date type: string nullable: true order_notes: type: string nullable: true source: type: string nullable: true promo_code: type: string nullable: true max_discount: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true metadata: additionalProperties: true type: object nullable: true call_hours: type: string nullable: true description: La plage propre a la commande, ou `null` si elle suit celle de la boutique. order_status: description: 'Ou en est la commande : `PENDING` avant tout appel abouti, puis `CONFIRMED`, `CANCELLED`, `UNREACHABLE`… selon le resultat de l''appel. **Jamais modifiable a la main** : il se lit.' type: string calls: description: Les appels passes sur cette commande, du plus ancien au plus recent. items: $ref: '#/components/schemas/OrderCallOut' type: array call_in_progress: description: 'Vrai quand un appel n''est pas termine : la commande ne peut alors ni etre modifiee, ni archivee, ni rappelee.' type: boolean closed: description: 'Vrai quand vous en avez fini avec la commande (`POST …/close`) : elle sort de la liste par defaut et ne peut plus etre appelee sans etre rouverte (`POST …/reopen`). Un axe a part de `order_status`, qui lui vient des appels.' type: boolean closed_at: format: date-time type: string nullable: true closed_reason: enum: - delivered - returned - cancelled - other type: string nullable: true description: 'Pourquoi elle est fermee : `delivered`, `returned`, `cancelled` (par vous) ou `other`. `null` tant qu''elle est ouverte.' status: enum: - active - archived type: string created_at: format: date-time type: string updated_at: format: date-time type: string archived_at: format: date-time type: string nullable: true required: - id - external_id - items - order_status - calls - call_in_progress - closed - status - created_at - updated_at type: object OrderCustomerOut: additionalProperties: false description: Le client d'une commande, tel que renvoye. properties: name: type: string nullable: true phone_number: type: string gender: type: string nullable: true email: type: string nullable: true notes: type: string nullable: true language: type: string nullable: true required: - phone_number type: object OrderItemOut: description: 'Un article de la commande, lu au catalogue **du moment** quand la ligne pointe encore une declinaison, sinon par ses champs propres. Les cles inconnues sont ignorees a la lecture : une photo d''appel ancienne peut porter des cles d''un format anterieur.' properties: name: type: string nullable: true quantity: default: 1 type: integer unit_price: anyOf: - type: number - pattern: ^[0-9]+([.][0-9]+)?$ type: string example: '250.00' - type: 'null' nullable: true variants: items: $ref: '#/components/schemas/OrderItemVariant' type: array nullable: true available_variants: items: items: $ref: '#/components/schemas/OrderItemVariant' type: array type: array nullable: true description: type: string nullable: true sku: type: string nullable: true product_url: type: string nullable: true product_external_id: type: string nullable: true variant_external_id: type: string nullable: true product_id: type: string nullable: true variant_id: type: string nullable: true type: object additionalProperties: false OrderCallOut: additionalProperties: false description: 'Un appel passe sur la commande, en resume. Le detail complet se lit sur `GET /v1/calls/{id}`.' properties: id: type: string request_status: type: string call_status: type: string result: type: string nullable: true result_detail: type: string nullable: true created_at: format: date-time type: string required: - id - request_status - call_status - created_at type: object PaginatedOrders: additionalProperties: false description: Enveloppe paginee `GET /v1/shops/{id}/orders`. properties: data: items: $ref: '#/components/schemas/OrderOut' type: array next_cursor: type: string nullable: true has_more: type: boolean required: - data - has_more type: object OrderItemVariant: type: object additionalProperties: false description: "Une paire nom/valeur d'une variante, telle que citee au client (« Taille : XL »)." properties: name: { type: string, maxLength: 40 } value: { type: string, maxLength: 80 } required: [name, value] Problem: type: object required: [type, title, status] properties: type: type: string format: uri example: https://docs.yasmine.akidly.com/errors/invalid_api_key title: { type: string } status: { type: integer } detail: { type: string } instance: { type: string } request_id: type: string example: a1b2c3d4 description: 8 chars hex. À inclure dans tout ticket support. # ----------------------------------------------------------------------- # Pagination # ----------------------------------------------------------------------- Page: type: object required: [data, has_more] properties: data: type: array items: {} has_more: { type: boolean } next_cursor: { type: string, nullable: true } # ----------------------------------------------------------------------- # CallCreate / CallOut # ----------------------------------------------------------------------- CustomerInput: type: object required: [name, phone_number] additionalProperties: false description: | Sous-objet `customer` du payload `POST /v1/calls` (Phase 2A). Identité client + contact + métadata facultative (gender, email, notes, language). `name` et `phone_number` requis, le reste facultatif. properties: name: type: string minLength: 1 maxLength: 200 description: | Nom du client. Strip automatique des espaces en début/fin, rejet des caractères de contrôle ASCII, normalisation Unicode NFC. examples: ["Ahmed Bennani"] phone_number: type: string description: | Numéro client au format E.164 (ex. `+212612345678`). Validé par libphonenumber (`is_valid_number`) et renormalisé en E.164 canonique avant traitement. Les espaces et séparateurs sont tolérés en entrée mais supprimés. examples: ["+212612345678"] gender: type: string enum: [male, female, unknown] nullable: true description: | Genre du client. Utilisé pour adapter les formules de politesse du dialogue. Mémorisé sur la fiche client (COALESCE stickiness — une valeur posée n'est pas écrasée par un POST ultérieur sans ce champ). email: type: string nullable: true maxLength: 100 pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" description: | Email du client (validation format boundary, cap 100 chars). Mémorisé sur la fiche client (conservé si déjà renseigné). examples: ["ahmed@example.com"] notes: type: string nullable: true maxLength: 500 description: | Notes libres sur le client (500 chars max). Visible côté dialogue en contexte de la confirmation. Persisté dans les notes de la fiche client (conservées si déjà renseignées). language: type: string nullable: true maxLength: 8 description: | Langue préférée du client (`ar`, `fr`). Propagé à la langue du client et celle par défaut de la boutique. examples: ["ar", "fr"] ShopInfoInput: type: object required: [name] additionalProperties: false description: | Sous-objet `shop_info`. Profil boutique : identité, coordonnées, politiques, commercial. `name` requis, reste optionnel. Un champ absent ou `null` **n'efface jamais** une valeur déjà enregistrée : un appel enrichit la fiche, il ne la détruit pas. **Préférez `/v1/shops`.** La fiche boutique se déclare une fois via `POST /v1/shops`, puis se modifie par `PATCH` — inutile de la retransmettre à chaque appel. `shop_info` reste supporté pour les intégrations qui envoient tout en une requête. Attention si vous mélangez les deux : le `PATCH` efface sur `null`, `shop_info` non — mieux vaut choisir une seule porte par boutique. ### Changements du 2026-09-01 **Renommés** (l'ancien nom renvoie désormais un 422) : | Avant | Après | |---|---| | `shop_phone_sav` | `phone` | | `shop_email_sav` | `email` | | `shop_whatsapp_sav` | `whatsapp` | | `size_exchange_policy` | `exchange_policy` | **Retirés du contrat** — les envoyer renvoie un 422 : - `loyalty_program` — programme de fidélité **Déprécié** : `industry`, `active`, `logo_url` restent acceptés et enregistrés, mais ne sont pas exploités et disparaîtront après préavis `Sunset`. properties: name: type: string minLength: 1 maxLength: 200 description: Nom commercial affiché côté agent vocal + dashboard. examples: ["GlowArt Casa"] sector: { type: string, nullable: true, maxLength: 80 } merchant_external_id: type: string nullable: true maxLength: 128 pattern: "^[a-zA-Z0-9_.-]+$" example: "M-4471" description: >- Identifiant du commerçant propriétaire de cette boutique, tel qu'il existe dans votre système. Sert à **regrouper plusieurs boutiques sous un même commerçant** : `GET /v1/shops` accepte ce champ en filtre. ⚠️ À ne pas confondre avec `shop_external_id`, qui identifie LA boutique. Celui-ci identifie son propriétaire, et plusieurs boutiques partagent la même valeur — c'est son objet. C'est une clé de regroupement, pas une fiche : le commerçant n'a pas d'attributs propres aujourd'hui (ni nom, ni contact). Si ce besoin apparaît, une ressource dédiée sera ajoutée sans que ce champ change de sens. has_physical_store: type: boolean nullable: true description: >- La boutique dispose-t-elle d'un point de vente physique où le client peut se présenter ? `true` oui, `false` vente à distance uniquement. Absent ou `null` vaut `false` — le troisième état « non déclaré » a été retiré le 2026-09-03. **Distinct de `address`** : celle-ci dit *où* vous êtes, celui-ci dit si le client *peut venir* — un vendeur en ligne a souvent une adresse (entrepôt, domicile) qu'il ne souhaite pas voir communiquée comme lieu de visite. Enregistré sur la fiche boutique, pas encore exploité pendant l'appel — activation prévue. industry: type: string nullable: true maxLength: 32 deprecated: true description: >- **Déprécié (2026-09-01).** Fait doublon avec `sector`, seul champ de classement exploité pendant l'appel. Toujours accepté et enregistré pour ne casser aucune intégration existante ; retrait après préavis `Sunset`. Ne rien construire dessus. active: type: boolean nullable: true deprecated: true description: >- **Déprécié (2026-09-01).** Remplacé par l'archivage de la fiche boutique (`DELETE /v1/shops/{shop_external_id}`), qui expose l'état réel via le champ `status`. logo_url: type: string nullable: true maxLength: 255 deprecated: true description: >- **Déprécié (2026-09-01).** Jamais exploité pendant l'appel. phone: type: string nullable: true maxLength: 30 description: >- Téléphone de la boutique, communicable au client pendant l'appel. **Renomme `shop_phone_sav` depuis le 2026-09-01** : la distinction service après-vente / ligne principale n'avait pas de réalité pour la majorité des boutiques. L'ancien nom est désormais refusé (422). email: type: string nullable: true maxLength: 50 pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" description: >- Email de contact de la boutique. **Renomme `shop_email_sav` depuis le 2026-09-01.** L'ancien nom est refusé (422). whatsapp: type: string nullable: true maxLength: 30 description: >- Numéro WhatsApp de la boutique. **Renomme `shop_whatsapp_sav` depuis le 2026-09-01.** L'ancien nom est refusé (422). address: { type: string, nullable: true, maxLength: 200 } customer_service_hours: type: string nullable: true maxLength: 200 description: >- Horaires du service client, en texte libre (ex. « Lun-Sam 9h-18h »). L'agent s'en sert pour répondre au client qui demande quand vous joindre. website_url: type: string nullable: true maxLength: 255 description: Site web de la boutique. delivery_policy: { type: string, nullable: true, maxLength: 250 } delivery_delay: type: string nullable: true maxLength: 100 description: >- Délai générique de livraison de la boutique (ex. « 8 heures », « 24-48h »). Utilisé pour répondre au client qui demande quand sa commande arrivera, lorsque la commande n'a pas de `estimated_delivery_date` (la date de la commande reste prioritaire). Distinct de `delivery_policy` (politique et conditions de livraison). payment_policy: { type: string, nullable: true, maxLength: 250 } return_policy: { type: string, nullable: true, maxLength: 250 } exchange_policy: type: string nullable: true maxLength: 250 description: >- Conditions d'échange d'un article : taille, couleur, modèle, article défectueux. **Renomme `size_exchange_policy` depuis le 2026-09-01** — l'ancien nom restreignait la réponse aux seules questions de taille. L'ancien nom est refusé (422). warranty_policy: { type: string, nullable: true, maxLength: 250 } inspection_before_payment: type: boolean nullable: true description: >- Le client peut-il ouvrir le colis avant de payer ? `true` autorisé, `false` refusé. Absent ou `null` vaut `false` — le troisième état « non déclaré » a été retiré le 2026-09-03, faute d'avoir jamais produit une différence de comportement. Enregistré sur la fiche boutique, pas encore exploité pendant l'appel — activation prévue. current_promotions: { type: string, nullable: true, maxLength: 250 } faq_free: { type: string, nullable: true, maxLength: 500 } OrderItem: type: object additionalProperties: false description: | Article structuré d'une commande (entrée de `OrderInput.items`). Optionnel côté contrat : un POST peut envoyer une liste structurée d'items, OU un résumé libre via `items_text`, OU les deux. Si les deux sont fournis, la liste structurée prime au moment de l'appel. properties: product_external_id: type: string nullable: true maxLength: 128 pattern: "^[a-zA-Z0-9_.-]+$" examples: ["TSHIRT-BLANC"] description: | Renvoie a un produit declare dans le catalogue de la boutique. Son nom, sa description, son prix et ses declinaisons sont alors lus depuis le catalogue **au moment de l'appel** : une ligne referencee ne porte que cette reference, `variant_external_id` et `quantity` — y joindre `name`, `unit_price`, `description`, `variants` ou `available_variants` est refuse (`422`). Pour un prix ou un libelle propre a cette commande, decrivez l'article sur place avec `name`, sans reference : c'est aussi la voie pour un article sur mesure, des frais de port ou un cadeau. Un produit absent du catalogue ne bloque jamais un appel. variant_external_id: type: string nullable: true maxLength: 128 pattern: "^[a-zA-Z0-9_.-]+$" examples: ["TSHIRT-BLANC-XL"] description: | Precise quelle declinaison du produit — taille, couleur. Inutile si le produit n'en a qu'une : elle est retenue automatiquement. S'il en a plusieurs et qu'aucune n'est designee, la requete est refusee en listant celles qui existent (`order_item_variant_required`). Aucune n'est choisie au hasard : l'appel annoncerait au client un article qu'il n'a pas commande. Exige `product_external_id` : une declinaison seule ne designe rien (`422`). name: type: string minLength: 1 maxLength: 150 description: Désignation de l'article (ex. "T-shirt blanc en coton"). examples: ["T-shirt blanc en coton"] quantity: type: integer minimum: 1 maximum: 999 default: 1 description: Nombre d'unités pour cet article. Défaut 1. variants: type: array nullable: true maxItems: 3 description: | Variantes nommées de l'article (max 3). Chaque entrée est une paire `name` (type — "Taille", "Couleur", "Pointure", ... ou un type libre) + `value` ("XL", "rouge"). Citées au client sous la forme « Taille : XL ». items: type: object required: [name, value] additionalProperties: false properties: name: type: string minLength: 1 maxLength: 40 description: Type de variante ("Taille", "Couleur", ...). value: type: string minLength: 1 maxLength: 80 description: Valeur ("XL", "rouge"). available_variants: type: array nullable: true maxItems: 60 description: | Combinaisons de variantes réellement disponibles pour cet article (max 60). Chaque combinaison est une liste de paires `name`/`value` (1 à 3, mêmes axes que `variants`). Fournir la liste des combinaisons qui existent, pas le produit cartésien : elle exprime les croisements — une couleur peut n'être disponible que dans certaines tailles. Quand ce champ est présent, l'agent ne propose et n'accepte QUE ces combinaisons ; toute autre valeur est annoncée comme indisponible. Absent : l'agent s'appuie sur `description` et les infos boutique, sinon sur la seule variante commandée. items: type: array minItems: 1 maxItems: 3 items: type: object required: [name, value] additionalProperties: false properties: name: type: string minLength: 1 maxLength: 40 description: Type de variante ("Taille", "Couleur", ...). value: type: string minLength: 1 maxLength: 80 description: Valeur ("XL", "rouge"). description: type: string nullable: true maxLength: 500 description: | Description libre du produit (matière, contenu, usage...). Sert à répondre aux questions du client sur l'article ; n'est pas récitée d'office. unit_price: type: string nullable: true pattern: "^[0-9]+(\\.[0-9]{1,2})?$" description: | Prix unitaire dans la devise de la commande (string, précision Decimal). Optionnel — omettre pour un article au prix non spécifié. **Deux décimales au plus** : un troisième chiffre serait arrondi en silence, il est refusé (`422`). Interdit sur une ligne référencée (`product_external_id`) : le prix vient alors du catalogue. examples: ["120.00"] OrderInput: type: object required: [amount] additionalProperties: false description: | Sous-objet `order`. Commande du client : logistique, montants, métadata commerciale. `amount` et `currency` requis. `delivery_address` optionnelle depuis 2026-08-22 : absente (ou réduite à un nom de ville), l'agent collecte l'adresse auprès du client pendant l'appel et la remonte dans `preferences` du compte rendu. `external_id` optionnel — génération fallback côté serveur si absent. **INSERT strict** : un 2e POST avec même `(shop_id, external_id)` retourne `409 order_external_id_already_exists`. **Détail des articles** : deux formes au choix, l'une ou l'autre, ou les deux à la fois. - `items` : liste structurée article par article (recommandé quand la commande contient plusieurs articles distincts cités par nom). - `items_text` : chaîne libre (recommandé pour les commandes à article unique ou quand le détail n'est pas découpé côté reseller). Les deux champs sont optionnels — une commande sans aucun des deux est valide. properties: max_discount: type: string nullable: true pattern: '^[0-9]+([.][0-9]+)?$' examples: ["30.00"] description: | Remise maximale accordable sur CETTE commande, dans la devise de la commande. Niveau le plus precis : fourni, il l'emporte sur celui des produits et sur celui de la boutique. Absent, la valeur est determinee cote serveur — la plus haute parmi les produits de la commande qui en declarent une, sinon celle de la boutique. Enregistre, pas encore exploite pendant l'appel. external_id: type: string nullable: true maxLength: 128 description: ID commande côté reseller, unique par boutique. delivery_address: type: string nullable: true maxLength: 500 description: | Adresse de livraison. Optionnelle : si absente ou réduite à un nom de ville, l'agent la collecte pendant l'appel (l'adresse confirmée remonte dans `preferences` du compte rendu). items: type: array nullable: true maxItems: 50 items: $ref: "#/components/schemas/OrderItem" description: | Liste structurée d'articles (max 50). Chaque entrée : `name` requis + `quantity` (défaut 1) + `variants` (paires nom/valeur, max 3) + `description` + `unit_price` optionnels. Laisser absent ou `null` pour une commande sans détail structuré (utiliser `items_text` à la place). items_text: type: string nullable: true maxLength: 500 description: | Résumé libre du contenu de la commande (ex. "t-shirt blanc et jean noir XXL"). Utilisé quand `items` n'est pas fourni ou est vide. Si `items` est aussi présent, la liste structurée prime au moment de l'appel. amount: type: string pattern: "^[0-9]+(\\.[0-9]+)?$" description: Montant total en devise indiquée (string, précision Decimal). examples: ["249.00"] currency: type: string enum: [MAD, EUR, USD, TND, DZD] order_date: { type: string, nullable: true, format: date } subtotal: { type: string, nullable: true } shipping_cost: { type: string, nullable: true } discount: { type: string, nullable: true } taxes: { type: string, nullable: true } delivery_method: type: string nullable: true enum: [standard, express, pickup] delivery_zone: type: string nullable: true enum: [urban, suburban, rural] estimated_delivery_date: { type: string, nullable: true, format: date } previous_attempts: { type: integer, nullable: true, minimum: 0, maximum: 100 } order_notes: { type: string, nullable: true, maxLength: 300 } source: type: string nullable: true enum: [web, pos, ads, other] promo_code: { type: string, nullable: true, maxLength: 50 } call_hours: type: string nullable: true maxLength: 200 description: | Plage d'appel de cette commande, au format des horaires OpenStreetMap : `Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00`. **Jamais avant 9 h ni après 22 h**, dans le fuseau de la boutique — une plage qui en sort est refusée (422). Absente : la plage de la boutique, sinon du lundi au samedi de 10 h à 21 h et le dimanche de 11 h à 20 h. Un appel demandé hors plage est accepté et part à la prochaine ouverture. example: "Mo-Fr 09:30-12:00,13:00-19:00; Sa 13:00-19:00" CallParamsInput: type: object required: [purpose] additionalProperties: false description: | Sous-objet `call_params`. Paramètres de l'appel : intention, tone, durée max, voix. `purpose` requis (enum interne). properties: purpose: type: string enum: [confirmation, retry, followup_delivery, satisfaction, custom] description: Intention métier de l'appel . channel: type: string nullable: true enum: [gsm, whatsapp] description: | Par où l'appel sort. **Omettez ce champ** sauf besoin particulier : le canal par défaut de votre installation s'applique, et c'est le bon dans l'immense majorité des cas. - `gsm` — appel téléphonique classique, reçu comme n'importe quel appel sur le réseau mobile du client. - `whatsapp` — appel WhatsApp Business natif. **Tous les canaux ne sont pas ouverts partout.** Demander un canal fermé renvoie `422 channel_not_available`, dont le `detail` nomme ceux qui sont disponibles et le canal par défaut. Le refus est volontairement explicite : un appel silencieusement re-routé vers un autre canal que celui demandé serait indétectable de votre côté. La demande est **toujours acceptée** : l'appel part dès que possible, au plus tard dans les 48 heures. Passé ce délai sans avoir pu partir, il est abandonné et le compte rendu le dit. tone: type: string nullable: true enum: [formal, friendly, default] max_call_duration_seconds: type: integer nullable: true minimum: 30 maximum: 600 description: >- Durée maximale souhaitée pour l'appel, en secondes. **Déclaratif** : la valeur est enregistrée sur la fiche boutique mais **aucun plafond n'est appliqué aujourd'hui** — implémentation prévue. Documenté explicitement depuis le 2026-09-01 : le champ existait depuis des mois sans que son absence d'effet soit dite. voice_id: type: string nullable: true maxLength: 32 description: >- Identifiant de voix souhaité pour l'appel. **Déclaratif** : la valeur est enregistrée mais la voix reste sélectionnée par le pays de l'appel — implémentation prévue. Documenté explicitement depuis le 2026-09-01, même raison que `max_call_duration_seconds`. CallCreate: type: object required: [shop_external_id, call_params] additionalProperties: false description: | Payload `POST /v1/calls` — structure complète imbriquée. BREAKING vs précédentes versions : `shop_ref` renommé `shop_external_id`, `amount`/`currency` déplacés dans `order`, `purpose` déplacé dans `call_params`. properties: customer: $ref: "#/components/schemas/CustomerInput" shop_external_id: type: string minLength: 1 maxLength: 128 pattern: "^[a-zA-Z0-9_.-]+$" description: | Slug stable identifiant la boutique côté reseller. Utilisé pour l'upsert boutique. Caractères autorisés : `[a-zA-Z0-9_.-]`. Remplace `shop_ref` des versions précédentes. examples: ["boutique-paris-01"] shop_info: $ref: "#/components/schemas/ShopInfoInput" order: $ref: "#/components/schemas/OrderInput" order_external_id: type: string minLength: 1 maxLength: 128 pattern: "^[a-zA-Z0-9_.-]+$" description: | **L'appel par référence.** Identifiant d'une commande déjà déclarée via `POST /v1/shops/{id}/orders`, dans la boutique de `shop_external_id`. Le serveur relit tout — client, articles au catalogue du moment, montants — photographie ce qu'il va annoncer (`order_snapshot`) et rattache l'appel à la commande. Relancer le client se fait avec la même référence : les appels s'empilent sur la commande, jamais de doublon. Refusé (`409 order_call_in_progress`) si un appel est déjà en cours. **Exactement l'un des deux** : `order` (commande décrite sur place, avec `customer`), ou `order_external_id` (sans `customer`, qui est celui de la commande). examples: ["CMD-2026-8891"] country: type: string enum: [MA, DZ, TN, FR] description: | Code pays ISO 3166-1 alpha-2 du client à appeler. Détermine le profil conversationnel et la **langue par défaut** appliquée si `language` n'est pas fourni : `MA`/`DZ`/`TN` → `ar`, `FR` → `fr`. Les profils conversationnels internes sont choisis côté serveur — transparent pour le reseller. examples: [MA] language: type: string nullable: true enum: [ar, fr] description: | Langue de l'appel. **Optionnel** — si absent, applique la langue locale du pays (`MA`/`DZ`/`TN` → `ar`, `FR` → `fr`). Permet à un reseller dont le client préfère le français d'avoir un appel en français même depuis un pays maghrébin (combinaisons `MA`/`DZ`/`TN` + `fr` toutes acceptées). **Combinaison rejetée** : `country=FR` + `language=ar` → 422 `language_not_supported_for_country` (cf [errors](https://docs.yasmine.akidly.com/errors)). **Rétrocompat** : un payload sans ce champ se comporte exactement comme avant l'introduction (langue locale appliquée). Aucune migration côté reseller requise. examples: ["fr"] call_params: $ref: "#/components/schemas/CallParamsInput" metadata: type: object nullable: true additionalProperties: true description: | Metadata libre du reseller (propagé dans vos métadonnées). Limite : 2 KB sérialisé JSON compact. CallOut: type: object required: [id, request_status, call_status, created_at, shop_id, customer_phone_masked] additionalProperties: false properties: id: { type: string, format: uuid } request_status: type: string enum: - queued - waiting_user_permission - permission_accepted - permission_refused - permission_expired - quota_blocked - cancelled_by_reseller - ready_to_dial - done description: | Rail DEMANDE côté Yasmine. Cycle nominal : `queued → waiting_user_permission → permission_accepted → ready_to_dial → done`. Terminaux échec : `permission_refused`, `permission_expired`, `quota_blocked`, `cancelled_by_reseller`. `quota_blocked` couvre 3 cas de quotas guards proactifs (cf `doc/whatsapp.md §4.2-§4.3`) : - `templates_quota_24h` : 1 call_permission_request déjà envoyé dans les 24 h glissantes. - `templates_quota_7d` : 2 déjà envoyés dans les 7 j glissants. - `auto_revoke_imminent_no_answer_streak_4` : 4 NO_ANSWER consécutifs → l'opérateur considère la permission auto-révoquée. Le diagnostic exact est conservé sous la forme `quota:`. Non exposé dans la réponse 201 initial (transition asynchrone via background task). call_status: type: string enum: [not_started, dialing, ringing, connected, ended, failed, cancelled] description: | Rail APPEL côté téléphonie. Pilotée par les webhooks entrants une fois `request_status = ready_to_dial`. Cycle : `not_started → dialing → ringing → connected → ended`. Terminaux échec : `failed`, `cancelled` (via POST `/v1/calls/{id}/cancel`). created_at: { type: string, format: date-time } shop_id: type: string format: uuid description: ID du boutique upserté depuis `shop_ref`. customer_phone_masked: type: string description: | Numéro masqué pour les logs / UI (ex. `+212 6** *** *78`). Le numéro brut n'est **jamais** retourné dans les réponses API — disponible uniquement côté ops pour investigation. examples: ["+212 6** *** *78"] purpose: type: string nullable: true amount: type: string nullable: true examples: ["249.00"] currency: type: string nullable: true pattern: "^[A-Z]{3}$" examples: [MAD] country: type: string nullable: true enum: [MA, DZ, TN, FR, null] description: Code ISO echoed du reseller — jamais le profil conversationnel interne. language: type: string nullable: true enum: [ar, fr, null] description: | Langue effectivement utilisée pour cet appel. Echo de la valeur fournie dans le payload, ou langue locale appliquée par défaut (`MA`/`DZ`/`TN` → `ar`, `FR` → `fr`). `null` sur les anciens appels antérieurs à l'introduction du champ. examples: ["ar"] metadata: type: object nullable: true additionalProperties: true # Champs cycle de vie + terminal. Tous nullable : `null` # tant que l'etape n'est pas atteinte. ringing_at: type: string format: date-time nullable: true description: Horodatage `ringing` (sonnerie cote client). connected_at: type: string format: date-time nullable: true description: Horodatage `connected` (client a decroche). Origine DB `accepted_at`. ended_at: type: string format: date-time nullable: true description: | Horodatage de fin (terminal naturel ou cancel). `null` tant que l'appel n'est pas en etat `ended`/`failed`/`cancelled`. call_duration_seconds: type: integer nullable: true minimum: 0 examples: [37] description: | Durée réelle de la conversation en secondes. `null` tant que l'appel n'est pas terminé. billable_duration_seconds: type: integer nullable: true minimum: 0 examples: [37] description: | Durée facturée pour cet appel. Égale à `max(call_duration_seconds, 10)` par règle de facturation (plancher 10 s). `null` tant que l'appel n'est pas finalisé. result: type: string nullable: true enum: [confirmed, cancelled, requires_action, null] description: | Résultat principal de l'appel. `confirmed` = la commande a été confirmée par le client (avec ou sans modification — voir `result_detail`). `cancelled` = la commande a été annulée (par le client ou faux numéro). `requires_action` = aucune décision automatique, le marchand doit traiter manuellement (rappel, attente d'une date, audio inaudible, etc. — voir `result_detail`). `null` tant que l'appel n'est pas terminé. result_detail: type: string nullable: true description: | Nuance fine du résultat (slug texte libre). Exemples : `modified` (commande confirmée avec modification), `wrong_number`, `denied_order`, `human_requested`, `price_dispute`, `postponed`, `callback`, `unconfirmed`, `unclear`, `no_answer`, `failed`. `null` quand `result` est `confirmed` ou `cancelled` sans nuance, ou tant que l'appel n'est pas terminé. customer_mood: type: string nullable: true enum: [positive, neutral, negative, frustrated, unclear, null] description: | Humeur perçue du client pendant l'appel. `unclear` est posé quand l'audio est globalement incompréhensible (corrélé généralement avec `result_detail=unclear`). `null` si non détectable ou tant que l'appel n'est pas terminé. flags: type: array nullable: true items: { type: string } description: | Drapeaux qualitatifs levés pendant l'appel. Valeurs courantes : `wrong_number`, `human_requested`, `price_dispute`, `address_incomplete`, `denied_order`, `audio_quality_bad`, `confirmed_by_relative`. Liste vide ou `null` si aucun drapeau. preferences: type: array nullable: true items: { type: string } description: | Préférences ou demandes spécifiques exprimées par le client (date de livraison souhaitée, créneau horaire, modification produit, numéro alternatif, etc.). Format texte court, une entrée par préférence. Liste vide ou `null` si aucune. next_action: type: string nullable: true maxLength: 200 description: | Action de suivi suggérée pour la boutique (ex. `rappeler à 16h`, `envoyer SMS confirmation`). `null` si aucune action requise. summary: type: string nullable: true maxLength: 600 description: | Résumé textuel de la conversation (1-3 phrases courtes). Peut contenir des informations PII du client (nom, adresse) telles qu'évoquées pendant l'appel. `null` tant que l'appel n'est pas terminé. failure_reason: type: string nullable: true description: | Slug machine-readable de la raison d'echec si applicable. Exemples : `quota:templates_24h_limit`, `permission_refused`, `technical_failure`, `reclaim_after_restart`. meta_error_code: type: integer nullable: true examples: [138021] description: | Code d'erreur de l'opérateur de messagerie sur appels échoués. Renseigné uniquement quand `result=FAILED` et que l'opérateur a fourni un code structuré. `null` sinon. meta_error_title: type: string nullable: true maxLength: 500 description: | Libellé textuel de l'erreur opérateur correspondant à `meta_error_code`. Renseigné uniquement quand `result=FAILED` et que l'opérateur a fourni un libellé. `null` sinon. recording_url: type: string nullable: true description: | URL **relative** pour télécharger l'enregistrement audio mixte (client + agent) au format WAV mono 16 kHz. Format : `/v1/calls/{call_id}/recording`. Authentification Bearer identique aux autres endpoints `/v1`. `null` tant que l'appel n'est pas terminé, ou si l'audio a été purgé après la fenêtre de rétention (30 jours). L'endpoint peut renvoyer `410 Gone` (slug `recording_gone`) dans ce dernier cas. cancelled_state: type: string nullable: true enum: [not_started, dialing, ringing, connected, ended, failed, cancelled, null] description: | Si l'appel a ete annule par le reseller (`POST /v1/calls/{id}/cancel`), etat du rail APPEL au moment du cancel. Origine DB le champ `cancelled_state` de la réponse. order_snapshot: type: object nullable: true additionalProperties: true description: | Ce que l'appel a annoncé au client, figé au départ de l'appel : `items` (articles résolus — même forme que ceux d'une commande), `amount`, `currency`, `max_discount`, `taken_at`. Chaque appel porte la sienne : une relance re-photographie. `null` sur un appel dont la photo n'a pas pu être reconstituée. Dans `items`, `unit_price` est un **nombre** (pas une chaîne comme sur la fiche d'une commande) ; `amount` et `max_discount` sont des chaînes. Présente aussi sur chaque ligne de `GET /v1/calls` : une page de 200 appels peut être volumineuse, ajuster `limit`. BalanceOut: type: object required: [balance_seconds, updated_at] additionalProperties: false properties: balance_seconds: type: integer description: | Solde en secondes facturables. Peut être négatif dans la limite du découvert autorisé (300 s par défaut). examples: [3600] currency: type: string nullable: true description: | Code ISO 4217 si la facturation est en monnaie. `null` aujourd'hui (balance en secondes pures). updated_at: type: string format: date-time description: | Horodatage du dernier mouvement ledger (`MAX(credit_transactions. created_at)`) ou `created_at` du reseller en fallback. CallCancelOut: type: object required: [id, status, cancelled_state, billed_seconds, cancelled_at] additionalProperties: false properties: id: { type: string, format: uuid } status: type: string enum: [cancelled, ended, failed] description: | `cancelled` si le cancel a eu effet. `ended`/`failed`/`cancelled` préexistant si l'appel était déjà terminal (idempotence). cancelled_state: type: string enum: [not_started, dialing, ringing, connected, ended, failed, cancelled] description: | `call_status` au moment où Yasmine a traité le cancel. Utile pour diagnostic : indique à quel stade du flow l'annulation a été reçue. Sur réponse idempotente, reflète l'état courant. billed_seconds: type: integer minimum: 0 description: | Secondes facturées. 0 si annulé avant `connected`. Sinon `ceil(now - accepted_at)` avec plancher minimum 10 s. cancelled_at: { type: string, format: date-time } CallList: allOf: - $ref: "#/components/schemas/Page" - type: object properties: data: type: array items: { $ref: "#/components/schemas/CallOut" } PaginatedCalls: type: object required: [data, has_more] additionalProperties: false description: | Enveloppe paginée `GET /v1/calls`. `next_cursor` est un curseur opaque signé HMAC-SHA256 (TTL 24 h) à passer tel quel au `?cursor=` de la requête suivante. `null` = dernière page. properties: data: type: array items: { $ref: "#/components/schemas/CallOut" } next_cursor: type: string nullable: true description: Cursor pour la page suivante. `null` si dernière page. has_more: type: boolean description: | Redondant avec `next_cursor is not null` — exposé pour simplifier le code client (pas besoin de comparer à `null`). # ----------------------------------------------------------------------- # Account # ----------------------------------------------------------------------- Reseller: type: object description: | Legacy schema — conservé pour compat usage interne. Le contrat public `GET /v1/me` retourne `MeOut` (5 champs, anti-BOPLA). properties: id: { type: string, format: uuid } name: { type: string } contact_email: { type: string, format: email, nullable: true } status: { type: string, enum: [active, suspended, closed] } created_at: { type: string, format: date-time } MeOut: type: object description: | Réponse `GET /v1/me`. Anti-BOPLA : ni `balance_seconds` (voir `/v1/me/balance`), ni `status`, ni aucun compteur interne. Les 10 champs profil société (owner_*, default_language, country, timezone, legal_name, tax_id, billing_address, business_type, website_url) sont tous `null` tant que la fiche société n'a pas été renseignée en DB côté ops. Aucun endpoint d'update self-service aujourd'hui (roadmap M4). required: [reseller_id, name, created_at, rate_limit_per_minute] additionalProperties: false properties: reseller_id: { type: string, format: uuid } name: { type: string } email: type: string nullable: true description: | Email de contact, nullable : `null` si non renseigné à la création. created_at: { type: string, format: date-time } rate_limit_per_minute: type: integer description: | Plafond requêtes/min sur `/v1/*` (valeur globale serveur, pas par-reseller aujourd'hui). example: 600 owner_name: type: string nullable: true maxLength: 120 description: "Nom du propriétaire du compte (personne physique)." example: "Chokri Bensassi" owner_phone_e164: type: string nullable: true maxLength: 20 description: "Téléphone propriétaire, format E.164." example: "+212612345678" default_language: type: string nullable: true maxLength: 8 description: "Langue par défaut du compte (`ar`, `fr`)." example: "fr" country: type: string nullable: true pattern: "^[A-Z]{2}$" description: | Code pays ISO 3166-1 alpha-2 du compte (deux lettres majuscules exactes). example: "MA" timezone: type: string nullable: true maxLength: 40 description: "Fuseau horaire IANA." example: "Africa/Casablanca" legal_name: type: string nullable: true maxLength: 255 description: "Raison sociale officielle." example: "MAGHREB TECHNOLOGY SENSORS SARL AU" tax_id: type: string nullable: true maxLength: 64 description: | Identifiant fiscal (ICE Maroc, SIRET France, VAT EU, etc.). example: "002123456000099" billing_address: type: string nullable: true description: "Adresse de facturation (texte libre, multi-lignes)." example: "15 Rue Ibn Tofail, Casablanca" business_type: type: string nullable: true enum: [single_merchant, multi_merchant, platform] description: | Archétype du reseller : `single_merchant` (une seule boutique), `multi_merchant` (plusieurs boutiques gérées), `platform` (agrégateur / SaaS revendant Yasmine à ses propres marchands). example: "multi_merchant" website_url: type: string nullable: true maxLength: 255 description: "Site commercial du reseller (URL publique)." example: "https://example.test" TransactionOut: type: object description: | Ligne ledger publique (`GET /v1/me/transactions`). required: [transaction_id, type, seconds, created_at] additionalProperties: false properties: transaction_id: type: integer format: int64 description: Identifiant interne, stable et ordonné. type: type: string enum: [topup, debit_call, refund, adjustment] seconds: type: integer description: | Mouvement signé : positif = crédit (topup/refund), négatif = débit (debit_call). reference: type: string nullable: true description: | Référence externe. Pour `debit_call` : `call_id` UUID. Libre pour autres types. reason: type: string nullable: true description: Description libre (motif ops du mouvement). created_at: { type: string, format: date-time } PaginatedTransactions: type: object description: Enveloppe paginée `GET /v1/me/transactions`. required: [data, has_more] additionalProperties: false properties: data: type: array items: { $ref: "#/components/schemas/TransactionOut" } next_cursor: type: string nullable: true description: Cursor opaque pour la page suivante, `null` si dernière. has_more: { type: boolean } UsageStatusBreakdown: type: object description: | Ventilation par `call_status`. Distingue `cancelled` (cancel reseller) de `failed` (échec technique). In-flight non comptés. required: [completed_calls, completed_seconds, failed_calls, cancelled_calls] additionalProperties: false properties: completed_calls: type: integer description: Calls `call_status = 'ended'`. completed_seconds: type: integer description: | Somme `billable_s` des completed. Base de facturation effective du mois. failed_calls: type: integer description: Calls `call_status = 'failed'` (échecs techniques). cancelled_calls: type: integer description: | Calls `call_status = 'cancelled'` (cancel reseller via `POST /v1/calls/{id}/cancel`). WebhookTestOut: type: object description: Réponse `POST /v1/me/webhooks/test`. required: [test_id, delivered, latency_ms, target_url, attempted_at] additionalProperties: false properties: test_id: type: string format: uuid description: | Identifiant unique du test. Permet de retracer la row associée dans `GET /v1/me/webhooks/deliveries`. delivered: { type: boolean } http_status: type: integer nullable: true description: Code HTTP reçu. `null` si timeout / erreur réseau. latency_ms: type: integer description: Durée wall-clock du POST en millisecondes. target_url: { type: string } attempted_at: { type: string, format: date-time } error_message: type: string nullable: true description: Extrait tronqué à 200 chars si échec. DeliveryOut: type: object description: Ligne d'historique webhook publique. Anti-BOPLA. required: [delivery_id, event_type, attempt_count, status, created_at, last_attempted_at] additionalProperties: false properties: delivery_id: type: integer format: int64 description: Identifiant interne, stable et ordonné. event_type: type: string description: | Ex: `call.ended`, `call.cancelled`, `webhook.test`. Non enum côté API — évolue au fil des events livrés. target_url: type: string nullable: true description: | URL au moment du POST. `null` sur les lignes antérieures. http_status: { type: integer, nullable: true } latency_ms: type: integer nullable: true description: Durée wall-clock en ms. `null` sur les lignes antérieures. attempt_count: type: integer description: | N° de tentative (1-3, retry 0s/30s/5min côté dispatcher). Plusieurs rows peuvent partager le même event_id si retry. status: type: string enum: [delivered, failed, pending] description: Computed depuis `delivered_at`/`error`. next_retry_at: type: string format: date-time nullable: true created_at: { type: string, format: date-time } last_attempted_at: { type: string, format: date-time } error_message: type: string nullable: true description: Extrait tronqué à 200 chars si échec. PaginatedDeliveries: type: object description: Enveloppe paginée `GET /v1/me/webhooks/deliveries`. required: [data, has_more] additionalProperties: false properties: data: type: array items: { $ref: "#/components/schemas/DeliveryOut" } next_cursor: type: string nullable: true has_more: { type: boolean } UsageOut: type: object description: | Réponse `GET /v1/me/usage`. Fenêtre semi-open `[period_start, period_end)` sur la date de création de l'appel. Période sans appel = 200 avec zéros (pas 404). required: [period, period_start, period_end, total_calls, total_seconds, breakdown] additionalProperties: false properties: period: type: string pattern: "^\\d{4}-(0[1-9]|1[0-2])$" example: "2026-04" period_start: { type: string, format: date-time } period_end: type: string format: date-time description: Premier instant du mois suivant (exclu). total_calls: { type: integer } total_seconds: type: integer description: | Somme `billable_s` de TOUS les calls du mois (incl. in-flight). Pour facturation réelle voir `breakdown.completed_seconds`. breakdown: $ref: "#/components/schemas/UsageStatusBreakdown" Balance: type: object properties: balance_seconds: { type: integer } balance_human: { type: string, example: 10h00m } as_of: { type: string, format: date-time } Transaction: type: object properties: id: { type: integer } type: { type: string, enum: [topup, debit_call, refund, adjustment] } seconds: { type: integer, description: "Positif (topup/refund) ou négatif (debit)." } reference: { type: string, nullable: true } reason: { type: string, nullable: true } created_at: { type: string, format: date-time } TransactionList: allOf: - $ref: "#/components/schemas/Page" - type: object properties: data: type: array items: { $ref: "#/components/schemas/Transaction" } UsagePeriod: type: object properties: period: { type: string, example: "2026-04" } calls_total: { type: integer } calls_by_result: type: object additionalProperties: { type: integer } billable_seconds: { type: integer } cost_usd: { type: string } ApiKey: type: object description: | Legacy — conservé pour compat. Le contrat public utilise `ApiKeyOut` (retourné par `GET /v1/me/api-keys`). properties: id: { type: string } prefix: { type: string, example: yk_abc123... } label: { type: string, nullable: true } created_at: { type: string, format: date-time } last_used_at: { type: string, format: date-time, nullable: true } revoked_at: { type: string, format: date-time, nullable: true } ApiKeyList: type: object description: Legacy — utiliser `ApiKeyListOut`. properties: data: type: array items: { $ref: "#/components/schemas/ApiKey" } ApiKeyCreated: allOf: - $ref: "#/components/schemas/ApiKey" - type: object required: [key] properties: key: type: string description: Legacy — utiliser `ApiKeyCreatedOut.secret_key`. ApiKeyCreateIn: type: object description: Body `POST /v1/me/api-keys`. Tout champ non déclaré est rejeté. additionalProperties: false properties: label: type: string nullable: true maxLength: 64 description: | Description libre pour identifier la clé côté UI/logs (ex: `ci-prod`, `shopify-staging`). Optionnel. ApiKeyOut: type: object description: | Shape commune GET / DELETE clé API. **Anti-BOPLA** : jamais de `key_hash`, jamais de `secret_key` — seul le `key_prefix` (12 chars) est exposé. required: [id, key_prefix, created_at, status] additionalProperties: false properties: id: { type: string, format: uuid } key_prefix: type: string description: | 12 premiers chars de la clé en clair. Suffisant pour identifier côté UI, insuffisant pour forger. example: "yk_Xa7bC3dE5f" label: type: string nullable: true maxLength: 64 created_at: { type: string, format: date-time } last_used_at: type: string format: date-time nullable: true description: | Dernière utilisation de la clé (posée par `touch_used()` à chaque auth réussie). last_used_ip: type: string nullable: true description: | Dernière IP client vue au moment de l'auth (format INET : IPv4 ou IPv6). revoked_at: type: string format: date-time nullable: true status: type: string enum: [active, revoked] description: | Computed côté serveur depuis `revoked_at` : `active` si NULL, `revoked` sinon. ApiKeyListOut: type: object description: Réponse `GET /v1/me/api-keys`. required: [data] additionalProperties: false properties: data: type: array items: { $ref: "#/components/schemas/ApiKeyOut" } ApiKeyCreatedOut: type: object description: | Réponse `POST /v1/me/api-keys`. `secret_key` **visible une seule fois** — à stocker immédiatement côté reseller. required: [id, key_prefix, created_at, secret_key] additionalProperties: false properties: id: { type: string, format: uuid } key_prefix: { type: string, example: "yk_Xa7bC3dE5f" } label: { type: string, nullable: true } created_at: { type: string, format: date-time } secret_key: type: string description: | Clé en clair (`yk_<40 chars base62>`) — **visible 1 seule fois**. Aucune autre route ne la re-expose. Perte = révocation + nouvelle création obligatoire. ApiKeyEventOut: type: object description: | Un event du journal d'audit d'une clé API. `api_key_id` et `reseller_id` ne sont **pas** exposés (redondants avec l'URL et le scope tenant). required: [id, event_type, created_at] additionalProperties: false properties: id: { type: string, format: uuid } event_type: type: string enum: [created, revoked, rate_limited] description: | Type d'événement. La liste peut être étendue dans une future version (ex: `used_from_new_ip`) — côté reseller, traiter un `event_type` inconnu comme information sans bloquer. ip: type: string nullable: true description: IP source (INET). Null si non capturée. user_agent: { type: string, nullable: true } metadata: type: object nullable: true description: | Metadata libre selon le type d'event : - `created` → `{"label": "