> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lerian.studio/llms.txt
> Use this file to discover all available pages before exploring further.

# Setting up the rail

> Provision Direct Pix via JD from zero: the Midaz ledger chain, the CRM holder records, the twenty accounting routes, the JD integration binding that carries your ISPB, and the checks that prove each step landed.

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.

<Note>
  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.
</Note>

## Before you start

You talk to three services, and confusing them is the most common first mistake.

| Service                  | Address                                         | Routes start with            | Extra header        |
| ------------------------ | ----------------------------------------------- | ---------------------------- | ------------------- |
| Ledger (Midaz)           | `MIDAZ_URL_ONBOARDING`, `MIDAZ_URL_TRANSACTION` | `/v1/organizations/...`      | —                   |
| CRM (account holders)    | `CRM_URL`                                       | `/v1/holders`, `/v1/aliases` | `X-Organization-Id` |
| The plugin's admin plane | the plugin's own address                        | `/system/...`                | —                   |

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.

```bash theme={null}
# Addresses. The first two are this rail's own deployment variables.
MIDAZ_URL_ONBOARDING="https://midaz-onboarding.example.com"
MIDAZ_URL_TRANSACTION="https://midaz-transaction.example.com"
CRM_URL="https://crm.example.com"
PIX_JD_BASE_URL="https://pix-jd.example.com"       # this deployment's own address

# Three different bearers. They are not interchangeable.
MIDAZ_BEARER_TOKEN="..."                           # for the ledger
CRM_BEARER_TOKEN="..."                             # for the CRM
PIX_JD_BEARER_TOKEN="..."                          # for the plugin, needs systemplane:write

MIDAZ_HEADERS=(-H "Authorization: Bearer $MIDAZ_BEARER_TOKEN"
               -H 'Content-Type: application/json')
```

<Note>
  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.
</Note>

<Warning>
  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.
</Warning>

## 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.

