Guide

Money posting

You run the payments. Octolit runs the trading account. Money posting is the one call that connects them: your cashier confirms a payment, you book it on the account, the client trades. Every posting is idempotent, audited and reflected in the balance at once.

#The principle

The platform never moves money without your call
Octolit books what you post - nothing more. It does not charge cards, does not talk to your payment provider and does not create deposits on its own. Only money posting moves money, and only through the CRM's own transaction service - the same path a back-office "Add transaction" by your staff takes. Everything else that changes a balance is trading itself.
Endpoint
POST …/users/{external_id}/transactions
Scope
balance:write - off by default
Idempotency
required, 1 - 128 characters, unique per company
Answer
201 booked · 200 replayed

#Before you start

  • The brand switch Money posting is on (403 brand_disabled otherwise).
  • Your key carries balance:write. Only the CRM owner or an admin with the "Edit transactions" right can put that scope on a key - treat the key like a payment credential: IP allow-list, short expiry, never on the web tier.
  • You know the user's account_id values (GET …/accounts). Without account_id the first account is used.
  • You have a stable reference per payment on your side (PSP id, invoice number). It becomes the idempotency key.

#Post a deposit

Your payment provider confirmed a card payment of 250 EUR. Book it as approved; the balance is updated in the same call.

POST /api/external/v1/users/cust_10042/transactions
curl -X POST "https://api.your-brand.com/api/external/v1/users/cust_10042/transactions" \
  -H "X-Api-Key: $OCTOLIT_PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "kind": "deposit",
  "amount": "250.00",
  "currency": "EUR",
  "account_id": 88213,
  "status": "approved",
  "method": "card",
  "reference": "psp_9f31c2",
  "idempotency_key": "dep-psp_9f31c2"
}'
201 Created
{
  "transaction": {
    "id": 551203,
    "kind": "deposit",
    "status": "approved",
    "amount": "250.00",
    "currency": "EUR",
    "account_id": 88213,
    "method": "card",
    "reference": "psp_9f31c2",
    "note": null,
    "created_at": "2026-11-11T09:40:12Z"
  },
  "balance": {
    "currency": "EUR",
    "balance": "250.00",
    "equity": "250.00",
    "margin": "0",
    "free_margin": "250.00",
    "margin_level": null,
    "open_pl": "0",
    "credit": "0",
    "as_of": "2026-11-11T09:40:12Z"
  },
  "idempotency_key": "dep-psp_9f31c2",
  "replayed": false
}
  • amount is a string to keep the exact decimals (up to 10). Numbers are accepted too.
  • currency defaults to the account's currency. Sending it is a cheap guard against booking EUR on a USD account (422).
  • method is free text shown in the CRM (default partner_api); reference is your id and is sent back in events; note is for your staff.
  • transaction.id is the CRM transaction id. It is the same id the deposit.status event carries - store it next to your payment.
  • Every posting leaves an audit row and a line in the client's history naming the key: "Partner API deposit via key '…'".

#Kinds and status

kindEffectEvent
depositCash in. Raises the balance; sets the first-deposit date on the client the first time; triggers the deposit e-mails and postbacks the CRM is configured for.deposit.status
withdrawalCash out. Refused over the free margin (409 insufficient_funds). Usually status: pending so the CRM back office approves it.withdrawal.status
creditCredit in: a credit line (bonus) the client can trade with but not withdraw. Shown separately as credit.-
credit_outCredit removed. Refused over the current credit (409 insufficient_funds).-

status is approved (default) or pending:

  • approved counts at once. Use it when your side already settled the payment.
  • pending is booked but waits for approval in the Octolit CRM (back office). When staff approve or decline, deposit.status / withdrawal.status arrive with previous_status. Use it for withdrawals, or for deposits your compliance team must look at first.

The money is booked by the same transaction service a back-office "Add transaction" uses: balance update, first-deposit date, client e-mails and postbacks all behave the same, and for an MT account the balance operation is sent to the MT server before the answer.

#Idempotency

Payments are retried by everyone - your PSP webhook, your job runner, your own code after a timeout. The idempotency key makes every retry safe:

  1. Derive the key from the payment

    Use your PSP's id or your own payment row id, with a short prefix: dep-psp_9f31c2. Never a timestamp, never a random value per attempt. Send it as idempotency_key in the body or as the Idempotency-Key header - either, not both with different values.

  2. Repeat the same call as often as you like

    The second call with the same key and the same body returns the stored answer: 200 with replayed: true and the original transaction.id. Nothing is booked twice.

  3. Handle the two conflicts

    Same key, different body → 409 idempotency_conflict: a bug on your side, do not "fix" it by changing the key. Same key while the first call is still running → 409 in_progress: wait a second and send the identical call again.

409 Conflict
{
  "error": {
    "code": "idempotency_conflict",
    "message": "The same Idempotency-Key was used with a different body."
  }
}

Keys are unique per company, across all your keys and users. A key is never reusable, even after a year.

#Withdrawals

A withdrawal is checked against the account's free margin, not the balance: with open positions the client cannot withdraw margin that is in use. Over the limit the call answers 409 insufficient_funds with details.available and books nothing. The usual pattern is status: pending - the withdrawal waits for approval in the CRM, and your back office approves it after the payout went out.

