Subscribe to Payment Events
Register webhook effects to receive real-time notifications when payments settle, fail, or change status — no polling, no batch files.
Knowing when a payment settles shouldn't require polling an API every few seconds or processing batch files overnight. The ledger can notify your server the moment something happens — a payment completes, a wallet is credited, an intent fails — through webhook effects.
An effect is a subscription: you tell the ledger which event to watch and where to send the notification. The ledger handles delivery, retries, and ordering.
Prerequisites
- A connection to a ledger — see How to Connect to a Ledger
- A publicly accessible HTTPS endpoint that can receive POST requests
- A signer with permission to create effects on the target domain
Register an effect
Create the webhook subscription
Register an effect that calls your server whenever a specific wallet receives a payment:
curl -X POST "https://{ledger-host}/v2/effects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}" \
-d '{
"data": {
"handle": "store1-payment-received",
"signal": "balance-received",
"filter": {
"wallet.data.handle": "store1@greatcoffee.co"
},
"action": {
"schema": "webhook",
"endpoint": "https://greatcoffee.co/api/webhooks/payment-received"
}
}
}'// @minka/ledger-sdk
const { response } = await sdk.effect
.init()
.data({
handle: 'store1-payment-received',
signal: 'balance-received',
filter: {
'wallet.data.handle': 'store1@greatcoffee.co',
},
action: {
schema: 'webhook',
endpoint: 'https://greatcoffee.co/api/webhooks/payment-received',
},
})
.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-payment-received",
"signal": "balance-received",
"filter": {
"wallet.data.handle": "store1@greatcoffee.co"
},
"action": {
"schema": "webhook",
"endpoint": "https://greatcoffee.co/api/webhooks/payment-received"
}
}
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://{ledger-host}/v2/effects"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer {your-token}")
.header("x-ledger", "{your-ledger}")
.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("Authorization", "Bearer {your-token}");
client.DefaultRequestHeaders.Add("x-ledger", "{your-ledger}");
var json = """
{
"data": {
"handle": "store1-payment-received",
"signal": "balance-received",
"filter": {
"wallet.data.handle": "store1@greatcoffee.co"
},
"action": {
"schema": "webhook",
"endpoint": "https://greatcoffee.co/api/webhooks/payment-received"
}
}
}
""";
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync(
"https://{ledger-host}/v2/effects", content);
var body = await response.Content.ReadAsStringAsync();Three fields define the subscription:
| Field | What it does |
|---|---|
| signal | The event type to watch — determines when the effect fires |
| filter | Narrows the signal to a specific wallet, domain, or currency |
| action | Where to send the notification — webhook sends an HTTP POST to your endpoint |
The curl examples use a Bearer token for authentication. In production, every mutation also requires a cryptographic signature — see Sign Payments for the full signing flow. The TypeScript SDK handles signing automatically.
Handle the webhook payload
The ledger sends a POST request with the full event data. Your handler should acknowledge immediately and process asynchronously:
// Your server — Express, Fastify, or any HTTP framework
app.post('/api/webhooks/payment-received', async (req, res) => {
// Acknowledge immediately — the ledger retries on non-2xx responses
res.status(202).send()
const event = req.body
const { signal, wallet, symbol, intent, amount } = event.data
// Idempotency — the handle uniquely identifies this event
if (await isAlreadyProcessed(event.data.handle)) return
console.log(`Received ${amount} ${symbol.data.handle} on ${wallet.data.handle}`)
console.log(`Intent: ${intent.data.handle}`)
// Your business logic — update POS, send receipt, trigger payout
await processPayment(event)
})Three things matter in this handler:
Acknowledge first, process later. Return a 202 immediately. The ledger considers any non-2xx response or network timeout a failed delivery and retries with exponential backoff.
Use the event handle for idempotency. Network retries can deliver the same event more than once. The event.data.handle is a unique identifier — check it before processing to avoid duplicate side effects.
The payment is already settled. The ledger fires the effect after the balance change commits. By the time your server receives the call, the funds are already in the wallet.
Webhook security
In production, verify that webhook calls originate from the ledger before processing them. The ledger signs every webhook payload — validate the signature against the ledger's public key before trusting the event data. See Sign Payments for details on signature verification.
Verify the effect is registered
curl "https://{ledger-host}/v2/effects/store1-payment-received" \
-H "Authorization: Bearer {your-token}" \
-H "x-ledger: {your-ledger}"The response confirms the signal, filter, and action are configured correctly. The effect is now active — every payment that credits store1@greatcoffee.co triggers a POST to your endpoint.
Available signals
| Signal | Fires when | Use case |
|---|---|---|
balance-received | A wallet is credited | POS confirmation, receipt generation, balance alerts |
balance-sent | A wallet is debited | Transaction alerts, spending notifications |
intent-completed | An intent reaches completed status | Reconciliation, downstream workflow triggers |
intent-rejected | An intent reaches rejected status | Error handling, retry logic, alerting |
You can combine signals with filters to target specific wallets, domains, or currencies. For example, an effect with signal: "balance-received" and filter: { "wallet.data.domain": "greatcoffee.co" } fires for every payment to any wallet in the greatcoffee.co domain.
Retry behavior
Effects are not fire-and-forget. The ledger guarantees delivery with automatic retries:
| Scenario | What the ledger does |
|---|---|
| Your endpoint returns a 2xx | Delivery confirmed — moves on |
| Your endpoint returns a non-2xx error | Retries with exponential backoff (1s → 1.2s → 1.44s... up to 1 hour max) |
| Your endpoint is unreachable | Same retry behavior — keeps trying |
Your endpoint returns 501 Not Implemented | Stops retrying — treats as permanent rejection |
If your server is down for maintenance, the ledger queues events and delivers them when your endpoint comes back online. There is no separate dead-letter queue to manage.
Common errors
| Error | Cause | Fix |
|---|---|---|
handle-already-exists | An effect with this handle is already registered | Use a unique handle or query the existing effect |
invalid-signal | The signal name is not recognized | Check the available signals table above |
endpoint-unreachable | The webhook URL could not be reached during registration validation | Ensure your endpoint is publicly accessible over HTTPS |
What's next
- Sign Payments — implement signature verification for incoming webhooks
- Get Notifications — bridge-side notification endpoints for intent status updates
- About Claims — understand the claim structure inside the events you receive
Using an Alphanumeric Anchor
Register a short business alias as a payment point — customers pay by typing the identifier instead of scanning a QR code.
Payments Hub Architecture
Understand how Payments Hub coordinates clients, Ledger, wallets, intents, proofs, bridges, external rails, and webhooks when money moves.