Skip to main content
A fresh Direct Pix via JD deployment starts, responds to its health probe, and refuses every payment. Nothing is broken: the rail needs a chain of objects to exist before it can move money, and until they do it fails closed rather than guessing. This page is that chain, in order, with the failure each missing link produces. Lerian runs this provisioning with you during onboarding. Use the page to know what has to exist, what each value means, and how to prove it landed.
The order is a dependency chain, not a preference. Midaz refuses each link while the previous one is missing, and one systemplane key has to be written before a payment can materialize the transaction limits. Where a step can run in any order, the page says so.

Before you start

You talk to three services, and confusing them is the most common first mistake. You also need:
  • A bearer token for the ledger, one for the CRM, and one for the plugin. They can come from different audiences.
  • The systemplane:write permission on the plugin token. Without it the configuration writes answer 403.
  • SYSTEMPLANE_ENABLED=true on the plugin. With it unset, the /system route group is not mounted at all and every configuration write answers 404.
  • Your institution’s ISPB: the 8-digit identifier you were accredited with at BACEN.
The examples below use these shell variables. Where the rail already has a name for a value, the variable carries that same name, so what you read here is what you set at deploy time. Every UUID, document, and ISPB is a placeholder — use the values your own environment returns.
Midaz exposes an onboarding surface and a transaction surface, and this rail configures them separately. A deployment that serves both from one address gives the two variables the same value. The calls below are grouped by the object each one creates.
The CRM requires the X-Organization-Id header on every collection route. Without it a query is not scoped to your organization, and what comes back is another record or nothing at all.

Provision the ledger and the holder records

These steps run against Midaz and the CRM. They create the accounts the rail posts to and the holder records it resolves counterparties from.
1

Create the organization and the ledger

The organization is your institution in the books. The ledger is the book it posts to. An institution can hold more than one ledger; this rail posts to exactly one.
A 201 with no id in the body is not a success: nothing can address what was created, not you and not the cleanup afterwards. Stop there rather than carrying an empty id into the next step.
2

Create the BRL asset, and wait for it to appear

The asset is the currency the money is recorded in. For Pix it is BRL.
Both 201 and 409 are good answers. An asset is addressed by its code inside a ledger, so “already exists” is indistinguishable from success.Two things happen here, and the second one is easy to miss:
  1. The ledger starts accepting accounts in that asset. Before this, it refuses every account with 0034 Asset Code Not Found.
  2. Midaz creates the @external/BRL account alongside it. It is the one account in a book that can be debited without having been credited first, so it is where the opening balance comes from and it is the counterparty of every settlement with the outside world.
The 201 arrives before the asset shows up in the listing. A script that creates the asset and creates an account on the next line is exactly what breaks intermittently. Poll the listing until the code appears, with a deadline:
3

Create one account per role

Create one account for each role your environment exercises — payer, payee, and whatever else your product has.
The account id is consumed twice later: in the CRM link below, and as the accountId of every business call you make against the plugin. The alias (@payer) is the short name the book is read and posted by, and it reappears in the next step in the least likely place.
4

Create the holder in the CRM

The holder is the owner of the account. The plugin resolves who a counterparty is from the CRM, not from the ledger.
Four fields, three traps:
  • type follows the document length. NATURAL_PERSON for a CPF (11 digits), LEGAL_PERSON for a CNPJ (14). The plugin converts this type into the number that goes on the wire to JD, so getting it wrong files the party as the wrong kind of person.
  • externalId has to be the account’s ledger alias (@payer), and the field name hides that. The CRM describes it as an external correlation identifier, which reads as optional. For this rail it is not: it is where the plugin reads which account in the book belongs to the holder. A holder with no externalId produces an account that resolves, looks complete, and fails every payment.
  • addresses.primary.city is required for QR codes and Pix Automático. It is the receiver city printed in the code, and the plugin refuses to generate a QR without it, answering 422 PIX-0033 and pointing at payee.city. No payment path reads the field, which is why its absence goes unnoticed until someone generates a QR.
