---
title: "Portability (change of PA)"
description: "Move a taxpayer between Plateformes Agreees: import the company from its SIRET, build and parse the normalised inter-PA message (subject + 18-field CSV), and track the request through its states and legal clocks."
canonical: "https://docs.get-flowie.com/guides/portability"
source: "https://docs.get-flowie.com/guides/portability.html"
---

# Portability (change of PA)

Guides

# Portability (change of PA)

A taxpayer may change _Plateforme Agréée_ at any time, and keeps its SIREN/SIRET-based addressing when it does — the PPF annuaire guarantees identifier portability, so nothing downstream has to be re-addressed. What has to happen instead is a hand-over between the two platforms: the **gaining PA** asks, the **losing PA** answers, and the annuaire is flipped on an agreed effective date.

This guide covers both directions, because Flowie plays both roles: incoming (a taxpayer picked Flowie, we issue the request) and outgoing (another PA is porting a taxpayer away, we must answer inside the legal delay). Four endpoints cover the exchange — two to onboard the company, two to speak the inter-PA wire format.

Outside France?

There is no _Plateforme Agréée_ to leave, and no regulated hand-over. What a switch takes in every other European country — the registry edit, the authorisation to re-grant, who keeps the archive — is covered in [Changing platform in Europe](<portability-europe.html>), which also carries a migration request form that needs no API key. 

## The clocks you must beat

Portability is a deadline problem before it is an integration problem. Three rules drive everything below:

  * **Acknowledge within 24 hours** of receiving a request.
  * **Decide within 5 business days** (_jours ouvrés_ , so weekends and _jours fériés_ do not count).
  * **Silence is agreement** (_le silence vaut accord_): past the delay, the port proceeds without the losing platform's approval.



A missed acknowledgement is a compliance failure on its own, independently of whether you would have accepted the port. Timestamp every message you send and receive — the `request_datetime` column of the CSV below exists for exactly that proof.

## 1 · Import the taxpayer from its SIRET

The portal flow gives you one input: the taxpayer's **SIRET**. Everything else is derived. Flowie takes the SIREN from the first nine digits, implies country `FR`, builds the Peppol id `0009:<siren>`, and resolves the legal name — and the current PA — from the PPF annuaire.
[code] 
    curl -X POST …/v1/companies/import \
      -H "Authorization: Bearer $FLOWIE_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "siret": "92137626500017",
        "mode": "portability"
      }'
[/code]

Three outcomes, in priority order:

  * **Import** — you passed a `sovosCompanyId`: the existing company is pulled and its tax id, name and capabilities are taken as authoritative.
  * **Provision** — no company id but an organization is known (request field or the configured default): a managed connection is provisioned from the SIRET.
  * **Local-only** — neither: the company is registered as `pending_verification` and a `company.import.pending` event is written so ops can link the backend later.



Onboarding never hard-fails for want of a backend id, which matters when a port request arrives before the commercial paperwork is done. The call is **idempotent on the SIRET** : re-running it re-syncs the existing registration rather than creating a second one, so a retry after a timeout is safe.

You get back the organization with its `id` and `peppolId`. Supply `companyName` or `countryCode` only to override what the annuaire resolves — omit them and the annuaire wins.

## 2 · Import in bulk

A platform migration moves hundreds of companies at once. `POST /v1/companies/import/batch` takes a list of the very same objects and runs them concurrently, five at a time.
[code] 
    curl -X POST …/v1/companies/import/batch \
      -H "Authorization: Bearer $FLOWIE_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "items": [
          {"siret": "92137626500017"},
          {"siret": "55208131766522"}
        ]
      }'
[/code]

The response is one result row per input item, in input order:
[code] 
    {
      "results": [
        {"index": 0, "status": "imported", "companyId": "org_…", "peppolId": "0009:921376265"},
        {"index": 1, "status": "failed", "error": "siret must be 14 digits"}
      ]
    }
[/code]

**A failed item does not sink the batch.** The call still returns `200` with a partial result set, so check every row rather than the status code — `index` points back at the position in your request. Because each item goes through the same idempotent path, re-sending the whole batch to retry the failures will not duplicate the ones that already succeeded.