<Steps>
  <Step title="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.

    ```bash theme={null}
    curl -s -X POST "$MIDAZ_URL_ONBOARDING/v1/organizations" "${MIDAZ_HEADERS[@]}" -d '{
      "legalName": "Example Institution",
      "legalDocument": "12345678000199",
      "address": { "country": "BR" }
    }'
    # -> {"id": "..."}  keep it as $MIDAZ_ORGANIZATION_ID

    curl -s -X POST "$MIDAZ_URL_ONBOARDING/v1/organizations/$MIDAZ_ORGANIZATION_ID/ledgers" "${MIDAZ_HEADERS[@]}" -d '{
      "name": "Pix ledger"
    }'
    # -> {"id": "..."}  keep it as $MIDAZ_LEDGER_ID
    ```

    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.
  </Step>

  <Step title="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`.

    ```bash theme={null}
    curl -s -X POST "$MIDAZ_URL_ONBOARDING/v1/organizations/$MIDAZ_ORGANIZATION_ID/ledgers/$MIDAZ_LEDGER_ID/assets" "${MIDAZ_HEADERS[@]}" -d '{
      "name": "BRL",
      "type": "currency",
      "code": "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.

    <Warning>
      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:

      ```bash theme={null}
      deadline=$(( $(date +%s) + 12 ))
      until curl -s "$MIDAZ_URL_ONBOARDING/v1/organizations/$MIDAZ_ORGANIZATION_ID/ledgers/$MIDAZ_LEDGER_ID/assets" "${MIDAZ_HEADERS[@]}" \
            | jq -e '.items[]? | select(.code=="BRL")' >/dev/null; do
        if [ "$(date +%s)" -ge "$deadline" ]; then
          echo "aborted: BRL did not appear in the listing within 12s" >&2
          echo "do not create the account: it would be refused with 0034" >&2
          exit 1
        fi
        sleep 0.15
      done
      ```
    </Warning>
  </Step>

  <Step title="Create one account per role">
    Create one account for each role your environment exercises — payer, payee, and whatever else your product has.

    ```bash theme={null}
    curl -s -X POST "$MIDAZ_URL_ONBOARDING/v1/organizations/$MIDAZ_ORGANIZATION_ID/ledgers/$MIDAZ_LEDGER_ID/accounts" "${MIDAZ_HEADERS[@]}" -d '{
      "name": "payer account",
      "assetCode": "BRL",
      "type": "deposit",
      "alias": "@payer",
      "status": { "code": "ACTIVE" }
    }'
    # -> {"id": "..."}  keep it as $MIDAZ_ACCOUNT_ID
    ```

    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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl -s -X POST "$CRM_URL/v1/holders" \
      -H "Authorization: Bearer $CRM_BEARER_TOKEN" -H 'Content-Type: application/json' \
      -H "X-Organization-Id: $MIDAZ_ORGANIZATION_ID" -d '{
      "type": "NATURAL_PERSON",
      "document": "12345678909",
      "name": "Example Person",
      "externalId": "@payer",
      "addresses": { "primary": { "city": "SAO PAULO" } }
    }'
    # -> {"id": "..."}  keep it as $CRM_HOLDER_ID
    ```

    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.

    <Warning>
      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.
    </Warning>
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl -s -X POST "$CRM_URL/v1/holders/$CRM_HOLDER_ID/aliases" \
      -H "Authorization: Bearer $CRM_BEARER_TOKEN" -H 'Content-Type: application/json' \
      -H "X-Organization-Id: $MIDAZ_ORGANIZATION_ID" -d '{
      "ledgerId":  "'"$MIDAZ_LEDGER_ID"'",
      "accountId": "'"$MIDAZ_ACCOUNT_ID"'",
      "bankingDetails": {
        "branch":      "0001",
        "account":     "1234567",
        "type":        "CACC",
        "openingDate": "2020-01-02",
        "bankId":      "12345678"
      }
    }'
    ```

    | Field                   | What it is                                                                 | If it is missing                                                                                            |
    | ----------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
    | `ledgerId`, `accountId` | the book and the account from the previous steps                           | the link addresses nothing                                                                                  |
    | `branch`, `account`     | branch and account number — the coordinates an incoming Pix is resolved by | the credit finds no destination                                                                             |
    | `type`                  | `CACC` checking, `SLRY` salary, `SVGS` savings, `TRAN` payment             | the plugin compares this type with the one on the payment and refuses with `400 PIX-0019` when they diverge |
    | `openingDate`           | the account opening **date**, not an instant                               | `400 PIX-0061`, saying the opening date could not be determined, on key verification and on claim opening   |
    | `bankId`                | your own institution's 8-digit ISPB                                        | see the warning below                                                                                       |

    <Warning>
      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.
    </Warning>

    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:

    ```bash theme={null}
    curl -s -G "$CRM_URL/v1/aliases" \
      -H "Authorization: Bearer $CRM_BEARER_TOKEN" -H "X-Organization-Id: $MIDAZ_ORGANIZATION_ID" \
      --data-urlencode 'document=12345678909' \
      --data-urlencode 'banking_details_branch=0001' \
      --data-urlencode 'banking_details_account=1234567' | jq
    ```
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl -s -X POST "$MIDAZ_URL_TRANSACTION/v1/organizations/$MIDAZ_ORGANIZATION_ID/ledgers/$MIDAZ_LEDGER_ID/transactions/json" "${MIDAZ_HEADERS[@]}" -d '{
      "description": "opening balance",
      "send": {
        "asset": "BRL",
        "value": "100.00",
        "source":     { "from": [ { "accountAlias": "@external/BRL",
                                    "amount": { "asset": "BRL", "value": "100.00" } } ] },
        "distribute": { "to":   [ { "accountAlias": "@payer",
                                    "amount": { "asset": "BRL", "value": "100.00" } } ] }
      }
    }'
    ```

    <Warning>
      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.
    </Warning>

    `@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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    # a CREDIT leg -> operationType "destination" (money ARRIVES)
    curl -s -X POST "$MIDAZ_URL_ONBOARDING/v1/organizations/$MIDAZ_ORGANIZATION_ID/ledgers/$MIDAZ_LEDGER_ID/operation-routes" "${MIDAZ_HEADERS[@]}" -d '{
      "title": "pix-jd out credit",
      "description": "Pix JD accounting route",
      "operationType": "destination"
    }'

    # a DEBIT leg -> operationType "source" (money LEAVES)
    curl -s -X POST "$MIDAZ_URL_ONBOARDING/v1/organizations/$MIDAZ_ORGANIZATION_ID/ledgers/$MIDAZ_LEDGER_ID/operation-routes" "${MIDAZ_HEADERS[@]}" -d '{
      "title": "pix-jd out debit",
      "description": "Pix JD accounting route",
      "operationType": "source"
    }'
    ```

    Each call returns `{"id": "..."}`, and that id is what the routing keys below hold.

    <Warning>
      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.
    </Warning>

    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.
  </Step>
