Skip to content

Creating Subscriptions

Subscriptions are created with the wh sub create CLI command, the warmhub_subscription_create MCP tool, or the SDK client.subscription.create(...) method. Each kind requires different configuration. Subscription management REST endpoints are not currently mounted.

Write-triggered webhook subscriptions support an optional allowTraceReentry setting.

  • Default: false
  • CLI flag: --allow-trace-reentry
  • Meaning when false: a subscription runs at most once per trace per shape
  • Meaning when true: the subscription may run again within the same trace
  • Hard fuse: a global chain-depth safety limit still applies even when reentry is allowed

This setting does not apply to cron subscriptions. If provided on cron create, it is ignored.

Current limitation: the MCP warmhub_subscription_create tool does not yet accept allowTraceReentry. You can still set it at create time through the CLI or SDK client.subscription.create(...) method, or patch it later through MCP with warmhub_subscription_update.

A webhook subscription can watch a source repo that is different from the repo where the subscription lives. When a matching write lands in the source repo, the subscription fires and delivers to the webhook URL configured in its home repo.

  • The source repo must be in the same org as the subscription’s home repo. Cross-org source repos are rejected at creation time.
  • Cron subscriptions cannot specify a source repo. Providing --source on a cron create is an error.
  • The subscription creator must have read access to the source repo. Creation fails if this check does not pass.
  • allowTraceReentry applies normally to cross-repo subscriptions.
Terminal window
# Webhook subscription in myrepo that fires on writes to other-repo
wh sub create cross-hook \
--on Signal \
--kind webhook \
--source myorg/other-repo \
--filter '{"shape":"Signal"}' \
--webhook-url https://example.com/hook

Cross-repo subscriptions are also supported through the SDK by setting sourceRepoRef on client.subscription.create(...).

await client.subscription.create({
orgName: 'myorg',
repoName: 'myrepo',
name: 'cross-hook',
kind: 'webhook',
shapeName: 'Signal',
filterJson: { shape: 'Signal' },
sourceRepoRef: 'myorg/other-repo',
webhookUrl: 'https://example.com/hook',
})

Cross-repo subscriptions are not currently supported through the warmhub_subscription_create MCP tool. Use the CLI or SDK instead.

There is no public HTTP subscription creation endpoint for cross-repo or same-repo subscriptions.

Cross-repo deliveries use the same top-level webhook payload shape as same-repo deliveries. Two details matter:

FieldDescription
repoThe subscription’s home repo — where the subscription lives
eventEvent discriminator: "warmhub.write", "warmhub.retract", or "warmhub.cron". Use this field to branch webhook handler logic by event type.
originRepoIdIdentifies the repo where the event originated (read-only). Cross-repo deliveries do not currently add a separate expanded sourceRepo object.

When the subscription is not cross-repo, the payload shape is still the same.

A webhook subscription sends an HTTP POST to your URL when matching operations are written.

Required: filter, webhook URL. Most webhooks also bind to a target shape via --on <shape> (or shapeName in the SDK/MCP) — that scopes the subscription to things and assertions of that shape. The one exception is shape lifecycle subscriptions: they omit --on and rely on a {"kind":"shape"} filter to subscribe to shape adds, revises, and retracts.

WarmHub validates webhook URLs at subscription create/update time and again at delivery time. URLs that don’t meet these requirements are rejected with the message “Webhook target is not reachable or not allowed”.

  • HTTPS required. Production WarmHub deployments do not deliver to http:// URLs.
  • Port allowlist: 80, 443, or 8443. Other ports (for example 25, 22, or 6379) are rejected.
  • Must resolve to a public IP address. WarmHub rejects loopback addresses like 127.0.0.1, private-network addresses (RFC 1918 ranges such as 10.0.0.0/8 and 192.168.0.0/16), link-local addresses, cloud-provider metadata IPs, and similar reserved ranges.
  • Encoded IP bypasses are rejected. WarmHub blocks non-canonical numeric host forms even when the scheme is otherwise allowed — examples include https://2130706433/ (decimal-encoded 127.0.0.1), hex or octal IP literals, IPv4-mapped IPv6 forms, and 6to4-mapped private IPs.
  • No credentials in the URL. https://user:pass@host/ is rejected. Use credential binding to attach auth headers instead.
  • Use a canonical hostname. WarmHub accepts normal DNS hostnames and canonical IP literals only.