One holder supports exactly one account. externalId is a single value per holder, so giving two accounts to the same holder makes the second one move money in the first. For a second account, create a second holder.
5

Link the holder to the account

The holder and the ledger account both exist, and nothing joins them yet. This step is the join, and it is how the plugin finds where to credit an incoming Pix.
Never send bankId as an empty string. An empty string is a value: it records “this account belongs to the institution whose ISPB is empty”, which is worse than saying nothing. If you do not have the ISPB yet, omit the field.Accounts with bankId filled in pay; accounts created without it answered a server error on cash-out. That symptom is measured, but the mechanism is not confirmed. Fill it in — it is your own ISPB, it costs nothing, and the alternative is debugging an error that names nothing.
Check what was recorded, and always filter. An unfiltered query returns the first link in the organization, which is how a verification ends up approving somebody else’s account:
6

Fund the accounts

A new account holds zero, and you cannot pay from an empty account. The opening credit comes from @external/BRL, created with the asset in step 2.
Two identical bodies are one posting. Midaz collapses the repeat: the second POST answers 201 carrying the first posting’s id, and nothing moves. A top-up that “worked” and did not change the balance is this. Change the description on every credit.
@external/BRL can be named in the body but never in a path. The alias contains a slash and the ledger route does not decode it, so reading its balance by path answers 404 or an empty 200. Posting from it is normal; reading it that way is not.
7

Create the twenty accounting routes

Every money path on this rail has its own pair of Midaz operation routes — one credit leg and one debit leg. Ten profiles, two legs each, so twenty routes. The configuration step further down stores their UUIDs.
Each call returns {"id": "..."}, and that id is what the routing keys below hold.
The direction is invertible and nothing warns you. A debit leaves the payer, so it is the source leg; a credit arrives, so it is destination. Swapped, every posting still answers success — in the wrong direction. No status code reports this.
Look a route up by title before creating it. Two routes with the same title make the next lookup pick either one, so if you provision the same book more than once, list first.

Configure the rail

The plugin’s live configuration lives in its systemplane: values you write over the admin API, which take effect without a redeploy or a restart. Every write is a PUT to /system/<namespace>/<key> with a {"value": ...} body, and 204 No Content is the success — the plane returns no body on a write. The tenant comes from the validated bearer, never from the URL or the body. In a multi-tenant deployment that means one bearer per tenant and one write per tenant.
Ask the plugin what it expects rather than trusting a copy. GET /system/-/catalog lists every key with its type and description, and GET /system/-/catalog/<namespace>/<key> describes one. The catalog is the source of truth; this page is a copy of it, and copies age.
1

Write the JD integration binding — your ISPB

