Sign Payments using code
Implement request signing for the Minka ledger API — hash the payload, build the signature digest, and sign with Ed25519. Required when integrating directly without the CLI or SDK.
Every request that mutates data on the ledger — creating a wallet, submitting an intent, registering an anchor — must be signed before it's sent. The ledger rejects unsigned requests. The CLI handles this automatically when you run commands like minka wallet create, but if you're integrating directly via the API — building a bridge, a backend service, or a custom payment flow — you need to implement the signing yourself.
The signing process has three steps: hash the request body, create a signature digest, and sign the digest with your private key. The example below walks through each step using a wallet creation request as the concrete payload.
The Minka SDK (@minka/ledger-sdk) handles serialization, hashing, digest creation, and signing automatically. You only need to implement signing manually when integrating directly with the REST API — for example, when building a bridge in a language without an SDK, or when your backend needs to sign requests server-side without the CLI.
Prerequisites
You need a signer with an Ed25519 key pair registered on the ledger. If you haven't set one up yet, see How to Connect to a Ledger.
Hash the payload
JSON serialization isn't deterministic — different languages and libraries can produce different property orderings for the same object. Two serializers producing different output would produce different hashes, and the signature would fail verification on the ledger.
The ledger solves this with RFC 8785 — a JSON Canonicalization Scheme that defines a single canonical representation for any JSON value. The most important rule: object properties must be sorted alphabetically. The safe-stable-stringify package implements this standard.
Start with the wallet creation payload you want to sign:
import { stringify } from 'safe-stable-stringify'
import crypto from 'crypto'
// The data object from your API request
const data = {
handle: 'store1@greatcoffee.co',
schema: 'merchant-wallet',
custom: {
name: 'GREAT COFFEE UNICENTRO',
city: 'BOGOTA',
postal: '110111',
type: 'store',
},
}
// Step 1: Canonical serialization (RFC 8785)
function serializeData(data: any): string {
return stringify(data)
}
// Step 2: SHA-256 hash of the serialized data
function createHash(data: any): string {
const serializedData = serializeData(data)
return crypto
.createHash('sha256')
.update(serializedData)
.digest('hex')
}
const dataHash = createHash(data)
// → "7f3a8b..." (64-character hex string)The hash is the same regardless of what language or platform produced the JSON — as long as the serializer follows RFC 8785.
Create a signature digest
The ledger supports attaching additional data to a signature through the custom property on the proof object. This custom data — things like status, bridge metadata, or wallet context — needs to be protected by the signature too.
The solution is a double-hash: take the payload hash, concatenate it with the serialized custom proof data (if any), and hash the result again. This produces a digest that covers both the primary data and any extensions.
function createSignatureDigest(
dataHash: string,
signatureCustom?: Record<string, any>,
): string {
const serializedCustomData = signatureCustom
? serializeData(signatureCustom)
: ''
return crypto
.createHash('sha256')
.update(dataHash + serializedCustomData)
.digest('hex')
}
// For this wallet creation, no custom proof data is needed
const digest = createSignatureDigest(dataHash)
// → "e4d5f6..." (64-character hex string)The double hash applies even when there's no custom data — sha256(dataHash) — because it provides additional protection against certain cryptographic attacks. The custom here refers to proof.custom, not to data.custom on the wallet record. They are different fields.
Sign with your private key
Sign the digest using your Ed25519 private key. The result is a base64-encoded signature that the ledger verifies against your registered public key.
function signDigest(digest: string, privateKeyDer: Buffer): string {
const digestBuffer = Buffer.from(digest, 'hex')
const key = crypto.createPrivateKey({
format: 'der',
type: 'pkcs8',
key: privateKeyDer,
})
// First argument must be undefined for Ed25519.
// Ed25519 uses SHA-512 internally — passing a hash
// algorithm here would break signing and verification.
return crypto.sign(undefined, digestBuffer, key).toString('base64')
}
const signature = signDigest(digest, myPrivateKeyDer)
// → "K7xB2m..." (base64-encoded Ed25519 signature)The crypto.sign() call for Ed25519 requires undefined as the first argument — not 'sha256' or any other algorithm string. Ed25519 applies SHA-512 internally as part of the algorithm. Passing a hash algorithm causes signing and verification to fail silently.
Send the signed request
Attach the hash and proof to the request body alongside the data. The proof contains the signing method, your public key, the digest, and the signature result. Click Run to see the ledger response.
curl -X POST "https://{ledger-host}/v2/wallets" \
-H "Content-Type: application/json" \
-H "x-ledger: payments-hub-demo" \
-d '{
"data": {
"handle": "store1@greatcoffee.co",
"schema": "merchant-wallet",
"custom": {
"name": "GREAT COFFEE UNICENTRO",
"city": "BOGOTA",
"postal": "110111",
"type": "store"
}
},
"hash": "7f3a8b2e9d...your-computed-hash",
"proofs": [
{
"method": "ed25519-v2",
"public": "tB/gTevBYDYYYUgOOlsKV2Iq8DzmEtleeTopaY63wqs=",
"digest": "e4d5f6a1b2...your-computed-digest",
"result": "K7xB2mN4pQ...your-ed25519-signature"
}
]
}'const response = await fetch('https://{ledger-host}/v2/wallets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-ledger': 'payments-hub-demo',
},
body: JSON.stringify({
data,
hash: dataHash,
proofs: [
{
method: 'ed25519-v2',
public: myPublicKey, // base64, 44 characters
digest, // hex, 64 characters
result: signature, // base64, Ed25519 signature
},
],
}),
})// The SDK handles hashing, digest creation, and signing automatically
const { response } = await sdk.wallet
.init()
.data({
handle: 'store1@greatcoffee.co',
schema: 'merchant-wallet',
custom: {
name: 'GREAT COFFEE UNICENTRO',
city: 'BOGOTA',
postal: '110111',
type: 'store',
},
})
.hash()
.sign([{ keyPair }])
.send()import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient client = HttpClient.newHttpClient();
String json = """
{
"data": {
"handle": "store1@greatcoffee.co",
"schema": "merchant-wallet",
"custom": {
"name": "GREAT COFFEE UNICENTRO",
"city": "BOGOTA",
"postal": "110111",
"type": "store"
}
},
"hash": "7f3a8b2e9d...your-computed-hash",
"proofs": [
{
"method": "ed25519-v2",
"public": "tB/gTevBYDYYYUgOOlsKV2Iq8DzmEtleeTopaY63wqs=",
"digest": "e4d5f6a1b2...your-computed-digest",
"result": "K7xB2mN4pQ...your-ed25519-signature"
}
]
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://{ledger-host}/v2/wallets"))
.header("Content-Type", "application/json")
.header("x-ledger", "payments-hub-demo")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(
request, HttpResponse.BodyHandlers.ofString());using System.Net.Http;
using System.Text;
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("x-ledger", "payments-hub-demo");
var json = """
{
"data": {
"handle": "store1@greatcoffee.co",
"schema": "merchant-wallet",
"custom": {
"name": "GREAT COFFEE UNICENTRO",
"city": "BOGOTA",
"postal": "110111",
"type": "store"
}
},
"hash": "7f3a8b2e9d...your-computed-hash",
"proofs": [
{
"method": "ed25519-v2",
"public": "tB/gTevBYDYYYUgOOlsKV2Iq8DzmEtleeTopaY63wqs=",
"digest": "e4d5f6a1b2...your-computed-digest",
"result": "K7xB2mN4pQ...your-ed25519-signature"
}
]
}
""";
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync(
"https://{ledger-host}/v2/wallets", content);
var body = await response.Content.ReadAsStringAsync();The response confirms the wallet was created. The meta.proofs field echoes back the signing method and signer identity — proof that the ledger accepted your signature.
What's next
With signing implemented, you can authorize any mutation on the ledger. The pattern is the same regardless of the resource type — wallets, intents, anchors all use the same hash → digest → sign → send flow.
- Onboard a Merchant — create a merchant wallet hierarchy with settlement accounts
- Send a Payment to an Account — push funds to a bank account
- About Signers — how key pairs, permissions, and audit trails work on the ledger
Connect to a Ledger
Install the Minka CLI, connect to a ledger environment, and create your signer — the cryptographic identity that authorizes every operation you perform.
Create a Merchant
Create a merchant wallet with a complete business profile — name, location, tax regime, settlement account — so every payment artifact the merchant creates inherits the right data automatically.