Skip to content
Ledger by RODMEN/A
Menu

Documentation

Sub-tenants

Give each of your own clients an isolated ledger: production and sandbox tenants, keys issued and revoked, suspend and restore, all through the API.

Last updated 2026-09-05.

If you resell, host or integrate on behalf of other businesses, you can give each of them their own tenant inside your account: their own accounts, their own journal, their own key. They cannot see each other, and they cannot see you.

Every request and response below was captured from a running instance.

  • Base URL: https://ledger.rodmena.co.uk
  • You need: a key with the ledger_partner role

Who this is for

You want toRead
Post transactions for your own businessGetting started
Give your clients each an isolated ledgerthis page

A partner is an ordinary tenant that is allowed to create and administer sub-tenants. A sub-tenant is a full tenant: isolated journal, its own accounts, its own credentials.

Two things to know before you start, because they shape everything else:

  • ledger_partner cannot post. It administers clients and nothing else — it cannot create an account or move a penny, in your tenant or theirs. Administering clients and transacting are deliberately different credentials, so a leaked partner key cannot move money and a leaked posting key cannot create tenants.
  • A sub-tenant cannot have sub-tenants. The hierarchy is exactly one level deep, and it is enforced in the database rather than by convention.

1. Get a partner key

Not yet self-service. Ask the ledger operator; they run:

python -m ledger.admin provision-tenant --name "Your Co" --slug your-co \
    --owner-sub <your identity sub> --contact-email ops@your.co
python -m ledger.admin assign-plan --tenant <tenant-uuid> --plan ledger-free
python -m ledger.admin mint-key --tenant <tenant-uuid> --label partner --role ledger_partner

The secret is shown once. If you lose it you get a new key, not the old one back.

You will usually want a second key with ledger_poster or ledger_admin for your own business’s transactions. One tenant can hold both.


2. Create a client

curl -X POST https://ledger.rodmena.co.uk/v1/sub-tenants \
  -H "Authorization: Bearer rak_..." -H 'Content-Type: application/json' \
  -d '{"name":"Acme Retail","kind":"production","contact_email":"ops@acme.example"}'
{
    "tenant_id": "799c3e0b-7ce2-4faa-868f-77b256c208e7",
    "name": "Acme Retail",
    "status": "active",
    "kind": "production",
    "created_at": "2026-08-23T00:35:11.857680+00:00"
}

kind is production or sandbox, and it is immutable. A sandbox exists so an integrator can make permanent mistakes somewhere that is not production; letting it be reclassified later would destroy exactly that. Give every client a sandbox as well as a production tenant — the journal is append-only, so there is no way to tidy up test data afterwards.

curl -X POST https://ledger.rodmena.co.uk/v1/sub-tenants \
  -H "Authorization: Bearer rak_..." -H 'Content-Type: application/json' \
  -d '{"name":"Acme Retail — sandbox","kind":"sandbox"}'
{
    "tenant_id": "d5da2c1e-e5a4-4486-afa8-baec59136b96",
    "name": "Acme Retail — sandbox",
    "status": "active",
    "kind": "sandbox",
    "created_at": "2026-08-23T00:35:11.909148+00:00"
}

You never supply a parent. It is taken from your credential, so there is nothing to get wrong and nothing to forge.


3. List your clients

curl https://ledger.rodmena.co.uk/v1/sub-tenants -H "Authorization: Bearer rak_..."
{
    "sub_tenants": [
        {
            "tenant_id": "d5da2c1e-e5a4-4486-afa8-baec59136b96",
            "name": "Acme Retail — sandbox",
            "status": "active",
            "kind": "sandbox",
            "created_at": "2026-08-23T00:35:11.909148+00:00"
        },
        {
            "tenant_id": "799c3e0b-7ce2-4faa-868f-77b256c208e7",
            "name": "Acme Retail",
            "status": "active",
            "kind": "production",
            "created_at": "2026-08-23T00:35:11.857680+00:00"
        }
    ]
}

You see your own clients and nobody else’s. This lists the tenant rows — it is not a way into a client’s ledger. A partner key reads and writes only your own tenant’s data: point it at /v1/accounts and you get your accounts, never theirs. Administering a client is not the same as being able to see their money, and there is no endpoint that crosses that line.

Asking about a tenant that is not your client returns 404 SUB_TENANT_NOT_FOUND — the same answer as one that does not exist, so the API never confirms the existence of somebody else’s tenant.


4. Mint a key for a client

curl -X POST https://ledger.rodmena.co.uk/v1/sub-tenants/799c3e0b-.../keys \
  -H "Authorization: Bearer rak_..." -H 'Content-Type: application/json' \
  -d '{"label":"acme-prod","role":"ledger_poster","ttl_days":36500}'
{
    "sub_tenant_id": "799c3e0b-7ce2-4faa-868f-77b256c208e7",
    "key_id": "ea857849-5194-4c63-908e-c0ebb8d0918f",
    "role": "ledger_poster",
    "label": "acme-prod",
    "expires_at": "2026-11-21T00:35:23.281835+00:00",
    "api_key": "rak_wuGvb1ystovC...",
    "warning": "This is the only time the key is shown. It cannot be re-read."
}

