Build the Credit and Debit Interface
The core endpoints every bridge must implement to participate in money movement
When the ledger moves money through a wallet backed by your bridge, it calls your bridge to coordinate the operation. Your bridge needs to implement six endpoints — three for debits (money leaving) and three for credits (money arriving). Each set follows the same prepare → commit → abort pattern from the two-phase commit protocol.
These are the only endpoints you must implement to have a working bridge. Everything else — status notifications and effects — is optional.
How it works
Every request from the ledger arrives as a signed JSON payload. Your bridge verifies the signature against the ledger's public key, then responds 202 Accepted immediately. The actual result is reported by submitting a proof — a signed statement posted to POST /v2/intents/:handle/proofs that tells the ledger what happened.
Never block the HTTP response while processing. Return 202 Accepted right away. The ledger learns the outcome through the proof your bridge submits afterward.
Debit endpoints
Debit endpoints are called when value leaves a wallet backed by your bridge. The debits trait must be declared in your bridge registration.
Prepare — POST /v2/debits
The ledger asks: "can you debit this account?" Your bridge validates the operation and optionally reserves the funds.
curl -X POST "https://{ledger-host}/v2/debits" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"data": {
"handle": "deb_01u9RGCevt4rEkRMV",
"schema": "debit",
"amount": 10000,
"symbol": { "handle": "usd" },
"source": {
"handle": "sav:42424242@mybank.co",
"custom": {
"name": "Alice Johnson",
"documentType": "ssn",
"documentNumber": "123-45-6789"
}
},
"target": {
"handle": "chk:10500029@yourbank.co",
"custom": {
"name": "Bob Smith",
"documentType": "ssn",
"documentNumber": "987-65-4321"
}
},
"intent": {
"data": {
"handle": "payment-bridge-001",
"claims": [{
"action": "transfer",
"amount": 10000,
"source": { "handle": "sav:42424242@mybank.co" },
"symbol": { "handle": "usd" },
"target": { "handle": "chk:10500029@yourbank.co" }
}],
"schema": "payment"
},
"hash": "96fcf594...",
"meta": { "status": "pending" }
}
}
}'import {
CoreAdapter, PrepareResult, ResultStatus,
TransactionContext,
} from '@minka/bridge-sdk'
import {
BridgeAccountNotFound, BridgeAccountInactive,
BridgeAccountInsufficientBalance, LedgerAddress,
LedgerAmount,
} from '@minka/ledger-sdk'
class DebitAdapter extends CoreAdapter {
async prepare(context: TransactionContext): Promise<PrepareResult> {
const entry = context.entry.data
// Parse source address — for debits, source is your bank
const { handle: accountNumber, domain } =
LedgerAddress.parse(entry.source.handle)
// Validate account in your banking core
const account = await bankingSdk.getAccount(accountNumber)
if (!account) {
throw new BridgeAccountNotFound(accountNumber)
}
if (!account.isActive()) {
throw new BridgeAccountInactive('Account disabled.')
}
// Convert amount (ledger sends integers)
const amount = await LedgerAmount.toDecimal(entry.amount, 100)
// Reserve or debit funds
const tx = await bankingSdk.debit(accountNumber, amount)
if (tx.status !== 'COMPLETED') {
throw new BridgeAccountInsufficientBalance(
`Insufficient balance: ${accountNumber}`
)
}
// Bridge SDK submits the proof automatically
return {
status: ResultStatus.Prepared,
coreId: `${domain}.${tx.id}`,
}
}
}@RestController
@RequestMapping("/v2")
public class DebitController {
private final BankingSdk bankingSdk;
@PostMapping("/debits")
public ResponseEntity<Void> prepare(@RequestBody DebitRequest request) {
var entry = request.getData();
var address = LedgerAddress.parse(entry.getSource().getHandle());
var account = bankingSdk.getAccount(address.getHandle());
if (account == null) {
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"failed", "bridge.account-not-found", null);
return ResponseEntity.accepted().build();
}
if (!account.isActive()) {
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"failed", "bridge.account-inactive", null);
return ResponseEntity.accepted().build();
}
var amount = BigDecimal.valueOf(entry.getAmount()).movePointLeft(2);
var tx = bankingSdk.debit(address.getHandle(), amount);
if (!"COMPLETED".equals(tx.getStatus())) {
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"failed", "bridge.account-insufficient-balance", null);
return ResponseEntity.accepted().build();
}
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"prepared", null, address.getDomain() + "." + tx.getId());
return ResponseEntity.accepted().build();
}
}[ApiController]
[Route("v2")]
public class DebitController : ControllerBase
{
private readonly BankingSdk _bankingSdk;
[HttpPost("debits")]
public IActionResult Prepare([FromBody] DebitRequest request)
{
var entry = request.Data;
var address = LedgerAddress.Parse(entry.Source.Handle);
var account = _bankingSdk.GetAccount(address.Handle);
if (account == null)
{
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"failed", "bridge.account-not-found", null);
return Accepted();
}
if (!account.IsActive())
{
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"failed", "bridge.account-inactive", null);
return Accepted();
}
var amount = entry.Amount / 100m;
var tx = _bankingSdk.Debit(address.Handle, amount);
if (tx.Status != "COMPLETED")
{
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"failed", "bridge.account-insufficient-balance", null);
return Accepted();
}
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"prepared", null, $"{address.Domain}.{tx.Id}");
return Accepted();
}
}What your bridge does:
- Save the entry by its
handle(your idempotency key — always starts withdeb_) - Validate: account exists, is active, has sufficient balance, passes compliance checks
- Reserve or debit the funds in your banking core
- Submit a proof with
status: preparedand the banking core's transaction ID ascoreId - If validation fails: submit a proof with
status: failed, a reason code, and detail message
Key fields in the payload:
- handle — unique debit entry ID. Track this through commit/abort.
- source — the wallet being debited (your bridge's side), with optional
custommetadata (name, document type, document number). - target — the counterparty wallet, with optional
custommetadata identifying the recipient. - amount / symbol — how much and in what currency.
- intent — the full intent with all claims and context for business rule validation.
Commit — POST /v2/debits/:handle/commit
The ledger says: "all participants agreed — finalize the debit."
curl -X POST "https://{ledger-host}/v2/debits/deb_01u9RGCevt4rEkRMV/commit" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"data": {
"action": "commit",
"handle": "deb_01u9RGCevt4rEkRMV",
"intent": {
"data": { "handle": "payment-bridge-001", "schema": "payment" },
"hash": "96fcf594...",
"meta": { "status": "committed" }
}
}
}'import { CommitResult, ResultStatus, TransactionContext } from '@minka/bridge-sdk'
async commit(context: TransactionContext): Promise<CommitResult> {
const entry = context.entry.data
// If funds were reserved in prepare, finalize now
// If already debited in prepare, this is a no-op
const tx = await bankingSdk.finalizeDebit(entry.handle)
// Bridge SDK submits the committed proof automatically
return {
status: ResultStatus.Committed,
coreId: tx?.id ? `mybank.co.${tx.id}` : undefined,
}
}@PostMapping("/debits/{handle}/commit")
public ResponseEntity<Void> commit(@PathVariable String handle,
@RequestBody CommitRequest request) {
var entry = request.getData();
var tx = bankingSdk.finalizeDebit(entry.getHandle());
var coreId = tx != null ? "mybank.co." + tx.getId() : null;
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"committed", null, coreId);
return ResponseEntity.accepted().build();
}[HttpPost("debits/{handle}/commit")]
public IActionResult Commit(string handle, [FromBody] CommitRequest request)
{
var entry = request.Data;
var tx = _bankingSdk.FinalizeDebit(entry.Handle);
var coreId = tx != null ? $"mybank.co.{tx.Id}" : null;
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"committed", null, coreId);
return Accepted();
}Your bridge loads the previously saved entry by handle, finalizes the deduction on the banking core, then submits a committed proof with the banking core's transaction ID:
curl -X POST "https://{ledger-host}/v2/intents/payment-bridge-001/proofs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"method": "ed25519-v2",
"public": "<bridge-signer-public-key>",
"digest": "<sha256-hash>",
"result": "<ed25519-signature>",
"custom": {
"moment": "2026-03-23T14:30:02.000Z",
"handle": "deb_01u9RGCevt4rEkRMV",
"status": "committed",
"coreId": "TXN-2026-0323-00849"
}
}'Commit must always succeed. If the banking core is unavailable, retry internally. Never return a failure. See Handle Errors and Retries.
Abort — POST /v2/debits/:handle/abort
The ledger says: "something went wrong — reverse everything."
curl -X POST "https://{ledger-host}/v2/debits/deb_01u9RGCevt4rEkRMV/abort" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"data": {
"action": "abort",
"handle": "deb_01u9RGCevt4rEkRMV",
"intent": {
"data": { "handle": "payment-bridge-001", "schema": "payment" },
"hash": "96fcf594...",
"meta": { "status": "aborted" }
}
}
}'import {
AbortResult, ResultStatus, TransactionContext
} from '@minka/bridge-sdk'
import {
BridgeUnexpectedCoreError, LedgerAddress, LedgerAmount
} from '@minka/ledger-sdk'
async abort(context: TransactionContext): Promise<AbortResult> {
const entry = context.entry.data
const { handle: accountNumber } =
LedgerAddress.parse(entry.source.handle)
const amount = LedgerAmount.toDecimal(entry.amount, 100)
// Find the original debit transaction
const original = await bankingSdk.findByToken(
`${entry.handle}-debit`
)
if (original?.status === 'COMPLETED') {
// Reverse by crediting the amount back
const reversal = await bankingSdk.credit(
accountNumber, amount, `${entry.handle}-reverse`
)
if (reversal.status !== 'COMPLETED') {
// Retry — abort must never fail permanently
throw new BridgeUnexpectedCoreError(reversal.errorReason)
}
return {
status: ResultStatus.Aborted,
coreId: `mybank.co.${reversal.id}`,
}
}
// Nothing to reverse
return { status: ResultStatus.Aborted }
}@PostMapping("/debits/{handle}/abort")
public ResponseEntity<Void> abort(@PathVariable String handle,
@RequestBody AbortRequest request) {
var entry = request.getData();
var address = LedgerAddress.parse(entry.getSource().getHandle());
var amount = BigDecimal.valueOf(entry.getAmount()).movePointLeft(2);
var original = bankingSdk.findByToken(entry.getHandle() + "-debit");
if (original != null && "COMPLETED".equals(original.getStatus())) {
var reversal = bankingSdk.credit(
address.getHandle(), amount, entry.getHandle() + "-reverse");
if (!"COMPLETED".equals(reversal.getStatus())) {
throw new RuntimeException("Abort reversal failed: " + reversal.getErrorReason());
}
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"aborted", null, "mybank.co." + reversal.getId());
} else {
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"aborted", null, null);
}
return ResponseEntity.accepted().build();
}[HttpPost("debits/{handle}/abort")]
public IActionResult Abort(string handle, [FromBody] AbortRequest request)
{
var entry = request.Data;
var address = LedgerAddress.Parse(entry.Source.Handle);
var amount = entry.Amount / 100m;
var original = _bankingSdk.FindByToken($"{entry.Handle}-debit");
if (original?.Status == "COMPLETED")
{
var reversal = _bankingSdk.Credit(
address.Handle, amount, $"{entry.Handle}-reverse");
if (reversal.Status != "COMPLETED")
{
throw new Exception($"Abort reversal failed: {reversal.ErrorReason}");
}
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"aborted", null, $"mybank.co.{reversal.Id}");
}
else
{
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"aborted", null, null);
}
return Accepted();
}If funds were deducted during prepare, create a reversal transaction. If funds were held, release the hold. Then submit an aborted proof — include the coreId of the reversal transaction if one was created:
curl -X POST "https://{ledger-host}/v2/intents/payment-bridge-001/proofs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"method": "ed25519-v2",
"public": "<bridge-signer-public-key>",
"digest": "<sha256-hash>",
"result": "<ed25519-signature>",
"custom": {
"moment": "2026-03-23T14:30:03.000Z",
"handle": "deb_01u9RGCevt4rEkRMV",
"status": "aborted",
"coreId": "TXN-2026-0323-00850"
}
}'Abort must always succeed, just like commit. An incomplete abort leaves phantom holds on customer accounts.
Credit endpoints
Credit endpoints are called when value arrives at a wallet backed by your bridge. The credits trait must be declared. The protocol mirrors debits — the difference is in the banking operation.
Prepare — POST /v2/credits
The ledger asks: "can this account receive funds?"
curl -X POST "https://{ledger-host}/v2/credits" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"data": {
"handle": "cre_baC0LUTVW9lzYA284",
"schema": "credit",
"amount": 10000,
"symbol": { "handle": "usd" },
"source": {
"handle": "sav:42424242@mybank.co",
"custom": {
"name": "Alice Johnson",
"documentType": "ssn",
"documentNumber": "123-45-6789"
}
},
"target": {
"handle": "chk:10500029@yourbank.co",
"custom": {
"name": "Bob Smith",
"documentType": "ssn",
"documentNumber": "987-65-4321"
}
},
"intent": {
"data": {
"handle": "payment-bridge-001",
"claims": [{
"action": "transfer",
"amount": 10000,
"source": { "handle": "sav:42424242@mybank.co" },
"symbol": { "handle": "usd" },
"target": { "handle": "chk:10500029@yourbank.co" }
}],
"schema": "payment"
},
"hash": "a04fc92b...",
"meta": { "status": "pending" }
}
}
}'import {
CoreAdapter, PrepareResult, CommitResult, AbortResult,
ResultStatus, TransactionContext,
} from '@minka/bridge-sdk'
import {
BridgeAccountNotFound, BridgeAccountInactive,
BridgeEntryRejected, LedgerAddress, LedgerAmount,
} from '@minka/ledger-sdk'
class CreditAdapter extends CoreAdapter {
async prepare(context: TransactionContext): Promise<PrepareResult> {
const entry = context.entry.data
// Parse target address — for credits, target is your bank
const { handle: accountNumber, domain } =
LedgerAddress.parse(entry.target.handle)
// Validate target account
const account = await bankingSdk.getAccount(accountNumber)
if (!account) {
throw new BridgeAccountNotFound(accountNumber)
}
if (!account.isActive()) {
throw new BridgeAccountInactive('Account disabled.')
}
// Validate currency
if (entry.symbol.handle !== 'usd') {
throw new BridgeEntryRejected(
`Expected usd, got ${entry.symbol.handle}`
)
}
// Credit prepare: validation only, no money moves
return { status: ResultStatus.Prepared }
}
}@RestController
@RequestMapping("/v2")
public class CreditController {
private final BankingSdk bankingSdk;
@PostMapping("/credits")
public ResponseEntity<Void> prepare(@RequestBody CreditRequest request) {
var entry = request.getData();
var address = LedgerAddress.parse(entry.getTarget().getHandle());
var account = bankingSdk.getAccount(address.getHandle());
if (account == null) {
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"failed", "bridge.account-not-found", null);
return ResponseEntity.accepted().build();
}
if (!account.isActive()) {
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"failed", "bridge.account-inactive", null);
return ResponseEntity.accepted().build();
}
if (!"usd".equals(entry.getSymbol().getHandle())) {
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"failed", "bridge.validation-failed", null);
return ResponseEntity.accepted().build();
}
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"prepared", null, null);
return ResponseEntity.accepted().build();
}
}[ApiController]
[Route("v2")]
public class CreditController : ControllerBase
{
private readonly BankingSdk _bankingSdk;
[HttpPost("credits")]
public IActionResult Prepare([FromBody] CreditRequest request)
{
var entry = request.Data;
var address = LedgerAddress.Parse(entry.Target.Handle);
var account = _bankingSdk.GetAccount(address.Handle);
if (account == null)
{
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"failed", "bridge.account-not-found", null);
return Accepted();
}
if (!account.IsActive())
{
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"failed", "bridge.account-inactive", null);
return Accepted();
}
if (entry.Symbol.Handle != "usd")
{
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"failed", "bridge.validation-failed", null);
return Accepted();
}
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"prepared", null, null);
return Accepted();
}
}What your bridge does:
- Save the entry by its
handle(starts withcre_) - Validate: target account exists, is active, accepts the currency, no restrictions
- Submit a proof with
status: prepared - If validation fails: submit a failed proof with reason and detail
Credit prepare typically does not move money — just validation. The actual credit happens in commit.
Commit — POST /v2/credits/:handle/commit
Same protocol as debit commit. This is where you actually credit the target bank account.
curl -X POST "https://{ledger-host}/v2/credits/cre_baC0LUTVW9lzYA284/commit" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"data": {
"action": "commit",
"handle": "cre_baC0LUTVW9lzYA284",
"intent": {
"data": { "handle": "payment-bridge-001", "schema": "payment" },
"hash": "a04fc92b...",
"meta": { "status": "committed" }
}
}
}'async commit(context: TransactionContext): Promise<CommitResult> {
const entry = context.entry.data
const { handle: accountNumber } =
LedgerAddress.parse(entry.target.handle)
const amount = await LedgerAmount.toDecimal(entry.amount, 100)
// Now actually credit the target account
const tx = await bankingSdk.credit(
accountNumber, amount, `${entry.handle}-credit`
)
return {
status: ResultStatus.Committed,
coreId: `yourbank.co.${tx.id}`,
}
}@PostMapping("/credits/{handle}/commit")
public ResponseEntity<Void> commit(@PathVariable String handle,
@RequestBody CommitRequest request) {
var entry = request.getData();
var address = LedgerAddress.parse(entry.getTarget().getHandle());
var amount = BigDecimal.valueOf(entry.getAmount()).movePointLeft(2);
var tx = bankingSdk.credit(
address.getHandle(), amount, entry.getHandle() + "-credit");
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"committed", null, "yourbank.co." + tx.getId());
return ResponseEntity.accepted().build();
}[HttpPost("credits/{handle}/commit")]
public IActionResult Commit(string handle, [FromBody] CommitRequest request)
{
var entry = request.Data;
var address = LedgerAddress.Parse(entry.Target.Handle);
var amount = entry.Amount / 100m;
var tx = _bankingSdk.Credit(
address.Handle, amount, $"{entry.Handle}-credit");
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"committed", null, $"yourbank.co.{tx.Id}");
return Accepted();
}Submit a proof with status: committed and the coreId of the credit transaction.
Abort — POST /v2/credits/:handle/abort
Same protocol as debit abort. Reverse any holds or preliminary entries created during prepare.
curl -X POST "https://{ledger-host}/v2/credits/cre_baC0LUTVW9lzYA284/abort" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"data": {
"action": "abort",
"handle": "cre_baC0LUTVW9lzYA284",
"intent": {
"data": { "handle": "payment-bridge-001", "schema": "payment" },
"hash": "a04fc92b...",
"meta": { "status": "aborted" }
}
}
}'async abort(context: TransactionContext): Promise<AbortResult> {
// Credit prepare only validates — no side-effects to reverse
return { status: ResultStatus.Aborted }
}@PostMapping("/credits/{handle}/abort")
public ResponseEntity<Void> abort(@PathVariable String handle,
@RequestBody AbortRequest request) {
var entry = request.getData();
submitProof(entry.getIntent().getData().getHandle(), entry.getHandle(),
"aborted", null, null);
return ResponseEntity.accepted().build();
}[HttpPost("credits/{handle}/abort")]
public IActionResult Abort(string handle, [FromBody] AbortRequest request)
{
var entry = request.Data;
SubmitProof(entry.Intent.Data.Handle, entry.Handle,
"aborted", null, null);
return Accepted();
}Proof reference
Every proof your bridge submits to POST /v2/intents/:handle/proofs follows the same structure. The custom object carries the phase-specific data:
- moment — ISO 8601 timestamp of when the operation completed on the bridge side.
- handle — the debit or credit entry handle. Must match the handle from the prepare call.
- status — one of:
prepared,committed,failed,aborted. - coreId — the transaction ID from your banking core. Include this whenever a balance movement occurred. This is the primary reconciliation key between the ledger and the external system.
- reason — (failed proofs only) a structured error code such as
bridge.account-insufficient-balance. - detail — (failed proofs only) a human-readable error message.
When using @minka/bridge-sdk, proof submission is handled automatically. The SDK signs and submits the proof based on the ResultStatus and coreId you return from your adapter methods. You only need to submit proofs manually if you are implementing the bridge REST interface directly.
When validation fails during prepare, submit a proof with status: failed:
{
"custom": {
"status": "failed",
"handle": "deb_01u9RGCevt4rEkRMV",
"reason": "bridge.account-insufficient-balance",
"detail": "Insufficient balance in account: 42424242",
"moment": "2026-03-23T14:30:00.000Z"
}
}Common reason codes: bridge.account-insufficient-balance, bridge.account-not-found, bridge.account-frozen, bridge.account-inactive, bridge.validation-failed.
Failed proofs are only valid during the prepare phase. Once the ledger sends commit or abort, your bridge must succeed — failed proofs are not accepted. See Handle Errors and Retries.
Handle conventions
- Debit handles start with
deb_(e.g.,deb_01u9RGCevt4rEkRMV) - Credit handles start with
cre_(e.g.,cre_baC0LUTVW9lzYA284)
Handles are unique and stable across the entire lifecycle. Use them as your idempotency key — if you receive the same handle twice for the same phase, it is a retry. Respond 202 Accepted without reprocessing.
Get Notifications
Optional endpoints — intent status updates and effect signals.
Build a Banking Core Bridge
Step-by-step tutorial implementing these endpoints with real TypeScript code.