Flowie
Guides

Portabilité (changement de PA)

Un assujetti peut changer de Plateforme Agréée à tout moment, et il conserve son adressage fondé sur le SIREN/SIRET en le faisant — l'annuaire du PPF garantit la portabilité de l'identifiant, donc rien en aval n'a à être réadressé. Ce qui doit avoir lieu, en revanche, c'est une reprise entre les deux plateformes : la PA entrante demande, la PA sortante répond, et l'annuaire bascule à une date d'effet convenue.

Ce guide couvre les deux sens, parce que Flowie joue les deux rôles : entrant (un assujetti a choisi Flowie, nous émettons la demande) et sortant (une autre PA nous reprend un assujetti, nous devons répondre dans le délai légal). Quatre endpoints couvrent l'échange — deux pour embarquer la société, deux pour parler le format de fil inter-PA.

Hors de France ?
Il n'y a pas de Plateforme Agréée à quitter, ni de reprise encadrée. Ce qu'un changement demande dans tous les autres pays d'Europe — l'écriture dans l'annuaire, l'autorisation à redonner, qui garde l'archive — est traité dans Changer de plateforme en Europe, qui porte aussi un formulaire de demande de migration ne nécessitant aucune clé API.

Les délais à tenir

La portabilité est un problème d'échéances avant d'être un problème d'intégration. Trois règles commandent tout ce qui suit :

Un accusé de réception manqué est un manquement en soi, indépendamment de la décision que vous auriez prise. Horodatez chaque message émis et reçu — la colonne request_datetime du CSV ci-dessous existe exactement pour cette preuve.

1 · Importer l'assujetti depuis son SIRET

Le parcours portail ne vous donne qu'une entrée : le SIRET de l'assujetti. Tout le reste en découle. Flowie prend le SIREN sur les neuf premiers chiffres, en déduit le pays FR, construit l'identifiant Peppol 0009:<siren>, et résout la raison sociale — ainsi que la PA actuelle — depuis l'annuaire du PPF.

curl -X POST …/v1/companies/import \
  -H "Authorization: Bearer $FLOWIE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "siret": "92137626500017",
    "mode": "portability"
  }'

Trois issues, par ordre de priorité :

L'embarquement n'échoue jamais durement faute d'identifiant backend, ce qui compte quand une demande de reprise arrive avant que le contractuel soit bouclé. L'appel est idempotent sur le SIRET : le rejouer resynchronise l'enregistrement existant au lieu d'en créer un second, donc un retry après timeout est sûr.

Vous récupérez l'organisation avec son id et son peppolId. Ne renseignez companyName ou countryCode que pour surcharger ce que résout l'annuaire — omettez-les et l'annuaire l'emporte.

2 · Importer en masse

Une migration de plateforme déplace des centaines de sociétés d'un coup. POST /v1/companies/import/batch prend une liste des mêmes objets et les traite concurremment, cinq à la fois.

curl -X POST …/v1/companies/import/batch \
  -H "Authorization: Bearer $FLOWIE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {"siret": "92137626500017"},
      {"siret": "55208131766522"}
    ]
  }'

La réponse contient une ligne de résultat par élément, dans l'ordre d'entrée :

{
  "results": [
    {"index": 0, "status": "imported", "companyId": "org_…", "peppolId": "0009:921376265"},
    {"index": 1, "status": "failed", "error": "siret must be 14 digits"}
  ]
}

Un élément en échec ne coule pas le lot. L'appel renvoie quand même 200 avec un résultat partiel : contrôlez chaque ligne plutôt que le code de statut — index renvoie à la position dans votre requête. Comme chaque élément emprunte le même chemin idempotent, renvoyer le lot entier pour rejouer les échecs ne dupliquera pas ceux qui étaient déjà passés.

3 · Envoyer le message inter-PA

