Skip to content

Platform

Webhooks

Halyard calls your HTTPS endpoint when something happens: a job fails, a queue backs up, a schedule skips a run. Every delivery is signed, retried for three days and replayable.

On this page

How webhooks work

A webhook endpoint is a URL on your side plus a list of event types. When a matching event happens, Halyard sends it as a JSON POST, signed with a secret only you and Halyard know.

  • At-least-once delivery. An event can arrive more than once; deduplicate on its id.
  • No ordering guarantee. Use created_at or fetch the job if order matters.
  • Fast acknowledgement. Respond with any 2xx within 10 seconds, then do the slow work in the background, for example in a Halyard job.

Set up an endpoint

  1. Expose an HTTPS route

    Add a POST route to your app, like /webhooks/halyard. It must be reachable from the internet over HTTPS with a valid certificate. During development, use the CLI instead.

  2. Register the endpoint

    Create it in the dashboard under Settings → Webhooks, or through the API:

    curl https://api.halyard.example/v3/webhook_endpoints \  -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d" \  -H "Content-Type: application/json" \  -d '{    "url": "https://app.northwind.example/webhooks/halyard",    "events": ["job.failed", "job.dead_lettered", "schedule.skipped"],    "description": "Alerting"  }'
  3. Store the signing secret

    The response includes a whsec_ secret. It is shown once; store it as HALYARD_WEBHOOK_SECRET in your secret manager. Each endpoint has its own secret.

  4. Verify, acknowledge, process

    Check the signature, return 204, and hand the event to a background job. The rest of this page covers each part.

Event payload

Every delivery has the same envelope. Headers identify the delivery and carry the signature:

Request headers
POST /webhooks/halyard HTTP/1.1Host: app.northwind.exampleContent-Type: application/jsonUser-Agent: Halyard-Webhooks/3.4Halyard-Delivery: dlv_2Hx7Kc9Me4Halyard-Signature: t=1791970331,v1=5f0c8e1a9b3d47c2e6a1f08b7d94c3e2a5b6f1d0c9e8a7b6d5c4f3e2a1b0c9d8
Request body
{  "id": "evt_4Zr8Nq1Tx6",  "type": "job.failed",  "created_at": "2026-10-14T09:32:11Z",  "api_version": "v3",  "livemode": false,  "data": {    "job": {      "id": "job_3Rk9Tq7Xw2",      "queue": "emails",      "task": "send-welcome-email",      "status": "retrying",      "attempt": 2,      "max_attempts": 5,      "next_attempt_at": "2026-10-14T09:37:11Z",      "last_error": {        "message": "SMTP timeout after 10s",        "class": "TimeoutError"      }    }  }}

Envelope fields

idstring
Event ID, like evt_4Zr8Nq1Tx6. The same event keeps its ID across retries, so use it to deduplicate.
typestring
One of the event types below.
created_attimestamp
When the event happened, in UTC (ISO 8601). Not when it was delivered.
livemodeboolean
false for events from test keys. Reject test events in production to avoid surprises.
dataobject
The resource the event is about, as the API would return it at the moment of the event.

Verify signatures

Anyone can send a POST to your endpoint. The Halyard-Signature header proves a delivery came from Halyard and wasn't changed or replayed. It looks like t=1791970331,v1=5f0c8e…, and you verify it in four steps:

  1. Split the header on , and read t (Unix seconds) and every v1 value.
  2. Build the signed string: the timestamp, a literal ., then the raw request body.
  3. Compute an HMAC with SHA-256 over that string using your endpoint secret, as lowercase hex, and compare it to each v1 in constant time.
  4. Reject the delivery if no signature matches, or if t is more than 5 minutes from your clock.
import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; export function verifyHalyardSignature(rawBody, header, secret) {  const items = header.split(",").map((part) => part.split("="));  const timestamp = Number(items.find(([k]) => k === "t")?.[1]);  const signatures = items.filter(([k]) => k === "v1").map(([, v]) => v);   if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {    throw new Error("Timestamp outside tolerance");  }   const expected = crypto    .createHmac("sha256", secret)    .update(`${timestamp}.${rawBody}`)    .digest("hex");   const valid = signatures.some(    (sig) =>      sig.length === expected.length &&      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),  );  if (!valid) throw new Error("No matching signature");   return JSON.parse(rawBody);}

