Skip to content
EventsIntermediate12 min

Fan out events to many services

Publish one order.paid event and let receipts, inventory and analytics each react on their own queue, with their own retries.

Tomás ReyesStaff engineer, Platform · Updated October 12, 2026
  • Node.js
  • Python
  • Go
  • cURL
On this page

How it works

A job says do this; an event says this happened. When an order is paid, the checkout code shouldn't need to know that a receipt goes out, stock gets reserved and analytics wants a copy. It publishes one order.paid event, and Halyard delivers it to every subscriber.

  • Queue subscriptions turn the event into a job on a queue you choose. The job's payload is the event's data.
  • Webhook endpoints receive the event as a signed HTTPS POST, for services that don't run a Halyard worker.
  • Each delivery is independent. If inventory is down, receipts still go out, and inventory retries on its own schedule.

Build it

  1. Publish the event

    Publish after the database write commits, and include a version so consumers can tell an old event from a new one. Keep data small: IDs and the few fields every consumer needs.

    curl https://api.halyard.example/v3/events \  -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d" \  -H "Content-Type: application/json" \  -d '{    "type": "order.paid",    "data": { "order_id": "ord_7Hc2Lp", "total": 4800, "currency": "eur", "version": 3 }  }'
  2. Subscribe each consumer

    Subscriptions are configuration, so create them once from a script or your infrastructure code rather than at app start. Adding a fourth consumer later is one more line; the checkout code never changes.

    // scripts/subscriptions.jsimport { Halyard } from "@halyard/node"; const halyard = new Halyard(process.env.HALYARD_KEY); await halyard.subscriptions.create({ event: "order.paid", queue: "emails", task: "send-receipt" });await halyard.subscriptions.create({ event: "order.paid", queue: "inventory", task: "reserve-stock" }); await halyard.webhookEndpoints.create({  url: "https://analytics.lumen.example/hooks/halyard",  events: ["order.paid"],});
  3. Handle the event in a worker

    A job created from an event has the event's ID and type on job.event. The inventory worker uses the ID to skip duplicates and the version to skip events that arrive after a newer change.

    // inventory/worker.jsimport { Worker } from "@halyard/node";import { db } from "./db.js"; const worker = new Worker(process.env.HALYARD_KEY, { queues: ["inventory"] }); worker.task("reserve-stock", async (job) => {  const { order_id, version } = job.payload;   const isNew = await db.processedEvents.insertIfAbsent(job.event.id);  if (!isNew) return { skipped: "duplicate event" };   const order = await db.orders.find(order_id);  if (order.version > version) return { skipped: "stale event" };   await db.stock.reserve(order.items);}); worker.start();
  4. Follow the deliveries

    One command shows where an event went and how each delivery is doing. The same view lives under Events in the dashboard.

    Terminal
    $ halyard events retrieve evt_9Lw2Hb4Kc8evt_9Lw2Hb4Kc8  order.paid  published 2026-10-12 14:03:51 UTC DELIVERY        TARGET                                   STATUS     ATTEMPTSjob_4Fp8Rk2Vt1  emails / send-receipt                    succeeded  1job_6Gd1Wm9Qa3  inventory / reserve-stock                retrying   2 (next in 40s)whk_2Jx7Nc5Ue0  https://analytics.lumen.example/hooks…   succeeded  1

Ordering and duplicates

Halyard delivers each event at least once and makes no promise about order between events. A retried order.paid can arrive after order.refunded, and a network blip can deliver the same event twice. Two habits cover both cases:

  1. Record job.event.id in a table with a unique constraint and skip IDs you have already seen.
  2. Compare the event's version with the current record and ignore anything older.

Naming events

Use noun.past_tense_verb: order.paid, user.deleted, invoice.finalized. Name the fact, not the reaction you expect (order.paid, not send-receipt), so new consumers can subscribe without renaming anything. When the shape of data has to change in a breaking way, publish a new type such as order.paid.v2 alongside the old one until every consumer has moved.

Recap

  • Producers publish facts (order.paid); they don't know who listens.
  • Each subscription turns an event into its own job, with its own retries and dead letters.
  • Deliveries are at-least-once and unordered, so consumers check versions and dedupe on the event ID.
  • halyard events retrieve shows every delivery of one event in one place.

Next steps

Keep going with a related guide, or look up every option used here in the Jobs API reference.

Suggested