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_ator fetch the job if order matters. - Fast acknowledgement. Respond with any
2xxwithin 10 seconds, then do the slow work in the background, for example in a Halyard job.
Set up an endpoint
Expose an HTTPS route
Add a
POSTroute 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.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" }'const endpoint = await halyard.webhookEndpoints.create({ url: "https://app.northwind.example/webhooks/halyard", events: ["job.failed", "job.dead_lettered", "schedule.skipped"], description: "Alerting",}); console.log(endpoint.id); // whk_6Pq3Ns8Rb1console.log(endpoint.secret); // whsec_7mYb2Qe9LkT4 (shown once)endpoint = halyard.webhook_endpoints.create( url="https://app.northwind.example/webhooks/halyard", events=["job.failed", "job.dead_lettered", "schedule.skipped"], description="Alerting",) print(endpoint.id) # whk_6Pq3Ns8Rb1print(endpoint.secret) # whsec_7mYb2Qe9LkT4 (shown once)Store the signing secret
The response includes a
whsec_secret. It is shown once; store it asHALYARD_WEBHOOK_SECRETin your secret manager. Each endpoint has its own secret.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:
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{ "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
idstringevt_4Zr8Nq1Tx6. The same event keeps its ID across retries, so use it to deduplicate.typestringcreated_attimestamplivemodebooleanfalse for events from test keys. Reject test events in production to avoid surprises.dataobjectVerify 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:
- Split the header on
,and readt(Unix seconds) and everyv1value. - Build the signed string: the timestamp, a literal
., then the raw request body. - Compute an HMAC with SHA-256 over that string using your endpoint secret, as lowercase hex, and compare it to each
v1in constant time. - Reject the delivery if no signature matches, or if
tis 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);}import hashlibimport hmacimport jsonimport time TOLERANCE_SECONDS = 300 def verify_halyard_signature(raw_body: bytes, header: str, secret: str) -> dict: items = [part.split("=", 1) for part in header.split(",")] timestamp = int(next((v for k, v in items if k == "t"), 0)) signatures = [v for k, v in items if k == "v1"] if abs(time.time() - timestamp) > TOLERANCE_SECONDS: raise ValueError("Timestamp outside tolerance") signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() if not any(hmac.compare_digest(expected, sig) for sig in signatures): raise ValueError("No matching signature") return json.loads(raw_body)package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "strconv" "strings" "time") const tolerance = 5 * time.Minute func Verify(body []byte, header, secret string) error { var ts int64 var sigs []string for _, part := range strings.Split(header, ",") { k, v, _ := strings.Cut(part, "=") switch k { case "t": ts, _ = strconv.ParseInt(v, 10, 64) case "v1": sigs = append(sigs, v) } } if ts == 0 || time.Since(time.Unix(ts, 0)).Abs() > tolerance { return errors.New("timestamp outside tolerance") } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(strconv.FormatInt(ts, 10) + ".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) for _, sig := range sigs { if hmac.Equal([]byte(sig), []byte(expected)) { return nil } } return errors.New("no matching signature")}require "json"require "openssl" TOLERANCE_SECONDS = 300 def verify_halyard_signature(raw_body, header, secret) items = header.split(",").map { |part| part.split("=", 2) } timestamp = items.find { |k, _| k == "t" }&.last.to_i signatures = items.select { |k, _| k == "v1" }.map(&:last) if timestamp.zero? || (Time.now.to_i - timestamp).abs > TOLERANCE_SECONDS raise ArgumentError, "Timestamp outside tolerance" end expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}") unless signatures.any? { |sig| OpenSSL.secure_compare(sig, expected) } raise ArgumentError, "No matching signature" end JSON.parse(raw_body)endRotating 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:
| Attempt | Delay | Since first try |
|---|---|---|
| 1 | Immediately | 0 |
| 2 | 1 minute | 1 min |
| 3 | 5 minutes | 6 min |
| 4 | 30 minutes | 36 min |
| 5 | 2 hours | 2 h 36 min |
| 6 | 6 hours | 8 h 36 min |
| 7 | 12 hours | 20 h 36 min |
| 8–10 | 24 hours each | Up 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.disabledevent 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.
# 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# 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/halyardEvent 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.
| Event | Group | Sent when |
|---|---|---|
job.created | Jobs | A job was accepted and stored. Sent for scheduled jobs too. |
job.started | Jobs | A worker picked up the job and started an attempt. |
job.succeeded | Jobs | An attempt finished without an error. Includes duration and attempt number. |
job.failed | Jobs | An attempt raised an error. A retry is scheduled if attempts remain. |
job.dead_lettered | Jobs | The job used every attempt, or failed permanently, and moved to the dead-letter queue. |
job.canceled | Jobs | The job was canceled through the API or the dashboard before it finished. |
queue.paused | Queues | A queue stopped handing out jobs. Includes who paused it. |
queue.resumed | Queues | A paused queue started handing out jobs again. |
queue.backlog_exceeded | Queues | Waiting jobs passed the backlog threshold set on the queue. |
schedule.triggered | Schedules | A schedule fired and created its job. Includes the job ID. |
schedule.skipped | Schedules | A run was skipped because the previous one was still going (overlap: skip). |
event.undeliverable | Events | A published event matched no subscription. Useful to catch typos in event types. |
endpoint.disabled | Webhooks | An endpoint was turned off after three days of failed deliveries. |
key.expiring | Account | An API key expires in seven days. |
Best practices
Process events asynchronously
204 stays well under the 10-second limit, even when the downstream work is slow.Deduplicate on the event ID
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
Keep test and live apart
livemode so a misconfigured test endpoint can never trigger production side effects.