</Steps>

## 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.

<Note>
  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.
</Note>

<Steps>
  <Step title="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.

    <Warning>
      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.
    </Warning>

    **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:

    ```text theme={null}
    "{\"ispb\":\"12345678\",\"organizationId\":\"...\",\"ledgerId\":\"...\"}"
    ```

    | Form                                                 | Result  |
    | ---------------------------------------------------- | ------- |
    | `{"value":"{\"ispb\":\"12345678\",...}"}` — a string | correct |
    | `{"value":{"ispb":"12345678",...}}` — an object      | refused |

    Let `jq` do the escaping:

    ```bash theme={null}
    # Replace all three with your own values before you run this.
    INSTITUTION_ISPB="12345678"                                    # 8 digits, your institution's ISPB
    MIDAZ_ORGANIZATION_ID="3fa85f64-5717-4562-b3fc-2c963f66afa6"   # same value as the deployment variable
    MIDAZ_LEDGER_ID="9c858901-8a57-4791-81fe-4a34d4dd8ab5"         # same value as the deployment variable

    # 1) build the document
    BINDING_DOCUMENT="$(jq -nc \
            --arg ispb   "$INSTITUTION_ISPB" \
            --arg orgId  "$MIDAZ_ORGANIZATION_ID" \
            --arg ledgerId "$MIDAZ_LEDGER_ID" \
            '{ispb:$ispb, organizationId:$orgId, ledgerId:$ledgerId}')"

    # 2) wrap the document as a STRING inside {"value": ...} and write it
    curl -s -o /dev/null -w '%{http_code}\n' -X PUT \
      -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" -H 'Content-Type: application/json' \
      -d "$(jq -nc --arg document "$BINDING_DOCUMENT" '{value:$document}')" \
      "$PIX_JD_BASE_URL/system/tenancy/jd_integration_binding"
    ```

    | Field            | Rule                                                   | Required on write  | Read at run time  |
    | ---------------- | ------------------------------------------------------ | ------------------ | ----------------- |
    | `ispb`           | exactly 8 digits, `0`–`9`, no mask and no spaces       | yes                | in both modes     |
    | `organizationId` | a valid, non-zero UUID (the all-zeros UUID is refused) | yes, in both modes | multi-tenant only |
    | `ledgerId`       | a valid, non-zero UUID                                 | yes, in both modes | multi-tenant only |

    <Note>
      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.
    </Note>

    <Warning>
      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.
    </Warning>

    **How to check it landed.** Read the key back. It holds no secret, so the value comes back in the clear:

    ```bash theme={null}
    curl -s -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" \
      "$PIX_JD_BASE_URL/system/tenancy/jd_integration_binding" | jq
    ```

    ```json theme={null}
    {
      "namespace": "tenancy",
      "key": "jd_integration_binding",
      "value": "{\"ispb\":\"12345678\",\"organizationId\":\"...\",\"ledgerId\":\"...\"}"
    }
    ```

    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.

    <Note>
      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.
    </Note>

    <Warning>
      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.
    </Warning>

    | Response               | What it means                                                       |
    | ---------------------- | ------------------------------------------------------------------- |
    | `204`                  | written                                                             |
    | `400 validation_error` | the **value** was refused for its format — not a permission problem |
    | `400 unknown_key`      | the key or namespace name is wrong                                  |
    | `401`                  | the bearer did not authenticate                                     |
    | `403`                  | authenticated, but without `systemplane:write`                      |
    | `404`                  | the `/system` group is not mounted — `SYSTEMPLANE_ENABLED` is false |
    | `503`                  | the configuration plane is unavailable                              |

    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.
  </Step>

  <Step title="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.

    ```
    tenant_policy/routing.<profile>.operation_credit_route
    tenant_policy/routing.<profile>.operation_debit_route
    ```

    | Profile                   | When it is used                                        |
    | ------------------------- | ------------------------------------------------------ |
    | `out`                     | a payment sent out                                     |
    | `out_reversal`            | the reversal of a sent payment                         |
    | `in`                      | a credit received                                      |
    | `in_qrcode`               | a credit received through a QR code                    |
    | `intra_psp`               | a payment between two accounts at your own institution |
    | `intra_psp_reversal`      | the reversal of that payment                           |
    | `med_credit`, `med_debit` | the two legs of a MED (fraud) refund                   |
    | `pixautomatico_debit`     | the Pix Automático debit                               |
    | `pixautomatico_reversal`  | the Pix Automático reversal                            |

    **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.

    ```bash theme={null}
    # OPERATION_ROUTES is the table YOU fill with the UUIDs the ledger step returned:
    # one line per profile and leg, "<profile> <leg> <operation route UUID>". Twenty
    # lines when every flow is provisioned. There is no magic function here.
    OPERATION_ROUTES="
    out                    credit 1f0a4c2e-5b91-4d77-9a10-6c3b8e2f0a01
    out                    debit  1f0a4c2e-5b91-4d77-9a10-6c3b8e2f0a02
    out_reversal           credit 1f0a4c2e-5b91-4d77-9a10-6c3b8e2f0a03
    out_reversal           debit  1f0a4c2e-5b91-4d77-9a10-6c3b8e2f0a04
    "

    operation_route_id() {
      printf '%s\n' "$OPERATION_ROUTES" | awk -v profile="$1" -v leg="$2" \
        '$1 == profile && $2 == leg { print $3 }'
    }

    for profile in out out_reversal in in_qrcode intra_psp intra_psp_reversal \
                   med_credit med_debit pixautomatico_debit pixautomatico_reversal; do
      for leg in credit debit; do
        uuid="$(operation_route_id "$profile" "$leg")"
        if [ -z "$uuid" ]; then
          echo "MISSING the UUID for $profile.$leg — that flow will refuse with PIX-0105" >&2
          continue
        fi
        curl -s -o /dev/null -w "$profile/$leg -> %{http_code}\n" -X PUT \
          -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" -H 'Content-Type: application/json' \
          -d "{\"value\":\"$uuid\"}" \
          "$PIX_JD_BASE_URL/system/tenant_policy/routing.$profile.operation_${leg}_route"
      done
    done
    ```

    **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.

    <Warning>
      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.
    </Warning>
  </Step>

  <Step title="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:

    | Mode          | Posting asset                            | Clearing account                            |
    | ------------- | ---------------------------------------- | ------------------------------------------- |
    | Single-tenant | `MIDAZ_ASSET_ID` (a deployment variable) | `MIDAZ_EXTERNAL_ID` (a deployment variable) |
    | Multi-tenant  | `tenant_policy/midaz.asset_id`           | `tenant_policy/midaz.external_id`           |

    <Warning>
      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.
    </Warning>

    **Both names lie about their shape.** Despite the `_ID`:

    | Value                | What it wants                          |
    | -------------------- | -------------------------------------- |
    | the posting asset    | the asset **code**: `BRL`              |
    | the clearing account | the account **alias**: `@external/BRL` |

    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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    PUT() { curl -s -o /dev/null -w "$1 -> %{http_code}\n" -X PUT \
              -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" -H 'Content-Type: application/json' \
              -d "$2" "$PIX_JD_BASE_URL/system/$1"; }

    PUT tenant_policy/transaction_limits.daily_period_init '{"value":6}'
    PUT tenant_policy/transaction_limits.daily_period_end  '{"value":20}'
    ```

    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.
  </Step>

  <Step title="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`.

    ```bash theme={null}
    curl -s -o /dev/null -w '%{http_code}\n' -X PUT \
      -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" -H 'Content-Type: application/json' \
      -d '{"value":false}' \
      "$PIX_JD_BASE_URL/system/plugin-br-pix-jd.indirects/enabled"
    ```

    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](#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.
  </Step>

  <Step title="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](/en/reference/interfaces/pix-jd/pix-jd-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.

    <Warning>
      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.
    </Warning>

    **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.
  </Step>
</Steps>

## 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                                                           | Where |
| - | -------------------------------------------------------------- | ----- |
| 1 | the ISPB of this deployment (`tenancy/jd_integration_binding`) | above |
| 2 | organization, ledger, asset, accounts, CRM holders             | above |
| 3 | the twenty accounting route legs                               | above |
| 4 | the posting asset and the clearing account                     | above |
| 5 | the daily window                                               | above |
| 6 | the transaction limit rows, which have no creation route       | above |
| 7 | **the delivery-secret encryption key**                         | below |
| 8 | **the hosting posture, `indirects/enabled = true`**            | below |
| 9 | **registering each indirect participant**                      | below |

<Warning>
  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.
</Warning>

<Note>
  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](/en/reference/interfaces/pix-jd/indirect-participants).