Le canal que l'AIFE impose entre plateformes est l'e-mail, avec un objet normalisé, un statut codifié et un CSV à 18 champs. POST /v1/portability/messages assemble ce message, l'envoie par e-mail à la plateforme d'en face et le consigne — vous n'avez jamais à formater un objet à la main, ni à chercher où l'envoyer, ni à perdre la preuve que vous l'avez envoyé.

curl -X POST …/v1/portability/messages \
  -H "Authorization: Bearer $FLOWIE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messageType": "REQUEST",
    "state": "received",
    "requestRef": "POR-2026-000123",
    "directionRole": "GAINING_PA",
    "taxpayerSiren": "921376265",
    "taxpayerSiret": "92137626500017",
    "losingPaName": "ESKER",
    "effectiveDate": "2026-09-01",
    "mandateRef": "MDT-2026-8891"
  }'

Vous récupérez l'identifiant de journal, l'objet, le CSV sous forme d'en-tête plus ligne et son empreinte, à qui le message a été adressé et comment cette adresse a été trouvée, et s'il est bien parti :

{
  "id": "pmsg_9f2c7a1d4b8e4c0f9a6d3e2b1c7f5a80",
  "subject": "[PORTABILITE][REQUEST][REQ][SIREN:921376265][REF:POR-2026-000123]",
  "messageType": "REQUEST",
  "statusCode": "REQ",
  "state": "received",
  "filename": "POR-2026-000123-REQ.csv",
  "csvHeader": "request_ref;message_type;…",
  "csvRow": "POR-2026-000123;REQUEST;REQ;…",
  "csvSha256": "6b1f…",
  "to": "[email protected]",
  "recipientSource": "registry:ESKER",
  "dispatched": true,
  "reason": "sent",
  "smtpMessageId": "<176…@flowie.fr>",
  "createdAt": "2026-09-01T08:14:02.114000+00:00"
}
Vous n'avez pas besoin de connaître l'adresse e-mail de l'autre plateforme
Nommez la contrepartie — losingPaName quand vous êtes la plateforme entrante, gainingPaName quand vous êtes la sortante — et elle est résolue depuis l'annuaire des Plateformes Agréées immatriculées : la boîte dédiée à la portabilité que la plateforme a publiée si elle en a une, son courriel de contact DGFiP sinon. recipientSource vous dit ce qui s'est passé (registry:<nom>, explicit si vous avez passé to vous-même, ou unresolved si le nom ne correspond à rien). to l'emporte toujours.
Le message est consigné qu'il parte ou non
L'envoi est conditionné par un coupe-circuit et par la configuration SMTP, et une clé bac à sable n'atteint jamais une vraie plateforme. Quand le mail ne part pas, dispatched vaut false et reason dit pourquoi — dispatch_disabled, not_configured (pas de relais ou pas d'expéditeur), no_recipient (aucune adresse à qui l'adresser), sandbox, ou l'erreur SMTP elle-même. La ligne du journal est écrite dans tous les cas : un environnement à sec produit la même piste d'audit sans l'e-mail, et une panne de relais vous laisse le message exact à ré-envoyer plutôt qu'un trou. Un environnement hors production peut aussi forcer un destinataire de repli : le message résout et consigne la vraie contrepartie, mais il est remis à l'adresse de repli — tester n'écrit jamais à une vraie plateforme.
Par où il part
Le message emprunte le circuit d'envoi de Flowie : il porte donc l'identité d'expédition et la délivrabilité de la plateforme, et non un relais connu de ce seul service ; la contrepartie répond à l'adresse de PORTABILITY_CHANNEL_FROM, que le corps du message indique. Ce circuit joint les fichiers par référence : le CSV voyage donc en ligne dans le corps — identique octet pour octet au csvRow retourné et à ce qui a été haché. S'il vous faut le CSV en pièce jointe .csv, forcez le relais direct (PORTABILITY_TRANSPORT=smtp). transport, dans la réponse et dans le journal, dit lequel a porté le message.

Les quatre valeurs de messageType correspondent aux étapes de l'échange — REQUEST, ACK, DECISION, COMPLETION — tandis que state est votre état interne, traduit pour vous en code de statut de fil (voir États de la demande). directionRole dit qui parle : GAINING_PA ou LOSING_PA.