role is ledger_reader, ledger_poster (default) or ledger_admin — the same ladder your own keys use. You cannot mint a ledger_partner key for a client: that is what keeps the hierarchy one level deep.

ttl_days defaults to 36500 (about a hundred years) and may be 1–36500. A key still has an expiry — the column is never null — but the default is long enough that it is not a scheduled outage, and a short life is something you opt into rather than inherit.

Choose a short one deliberately, and write the date down, because nothing here will remind you. No job scans expiry: the only places it is read are the request that refuses on it, the listing below, and the operator’s tooling. The first sign of an expiry is a 401 CREDENTIAL_EXPIRED in production.

That matters most for a partner key, where an expiry is a lockout rather than an outage: the partner key is what mints replacements, so once it lapses you cannot mint your way out and must ask the operator. A sub-tenant key is always reissuable with the partner key, so a deliberately short life there — a setup credential you let die on purpose — costs you nothing.

Expiry is not a security control on a leaked key. The authorization service does not expire keys at all; this deadline is the ledger’s own. If a credential is compromised, revoke it — that is immediate and has no cache window. Waiting for an expiry does not bound the damage.

Hand the secret over immediately and never store it. It is not recoverable — the authorization service keeps only its SHA-256. If minting half-fails, the key is revoked rather than left live, and it is never retried automatically: a blind retry mints a second live credential that nobody ever saw.


5. See a client’s keys

curl https://ledger.rodmena.co.uk/v1/sub-tenants/799c3e0b-.../keys \
  -H "Authorization: Bearer rak_..."
{
    "keys": [
        {
            "key_id": "ea857849-5194-4c63-908e-c0ebb8d0918f",
            "key_prefix": "rak_wuGvb1ys",
            "label": "acme-prod",
            "status": "active",
            "created_at": "2026-08-23T00:35:23.374626+00:00",
            "expires_at": "2026-11-21T00:35:23.281835+00:00",
            "last_used_at": null
        }
    ]
}

Metadata only — the secret is not here and cannot be recovered. last_used_at is null until the key is used, then records use to a five-minute granularity: it answers “is anyone still using this?” before you revoke, not “when exactly was the last call”.

Revoke one:

curl -X DELETE https://ledger.rodmena.co.uk/v1/sub-tenants/799c3e0b-.../keys/ea857849-...

Revocation is immediate — the next request with that key is 401, with no cache window.


6. Suspend and restore a client

This is the control to reach for when a client stops paying, or when you think a credential of theirs has leaked and you want everything stopped at once.

curl -X PATCH https://ledger.rodmena.co.uk/v1/sub-tenants/799c3e0b-... \
  -H "Authorization: Bearer rak_..." -H 'Content-Type: application/json' \
  -d '{"status":"suspended"}'
{
    "tenant_id": "799c3e0b-7ce2-4faa-868f-77b256c208e7",
    "name": "Acme Retail",
    "status": "suspended",
    "kind": "production",
    "created_at": "2026-08-23T00:35:11.857680+00:00"
}

While suspended, every key belonging to that client is refused:

{"error":{"code":"TENANT_SUSPENDED","message":"this tenant is not active (status: suspended)"}}

with HTTP 403. Set "status":"active" and their keys work again on the very next request — suspension holds nothing hostage and destroys nothing. Their journal is untouched throughout; suspension stops access, it does not alter history.

Note what suspension is not: it is not a delete. Nothing removes a posted transaction, ever, for anyone. If a client leaves, suspend them and keep the record.

If you are suspended, so are your clients. Should your own tenant ever be suspended, every one of your sub-tenants stops too — their traffic is metered against your plan, so they cannot keep trading on an account that has been stopped. Your clients see the same 403 TENANT_SUSPENDED.

You can also rename a client ({"name":"..."}). You cannot change kind or reparent them; both are refused with 422 INVALID_TENANT.


What your clients see

Nothing about you, and nothing about each other. A client’s key behaves exactly like a direct customer’s:

  • they read and write only their own accounts and entries;
  • another tenant’s account id returns 404, never 403 — an id they cannot use does not become an oracle telling them it exists;
  • they cannot list, create or administer sub-tenants of their own (403).

Their traffic is metered against your plan, since they are your clients. One busy client consumes your allowance, so watch usage as you grow the roster.


Errors you will actually hit

StatusCodeMeans
403FORBIDDENyour key lacks ledger_partner — a posting key cannot administer clients
403TENANT_SUSPENDEDthe tenant, or its parent, is not active
404SUB_TENANT_NOT_FOUNDnot your client — you can only administer your own
422INVALID_TENANThierarchy rule: no nesting, no reparenting, kind is immutable
422INVALID_KEYbad role, missing label, or ttl_days outside 1–36500

Next