Menu QaldyDocs

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

  1. 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 with trigger_not_declared.
  2. 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.
  3. Copy the address, https://<api host>/v1/hooks/<automation id>, into the sender.
  4. 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

text
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 gets null).
  • No cookie is needed and none is read. This is the one /v1 route 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 trigger webhook.

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.

ts
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.

RuleResult
Signature does not match401 bad_signature
t more than 5 minutes from the server's clock (either way)401 stale_timestamp
The same signed request again409 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:

text
secret  qws_docs_example_secret
t       1800000000
body    {"type":"order.created","orderId":4411,"total":59.9}
v1      e851811046e5d0b2cd463b9eae911ed8156a73fdd83b98731f3cce285749996b

bash and openssl

bash
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

js
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

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.

text
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:

bash
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:

GuardWhat it does
Hidden fieldA 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 limit5 submissions a minute from one address to one form, and 20 a minute for the form as a whole (changeable, 1 to 600)
Size32 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:

html
<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

StatuscodeMeaning
202A run started (or the key matched one that did)
400invalid_body, invalid_idempotency_keyThe body did not parse as its Content-Type says, or the key is not usable
401bad_signature, bad_token, stale_timestampThe proof failed
403hook_disabledThe owner turned it off
403bad_originA public form was posted from a site that is not on its list
404hook_not_foundNo webhook at this address
409replayedThis signed request was already accepted
409automation_paused, not_runnable, trigger_not_declared, quota_reachedThe automation cannot run now; the message says why
413too_largeMore than 256 KB (32 KB for a public form)
415unsupported_media_typeNeither JSON nor a form
429rate_limitedSlow down; Retry-After says for how long
503hook_unreadableThe 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's CF-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

TargetStatusHow
In the consoleliveThe status on the automation and in the list, and the audit rows. Needs nothing set up
TelegramliveThrough 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
EmailplannedNot sent yet. Apps can send email through the Resend preset, but heartbeat alerts do not use it yet

A message reads, in plain words:

text
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

Type to search. Up and down to choose, Enter to open, Esc to close.