SeptemCore LogoSeptemCore
PrimitivesMoney

Transactions

Credit and debit operations. Transfer between wallets. All amounts are positive integers (cents). Idempotency-Key UUID required. SELECT FOR UPDATE concurrent protection. Lock ordering by wallet UUID ASC prevents deadlocks. PostgreSQL deadlock auto-retry max 3 (100/200/400ms).

Transactions are the atomic balance operations of the Money Service. Every operation — credit, debit, transfer — runs inside a single PostgreSQL ACID transaction and produces an immutable record in the transactions table.


Amount Rules

Before any transaction, verify the amount:

RuleValueError on violation
TypePositive integer (cents)400 Bad Requestinvalid-amount
Minimum1 cent (amount > 0)400 Bad Requestinvalid-amount
Zero amountNot allowed400 Bad Requestinvalid-amount
Negative amountNot allowed400 Bad Requestinvalid-amount
MaximumPlan-defined (default $100 000 = 10 000 000 cents)422 Unprocessable EntityLIMIT_EXCEEDED
$12.50  → 1250
$0.01   → 1
$100    → 10000

Idempotency Key

Every balance-modifying operation requires an Idempotency-Key header (UUID v4 or v7). A duplicate request with the same key returns the original result without re-executing the operation.

POST https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/credit
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "amount":      5000,
  "description": "Subscription payment",
  "metadata":    { "invoiceId": "01j9painv100000000000000" }
}

If the same Idempotency-Key is sent again (e.g. after a network timeout), the Money Service returns the original 201 Created response without crediting again. The idempotency key is stored for 24 hours after the operation.


Credit

Add funds to a wallet.

const tx = await kernel.money().credit({
  walletId:       '01j9paw1t000000000000000',
  amount:         5000,           // $50.00 in cents
  idempotencyKey: crypto.randomUUID(),
  description:    'Subscription payment',
  metadata:       { invoiceId: '01j9painv100000000000000' },
});

// tx:
// {
//   transactionId: '01j9patx1000000000000000',
//   type:          'credit',
//   status:        'completed',
//   amount:        5000,
//   currency:      'USD',
//   walletId:      '01j9paw1t000000000000000',
//   balanceAfter: {
//     available: 15000,
//     pending:   0,
//     frozen:    0
//   },
//   createdAt: '2026-04-15T10:30:00.000Z'
// }
POST https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/credit
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "amount":      5000,
  "description": "Subscription payment",
  "metadata":    { "invoiceId": "01j9painv100000000000000" }
}

Response 201 Created:

{
  "transactionId": "01j9patx1000000000000000",
  "type":          "credit",
  "status":        "completed",
  "amount":        5000,
  "currency":      "USD",
  "walletId":      "01j9paw1t000000000000000",
  "balanceAfter": {
    "available": 15000,
    "pending":   0,
    "frozen":    0
  },
  "createdAt": "2026-04-15T10:30:00.000Z"
}

Debit

Withdraw funds from a wallet. The service checks that available >= amount before executing. If insufficient: 400 Insufficient Funds.

const tx = await kernel.money().debit({
  walletId:       '01j9paw1t000000000000000',
  amount:         2500,           // $25.00 in cents
  idempotencyKey: crypto.randomUUID(),
  description:    'Service fee',
});
POST https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/debit
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: 7f9c12a4-8b3e-44f0-9d2c-1a5e6b7c8d90

{
  "amount":      2500,
  "description": "Service fee"
}

Response 201 Created:

{
  "transactionId": "01j9patx2000000000000000",
  "type":          "debit",
  "status":        "completed",
  "amount":        2500,
  "currency":      "USD",
  "walletId":      "01j9paw1t000000000000000",
  "balanceAfter": {
    "available": 12500,
    "pending":   0,
    "frozen":    0
  },
  "createdAt": "2026-04-15T10:31:00.000Z"
}

Transfer

Transfer funds between two wallets atomically. Both wallets must belong to the same tenant. Wallets may be in different currencies only if the module provides pre-converted amounts — the Money Service does not perform currency conversion.

const tx = await kernel.money().transfer({
  fromWalletId:   '01j9paw1t000000000000000',
  toWalletId:     '01j9paw2u000000000000000',
  amount:         3000,           // $30.00 in cents
  idempotencyKey: crypto.randomUUID(),
  description:    'Internal transfer',
});
POST https://api.septemcore.com/v1/wallets/transfer
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890