La grammaire de l'objet est stricte et positionnelle :

[PORTABILITE][<MESSAGE_TYPE>][<STATUS_CODE>][SIREN:<9 chiffres>][REF:<request_ref>]

Un SIREN qui ne fait pas exactement neuf chiffres, un type de message inconnu, ou un requestRef contenant ] est rejeté en 400 avant toute construction.

4 · Analyser un message entrant

L'autre moitié : retransformer un message reçu en champs structurés. Passez l'objet, et la ligne CSV quand vous l'avez.

curl -X POST …/v1/portability/messages/parse \
  -H "Authorization: Bearer $FLOWIE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "[PORTABILITE][DECISION][ACC][SIREN:921376265][REF:POR-2026-000123]",
    "csvRow": "POR-2026-000123;DECISION;ACC;LOSING_PA;921376265;…"
  }'

La réponse vous donne le type de message, le code de statut de fil, l'state interne correspondant, le SIREN, la référence de la demande, et — si une ligne a été fournie — les 18 colonnes analysées dans fields.

Un objet qui ne respecte pas la grammaire renvoie 400 : mettez-le en rebut, n'ouvrez pas de demande à partir de lui. C'est tout l'intérêt d'un objet normalisé — ce qui ne s'analyse pas n'est pas un message de portabilité, et deviner son intention est le meilleur moyen de faire migrer le mauvais assujetti. Une ligne CSV qui n'a pas exactement 18 colonnes est rejetée de la même façon.

5 · Basculer le routage à la date d'effet

S'accorder sur une reprise ne change rien en soi. Ce qui décide où part une facture, c'est l'adresse de facturation électronique de l'assujetti côté conformité — c'est elle que le répertoire finit par router — et POST /v1/portability/routing est ce qui la déplace :

curl -X POST …/v1/portability/routing \
  -H "Authorization: Bearer $FLOWIE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "organizationId": "019c76b2-9c94-7000-8cb6-ef104afb6093",
    "siren": "921376265",
    "siret": "92137626500018",
    "effectiveDate": "2026-10-01",
    "role": "GAINING_PA"
  }'
{
  "organizationId": "019c76b2-9c94-7000-8cb6-ef104afb6093",
  "connectionId": "conn_7Yb3…",
  "role": "GAINING_PA",
  "siren": "921376265",
  "effectiveDate": "2026-10-01",
  "serviceUntil": null,
  "created": true,
  "address": { "id": "addr_2Kd9…", "siren": "921376265", "active": true }
}

Tout est dans la date. En tant que plateforme entrante, vous déclarez l'adresse avec validFrom = la date d'effet : une reprise conclue en août pour le 1er octobre ne capte pas les factures dès août. En tant que plateforme sortante (role: "LOSING_PA"), rien n'est supprimé : l'émission s'arrête à la date d'effet tandis que la réception reste ouverte jusqu'à date d'effet + 12 mois — le service minimal que la LFI 2026 impose à la plateforme sortante, pour que les flux en cours aboutissent. Ajustez la fenêtre avec minimalServiceMonths si un contrat promet plus long.

L'appel est idempotent sur le SIREN : une adresse déjà déclarée pour lui est mise à jour, jamais dupliquée. Si l'organisation a plusieurs connexions, vous devez désigner la bonne avec connectionId — en choisir une à votre place, c'est ainsi qu'une reprise atterrit sur la mauvaise société. Et si vous embarquez l'assujetti dans le même mouvement, POST /v1/companies/import accepte désormais la même effectiveDate et la reporte sur l'adresse qu'il crée.

L'annuaire des PA

Une demande de reprise ne vaut que par ce que vous savez de l'endroit où l'autre plateforme lit son courrier. GET /v1/portability/platforms est cet annuaire — tous les opérateurs immatriculés par la DGFiP, avec l'adresse à laquelle un message de portabilité doit réellement partir :

