# Webhooks and heartbeats Two things for automations that start from the outside world. URL: https://docs.qaldy.com/connectors/webhooks/ 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:///v1/hooks/`, 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 ``` POST /v1/hooks/ Content-Type: application/json (or application/x-www-form-urlencoded) Qaldy-Signature: t=,v1= Idempotency-Key: ``` - 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/`). - 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 `"."`, 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 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)= `, OpenSSL 1.1 prints `(stdin)= `. `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. ``` 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: `; 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=`, 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: ```html
``` 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'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 | 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](https://docs.qaldy.com/connectors/telegram/)) to a chat id (`-1001234567890`) or a public channel (`@roastery_ops`). The bot must be in the chat | | Email | planned | Not sent yet. Apps can send email through the [Resend preset](https://docs.qaldy.com/connectors/resend/), 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 ```