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

# Create Transaction

> Moves a customer's balance out — to another customer of yours (`toEntityId`), or to a blockchain address (`toAddress`). This is the operation behind the dashboard's own Transfer and Withdraw actions; a merchant's own backend key reaches the identical rail.

Both destinations settle the same way: on-chain, through the real withdrawal rail — compliance screening, your withdrawal policy, custody signing, all of it. `toEntityId` and `toAddress` differ only in how the receiver is named — an existing customer's own deposit address, resolved for you, or an address you supply directly — never in what actually happens to the money. There is no ledger-only transfer here: crediting a receiver without a real chain movement behind it would let their balance outgrow what custody actually holds at their address.

If your withdrawal policy requires sign-off at this amount and `approvals` doesn't yet meet it, this call refuses outright with `400 APPROVALS_REQUIRED` — it does not queue itself for later. **Retrying with the same `idempotencyKey` will not pick up new approvals**, even after the threshold is met: the funds this request tried to lock are already unwound under that key, and replaying it returns that same outcome again. Collect every required approval before calling this endpoint, or use a new `idempotencyKey` once you have them.

The dashboard's own held-for-approval queue — where an under-approved transfer waits and is completed later once approved — is something the dashboard's backend layers in front of this same call. Calling this endpoint directly does not get you that queue; it is on you to gather approvals up front.

**Requires:** `ledger:write` or `transfer:create`



## OpenAPI

````yaml /openapi/core.openapi.json post /v1/transactions
openapi: 3.0.0
info:
  title: KeyPair Core
  description: |-
    The API a merchant's server calls directly. Provisioning — creating
    tenants, issuing keys — happens through the control plane and is not
    described here.

    ## Authentication

    Every route needs an API key, and the tenant comes from the key, never
    from a header you control.

    `Authorization: Bearer kp_test_…` is the simple form. For anything that
    moves money, prefer **signed requests**: send only the key's public
    prefix plus an Ed25519 signature over
    `timestamp \n nonce \n METHOD \n path \n sha256(body)`, so no secret
    travels and a captured request cannot be replayed or altered. See
    `examples/api-client.ts` for a zero-dependency implementation.

    ## Amounts

    Always **signed integer strings in minor units** — debits negative,
    credits positive, summing to zero per currency. Never floats: JSON
    numbers lose precision above 2^53, and 1 ETH is 10^18 wei.

    ## Idempotency

    Every mutating call takes an `idempotencyKey`. Derive it from your own
    domain objects — an order id, a payout id — never randomly. A random
    key makes a timeout unrecoverable: you cannot tell whether the first
    call landed, and retrying pays twice.

    ## Errors

    Every error, from any layer, has the shape `{ code, message }`. Retry
    `429` (honouring `Retry-After`) and `5xx` with the *same* idempotency
    key. `422 INSUFFICIENT_FUNDS`, `400 UNBALANCED_TRANSACTION` and
    `403 DESTINATION_SCREENED` are terminal.
  version: 1.0.0
  contact: {}
servers:
  - url: http://localhost:3000
    description: Local
security: []
tags:
  - name: Entities
    description: The people and businesses you serve
  - name: Accounts
    description: Balances, statements, verification
  - name: Transactions
    description: Posting and reversing money movement
  - name: Activity
    description: Recent transactions and holdings, summarised
  - name: Compliance
    description: KYC on your customers, and held deposits
  - name: Policies & allowlists
    description: What a customer withdrawal is checked against
  - name: Treasury
    description: 'Company wallets: gas, policy, and spending them'
  - name: Reconciliation
    description: Whether the ledger agrees with custody
  - name: Tenant administration
    description: Your own keys, custody key, and webhooks
  - name: Operations
    description: Everything else
