# Internal Transactions API

Internal Transactions move funds **book-to-book** between two Monato accounts (same institution). These do not go through SPEI, so there’s no CEP and settlement is typically near-real-time when both instruments are active and funded.

## Overview

- **Rail:** Internal (book-to-book)
- **CEP:** Not applicable
- **Latency:** Near-real-time
- **Use cases:**
  - Move funds between Cost Centers / Business Units
  - Intra-merchant payouts
  - Reserve account management
  - Move funds to another Monato customer
- **Webhooks / Notifications:**
  - The credit leg of a successful internal transaction **may** generate a `MONEY_IN` webhook to the **destination instrument** (the client that owns the destination instrument), only if they have a `MONEY_IN` webhook registered.
  - Webhook emission depends on ownership (see **Webhooks / Notifications** section below).
  - Payload format matches the **Money In confirmation** guide, with `sub_category = "INT_CREDIT"`.


> **Parity with Money Out:** Request body format and most validations are the same as **Money Out**, with a few **extra internal checks** noted below.


## Endpoint

`POST /v1/transactions/internal_transaction`

## Request

**Path params:** none
**Query params:** none

**Headers**

- `Authorization: Bearer <JWT>`
- `Content-Type: application/json`


**Body**


```json
{
  "client_id": "c2d1d1e3-3340-4170-980e-e9269bbbc551",
  "source_instrument_id": "709448c3-7cbf-454d-a87e-feb23801269a",
  "destination_instrument_id": "dd7f8d89-94dd-43ca-871b-720fde378b52",
  "transaction_request": {
    "amount": "1.90",
    "currency": "MXN",
    "description": "Internal transfer",
    "external_reference": "1238766"
  }
}
```

**Field notes**

- `source_instrument_id`, `destination_instrument_id`
IDs of two valid internal instruments. They can belong either to your client or to one of your customers, as long as both instruments are internal to Monato.


To discover internal instruments available for your client or customers, use:


```json
GET /v1/clients/{clientId}/instruments
```

with the optional `customer_id` filter.

## Validations

### Field-level

- **amount**
  - Must be a numeric string with **2 decimal places** (e.g., `"1.90"`).
  - Must be **greater than 0** (otherwise backend returns `DATA_ERROR: "Transaction Amount must be higher than 0."`).
- **currency**
  - Only **`"MXN"`** is supported (otherwise: `DATA_ERROR: "Transaction currency unsupported."`).
- **description**
  - **Length < 40** characters (if exceeded: `DATA_ERROR: "Transaction description must have less than 40 characters length."`).
- **external_reference**
  - Must be **numeric** and **max 7 digits** (otherwise: `DATA_ERROR: "External reference should be numeric and have a maximum length of 7 digits."`).
- **IDs**
  - `client_id`, `source_instrument_id`, `destination_instrument_id` must be valid **UUIDs**.


### Instrument state

- **Source**
  - **Exists**, is **active**, **not blocked**, and has **sufficient funds**
(if not: `FAILED_PRECONDITION: "The account does not have sufficient funds."`).
- **Destination**
  - **Exists / is assigned**, **active**, and **not blocked**
(if not: `FAILED_PRECONDITION: "The account is not currently active."`).
- **Same institution (internal rail)**
  - Destination must be **internal to Monato** (if not: `409 external_transfer_not_allowed`).
- **Different instruments**
  - Best practice: `source_instrument_id` **≠** `destination_instrument_id`.


## Response examples

### Success (LIQUIDATED)


```json
{
    "id": "09c9caac-3b74-4690-8ac5-5a01b2559b3f",
    "bankId": "d3435bd9-998d-4e8a-9067-6b71d5fd3ac7",
    "clientId": "b000654b-4d12-46e5-b451-662459b6effc",
    "externalReference": "1238801",
    "trackingId": "20250925FINCHCUCHFGMRLZ",
    "description": "Prueba Internal 25/09/25",
    "amount": "1.00",
    "currency": "MXN",
    "category": "INTER_TRANS",
    "subCategory": "INT_DEBIT",
    "transactionStatus": "LIQUIDATED",
    "audit": {
        "createdAt": "2025-09-25 15:48:28.486316-06:00",
        "updatedAt": "2025-09-25 15:48:28.773897-06:00",
        "deletedAt": "None",
        "blockedAt": "None"
    }
}
```

