{"openapi":"3.0.0","info":{"title":"Marvin — API partenaire & intégrations","description":"Référence des API publiques Marvin destinées aux intégrateurs externes (partenaires de sourcing, site carrière). Authentification par clé `X-Marvin-Public-Key`.","version":"1.0.0"},"components":{"securitySchemes":{"cookieAuth":{"type":"apiKey","in":"cookie","name":"session","description":"JWT session cookie"},"partnerApiKey":{"type":"apiKey","in":"header","name":"X-Marvin-Public-Key","description":"Clé `pk_live_…` scopée par tenant, générée par le client depuis Marvin, à droits limités. Droits possibles : `public:careers:read`, `public:careers:apply`, `public:contacts:write`, `public:companies:write`, `partner:projects:read`, `partner:candidates:write`. Droit exigé par chaque opération : https://developers.marvins.ai/docs/authentication."}},"schemas":{"Error":{"type":"object","description":"Erreurs des opérations JSON : un objet `error` qui porte un `code` lisible par une machine (et le plus souvent un `message`). Les flux XML peuvent répondre une erreur en XML, en texte ou dans cette enveloppe JSON, selon l'endroit où elle survient : se fier au statut HTTP et au `Content-Type`.","required":["error"],"properties":{"error":{"type":"object","required":["code"],"properties":{"code":{"type":"string","description":"Code stable, lisible par une machine (`unauthorized`, `insufficient_scope`, `slug_mismatch`, `rate_limited`…)."},"message":{"type":"string","description":"Explication lisible."},"retryable":{"type":"boolean","description":"Présent sur les erreurs du gestionnaire global : `true` si réessayer a un sens."},"retryAfterSec":{"type":"integer","description":"Délai conseillé avant de réessayer, en secondes (débit dépassé)."},"field":{"type":"string","description":"Champ en cause, quand l’erreur porte sur un champ précis."}}}}}}},"paths":{"/v1/public/careers/{slug}":{"get":{"operationId":"listCareersJobs","summary":"Lister les offres ouvertes et les infos du tenant (JSON)","tags":["Careers (Public)"],"security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/careers/{slug}/channel/site-web":{"get":{"operationId":"listSiteWebChannelJobs","summary":"Lister les offres du canal « Site web » (JSON, sélection curée)","tags":["Careers (Public)"],"security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/careers/{slug}/jobs/{id}":{"get":{"operationId":"getCareersJob","summary":"Détail d’une offre (JSON)","tags":["Careers (Public)"],"security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/careers/{slug}/feed.xml":{"get":{"operationId":"getCareersJobsFeedXml","summary":"Flux XML des offres (auth par header X-Marvin-Public-Key ou query ?key=)","tags":["Careers (Public)"],"security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/public/careers/{slug}/jobs/{id}/apply":{"post":{"operationId":"submitJobApplication","summary":"Soumettre une candidature à une offre depuis votre site","tags":["Careers (Public)"],"description":"Reçoit une candidature déposée depuis le formulaire du site web du\nclient et la crée dans Marvin (CV récupéré + parsé, profil rattaché\nou créé, candidature attachée à l’offre, source `site_web`).\n\n`{id}` est l’identifiant de l’offre lu dans le flux d’offres : `<jobid>`\ndu flux Site web, `<id>` de `feed.xml`.\n\nAvec une clé Site web, la candidature est acceptée sur toute offre\nouverte, même si sa diffusion n’est pas activée dans Marvin. Pour un\ndépôt de partenaire (clé partenaire, ou `?origin=` d’un outil de\nsourcing), l’offre doit en plus être diffusée.\n\n#### Authentification\nHeader `X-Marvin-Public-Key: pk_live_…` — clé générée depuis\nConfiguration → Multidiffusion → Site web → Configurer (scope\ncandidatures), ou clé partenaire générée depuis Paramètres →\nIntégrations par un connecteur de sourcing prévu pour ces routes\n(scope `partner:candidates:write`).\nLa clé est un secret côté serveur : ne jamais l’exposer côté\nnavigateur.\n\n#### Attribution de la source (optionnel)\nQuery `?origin=` pour attribuer la provenance. Sans paramètre, la\ncandidature est attribuée à `site_web`. Un outil de sourcing passe\nl'identifiant de provenance que Marvin lui a communiqué : la\ncandidature **et** le candidat lui sont attribués. Avec une clé\npartenaire, la provenance est fixée par la clé et `?origin=` est\nignoré.\n\n#### Corps de la requête\n`multipart/form-data` (avec CV) ou `application/json`. Champs\ncompatibles avec le format historique V1 :\n\n| Champ | Requis | Description |\n| --- | --- | --- |\n| `firstName` | oui | Prénom du candidat |\n| `lastName` | oui | Nom du candidat |\n| `email` | oui | E-mail du candidat |\n| `phoneNumber` | non | Téléphone |\n| `linkedin` | non | URL du profil LinkedIn |\n| `motivationText` | non | Message / lettre de motivation |\n| `resume` (ou `cv`) | non | Fichier CV (PDF, Word ou image), en multipart |\n| `languages` | non | Langues du candidat (voir ci-dessous) |\n| `city` | non | Ville du candidat (voir ci-dessous) |\n| `postalCode` | non | Code postal du candidat |\n| `countryCode` | non | Pays du candidat, code ISO 3166-1 alpha-2 |\n\nLes champs personnalisés du formulaire de candidature sont acceptés\nen plus. Un champ à choix multiple s’envoie en chaîne : le libellé d’une\noption, ou un tableau JSON sérialisé (`[\"A\",\"B\"]`). Une valeur qui n’est pas\nune option du champ fait refuser la candidature (`409`, `conflict`).\n\n#### Langues du candidat (optionnel)\n`languages` ajoute les langues du candidat à sa fiche : une ou plusieurs\nlangues, séparées par une virgule ou un point-virgule (en JSON, un tableau\nde chaînes est aussi accepté ; en multipart, le champ peut être répété).\nChaque langue est désignée par son code ISO 639-1 à deux lettres (`fr`,\n`en`, `es`… ; `fra` n’est pas reconnu)\nou par son nom en français ou en anglais, parmi la centaine de langues\ncourantes que Marvin connaît. La candidature ne modifie ni ne retire les\nlangues que la fiche porte déjà. Une valeur non reconnue est ignorée sans\nfaire échouer la candidature. Au plus 10 langues distinctes sont retenues.\n\n#### Localisation du candidat (optionnel)\n`city` (avec `postalCode` et `countryCode` facultatifs) renseigne la\nlocalisation du candidat sur sa fiche, rattachée au référentiel de lieux\nde Marvin (une ville, jamais une rue). Une précision après une virgule est\nignorée (`Lyon, France` vaut `Lyon`). Le code postal départage les\ncommunes homonymes ; `countryCode` (ISO 3166-1 alpha-2) limite la\nrecherche à un pays — sans lui, la France est privilégiée sans être\nimposée. Une localisation déjà présente sur la fiche n’est jamais\nremplacée. Une ville introuvable est ignorée ; une valeur illisible dans\nl’un des trois champs fait ignorer toute la localisation. La candidature\nest acceptée dans tous les cas ; `postalCode` sans `city` est ignoré.\n\n#### Exemple\n```bash\ncurl -X POST \\\n  \"https://api.marvins.ai/v1/public/careers/{slug}/jobs/{id}/apply\" \\\n  -H \"X-Marvin-Public-Key: pk_live_…\" \\\n  -F \"firstName=Marie\" \\\n  -F \"lastName=Dupont\" \\\n  -F \"email=marie.dupont@example.com\" \\\n  -F \"phoneNumber=+33612345678\" \\\n  -F \"resume=@/chemin/vers/cv.pdf\"\n```\n\n#### Réponses\n`201` candidature reçue · `401` clé absente/invalide · `403` scope\nmanquant · `404` offre introuvable · `410` offre fermée ou supprimée\n(ou non diffusée, pour un dépôt de partenaire) · `400` `firstName`,\n`lastName` ou `email` manquant ou invalide · `422` autre champ rendu\nobligatoire par le formulaire du client, nommé dans `error.field` ·\n`409` champ personnalisé modifié pendant l’envoi, ou valeur hors des\noptions d’un champ à choix multiple.","security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/careers/{slug}/jobs/{id}/apply-widget":{"post":{"operationId":"submitJobApplicationFromWidget","summary":"Soumettre une candidature depuis le widget navigateur (reCAPTCHA, sans clé)","tags":["Careers (Public)"],"description":"Endpoint appelé par le **widget careers embarqué** (navigateur), pas\npar un serveur. Le tenant est résolu par le `:slug` de l’URL — aucune\nclé publique n’est requise ni acceptée ici (la clé embarquée dans le\nHTML est en lecture seule).\n\nAnti-abus : reCAPTCHA v3 invisible via le header `X-Recaptcha-Token`\n(généré par `grecaptcha.execute(SITE_KEY, { action: \"apply\" })`) +\nrate-limit par IP. Si `RECAPTCHA_SECRET_KEY` n’est pas configurée côté\nserveur (dev/preview), la vérification est ignorée.\n\nCorps identique au formulaire carrière (`multipart/form-data` avec CV\nou `application/json`) : `firstName`, `lastName`, `email`, `phone`,\n`linkedinLink`, `motivationText`, `cv`.\n\n`201` candidature reçue · `404` tenant/offre introuvable · `422`\nreCAPTCHA échoué ou champ requis manquant.","parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/careers/{slug}/apply-widget":{"post":{"operationId":"submitSpontaneousApplicationFromWidget","summary":"Soumettre une candidature spontanée depuis le widget navigateur (reCAPTCHA, sans clé)","tags":["Careers (Public)"],"description":"Endpoint appelé par le **widget careers embarqué** (navigateur) pour une\ncandidature **spontanée** (non liée à une offre). Tenant résolu par le\n`:slug` de l’URL — aucune clé publique requise ni acceptée.\n\nAnti-abus : reCAPTCHA v3 invisible via header `X-Recaptcha-Token` (action\n`apply_spontaneous`) + rate-limit par IP + fail-closed en prod (503 si le\nsecret reCAPTCHA n’est pas configuré). Gardé par le toggle\n`allowSpontaneous` (Paramètres → Carrières) : `403` si désactivé.\n\nCorps identique au formulaire carrière (`multipart/form-data` avec CV ou\n`application/json`), sans `{id}` : `firstName`, `lastName`, `email`,\n`phone`, `linkedinLink`, `motivationText`, `cv`.\n\n`201` candidature reçue · `403` candidatures spontanées désactivées · `404`\ntenant introuvable · `422` reCAPTCHA échoué ou champ requis manquant · `503`\nreCAPTCHA indisponible (prod).","parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/careers/{slug}/apply":{"post":{"operationId":"submitSpontaneousApplication","summary":"Soumettre une candidature spontanée (sans offre) depuis votre site","tags":["Careers (Public)"],"description":"Reçoit une candidature **spontanée** (non liée à une offre) déposée\ndepuis le site du client et la crée dans Marvin (CV récupéré + parsé,\nprofil rattaché ou créé, candidature `job_id` null, source `site_web`).\n\n#### Authentification\nHeader `X-Marvin-Public-Key: pk_live_…` (même clé que l’endpoint de\ncandidature à une offre, scope candidatures). Secret côté serveur.\n\n#### Corps de la requête\n`multipart/form-data` (avec CV) ou `application/json`. Champs identiques\nà l’endpoint offre, sans `{id}` :\n\n| Champ | Requis | Description |\n| --- | --- | --- |\n| `firstName` | oui | Prénom du candidat |\n| `lastName` | oui | Nom du candidat |\n| `email` | oui | E-mail du candidat |\n| `phoneNumber` | non | Téléphone |\n| `linkedin` | non | URL du profil LinkedIn |\n| `motivationText` | non | Message / lettre de motivation |\n| `resume` (ou `cv`) | non | Fichier CV (PDF, Word ou image), en multipart |\n| `languages` | non | Langues du candidat (voir ci-dessous) |\n| `city` | non | Ville du candidat (voir ci-dessous) |\n| `postalCode` | non | Code postal du candidat |\n| `countryCode` | non | Pays du candidat, code ISO 3166-1 alpha-2 |\n\n#### Langues du candidat (optionnel)\n`languages` ajoute les langues du candidat à sa fiche : une ou plusieurs\nlangues, séparées par une virgule ou un point-virgule (en JSON, un tableau\nde chaînes est aussi accepté ; en multipart, le champ peut être répété).\nChaque langue est désignée par son code ISO 639-1 à deux lettres (`fr`,\n`en`, `es`… ; `fra` n’est pas reconnu)\nou par son nom en français ou en anglais, parmi la centaine de langues\ncourantes que Marvin connaît. La candidature ne modifie ni ne retire les\nlangues que la fiche porte déjà. Une valeur non reconnue est ignorée sans\nfaire échouer la candidature. Au plus 10 langues distinctes sont retenues.\n\n#### Localisation du candidat (optionnel)\n`city` (avec `postalCode` et `countryCode` facultatifs) renseigne la\nlocalisation du candidat sur sa fiche, rattachée au référentiel de lieux\nde Marvin (une ville, jamais une rue). Une précision après une virgule est\nignorée (`Lyon, France` vaut `Lyon`). Le code postal départage les\ncommunes homonymes ; `countryCode` (ISO 3166-1 alpha-2) limite la\nrecherche à un pays — sans lui, la France est privilégiée sans être\nimposée. Une localisation déjà présente sur la fiche n’est jamais\nremplacée. Une ville introuvable est ignorée ; une valeur illisible dans\nl’un des trois champs fait ignorer toute la localisation. La candidature\nest acceptée dans tous les cas ; `postalCode` sans `city` est ignoré.\n\n#### Exemple\n```bash\ncurl -X POST \\\n  \"https://api.marvins.ai/v1/public/careers/{slug}/apply\" \\\n  -H \"X-Marvin-Public-Key: pk_live_…\" \\\n  -F \"firstName=Marie\" \\\n  -F \"lastName=Dupont\" \\\n  -F \"email=marie.dupont@example.com\" \\\n  -F \"resume=@/chemin/vers/cv.pdf\"\n```\n\n#### Réponses\n`201` candidature reçue · `400` champ invalide · `401` clé\nabsente/invalide · `403` scope manquant · `404` `slug` d’un autre\nclient que la clé · `422` champ requis manquant · `409` champ\npersonnalisé modifié pendant l’envoi, ou valeur hors des options d’un\nchamp à choix multiple.","security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/crm/{slug}/contact-requests":{"post":{"operationId":"createContactRequest","summary":"Créer une demande de contact (entreprise ou particulier) depuis votre site","tags":["CRM (Public)"],"description":"Reçoit une **demande de contact** déposée sur le site web du client —\nformulaire **entreprise** (audit RH, recrutement, coaching…) ou\n**particulier** (bilan, reconversion…) — et la crée dans Marvin côté CRM :\nentreprise retrouvée ou créée, contact retrouvé ou créé (jamais de\ncandidat, jamais de candidature), demande visible dans la timeline du\ncontact et de l’entreprise, notification aux recruteurs.\n\n#### Authentification\nHeader `X-Marvin-Public-Key: pk_live_…` — clé générée depuis\nConfiguration → Multidiffusion → Site web → Configurer, avec le droit\n**demandes de contact** (`public:contacts:write`). Secret côté serveur :\nne jamais l’exposer côté navigateur.\n\n#### Corps de la requête (`application/json`)\n\n| Champ | Requis | Description |\n| --- | --- | --- |\n| `kind` | oui | `company` (entreprise) ou `individual` (particulier) |\n| `firstName`, `lastName` | oui | Identité du demandeur |\n| `email` | oui | E-mail du demandeur — clé de rapprochement avec une fiche existante (contact distinct si l’e-mail est partagé par plusieurs fiches) |\n| `companyName` | si `kind=company` | Nom de l’entreprise (retrouvée ou créée) |\n| `companyWebsite` | non | Site web de l’entreprise (schéma ajouté si absent ; ignoré si invalide) |\n| `phone` | non | Téléphone (national FR ou E.164 ; ignoré si non reconnu, jamais bloquant) |\n| `jobTitle` | non | Fonction du demandeur |\n| `topic` | non | Objet de la demande (ex. « Audit RH », « Bilan de compétences ») |\n| `message` | non | Message du visiteur (5 000 caractères max) |\n| `formName` | non | Nom du formulaire d’origine (ex. `contact-entreprise`) |\n| `listId` | non | Identifiant d’une liste de contacts de votre CRM : le contact y est ajouté (sans doublon) |\n| `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | non | Provenance |\n\nToute autre clé est refusée (`400 validation_error`). La provenance\n(`source`) est toujours **Site web** ; `formName` et les UTM distinguent\nvos formulaires.\n\n#### Exemple\n```bash\ncurl -X POST \\\n  \"https://api.marvins.ai/v1/public/crm/{slug}/contact-requests\" \\\n  -H \"X-Marvin-Public-Key: pk_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"kind\":\"company\",\"firstName\":\"Marie\",\"lastName\":\"Dupont\",\n       \"email\":\"marie.dupont@acme.example\",\"companyName\":\"Acme\",\n       \"topic\":\"Audit RH\",\"message\":\"Nous souhaitons…\",\"formName\":\"contact-entreprise\"}'\n```\n\n#### Réponses\n`201 { data: { id, personId, companyId } }` demande reçue (`companyId`\nnull pour un particulier) · `400 validation_error` champ invalide (`field`)\n· `401` clé absente/invalide · `403 insufficient_scope` droit manquant ·\n`404` client introuvable · `422 missing_required_field` champ requis\nmanquant (`field`) · `422 list_not_found` `listId` inconnu, supprimé ou\nqui n’est pas une liste de contacts (`field`) · `429` trop de requêtes\n(30/min par clé).\n\nUne même demande soumise deux fois en moins de 10 minutes (même e-mail,\nmême nature, même objet, même entreprise, même message, même formulaire)\nrenvoie `201` avec les identifiants de la première, sans créer de doublon.","security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/crm/{slug}/fields":{"get":{"operationId":"getCrmFields","summary":"Lire le référentiel de votre compte (statuts, effectifs, champs personnalisés)","tags":["CRM (Public)"],"description":"Rend le **référentiel** de votre compte, pour construire le mapping de votre CRM vers\nMarvin sans demander les clés : tranches d’effectif (`size`), statuts entreprise\n(`status`), champs personnalisés entreprise et contact (`customFields`).\nLecture de la configuration seulement : aucune fiche n’est lue.\n\n#### Authentification\nLa même clé que l’envoi des entreprises (`X-Marvin-Public-Key`, droit\n`public:companies:write`), avec la même restriction par IP.\n\n#### Réponse `200`\n```json\n{ \"data\": {\n  \"sizes\": [\"0-1\", \"2-10\", \"…\"],\n  \"companyStatuses\": [{ \"key\": \"client\", \"label\": \"Client\", \"category\": \"won\" }],\n  \"companyCustomFields\": [{ \"key\": \"secteur\", \"label\": \"Secteur\", \"type\": \"select\",\n                           \"options\": [\"Industrie\", \"Services\"] }],\n  \"contactCustomFields\": [{ \"key\": \"role\", \"label\": \"Rôle\", \"type\": \"select\",\n                           \"options\": [\"RH\", \"Comptable\"] }]\n} }\n```\n`type` : `text`, `textarea`, `number`, `date`, `boolean`, `select`, `multiselect`,\n`url`, `email` ou `marker`. `options` (choix acceptés) n’est rendu que pour `select`,\n`multiselect` et `marker`. `category` du statut : `interested`, `in_progress`, `won`,\n`lost`… Les champs réservés aux candidats ne sont pas rendus.\n\nErreurs : `401` clé absente ou invalide · `403 insufficient_scope` droit manquant ·\n`403 ip_not_allowed` adresse IP non autorisée (`error.ip`) · `404` compte introuvable ·\n`429` trop de requêtes (120 par minute, par clé et par route).","security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/public/crm/{slug}/companies/{externalId}":{"patch":{"operationId":"upsertCrmCompany","summary":"Créer ou mettre à jour une entreprise et ses contacts depuis votre CRM","tags":["CRM (Public)"],"description":"Crée ou met à jour une **entreprise** et ses **contacts** à partir de votre CRM.\nLe flux va de votre CRM vers Marvin ; votre CRM fait foi.\n\n- `externalId` (dans l’adresse) est l’identifiant de l’entreprise dans votre CRM.\n  Premier envoi : l’entreprise est créée, ou rapprochée d’une fiche existante (SIREN,\n  puis SIRET, puis nom exact). Envois suivants : la même fiche est mise à jour.\n- **Seuls les champs envoyés changent.** Une valeur envoyée remplace celle de Marvin ;\n  `null` vide le champ ; un champ absent ne bouge pas.\n- Rejouer un envoi ne crée jamais de doublon.\n- Marvin ne supprime rien : un contact absent de l’envoi reste rattaché.\n- Une fiche supprimée dans Marvin n’est jamais recréée.\n\n#### Authentification\nHeader `X-Marvin-Public-Key: pk_live_…` — clé générée par un gestionnaire depuis\nParamètres → Intégrations → CRM, avec le droit `public:companies:write`.\nSecret côté serveur : ne jamais l’exposer côté navigateur.\nLa clé peut être restreinte à une liste d’adresses IP ou de plages (`203.0.113.0/24`),\nréglée par un gestionnaire (20 au plus ; `0.0.0.0/0` et `::/0` sont refusés) : un appel\nvenu d’une autre adresse est refusé (`403 ip_not_allowed`, l’adresse vue par Marvin est\nrendue dans `error.ip`).\n\n#### Corps de la requête (`application/json`)\n\n| Champ | Format |\n| --- | --- |\n| `name` | Texte, 200 caractères max. Obligatoire pour créer l’entreprise |\n| `siren` | 9 chiffres (espaces tolérés) |\n| `siret` | 14 chiffres (espaces tolérés) |\n| `website` | Adresse du site (`https://` ajouté si absent) |\n| `linkedinUrl` | Adresse de la page LinkedIn de l’entreprise (`/company/…`) |\n| `size` | Tranche d’effectif : `0-1`, `2-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+` |\n| `revenue` | Chiffre d’affaires en euros, entier de 0 à 2 147 483 647 |\n| `address` | Adresse postale en texte, 500 caractères max |\n| `status` | Clé d’un statut entreprise de votre compte (`GET …/fields`) |\n| `signedAt` | Date de signature du contrat : `AAAA-MM-JJ` ou date-heure ISO 8601. `null` la vide |\n| `city` | Ville, 100 caractères max : renseigne la localisation de l’entreprise |\n| `postalCode` | Code postal, départage les villes homonymes (avec `city`). `CEDEX` est ignoré |\n| `country` | Pays, code ISO à 2 lettres (`FR`, `BE`…). Absent : la France est privilégiée |\n| `customFields` | Champs personnalisés entreprise : `{ \"<clé>\": valeur }` (`GET …/fields`) |\n| `contacts` | Liste de 50 contacts au plus (voir ci-dessous) |\n\nChaque contact :\n\n| Champ | Format |\n| --- | --- |\n| `externalId` | Identifiant du contact dans votre CRM (obligatoire, unique dans l’envoi) |\n| `firstName`, `lastName` | Texte, 100 caractères max. Obligatoires pour créer le contact |\n| `email` | Adresse e-mail. Au premier envoi, rapproche le contact d’une fiche existante |\n| `phone` | Téléphone (national français ou international) |\n| `jobTitle` | Fonction, 150 caractères max |\n| `customFields` | Champs personnalisés ouverts aux contacts : `{ \"<clé>\": valeur }` |\n\n#### Statut et date de signature\nUne clé inconnue est refusée (`422 unknown_status`), jamais remplacée. Sans `signedAt`, le\npremier passage à un statut de catégorie « gagné » date la signature du jour. `signedAt`\nenvoyé l’emporte toujours : à la reprise des entreprises déjà sous contrat, envoyez la date\nde signature de leur contrat. Un statut reçu de votre CRM ne déclenche aucune\nautomatisation de votre compte.\n\n#### Localisation\n`city` est rapprochée du référentiel des communes (le code postal départage les\nhomonymes). Votre CRM ne gère que la localisation qu’il a posée : si l’entreprise a déjà\ncette ville dans Marvin, rien n’est dupliqué ; une nouvelle ville remplace celle posée par\nvotre CRM ; les localisations saisies dans Marvin ne sont jamais retirées. `city: null`\nretire la seule localisation posée par votre CRM. Ville ou code postal illisible, ville non\nreconnue : rien ne change et l’avertissement `location_not_found` est rendu. Renvoyer la\nmême ville ne la recalcule pas. `postalCode` et `country` ne servent qu’avec `city`.\n\n#### Champs personnalisés\nLes clés et types acceptés sont rendus par `GET /v1/public/crm/{slug}/fields`. Valeur :\ntexte, nombre ou booléen selon le type du champ (date `AAAA-MM-JJ`, liste : un choix\nexistant, liste multiple : tableau JSON `[\"A\",\"B\"]` ou texte `\"A, B\"`). Une clé inconnue,\nun champ réservé aux candidats sur un contact, un tableau sur un champ qui n’est pas une\nliste multiple ou une valeur invalide est refusé (`400 validation_error`,\n`field: customFields.<clé>` ou `contacts.<i>.customFields.<clé>`). `null`, `\"\"` et `[]`\neffacent. Entreprise et contact créé par votre CRM : la valeur envoyée remplace. Contact\nqui existait déjà dans Marvin : seuls les champs encore vides sont remplis. Un champ date à\nrappel automatique ne crée, ne déplace ni n’annule aucun rappel.\n\nLes identifiants (`externalId`) acceptent lettres, chiffres et `. _ : @ -`, 200\ncaractères au plus. Toute autre clé est refusée (`400 validation_error`).\n\n#### Exemple\n```bash\ncurl -X PATCH \\\n  \"https://api.marvins.ai/v1/public/crm/{slug}/companies/1234567\" \\\n  -H \"X-Marvin-Public-Key: pk_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"Acme\",\"siren\":\"123456789\",\"size\":\"51-200\",\"revenue\":4500000,\n       \"contacts\":[{\"externalId\":\"9876\",\"firstName\":\"Marie\",\"lastName\":\"Dupont\",\n       \"email\":\"marie.dupont@acme.example\",\"jobTitle\":\"DRH\"}]}'\n```\n\n#### Réponses\n`201` entreprise créée · `200` entreprise mise à jour. Corps :\n`{ data: { companyId, created, contacts: [{ externalId, personId, created }], warnings } }`.\n`warnings` liste ce qui n’a pas été appliqué sans bloquer l’envoi (`code`, `field`,\n`message`) : `contact_company_conflict` (contact déjà rattaché à une autre entreprise,\nnon déplacé), `contact_deleted` (contact supprimé dans Marvin, non recréé),\n`contact_email_ambiguous` (e-mail porté par plusieurs fiches, contact distinct créé),\n`contact_email_already_linked` (fiche de cet e-mail déjà liée à un autre contact de\nvotre CRM, contact distinct créé), `location_not_found` (ville non reconnue, localisation\ninchangée).\n\nErreurs : `400 validation_error` champ invalide (`field`) · `401` clé absente ou\ninvalide · `403 insufficient_scope` droit manquant · `403 ip_not_allowed` adresse IP\nnon autorisée pour cette clé (`error.ip`) · `404` compte introuvable ·\n`409 record_deleted` entreprise supprimée dans Marvin · `409 record_unresolved` fiche\nliée introuvable, à signaler au support · `409 value_already_used`\nSIREN, SIRET ou LinkedIn déjà portés par une autre entreprise (`field`) ·\n`409 custom_field_settings_changed` réglage d’un champ personnalisé modifié pendant\nl’envoi (`field`), relire `GET …/fields` puis rejouer ·\n`422 missing_required_field` champ obligatoire pour une création (`field`) ·\n`422 unknown_status` statut inconnu de votre compte (`field: status`) ·\n`429` trop de requêtes (120 par minute, par clé et par route) ·\n`503 temporarily_unavailable` base\noccupée, réessayer après le délai de l’en-tête `Retry-After`. En cas d’erreur, aucune\nfiche n’est créée ni modifiée : l’envoi peut être rejoué tel quel.\n\nLes envois d’un même compte sont traités l’un après l’autre : inutile de paralléliser.","security":[{"partnerApiKey":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"slug","required":true},{"schema":{"type":"string"},"in":"path","name":"externalId","required":true}],"responses":{"200":{"description":"Default Response"},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/partner/projects":{"get":{"operationId":"listPartnerProjects","summary":"Lister les projets de recrutement ouverts et les étapes du process","tags":["Partner API"],"security":[{"partnerApiKey":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"stages":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"}},"required":["key","label"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"companyName":{"nullable":true,"type":"string"}},"required":["projectId","title","companyName"],"additionalProperties":false}}},"required":["stages","projects"],"additionalProperties":false}}}},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/partner/candidates":{"post":{"operationId":"createPartnerCandidate","summary":"Créer un candidat (avec son CV) et le rattacher à un projet à une étape","tags":["Partner API"],"description":"Crée (ou réutilise) le candidat et le rattache au projet ciblé à\nl'étape choisie. Idempotent sur `externalId`.\n\n#### Corps de la requête\n`application/json` ou `multipart/form-data` (pour joindre le CV).\n\n| Champ | Requis | Description |\n| --- | --- | --- |\n| `firstName` | oui | Prénom |\n| `lastName` | oui | Nom |\n| `email` | non \\* | E-mail |\n| `phone` | non | Téléphone (E.164) |\n| `linkedinUrl` | non \\* | URL du profil LinkedIn — clé de déduplication |\n| `projectId` | oui | Projet cible (cf. `GET /v1/partner/projects`) |\n| `stage` | non | Étape du process ; défaut = 1ʳᵉ étape du client |\n| `externalId` | non | Votre identifiant interne — clé d’idempotence |\n| `skipPlacementFee` | non | `true` pour créer le process sur une étape « recruté » sans honoraires (sinon 422 `placement_fee_required` chez un client qui facture dans Marvin). JSON uniquement |\n| `cv` | non | **Fichier CV** (PDF, Word ou image, 10 Mo max), en multipart |\n\n\\* `email` **ou** `linkedinUrl` est requis.\n\nLe CV est rattaché aux documents de la fiche et remplit\nautomatiquement le profil (expériences, compétences, coordonnées)\nsans jamais écraser une donnée déjà saisie. `documentId` le confirme\ndans la réponse. Un même CV n’est jamais versé deux fois sur une\nfiche : renvoyer le même profil est sans effet, et un rejeu rattrape\nau contraire un CV qui manquait.\n\n#### Réponses\n`201` candidat créé ou rattaché · `200` rejeu idempotent · `400` corps\ninvalide, étape inconnue ou fichier non supporté · `401` clé absente\nou révoquée · `403` scope manquant · `404` projet inexistant · `413`\nCV trop lourd · `429` débit dépassé.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"firstName":{"type":"string","minLength":1,"maxLength":100},"lastName":{"type":"string","minLength":1,"maxLength":100},"email":{"anyOf":[{"type":"string"},{"type":"string","nullable":true,"enum":[null]}]},"phone":{"anyOf":[{"type":"string"},{"type":"string","nullable":true,"enum":[null]}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"string","nullable":true,"enum":[null]}]},"projectId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"stage":{"type":"string","minLength":1,"maxLength":64},"externalId":{"type":"string","minLength":1,"maxLength":200},"skipPlacementFee":{"type":"boolean"}},"required":["firstName","lastName","projectId"]}},"multipart/form-data":{"schema":{"type":"object","properties":{"firstName":{"type":"string","minLength":1,"maxLength":100},"lastName":{"type":"string","minLength":1,"maxLength":100},"email":{"anyOf":[{"type":"string"},{"type":"string","nullable":true,"enum":[null]}]},"phone":{"anyOf":[{"type":"string"},{"type":"string","nullable":true,"enum":[null]}]},"linkedinUrl":{"anyOf":[{"type":"string"},{"type":"string","nullable":true,"enum":[null]}]},"projectId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"stage":{"type":"string","minLength":1,"maxLength":64},"externalId":{"type":"string","minLength":1,"maxLength":200},"skipPlacementFee":{"type":"boolean"}},"required":["firstName","lastName","projectId"]}}}},"security":[{"partnerApiKey":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"processId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"created":{"type":"boolean"},"deduped":{"type":"boolean"},"documentId":{"nullable":true,"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},"required":["personId","processId","created","deduped","documentId"],"additionalProperties":false}}}},"201":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"processId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"created":{"type":"boolean"},"deduped":{"type":"boolean"},"documentId":{"nullable":true,"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},"required":["personId","processId","created","deduped","documentId"],"additionalProperties":false}}}},"4XX":{"description":"Erreur du client : clé absente ou invalide, droit manquant, requête invalide, ressource introuvable, débit dépassé.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"5XX":{"description":"Erreur du serveur.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/public/feeds/site-web/{tenantSlug}.xml":{"get":{"operationId":"getSiteWebJobsFeedXml","summary":"Flux XML des offres « Site web » (public par slug, sans clé)","tags":["Public","Career","Careers (Public)"],"description":"Flux XML des offres ouvertes du tenant, à transmettre au webmaster du\nsite client. Public par slug (aucune clé), gaté par l’activation du\ncanal « Site web » (sinon `404`). Racine `<source>`, un `<job>` par\noffre. Le `<jobid>` de chaque offre est la clé à reporter dans l’URL\nde candidature `POST /v1/public/careers/{slug}/jobs/{jobid}/apply`.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":120},"in":"path","name":"tenantSlug","required":true}],"responses":{"200":{"description":"Default Response"}}}}},"tags":[{"name":"Careers (Public)","description":"Site carrières public"},{"name":"CRM (Public)","description":"Écriture dans le CRM Marvin (`/v1/public/crm/*`) — auth `X-Marvin-Public-Key` : demandes de contact du site web (scope `public:contacts:write`, MAR-9467), entreprises et contacts envoyés par votre CRM (scope `public:companies:write`, MAR-13719)."},{"name":"Partner API","description":"API partenaire M2M (`/v1/partner/*`) — auth `X-Marvin-Public-Key` scopée par tenant (MAR-4434)."}],"servers":[{"url":"https://api.marvins.ai"}]}