BUILDING Parts of this page describe what is not built yet, and each says so.
Webhooks
A webhook endpoint lets a system outside RunningLake start a run of one pipeline by sending an HTTP request. The sender signs the request, RunningLake checks the signature, turns the request’s JSON into the pipeline’s parameters, and starts the run as a service principal you chose. The sender can start that one pipeline in that one environment and nothing else: a request body can never choose a project, an environment or a pipeline.
A sender cannot call an endpoint yet. A control plane answers the address only when its operator has switched the receiver on, and rl webhook list says whether it has.
No plane can run a pipeline yet. Everything on this page that is about declaring a trigger, making an endpoint, signing, testing a mapping and reading the log works now.
What a webhook endpoint is
- A trigger is declared in the pipeline’s own file, so it is reviewed like the rest of the pipeline. It names the parameters a request fills and where each value comes from.
- An endpoint is an address for one trigger in one environment, belonging to a service principal. The principal decides what the call may start: it must be able to start the pipeline there, and its limits and its kill switch apply to every delivery.
- Making an endpoint and rotating its secret are console-only: they need an owner or admin who confirmed it is them in the last five minutes, which no API token has. Listing endpoints, reading the log, testing a mapping and disabling an endpoint do not need that, and a token may do them.
Declare a trigger
The pipeline below declares two parameters and one trigger. The trigger fills day from the request’s JSON and sets region to a constant.
apiVersion: runninglake.io/v1alpha1
kind: Pipeline
metadata:
name: payouts
spec:
owner:
team: finance
routing:
format: ICEBERG
parameters:
- name: day
type: date
min: 2020-01-01
max: 2040-01-01
required: true
description: The day whose payouts to load
- name: region
type: enum
values: [us, eu]
default: us
triggers:
webhooks:
- name: stripe-payout
parameters:
day: {from: /data/object/arrival_date}
region: {value: eu}
limits: {startsPerHour: 20, activeRuns: 2}
assets:
- name: gold.finance.payouts
sql: select getvariable('day') as day, getvariable('region') as region
materialization: append
- A trigger’s name starts with a lowercase letter and then holds lowercase letters, digits and hyphens, up to 63 characters. It is unique in the pipeline, and a pipeline has at most 16 triggers.
-
Each entry under
parametersnames a parameter the pipeline declares and has exactly one offrom, a JSON Pointer (RFC 6901) into the request’s body, andvalue, a constant written as text. At most 32 mappings. Pointers only: there are no filters, templates or expressions,~0and~1are the escapes for~and/, and/-is refused. - A required parameter with no default must be mapped, and a constant is checked against the parameter’s type and constraints where it is written.
-
A parameter read from the body must be constrained. A string needs a
pattern(an enum already is one), and an integer, a decimal, a date or a timestamp needs both aminand amax. A signature proves who sent a body, not that what it contains is safe to size work or name a file by, because vendors relay text their own users typed. A pipeline that reads an unconstrained value from the body is refused when it is proposed, and the refusal names the parameter and the rule, never the pointer or a value. -
limitscan only lower what an endpoint allows:startsPerHourfrom 1 to 30 andactiveRunsfrom 1 to 5.
What a request becomes is text, and the parameter’s declared type judges it after. A JSON string is used as it is, a number as its own spelling (it is never converted through a floating-point number), and
true and false as those words, so JSON true and the string "true" are the same text. JSON null, a missing key and a position past the end
of a list are absent: the parameter takes its default, or the delivery is refused as missing a required value. A pointer that lands on an object or a list is refused.
The page on parameters says what the types accept.
The address and the secret
An endpoint’s address ends in the organisation’s id and the endpoint’s id, and is not a secret. It always expires: 7, 30, 90, 180 or 365 days, 90 if you do not choose.
The signing secret is the secret. It is whsec_ followed by the standard base64 of 32 random bytes, which is the form Standard Webhooks libraries decode. It is shown once,
when the endpoint is made or its secret rotated, and never again: keep it where the sender can read it and out of shared history.
Sign and send
A delivery is a POST of JSON, signed as Standard Webhooks version 1 signs. Three headers, exactly one of each (a repeated header is refused whole):
webhook-id: an id for this delivery, 1 to 128 characters from letters, digits,_,:and-. A retry of the same delivery sends the same id.webhook-timestamp: the time you signed it, in seconds since 1970. RunningLake refuses one more than five minutes from its own clock, either way.-
webhook-signature:v1,and then the base64 of the HMAC-SHA256, under the secret’s 32 bytes, ofwebhook-id, a dot,webhook-timestamp, a dot and the raw body, exactly as it will be sent. During a rotation a sender may send several signatures, separated by spaces, and RunningLake accepts the delivery if any one of them is right (at most five are read).
The body is at most 256 KiB, sent as application/json, not compressed (a request with any Content-Encoding is refused, never decompressed), over https, and has to arrive within about ten seconds.
Each sample below reads WEBHOOK_URL, WEBHOOK_SECRET and a body.json beside it, signs the bytes of that file and sends them.
Shell, with openssl and curl
# WEBHOOK_URL is the endpoint's address. WEBHOOK_SECRET is the whsec_ text shown once when the endpoint was made. Keep it out of shared history.
# The id is new each run. A retry of the same delivery sends the same id.
id="msg_$(date +%s)"
ts="$(date +%s)"
key="$(printf '%s' "${WEBHOOK_SECRET#whsec_}" | base64 -d | od -An -vtx1 | tr -d ' \n')"
sig="$({ printf '%s.%s.' "$id" "$ts"; cat body.json; } | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$key" -binary | base64)"
curl -sS -X POST "$WEBHOOK_URL" \
-H "content-type: application/json" \
-H "webhook-id: $id" \
-H "webhook-timestamp: $ts" \
-H "webhook-signature: v1,$sig" \
--data-binary @body.json
Python
# WEBHOOK_URL is the endpoint's address. WEBHOOK_SECRET is the whsec_ text shown once when the endpoint was made. Keep it out of shared history.
# The id is new each run. A retry of the same delivery sends the same id.
import base64
import hashlib
import hmac
import os
import time
import urllib.error
import urllib.request
key = base64.b64decode(os.environ["WEBHOOK_SECRET"].removeprefix("whsec_"))
body = open("body.json", "rb").read()
ts = str(int(time.time()))
msg_id = "msg_" + ts
signed = msg_id.encode() + b"." + ts.encode() + b"." + body
sig = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
request = urllib.request.Request(
os.environ["WEBHOOK_URL"],
data=body,
method="POST",
headers={
"content-type": "application/json",
"webhook-id": msg_id,
"webhook-timestamp": ts,
"webhook-signature": "v1," + sig,
},
)
try:
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
except urllib.error.HTTPError as refused:
print(refused.code, refused.read().decode())
Node
// WEBHOOK_URL is the endpoint's address. WEBHOOK_SECRET is the whsec_ text shown once when the endpoint was made. Keep it out of shared history.
// The id is new each run. A retry of the same delivery sends the same id.
import { createHmac } from 'node:crypto'
import { readFileSync } from 'node:fs'
const key = Buffer.from(process.env.WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64')
const body = readFileSync('body.json')
const ts = String(Math.floor(Date.now() / 1000))
const id = `msg_${ts}`
const sig = createHmac('sha256', key).update(`${id}.${ts}.`).update(body).digest('base64')
const response = await fetch(process.env.WEBHOOK_URL, {
method: 'POST',
body,
headers: {
'content-type': 'application/json',
'webhook-id': id,
'webhook-timestamp': ts,
'webhook-signature': `v1,${sig}`,
},
})
console.log(response.status, await response.text())
Go
// WEBHOOK_URL is the endpoint's address. WEBHOOK_SECRET is the whsec_ text shown once when the endpoint was made. Keep it out of shared history.
// The id is new each run. A retry of the same delivery sends the same id.
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func main() {
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(os.Getenv("WEBHOOK_SECRET"), "whsec_"))
if err != nil {
log.Fatal("WEBHOOK_SECRET is not the text shown when the endpoint was made")
}
body, err := os.ReadFile("body.json")
if err != nil {
log.Fatal(err)
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
id := "msg_" + ts
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
request, err := http.NewRequest(http.MethodPost, os.Getenv("WEBHOOK_URL"), bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
request.Header.Set("content-type", "application/json")
request.Header.Set("webhook-id", id)
request.Header.Set("webhook-timestamp", ts)
request.Header.Set("webhook-signature", "v1,"+base64.StdEncoding.EncodeToString(mac.Sum(nil)))
response, err := http.DefaultClient.Do(request)
if err != nil {
log.Fatal(err)
}
answer, err := io.ReadAll(response.Body)
_ = response.Body.Close()
if err != nil {
log.Fatal(err)
}
fmt.Println(response.StatusCode, string(answer))
}
What you are told
Once the signature is right, the answer says what became of the delivery. The body of each is a small JSON object.
| Answer | What it means | Should the sender retry? |
|---|---|---|
202 with run_id | A run was started. | No. |
200 with run_id and replayed | This delivery id already started a run with the same parameters. Nothing new was created. | No. |
422 with parameter_invalid | The body could not be mapped: a value the parameter does not accept, or a required one that is missing. The answer names the parameter and the rule, never the value. | Not until the body or the mapping changes. |
422 with body_invalid | The body is not one JSON value the receiver reads (a second value, a repeated key, invalid text, nesting past 64 levels, a number of more than 64 characters). The answer gives a closed reason. | No. |
422 with delivery_id_invalid | The delivery id is not one of the allowed characters or is longer than 128. | No. |
429 with Retry-After | The endpoint’s or the principal’s start ceiling was reached. | Yes, after the time given. |
503 with not_ready and Retry-After | The pipeline cannot take a run just now, for a reason that passes by itself. | Yes, after the time given. |
409 with not_runnable | The pipeline cannot take a run until someone changes it, its environment’s binding or its plane. No wait will cure it. | No. |
503 with in_flight | The same delivery is being started right now. | Yes, after a second. |
409 with delivery_id_reused | This delivery id already started a run with different parameters. A second payload is never swallowed behind a 200. | No: use a new id. |
404, with no body | Anything else. See the next section. | Check the address, the secret and the endpoint first. |
The line a sender’s retry policy can key on is the one between 503 not_ready and 409 not_runnable: a 5xx with Retry-After reads as ask again later, a 4xx as asking again will not help.
What a given sender does with either is its own; RunningLake does not measure it.
Why a refusal is always the same 404
Every refusal before the signature is proved is one 404 with no body and Cache-Control: no-store: an endpoint that does not exist, a bad signature, a timestamp too far off, a disabled or expired endpoint,
a malformed header, an encoded body, a body over the limit, a secret that cannot be read. A delivery that proved its signature but whose principal may no longer start the pipeline is answered the same way, and recorded so its owner can see why.
The address of an endpoint is in your sender’s configuration and the organisation’s id is not a secret, so a stranger who guesses addresses must learn nothing from the answers, not even which endpoints exist or whether a guess was close.
The cost is that the sender cannot tell a wrong address from a wrong signature. Work from RunningLake’s side instead: the endpoint’s state and whether the receiver is on are in the console and in rl webhook list; a
test delivery signs a body you paste with the endpoint’s stored secret, verifies it as the receiver would and shows what it would map to, with no sender involved; and your sender’s own log has the failures, because RunningLake does not keep them.
Retries and idempotency
- A sender that retries a delivery sends the same
webhook-id, signed again with a new timestamp. -
A delivery id that has started a run is remembered with that run for good. Sent again, it answers
200with the first run and starts nothing, however long after. - A delivery that was refused, for a ceiling, a pipeline that was not ready or a value that did not map, is recorded and never blocks a retry: retried after the cause is gone, it starts a run.
- The same id with different parameters is
409delivery_id_reused, not retryable, so a second payload is never swallowed. - Anyone who captured a refused delivery’s bytes could replay them inside the five-minute window, because a refusal records nothing to dedupe against. It buys the replayer nothing the capture did not already allow, and sending only over https keeps a capture rare.
Rotating a secret
Rotating mints a new secret, shown once, and keeps the old one verifying for an overlap you choose, from one hour to seven days and 24 hours if you do not choose, so a sender can switch without a gap. At most two secrets verify at a time. If an older secret may have leaked, stop it at once instead of waiting for the overlap to end; that needs no confirmation. The current secret cannot be stopped that way: rotate, or disable the endpoint.
What you can see
- The log of an endpoint holds deliveries whose signature verified, for 30 days: one entry for each sender id and outcome, so a sender’s retries raise the count and do not add entries. Each shows when it arrived, the outcome (started a run, replayed, refused because the payload did not map, because a ceiling was reached, because the pipeline could not take a run, or because the principal may no longer start the pipeline, or a test delivery), the run, the first parameter that failed, the names of the parameters the body supplied and how long the answer took. Never the body, never a header, never a value. Failed verifications are not recorded; they are counted without the endpoint or the organisation, because a record per refused request would let anyone fill a log.
- In the console, a pipeline’s Callers tab shows each trigger’s mapping, the endpoints bound to it, the receiver’s state and, for an endpoint, a test delivery. Its mapping editor previews a draft of a mapping over a test body before you propose it.
-
From the command line:
rl webhook listshows a pipeline’s endpoints and whether the receiver is switched off;rl webhook deliveriesshows the log of a trigger;rl webhook previewruns the applied mapping over a body from a file and exits 1 if a parameter would be refused;rl webhook disablestops an endpoint at once. Making and rotating exit 2: they are made in the console. See the rl command line.
What is not supported
Standard Webhooks version 1a, which signs with ed25519, is not supported. RunningLake would hold only a public key to check it with, and the signing secret above is the one scheme every sender can use.
Signatures that providers invent for themselves, such as GitHub’s X-Hub-Signature-256, are not accepted either, because they carry no timestamp and so cannot refuse a replay.
For events in GitHub, run a workflow in GitHub Actions and let it act as a service principal: CI with GitHub Actions needs no secret stored anywhere.