The same rules apply to fallbackWebhookUrl — an optional secondary endpoint WarmHub calls after a terminal delivery failure on the primary webhook. Set it via the SDK fallbackWebhookUrl field (see client.subscription), the CLI --fallback-webhook-url flag, or the warmhub_subscription_create / warmhub_subscription_update MCP tools.

For local development, expose your receiver through a public HTTPS tunnel (for example ngrok or Cloudflare Tunnel) and point the subscription at the tunnel URL. WarmHub does not deliver to http:// URLs or to private/loopback IPs.

WarmHub re-validates every redirect hop against the same webhook URL rules above. A URL that passes create-time validation can still fail at delivery time if it redirects to:

  • a non-HTTPS target
  • a non-allowlisted port
  • a private, loopback, link-local, or otherwise reserved address
  • a non-canonical numeric host form

WarmHub also enforces a redirect-follow limit. If the target loops or exceeds that cap, the attempt fails with WEBHOOK_REDIRECT_LIMIT.

If a redirect crosses origins, WarmHub does not forward your auth, signature, idempotency, run-tracing, or W3C trace-propagation headers to the new origin. Only body-describing headers needed to preserve the request payload are retained. In practice, that means webhook endpoints behind a vanity redirector or cross-origin bounce URL should terminate on the final receiving origin directly rather than relying on WarmHub to carry credentials across origins.

If validation fails, the outcome falls into one of two buckets:

  • Your URL is rejected. The request comes back with the message Webhook target is not reachable or not allowed. Edit the URL to match the rules above before retrying — the same message is used for every reason (scheme, port, host form, credentials, reserved IP, …) on purpose, so the surface can’t be used to fingerprint internal infrastructure.
  • WarmHub couldn’t check it right now. The request comes back with the message Webhook URL validation temporarily unavailable, please retry. This means a transient infrastructure issue (typically a DNS resolver hiccup) interrupted the check before validation could finish. Submit the same request again once the resolver issue clears.
Terminal window
wh sub create signal-hook \
--on Signal \
--kind webhook \
--filter '{"shape":"Signal"}' \
--webhook-url https://example.com/hook
# Optional: allow same-trace reentry for a self-chaining webhook
wh sub create signal-loop \
--on Signal \
--kind webhook \
--filter '{"shape":"Signal"}' \
--webhook-url https://example.com/hook \
--allow-trace-reentry
{
"name": "warmhub_subscription_create",
"arguments": {
"orgName": "myorg",
"repoName": "myrepo",
"name": "signal-hook",
"kind": "webhook",
"shapeName": "Signal",
"filterJson": { "shape": "Signal" },
"webhookUrl": "https://example.com/hook"
}
}

The POST body includes:

FieldDescription
event"warmhub.write", "warmhub.retract", or "warmhub.cron"
traceIdUnique trace identifier for the event chain
runIdAction run identifier
subscriptionIdSubscription identifier
callback_urlCallback endpoint to report asynchronous progress or terminal outcome for this run
repo{ "orgName", "repoName" } — the subscription’s home repo
matchedOperationIndexesIndexes of operations that matched the filter
matchedOperationsThe matched operations with their details

Headers include X-WarmHub-Idempotency-Key, X-WarmHub-Run-Id, and X-WarmHub-Attempt for deduplication and observability. Deliveries also include World Wide Web Consortium (W3C) trace propagation headers such as traceparent and tracestate when they run inside an active backend trace.

Use callback_url when your handler accepts the request and finishes work asynchronously. Post processing, success, failure, or retry_requested back to that URL with normal repo:write authentication.

A cron subscription fires on a schedule and delivers to a webhook URL.

Required: cronspec, webhook URL. Cron subscriptions are not bound to a shape and do not use filters.

Cron webhookUrl uses the same webhook URL safety validator as webhook subscriptions. The target must satisfy the Webhook URL Requirements above: allowed scheme, allowed ports, and public-network reachability rules all apply here too.

Cron subscriptions do not support a source repo (--source). Providing a source repo on a cron create is rejected.

Terminal window
# Cron that calls a webhook every 15 minutes
wh sub create health-check \
--kind cron \
--cronspec "*/15 * * * *" \
--webhook-url https://example.com/health
# Cron with timezone
wh sub create daily-report \
--kind cron \
--cronspec "0 8 * * *" \
--timezone America/New_York \
--webhook-url https://example.com/daily-report