curl "…/v1/portability/platforms?q=esker" \
  -H "Authorization: Bearer $FLOWIE_KEY"
{
  "data": [
    {
      "name": "ESKER",
      "website": "https://www.esker.fr/",
      "email": "[email protected]",
      "portabilityEmail": "[email protected]",
      "contactEmail": "[email protected]",
      "registeredOn": "2025-10-14",
      "status": "registered"
    }
  ],
  "total": 1,
  "source": "https://www.impots.gouv.fr/je-consulte-la-liste-des-plateformes-agreees",
  "snapshotDate": "2026-08-20"
}

Il fusionne les deux listes officielles de la DGFiP — les opérateurs remplissant toutes les conditions (status: "registered") et ceux encore en attente des tests d'interopérabilité ("pending_interop") — avec les boîtes de portabilité dédiées que les plateformes se sont communiquées entre elles. contactEmail est celle qui compte : l'adresse dédiée quand elle existe, le contact DGFiP générique sinon. Filtrez avec q (nom, e-mail ou site) et status.

La liste bouge chaque semaine au fil des immatriculations : la réponse porte donc sa propre snapshotDate et la source dont elle est issue. S'il vous faut la liste faisant foi à l'instant t, c'est cette source.

États de la demande

Une demande de portabilité traverse huit états. Chacun correspond à un code court porté par l'objet et par le CSV, pour qu'une contrepartie puisse router automatiquement dessus :

ÉtatCode de filSignification
receivedREQDemande créée ou message entrant analysé. Démarre les compteurs 24 h et 5 jours.
acknowledgedACKRéception confirmée sous 24 h. Le premier délai est tenu.
acceptedACCDécision favorable, dans la fenêtre de 5 jours.
rejectedREJRefus. Un motif est attendu — renseignez reasonCode et reasonText.
auto_acceptedTACAccord tacite : le délai est passé sans décision, donc le silence vaut accord.
executingMIGDate d'effet atteinte ; la bascule de l'annuaire est en cours.
completedCMPL'annuaire confirme que la nouvelle plateforme est active.
failedERRErreur de transport ou d'annuaire.

Envoyez l'état interne dans state ; vous n'écrivez jamais le code de fil vous-même. Au retour, parse résout le code vers l'état pour vous. Un état inconnu donne un 400, pas un passage silencieux.

Les deux états de décision diffèrent par qui les déclenche. accepted et rejected sont un acte délibéré dans la fenêtre ; auto_accepted est ce qui arrive à la partie silencieuse quand la fenêtre se referme. Si vous êtes la plateforme sortante, un TAC sur votre demande est le signal que vous avez manqué l'échéance.

Suivre une demande

L'embarquement de société écrit dans le journal d'événements, et c'est là qu'on suit une migration aujourd'hui :

curl "…/v1/events?type=company.imported&limit=100" \
  -H "Authorization: Bearer $FLOWIE_KEY"

Deux types d'événements sont écrits par le chemin d'import : company.imported quand l'enregistrement est actif, et company.import.pending quand la société est restée locale et attend un rattachement par les ops. Chacun porte l'identifiant de la société, l'identifiant Peppol, le SIRET, le SIREN et le pays, ce qui suffit à réconcilier une migration de masse ligne à ligne. La liste est paginée par curseur — continuez à passer le cursor retourné jusqu'à ce que hasMore soit false.

Ce sont des entrées de journal, pas des événements webhook
Elles se lisent via GET /v1/events mais ne figurent pas au catalogue des webhooks : abonner un webhook à company.imported ne délivrera rien. Interrogez le journal pour l'instant ; la référence webhooks liste ce qui est réellement poussé.

Chaque message envoyé est consigné, et le journal s'interroge :

curl "…/v1/portability/messages?requestRef=POR-2026-000123" \
  -H "Authorization: Bearer $FLOWIE_KEY"

