Skip to main content
Your institution is a direct participant: it is present at Pix’s national settlement system (SPI) in its own right, and this plugin is how it gets there. An indirect participant is a smaller institution that has no connection of its own and reaches SPI through yours. It is a client of your institution rather than a client of the rail. Nothing about that relationship exists at BACEN. It exists in your tenant’s registry, and you create it with POST /v1/indirects.

What registering one creates


Registering an indirect participant does more than write a row. The plugin also creates that institution’s settlement position in Midaz: a ledger account whose alias is @pi_ followed by the institution’s ISPB, the eight-digit code BACEN assigns to every participant. An indirect on ISPB 12345678 settles at @pi_12345678. That position is where that institution’s money sits. The response returns it as piAccountAlias, and the alias is derived from ispb — you never choose it, and a client never creates the account. One position per institution is the whole point. A credit that arrives for one indirect lands only on that indirect’s account, so one institution’s money is never mixed with another’s and never with your own. That segregation is the property the registry exists to provide.
The alias uses an underscore, not a slash: @pi_12345678. Midaz rejects a slash in a user alias, so a path-shaped name resolves to nothing.

Before you register anyone


Indirect participation is off by default. The per-tenant systemplane flag plugin-br-pix-jd.indirects/enabled must be on, and its default value is false. What the flag actually gates is worth knowing, because the two halves behave differently:
  • The management API works with the flag off. You can register participants, list them and read one before enablement. Onboarding ahead of go-live is deliberate.
  • No money moves with the flag off. Resolution — the step that decides which indirect an incoming credit belongs to — reports the feature as disabled, so an inbound credit is never routed to a @pi account and no indirect can originate an outbound order.
Two operations refuse outright while the flag is off, with 422 PIX-0111: the own-QR-certificate write on PATCH /v1/indirects/{indirectId}, and GET /v1/indirects/{indirectId}/jwks.

Registering one


1

Turn the feature on for the tenant

Set plugin-br-pix-jd.indirects/enabled to true. Registration works without it, but nothing settles until it is on.
2

POST /v1/indirects

Send the institution’s name, its ispb, and a delivery block holding endpointUrl (an HTTPS URL) and secret. The answer is 201 and carries indirectId — the identifier every later call routes by — and piAccountAlias.
3

Nothing to poll — a 201 means ready

Every step runs before the registry row is written: the ISPB uniqueness check, the optional JD directory check, then the creation of the @pi_{ispb} account in Midaz. Only then is the row written, and it is written ACTIVE. There is no intermediate state and nothing to wait for.
4

On a failure, fix the cause and POST again

A failed step answers a coded error naming the step, and no registry row is written, so the ISPB is still free. Fix the cause and POST again — re-posting is the recovery, and there is no separate retry route because nothing was left half-written to resume.
Registration is atomic: a 201 means ready, and a failure leaves no registry row. status on a 201 is always ACTIVE. If you are writing a client that polls until the participant becomes routable, delete that loop — it is waiting for a transition that cannot happen. The one thing a failure can leave behind is the settlement account, when that step succeeded and a later one did not. It is not rolled back, and it is harmless: nothing routes to a @pi account without an ACTIVE registry row pointing at it, and creating the account is idempotent by alias, so your next attempt adopts the same account instead of making a second one. What a failure never leaves is a registry row or a held ISPB. The response still carries a provisioning object with a failedStep field. It is legacy and always null for anything this service registers, because a failed registration leaves no row to carry a marker. The lifecycle states are ACTIVE, SUSPENDED and CLOSED.
PENDING_PROVISIONING is a retired state with a sharp edge. Nothing is registered into it any more, and it is still accepted as a filter on GET /v1/indirects so that rows written before registration became atomic still read back as themselves. But that state has no lifecycle transitions in either direction — a legacy row cannot be suspended and cannot be closed; every action on it is refused 409 PIX-0094.Such a row is readable, not routable, and it still holds its ISPB against the open-uniqueness index. So it blocks any fresh registration of that institution, and no API call can free it. Clearing one is an operator and data decision, deliberately not an API operation. If you inherited a tenant from before this change, run GET /v1/indirects?status=PENDING_PROVISIONING once to find out whether you have any.