Cronspec uses standard 5-field Unix cron format:

┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12)
│ │ │ │ ┌───────────── day of week (0–7, 0 and 7 are Sunday)
│ │ │ │ │
* * * * *

Minimum interval: 5 minutes. Cronspecs that fire more frequently are rejected.

Timezone: Optional IANA timezone string (e.g., America/New_York). Defaults to UTC.

6-field format: Supported, but the seconds field must be literal 0 (no sub-minute scheduling).

Valid examples:

CronspecMeaning
*/15 * * * *Every 15 minutes
0 8 * * *Daily at 8:00 AM
0 9 * * 1Every Monday at 9:00 AM
0 */6 * * *Every 6 hours

Invalid examples:

CronspecReason
* * * * *Every minute — below 5-minute minimum
*/2 * * * *Every 2 minutes — below 5-minute minimum

Webhook subscriptions require a filter that determines which write operations trigger the action. Filters are evaluated against each operation; the subscription fires if at least one operation matches.

FieldTypeDescription
operationstring | string[]Operation type: "add", "revise", "retract"
kindstring | string[]Entity kind: "shape", "thing", "assertion", "collection"
shapestringShape name of the thing or assertion the operation targets. Filters remain stable if the shape is renamed later. Does not match shape-lifecycle operations — use {"kind":"shape"} plus name for those.
namestring | string[]Exact match on the operation’s name. For thing and assertion operations the name is the full Shape/name wref (e.g. Signal/sensor-1); for shape lifecycle operations it is the bare shape name (e.g. Reviewer). Most useful with kind: "shape" to target a single shape’s lifecycle (e.g. {"all":[{"kind":"shape"},{"name":"Reviewer"}]}). Array form is OR-combined and rejected at create time if it contains non-string entries or is empty. For things/assertions, prefer match for glob patterns.
matchstring | string[]Glob match on the operation’s name. For things and assertions that name is the full Shape/name wref; for shape lifecycle operations it is the bare shape name (e.g. Reviewer, not Shape/Reviewer). Uses the same glob syntax as wh thing list --match. Array form is OR-combined and must contain at least one pattern.
FieldTypeDescription
allFilterNode[]AND — all children must match
anyFilterNode[]OR — at least one child must match
notFilterNodeNOT — child must not match

Match all operations on a shape (simplest filter):

{ "shape": "Signal" }

Match only new things added to a shape:

{
"all": [
{ "operation": "add" },
{ "kind": "thing" }
]
}

Match assertions or things but not revisions:

{
"all": [
{ "any": [{ "kind": "assertion" }, { "kind": "thing" }] },
{ "not": { "operation": "revise" } }
]
}

Shape lifecycle subscriptions watch for shape adds, revises, and retracts rather than operations on things of a shape. They do not require an --on <shapeName> target; the filter drives matching.

Subscribe to all shape lifecycle events in a repo:

{ "kind": "shape" }
Terminal window
wh sub create shape-changes --repo myorg/myrepo \
--kind webhook \
--filter '{"kind":"shape"}' \
--webhook-url https://hooks.example.com/shapes

Subscribe to shape retracts only:

{
"all": [
{ "kind": "shape" },
{ "operation": "retract" }
]
}
Terminal window
wh sub create shape-retracts --repo myorg/myrepo \
--kind webhook \
--filter '{"all":[{"kind":"shape"},{"operation":"retract"}]}' \
--webhook-url https://hooks.example.com/shapes

Subscribe to events on a specific shape by name:

{
"all": [
{ "kind": "shape" },
{ "name": "Reviewer" }
]
}

Webhook event discriminator: Shape adds and revises emit warmhub.write; shape retracts emit warmhub.retract. Branch on matchedOperations[*].operation.kind to distinguish shape lifecycle deliveries from thing/assertion deliveries.

shape vs. name for lifecycle filters: The shape predicate matches things and assertions whose shape is Reviewer, so it never matches shape lifecycle operations (a shape add isn’t itself “of a shape”). To target a single shape’s lifecycle, combine {"kind":"shape"} with {"name":"Reviewer"} instead of {"shape":"Reviewer"}.