GET /v1/portability/messages les liste du plus récent au plus ancien, limités à votre organisation, filtrables par requestRef, siren, state, messageType et dispatched — une référence rejoue donc tout un échange, et dispatched=false retrouve les messages qui ne sont jamais partis et qu'il faut ré-envoyer. La pagination est par curseur comme ailleurs dans l'API, avec un total réel.

GET /v1/portability/messages/{id} renvoie le dossier de preuve d'un message : la ligne CSV exacte qui est partie avec son csvSha256, le destinataire et la façon dont il a été résolu, l'identifiant SMTP du message, et annuaire — ce que l'annuaire PPF répondait pour cet assujetti au moment de l'envoi. L'annuaire nous est en lecture seule : une reprise ne s'y écrit pas, elle s'écrit dans l'adresse de routage (étape 5) et s'y propage. L'instantané sert à prouver l'avant et vérifier l'après, en le comparant à ce que dit l'annuaire une fois la propagation faite — ce qui fait l'objet d'un appel dédié :

curl "…/v1/portability/annuaire/921376265"   -H "Authorization: Bearer $FLOWIE_KEY"

GET /v1/portability/annuaire/{siren} renvoie la ligne qui décide où partent les factures de cet assujetti : currentPaMatricule (la plateforme qui le route — 9998 est le défaut PPF, donc personne n'est déclaré et il n'y a peut-être rien à reprendre), effectiveFrom, un effectiveTo quand un départ est déjà programmé, et isFlowie une fois la bascule propagée chez nous. À lire avant une reprise pour savoir à qui on la prend, et après pour savoir si elle a pris.

Il n'y a toujours pas de minuterie côté serveur
Rien n'envoie d'accusé de réception à votre place et rien ne bascule une demande en silence vaut accord au cinquième jour ouvré. Le journal des messages vous donne les horodatages qui prouvent les délais ; les délais eux-mêmes restent à votre charge.

Le CSV à 18 champs

Une ligne d'en-tête et une ligne de données, séparées par des points-virgules, dans cet ordre exact :

#ColonneNotes
1request_refVotre référence stable pour la demande.
2message_typeREQUEST / ACK / DECISION / COMPLETION.
3status_codeLe code de fil du tableau ci-dessus.
4direction_roleGAINING_PA ou LOSING_PA.
5taxpayer_sirenNeuf chiffres.
6taxpayer_siretQuatorze chiffres.
7taxpayer_nameRaison sociale.
8gaining_pa_idCode opérateur ou SIREN.
9gaining_pa_name
10losing_pa_id
11losing_pa_name
12effective_dateDate d'effet, ISO-8601.
13transferred_addressesIdentifiants de routage, joints par des barres verticales. Envoyez une liste, récupérez une liste.
14mandate_refLe mandat de désignation.
15mandate_signatoryReprésentant légal.
16request_datetimeISO-8601 avec fuseau explicite — la preuve du délai. Vaut maintenant (UTC) par défaut.
17reason_codeAttendu sur un refus.
18reason_text

Seule transferred_addresses se répète, et elle utilise | à l'intérieur de la cellule pour ne jamais entrer en collision avec le séparateur. Les valeurs manquantes sont écrites comme chaînes vides, jamais omises — c'est le nombre de colonnes que l'analyseur valide.

Ce qui reste provisoire

Le format de fil est un modèle de travail, pas un format certifié
La grammaire de l'objet, les codes de statut et l'ensemble des colonnes du CSV sont la lecture que Flowie fait du processus, en attendant la publication de l'annexe AIFE du 15/07, qui n'est pas encore indexée publiquement. Ils sont centralisés dans un seul module, précisément pour que la réconciliation soit une modification unique et bien testée plutôt qu'une migration éparpillée.

Développez contre eux — c'est à cela qu'ils servent — mais traitez les chaînes exactes comme susceptibles de changer, gardez votre propre requestRef comme clé de jointure, et relisez le changelog avant la mise en production. Ce qui ne changera pas, c'est la forme : un objet normalisé, un statut codifié, dix-huit colonnes, et des délais qui partent dès qu'une demande arrive.

La suite