</Note>

### 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.

```text theme={null}
409  PIX-0107  "Indirect Delivery Encryption Not Provisioned"
     "No delivery-secret encryption key is provisioned for this tenant, so the
      indirect participant was not saved and no secret was stored. ..."
```

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.

<Note>
  **`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.
</Note>

<Warning>
  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.
</Warning>

**Where it lives, and why it is not in the systemplane.** This contrast explains where each kind of value belongs on this rail.

|                 | The ISPB (`tenancy/jd_integration_binding`)                           | The encryption key                                          |
| --------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- |
| What it is      | **identity** — the number that identifies you at BACEN                | **a credential** — cryptographic material                   |
| Is it a secret? | no. An ISPB, an organization id, and a ledger id are not credentials  | yes                                                         |
| Where it lives  | the **systemplane**, so it can be read and written over the admin API | **outside** the systemplane                                 |
| Single-tenant   | a systemplane key                                                     | the `INDIRECTS_DELIVERY_ENCRYPTION_KEY` deployment variable |
| Multi-tenant    | a systemplane key, per tenant                                         | your deployment's secret store, per tenant                  |

<Warning>
  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.
</Warning>

**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.

```bash theme={null}
openssl rand -hex 32
```

Generate one per deployment. Do not copy another environment's key and do not reuse another service's.

<Note>
  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.
</Note>

In single-tenant, set the deployment variable:

```bash theme={null}
# in the process environment — never in a versioned file
INDIRECTS_DELIVERY_ENCRYPTION_KEY="paste-the-64-hex-characters-here"
```

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.

<Warning>
  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.
</Warning>

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.

<Warning>
  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".
</Warning>

*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.

<Warning>
  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.
</Warning>

### Step 8: declare that this tenant hosts indirect participants

`plugin-br-pix-jd.indirects/enabled` has to be `true`.

```bash theme={null}
curl -s -o /dev/null -w '%{http_code}\n' -X PUT \
  -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" -H 'Content-Type: application/json' \
  -d '{"value":true}' \
  "$PIX_JD_BASE_URL/system/plugin-br-pix-jd.indirects/enabled"
