Skip to main content

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:

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?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

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