Publish Nodora Signals to a Kafka Topic
Integrate Nodora with Apache Kafka using a Node.js microservice that publishes rule signals to a Kafka topic for real-time event streaming.
A signal is a domain event your rule already knows how to describe:
- "This order is worth streaming"
- "This account tripped the fraud threshold"
- "This user just crossed into a paid tier"
Those are exactly the events the rest of your stack wants on a bus, where a warehouse loader, a feature pipeline, and three other consumers can each read them at their own pace.
Kafka is the usual home for that bus. The gap is that a Nodora webhook action speaks HTTP and Kafka does not. This post closes the integration gap by introducing a lightweight microservice. The webhook action POSTs the signal payload to the microservice, which transforms it into an event and publishes it to a Kafka topic. The rule decides, the signal carries the decision, the microservice puts it on the log.
The rule and the signal
The decision lives in the ruleset. The signal is the part that leaves the building, so declare it with the fields a downstream consumer will actually want:
signal OrderPlaced(order_id, user_id, total, currency, tier)
rule Checkout {
out approved = input.total > 0 && input.items_count > 0
high_value = input.total >= 500
emit OrderPlaced(
input.order_id,
input.user_id,
input.total,
input.currency,
input.tier
) when approved && high_value
}Two pieces matter. signal OrderPlaced(...) declares the event and the positional arguments it carries: this is the name you will pick from the dropdown when creating the action, and the parameter order is the contract the microservice relies on. emit ... when approved && high_value is the gate: only approved, high-value orders make it onto the topic, so the rule doubles as your filter. Loosen or tighten that condition later and the stream changes with no code change anywhere downstream.
Once the rule looks right, release it to the environment your application talks to (usually production). Actions only run against released rule versions.
When the signal is emitted in a released environment, the platform fans out to every action attached to that signal name. The Webhook action performs a single POST with a JSON body shaped like the emission:
{
"name": "OrderPlaced",
"args": ["ord_9f2", "usr_01H8X9", 540, "usd", "pro"]
}args is positional and lines up with the signal declaration: order_id, user_id, total, currency, tier.
The microservice
This is a small standalone service rather than a serverless route on purpose: a Kafka producer holds a long-lived connection to the brokers, and confluent-kafka-javascript is a native binding around librdkafka. You want one producer that connects once and stays warm, not one per request. Run it anywhere the platform can reach over HTTPS.
Install the dependencies:
npm install express @confluentinc/kafka-javascriptA single shared producer, connected lazily and reused across requests:
// kafka.ts
import { KafkaJS } from "@confluentinc/kafka-javascript";
const kafka = new KafkaJS.Kafka({
kafkaJS: {
brokers: process.env.KAFKA_BROKERS!.split(","),
ssl: true,
sasl: {
mechanism: "plain",
username: process.env.KAFKA_API_KEY!,
password: process.env.KAFKA_API_SECRET!,
},
},
});
let producerPromise: Promise<KafkaJS.Producer> | null = null;
export function getProducer() {
if (!producerPromise) {
const producer = kafka.producer();
producerPromise = producer.connect().then(() => producer);
}
return producerPromise;
}The kafkaJS-namespaced config is the KafkaJS-compatible API that ships inside @confluentinc/kafka-javascript. Point brokers at a local broker and drop ssl/sasl for development.
Now the endpoint. It does three things: reject requests without a valid secret key, turn the positional args into a named event, and produce that event to a topic keyed by the order id so all events for one order land on the same partition in order.
// server.ts
import express from "express";
import { timingSafeEqual } from "node:crypto";
import { getProducer } from "./kafka";
const app = express();
app.use(express.json());
const SECRET_KEY = process.env.WEBHOOK_SECRET_KEY!;
const TOPIC = process.env.KAFKA_TOPIC ?? "nodora.signals";
function validSecretKey(provided: string | undefined) {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(SECRET_KEY);
return a.length === b.length && timingSafeEqual(a, b);
}
// maps a signal's positional args back into a named event
function toEvent(name: string, args: unknown[]) {
switch (name) {
case "OrderPlaced": {
const [order_id, user_id, total, currency, tier] = args;
return {
key: String(order_id),
value: { order_id, user_id, total, currency, tier },
};
}
default:
// unknown signals still get through, keyed by name
return { key: name, value: { args } };
}
}
app.post("/signals", async (req, res) => {
if (!validSecretKey(req.header("x-secret-key"))) {
return res.status(401).json({ error: "invalid secret key" });
}
const { name, args } = req.body ?? {};
if (typeof name !== "string" || !Array.isArray(args)) {
return res.status(400).json({ error: "invalid payload" });
}
const { key, value } = toEvent(name, args);
try {
const producer = await getProducer();
await producer.send({
topic: TOPIC,
messages: [
{
key,
value: JSON.stringify({
signal: name,
data: value,
emitted_at: new Date().toISOString(),
}),
},
],
});
// webhook delivery counts as success once the broker has acked
return res.status(202).json({ ok: true });
} catch (err) {
console.error("kafka produce failed", err);
return res.status(502).json({ error: "failed to publish" });
}
});
app.listen(3000, () => console.log("signal bridge listening on :3000"));A few choices worth calling out:
- The secret key is the only thing standing between the open internet and your topic. The endpoint is a public URL, so the header check is not optional.
timingSafeEqualkeeps the comparison from leaking the key's length or contents through response timing. - Producing happens inside the request and the endpoint returns
202only after the broker acknowledges. If Kafka is down, the wrapper returns502and the platform records a failed delivery instead of silently dropping the event. - The Kafka envelope adds
signalandemitted_ataround the data so consumers can route and order without re-deriving anything from positions.
Wire up the action
In the ruleset's Actions tab, create an action:
- Signal: pick
OrderPlacedfrom the dropdown. It is populated from the signals declared in your ruleset. - Action: pick Webhook.
- Webhook URL: the public URL of the wrapper, e.g.
https://yourdomain.com/signals. - Headers: add
x-secret-keywith the same secret value asWEBHOOK_SECRET_KEYon the service.
Save it. From here on, every emitted OrderPlaced in the released environment becomes a POST to the microservice, which authenticates it and produces it to the topic.
Evaluate from your app
The last piece is running the rule. The @nodora/client package fetches the released ruleset, evaluates it locally against your input, and routes emitted signals through the platform so configured actions run:
// app/lib/nodora.ts
import { NodoraClient } from "@nodora/client";
export const nodora = new NodoraClient({
apiKey: process.env.NODORA_API_KEY!,
env: "production",
strategy: { type: "stale-while-revalidate", ttl: 60_000 },
});
export const checkout = nodora.ruleset("Checkout");Then, wherever an order is finalized, evaluate the rule with the order's details:
import { checkout } from "@/app/lib/nodora";
export async function onOrderPaid(order: Order) {
const result = await checkout.evaluate("Checkout", {
order_id: order.id,
user_id: order.userId,
total: order.total,
currency: order.currency,
items_count: order.items.length,
tier: order.customerTier,
});
// approved is just an output; the signal fan-out already happened
return result.outputs.approved;
}A $40 order evaluates, the when guard rejects, nothing is emitted, and the topic stays quiet. A $540 order evaluates, the guard passes, OrderPlaced is emitted, the platform POSTs it to the microservice.
Why the seam is worth it
Each layer owns exactly one thing, and you can change any of them without touching the others:
- The rule owns the decision and the filter. Stream every order, or only high-value ones, or only in certain regions: that is a
whenedit and a release. - The action owns the transport. The platform's only job is "POST this payload here with this header."
- The wrapper owns the Kafka details: brokers, topic, partitioning, the envelope. Swap the topic, change the key strategy, or add a second sink, and neither the rule nor your application code changes.
A rule decides, a signal carries the decision, an action delivers it over HTTP, and one small service turns it into a durable event on the log.