This guide explains how to receive real-time notifications of LumenFlow contract events in your backend system using the Stellar Horizon event stream.
LumenFlow emits Soroban contract events for every significant action (payments, refunds, disputes, etc.). Your backend can subscribe to these events via the Horizon HTTP event stream and trigger webhooks or internal workflows.
Horizon exposes a Server-Sent Events (SSE) endpoint for contract events:
GET https://horizon-testnet.stellar.org/contracts/{CONTRACT_ID}/events
For mainnet replace horizon-testnet.stellar.org with horizon.stellar.org.
| Parameter | Description |
|---|---|
cursor |
Paging token — use now to start from the current ledger, or a saved token to resume |
limit |
Max events per page (default 20, max 200) |
topic1 |
Filter by first topic — use lumenflow to receive only LumenFlow events |
https://horizon-testnet.stellar.org/contracts/{CONTRACT_ID}/events?cursor=now&topic1=lumenflow
Events delivered via Horizon are signed by the Stellar network validators. To verify an event is genuine:
- Check the contract ID — confirm
contract_idin the event matches your deployed contract address. - Check the ledger sequence — events include a
ledgerfield; cross-reference with Horizon's/ledgers/{seq}endpoint to confirm finality. - Verify the topic — the first topic must be
lumenflowand the second must match the expected event name (e.g.payment_processed). - Replay protection — store the
paging_tokenof each processed event and reject duplicates (see Idempotency).
Note: Horizon itself does not provide a cryptographic signature over event data. For high-value integrations, additionally verify the transaction hash on-chain via
/transactions/{hash}.
LumenFlow payment and refund events include the merchant address as topic[2]. You can pass this directly as the topic3 query parameter on the Horizon SSE stream to receive only events for a specific merchant, eliminating client-side filtering.
GET https://horizon-testnet.stellar.org/contracts/{CONTRACT_ID}/events
?cursor=now
&topic1=lumenflow
&topic2=payment_processed
&topic3=<merchant-address-xdr-base64>
To compute the base64 XDR of a Stellar address:
import { Address } from '@stellar/stellar-sdk';
const merchantXdr = Address.fromString('G...MERCHANT_ADDR')
.toScVal()
.toXDR('base64');
const url =
`${HORIZON_URL}/contracts/${CONTRACT_ID}/events` +
`?cursor=now&topic1=lumenflow&topic2=payment_processed&topic3=${encodeURIComponent(merchantXdr)}`;The following example uses the eventsource package to consume the SSE stream and forward events to your webhook endpoint.
npm install eventsource node-fetch @stellar/stellar-sdkconst EventSource = require("eventsource");
const fetch = require("node-fetch");
const { Address, scValToNative, xdr } = require("@stellar/stellar-sdk");
const CONTRACT_ID = process.env.CONTRACT_ID; // deployed contract address
const MERCHANT_ADDR = process.env.MERCHANT_ADDR; // your merchant's Stellar address
const WEBHOOK_URL = process.env.WEBHOOK_URL; // your backend endpoint
const HORIZON_URL = process.env.HORIZON_URL || "https://horizon-testnet.stellar.org";
// Encode the merchant address as base64 XDR for the topic3 filter
const merchantTopicXdr = Address.fromString(MERCHANT_ADDR).toScVal().toXDR("base64");
// Resume from a saved cursor, or start from now
let cursor = process.env.CURSOR || "now";
// In-memory idempotency store (use Redis/DB in production)
const processed = new Set();
function buildUrl(eventType) {
const params = new URLSearchParams({
cursor,
topic1: "lumenflow",
topic2: eventType,
topic3: merchantTopicXdr,
});
return `${HORIZON_URL}/contracts/${CONTRACT_ID}/events?${params}`;
}
function connect(eventType) {
const es = new EventSource(buildUrl(eventType));
es.addEventListener("message", async (msg) => {
const event = JSON.parse(msg.data);
const token = event.paging_token;
// Idempotency check
if (processed.has(token)) return;
processed.add(token);
cursor = token; // persist so we can resume after restart
// Decode the data payload
const rawVal = xdr.ScVal.fromXDR(event.value.xdr, "base64");
const data = scValToNative(rawVal);
// For payment_processed: data = [order_id, payer, amount]
// merchant_address is in event.topic[2], already filtered by Horizon
console.log(`[${eventType}] merchant=${MERCHANT_ADDR}`, data);
try {
await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: eventType,
merchant: MERCHANT_ADDR,
data,
ledger: event.ledger,
token,
}),
});
} catch (err) {
console.error("Webhook delivery failed:", err.message);
}
});
es.addEventListener("error", (err) => {
console.error(`SSE error (${eventType}), reconnecting in 5s:`, err.message);
es.close();
setTimeout(() => connect(eventType), 5000);
});
}
// Subscribe to all merchant-specific event types
[
"payment_processed",
"refund_initiated",
"refund_approved",
"refund_rejected",
"refund_executed",
].forEach(connect);CONTRACT_ID=<your-contract-id> \
MERCHANT_ADDR=G...YOUR_MERCHANT_ADDRESS \
WEBHOOK_URL=https://your-backend.example.com/lumenflow-events \
node webhook-server.jsNetwork retries and Horizon reconnections can deliver the same event more than once. Your handler must be idempotent.
- Persist the
paging_tokenof every successfully processed event in a database. - Before processing, query the database — if the token already exists, skip the event.
- Use a database transaction to atomically record the token and apply the business logic.
// Pseudocode
async function handleEvent(event) {
const token = event.paging_token;
const alreadyProcessed = await db.events.findOne({ token });
if (alreadyProcessed) return;
await db.transaction(async (tx) => {
await tx.events.insert({ token, processed_at: new Date() });
await applyBusinessLogic(event, tx);
});
}For payment_processed events, the order_id in the event data is unique per payment. You can use it as a secondary idempotency key in your payments table.
Payment and refund events expose the merchant address as topic[2] for server-side filtering.
| Event | topic[1] | topic[2] | Data |
|---|---|---|---|
payment_processed |
payment_processed |
merchant_address ✦ |
(order_id, payer, amount) |
refund_initiated |
refund_initiated |
merchant_address ✦ |
(refund_id, order_id) |
refund_approved |
refund_approved |
merchant_address ✦ |
(refund_id, order_id) |
refund_rejected |
refund_rejected |
merchant_address ✦ |
(refund_id, order_id) |
refund_executed |
refund_executed |
merchant_address ✦ |
(refund_id, order_id) |
multisig_initiated |
multisig_initiated |
— | payment_id |
multisig_executed |
multisig_executed |
— | payment_id |
merchant_registered |
merchant_registered |
— | merchant_address |
payment_archived |
payment_archived |
— | order_id |
contract_paused |
contract_paused |
— | () or (reason, lock_until) |
contract_unpaused |
contract_unpaused |
— | () or ("multisig_override",) |
✦ Filterable via topic3 on Horizon SSE or the topics[2] filter in Soroban RPC.
For the full events reference see events-reference.md.
The TypeScript SDK ships a WebhookRelay that implements the retry and
deduplication rules described above so you don't have to re-derive them.
import { WebhookRelay, MemoryDedupeStore } from '@lumenflow/sdk';
const relay = new WebhookRelay({
// POST the event to your downstream endpoint; return the HTTP status.
deliver: async (event) => {
const res = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
return { status: res.status };
},
// Swap MemoryDedupeStore for a Redis/DB-backed store in production.
store: new MemoryDedupeStore(),
maxAttempts: 4,
});
// For each event from the Horizon SSE / RPC poll:
await relay.relay({ pagingToken: event.paging_token, type, topic, value: data, ledger });Semantics:
| Situation | Behaviour |
|---|---|
| Event already in the dedupe store | Dropped, returns { status: 'duplicate' }, webhook not called |
| Downstream returns 2xx | Key recorded, returns { status: 'delivered', attempts } |
| Downstream returns 408 / 425 / 429 / 5xx, or the request throws | Retried with exponential backoff up to maxAttempts |
| Downstream returns any other 4xx (401, 422, …) | Fails fast with WebhookDeliveryError — never retried |
| Retry budget exhausted | Throws WebhookDeliveryError; the key is not recorded, so the event is redelivered on the next poll |
Because a key is only recorded after a successful POST, delivery is at-least-once — your downstream handler must stay idempotent on the event key (see Idempotency Considerations).
LumenFlow does not expose custom webhooks. Integrators should watch on-chain activity via Stellar Horizon API contract events or Soroban events.
- No events received: confirm correct contract ID and network (testnet vs mainnet).
- Missed events during downtime: track the last processed ledger and backfill via Horizon's paginated event history.
- Friendbot is testnet-only funding, not for production integration.