What it is. The ISPB is the 8-digit number that identifies your institution at the Banco Central. It is the “who am I” that goes on every Pix message, and the plugin cannot sign anything as yours without it.Why you would not guess this. The ISPB is not a deployment variable. It comes from the systemplane key tenancy/jd_integration_binding, in both single-tenant and multi-tenant mode, and there is no environment fallback. While the key is empty the plugin starts, answers its health probe, looks healthy — and refuses every payment on every money route.What breaks without it. 409 PIX-0092, “Tenant Pix integration not provisioned”. One end-to-end battery collected 86 refusals from this single empty value; the next most frequent code in the same run appeared 6 times. The response text asks you to contact support and does not name the key, so the code is what you search for.
Restarting does not help. This is not a value read at boot — the plugin reads the key on every call, so restarting a deployment that has no ISPB returns a deployment that still has no ISPB. Writing the key does help, with the app running: the next request passes, with no redeploy.
The trap: the value is a string that contains JSON. The body is always {"value": ...}, and here value is not an object. It is a string whose content is a JSON document. That is how it is stored, with the inner quotes escaped:
Let jq do the escaping:
All three fields are required on the write even in single-tenant, and that is the surprising part. A single-tenant deployment does not read organizationId or ledgerId from this key — the book it posts to keeps coming from MIDAZ_ORGANIZATION_ID and MIDAZ_LEDGER_ID. The write validator asks for all three anyway. Fill the two UUIDs with the same values those variables carry.Two sources of truth for the same fact could disagree without anyone noticing, which is why the run-time answer stays with the deployment values. In multi-tenant all three fields are read, and they are what resolves each tenant’s JD and Midaz identity.
Decoding is strict: an unknown field is refused, not ignored, and it takes the whole write down. That includes the retired routeProfiles field — the accounting routes moved to the routing.* keys below — so a document copied from an old configuration does not go in. Anything after the first JSON document is refused too.
How to check it landed. Read the key back. It holds no secret, so the value comes back in the clear:
If value comes back as an object rather than a quoted string, you wrote the wrong form. If it comes back "", the write did not happen — check the status code of the PUT. The real confirmation is behavioral: money routes that answered 409 PIX-0092 stop answering it.A malformed ISPB cannot be stored through this route. The write validator is the same decoder the read uses, so a 7-digit ISPB or a broken UUID is refused on the spot with 400 validation_error instead of being discovered on the first payment.
That is why you will not meet 409 PIX-0121“Tenant Pix integration ISPB invalid” — while following this page. It is the code for a binding that is provisioned and whose ispb is not 8 digits, and this route cannot create that state. It appears only when a value reached the key some other way: a direct write to the plugin’s database, or a write made before the validator existed. It is documented because if you ever do see it, its message is the one that names both the key and the field to correct.
A 400 here is not a permission problem. That reading has already cost time: three test scenarios read this exact 400 as “my bearer has no admin grant”. Missing permission is 403. This 400 means the value you sent is wrong. The response does not say which field, so check the 8 digits first — it is the most common mistake.
The empty string is accepted, and it is the “not provisioned” sentinel. Writing it takes the deployment back to refusing with 409 PIX-0092, so do not do it in an environment that is paying.
2

Write the twenty routing keys

What it is. For each money path, the UUID pair of the Midaz operation routes created above. All twenty keys live in the tenant_policy namespace, all hold a string, and all hold a route UUID that already exists in Midaz.
What breaks without it. A missing leg does not degrade the flow, it refuses it: the matching money path fails closed with 409 PIX-0105. That is deliberate — refusing a transaction beats posting it against an undefined route. If one specific flow “does not work” and the others do, this is the first place to look.
How to check. Each write answers 204. A profile your product never exercises can stay empty — that flow then refuses, which is what you want instead of a posting on an undefined route.
An empty string is the “not yet provisioned” sentinel and is accepted. The all-zeros UUID is refused: it parses cleanly but is never a real Midaz identifier. A script that fills unused routes with zeros as a “safe empty” passes any naive format check and is rejected by this one. Use the empty string.
3

Set the posting asset and the clearing account

What it is. The asset the money is posted in, and the external account that stands in for the world outside your institution.Where the values go depends on the deployment mode, and this is where a 204 can mislead you:
In single-tenant, the two systemplane keys exist, accept a write, and answer 204 — and nothing reads them. The plugin resolves the asset and the clearing account from the deployment variables, and the systemplane keys supersede them only when MULTI_TENANT_ENABLED=true. You can write both, get 204 on both, and still have the money path refusing, because the variables are still empty. A 204 here is not confirmation that the value will be used.
Both names lie about their shape. Despite the _ID:A UUID in either one answers PIX-4011 and names no account.What breaks without it. 409 PIX-0106, “Tenant ledger configuration missing”. The response names both halves and both places to set them, so it tells you which one you are missing. It is a different code from PIX-0105: a tenant can have all twenty route legs correct and still refuse every posting because the asset or the clearing account is unset.
4

Set the daily window

The two ends of the window transaction limits are accounted in. Both are integers from 0 to 23 — clock hours, not timestamps — and both are systemplane keys in both deployment modes.
The values above are an example; use your own. What does not change is the JSON type: an integer, unquoted. A value outside the range answers 400 rather than silently clamping to the bound.
5

Declare whether this deployment hosts indirect participants

plugin-br-pix-jd.indirects/enabled declares what the tenant is. A plain direct participant sets it to false.
It is a JSON boolean, unquoted: {"value":"true"} answers 400. If this deployment settles Pix on behalf of other institutions, set it to true and follow Hosting indirect participants further down this page — there is one more value to provision before you can register anybody, and without it every registration refuses.
6