{
  "fromWalletId": "01j9paw1t000000000000000",
  "toWalletId":   "01j9paw2u000000000000000",
  "amount":       3000,
  "description":  "Internal transfer"
}

Response 201 Created:

{
  "transactionId": "01j9patx3000000000000000",
  "type":          "transfer",
  "status":        "completed",
  "amount":        3000,
  "fromWalletId":  "01j9paw1t000000000000000",
  "toWalletId":    "01j9paw2u000000000000000",
  "fromBalanceAfter": { "available": 9500, "pending": 0, "frozen": 0 },
  "toBalanceAfter":   { "available": 3000, "pending": 0, "frozen": 0 },
  "createdAt": "2026-04-15T10:32:00.000Z"
}

Transfer lock ordering (deadlock prevention)

When two concurrent transfers involve the same pair of wallets in opposite directions, naive locking causes a deadlock. The Money Service prevents this by always locking wallets in ascending UUID order:

Transfer A: wallet-1 → wallet-2
  Lock order: wallet-1 (lower UUID), then wallet-2

Transfer B: wallet-2 → wallet-1
  Lock order: wallet-1 (lower UUID), then wallet-2

Both acquire the same lock order → no deadlock.

SQL:

SELECT ... FOR UPDATE
WHERE id IN ($fromWalletId, $toWalletId)
ORDER BY id ASC

If PostgreSQL signals a deadlock (40P01), the Money Service retries automatically up to 3 times with exponential backoff:

AttemptDelay
1 (first try)— (immediate)
2100 ms
3200 ms
4 (max)400 ms, then 500 Internal Server Error

Get Transaction Details

GET https://api.septemcore.com/v1/transactions/01j9patx1000000000000000
Authorization: Bearer <access_token>
{
  "transactionId": "01j9patx1000000000000000",
  "type":          "credit",
  "status":        "completed",
  "amount":        5000,
  "currency":      "USD",
  "walletId":      "01j9paw1t000000000000000",
  "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
  "description":   "Subscription payment",
  "metadata":      { "invoiceId": "01j9painv100000000000000" },
  "balanceAfter": {
    "available": 15000,
    "pending":   0,
    "frozen":    0
  },
  "reversed":  false,
  "createdAt": "2026-04-15T10:30:00.000Z"
}

Transaction History

GET https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/transactions?page_size=20&page_token=01j9patx...
Authorization: Bearer <access_token>
{
  "data": [
    {
      "transactionId": "01j9patx2000000000000000",
      "type":          "debit",
      "status":        "completed",
      "amount":        2500,
      "currency":      "USD",
      "description":   "Service fee",
      "reversed":      false,
      "createdAt":     "2026-04-15T10:31:00.000Z"
    }
  ]
}

Transactions are retained for 7 years (AML/KYC compliance). The transactions table is partitioned by created_at (monthly partitions: transactions_2026_03, transactions_2026_04, …). Queries use a composite index (wallet_id, created_at DESC) on each partition for O(log n) retrieval without full-table scans.


Concurrent Debit Protection

Simultaneous debit operations on the same wallet are serialized using PostgreSQL row-level pessimistic locking:

Thread A: debit $50 on wallet-1
Thread B: debit $50 on wallet-1 (simultaneously)

PostgreSQL:
  Thread A: acquires SELECT FOR UPDATE on wallet-1 row (~ms)
  Thread B: waits for Thread A to release lock
  Thread A: checks available >= 5000 ✅ → UPDATE → COMMIT → release lock
  Thread B: acquires lock
  Thread B: checks available >= 5000 (available now = original - 5000)
    → if sufficient: proceeds
    → if insufficient: 400 Insufficient Funds

A CHECK (balance >= 0) constraint in PostgreSQL provides a second layer of protection. Negative balance is architecturally impossible.


Error Reference

ScenarioHTTPCode
Insufficient available balance400INSUFFICIENT_FUNDS
amount is zero or negative400INVALID_AMOUNT
amount exceeds plan limit422LIMIT_EXCEEDED
Duplicate Idempotency-Key200Original response returned (no re-execution)
Missing Idempotency-Key header400validation-error
Wallet not found404not-found
Cross-tenant wallet access403forbidden
Transfer: same wallet as source and target400validation-error
Deadlock exhausted retries (3)500INTERNAL_ERROR

On this page