Copy-paste recipes. Each one is the smallest working snippet that demonstrates one capability. For narrative walkthroughs (full setups, deployment, hardening) see the guides at
apps/web/content/guides/.Legend. ✅ ships today and runs against testnet/mainnet. 🛠️ planned, tracked in
ROADMAP.md.
- Watch an account for incoming payments
- Subscribe to multiple addresses with one engine
- Filter events with a predicate
- Handle reconnection and rate-limit notifications
- Use a custom Horizon URL
- Deliver events to an HTTPS endpoint
- Verify a webhook in a Cloudflare Worker
- Fan out one event to multiple URLs
- Route
webhook.failedto a dead-letter queue - Capture an anchor audit trail
- Render live payments in React with type narrowing
- Stand up an SSE endpoint in Next.js
- Subscribe to Soroban contract events
- Unit test webhooks with deterministic jitter
- Generate TypeScript types from a Soroban contract
- Back up and restore cursor positions
- Inspect or replay dead-letter-queue entries
- Subscribe to contract-specific typed events in React
- Migrate from Horizon-only to unified ingestion
The shortest path. Subscribe, attach a handler, wait for events. ✅
import { EventEngine } from "@orbital-stellar/pulse-core";
const engine = new EventEngine({ network: "testnet" });
engine.start();
const watcher = engine.subscribe("GABC...YOUR_ACCOUNT");
watcher.on("payment.received", (event) => {
console.log(`+${event.amount} ${event.asset} from ${event.from}`);
});Send a test payment from the Stellar Laboratory and the event prints within seconds. engine.stop() cleanly closes the upstream connection - always call it in your shutdown path.
One Horizon connection, many watchers. The engine fans events out internally - no extra network cost per subscriber. ✅
import { EventEngine } from "@orbital-stellar/pulse-core";
const engine = new EventEngine({ network: "mainnet" });
engine.start();
const accounts = ["GABC...", "GDEF...", "GHIJ..."];
for (const address of accounts) {
const watcher = engine.subscribe(address);
watcher.on("*", (event) => {
console.log(`[${address.slice(0, 8)}] ${event.type}`);
});
}engine.subscribe() is idempotent - calling it twice for the same address returns the same Watcher. To stop watching one account without tearing down the stream: engine.unsubscribe(address). To stop watching everything: engine.unsubscribeAll().
Pass a filter function on subscribe() to suppress events you don't want delivered. The filter runs before any on(…) handler fires. ✅
import { EventEngine, type NormalizedEvent } from "@orbital-stellar/pulse-core";
const engine = new EventEngine({ network: "mainnet" });
engine.start();
const watcher = engine.subscribe("GABC...", {
filter: (event: NormalizedEvent) =>
event.type === "payment.received" &&
Number(event.amount) >= 100, // ≥ 100 units, whatever the asset
});
watcher.on("payment.received", (event) => {
console.log(`Large payment: ${event.amount} ${event.asset}`);
});A predicate that throws is treated as false (suppress, with a warn log) - the engine never crashes on a bad filter.
Lifecycle notifications surface alongside operation events on every watcher. Surface them as toasts, banners, or structured logs. ✅
import { EventEngine } from "@orbital-stellar/pulse-core";
const engine = new EventEngine({ network: "mainnet" });
engine.start();
const watcher = engine.subscribe("GABC...");
watcher.on("engine.reconnecting", (n) => {
console.warn(`Reconnect attempt ${n.attempt}, delay ${n.delayMs}ms`);
});
watcher.on("engine.rate_limited", (n) => {
console.warn(`Horizon rate-limited us. Backing off ${n.delayMs}ms`);
});
watcher.on("engine.reconnected", (n) => {
console.info(`Stream restored on attempt ${n.attempt}`);
});
watcher.on("engine.stopped", () => {
console.info("Engine stopped");
});The engine parses Retry-After headers on 429 responses and uses that exact delay (falling back to 60 s if the header is missing).
Self-hosted node, regional mirror, or futurenet. The network field still picks the chain context; horizonUrl overrides the HTTP target. ✅
import { EventEngine } from "@orbital-stellar/pulse-core";
const engine = new EventEngine({
network: "mainnet",
horizonUrl: "https://horizon.your-node.example.com",
reconnect: { initialDelayMs: 2000, maxDelayMs: 60_000 },
});
engine.start();The URL must be http:// or https://. The engine validates the URL at construction time and throws synchronously if it's malformed - you get a fast error, not a silent SSE failure.
WebhookDelivery attaches to a watcher and POSTs every event to your endpoint with HMAC-SHA256 signing, exponential backoff retry, and a configurable per-attempt timeout. ✅
Sender side - attach delivery to the watcher:
import { EventEngine } from "@orbital-stellar/pulse-core";
import { WebhookDelivery } from "@orbital-stellar/pulse-webhooks";
const engine = new EventEngine({ network: "mainnet" });
engine.start();
const watcher = engine.subscribe("GABC...");
new WebhookDelivery(watcher, {
url: "https://your-app.com/hooks/stellar",
secret: process.env.WEBHOOK_SECRET!,
retries: 3,
deliveryTimeoutMs: 10_000,
});Receiver side - verify the signature and enforce the replay window with maxAgeMs:
import { verifyWebhook } from "@orbital-stellar/pulse-webhooks";
import express from "express";
const app = express();
app.post(
"/hooks/stellar",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("x-orbital-signature");
const timestamp = req.header("x-orbital-timestamp");
if (!signature || !timestamp) return res.sendStatus(400);
const event = verifyWebhook(
req.body.toString(),
signature,
process.env.WEBHOOK_SECRET!,
timestamp,
{ maxAgeMs: 5 * 60 * 1000 }, // reject signatures older than 5 minutes
);
if (!event) return res.sendStatus(401);
console.log(`Verified ${event.type}`);
res.sendStatus(200);
},
);Each request carries x-orbital-signature (hex HMAC-SHA256 over ${timestamp}.${body}), x-orbital-timestamp, and x-orbital-attempt. Always pass maxAgeMs to bound replay - a signature without a replay window is valid indefinitely. The default is 300_000 (5 minutes), matching the recommendation in SECURITY.md.
verifyWebhookEdge uses Web Crypto, so it runs on Cloudflare Workers, Vercel Edge, Deno, and browsers - anywhere without Node's crypto module. ✅
import { verifyWebhookEdge } from "@orbital-stellar/pulse-webhooks";
export default {
async fetch(request: Request, env: { WEBHOOK_SECRET: string }) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const signature = request.headers.get("x-orbital-signature");
const timestamp = request.headers.get("x-orbital-timestamp");
if (!signature || !timestamp) {
return new Response("Missing headers", { status: 400 });
}
const payload = await request.text();
const event = await verifyWebhookEdge(
payload,
signature,
env.WEBHOOK_SECRET,
timestamp,
{ maxAgeMs: 5 * 60 * 1000 }, // reject signatures older than 5 minutes
);
if (!event) return new Response("Invalid signature", { status: 401 });
// event is a verified, typed NormalizedEvent
console.log(`Verified ${event.type}`);
return new Response("ok");
},
};The verifier returns null on any failure (bad signature, malformed timestamp, bad JSON) - fail closed, never assume success.
WebhookDelivery.config.url accepts an array. Each URL retries independently - one slow endpoint does not block delivery to the others. ✅
new WebhookDelivery(watcher, {
url: [
"https://primary.your-app.com/hooks/stellar",
"https://staging.your-app.com/hooks/stellar",
"https://analytics.your-app.com/hooks/stellar",
],
secret: process.env.WEBHOOK_SECRET!,
retries: 3,
});The webhook.failed event (see recipe 9) fires per-URL, so you can detect which endpoint is sick and route accordingly.
When a delivery exhausts its retries, the watcher emits webhook.failed with the original event in raw.originalEvent and the failed URL in raw.url. Catch it and persist to a DLQ. ✅
import { EventEngine, type NormalizedEvent } from "@orbital-stellar/pulse-core";
import { WebhookDelivery, type WebhookFailureRaw } from "@orbital-stellar/pulse-webhooks";
const engine = new EventEngine({ network: "mainnet" });
engine.start();
const watcher = engine.subscribe("GABC...");
new WebhookDelivery(watcher, {
url: "https://flaky.your-app.com/hooks/stellar",
secret: process.env.WEBHOOK_SECRET!,
retries: 3,
});
watcher.on("webhook.failed", async (event) => {
const { url, error, attempts, originalEvent } = event.raw as WebhookFailureRaw;
await persistToDLQ({
url,
error,
attempts,
event: originalEvent,
failedAt: new Date().toISOString(),
});
});
declare function persistToDLQ(record: unknown): Promise<void>;webhook.dropped fires when the concurrent-retry cap evicts a pending retry - handle it the same way if you care about every miss.
Compose CursorStore + RetryQueue + DeadLetterStore to capture payment and trustline events for a set of anchor distribution accounts into an append-only audit log. The result is replay-safe: replay --from <cursor> rebuilds the audit log byte-identically from any point. ✅
import { EventEngine, FileCursorStore, type NormalizedEvent } from "@orbital-stellar/pulse-core";
import { MemoryRetryQueue, MemoryDeadLetterStore } from "@orbital-stellar/pulse-webhooks";
import { createWriteStream } from "fs";
// 1. Durable cursor store: survives process restarts.
const cursorStore = new FileCursorStore("./.orbital-cursors");
// 2. Retry queue: backs webhook delivery retries (swap for RedisRetryQueue in
// production so retries survive restarts).
const retryQueue = new MemoryRetryQueue();
// 3. Dead-letter store: terminal failure persistence (swap for
// PostgresDeadLetterStore in production for durability).
const deadLetter = new MemoryDeadLetterStore();
const engine = new EventEngine({
network: "testnet",
cursorStore,
streamKey: "anchor-audit",
});
engine.start();
// Anchor distribution accounts to monitor.
const accounts = ["GABC...", "GDEF..."];
// Append-only audit log (JSON Lines, sorted keys for deterministic output).
const auditLog = createWriteStream("./audit.jsonl", { flags: "a" });
for (const account of accounts) {
const watcher = engine.subscribe(account, {
filter: (event: NormalizedEvent): boolean =>
event.type === "payment.received" ||
event.type === "payment.sent" ||
event.type === "trustline.added" ||
event.type === "trustline.removed",
});
watcher.on("*", (event) => {
const raw = event.raw as Record<string, unknown> | undefined;
const [ledgerStr, opIdxStr] = (raw?.id as string ?? "0-0").split("-");
const record = {
ledger: parseInt(ledgerStr, 10),
txHash: extractTxHash(raw),
operationIndex: parseInt(opIdxStr, 10),
memo: (raw?.memo as string) ?? null,
asset: event.asset,
from: "from" in event ? event.from : event.account,
to: "to" in event ? event.to : event.asset.split(":")[1] ?? "",
eventType: event.type,
timestamp: event.timestamp,
raw: event.raw,
};
// Stable JSON: sorted keys for byte-identical replay.
auditLog.write(JSON.stringify(record, sortedKeys) + "\n");
});
}
function extractTxHash(raw?: Record<string, unknown>): string {
const links = raw?._links as Record<string, unknown> | undefined;
const txHref = links?.transaction as Record<string, unknown> | undefined;
if (typeof txHref?.href === "string") {
return txHref.href.split("/").pop() ?? "";
}
return "";
}
function sortedKeys(_key: string, value: unknown): unknown {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const sorted: Record<string, unknown> = {};
for (const k of Object.keys(value).sort()) {
sorted[k] = (value as Record<string, unknown>)[k];
}
return sorted;
}
return value;
}
// Graceful shutdown.
process.on("SIGTERM", () => { auditLog.end(); engine.stop(); });Delivery guarantee: at-least-once with idempotency keys. The composition of CursorStore + RetryQueue + DeadLetterStore provides at-least-once semantics, not exactly-once. Consumers MUST deduplicate by (ledger, operationIndex). A process restart between event capture and cursor flush WILL produce duplicates on restart. For a full, production-shape AnchorService class that wraps this composition (with CLI and replay support), see examples/anchor-starter/.
useStellarEvent<T> is generic - pass a narrow union as T to get full autocomplete and exhaustive switch checking. ✅
"use client";
import { useStellarEvent } from "@orbital-stellar/pulse-notify";
import type { NormalizedEvent } from "@orbital-stellar/pulse-core";
type WalletEvents = Extract<
NormalizedEvent,
{ type: "payment.received" | "payment.sent" | "trustline.added" }
>;
export function Wallet({ address }: { address: string }) {
const { event, connected, error } = useStellarEvent<WalletEvents>(
process.env.NEXT_PUBLIC_ORBITAL_URL!,
address,
{ event: ["payment.received", "payment.sent", "trustline.added"] },
);
if (error) return <p className="text-red-500">{error}</p>;
if (!connected) return <p>Connecting…</p>;
if (!event) return <p>Listening…</p>;
switch (event.type) {
case "payment.received":
return <p>+{event.amount} {event.asset} from {event.from.slice(0, 8)}…</p>;
case "payment.sent":
return <p>−{event.amount} {event.asset} to {event.to.slice(0, 8)}…</p>;
case "trustline.added":
return <p>Added trustline for {event.asset}</p>;
}
}A switch over event.type with no default clause will produce a TypeScript error if you ever miss a case - the narrow union does the work.
The hooks expect a backend that re-emits Orbital events as Server-Sent Events. The marketing site ships a working reference at apps/web/app/api/events/[address]/route.ts - copy it, strip the demo limits in apps/web/lib/demo-limits.ts, and you have your production SSE handler. ✅
// app/api/events/[address]/route.ts
import { EventEngine } from "@orbital-stellar/pulse-core";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const g = globalThis as unknown as { __engine?: EventEngine };
function engine() {
if (!g.__engine) {
g.__engine = new EventEngine({ network: "mainnet" });
g.__engine.start();
}
return g.__engine;
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ address: string }> },
) {
const { address } = await params;
const watcher = engine().subscribe(address);
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (e: unknown) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`));
const beat = setInterval(
() => controller.enqueue(encoder.encode(`: heartbeat\n\n`)),
30_000,
);
watcher.on("*", send);
req.signal.addEventListener("abort", () => {
clearInterval(beat);
watcher.removeListener("*", send);
engine().unsubscribe(address);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}The globalThis trick keeps one EventEngine alive across Next.js HMR. In production (next start) it persists for the lifetime of the Node process. On Vercel serverless, expect periodic reconnects when the function instance recycles - fine for demos, not for production Cloud.
Subscribes to smart-contract events by contract ID and topic filter via Stellar RPC. Same normalized-event taxonomy as classic operations, with two new types: contract.invoked and contract.emitted.
import { EventEngine } from "@orbital-stellar/pulse-core";
const engine = new EventEngine({
network: "mainnet",
soroban: {
rpcUrl: "https://soroban-rpc.your-node.example.com",
},
});
engine.start();
const watcher = engine.subscribeContract({
contractId: "CA...",
topics: ["transfer"], // optional topic filter
});
watcher.on("contract.emitted", (event) => {
console.log(event.contractId, event.topic, event.decodedData);
});Decoding to typed decodedData requires the ABI Registry client (@orbital-stellar/abi-registry), wired in via EventEngine's ABI registry config - see packages/abi-registry/README.md. Without a registered ABI, raw XDR is exposed in event.raw.
Inject a custom RNG into WebhookDelivery to make exponential backoff delays deterministic in your test suite. ✅
import { Watcher } from "@orbital-stellar/pulse-core";
import { WebhookDelivery } from "@orbital-stellar/pulse-webhooks";
import { vi } from "vitest";
// A simple seeded RNG for deterministic results
let seed = 12345;
const seededRandom = () => {
seed = (seed * 16807) % 2147483647;
return (seed - 1) / 2147483646;
};
const watcher = new Watcher("GABC...");
new WebhookDelivery(watcher, {
url: "https://example.com/webhook",
secret: "top-secret",
retries: 3,
random: seededRandom, // 👈 Inject RNG here
});Combine this with vi.useFakeTimers() to verify that retries happen after the exact jittered delay you expect without waiting for real-world wall clock time.
Generate TypeScript type declarations and Zod schemas from a deployed Soroban contract's spec. ✅
# Generate types from a testnet contract
orbital typegen CBCG...YOUR_CONTRACT --out src/contract-types.ts
# Target mainnet with a custom RPC endpoint
orbital typegen CBCG...YOUR_CONTRACT \
--network mainnet \
--rpc-url https://soroban-mainnet.example.com \
--out src/contract-types.ts# If you already have a contract spec JSON file
abi-registry-generate ./my-contract-spec.json ./my-contract.d.tsThe abi-registry-generate binary takes a Soroban contract spec JSON file and outputs a .d.ts file with generated TypeScript types. It ships with @orbital-stellar/abi-registry and is available via npx abi-registry-generate.
Dump or restore cursor positions from a Postgres database for migration or disaster recovery. Requires the pg package installed separately. ✅
# Install the pg driver (if not already available)
npm install pg
# Dump all cursors as line-delimited JSON to a file
orbital cursor dump > cursors-backup.jsonl
# Restore cursors from the backup file
orbital cursor restore < cursors-backup.jsonlThe cursor dump command reads from the cursor_store table and outputs one JSON object per line. cursor restore reads the same format from stdin and writes back. Both commands connect to Postgres via the PG* environment variables (PGHOST, PGPORT, PGDATABASE, etc.) — the same ones psql uses.
When webhook deliveries fail after all retries, entries land in the dead-letter queue (DLQ). The orbital-dlq CLI lets you list, dump, and replay them. ✅
# List all DLQ entries for a specific URL
orbital-dlq dlq list --url https://your-app.com/hooks/stellar
# Filter entries since a timestamp with a limit
orbital-dlq dlq list \
--url https://your-app.com/hooks/stellar \
--since 2026-01-01T00:00:00Z \
--limit 50
# Dump all DLQ entries as line-delimited JSON
orbital-dlq dlq dump
# Replay a specific DLQ entry (re-signs and re-delivers)
orbital-dlq dlq replay <entry-id> --secret "$WEBHOOK_SECRET"The orbital-dlq binary ships with @orbital-stellar/pulse-webhooks and is available via npx orbital-dlq. Set ORBITAL_WEBHOOK_SECRET in your environment or pass --secret to re-sign replayed deliveries.
Use orbital codegen with a contract spec to generate per-event React hooks with
Zod runtime validation, then consume them in your components. ✅
Step 1: Generate types and hooks from a registry spec
import { generateContractArtifacts, generateContractHooks } from "@orbital-stellar/abi-registry";
import type { ContractSpec } from "@orbital-stellar/abi-registry";
// Load the spec - from a registry client, local file, or WASM discovery
const spec: ContractSpec = { /* ... contract spec ... */ };
const artifacts = generateContractArtifacts(spec);
// artifacts.declarations → TypeScript interfaces + types
// artifacts.schemas → Zod validation schemas
// artifacts.hooks → React hook wrappers (e.g. useSwapExecuted)
// Or use the standalone hook generator:
const hooks = generateContractHooks(spec);Step 2: Use generated hooks in a React component
import { useContractEvent } from "@orbital-stellar/pulse-notify";
import { SwapExecutedEventSchema } from "./generated/MyContract";
function SwapActivity({ serverUrl, contractId }: { serverUrl: string; contractId: string }) {
const { event, connected, error } = useContractEvent({
serverUrl,
contractId,
topics: ["swap_executed"],
schema: SwapExecutedEventSchema, // Zod validation
});
if (error) return <div>Error: {error}</div>;
if (!connected) return <div>Connecting...</div>;
if (!event) return <div>Waiting for swap events...</div>;
// event.data is now validated and typed
return (
<div>
<p>Swap executed: {JSON.stringify(event.data)}</p>
<p>Ledger: {event.ledger}</p>
</div>
);
}Step 3: Use auto-generated hook wrappers (from orbital codegen)
For contracts with registered schemas, orbital codegen emits named hooks
like useSwapExecuted() that wrap useContractEvent with the correct
Zod schema pre-applied:
import { useSwapExecuted } from "@orbital-stellar/generated/MyContract";
function SwapWatcher({ serverUrl, contractId }: { serverUrl: string; contractId: string }) {
const { event, connected } = useSwapExecuted({ serverUrl, contractId });
if (event) {
// event is typed as the SwapExecutedEvent interface
return <div>Swapped at ledger {event.ledger}: {event.data.amount}</div>;
}
return <div>Watching for swaps...</div>;
}Connection lifecycle:
- One SSE connection is shared per
(contractId, topics)regardless of how many hook instances mount it - asserted by the test suite. - When all hook instances unmount, the connection closes and the subscription count returns to zero.
Every v1.0.0 deployment ingests through Horizon SSE, implicitly - there was no other transport. Wave 1.6 adds the CAP-67 unified event stream (Stellar RPC) as a second transport for classic asset movements, selected by an ingestion flag. 🛠️
Migrate in three steps - "horizon" → "auto" → "unified" - verifying parity before each cut, and roll back by reversing the same flag. Nothing about your NormalizedEvent handlers changes: the taxonomy is identical on both transports, only the rail underneath moves.
What ships today. The
ingestionflag itself is 🛠️ (ROADMAP.md, Wave 1.6). The pieces this recipe leans on - the unified transport lifecycle (soroban.unifiedEvents), the routing decision (resolveFamilyTransport), and the dedupe primitives (deriveDedupeKey,DedupeWindow) - ship today ✅, so steps 0 and 1 are runnable now and steps 2-3 are the flag flips they lead to.
| Mode | payment / trustlineAuth events |
Every other family | Use it when |
|---|---|---|---|
"horizon" |
Horizon SSE | Horizon SSE | Default. Exactly v1.0.0 behavior |
"auto" |
Unified stream, falling back to Horizon | Horizon SSE | Migrating, and as a steady-state safety net |
"unified" |
Unified stream | Horizon SSE | RPC-first, once you trust the parity numbers |
Only the payment and trustlineAuth families have CAP-67 equivalents. Offers, account options, account merges, manage data, bump sequence, trustline limits, claimable balances, and liquidity pools stay on Horizon in every mode - "unified" does not mean "no Horizon connection". Check any family against the routing matrix without starting an engine: ✅
import { resolveFamilyTransport } from "@orbital-stellar/pulse-core";
resolveFamilyTransport("payment", "unified"); // "unified"
resolveFamilyTransport("trustlineAuth", "unified"); // "unified"
resolveFamilyTransport("offer", "unified"); // "horizon" - no CAP-67 equivalent
resolveFamilyTransport("payment", "horizon"); // "horizon" - mode winsMake today's implicit default explicit before you change anything, so the migration is one visible diff rather than a default shifting under you on upgrade. 🛠️
import { EventEngine } from "@orbital-stellar/pulse-core";
const engine = new EventEngine({
network: "mainnet",
ingestion: "horizon", // same behavior as v1.0.0, now stated out loud
});
engine.start();Turn the unified poller on next to Horizon. It connects, reconnects, persists its own cursor, and reports status - but it does not deliver anything to your watchers, so your handlers cannot see a difference. That is what makes this the safe first move. ✅
import { EventEngine, FileCursorStore } from "@orbital-stellar/pulse-core";
const cursorStore = new FileCursorStore("./.orbital-cursors");
const engine = new EventEngine({
network: "mainnet",
soroban: {
rpcUrl: "https://mainnet.sorobanrpc.com",
unifiedEvents: true, // start the CAP-67 poller alongside Horizon SSE
},
cursorStore, // give the unified stream somewhere durable to checkpoint
});
engine.start();
// Parity signal: both sources should be running and advancing together.
setInterval(() => {
const { horizon, unified } = engine.status().sources;
console.log(
JSON.stringify({
msg: "ingestion.parity",
horizon: { running: horizon.running, lastEventAt: horizon.lastEventAt },
unified: { running: unified.running, lastEventAt: unified.lastEventAt },
}),
);
}, 30_000);Watch the unified transport's own health too - lifecycle notifications carry source: "unified", so you can separate RPC trouble from Horizon trouble: ✅
const watcher = engine.subscribe("GABC...");
watcher.on("engine.reconnecting", (n) => {
if (n.source === "unified") {
console.warn(`Unified RPC reconnect #${n.attempt}, delay ${n.delayMs}ms`);
}
});
watcher.on("engine.rate_limited", (n) => {
if (n.source === "unified") console.warn(`Unified RPC rate-limited ${n.delayMs}ms`);
});Move on when, over a window you consider representative, unified.lastEventAt keeps pace with horizon.lastEventAt and engine.reconnecting from source: "unified" is quiet. A unified stream that is persistently behind or reconnecting is an RPC endpoint problem - fix it here, where it costs you nothing, not after the cut.
"auto" serves asset movements from the unified stream while it is healthy and falls back to Horizon when it is not. Every other family keeps coming from Horizon as before. 🛠️
const engine = new EventEngine({
network: "mainnet",
ingestion: "auto",
soroban: {
rpcUrl: "https://mainnet.sorobanrpc.com",
unifiedEvents: true,
},
cursorStore,
});
engine.start();This is the first step that changes where your events actually come from. It is also the step that can produce brief double observation of the same movement - see Dedupe during the transition below. Many deployments stop here permanently: "auto" is the mode with a fallback.
"unified" removes the Horizon fallback for asset movements. If RPC is down, those events do not arrive by another route - you find out instead of silently degrading. 🛠️
const engine = new EventEngine({
network: "mainnet",
ingestion: "unified",
soroban: {
rpcUrl: "https://mainnet.sorobanrpc.com",
unifiedEvents: true,
},
cursorStore,
});
engine.start();Only take this step if you run an RPC endpoint you control or trust operationally. Horizon is still connected for the families with no CAP-67 equivalent, so this is a narrowing of the fallback, not a removal of Horizon.
Rollback is the same flag in reverse - "unified" → "auto" → "horizon" - plus a restart. There is no migration to undo and no state to reset, because each transport checkpoints to its own cursor key.
// Roll all the way back to v1.0.0 behavior.
const engine = new EventEngine({
network: "mainnet",
ingestion: "horizon",
cursorStore,
});
engine.start();Roll back on any of: asset-movement events arriving late relative to your Horizon baseline, sustained engine.reconnecting with source: "unified", or a gap you can attribute to the RPC endpoint. Dropping to "auto" is usually enough - it restores the Horizon fallback without giving up the unified stream.
Each transport keeps its own cursor entry in the CursorStore, under its own key: ✅
| Transport | Key with streamKey set |
Default key |
|---|---|---|
| Horizon SSE | <streamKey> |
horizon:<network> |
| Soroban contract events | <streamKey>:soroban |
soroban:<network> |
| CAP-67 unified stream | <streamKey>:unified |
unified:<network> |
Three consequences worth internalizing before you flip anything:
- Switching modes never overwrites the Horizon cursor. Rolling back resumes Horizon from where it was, not from
"now"- which is exactly why rollback is cheap. - The two cursor values are not interchangeable. A Horizon cursor is a paging token; a unified cursor is the RPC
getEventspagination cursor. Both are opaque - never copy one into the other's key, and never hand-edit either. - A first cut to
"auto"/"unified"with no stored unified cursor starts the unified stream from the RPC's current position, not from your Horizon cursor. Run step 1 long enough for the unified cursor to be checkpointed and warm before the cut, and the switch is seamless. Cut cold and the window between the two positions is a gap for asset-movement events.
Cursor durability rules are unchanged by any of this - PostgresCursorStore for active-active, the in-memory default for development only. See docs/cursor-format.md. Full unified-stream cursor semantics in CursorStore, including replay against BACKFILL_STELLAR_ASSET_EVENTS history, are tracked in Wave 1.6. 🛠️
While both transports are live - "auto" steady state, an "auto" fallback recovery, or the moments either side of a cut - both can observe the same on-chain movement. Delivery is deduped on a key that is stable across transports: a Horizon operation record and a CAP-67 unified event for the same movement derive the same key, because both carry the transaction hash and the position within that transaction. ✅
import { deriveDedupeKey, DedupeWindow } from "@orbital-stellar/pulse-core";
const window = new DedupeWindow(10_000); // bounded: oldest key evicted at capacity
const key = deriveDedupeKey({ txHash: "abc123...", index: 0 });
if (window.seenBefore(key)) return; // already delivered by the other transportThe guarantee is at-most-once delivery within the window, not globally. DedupeWindow is bounded by design - memory stays flat no matter how many keys pass through - so a duplicate separated by more than capacity intervening events is not caught. Size the capacity above the number of events you expect inside your longest fallback flap, and keep your handlers idempotent regardless. Wiring this into the engine's delivery path ahead of watcher fan-out is 🛠️; the key derivation and window are usable now.
apps/web/content/guides/- narrative walkthroughs (real-time events, webhooks)docs/ARCHITECTURE.md- system diagrams, lifecycle, trust boundariespackages/pulse-core/README.md- full API referencepackages/pulse-webhooks/README.md- delivery contract, securitypackages/pulse-notify/README.md- React hook reference