## Error Examples

### Inactive account — 400 FAILED_PRECONDITION (10-E4120)


```json
{
    "code": 9,
    "message": "API Error",
    "details": [
        {
            "@type": "type.googleapis.com/google.rpc.ErrorInfo",
            "reason": "FAILED_PRECONDITION",
            "domain": "CORE",
            "metadata": {
                "error_detail": "The account is not currently active.",
                "http_code": "400",
                "module": "Transactions",
                "method_name": "InternalTransaction",
                "error_code": "10-E4120"
            }
        }
    ]
}
```

### Insufficient funds — 400 FAILED_PRECONDITION (10-E4120)


```json
{
    "code": 9,
    "message": "API Error",
    "details": [
        {
            "@type": "type.googleapis.com/google.rpc.ErrorInfo",
            "reason": "FAILED_PRECONDITION",
            "domain": "CORE",
            "metadata": {
                "error_detail": "The account does not have sufficient funds.",
                "http_code": "400",
                "module": "Transactions",
                "method_name": "InternalTransaction",
                "error_code": "10-E4120"
            }
        }
    ]
}
```

### Invalid External reference — 400 DATA_ERROR (10-E4120)


```json
{
    "code": 9,
    "message": "API Error",
    "details": [
        {
            "@type": "type.googleapis.com/google.rpc.ErrorInfo",
            "reason": "DATA_ERROR",
            "domain": "CORE",
            "metadata": {
                "error_detail": "External reference should be numeric and have a maximum length of 7 digits.",
                "http_code": "400",
                "module": "Transactions",
                "method_name": "InternalTransaction",
                "error_code": "10-E4120"
            }
        }
    ]
}
```

### Invalid amount (<= 0) — 400 DATA_ERROR (10-E4120)


```json
{
    "code": 9,
    "message": "API Error",
    "details": [
        {
            "@type": "type.googleapis.com/google.rpc.ErrorInfo",
            "reason": "DATA_ERROR",
            "domain": "CORE",
            "metadata": {
                "error_detail": "Transaction Amount must be higher than 0.",
                "http_code": "400",
                "module": "Transactions",
                "method_name": "InternalTransaction",
                "error_code": "10-E4120"
            }
        }
    ]
}
```

### Large description — 400 DATA_ERROR (10-E4120)


```json
{
    "code": 9,
    "message": "API Error",
    "details": [
        {
            "@type": "type.googleapis.com/google.rpc.ErrorInfo",
            "reason": "DATA_ERROR",
            "domain": "CORE",
            "metadata": {
                "error_detail": "Transaction description must have less than 40 characters length.",
                "http_code": "400",
                "module": "Transactions",
                "method_name": "InternalTransaction",
                "error_code": "10-E4120"
            }
        }
    ]
}
```

### Currency not supported — 400 DATA_ERROR (10-E4120)


```json
{
    "code": 9,
    "message": "API Error",
    "details": [
        {
            "@type": "type.googleapis.com/google.rpc.ErrorInfo",
            "reason": "DATA_ERROR",
            "domain": "CORE",
            "metadata": {
                "error_detail": "Transaction currency unsupported.",
                "http_code": "400",
                "module": "Transactions",
                "method_name": "InternalTransaction",
                "error_code": "10-E4120"
            }
        }
    ]
}
```

## Webhooks / Notifications

A successful internal transaction returns the **debit leg** (source side) in the `POST /v1/transactions/internal_transaction` response.

Additionally, the **credit leg** **may** trigger a `MONEY_IN` webhook, depending on ownership:

- **Different owner (inbound credit):** If the destination instrument belongs to a **different `owner_id`** than the initiator (**even if the `client_id` is the same**), Monato sends a `MONEY_IN` webhook to the client that owns the destination instrument (only if a `MONEY_IN` webhook is registered).
  - `sub_category = "INT_CREDIT"`
