Webhook Sinks

Webhook sinks POST an HTTP request to a configured URL when a matching event fires. The request body is rendered through a Go template giving you full control over the payload structure.


Configuration

Field Description
URL Target URL to POST to. Must accept application/json.
Secret HMAC-SHA256 signing key. Auto-generated on creation. Masked in list view; click the eye icon to reveal in the edit form.
Headers Custom HTTP headers appended to every request.
Body Template Go template rendered into the JSON request body. Leave empty to use the default template.

Request Format

Headers

Every webhook request includes these headers:

Header Description
Content-Type application/json
X-Knot-Event-Id UUIDv7 event identifier
X-Knot-Event-Type Event type string (e.g. space.started)
X-Knot-Event-Ts HLC timestamp (RFC 3339)
X-Knot-Signature sha256=<hex HMAC-SHA256 of body using secret>

Any custom headers from the sink configuration are appended.

Signature Verification

To verify the request is from knot, compute the HMAC-SHA256 of the raw body and compare:

import hmac, hashlib

def verify(body, signature, secret):
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# usage
# verify(request.body, request.headers["X-Knot-Signature"], "your-secret")

Success Criteria

HTTP 2xx within the 10-second timeout is success. Any other status code or network error triggers a retry (3 attempts, 5s / 15s backoff).


Template Variables

The body template uses Go template syntax with ${{ }} delimiters. The following variables are available:

.event

The firing event.

Variable Description
${{ .event.id }} UUIDv7 event identifier
${{ .event.type }} Event type string
${{ .event.ts }} HLC timestamp string
${{ .event.data }} Event payload as a map (use ${{ json .event.data }} to embed as JSON)

.space

The source space.

Variable Description
${{ .space.id }} Space UUID
${{ .space.name }} Space name
${{ json .space.urls }} All port URLs as a JSON object, keyed by port name

Each entry in .space.urls is built from the template’s port definitions: https://<username>--<spacename>--<port>.<domain>. For example, if the template defines ports web (80) and web2 (8080):

"urls": {
  "web": "https://alice--myspace--80.knot.example.com",
  "web2": "https://alice--myspace--8080.knot.example.com"
}

.actor

Who or what triggered the event.

Variable Description
${{ .actor.id }} Actor ID (user ID, or empty for system)
${{ .actor.username }} Actor username
${{ .actor.kind }} User, System, or MCP

.custom

Custom fields defined on the source space’s template and entered at space-creation time. Each field is accessible by its name:

Variable Description
${{ .custom.MY_FIELD }} Value of a custom field named MY_FIELD

For example, if your template defines custom fields GITHUB_REPO and DEPLOY_ENV, you can include them:

{
  "repo": "${{ .custom.GITHUB_REPO }}",
  "env":  "${{ .custom.DEPLOY_ENV }}"
}

Fields that are defined on the template but not set on the space render as empty strings. See Custom Variables for how to define custom fields on templates.


Template Functions

Function Description Example
json Marshal a value to JSON ${{ json .space.urls }}
quote Escape double quotes in a string ${{ quote .space.name }}
toUpper Convert to uppercase ${{ toUpper .actor.kind }}
toLower Convert to lowercase ${{ toLower .event.type }}
map Build a map from key-value pairs ${{ map "k" "v" "k2" "v2" }}

Default Template

When the body template is left empty, knot uses this default:

{
  "event_id":   "${{ .event.id }}",
  "event_type": "${{ .event.type }}",
  "event_ts":   "${{ .event.ts }}",
  "data": ${{ json .event.data }}
}

The .space and .actor scopes are omitted from the default — space info is included directly in the data block for space.* events (see below). Use a custom template if you need actor or port URLs in the webhook body.


System Event Payloads

The data block for system events. Space lifecycle events include space_name and space_id directly in data; space.created and space.started also include space_urls:

Event data fields
space.created space_name, space_id, space_urls, template_id, startup_script_id
space.started space_name, space_id, space_urls, node_id, started_at
space.stopped space_name, space_id, stopped_at
space.deleted space_name, space_id, deleted_at
space.healthy space_name, space_id, previous, current, checked_at
space.unhealthy space_name, space_id, previous, current, consecutive_failures, checked_at

For custom events, data is whatever was passed to knot event / POST /event / knot.event.emit().