Skip to main content
Your institution is a direct participant. It has its own presence at the national Pix settlement system (SPI), and this plugin is its way there. An indirect participant is a smaller institution without a connection of its own. It reaches SPI through yours. It is a client of your institution, not a client of the rail. That relationship does not exist at BACEN. It exists in your tenant’s registry, and you create it with POST /v1/indirects. The direct and indirect model explains where an indirect participant’s money lives: the settlement account at the Banco Central, the limit at JD, and the position in your ledger.
This page explains what a hosted participation is and how it behaves. Hosting indirect participants covers the provisioning: the delivery-secret encryption key, the hosting posture, and the registration call. Its numbering continues from the setup page. Of those steps, only the encryption key refuses every registration on its own, so provision it first. The hosting posture gates the money paths, not the registry, so you can register participants before you turn it on.

Who is responsible for what


Most operational mistakes here come from a search for the answer in the wrong column. In practice, a Pix for a customer of an indirect participant stops with you. It lands in an account that represents that institution’s position. You do not know, and cannot know, which of its customers owns the money. The indirect participant credits its customer in its own core, after it receives your notice.

What registering one creates


A registration does more than write a row. The plugin also creates that institution’s settlement position in Midaz. That is a ledger account whose alias starts with @pi_ and ends with the institution’s ISPB, the eight-digit code that BACEN gives to every participant. An indirect on ISPB 12345678 settles at @pi_12345678. That position holds that institution’s money. The response returns it as piAccountAlias. The plugin derives the alias from ispb. You never choose it, and a client never creates the account. One position per institution is the whole point. A credit for one indirect lands only on that indirect’s account. One institution’s money therefore never mixes with another’s, and never with your own. The registry exists to give that segregation.
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. The flag gates two halves, and they behave differently:
  • The management API works with the flag off. You can register participants, list them, and read one before enablement. Onboarding before go-live is deliberate.
  • No money moves with the flag off. Resolution decides which indirect an incoming credit belongs to. With the flag off, it reports the feature as disabled. No inbound credit goes 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. One is the own-QR-certificate write on PATCH /v1/indirects/{indirectId}. The other is GET /v1/indirects/{indirectId}/jwks.
The read fails closed. When a money path “goes back to direct behavior” and nobody changed the key, this is the cause. Look at the systemplane.

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 with endpointUrl and secret. The answer is 201. It carries indirectId, the identifier that every later call routes by, and piAccountAlias.
3

Nothing to poll: a 201 means ready

Every check runs before the plugin writes the registry row. First comes the ISPB uniqueness check, then the optional JD directory check, then the creation of the @pi_{ispb} account in Midaz. Only then does the plugin write the row, with status 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 that names the step. The plugin writes no registry row, so the ISPB stays free. Fix the cause and send the POST again. That is the recovery. There is no separate retry route, because no half-written row remains to resume.
Registration is atomic: a 201 means ready, and a failure leaves no registry row. On a 201, status is always ACTIVE. Do not write a client that polls until the participant becomes routable. That loop waits for a change that cannot happen. A failure can leave one thing behind: the settlement account, when that step succeeded and a later one failed. The plugin does not roll it back, and it does no harm. Nothing routes to a @pi account without an ACTIVE registry row that points at it. The account creation is idempotent by alias, so your next attempt adopts the same account and does not make a second one. A failure never leaves 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 every registration of this service, because a failed registration leaves no row. The lifecycle states are ACTIVE, SUSPENDED, and CLOSED.
PENDING_PROVISIONING is a retired state with a sharp edge. No registration goes into it now. GET /v1/indirects still accepts it as a filter, so that rows from before atomic registration read back as themselves. But that state has no lifecycle transitions in either direction. You cannot suspend or close a legacy row. Every action on it answers 409 PIX-0094.Such a row is readable but not routable, and it still holds its ISPB against the open-uniqueness index. It therefore blocks a new registration of that institution, and no API call can free it. To clear one is an operator and data decision, and it is not an API operation. If your tenant is older than atomic registration, run GET /v1/indirects?status=PENDING_PROVISIONING one time to find any such rows.

How money reaches an indirect participant


Follow one inbound Pix. The indirect calls no endpoint in this path. Your plugin does the work.
  1. “Can this account receive?” Before the Banco Central moves the money, it asks if the destination account accepts the credit. The question comes to you, because the indirect participant has no line to the SPI.
  2. A Pix arrives for an account at the indirect institution. It reaches your direct participation through the plugin’s cash-in webhook.
  3. Resolution finds the indirect from the ISPB in the receiver block, in the participation registry. There are four outcomes, and only the first one credits an indirect:
    • The ISPB matches an ACTIVE participation. The credit posts to that participation’s @pi_{ispb} alias.
    • The ISPB is your own. It is your book, and the CRM resolves the account as usual.
    • The ISPB matches nothing, or a suspended or closed participation. The plugin refuses the credit with 404 PIX-0119. It tells JD that it does not serve that receiver. It stores nothing, and no money settles.
    • The resolution fails on infrastructure. The plugin neither credits nor refuses. It answers an error, so that JD delivers the credit again. It fails closed on purpose and guesses nothing about money.
    The plugin never caches a resolution miss. An indirect that just became active therefore never hides behind an earlier negative answer.
  4. The credit lands on @pi_{ispb}. The plugin records the movement against that settlement position and links its own transaction record to the indirect. It records the notice in the same database transaction as the credit. Either both exist, or neither does.
  5. The plugin pushes a signed notice to the delivery.endpointUrl that you registered, with JD’s payload byte for byte. The plugin calls the institution, not the reverse.
  6. The indirect participant credits its own customer, in its own core.