- **Same owner (self-transfer):** If the destination instrument belongs to the **same `client_id` and same `owner_id`** as the initiator, **no `MONEY_IN` webhook is emitted**.
  - No webhook event is created for replay/resend.
  - In this case, the API response is the source of truth (and you can also fetch the transaction by id for reconciliation).


### Resend / replay behavior (Dashboard)

The dashboard “resend webhook” action only replays **already generated** webhook events.

- For **same-owner self-transfers**, since no webhook event is created, there is nothing to resend.
- Showing “resend webhook” for same-owner internal transfers is a known UI bug and we are addressing it.


### `MONEY_IN` webhook payload (when emitted)

- Uses the common `MONEY_IN` envelope:

```json
{
  "id_msg": "6daea2d2-ccb0-48f3-917c-f387dc8e99b0",
  "msg_name": "MONEY_IN",
  "msg_date": "2025-11-20",
  "body": {
    "...": "..."
  }
}
```
- Includes, in the `body`, fields like:

```json
{
  "id": "a0037594-5064-4dda-896b-f9b5dd4988dd",
  "beneficiary_account": "734185000000001177",
  "beneficiary_name": "MERCHANT TEST",
  "beneficiary_rfc": "FTR230125Q00",
  "payer_account": "734185000000000822",
  "payer_name": "Customer Test-1 Legal",
  "payer_rfc": "ND",
  "payer_institution": "90734",
  "amount": "1.00",
  "transaction_date": "2025-11-20 15:05:59",
  "tracking_key": "20251120FINCHESDHI7FVTU",
  "payment_concept": "CUST - SPEI",
  "numeric_reference": "1100003",
  "sub_category": "INT_CREDIT",
  "registered_at": "2025-11-20T15:05:59.915184-06:00",
  "owner_id": "24f1e5d5-4045-4b1a-a0c4-5e6c6b1d44ef"
}
```


> See the **Money In confirmation** documentation for the full field-by-field description of the `MONEY_IN` webhook payload.


For internal credits, the webhook is **informational**:

* The funds are already moved book-to-book when the webhook is sent.
* HTTP status codes on your response **do not** trigger an automatic rollback or refund.


### Endpoint error mapping (quick reference)

| Failing rule | Typical response |
|  --- | --- |
| Source account inactive/blocked | `400 FAILED_PRECONDITION` (`error_code: 10-E4120`) |
| Insufficient funds (source) | `400 FAILED_PRECONDITION` (`error_code: 10-E4120`) |
| `external_reference` non-numeric or > 7 digits | `400 DATA_ERROR` (`error_code: 10-E4120`) |
| `amount` ≤ 0 | `400 DATA_ERROR` (`error_code: 10-E4120`) |
| `description` ≥ 40 characters | `400 DATA_ERROR` (`error_code: 10-E4120`) |
| `currency` other than MXN | `400 DATA_ERROR` (`error_code: 10-E4120`) |
| Destination not found / not assigned | `404 destination_not_found` |
| Destination is external (not internal rail) | `409 external_transfer_not_allowed` |


### Categories and subCategories for internal transactions

- **Debit leg (source account):**
  - `category = "INTER_TRANS"`
  - `subCategory = "INT_DEBIT"`
- **Credit leg (destination account, as seen in the MONEY_IN webhook or related transaction):**
  - `category = "INTER_TRANS"`
  - `subCategory = "INT_CREDIT"`


⚠️ **Disclaimer**
We plan to unify the APIs in the future so that both external Money Out (SPEI) and internal transactions (between Monato accounts) are supported through a single endpoint.

For now, please use the endpoints separately as documented:

- Use `POST /v1/transactions/internal_transaction` for internal (book-to-book) movements.
- Use the Money Out endpoints for external SPEI transfers.
- There is **no `/refund` endpoint** for internal transactions. If you need to “reverse” an internal movement, you must create a new `internal_transaction` in the opposite direction.