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
Publish the event
Publish after the database write commits, and include a
versionso consumers can tell an old event from a new one. Keepdatasmall: 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 } }'// app/orders/pay.jsimport { Halyard } from "@halyard/node";import { db } from "@/lib/db"; const halyard = new Halyard(process.env.HALYARD_KEY); export async function markPaid(order) { await db.orders.update(order.id, { status: "paid", version: order.version + 1 }); await halyard.events.publish({ type: "order.paid", data: { order_id: order.id, total: order.total, currency: order.currency, version: order.version + 1 }, });}# app/orders/pay.pyimport osfrom halyard import Halyardfrom app.db import db halyard = Halyard(os.environ["HALYARD_KEY"]) def mark_paid(order): db.orders.update(order.id, status="paid", version=order.version + 1) halyard.events.publish( type="order.paid", data={"order_id": order.id, "total": order.total, "currency": order.currency, "version": order.version + 1}, )// orders/pay.gofunc MarkPaid(ctx context.Context, client *halyard.Client, order Order) error { if err := db.MarkPaid(ctx, order.ID, order.Version+1); err != nil { return err } _, err := client.Events.Publish(ctx, &halyard.EventParams{ Type: "order.paid", Data: map[string]any{"order_id": order.ID, "total": order.Total, "currency": order.Currency, "version": order.Version + 1}, }) return err}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"],});# scripts/subscriptions.pyimport osfrom halyard import Halyard halyard = Halyard(os.environ["HALYARD_KEY"]) halyard.subscriptions.create(event="order.paid", queue="emails", task="send-receipt")halyard.subscriptions.create(event="order.paid", queue="inventory", task="reserve-stock") halyard.webhook_endpoints.create( url="https://analytics.lumen.example/hooks/halyard", events=["order.paid"],)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();# inventory/worker.pyimport osfrom halyard import Workerfrom inventory.db import db worker = Worker(os.environ["HALYARD_KEY"], queues=["inventory"]) @worker.task("reserve-stock")def reserve_stock(job): order_id, version = job.payload["order_id"], job.payload["version"] if not db.processed_events.insert_if_absent(job.event.id): return {"skipped": "duplicate event"} order = db.orders.find(order_id) if order.version > version: return {"skipped": "stale event"} db.stock.reserve(order.items) worker.start()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:
- Record
job.event.idin a table with a unique constraint and skip IDs you have already seen. - Compare the event's
versionwith 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.