The plugin does not ask the CRM on the indirect path. The destination comes from the participation registry, never from caller data. That is deliberate. If an indirect participant had your own ISPB by mistake, credits for your own book would land in its account. The “is this ISPB ours?” check therefore runs before the “does it match an indirect participant?” check.
The notice carries two headers the receiving institution verifies: Delivery is at-least-once. The plugin can send the same notice again after a retryable failure, or after a crash between the send and the bookkeeping. The endToEndId stays the same across attempts. 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 for good. The retries run out, or the endpoint answers 4xx. After that, the plugin marks the notice invalid and never tries it again. The money is correct either way. The result is narrow but serious: the movement is real, and the institution does not know about it. GET /v1/indirects/{indirectId}/transactions closes that gap. It returns the movements that settled on one indirect’s @pi_{ispb} account. It is therefore the authoritative record, not the notice. An institution that reads it on a schedule no longer depends on the delivery. It is also the only way to read a window again after an outage of its own. List an indirect participant’s transactions gives the rules that make a reconciliation correct. They cover the required half-open since window, integer centavos, oldest-first cursor paging, and the silent clamp on limit. A read of the feed changes nothing and sends no notice again.

Returning a Pix that an indirect participant received


A devolução of a Pix that landed on an indirect participant’s position comes from that indirect. Send POST /v1/refunds with the indirectId of that participant. The devolução then debits its @pi_{ispb} position. The indirectId must name exactly the participant that received the original Pix. The plugin answers 422 PIX-0135 and moves no money when:
  • the original Pix landed on an indirect’s position, and the refund has no indirectId.
  • the refund names another indirect participant.
  • the original Pix landed on a direct account, and the refund has an indirectId.
The indirectId then passes the same checks as on a payment order: 422 PIX-0111 when the feature is off, 404 PIX-0095 for an unknown participant, and 422 PIX-0100 when the participation is not ACTIVE.

Hosting the QR code under the institution’s own certificate


By default, the plugin publishes an indirect’s dynamic QR code under your direct participation. An indirect can publish its own instead. Set qrCertificate.ownCertificate with a publicBaseUrl on PATCH /v1/indirects/{indirectId}. The institution then serves the signed document from its own host. The institution cannot make the validation keys itself. JDPI holds the certificate and does the signing. GET /v1/indirects/{indirectId}/jwks returns the JWK Set for that indirect. The institution publishes it on its own host, and payer PSPs validate against it. Fetch it when you configure the certificate, and again after each rotation. Lifecycle status deliberately does not gate that read. A suspended or closed indirect still has live QR codes in circulation. Without the key, their validation would break.

Reading the registry


The filters are status and ispb (8 digits, exact). List indirect participants gives the cursor-paging rules and the silent clamp on limit. A GET by id, and the list, always show the current truth. The money paths read a cached projection. Every replica can take up to the resolution cache TTL to see a suspension. The TTL is 30 seconds by default. The ledger lock from suspend applies at the moment of the call.

Suspending, reactivating, and closing


PATCH /v1/indirects/{indirectId} carries at most one lifecycle action: suspend, reactivate, or close. Each one writes the ledger as well as the registry.
  • suspend stops both sending and receiving on the indirect’s @pi account. The ledger itself refuses that participant’s money. A suspension therefore holds even on a replica whose resolution cache is stale. reactivate starts both again.
  • close is permanent. CLOSED is terminal, and after it the plugin refuses every field change on the row too.
A close deletes nothing. The plugin blocks the @pi account for good but keeps it. A later registration of the same ISPB reuses that account. The account is unblocked, active again, and keeps its accounting history. A PATCH with only fields and no action changes those fields. An empty PATCH changes nothing and returns the current row. It is not an error.
There is no DELETE on /v1/indirects, and CLOSED is terminal. The only exit from the registry is close, which keeps the row and the account. A mistaken registration in a live tenant stays in that tenant’s registry for good. Its ISPB stays held against the open-uniqueness constraint until you close it. Every institution code that you spend is permanent. Check name, ispb, and the delivery endpoint before you call.

What goes wrong


The operation pages list every refusal of this registry, with its status and exact condition. They also say what the plugin wrote or did not write when it refused. See Register an indirect participant, Update an indirect participant, Get an indirect participant, List indirect participants, Get an indirect participant’s JWK Set, and List an indirect participant’s transactions. The Pix JD error list has every code, with the exact detail of each one.
The status tells you whether to retry, not the code family. PIX-0107 and PIX-0123 are the same condition, seen two ways. A 409 means that the rail read the configuration and found that the key does not exist. Only an operator can change that, so a retry loops forever. A 503 means that the read did not complete. The rail does not know that the key is absent, so a retry is right. Neither one stores anything.

Where to go next