paths:
  /v1/transactions:
    post:
      tags:
        - Transactions
      summary: Create Transaction
      description: >-
        Moves a customer's balance out — to another customer of yours
        (`toEntityId`), or to a blockchain address (`toAddress`). This is the
        operation behind the dashboard's own Transfer and Withdraw actions; a
        merchant's own backend key reaches the identical rail.


        Both destinations settle the same way: on-chain, through the real
        withdrawal rail — compliance screening, your withdrawal policy, custody
        signing, all of it. `toEntityId` and `toAddress` differ only in how the
        receiver is named — an existing customer's own deposit address, resolved
        for you, or an address you supply directly — never in what actually
        happens to the money. There is no ledger-only transfer here: crediting a
        receiver without a real chain movement behind it would let their balance
        outgrow what custody actually holds at their address.


        If your withdrawal policy requires sign-off at this amount and
        `approvals` doesn't yet meet it, this call refuses outright with `400
        APPROVALS_REQUIRED` — it does not queue itself for later. **Retrying
        with the same `idempotencyKey` will not pick up new approvals**, even
        after the threshold is met: the funds this request tried to lock are
        already unwound under that key, and replaying it returns that same
        outcome again. Collect every required approval before calling this
        endpoint, or use a new `idempotencyKey` once you have them.


        The dashboard's own held-for-approval queue — where an under-approved
        transfer waits and is completed later once approved — is something the
        dashboard's backend layers in front of this same call. Calling this
        endpoint directly does not get you that queue; it is on you to gather
        approvals up front.


        **Requires:** `ledger:write` or `transfer:create`
      operationId: TransactionController_postTransaction
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EntityTransactionDto'
      responses:
        '201':
          description: ''
        '400':
          description: Malformed request, or a ledger rule refused it. `code` says which.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Missing, malformed, or revoked API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: The key is valid but lacks the scope this operation requires.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >-
            Idempotency conflict: this key was used with a different request
            body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - apiKey: []
        - signed: []
components:
  schemas:
    EntityTransactionDto:
      type: object
      properties:
        fromEntityId:
          type: string
          description: The customer whose balance this moves out of.
        idempotencyKey:
          type: string
          minLength: 8
          maxLength: 255
        asset:
          type: string
          description: Chain-qualified asset code, e.g. USDT_TRON.
          pattern: /^[A-Z0-9_]{2,16}$/
        amount:
          type: string
          description: Minor units, as a string — an amount is never a float.
          pattern: /^[1-9][0-9]{0,38}$/
        toEntityId:
          type: string
          description: 'Internal: another customer of the same merchant.'
        toAddress:
          type: string
          description: 'External: a blockchain address. Goes through the withdrawal rail.'
          minLength: 16
          maxLength: 120
        memoTag:
          type: string
          maxLength: 64
        approvals:
          description: Approver identities, when withdrawal policy requires sign-off.
          type: array
          items:
            type: string
        reason:
          type: string
          description: >-
            The merchant's own label for this movement — "sell", "buy", "sweep".

            Recorded for audit; never branched on, so a new product name needs
            no

            change here.
          maxLength: 40
        metadata:
          type: object
      required:
        - fromEntityId
        - idempotencyKey
        - asset
        - amount
    Error:
      type: object
      properties:
        code:
          type: string
          description: Stable, machine-readable. Branch on this, not on the message.
          example: INSUFFICIENT_FUNDS
        message:
          type: string
          description: Human-readable, and subject to change. Do not parse.
        details:
          type: array
          items:
            type: string
          description: >-
            Present on validation failures: one entry per field that was
            rejected. Absent otherwise.
      required:
        - code
        - message
  securitySchemes:
    apiKey:
      scheme: bearer
      bearerFormat: JWT
      type: http
      description: Your API key, `kp_test_…` or `kp_live_…`.
    signed:
      type: apiKey
      in: header
      name: x-keypair-key-id
      description: >-
        Signed requests: the key's public prefix. Send with x-keypair-timestamp,
        x-keypair-nonce and x-keypair-signature.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.