Webhooks and heartbeats
Two things for automations that start from the outside world.
Two things for automations that start from the outside world.
- Webhook: another system calls an address, and the automation runs with the call's body as its input. Every call is checked before anything starts.
- Heartbeat: you say how often an automation must run. When it goes quiet, Qaldy says so. This catches the failure where every step is green and nothing landed, because nothing ran.
Both live on the automation: /automations → open one → What starts it (Webhook) and Heartbeat.
Set a webhook up#
- Open the automation and pick Webhook under What starts it. The app behind it must declare
a webhook trigger in its manifest (
{ "type": "webhook" }). The builder does not write one yet: add it and publish a new version, or a call is refused withtrigger_not_declared. - Set up webhook creates the secret. It is shown once: copy it now into the sender's
secret store. The console keeps only a hint (
qws_…ab12) so you can tell secrets apart. - Copy the address,
https://<api host>/v1/hooks/<automation id>, into the sender. - Send test event posts a signed sample through the same path a real call takes (the delivery is marked Sample, and a run starts). The last ten deliveries are listed with their status.
Rotate secret issues a new one at once; the old one stops working at that moment. The
Accepting calls switch turns the webhook off: calls get 403 hook_disabled until it is on
again. Who calls it offers Your own code, signed, A service that sends a token, A
Telegram bot, GitHub, A web form on your site and Anyone, with no proof (below), and
Rate limit sets the calls a minute. A new webhook starts as whichever you pick in Set up
webhook; switching later replaces the secret (or removes it, for a form) after a confirmation. Creating, rotating, switching and testing each write an audit row (webhook.create, webhook.rotate,
webhook.mode, webhook.enable, webhook.disable, webhook.limit, webhook.test). An audit row
holds the mode and the hint, never a secret.
The request#
POST /v1/hooks/<automation id>
Content-Type: application/json (or application/x-www-form-urlencoded)
Qaldy-Signature: t=<unix seconds>,v1=<hex>
Idempotency-Key: <optional, 1–128 of A-Za-z0-9._:->- The body is at most 256 KB. JSON or a urlencoded form; anything else is
415. An empty body is allowed (the app getsnull). - No cookie is needed and none is read. This is the one
/v1route the browser origin check lets through, and only this exact path shape (/v1/hooks/<id>). - A correct call answers
202 {"status":"accepted","runId":"run_…","deliveryId":"whd_…"}. The run is recorded with triggerwebhook.
What the app receives#
The parsed body reaches the app's scheduled() handler as event.input (a form arrives as an
object; a field sent twice is an array; no body is null). The platform has checked the caller,
but the content is still the sender's: validate it like any request body.
export default defineApp({
async scheduled(event, ctx) {
const order = event.input as { orderId?: number } | null
if (!order?.orderId) return { idle: true } // nothing to do, saves no one's time
// …
},
})Signing a request#
v1 is the lowercase hex of HMAC-SHA256 over the string "<t>.<raw body>", keyed with the
secret. t is the current time in unix seconds, and the bytes you sign must be the bytes you send:
sign the exact string, not an object you serialise twice.
| Rule | Result |
|---|---|
| Signature does not match | 401 bad_signature |
t more than 5 minutes from the server's clock (either way) | 401 stale_timestamp |
| The same signed request again | 409 replayed |
More than one v1= | any one matching is enough (sign with both secrets while you rotate) |
Other schemes (v0=, v2=) | ignored |
A caller that cannot sign gets bad_signature and nothing else: the answer never says whether the
address, the timestamp or the secret was wrong.
A known value to check your code against:
secret qws_docs_example_secret
t 1800000000
body {"type":"order.created","orderId":4411,"total":59.9}
v1 e851811046e5d0b2cd463b9eae911ed8156a73fdd83b98731f3cce285749996bbash and openssl#
URL='https://api.example.com/v1/hooks/auto_k3v9q8'
BODY='{"type":"order.created","orderId":4411,"total":59.9}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$QALDY_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST "$URL" \
-H 'Content-Type: application/json' \
-H "Qaldy-Signature: t=$T,v1=$SIG" \
-H 'Idempotency-Key: order-4411' \
--data "$BODY"sed keeps the hex: OpenSSL 3 prints SHA2-256(stdin)= <hex>, OpenSSL 1.1 prints (stdin)= <hex>.
printf '%s.%s' adds no newline, which matters: a trailing newline changes the signature.
Node#
import { createHmac } from 'node:crypto'
const url = 'https://api.example.com/v1/hooks/auto_k3v9q8'
const body = JSON.stringify({ type: 'order.created', orderId: 4411, total: 59.9 })
const t = Math.floor(Date.now() / 1000)
const v1 = createHmac('sha256', process.env.QALDY_WEBHOOK_SECRET)
.update(`${t}.${body}`)
.digest('hex')
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Qaldy-Signature': `t=${t},v1=${v1}`,
'Idempotency-Key': 'order-4411',
},
body, // the same string that was signed
})
console.log(res.status, await res.json())Python#
import hashlib, hmac, json, os, time, urllib.request
url = "https://api.example.com/v1/hooks/auto_k3v9q8"
body = json.dumps({"type": "order.created", "orderId": 4411, "total": 59.9},
separators=(",", ":")).encode()
t = int(time.time())
v1 = hmac.new(os.environ["QALDY_WEBHOOK_SECRET"].encode(),
f"{t}.".encode() + body, hashlib.sha256).hexdigest()
req = urllib.request.Request(url, data=body, method="POST", headers={
"Content-Type": "application/json",
"Qaldy-Signature": f"t={t},v1={v1}",
"Idempotency-Key": "order-4411",
})
with urllib.request.urlopen(req) as res:
print(res.status, res.read().decode())The console shows the same bash and Node recipes with the address filled in, and they read the secret from the environment: a secret pasted into an example ends up in a chat or a repository.
Services that cannot sign#
Some services let you set a header or a URL, nothing more. Choose Token: the
console issues a token (qwt_…, shown once) and the sender puts it in a header.
curl -X POST "$URL" -H 'Content-Type: application/json' -H "X-Qaldy-Token: $QALDY_WEBHOOK_TOKEN" --data "$BODY"A wrong or missing token is 401 bad_token. A token is a password that travels in every call, so it
has no timestamp and cannot be told from a replay: add an Idempotency-Key when a repeated call
must not run twice. Only a digest of the token is kept.
None (unsigned: no proof, the address alone starts a run) is off by default. It exists for callers that cannot send a header at all. Choosing it asks Turn the proof off? and is written to the audit log; anyone who learns the address can then start the automation, so keep the rate limit low and the app's own checks in place.
Telegram, GitHub and public forms#
Three more ways to start an automation, each proved the way its sender can prove itself. All of
them reach the app the same way: the parsed body is event.input of scheduled().
A Telegram bot#
Choose A Telegram bot. The console issues a token (qwt_…, shown once) and shows a
setWebhook call that gives it to Telegram as the secret_token:
URL='https://api.example.com/v1/hooks/auto_k3v9q8'
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
--data-urlencode "url=$URL" \
--data-urlencode "secret_token=$QALDY_WEBHOOK_TOKEN" \
--data-urlencode 'allowed_updates=["message","callback_query"]'From then on Telegram calls the address for each update with X-Telegram-Bot-Api-Secret-Token: <token>; a wrong or missing one is 401 bad_token. The update's update_id makes Telegram's
retry a repeat: the answer is 202 with the same run and Idempotent-Replayed: true, not a
second run. Notes: Telegram keeps one webhook per bot, so this replaces any other and stops
getUpdates; the bot token is read from your shell and never goes through Qaldy, which only
holds the webhook token. Qaldy does not call setWebhook for this trigger. (Telegram ops, in Settings → Telegram,
does call it for a bot of its own, and asks before replacing a webhook that is already there: use a
separate bot for each.) The
Telegram inbox to a sheet template is built on this.
GitHub#
Choose GitHub. The console issues a secret (qws_…, shown once). In the repository:
Settings → Webhooks → Add webhook, payload URL = the address, content type
application/json, secret = the one shown, then the events the app handles (the console's example
makes the same call with curl or Node). GitHub signs each delivery with
X-Hub-Signature-256: sha256=<hex>, HMAC-SHA256 over the raw body with that secret; a mismatch is
401 bad_signature. X-GitHub-Delivery makes a redelivery a repeat. GitHub sends a ping first,
which starts a run like any other body: an app that handles only issues should ignore a body with
no issue. There is no timestamp in GitHub's scheme, so a captured delivery can be sent again;
the delivery id catches GitHub's own redeliveries only.
A web form on your site#
Choose A web form on your site for a public page that anyone may submit. It has no secret: a browser can't keep one. It is guarded instead:
| Guard | What it does |
|---|---|
| Hidden field | A field named _gotcha (changeable) that people never see. A bot that fills it in gets 202 like everyone else, a delivery row says honeypot, and nothing starts |
| Rate limit | 5 submissions a minute from one address to one form, and 20 a minute for the form as a whole (changeable, 1 to 600) |
| Size | 32 KB at most, 40 fields; JSON or a urlencoded form |
| Sites (optional) | Up to five https://example.com origins. When a browser names its site (Origin) and it is not listed: 403 bad_origin. A request that names no site (a script, curl) is not stopped, so this keeps other people's pages out, not attackers |
| Redirect (optional) | An https page a browser is sent to after a submit (303); without one it shows a short thank-you page |
A browser posting the form (Accept: text/html) gets a page; anything else gets the usual JSON.
Put it on your site as a plain form, no script needed:
<form action="https://api.example.com/v1/hooks/auto_k3v9q8" method="POST">
<input name="name" placeholder="Your name" required>
<input name="phone" placeholder="Phone" required>
<textarea name="message"></textarea>
<!-- Bots fill in every field. People never see this one. -->
<input name="_gotcha" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px">
<button type="submit">Send</button>
</form>The settings (hidden field name, redirect, sites) are on the automation under the mode picker;
changing them writes webhook.form to the audit log (the field name, how many sites, whether
there is a redirect).
Because there is no proof, anything the form's visitors write is untrusted input: the app must
validate and cap what it reads. The Website lead to a sheet and Telegram
and Website form to a Notion database templates do. A form is not a CAPTCHA: a determined
script can still submit to the limit, so keep the limits low and the app's checks in place.
Safe retries: Idempotency-Key#
Send the same Idempotency-Key again and no new run starts: the answer is 202 with the same
runId and an Idempotent-Replayed: true header. Keys are remembered for 24 hours per webhook.
Use something the sender already has: an order id, an event id. A retry after a timeout or a 5xx
is then safe, which a replayed signature (409) is not.
Answers#
| Status | code | Meaning |
|---|---|---|
| 202 | A run started (or the key matched one that did) | |
| 400 | invalid_body, invalid_idempotency_key | The body did not parse as its Content-Type says, or the key is not usable |
| 401 | bad_signature, bad_token, stale_timestamp | The proof failed |
| 403 | hook_disabled | The owner turned it off |
| 403 | bad_origin | A public form was posted from a site that is not on its list |
| 404 | hook_not_found | No webhook at this address |
| 409 | replayed | This signed request was already accepted |
| 409 | automation_paused, not_runnable, trigger_not_declared, quota_reached | The automation cannot run now; the message says why |
| 413 | too_large | More than 256 KB (32 KB for a public form) |
| 415 | unsupported_media_type | Neither JSON nor a form |
| 429 | rate_limited | Slow down; Retry-After says for how long |
| 503 | hook_unreadable | The server cannot read this webhook's secret; rotate it |
Errors are the usual envelope: { "error": { "code", "message", "hint" }, "requestId" }.
Limits#
- Per webhook: 60 calls a minute by default, 1 to 600 in the console. Only callers that proved themselves take from it, so someone who merely knows the address cannot use up your allowance.
- Per address: 20 refused calls in a minute from one IP to one webhook pause that IP for that
webhook (
429), so guessing a signature is slow. A public form also takes 5 submissions a minute per address. In production the address is Cloudflare'sCF-Connecting-IP(the API is reachable only through the tunnel, so it can't be forged); elsewhere it is the connection's. - Deliveries: the console lists the last ten, and rows are kept for a day. A delivery row never holds a body, only its size, the outcome and why. The one place a body is kept is a dead letter (below): a call that proved itself (a good signature or token) but could not start a run because the automation was paused or off, the app had no code or did not declare the trigger, a budget the owner set was used up, or the waiting list at the spending cap was full. Nothing is kept from a caller that did not prove itself, from a public form, or from a console test event. See replay.md.
- Limits are counted per API process today. Behind more than one API replica they are per replica.
How the secret is kept#
Heartbeats#
Open Heartbeat on an automation and say at least N runs per hour (or per day).
- What counts: runs that succeeded and started in the window, which trails now by an hour or a day. Failed and blocked runs are a different problem (they already show as failures); a run still in flight counts when it ends.
- Healthy while the window holds N or more.
- Quiet from the moment it first held fewer, shown as "Quiet for 3 h". A new expectation gets one full window to prove itself before any verdict.
- Alerting once it has been quiet past a grace period (10 minutes for an hourly expectation, 1 hour for a daily one), so a job due at 09:00 that starts at 09:00:40 does not flap. This is when people are told, once.
- Healthy again the moment a run restores the count: the state clears and the same places are told it is running again. A paused automation, or a heartbeat switched off, is not watched.
A minute-by-minute evaluator reads the runs table; it never changes the automation itself (a quiet
automation is not a broken one). Each alert writes heartbeat.alert, a recovery
heartbeat.recovered, and every send attempt heartbeat.notified with ok and a reason when it
failed. A failed send is recorded and not retried in a loop: the alert already stands in the console.
Who is told#
| Target | Status | How |
|---|---|---|
| In the console | live | The status on the automation and in the list, and the audit rows. Needs nothing set up |
| Telegram | live | Through the workspace's own bot (Telegram connector) to a chat id (-1001234567890) or a public channel (@roastery_ops). The bot must be in the chat |
| planned | Not sent yet. Apps can send email through the Resend preset, but heartbeat alerts do not use it yet |
A message reads, in plain words:
Daily sales sync has gone quiet.
It ran 0 times in the last day; it should run at least 1 a day.
Quiet since 2026-10-15 09:00 UTC.
https://console.example.com/automations?id=auto_k3v9q8