POST …/transactions (withdrawal, key in the header)
curl -X POST "https://api.your-brand.com/api/external/v1/users/cust_10042/transactions" \
  -H "X-Api-Key: $OCTOLIT_PARTNER_KEY" \
  -H "Idempotency-Key: wd-req_20481" \
  -H "Content-Type: application/json" \
  -d '{
  "kind": "withdrawal",
  "amount": "100.00",
  "account_id": 88213,
  "status": "pending",
  "method": "bank",
  "reference": "req_20481",
  "note": "IBAN ending 4471"
}'
409 Conflict
{
  "error": {
    "code": "insufficient_funds",
    "message": "The withdrawal exceeds the free margin.",
    "details": {
      "available": "221.20"
    }
  }
}

When the payout fails on your side, your staff decline the pending transaction in the CRM; withdrawal.status tells you with the new status and previous_status.

#Credit (bonus)

credit adds to the credit line, credit_out removes from it. Credit is shown separately as credit in every balance answer and is never cash: it cannot be withdrawn, and a credit_out over the current credit answers 409 insufficient_funds. Use a key that encodes the campaign and the client so a bonus is never granted twice.

POST …/transactions (credit)
curl -X POST "https://api.your-brand.com/api/external/v1/users/cust_10042/transactions" \
  -H "X-Api-Key: $OCTOLIT_PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "kind": "credit",
  "amount": "50",
  "account_id": 88213,
  "reference": "welcome-bonus",
  "note": "Welcome bonus, 30 days",
  "idempotency_key": "bonus-cust_10042-2026-11"
}'

#Reconcile

Two sources, use both:

  1. The balance endpoint - the truth right now

    GET …/balance returns the user's figures across all accounts plus one entry per account. balance is cash, credit the credit line, equity includes open profit and loss, as_of says how fresh the numbers are. For MT accounts these are the last figures the connector reported.

    GET /api/external/v1/users/cust_10042/balance
    curl "https://api.your-brand.com/api/external/v1/users/cust_10042/balance" \
      -H "X-Api-Key: $OCTOLIT_PARTNER_KEY"
    200 OK
    {
      "balance": {
        "currency": "EUR",
        "balance": "250.00",
        "equity": "262.40",
        "margin": "41.20",
        "free_margin": "221.20",
        "margin_level": "636.9",
        "open_pl": "12.40",
        "credit": "50",
        "as_of": "2026-11-11T10:02:00Z",
        "accounts": [
          {
            "id": 88213,
            "name": "Main EUR",
            "currency": "EUR",
            "demo": false,
            "type": {
              "id": 4,
              "name": "Standard",
              "provider": "internal"
            },
            "balance": "250.00",
            "equity": "262.40",
            "margin": "41.20",
            "free_margin": "221.20",
            "margin_level": "636.9",
            "open_pl": "12.40",
            "closed_pl": "0",
            "credit": "50",
            "mt_login": null,
            "created_at": "2026-11-11T09:31:00Z"
          }
        ]
      }
    }
  2. Webhooks - every change as it happens

    deposit.status and withdrawal.status fire for every transaction whose status changes - your postings and the ones your staff enter in the CRM alike. Match on data.transaction.id (your postings) or on reference, and on data.client.external_id for the client. Unknown ids are deposits you did not post: book them on your side.

  3. A nightly comparison

    Sum your approved postings per account and compare with balance minus trading results. A difference means a posting you lost (check replayed answers you ignored) or a back-office entry you did not consume. The CRM's transaction list shows the method and reference of every row for exactly this.

#Demo vs live

  • Demo accounts take postings too - that is how you top up a practice account from your own UI. The same endpoint, the same key rules; the account's demo: true tells them apart in your books.
  • Never mix: derive the key from the account as well when the same payment reference can exist in both worlds (demo-topup-88214-2026-11-11).
  • Send account_id on every posting. Without it the user's first account is used - which may be the live one.

#Errors

403brand_disabledThe brand's "Money posting" switch is off.
403insufficient_scopeThe key has no balance:write.
404-No client with this external_id.
409idempotency_conflictSame key, different body. Pick a new key for a new posting.
409in_progressThe first call with this key is still being booked. Wait a moment and repeat the same call.
409insufficient_fundsWithdrawal over free margin or credit_out over credit; details.available tells how much there is.
422invalid_requestA field is wrong; details.errors names it (amount not > 0, unknown currency, account not the user's).
422money_rejectedThe CRM's transaction service refused the posting (for example trading is paused for the user). Nothing was booked.

Rule of thumb: on 409 in_progress, 429 and 5xx retry the identical call with backoff. On every other error stop and alert; nothing was booked.

#Go-live checklist

  • The key with balance:write lives only on the server that talks to your PSP; IP allow-list set.
  • Every posting carries an idempotency key derived from the payment, and your retry sends the identical body.
  • transaction.id is stored next to the payment; replayed: true is treated as success.
  • Withdrawals are posted pending and your back office approves them in the CRM after the payout.
  • deposit.status / withdrawal.status are consumed and unknown transactions are booked on your side.
  • A nightly comparison against GET …/balance runs and alerts on differences.
  • Tested on a demo account first, then with one live posting of a small amount.

Reference: post a transaction, balance, deposit.status, error codes.