Materialize the transaction limits with one small payment

This is the least guessable step on the page, because the product offers no way to create what it needs.What you expect. You provision an account and read its available limit.What happens. GET /v1/limits/available answers 404 PIX-0063, “The specified transaction limit was not found in the system. Please verify the identifier and try again.”, and GET /v1/limits answers {"data":[]}. It looks like a broken account. It is not: it is the normal initial state of an account that has never transacted.PIX-0063 names whichever thing was looked up, so the error list prints its generic form — “The specified entity was not found in the system” — and this route fills in transaction limit. Same code, same 404.Why you cannot fix it by creating something. There is no creation route. PATCH /v1/limits updates a row that has to exist already. The rows are materialized by exactly one thing: the limit pre-flight of an outbound payment. The first time the account sends a payment, the plugin notices it has no rows, creates the defaults, re-reads them, and carries on.So the step is: send one small outbound payment. One cent is enough, and it is what the automated provisioning does.
An account with no limit rows is not an account with no limit. If the automatic creation cannot establish the limits, the payment is refused, not waved through.And do not try to force the rows by sending an amount above the ceiling. The balance check runs before the limit enforcer, so a large amount is refused for insufficient funds and the enforcer is never reached — no rows appear. It has to be an ordinary payment that fits the balance.
How to check. GET /v1/limits goes from {"data":[]} to a list, and /v1/limits/available goes from 404 to 200.This step depends on the routing keys being written first: it materializes the rows by making a real payment, and with no route the payment is refused with PIX-0105. Everything else on this page can be done in any order.

Hosting indirect participants

Skip this section if your deployment settles only its own customers’ Pix. To settle a Pix at the Banco Central, an institution has to be connected to the National Financial System infrastructure. That is expensive and slow, and not every institution does it. An institution that does not uses somebody else’s connection: it becomes an indirect participant, and a direct participant settles for it and hosts it. A tenant that hosts indirect participants is a direct participant first. Everything above — the ISPB binding, the organization, the ledger, the asset, the accounts, the CRM records, the twenty accounting routes — is provisioned exactly as written. Call that the direct-participant chain, steps 1 to 6. This section is what you add on top, and its numbering continues from there.
Step 7 comes before step 9, not after: without the encryption key, every registration in step 9 fails. Steps 7 and 8 are independent of each other — the posture does not validate the key, and the key is not read by the posture.
This section is the provisioning. What a hosted participation is, how an inbound credit reaches one, how its lifecycle behaves and every refusal it can answer are in Indirect participants.

Step 7: the delivery-secret encryption key

