Transactions
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:
| Rule | Value | Error on violation |
|---|---|---|
| Type | Positive integer (cents) | 400 Bad Request — invalid-amount |
| Minimum | 1 cent (amount > 0) | 400 Bad Request — invalid-amount |
| Zero amount | Not allowed | 400 Bad Request — invalid-amount |
| Negative amount | Not allowed | 400 Bad Request — invalid-amount |
| Maximum | Plan-defined (default $100 000 = 10 000 000 cents) | 422 Unprocessable Entity — LIMIT_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:
| Attempt | Delay |
|---|---|
| 1 (first try) | — (immediate) |
| 2 | 100 ms |
| 3 | 200 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?limit=20&cursor=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"
}
],
"pagination": {
"nextCursor": null,
"hasMore": false
}
}
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
| Scenario | HTTP | Code |
|---|---|---|
| Insufficient available balance | 400 | INSUFFICIENT_FUNDS |
amount is zero or negative | 400 | INVALID_AMOUNT |
amount exceeds plan limit | 422 | LIMIT_EXCEEDED |
Duplicate Idempotency-Key | 200 | Original response returned (no re-execution) |
Missing Idempotency-Key header | 400 | validation-error |
| Wallet not found | 404 | not-found |
| Cross-tenant wallet access | 403 | forbidden |
| Transfer: same wallet as source and target | 400 | validation-error |
| Deadlock exhausted retries (3) | 500 | INTERNAL_ERROR |