01 / apply it
yardr pack apply webhookyardr carries this pack in its binary; the name is enough. yardr pack check webhook shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/webhook/From this site, as the files below.
02 / the files
Every file, as it ships.
# A generic webhook as a pack of kind source: the integration, imported
# into the yard and into no depot. It is for any service that POSTs to a
# URL or takes one, which covers Zapier, Make and n8n in both directions.
# The yard hears any POST at a hook path, checked by a shared secret, and
# it can send one call, a JSON object POSTed to a URL that is itself the
# secret.
#
# yardr pack apply webhook
#
# The source is named webhook. token is a name yardr has built in, so this
# pack cannot take it.
name: webhook
kind: source
setup: SETUP.md
sources:
webhook:
declaration: webhook.yaml
# Setting up a generic webhook
The pack adds one source, `webhook`, for any service that can POST to a URL
or hand you a URL to POST to. Zapier, Make and n8n are the three it was
written for, and the steps below name each. The two directions are
separate, and you can set up either without the other.
- **Hear.** The service POSTs to a hook path of the yard with a shared
secret in a header. The POST becomes an event, and a subscription
delivers it (steps 2 to 4).
- **Send.** An agent POSTs a JSON object to a URL the service gave you, with
`yardr call webhook send` (steps 5 to 7). That URL is the credential. These
services make its path hard to guess, so anyone who holds it can start
what it starts. The yard keeps it the way it keeps a key.
1. **Apply the pack.** As the operator:
```sh
yardr pack apply webhook
# source webhook is declared: 1 call under the URL in WEBHOOK_URL (yardr source show webhook)
```
The commands that read a body and print one are written in `jq`, so the
machine the yard runs on needs it.
## Hearing a service
2. **A hook and its secret.** The secret is yours to make. The service keeps
whatever you give it, and the yard keeps the same value in the file the
hook names, so put it in both places. Make one hook for each Zap,
scenario or workflow that posts, each with a secret of its own, so you
can turn one off without touching the rest. A new file takes its mode
from the shell's umask, so narrow that first. Each command below is one
thing to type, in a block of its own:
```sh
umask 077
```
```sh
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' > ~/.yardr/webhook.secret
```
```sh
chmod 600 ~/.yardr/webhook.secret
```
```sh
yardr hook add zapier --source webhook --secret-file ~/.yardr/webhook.secret
# hook zapier saved: POST /hooks/zapier (webhook)
```
The path ends in the hook's name, here `zapier`. Name each hook for its
sender and give each its own secret file.
3. **The sender.** All three post the same request: a POST to
`https://<public host>/hooks/<name>` with a JSON body and the header
`X-Yardr-Secret` set to the secret from step 2.
- **Zapier.** Add an action, *Webhooks by Zapier*, with the event *POST*.
Set *URL* to the hook's address and *Payload Type* to *JSON*. Under
*Data*, add a key and a value for each field you want to send. Under
*Headers*, put `X-Yardr-Secret` in the left field and the secret in
the right.
- **Make.** Add the *HTTP* app's *Make a request* module. Set *URL* to the
hook's address and *Method* to *POST*. Under *Headers*, add
`X-Yardr-Secret` with the secret as its value. Set *Body content type* to
*application/JSON* and *Body input method* to *JSON string*, then put
the JSON object in *Body content*. This module sends only to `https://`
addresses, so the yard's public address has to be one.
- **n8n.** Add an *HTTP Request* node. Set *Method* to *POST* and *URL* to
the hook's address. Turn on *Send Headers*, choose *Using Fields Below*
under *Specify Headers*, and add the name `X-Yardr-Secret` with the
secret as its value. To keep the secret out of the workflow, use a
*Header Auth* credential with the same name and value instead. Turn on
*Send Body*, set *Body Content Type* to *JSON*, and fill in *Specify
Body*.
Any other service that can set a header on a POST works the same way.
4. **What the yard hears.** A JSON object body can set `type`, `action`,
`text`, `url` and `actor`. A `title` stands in where there is no `text`.
The `url` is kept when it starts with `http://` or `https://`, and the
`actor` is an object with an `id` and a `name`, or a bare name. What the
body leaves out is filled in: the type is `event`. When neither `text`
nor `title` supplies non-empty text, the body itself becomes the text: a
string as sent, or other JSON compact with keys in alphabetical order.
When one of those fields supplies the text, other object fields are not
kept. Text is collapsed to one line and truncated at 500 characters. Try
the hook before you set up the sender:
```sh
curl -sS -X POST "https://<public host>/hooks/zapier" -H "Content-Type: application/json" -H "X-Yardr-Secret: $(cat ~/.yardr/webhook.secret)" -d '{"type":"lead","action":"created","text":"New lead: Ada"}'
# {"seq":42}
```
Without the header, or with a wrong secret, the hook answers 401 and
records nothing. A subscription hears the events in an inbox:
```sh
yardr subscribe <hearer> webhook --scope all --where type=lead
```
Bodies are unique per source: if the exact same body arrives again, the
yard answers it as a duplicate and records no second event. A sender that
retries with a delivery id can add `X-Yardr-Delivery`, with the same id on
every try of one event; the yard drops a delivery whose id it has already
heard even when its body changed.
## Sending to a service
5. **The service's URL.** Each service shows the address to POST to in a
different place.
- **Zapier.** Start a Zap with the trigger *Webhooks by Zapier* and the
event *Catch Hook*. The *Test* tab shows the webhook URL, shaped like
`https://hooks.zapier.com/hooks/catch/<account>/<hook>/`.
- **Make.** Start a scenario with the *Webhooks* app's *Custom webhook*
module. Adding the module shows the address Make made for it.
- **n8n.** Start a workflow with a *Webhook* node. Set its *HTTP Method* to
*POST*, and leave its authentication as *None* and its path as n8n
generated it. The yard sends no header, so the path is the only check.
Use the *Production URL* once the workflow is published. The test URL
answers only while the editor is listening.
Copy the URL whole, with its trailing slash if it has one. The yard sends
to exactly the address you give it. It refuses an address that is not
`https://`, or that has a user, a query or a fragment in it. Plain
`http://` is allowed to this machine only, for a stand-in.
6. **Give the yard the URL.** Nothing is written by hand. The command asks
for the URL and shows nothing as you type or paste it, and the yard's
server writes it to a file only you can read,
`~/.yardr/sources/webhook.env`.
```sh
yardr source key webhook # WEBHOOK_URL, the service's URL: the operator's to give
```
A crew member can ask for it instead. You answer on its card in
footplate or in `yardr chat` (`ctrl+o`), where the field shows dots:
```sh
yardr bead ask <bead> "the webhook URL of the Zap" --secret webhook --context "Webhooks by Zapier, Catch Hook, the Test tab."
```
The URL goes nowhere else. It is not in mail, chat, a bead or a pack, and
no error, event or command of the yard repeats it. A yard has one URL,
because the pack declares one source and one variable. A second URL is a
second source. Copy the pack's directory from a yardr checkout, rename the
source and the variable in the copy's `webhook.yaml`, and add it with
`yardr source add <copy>/webhook.yaml`, which copies the commands in the
`webhook/` directory beside it.
7. **Grant the call and test it.** An update call is granted by its name,
and granting is the operator's:
```sh
yardr source grant webhook send --group builders
```
The source has no check call, because a hook has no request that changes
nothing. The first send is the test, so get the Zap, scenario or workflow
ready to receive it first. In a session of a granted group:
```sh
yardr call webhook send data='{"text":"hello from the yard"}'
```
`data` is one JSON object, 64 KB at most, sent exactly as written. A
`2xx` from the service means it took the call. If the answer is JSON, it
is printed, and Zapier's is `{"status":"success",…}`. If it is not, it is
dropped. Make answers the word `Accepted` until you add a *Webhook
response* module, and the call still succeeds. Each caller can make 10
updates an hour to the source, and `yardr source set webhook
updates_per_hour=<n>` changes that. The next call is refused until the
hour is up.
#!/bin/sh
# The body of send: its data parameter, a JSON object written as text, sent
# as that object. Stdin {"params":{"data":"…"}}, stdout the body, one JSON
# value, as a build command's contract has it. Needs jq.
#
# The text is checked and then printed as it came, not parsed and written
# again. A number the sender wrote, such as a 64-bit id, reaches the service
# with the digits it was given, whichever jq this is. A refusal says one
# fixed line of its own and never echoes what was posted, which is the
# caller's.
data=$(jq -r '.params.data') || exit 1
if printf '%s' "$data" | jq -es 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1; then
printf '%s' "$data"
else
echo 'data is no JSON object: send takes one, such as {"text":"hello"}' >&2
exit 1
fi
#!/bin/sh
# Any verified POST to the yard's envelope: stdin {"headers":{…},"body":…},
# stdout the envelope. Needs jq. internal/hooks feeds it bodies its tests
# write and compares the envelopes with the shapes below.
#
# The body is whatever the sender posted: a JSON object, other JSON, or
# text (the core hands over a body that is not JSON as a string). A JSON
# object can set type, action, text, url and actor, as the token adapter's
# can (internal/hooks/token.go). Here the type is event when the body names
# none, a title stands in for a missing text, and an actor can be a bare
# name, used as both its id and its name. What the body leaves out is the
# delivery's own words: the text is the body, a string as it is and any
# other JSON compact. A link is made only from an http or https address, so
# a sender's text never becomes another kind of link. The sender holds the
# hook's secret and is no stranger, but its words are still data.
# The script prints nothing to stderr, and every body gets an envelope,
# never a 400 that the sender would retry.
exec jq -c '
# the words a field holds: a string, else nothing
def str: if type == "string" then . else "" end;
# what names a person: a string, or a number written out (an id often is one)
def name: if type == "number" then tostring else str end;
# the text in one line: whitespace collapsed and trimmed. The jq POSIX space
# class includes the Unicode spaces split by Go strings.Fields.
def squash: gsub("^[[:space:]]+|[[:space:]]+$"; "") | gsub("[[:space:]]+"; " ");
# trunc is hooks.truncate: squash, and at most 500 runes
def trunc: squash | if length > 500 then .[0:499] + "…" else . end;
# an actor: the id and the name of an object, or a bare name for both
def party:
if type == "object" then {id: (.id | name | squash), name: (.name | name | squash)} | with_entries(select(.value != ""))
elif type == "string" and squash != "" then squash as $n | {id: $n, name: $n}
else {} end;
.body as $b
| (if ($b | type) == "object" then $b else {} end) as $o
| (($o.type | str | squash | ascii_downcase) | if . == "" then "event" else . end) as $type
| ($o.action | str | squash) as $action
# text, else title, else the body itself: a string as it is, anything else compact JSON
| ([$o.text, $o.title] | map(select(type == "string" and squash != "")) | first) as $named
| (if $named != null then $named else ($b | if type == "string" then . else tojson end) end | trunc) as $text
| (($o.url | str | squash) | if test("^https?://[^[:space:]]+$") then . else "" end) as $url
| {source: "webhook", type: $type, actor: ($o.actor | party), thread: {}, mentions: []}
| if $action != "" then . + {action: $action} else . end
| if $text != "" then . + {text: $text} else . end
| if $url != "" then . + {url: $url} else . end
'
# A generic webhook as a declared source. It covers what Zapier, Make, n8n
# and similar tools send and take. The yard hears any POST at a hook path,
# checked by a shared secret in a header. Its one call, send, POSTs a JSON
# object to a URL, and that URL is the credential (credential.url). These
# tools give a hook an address whose path nobody can guess, and Zapier's own
# page says to treat it as a password, so the yard keeps it as one. The file
# names no base and no path, and the URL reaches no error, event or command.
#
# Checked on 21 October 2026 against the services' own pages. Zapier's
# "Send webhooks in Zap workflows" and "Trigger Zap workflows from webhooks"
# (help.zapier.com) say that a Catch Hook URL is "secured by obscurity",
# that it takes a JSON POST and answers 200 with a JSON body saying
# success, and that the Webhooks by Zapier POST action sends the headers set
# in its Headers fields. Make's "Webhooks" page (help.make.com/webhooks)
# says a custom webhook answers 200 with the word "Accepted" until a
# Webhook response module says otherwise, and the HTTP app's page
# (apps.make.com/http) says its "Make a request" module sends to https://
# addresses only. n8n's HTTP Request and Webhook nodes (docs.n8n.io) set a
# header with Send Headers or a Header Auth credential, send a JSON body,
# have a production URL, and answer at once with "Workflow got started"
# unless told otherwise.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
name: webhook
about: Any service that POSTs to a URL, or takes one (Zapier, Make, n8n). Hears its POSTs, and POSTs a JSON object to its URL.
# The name of the variable, never the value: the URL every call goes to.
# A call holds the value to what a base is, so it has to be https, or
# plain http to this machine, with no user, query or fragment.
credential:
env: WEBHOOK_URL
url: true
# ---- what it sends us ----------------------------------------------------
hook:
# Adapter.Verify stays data, checked by the core: the secret is handed to
# no command, and an unverified body reaches none. These tools sign
# nothing the yard can check, but each can set a header of its own on a
# request, so the secret goes in one and is compared as it arrives. A
# sender may also send its own id for a delivery in X-Yardr-Delivery. It
# is optional, and an id the yard has heard before marks a retry.
verify:
scheme: secret
signature: { header: X-Yardr-Secret }
delivery: { header: X-Yardr-Delivery }
# Bodies are unique per source. A repeated X-Yardr-Delivery is also a
# retry when the sender supplies delivery ids.
bodies: unique
# Adapter.Normalize. stdin {"headers":{…},"body":…}, stdout the
# envelope. The core sets source and delivery itself.
normalize: [webhook/normalize]
# ---- what an agent may ask of it, through `yardr call` --------------------
# One update, granted by its name. The file has no read call and so no
# check: a hook has no request that changes nothing, so the first send is
# the test.
calls:
send:
kind: update
about: POST a JSON object to the webhook URL
params:
# The object, written as JSON text. The build command checks that it
# is one object and prints it as it came, so a number the sender wrote
# keeps its digits. Free text: its value is in no event.
data: { type: string, max: 65536 }
request:
method: POST
build: [webhook/build-send]