## 3 · Send the inter-PA message

The channel the AIFE imposes between platforms is **email** , with a normalised subject, a codified status and an 18-field CSV. `POST /v1/portability/messages` assembles that message, **emails it to the counterparty platform** and records it — so you never hand-format a subject line, never look up where to send it, and never lose the proof that you sent it.
[code] 
    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"
      }'
[/code]

You get back the log id, the subject, the CSV as header plus row and its hash, who it was addressed to and how that address was found, and whether it actually left:
[code] 
    {
      "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": "contact-pdp@esker.com",
      "recipientSource": "registry:ESKER",
      "dispatched": true,
      "reason": "sent",
      "smtpMessageId": "<176…@flowie.fr>",
      "createdAt": "2026-09-01T08:14:02.114000+00:00"
    }
[/code]

You do not need to know the other platform’s email address

Name the counterparty — `losingPaName` when you are the gaining platform, `gainingPaName` when you are the losing one — and it is resolved against the [registry of registered Plateformes Agréées](<#platforms>): the dedicated portability inbox the platform published if it has one, its DGFiP _courriel de contact_ otherwise. `recipientSource` tells you which happened (`registry:<name>`, `explicit` when you passed `to` yourself, or `unresolved` when the name matched nothing). Passing `to` always wins. 

The message is recorded whether or not it is emailed

Sending is gated by a kill-switch and by the SMTP configuration, and a sandbox key never reaches a real platform. When the mail does not go out, `dispatched` is `false` and `reason` says why — `dispatch_disabled`, `not_configured` (no relay or no sender), `no_recipient` (nothing to address it to), `sandbox`, or the SMTP error itself. The row in the message log is written either way, so a dry environment produces the same audit trail minus the email, and a relay outage leaves you with the exact message to re-send rather than a gap. A non-production deployment can also set a recipient override: the message still resolves the real counterparty and records it, but it is delivered to the override address, so testing never emails a real platform. 

How it leaves

The message goes out through Flowie’s own mail path, so it carries the platform’s sender identity and delivery handling rather than a relay only this service knows about; the counterparty replies to the address in `PORTABILITY_CHANNEL_FROM`, which the body states. That path attaches files by reference, so the CSV travels **inline in the body** — byte-identical to the `csvRow` you get back and to what was hashed. If you need the CSV as a real `.csv` file, pin the direct-relay transport (`PORTABILITY_TRANSPORT=smtp`) and it is attached instead. `transport` on the response and in the log says which one carried it. 

The four `messageType` values map to the steps of the exchange — `REQUEST`, `ACK`, `DECISION`, `COMPLETION` — while `state` is your internal state and is translated to the wire status code for you (see [Request states](<#states>)). `directionRole` says who is speaking: `GAINING_PA` or `LOSING_PA`.

The subject grammar is strict and positional:
[code] 
    [PORTABILITE][<MESSAGE_TYPE>][<STATUS_CODE>][SIREN:<9 digits>][REF:<request_ref>]
[/code]

A SIREN that is not exactly nine digits, an unknown message type, or a `requestRef` containing `]` is rejected with `400` before anything is built.

## 4 · Parse an inbound message

The other half: turn a message you received back into structured fields. Pass the subject, and the CSV row when you have it.
[code] 
    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;…"
      }'
[/code]

The response gives you the message type, the wire status code, the internal `state` it maps back to, the SIREN, the request reference, and — when a row was supplied — the 18 parsed columns as `fields`.

**A subject that does not match the grammar returns`400`: dead-letter it, do not open a request from it.** That is the whole point of a normalised subject — anything that fails to parse is not a portability message, and guessing at its intent is how you end up porting the wrong taxpayer. A CSV row with anything other than 18 columns is rejected the same way.

## 5 · Switch the routing at the date d'effet

Agreeing a port changes nothing by itself. What decides where an invoice goes is the taxpayer’s e-invoicing address on the compliance backend — that is what the directory ends up routing on — and `POST /v1/portability/routing` is what moves it:
[code] 
    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"
      }'
[/code]
[code] 
    {
      "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 }
    }
[/code]

The date is the point. As the **gaining** platform you declare the address with `validFrom` = the date d'effet, so a port agreed in August for 1 October does not start pulling invoices in August. As the **losing** platform (`role: "LOSING_PA"`) nothing is deleted: emission stops on the date d'effet while reception stays open until `effectiveDate + 12 months` — the minimal service LFI 2026 requires of the outgoing platform, so flows already in flight still resolve. Override the window with `minimalServiceMonths` when a contract promises longer.

It is idempotent on the SIREN: an address already declared for it is updated, never duplicated. If the organization has more than one connection you must name it with `connectionId` — choosing one for you is how a port lands on the wrong company. And when you are onboarding the taxpayer in the same move, `POST /v1/companies/import` now takes the same `effectiveDate` and carries it onto the address it creates.

## The PA address book

A port request is only as sendable as your knowledge of where the other platform reads its mail. `GET /v1/portability/platforms` is that address book — every operator the DGFiP has registered, with the address a portability message should actually go to:
[code] 
    curl "…/v1/portability/platforms?q=esker" \
      -H "Authorization: Bearer $FLOWIE_KEY"
[/code]
[code] 
    {
      "data": [
        {
          "name": "ESKER",
          "website": "https://www.esker.fr/",
          "email": "info@esker.fr",
          "portabilityEmail": "contact-pdp@esker.com",
          "contactEmail": "contact-pdp@esker.com",
          "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"
    }
[/code]

It merges the two official DGFiP lists — operators meeting every condition (`status: "registered"`) and those still awaiting the interoperability tests (`"pending_interop"`) — with the dedicated portability inboxes platforms circulated among themselves. `contactEmail` is the one that matters: the dedicated address when there is one, the generic DGFiP contact otherwise. Filter with `q` (name, email or website) and `status`.

The list moves every week as platforms are registered, so the payload carries its own `snapshotDate` and the `source` it was built from. If you need the authoritative list at this instant, that source is it.

## Request states

A portability request moves through eight states. Each maps to a short wire code carried in the subject and in the CSV, so a counterparty can machine-route on it:

State| Wire code| What it means  
---|---|---  
`received`| `REQ`| Request created or inbound message parsed. Starts the 24 h and 5-day clocks.  
`acknowledged`| `ACK`| Receipt confirmed inside 24 h. The first SLA is met.  
`accepted`| `ACC`| Decided in favour of the port, inside the 5-day window.  
`rejected`| `REJ`| Refused. A reason is expected — fill `reasonCode` and `reasonText`.  
`auto_accepted`| `TAC`| Tacit agreement: the deadline passed with no decision, so _silence vaut accord_.  
`executing`| `MIG`| Effective date reached; the annuaire switch is in progress.  
`completed`| `CMP`| The annuaire confirms the new platform is active.  
`failed`| `ERR`| Transport or annuaire error.  
  
Send the internal state in `state`; you never write the wire code yourself. On the way back in, `parse` resolves the code to the state for you. An unknown state is a `400`, not a silent pass-through.

The two decision states differ in who fires them. `accepted` and `rejected` are a deliberate act inside the window; `auto_accepted` is what happens _to_ the silent party when the window closes. If you are the losing platform, `TAC` arriving on your request is the signal that you missed the deadline.

## Tracking a request

Company onboarding writes to the event log, which is where you follow a migration today:
[code] 
    curl "…/v1/events?type=company.imported&limit=100" \
      -H "Authorization: Bearer $FLOWIE_KEY"
[/code]

Two event types are written by the import path: `company.imported` when the registration is active, and `company.import.pending` when the company landed local-only and still needs an ops link. Each carries the company id, the Peppol id, the SIRET, the SIREN and the country, which is enough to reconcile a bulk migration row by row. The listing is cursor-paginated — keep passing the returned `cursor` until `hasMore` is `false`.

These two are event-log entries, not webhook events

They are readable through [`GET /v1/events`](<../reference/index.html#list-events>) but they are not in the webhook catalogue, so subscribing a webhook to `company.imported` will not deliver anything. Poll the event log for now; the [webhook reference](<../reference/webhooks.html#events>) lists what does get pushed. 

Every message you send is logged, and the log is queryable:
[code] 
    curl "…/v1/portability/messages?requestRef=POR-2026-000123" \
      -H "Authorization: Bearer $FLOWIE_KEY"
[/code]

`GET /v1/portability/messages` lists them newest first, scoped to your organization, filterable by `requestRef`, `siren`, `state`, `messageType` and `dispatched` — so one reference replays a whole exchange, and `dispatched=false` finds the messages that never left and need re-sending. It is cursor-paginated like the rest of the API and returns a real `total`.

`GET /v1/portability/messages/{id}` returns the proof bundle for one message: the exact CSV row that left with its `csvSha256`, the recipient and how it was resolved, the SMTP message id, and `annuaire` — what the PPF _annuaire_ answered for that taxpayer at the moment we sent. The annuaire itself is read-only to us: a port is not written into it, it is written into the routing address ([step 5](<#execute>)) and propagates from there. The snapshot is what lets you prove the before and check the after — compare it with what the annuaire says once propagation has happened, which is a call of its own:
[code] 
    curl "…/v1/portability/annuaire/921376265"   -H "Authorization: Bearer $FLOWIE_KEY"
[/code]

`GET /v1/portability/annuaire/{siren}` answers with the line that decides where this taxpayer's invoices go: `currentPaMatricule` (the platform routing it — `9998` is the PPF default, meaning nobody has been declared and there may be nothing to port), `effectiveFrom`, an `effectiveTo` when a departure is already scheduled, and `isFlowie` once the switch has propagated to us. Read it before a port to know who you are porting away from, and after to know whether it landed.

There is still no server-side SLA timer

Nothing fires an acknowledgement for you and nothing flips a request to _silence vaut accord_ when the fifth business day passes. The message log gives you the timestamps to prove the delays; the clocks themselves are still yours to run. 

## The 18-field CSV

One header row and one data row, semicolon-delimited, in this exact order:

#| Column| Notes  
---|---|---  
1| `request_ref`| Your stable reference for the request.  
2| `message_type`| REQUEST / ACK / DECISION / COMPLETION.  
3| `status_code`| The wire code from the table above.  
4| `direction_role`| GAINING_PA or LOSING_PA.  
5| `taxpayer_siren`| Nine digits.  
6| `taxpayer_siret`| Fourteen digits.  
7| `taxpayer_name`| Legal name.  
8| `gaining_pa_id`| Operator code or SIREN.  
9| `gaining_pa_name`|   
10| `losing_pa_id`|   
11| `losing_pa_name`|   
12| `effective_date`|  _Date d'effet_ , ISO-8601.  
13| `transferred_addresses`| Routing ids, pipe-joined. Send a list, get a list back.  
14| `mandate_ref`| The _mandat de désignation_.  
15| `mandate_signatory`| Legal representative.  
16| `request_datetime`| ISO-8601 with an explicit timezone — the proof of delay. Defaults to now (UTC).  
17| `reason_code`| Expected on a rejection.  
18| `reason_text`|   
  
Only `transferred_addresses` repeats, and it uses `|` inside the cell so it never collides with the delimiter. Missing values are written as empty strings, never omitted — the column count is what the parser validates.

## What is still provisional

The wire format is a working model, not a certified one

The subject grammar, the status codes and the CSV column set are Flowie's reading of the process pending publication of the AIFE annex of 15/07, which is not yet publicly indexed. They are centralised in a single module precisely so that reconciliation is one well-tested edit rather than a scattered migration. 

Build against them — that is what they are for — but treat the exact strings as subject to change, keep your own `requestRef` as the key you join on, and re-read the [changelog](<../changelog.html>) before you go live. What will not change is the shape: a normalised subject, a codified status, eighteen columns, and clocks that start the moment a request lands.

## Next

  * [Import a company](<../reference/index.html#import-company>) — full parameter list and responses.
  * [Portability endpoints](<../reference/index.html#portability>) — send, follow and parse, in the API reference.
  * [France compliance](<../compliance/fr/index.html>) — the wider PPF and PA picture.
  * [Events](<../reference/index.html#list-events>) — the log you poll to follow a migration.