```

`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](/en/reference/interfaces/pix-jd/indirect-participants#before-you-register-anyone).

<Note>
  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.
</Note>

### 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.

```bash theme={null}
# The signing secret does NOT go on the command line: the value lands in your
# shell history and in CI logs. Type it with `read -rs`, or read it from a vault.
read -rs -p 'delivery secret for this participant: ' INDIRECT_DELIVERY_SECRET; echo

curl -s -X POST "$PIX_JD_BASE_URL/v1/indirects" \
  -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg secret "$INDIRECT_DELIVERY_SECRET" '{
    name: "Indirect PSP Ltda",
    ispb: "87654321",
    messagingMode: "raw",
    delivery: { endpointUrl: "https://indirect.example.com/pix", secret: $secret }
  }')"
```

| Field                  | Rule                                                              | If it is wrong           |
| ---------------------- | ----------------------------------------------------------------- | ------------------------ |
| `name`                 | 1 to 120 characters                                               | `422 PIX-0098`           |
| `ispb`                 | exactly 8 digits — the **indirect institution's** ISPB, not yours | `422 PIX-0098`           |
| `delivery.endpointUrl` | a valid `https` URL, where the notices are delivered              | `422 PIX-0098`           |
| `delivery.secret`      | the symmetric signing secret, agreed with that institution        | `422 PIX-0098` if absent |
| `messagingMode`        | `raw` is the only value today                                     | `422 PIX-0098`           |

A `201` means the participation is ready to use: registration is atomic, `status` is always `ACTIVE`, and there is nothing to poll for.

<Warning>
  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.
</Warning>

<Warning>
  **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.
</Warning>

The refusals, the lifecycle actions, and how to read the registry back are in [Indirect participants](/en/reference/interfaces/pix-jd/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`.

| Key                        | Type    | Range   | Default | What it is                                                                                                                                        |
| -------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`                  | boolean | —       | `false` | the hosting posture                                                                                                                               |
| `delivery_concurrency`     | integer | 1–256   | `8`     | how many delivery `POST`s run in parallel. Isolation is per participant: one stuck endpoint occupies at most one slot and never blocks the others |
| `delivery_max_attempts`    | integer | 1–64    | `8`     | the retry budget before a delivery row ends up `INVALID`. Recovery from there is manual                                                           |
| `resolution_cache_ttl_sec` | integer | 0–86400 | `30`    | how long a resolution stays cached. `0` disables the cache, which is why the minimum is 0                                                         |
| `validate_ispb_on_jd`      | boolean | —       | `false` | with `true`, registration queries JD's participant directory and **fails retryably** if JD is down                                                |

```bash theme={null}
PUT() { curl -s -o /dev/null -w "$1 -> %{http_code}\n" -X PUT \
          -H "Authorization: Bearer $PIX_JD_BEARER_TOKEN" -H 'Content-Type: application/json' \
          -d "$2" "$PIX_JD_BASE_URL/system/$1"; }

PUT plugin-br-pix-jd.indirects/enabled                  '{"value":true}'
PUT plugin-br-pix-jd.indirects/delivery_concurrency     '{"value":8}'
PUT plugin-br-pix-jd.indirects/delivery_max_attempts    '{"value":8}'
PUT plugin-br-pix-jd.indirects/resolution_cache_ttl_sec '{"value":30}'
PUT plugin-br-pix-jd.indirects/validate_ispb_on_jd      '{"value":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.

<Note>
  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`.
</Note>

## 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.

| Missing                                          | Response       | What you see                                                                                                 |
| ------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------ |
| the ISPB binding                                 | `409 PIX-0092` | every money route refuses. The text asks you to contact support and names no key                             |
| one accounting route leg                         | `409 PIX-0105` | only the flows using that profile refuse; the others keep working                                            |
| the posting asset or the clearing account        | `409 PIX-0106` | every posting refuses, and the response names both halves and where to set them                              |
| the transaction limit rows                       | `404 PIX-0063` | *"The specified transaction limit was not found in the system. Please verify the identifier and try again."* |
| the delivery encryption key (indirect flow only) | `409 PIX-0107` | every indirect registration refuses; nothing was stored                                                      |

<Note>
  `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.
</Note>

### 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.

| Your setup is incomplete — provision, do not retry                          | The source did not answer — retry                                                 |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `409 PIX-0092` the tenant's Pix integration is not provisioned              | `503 PIX-0051` the tenant's Pix integration could not be reached                  |
| `409 PIX-0121` the binding exists and its `ispb` is not 8 digits            | `503 PIX-0051` (same sibling: the binding could not be read)                      |
| `409 PIX-0106` the posting asset or the clearing account is not provisioned | `503 PIX-0122` the ledger identity could not be read from the configuration plane |
| `409 PIX-0107` the delivery-secret encryption key is not provisioned        | `503 PIX-0123` the key could not be read from its key source                      |

<Warning>
  **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.
</Warning>

<Note>
  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.
</Note>

The full catalog, with the exact `detail` each code carries, is the [Pix JD error list](/en/reference/interfaces/pix-jd/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.

<Note>
  **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.
</Note>

## Where to go next

<Columns cols={2}>
  <Card title="Environment variables" href="/en/interfaces/pix-jd/pix-jd-environment-variables">
    The deploy-time configuration of this rail: JD connectivity, the ledger and CRM endpoints, QR hosting, and the notification providers.
  </Card>

  <Card title="Indirect participants" href="/en/reference/interfaces/pix-jd/indirect-participants">
    What a hosted participation is, how an inbound credit reaches one, its lifecycle, and every refusal it can answer.
  </Card>

  <Card title="Direct Pix via JD" href="/en/interfaces/pix-jd/direct-pix-via-jd">
    How settled Pix movements land in Midaz, and how the two systems correlate.
  </Card>

  <Card title="Pix JD error list" href="/en/reference/interfaces/pix-jd/pix-jd-error-list">
    Every `PIX-NNNN` code, its status, and the `detail` text the response carries.
  </Card>
</Columns>