Rotating secrets

Roll a secret from the dashboard or with POST /webhook_endpoints/:id/roll_secret. For 24 hours by default, Halyard signs every delivery with both the old and the new secret, so the header carries two v1 values. Deploy the new secret any time in that window; the code above accepts either.

Retries and delivery

A delivery succeeds when your endpoint answers 2xx within 10 seconds. Anything else, including redirects and timeouts, counts as a failure and is retried on this schedule:

Webhook delivery retry schedule
AttemptDelaySince first try
1Immediately0
21 minute1 min
35 minutes6 min
430 minutes36 min
52 hours2 h 36 min
66 hours8 h 36 min
712 hours20 h 36 min
8–1024 hours eachUp to 3 days
  • After 10 failed attempts the delivery is marked failed. It stays visible in the dashboard for 30 days and you can resend it with one click.
  • If every delivery to an endpoint fails for three days, the endpoint is disabled and we send an endpoint.disabled event to your other endpoints and an email to project owners.
  • Requests come from a fixed set of IP addresses per region, listed on the Security page, if your firewall needs them.

Test locally

The Halyard CLI opens a tunnel from your test project to your machine, so you can receive real deliveries on localhost without exposing anything.

Terminal 1
# Log in once; the CLI stores a test-mode key for this machine.halyard login # Forward deliveries for your test project to a local server.halyard listen --forward-to localhost:3000/webhooks/halyard> Ready. Signing secret for this session: whsec_cli_Jr4Wd8Hs2V> Forwarding job.*, queue.*, schedule.* to http://localhost:3000/webhooks/halyard
Terminal 2
# In a second terminal: send a realistic test event.halyard trigger job.failed # Re-send a real delivery from the last 30 days to your local server.halyard events resend evt_4Zr8Nq1Tx6 --forward-to localhost:3000/webhooks/halyard

Event types

Subscribe to the types you need, or use a wildcard like job.*. New types are added in minor releases and never sent to endpoints that didn't subscribe to them, except through wildcards.

Webhook event types
EventGroupSent when
job.createdJobsA job was accepted and stored. Sent for scheduled jobs too.
job.startedJobsA worker picked up the job and started an attempt.
job.succeededJobsAn attempt finished without an error. Includes duration and attempt number.
job.failedJobsAn attempt raised an error. A retry is scheduled if attempts remain.
job.dead_letteredJobsThe job used every attempt, or failed permanently, and moved to the dead-letter queue.
job.canceledJobsThe job was canceled through the API or the dashboard before it finished.
queue.pausedQueuesA queue stopped handing out jobs. Includes who paused it.
queue.resumedQueuesA paused queue started handing out jobs again.
queue.backlog_exceededQueuesWaiting jobs passed the backlog threshold set on the queue.
schedule.triggeredSchedulesA schedule fired and created its job. Includes the job ID.
schedule.skippedSchedulesA run was skipped because the previous one was still going (overlap: skip).
event.undeliverableEventsA published event matched no subscription. Useful to catch typos in event types.
endpoint.disabledWebhooksAn endpoint was turned off after three days of failed deliveries.
key.expiringAccountAn API key expires in seven days.

Best practices

Process events asynchronously
Acknowledge first, then work. A handler that enqueues a Halyard job and returns 204 stays well under the 10-second limit, even when the downstream work is slow.
Deduplicate on the event ID
Store processed evt_ IDs for a few days, for example with a unique index, and skip any you have already handled. Retries and resends reuse the same ID.
Subscribe narrowly
Every endpoint receives only the types it lists. Narrow subscriptions mean less traffic, fewer wasted retries and a smaller blast radius if an endpoint goes down.
Keep test and live apart
Test and live projects have separate endpoints and secrets. Check livemode so a misconfigured test endpoint can never trigger production side effects.

Suggested