Why this step exists. When you register an indirect participant, you supply a delivery secret. The plugin signs every notice it sends to that institution’s endpoint with it, and it is how the institution knows the notice really came from you. That secret is stored encrypted, and the key that encrypts it does not come from the database. It comes from outside. If the key is not configured, every registration that carries a secret fails. This is not an edge case — it is the whole write surface of the indirect flow.
Read the guarantee in the middle of that sentence: no secret was stored. Encryption fails before any write, so there is no half-created row with a plaintext secret to go and clean up. Fixing the key and repeating the POST is the whole recovery.
409 here means “provision it”, not “try again later”. The rail looked for the key and established that it is not there, and only an operator can put it there — so repeating the call without that change fails identically. That is why the code is a 409 and says nothing about retrying.Its sibling is 503 PIX-0123, “Indirect delivery key source unavailable”, which is what you get when the key could not be read: the custody backend refused or did not answer, or a multi-tenant request carried no tenant to resolve the key for. There, the key is not known to be missing — the read itself did not complete — so the response names the faulting dependency and retrying is the right move. Both refuse the registration and store nothing; the pair exists so you can tell an incomplete setup from an outage.
The plugin starts without the key. There is no boot refusal: the process logs a warning at startup and comes up healthy, because nothing at startup distinguishes a deployment that will host indirect participants from one that never registers a single one — the posture is a per-tenant key, read on every request. So the symptom arrives on the first registration, not on deploy. If you did not read the startup log, the refusal is your first notice.
Where it lives, and why it is not in the systemplane. This contrast explains where each kind of value belongs on this rail.
Do not put the encryption key in the systemplane. The systemplane is the live configuration plane, readable over the admin API, and that is the right home for everything that is not a secret. Credential material does not go there, and the rail separates the two on purpose.Do not commit it either — not in a versioned .env, not in a values.yaml, not in a compose file.
The format: exactly 64 hex characters. It is an AES-256 key — 32 bytes — hex-encoded. That is 64 hex characters, not 63 and not 65.
Generate one per deployment. Do not copy another environment’s key and do not reuse another service’s.
An absent, blank, or malformed value all produce the same 409 PIX-0107. There is no default and no degradation to storing the secret in the clear. That is deliberate: a silent default here would store customer secrets encrypted with a key everybody knows.
In single-tenant, set the deployment variable:
That placeholder is deliberately not a valid key: pasted as-is it fails closed with the refusal above rather than encrypting your customers’ secrets with a value published on a documentation page. It is read at startup, so changing it needs a process restart. That is different from the ISPB binding, which is read on every call and heals without one.
Do not type the value on a command line. It ends up in your shell history and in CI logs. Read it from a vault, or type it with read -rs, which does not echo.
In multi-tenant the deployment variable is ignored. The key is resolved per tenant from your deployment’s secret store, and the tenant comes from the validated bearer, never from the payload. A tenant with no entry gets the same 409 PIX-0107 while the other tenants keep working: the resolution is per tenant and fails closed per tenant. How to check it landed. No route reads the key back, and that is how it should be — it is a secret. There are two signals, and they are not worth the same in the two modes. The startup log — valid in single-tenant only. With the key resolvable, the “key unavailable” warning does not appear. If it does appear, no registration carrying a secret will pass.
In multi-tenant that signal proves nothing, and trusting it is the mistake. The startup check is satisfied by the existence of a secret-store client — it queries no tenant. So the warning stays silent in multi-tenant even when no tenant has the key provisioned. Silence there means “a source exists”, not “your tenants are ready”.
The behavior — the only signal that counts in both modes. Register an indirect participant: the response goes from 409 PIX-0107 to 201. In multi-tenant that proves that tenant and only that tenant, so repeat it per tenant.
Think before you use the behavioral check. A registration is permanent — there is no delete route, and the only exit is close, which keeps the row and holds the ISPB. Do not spend a throwaway registration to test the key in a production environment; use an ISPB you actually intend to operate. In single-tenant, the startup log saves you that cost. In multi-tenant it does not, because it does not answer the question.

Step 8: declare that this tenant hosts indirect participants

plugin-br-pix-jd.indirects/enabled has to be true.
204 when accepted. It is a JSON boolean, unquoted: {"value":"true"} answers 400. The management API works with the posture off, so you can register participants before you enable — what the posture gates is the money paths. The full breakdown of what each half does is in Before you register anyone.
The read fails closed. If the systemplane does not answer, if the key does not resolve, or if the value comes back the wrong type, the plugin reads it as off — never on by accident. A money path that “went back to behaving like a direct one” with nobody having touched the key is this mechanism. Look at the systemplane.

Step 9: register an indirect participant

One call runs the whole assembly: it checks the ISPB, creates the @pi_{ispb} settlement account in Midaz, and marks the participation active.
A 201 means the participation is ready to use: registration is atomic, status is always ACTIVE, and there is nothing to poll for.
Do not write an indirect participant’s ISPB into tenancy/jd_integration_binding. That key is the identity the plugin presents to JD, so a third party’s ISPB there makes the plugin introduce itself as another institution — and nothing warns you, because 8 valid digits are accepted and the write answers 204. The key is one per tenant, not one per indirect participant: every indirect participant inside a tenant reaches the SPI through the host’s ISPB.
Registration is permanent. There is no DELETE on /v1/indirects, and CLOSED is terminal. An indirect participant registered by mistake in a live tenant does not come back out, and its ISPB stays held against the uniqueness rule until somebody closes it. Check the name, the ISPB, and the endpoint before you call. Rehearse on a disposable environment.
The refusals, the lifecycle actions, and how to read the registry back are in Indirect participants.

