Dose OS · API publique
Référence des endpoints HTTP exposés par Dose OS — health check, MCP JSON-RPC 2.0 (compatible Claude Desktop), import/export CSV des leads, webhooks signés (Dropbox Sign, WhatsApp) et générateur d'OG images. Tous les exemples utilisent curl; les tokens et secrets restent en variables d'environnement (jamais inline).
Conventions
- Base URL ·
https://dose-os.vercel.appen production. Substituer par votre URL custom si self-hosté. - Format · JSON pour les réponses (
application/json) sauf exports CSV (text/csv; charset=utf-8) et OG images (image/png). - Erreurs · forme canonique
{ error: string, ... }. Les webhooks répondent généralement200même en cas d'échec applicatif pour éviter les retries en boucle. - Rate limits· pas de quota agressif côté plateforme. Restez raisonnable (~10 req/s) ; les webhooks externes sont contrôlés par leurs émetteurs (Meta, Dropbox Sign).
Health check public utilisé par les sondes externes (UptimeRobot, Vercel) et la page /status interne. Retourne toujours 200 si le process Next.js est up.
Réponse · 200 OK
{
"status": "ok",
"name": "dose-os",
"timestamp": "2026-04-28T09:12:34.000Z",
"version": "abc1234"
}version reflète VERCEL_GIT_COMMIT_SHA tronqué à 7 caractères, ou "dev" en local.
Exemple
curl https://dose-os.vercel.app/api/healthNotes
force-dynamic· pas de cache, le timestamp est toujours frais.- Pas d'auth · usage prévu pour des sondes anonymes.
Génère dynamiquement une image OpenGraph (1200×630) à partir d'un titre et d'un sous-titre fournis en query string. Utilisé par les metadata Next.js des pages publiques.
Query parameters
- title
- string · ≤ 90 caractères · défaut « Votre agence Meta Ads pilotée par l'IA »
- subtitle
- string · ≤ 120 caractères · défaut « Coût/réservation < 15 € · 13 restaurants parisiens »
Réponse · 200 OK
image/png · 1200×630 · Cache-Control: public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400.
Exemple
curl -o og.png \
"https://dose-os.vercel.app/api/og?title=Hello&subtitle=World"Notes
- Edge runtime · génération < 200ms en cold start.
- Charte alignée sur
globals.css· gradient ink → gold. - Une variante statique pleine page existe sur
/opengraph-image.
Exporte la pipeline leads au format CSV (UTF-8 BOM-prefixed pour Excel). Filtres optionnels en query string. Limite stricte à 10 000 lignes par appel.
Query parameters
- status
- enum ·
new | contacted | qualified | won | lost - temperature
- enum ·
cold | warm | hot· dérivé du score (≥70 hot, ≥40 warm, sinon cold) - since
- ISO 8601 · valeur invalide silencieusement ignorée
Schéma CSV
id,name,email,phone,status,score,temperature,source,client_slug,created_at,last_activity_atBOM UTF-8 en tête, séparateur virgule, CRLF en fin. last_activity_at tombe en fallback sur created_at si aucune activité.
Réponses
200·text/csv; charset=utf-8·Content-Disposition: attachment; filename="leads-YYYY-MM-DD.csv"403·{ error: 'agency_only' }si l'utilisateur n'est pas membre agence500·{ error: 'query_failed', details }
Exemple
# Export "hot" leads only since 2026-01-01
curl -L -b cookies.txt \
"https://dose-os.vercel.app/api/leads/export.csv?temperature=hot&since=2026-01-01" \
-o leads.csvNotes
- Auth · session Supabase + appartenance agence (cf.
isAgencyMember()). Pas de redirect 302 vers /login (un download d'HTML serait pire que la 403). - Pas de pagination · cap dur à 10 000 leads. Pour des volumes plus grands, utiliser MCP
list_recent_leads. Cache-Control: no-store.
Importe un CSV de leads (multipart/form-data, champ file). Détection automatique des colonnes, validation Zod par ligne, insertion par batch de 100. Cap à 5 MB.
Request · multipart/form-data
- file
- File · CSV obligatoire · ≤ 5 MB · première ligne = en-têtes
Colonnes reconnues (mapping fuzzy) · name, email, phone, source, stage, score, client_slug. Au moins email ou phonerequis. Si l'agence n'a qu'un client, client_slug est optionnel.
Réponse · 200 OK
{
"ok": 142,
"errors": [
{ "row": 17, "message": "email_invalid" },
{ "row": 23, "message": "missing_contact" },
{ "row": 89, "message": "unknown_client_slug" }
]
}Le statut HTTP reste 200 même en cas d'échecs partiels — le client lit errors[] pour surfacer les problèmes ligne par ligne.
Codes d'erreur
400·file_missingouinvalid_form403·agency_only413·file_too_large(max 5 MB)
Exemple
curl -X POST -b cookies.txt \
-F "file=@leads.csv" \
https://dose-os.vercel.app/api/leads/importNotes
- Idempotence · pas de dédoublonnage automatique côté serveur. Si vous ré-importez le même CSV, vous créerez des doublons. Filtrez en amont si besoin.
- Insertion par batch de 100 lignes — un échec batch remonte sous la première ligne du batch dans
errors[]. - RLS appliquée · les leads sont créés sur le
client_iddérivé duclient_slug.
Endpoint MCP (Model Context Protocol) JSON-RPC 2.0. Compatible Claude Desktop. Expose 8 outils pour piloter l'agence (clients, leads, métriques, approbations, audits).
Authorization header
Authorization: Bearer $DOSE_MCP_TOKENLe token est défini via la variable d'environnement DOSE_MCP_TOKEN. Si la variable est vide, l'endpoint répond 503 mcp_disabled. Comparaison constant-time (anti timing attack).
Méthodes JSON-RPC supportées
initialize· handshake MCPtools/list· catalogue des 8 outilstools/call· exécution d'un outil avec arguments validés Zod
Les requêtes batch (array de payloads) sont supportées selon JSON-RPC 2.0 §6 — les notifications (sans id) sont silencieusement ignorées dans la réponse.
GET /api/mcp · descriptor
{
"enabled": true,
"transport": "json-rpc-2.0",
"methods": ["initialize", "tools/list", "tools/call"]
}Utile pour vérifier que l'URL est joignable depuis Claude Desktop avant de configurer le bearer.
Exemple · tools/list
curl -X POST https://dose-os.vercel.app/api/mcp \
-H "Authorization: Bearer $DOSE_MCP_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }'Exemple · tools/call
curl -X POST https://dose-os.vercel.app/api/mcp \
-H "Authorization: Bearer $DOSE_MCP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_client_metrics",
"arguments": { "client_slug": "roger-la-grenouille", "days": 30 }
}
}'Erreurs
401·{ error: 'unauthorized' }(header absent ou token incorrect)503·{ error: 'mcp_disabled' }(la variableDOSE_MCP_TOKENn'est pas définie)400· JSON malformé · réponse JSON-RPCparse_error (-32700)- Erreurs JSON-RPC standards ·
-32601method not found,-32602invalid params,-32603internal error
Catalogue des 8 outils MCP
Le contrat complet (inputSchema JSON Schema) est exposé via tools/list.
list_clientsListe les clients agence actifs (slug, budget mensuel, seuil auto-approve). Param optionnelinclude_inactive.get_client_metricsRéservations, couverts, coût par résa et revenu estimé sur une fenêtre. Paramsclient_slug(req),days1-90 (def 30).list_pending_approvalsDemandes d'approbation non résolues en attente. Param optionnellimit1-200 (def 50).approve_optimizer_actionApprouve une action optimizer (budget_increase / budget_decrease / pause_campaign / duplicate_winner) et l'exécute sur Meta. Paramsactivity_id(uuid, req),note≤500 chars.list_recent_leadsLeads récents tous clients confondus. Params optionnelslimit1-500 (def 50),client_slug.get_agent_runsRuns d'agents récents pour debug cron. Params optionnelsagent,status(queued | running | succeeded | failed),limit1-200 (def 25).trigger_auditDéclenche l'agentaudit_strategiqueen modemanual:on_demand. Paramclient_slug(req). Retourne scanned / findings / cost.get_reservations_30dAgrégat des réservations sur N jours par client (count, covers, cost_per_reservation). Param optionneldays1-90 (def 30).
Webhook Dropbox Sign (ex-HelloSign). Met à jour brand_context.contract / leads.raw_payload.contract sur les events lifecycle. Vérification HMAC-SHA256 (event_time + event_type, signé avec DROPBOX_SIGN_API_KEY).
Format de la requête
Content-Type: multipart/form-data
# Le body contient un seul field "json" avec l'event signé.
# La signature est dans event.event_hash :
# hmac_sha256(API_KEY, event_time + event_type) === event_hashPayload (subset lu, dans le field "json")
{
"event": {
"event_time": "1714402800",
"event_type": "signature_request_all_signed",
"event_hash": "<hmac_sha256_hex>",
"event_metadata": { "related_signature_id": "..." }
},
"signature_request": {
"signature_request_id": "abc-123",
"is_complete": true,
"signatures": [{
"signer_email_address": "client@restaurant.fr",
"status_code": "signed",
"signed_at": 1714402800
}],
"metadata": { "client_id": "<uuid lead/client>", "formula_slug": "standard" }
}
}Events traités · signature_request_sent, signature_request_viewed, signature_request_all_signed, signature_request_declined, signature_request_canceled, signature_request_expired, callback_test.
Réponses
200· bodyHello API Event Received(texte exigé par Dropbox Sign — sans ça le webhook est désactivé après quelques échecs)400·{ error: 'invalid_form_data' | 'missing_json_field' | 'invalid_json' | 'incomplete_event' }403·{ error: 'invalid_signature' }(HMAC ne match pas)503·{ error: 'not_configured' }(DROPBOX_SIGN_API_KEY vide)
Notes
- Activity
contract_signedémise surall_signed,contract_voided/contract_declinedsurdeclined | canceled | expired. - Audit log
contract.status_updatedsystématique. Notification Slack + emailcontract_signed+ emailqonto_mandateen best-effort sur completion. - Idempotence · le serveur merge l'objet
brand_context.contractsans écrasersigned_at/voided_atexistants. La clé DBenvelope_idcontient lesignature_request_idDropbox Sign (ex-DocuSign envelope GUID).
Webhook WhatsApp Cloud API (Meta). Reçoit les réponses des clients aux demandes d'approbation et exécute la décision (OK / STOP) sur la dernière approval_requested non résolue.
GET · handshake Meta
GET /api/webhooks/whatsapp
?hub.mode=subscribe
&hub.verify_token=$WHATSAPP_VERIFY_TOKEN
&hub.challenge=<random>Retourne le challenge en clair (200 text/plain) si le verify_token matche · sinon 403 forbidden.
POST · payload Meta (subset lu)
{
"object": "whatsapp_business_account",
"entry": [{
"changes": [{
"value": {
"messages": [{
"from": "33612345678",
"type": "text",
"text": { "body": "OK" }
}]
}
}]
}]
}Détection d'intention
- approve ·
/^(ok|oui|approve|approuv|go|yes)\b/i - deny ·
/^(stop|non|no|refuse|skip)\b/i - sinon · unknown → message d'aide envoyé au sender.
Réponse
Toujours 200 { ok: true } pour éviter les retries Meta. Les échecs internes sont loggés via audit_logs / console.error.
Notes
- Le numéro
fromest matché contreclients.whatsapp_number(avec / sans préfixe « + »). Si aucun client ne matche, le message est ignoré silencieusement. - Sur approve, exécute
executeOptimizerAction()et pose une activityapproval_granted. Sur deny, activityapproval_denied. - Idempotence · seule la dernière
approval_requestednon résolue est ciblée. Le payload est marquéresolved_ataprès décision. - Pas de signature HMAC explicite en POST · Meta s'appuie sur le verify_token + canal dédié. Ne pas exposer cet endpoint sans un
WHATSAPP_VERIFY_TOKENconfiguré.
Webhook Meta Lead Ads. Reçoit en temps réel chaque soumission de Lead Form, fetch le détail via Graph API (PAGE_ACCESS_TOKEN), insère un lead source='META_LEAD_AD' et déclenche l'agent lead_concierge via Inngest.
GET · handshake Meta
GET /api/webhooks/meta-leads
?hub.mode=subscribe
&hub.verify_token=$META_LEAD_VERIFY_TOKEN
&hub.challenge=<random>Retourne le challenge en clair (200 text/plain) si le verify_token matche · sinon 403 forbidden.
POST · headers attendus
Content-Type: application/json
x-hub-signature-256: sha256=<hex(HMAC_SHA256(rawBody, $META_APP_SECRET))>POST · payload Meta (subset lu)
{
"object": "page",
"entry": [{
"id": "<page_id>",
"time": 1735320000,
"changes": [{
"field": "leadgen",
"value": {
"leadgen_id": "987654321",
"page_id": "1122334455",
"form_id": "5566778899",
"ad_id": "120211000000000000",
"created_time": 1735320000
}
}]
}]
}Le webhook ne contient que les IDs · le détail (nom, email, téléphone) est récupéré via GET /v23.0/{leadgen_id}?fields=field_data en utilisant le PAGE_ACCESS_TOKEN stocké dans brand_context.meta_page_access_token (fallback META_ACCESS_TOKEN).
Configuration Meta
- Meta Business → App → Webhooks → Page →
leadgen. - Callback URL ·
https://agence.dosedefrance.com/api/webhooks/meta-leads. - Verify token ·
$META_LEAD_VERIFY_TOKEN(Vercel env, identique des deux côtés). - L'abonnement par page est posé automatiquement par
/api/portal/onboarding/meta-callbackviaPOST /v23.0/{page_id}/subscribed_apps.
Réponse
Toujours 200 { ok: true, processed: [...] } sauf signature invalide → 401. Les erreurs (page inconnue, fetch leadgen KO) sont loggées via Sentry breadcrumbs sans bloquer le 200 final (Meta retry sinon).
Notes
- Le matching client se fait sur
clients.meta_page_id= page_id du payload. - Les leads sont stockés avec
source='META_LEAD_AD'et le payload Meta complet dansraw_payload(JSONB). - Inngest event
lead.createdémis → l'agentlead_conciergereprend en relais (notification, scoring, follow-up). - Si le fetch leadgen échoue (token expiré, lead supprimé), on persiste tout de même un stub lead avec
fetch_errordansraw_payload— pas de signal perdu.
Endpoint debug agency-only qui envoie un message WhatsApp réel pour valider la configuration WHATSAPP_BUSINESS_TOKEN + WHATSAPP_PHONE_NUMBER_ID. À utiliser après chaque rotation de token Meta.
Query parameters
- to
- E.164 sans «+» (e.g.
33612345678) · prioritaire sur l'env. Quand absent, fallback surWHATSAPP_TEST_TO.
Réponses
200·{ ok: true, to, sent_at }400·missing_to(ni query, ni env)401·unauthorized403·agency_only503·whatsapp_not_configured502·{ ok: false, to, error }(la Graph API a refusé)
Exemple
curl -L -b cookies.txt \
"https://dose-os.vercel.app/api/test/whatsapp/send?to=33612345678"Notes
- Coût · ~0,005 à 0,05 €/message selon la zone (Meta Cloud tarif business). Usage debug exclusif — pas exposé en public.
- Le message envoyé contient un timestamp ISO pour faciliter le matching côté inbox WhatsApp.
Endpoint debug agency-only qui valide Google Calendar Domain-Wide Delegation : tente listCalendars(impersonate) + getNextSlots(60min × 5) sur l'utilisateur impersonné.
Query parameters
- impersonate
- email Workspace org · default
GOOGLE_AGENCY_IMPERSONATE_EMAILougaetan@dosedefrance.com
Réponse · 200
{
"ok": true,
"impersonate": "gaetan@dosedefrance.com",
"calendars_count": 4,
"calendars": [
{ "id": "primary", "summary": "Gaetan", "primary": true,
"timeZone": "Europe/Paris", "accessRole": "owner" }
],
"next_slots_count": 5,
"next_slots": [
{ "start": "2026-04-29T08:00:00.000Z",
"end": "2026-04-29T09:00:00.000Z",
"label": "Mardi 29 avril · 10h00" }
]
}Erreurs
401·unauthorized403·agency_only503·gcal_not_configured502·gcal_dwd_failed(Workspace admin n'a probablement pas autorisé le scope calendar pour le client_id du service account — voiradmin.google.com→ Sécurité > Délégation à l'échelle du domaine).
Notes
getNextSlotscible la fenêtre Lun-Ven 10h-18h Europe/Paris, snap demi-heure, max 1 slot/jour, durée 60 min. Aligné sur la landing /audit-gratuit.- Cache token DwD par {impersonate} (60 min) côté server process — pas de double JWT exchange entre les deux appels parallèles.
Renvoie le PNG d'un asset généré par /tools/content-generator. Source : cache mémoire éphémère server-side (TTL 30 min). Si l'asset est un data: URL inline, on stream les bytes ; si c'est une URL hosted (DALL-E 3), on redirige (302).
Query parameters
- genId
- ID de génération (16 hex) renvoyé par l'action
generateContentAction - assetIdx
- index de l'asset dans le bundle (0-based)
Réponse · 200
Content-Type: image/png
Cache-Control: private, max-age=600
<binary PNG bytes>Erreurs
400·missing_params/invalid_assetIdx403·agency_only404·generation_expired(TTL 30 min écoulé) ouasset_not_found
Stream un ZIP (méthode store, sans compression) contenant l'image PNG + un copy.txt par asset. Si assetIdx est fourni, ZIP avec un seul asset. Sinon ZIP de tout le bundle. Inclut un README.txt avec le contexte génération.
Query parameters
- genId
- ID de génération (16 hex)
- assetIdx
- optionnel · si absent → ZIP de tout le bundle
Réponse · 200
Content-Type: application/zip
Content-Disposition: attachment; filename="dose-content-<client>-<id>.zip"
README.txt
01-meta_feed/copy.txt
01-meta_feed/image.png
02-meta_story/copy.txt
02-meta_story/image.png
…Erreurs
400·missing_genId/invalid_assetIdx403·agency_only404·generation_expired
Notes
- ZIP encodé en méthode
store(compression=0) — suffisant car les PNG sont déjà compressés. Pas de dépendance npm (JSZipnon installé). - Si une image hosted DALL-E a expiré entre la génération et le download, le ZIP contient un
image-error.txtà la place — la copy reste accessible.