Subscribe to Payment Events

Register webhook effects to receive real-time notifications when payments settle, fail, or change status — no polling, no batch files.

Audience: Developers, Integration Engineers · Read time: 3 min

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"
      }
    }
  }'
POST /v2/effects
// @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:

FieldWhat it does
signalThe event type to watch — determines when the effect fires
filterNarrows the signal to a specific wallet, domain, or currency
actionWhere 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

SignalFires whenUse case
balance-receivedA wallet is creditedPOS confirmation, receipt generation, balance alerts
balance-sentA wallet is debitedTransaction alerts, spending notifications
intent-completedAn intent reaches completed statusReconciliation, downstream workflow triggers
intent-rejectedAn intent reaches rejected statusError 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:

ScenarioWhat the ledger does
Your endpoint returns a 2xxDelivery confirmed — moves on
Your endpoint returns a non-2xx errorRetries with exponential backoff (1s → 1.2s → 1.44s... up to 1 hour max)
Your endpoint is unreachableSame retry behavior — keeps trying
Your endpoint returns 501 Not ImplementedStops 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

ErrorCauseFix
handle-already-existsAn effect with this handle is already registeredUse a unique handle or query the existing effect
invalid-signalThe signal name is not recognizedCheck the available signals table above
endpoint-unreachableThe webhook URL could not be reached during registration validationEnsure 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

On this page