The systemplane keys of the indirects namespace

Five keys, all in plugin-br-pix-jd.indirects, and all of them always exist. The three delivery and resolution keys only take effect once enabled is true, because they tune the money paths. validate_ispb_on_jd is the exception: it gates a step of registration, which works while enabled is still false.
204 when accepted, 400 when the validator refuses. Booleans and integers both go unquoted, and a value outside the range answers 400 rather than clamping silently.
Neither of the two steps that break the indirect flow on their own lives in this namespace. Identity is the systemplane key tenancy/jd_integration_binding, and without it every payment refuses with 409 PIX-0092. The encryption key lives in a deployment variable or a secret store, and without it every registration refuses with 409 PIX-0107.

What each skipped step looks like

Every refusal below is a 409 except the limits one, and none of them resolves by waiting. They resolve by provisioning the missing value.
PIX-0092 stopping does not mean payments pass. It is the first barrier, not the last: with the binding in place, an empty accounting route still refuses with PIX-0105, and a missing asset or clearing account still refuses with PIX-0106. Three codes, three causes, three different fixes.

Do not retry a 409. Do retry its 503 sibling

This is the distinction that tells you whether the problem is your setup or somebody’s outage, and the status carries it. A 409 above is positive knowledge of absence: the rail read your configuration successfully and found nothing there. Only an operator can supply the value, so repeating the request cannot change the answer — a client that honours retry semantics would loop forever against a condition that never resolves on its own. Each of those responses names what to set. Most of those conditions have a 503 sibling for the case where the rail could not read the configuration at all. Nothing was established about what you have provisioned, the response names the faulting dependency, and retrying is the right move.
The accounting routes are the exception, and it is worth knowing. PIX-0105 has no 503 sibling: a route leg that is missing, empty, malformed, the all-zeros UUID, or unreadable all answer that same 409. So unlike every other code here, a PIX-0105 on its own does not separate “this leg was never provisioned” from “the configuration plane did not answer”. Read the twenty legs back before concluding it is an outage.
Both halves of each pair refuse the operation, and neither posts anything nor stores anything. The difference is entirely what you should do next, which is why they are separate codes instead of one envelope covering both.PIX-0121 is a special case in a different way: you cannot provoke it through the admin API, because the write validator refuses a malformed ispb before it is stored. It appears only when a value reached the key by another path — see the binding step above.
The full catalog, with the exact detail each code carries, is the Pix JD error list.

Prove the setup is complete

Run these in order. Each one fails for a different reason, which is what makes the sequence worth running instead of a single smoke test.
  1. The ledger accepts an account. Create a disposable account and delete it. If the book refuses, the refusal names the missing link. Skip this and the same problem comes back later as “account not found” inside a payment flow, three layers from its cause. Never fund the probe account: Midaz refuses to delete an account with a balance.
  2. The binding reads back as a quoted string carrying your ISPB, as shown above.
  3. The alias query returns your account, filtered by document, branch, and account number.
  4. A money route stops answering 409 PIX-0092. This is the same call you were already making — no test harness needed.
  5. GET /v1/limits returns a list for an account that has sent its first payment.
Some provisioning steps are not yours. Lerian’s end-to-end battery establishes four more things before it runs: a pool of MED infractions, credentials for its JD test double, and two internal bookkeeping files. Those are test fixtures with no equivalent in a real deployment — in production the infractions arrive from JD, and JD authenticates itself. Do not try to build them.

Where to go next

Environment variables

The deploy-time configuration of this rail: JD connectivity, the ledger and CRM endpoints, QR hosting, and the notification providers.

Indirect participants

What a hosted participation is, how an inbound credit reaches one, its lifecycle, and every refusal it can answer.

Direct Pix via JD

How settled Pix movements land in Midaz, and how the two systems correlate.

Pix JD error list

Every PIX-NNNN code, its status, and the detail text the response carries.