Skip to content

Core concepts

Events

Publish something that happened once, and let every interested task react to it. Events decouple the code that knows about a change from the work it triggers.

On this page

Publish and subscribe

A job says do this; an event says this happened. When your checkout code publishes order.paid, it doesn't need to know that a receipt is emailed, revenue charts update and the CRM syncs. Each of those is a subscription that turns the event into a job on a queue.

Because every delivery becomes an ordinary job, it gets the same retries, timeouts, priorities and logs as any other work. There is no second system to monitor.

Publishing an event

An event has a type in noun.verb form and a data object up to 64 KB. The API answers once the event is stored and its deliveries are queued, and returns how many subscriptions matched.

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_6Tn2Rk", "amount": 4900, "currency": "eur", "plan": "pro" },    "ordering_key": "cus_4Hd8Wk2Pq"  }'
Event envelope
{  "id": "evt_9Dk3Vr8Ms1",  "object": "event",  "type": "order.paid",  "data": { "order_id": "ord_6Tn2Rk", "amount": 4900, "currency": "eur", "plan": "pro" },  "ordering_key": "cus_4Hd8Wk2Pq",  "published_at": "2026-10-14T10:41:22.507Z",  "source": "api",  "deliveries": 3}

Subscribing

A subscription maps an event type to a queue and task. Declare subscriptions next to your queues in halyard.yaml so they are reviewed and deployed with your code:

halyard.yaml
# halyard.yamlsubscriptions:  - event: order.paid    queue: emails    task: send-receipt   - event: order.paid    queue: reports    task: update-revenue-dashboard   - event: order.*    queue: webhooks    task: sync-crm-contact    filter: data.plan in ["pro", "scale"]

Or create them at runtime with a key that has the events:subscribe scope:

const sub = await halyard.subscriptions.create({  event: "order.paid",  queue: "emails",  task: "send-receipt",}); console.log(sub.id); // sub_4Pz7Kd1Xr5

The task receives the full event envelope as job.payload.event:

src/tasks/send-receipt.ts
worker.task("send-receipt", async (job) => {  const { event } = job.payload; // the full event envelope  if (await receipts.exists(event.data.order_id)) return; // already sent   await sendEmail({ template: "receipt", order: event.data.order_id });  await receipts.record(event.data.order_id, event.id);});

Event types

You can publish any type you like. Halyard also publishes its own events about jobs and schedules, which you subscribe to the same way, for example to alert on failures.

TypePublished byData fields
user.signed_upYour appuser_id, email, plan
order.paidYour apporder_id, amount, currency
invoice.finalizedYour appinvoice_id, customer_id
job.succeededHalyardjob_id, queue, task, duration_ms
job.failedHalyardjob_id, queue, task, error, attempts
schedule.missedHalyardschedule_id, due_at, policy

Patterns match on segments: order.* matches order.paid and order.refunded, job.failed matches only itself, and * matches everything you publish.

Fan-out

One event creates one job per matching subscription, on that subscription's queue. The jobs are independent: if the CRM sync fails and retries, the receipt still goes out on time.

order.paidevt_9Dk3Vr8Ms1
  • send-receiptqueue: emails
  • update-revenue-dashboardqueue: reports
  • sync-crm-contactqueue: webhooks
Three subscriptions match, so one publish creates three jobs that retry independently.

Filtering subscriptions

Add a filter to receive only the events you care about. Filters run before a job is created, so filtered-out events cost nothing and never show up in your queues.

FilterMatches
data.plan == "pro"Exact match on a field
data.amount >= 10000Numeric comparison (amounts in minor units)
data.currency in ["eur", "gbp"]Value in a list
exists(data.coupon)Field is present and not null
data.country != "US" && data.amount > 0Combine with && and ||
Terminal
halyard events test order.paid --file sample.json

Delivery guarantees

Delivery is at-least-once. Every matching subscription gets a job, and that job retries until it succeeds or runs out of attempts. In rare cases, such as a worker crashing after it finished but before it reported back, a handler runs twice for the same event.

  • Use event.id as a natural deduplication key, as the handler above does.
  • Prefer upserts and "set to" updates over increments.
  • Events are kept for 30 days and can be replayed to one subscription from the dashboard or API.

Ordering

By default events are delivered as fast as possible with no ordering between them. When order matters, set an ordering_key: events with the same key are delivered to each subscription one at a time, in publish order. A failing delivery holds back later ones with the same key until it succeeds or fails for good.

Does ordering slow everything down?
Only for events that share a key. Pick a key that groups what must stay ordered, like a customer ID, and events for different customers still run in parallel.
What happens to ordered events after a dead letter?
The failed delivery moves to the dead-letter queue and the next event for that key is released. Replaying the dead letter later does not reorder events that already ran.

Suggested