--on Signal does not subscribe to changes to the Signal shape itself. Binding a subscription to a shape with --on Signal (or shapeName: "Signal") scopes it to Signal’s things and assertions — adding, revising, or retracting the Signal shape definition will not fire it. To subscribe to shape lifecycle events, omit --on and use a {"kind":"shape"} filter.

The --on / shapeName requirement is waived only when the filter has a literal kind: "shape" (or kind: ["shape"]) constraint at the top level or inside an all chain. The check is structural, not semantic — any disjunctions and not branches are not inspected even when every branch happens to be shape-only.

Form--on required?Notes
{"kind":"shape"}noTop-level exact "shape".
{"kind":["shape"]}noTop-level single-element array of "shape".
{"all":[{"kind":"shape"}, …]}noall chains preserve the shape-only guarantee.
{"any":[{"kind":"shape"}, {"kind":"thing"}]}yesany is not inspected for the exemption.
{"any":[{"all":[{"kind":"shape"}, …]}, {"all":[{"kind":"shape"}, …]}]}yesEven when every branch is shape-only, top-level any disqualifies the filter. Express as one {"all":[{"kind":"shape"}, {"any":[…]}]} instead.
{"not":{"kind":"shape"}}yesNegation does not narrow to shape ops.
{"kind":["shape","thing"]}yesMixed-kind unions match non-shape ops.
Any filter without a kind:"shape" constraintyesStandard webhook subscription path.

The create surface rejects filters in the “yes” rows when --on (or filterJson.shape) is missing.

Match things under a naming branch (everything under Sensor/hq/):

{
"all": [
{ "kind": "thing" },
{ "match": "Sensor/hq/**" }
]
}

Match by deeper glob patterns — globstars, single-segment wildcards, and brace expansion all work the same as wh thing list --match:

{
"all": [
{ "kind": "thing" },
{ "match": "Sensor/**/temp" }
]
}

Match any of several patterns (array = OR; empty arrays are invalid):

{
"all": [
{ "kind": "thing" },
{ "match": ["Sensor/hq/**", "Sensor/warehouse/**"] }
]
}

To require two patterns both match, nest under all instead of using an array — see Naming as Navigation for worked examples.

Shape names in filters are rename-safe: if a shape is renamed later, existing filters continue to match that shape.

For cross-repo subscriptions, shape names are resolved against the source repo’s namespace at creation time.

Webhook subscriptions can use credential binding to inject authentication headers into delivery requests. This keeps secrets out of webhook URLs and subscription configuration.

  1. Create a credential set:
Terminal window
wh credential create webhook-keys
  1. Set authentication keys:
Terminal window
# Bearer token
echo "tok_secret" | wh credential set webhook-keys WEBHOOK_BEARER_TOKEN
# Or API key
echo "key_secret" | wh credential set webhook-keys WEBHOOK_API_KEY
  1. Bind the credential set to a subscription:
Terminal window
wh sub bind signal-hook --credentials webhook-keys
Key NameHeader Produced
WEBHOOK_BEARER_TOKENAuthorization: Bearer <value>
WEBHOOK_API_KEYX-API-Key: <value> (or custom header via WEBHOOK_API_KEY_HEADER)
WEBHOOK_BASIC_USERNAME + WEBHOOK_BASIC_PASSWORDAuthorization: Basic <base64>
WEBHOOK_SIGNING_SECRETX-WarmHub-Signature (HMAC-SHA256) + X-WarmHub-Timestamp
FALLBACK_BEARER_TOKENAuthorization: Bearer <value> on fallbackWebhookUrl deliveries
FALLBACK_API_KEYX-API-Key: <value> on fallbackWebhookUrl deliveries (or custom header via FALLBACK_API_KEY_HEADER)
FALLBACK_BASIC_USERNAME + FALLBACK_BASIC_PASSWORDAuthorization: Basic <base64> on fallbackWebhookUrl deliveries
FALLBACK_SIGNING_SECRETX-WarmHub-Signature (HMAC-SHA256) + X-WarmHub-Timestamp on fallbackWebhookUrl deliveries

Credential resolution is best-effort — if a credential set is missing or revoked, the webhook is still delivered without auth headers.

Terminal window
wh sub unbind signal-hook

Removing the credential binding stops auth headers from being injected on future deliveries.