How money reaches an indirect participant


Follow one inbound Pix. Nothing in this path is an endpoint the indirect calls; it is your plugin doing the work.
  1. A Pix arrives for an account held at the indirect institution and reaches your direct participation.
  2. Resolution finds the indirect by ISPB, and only if it is ACTIVE. A resolution miss is never cached, so an indirect that just went active is not shadowed by an earlier negative answer.
  3. The credit is booked on @pi_{ispb}. The plugin records the movement against that settlement position and links its own transaction record to the indirect.
  4. A signed notice is pushed to the delivery.endpointUrl you registered. This is the plugin calling the institution, not the reverse.
The notice carries two headers the receiving institution verifies: Delivery is at-least-once. The same notice can be re-sent after a retryable failure or a crash between the send and the bookkeeping, and the endToEndId is stable across attempts — so a receiver must deduplicate by endToEndId and treat a repeated payload as already handled.
The stored secret is write-only. Every read of an indirect returns delivery.secret as ***, so a lost secret is replaced with a PATCH, never recovered.

When the notice never arrives


The push can fail permanently — retries exhausted, or the endpoint answers 4xx, after which the notice is marked invalid and never tried again. The money is booked correctly either way. The consequence is narrower and worse than it sounds: the movement is real, and the institution does not know about it. GET /v1/indirects/{indirectId}/transactions is what closes that gap. It returns the movements that actually settled on one indirect’s @pi_{ispb} account, which makes it the authoritative record rather than the notice. Sweeping it on a schedule is how an institution stops depending on delivery having worked, and it is also the only way to re-read a window after an outage of its own. Four things about that feed decide whether a reconciliation is correct:
  • since is required, as an RFC 3339 instant. The window is half-open — since is inclusive, until is exclusive — so passing one window’s until as the next window’s since walks forward without double-counting a movement or dropping one in the gap.
  • amount is in centavos, as an integer. 1050 is R$ 10,50. Never a decimal, never reais.
  • Rows come back oldest first, and the cursor is the only correct way to page. Pass the previous page’s nextCursor back as cursor; a null nextCursor means that page was the last in the window. Advancing since past the last row’s timestamp instead will silently skip every other movement booked in that same instant.
  • limit defaults to 25 and is clamped to 100. A larger value is quietly reduced rather than rejected, so do not read the page size you asked for as the page size you got.
Reading the feed changes nothing and re-sends no notice.

Hosting the QR code under the institution’s own certificate


By default a dynamic QR code issued for an indirect is signed and published under your direct participation. An indirect can instead publish its own: set qrCertificate.ownCertificate with a publicBaseUrl on PATCH /v1/indirects/{indirectId}, and the signed document is served from the institution’s own host. The institution cannot produce the validation keys itself, because JDPI holds the certificate and does the signing. GET /v1/indirects/{indirectId}/jwks returns the JWK Set for that indirect so the institution can publish it on its own host for payer PSPs to validate against. Fetch it when you configure the certificate and again whenever it rotates. Lifecycle status deliberately does not gate that read: a suspended or closed indirect still has live QR codes in circulation, and withholding the key would break their validation.

Suspending, reactivating and closing


PATCH /v1/indirects/{indirectId} carries at most one lifecycle actionsuspend, reactivate or close — and each one writes the ledger as well as the registry.
  • suspend disables both sending and receiving on the indirect’s @pi account. The ledger itself refuses that participant’s money, which is why a suspension holds even on a replica whose resolution cache is still stale. reactivate re-enables both.
  • close is irreversible. CLOSED is terminal, and afterwards every field change on the row is refused too.
A close never deletes anything. The @pi account is permanently blocked but kept, so a later re-registration of the same ISPB reuses the historical account.
There is no DELETE on /v1/indirects, and CLOSED is terminal. The registry’s only exit is close, which keeps the row and the account. An indirect registered by mistake against a live tenant stays in that tenant’s registry for good, and its ISPB is held against the open-uniqueness constraint until it is closed. Every institution code you spend is permanent — verify name, ispb and the delivery endpoint before you call.

What goes wrong


Where to go next