Webhooks assume you have a server. GitHub, Stripe and Linear all want a public HTTPS address to POST to, and a Mac on home Wi-Fi has none. The usual answers each cost something. Port forwarding opens your router, a free tunnel changes its URL on every restart, and a VPS is one more machine to patch.
We hit this with our own releases. Signed macOS builds need a Mac, and GitHub's hosted macOS runners bill at ten times the Linux rate. We already had a Mac mini on a shelf. What we needed was for git push origin v1.54.0 to start a build on it.
How Watchflows ships its own releases
The mini runs the production app with one flow imported, Release on Tag:
- A tag push makes GitHub send a
createevent tohttps://hooks.watchflows.app/<channel-id>. - The relay queues it and pushes it down the mini's open connection.
- Public Webhook checks the GitHub signature on the mini.
- A Condition requires
body.ref_typeequalstagandbody.refmatches^v[0-9]+\.[0-9]+\.[0-9]+$, so a new branch never starts a build. - Run Script calls
release_local.sh, which reads the tag out of$PAYLOAD_bodyitself. Nothing from the webhook is templated into the shell. - The script prints a JSON summary, and two Conditions on
output.statussend a Shipped or Failed notification.lockedandskipped-already-builtstay quiet.
A second flow checks GitHub for new tags every ten minutes as a safety net, and a shared ledger of built tags keeps the two from building the same release twice.
Get a notification when a GitHub Actions run fails
You probably do not ship a Mac app from a closet. You probably do push code and find out 20 minutes later, from an email, that CI failed. This flow posts a notification the moment a GitHub Actions run fails, opens the run's page when you click it, and appends a line to a log.
GitHub (Public Webhook)
Select the node and press Create Public URL. The How to Use section shows the full address. Verify signature is already on in this flow (a new node starts with it off) and Signature Scheme is GitHub. Paste a long random string into Signing Secret (openssl rand -hex 32 makes one).
Then in GitHub, under the repository's Settings ▸ Webhooks ▸ Add webhook:
| Field | Value |
|---|---|
| Payload URL | https://hooks.watchflows.app/<channel-id> |
| Content type | application/json |
| Secret | The same string |
| Events | Let me select individual events ▸ Workflow runs |
The content type matters. GitHub defaults to form encoding, and the trigger only parses the body into an object when the sender says application/json. Otherwise body arrives as one string and every rule below reads nothing.
Failed run?
GitHub sends three workflow_run events for every run: requested, in_progress and completed. The Condition keeps one of them with two rules under Match All: body.action equals completed, and body.workflow_run.conclusion equals failure. A cancelled run has the conclusion cancelled and goes nowhere.
Tell me, and CI log
The Notification's title is {{body.repository.name}}: {{body.workflow_run.name}} failed, the body is the branch and {{body.workflow_run.display_title}}, and Action URL is {{body.workflow_run.html_url}}, so a click opens the failed run. Log to File appends {{timestamp}}, the repo, the branch and the URL to ~/logs/ci-failures.log.
Both actions hang off the Condition's Yes outlet rather than one after the other. Notification replaces the payload with notified, title and body, so anything wired after it would see no body.repository at all.
Give me a public webhook that verifies GitHub signatures. When a workflow_run event says a run completed with conclusion failure, notify me with the repo, workflow name and branch, open the run's page when I click the notification, and append a line to ~/logs/ci-failures.log.
What the relay sees, and what it cannot do
The relay is a pipe. It forwards the body byte for byte, with the headers, method and path, and it keeps the delivery in an encrypted queue only until your Mac confirms it. Channel records hold hashes of the token and license, never the values, and the gateways run with logging off, so bodies never reach a log line. The full list is on What Transits Our Servers.
Two limits follow from that design:
- It cannot tell real from forged. Signature checking happens on your Mac, with a secret the relay never has. Turn it on.
- It never answers for your flow. Every accepted delivery gets
202 Accepted. GitHub's Recent Deliveries tab shows green even when your Mac rejected the signature, so when a delivery does nothing, look at the trigger node's error first.
The address itself is the credential. Exporting a flow empties the channel id and the signing secret, which is why the download above has a blank secret. If the address leaks, Regenerate replaces it at once and Revoke kills it.
Sleep, duplicates and rate limits
- The Mac is asleep. Deliveries wait up to 24 hours and arrive in order when it reconnects. The queue holds 200 requests or 5 MB per channel.
- The same delivery arrives twice. The relay is at-least-once. Your Mac remembers the last 200 delivery ids per channel, on disk, so a repeat is confirmed but does not fire again, even after a restart. GitHub's Redeliver button is a new delivery to the relay, so it does fire, which is what you want while testing.
- The secret is wrong. The delivery is dropped with a trigger error and not confirmed. Fix the secret, and anything still queued is checked again on the next reconnect.
- It is busy. A channel takes 60 requests a minute (429 over that) and about 100 KB per request (413 over that). Every Actions run costs three deliveries, so a monorepo firing twenty runs a minute is at the cap.
- Two Macs, one flow. A channel has one live subscriber. Import the flow on one Mac only.
The relay comes with owning the app ($49 once) or a Watchflows Cloud plan, and needs the Enable public relay switch in Settings ▸ Webhooks.
Variations
- Only main. Add a third rule:
body.workflow_run.head_branchequalsmain. - Run something instead. Replace Tell me with Run Script. The payload arrives as environment variables (
$PAYLOAD_bodyholds the JSON), the same way the release flow reads its tag. - Stripe sales. Set the scheme to Stripe and test
body.typeequalscheckout.session.completed. One catch: the Stripe scheme rejects a signature more than five minutes old, measured when your Mac receives it. A sale that waited in the queue while the Mac slept fails the check, and Stripe will not resend it because it already got its 202. Use this on a Mac that stays awake.