Runs onWrit CloudSelf-hosted
On this page
Reference & guide
Automations & webhooks
An automation connects an event to an action: a detected change, an inbound webhook, a schedule, or a run event fires a trigger, its conditions are checked, and its actions run. Webhooks carry events in and results out — signed in both directions.
Triggers, conditions, actions
An automation is built from blocks: a trigger (the firing event), optional
conditions, and one or more actions. Triggers fire on these event
types — change_detected is the default:
| Event type | Fires when |
|---|---|
change_detected | A monitor check finds real change against its baseline. |
webhook_received | An external system calls your inbound hook or a custom_path door. |
ai_session_started / ai_session_completed | An AI session begins or settles. |
workflow_started / workflow_completed | A workflow run begins or settles. |
monitor_down / monitor_stale / monitor_recovered | A monitor stops answering, stops reporting, or comes back. |
crawl_started / crawl_completed / crawl_failed | A crawl begins, finishes, or fails. |
scheduled | A schedule block at the root of the automation fires on time. |
Actions are notification, ai_session, workflow,
crawl or create_persona — plus return_data in block form for
answering a synchronous caller. When several rules match, priority is the execution
order. Conditions use the same eleven operators, template context and filters documented in
monitors.
Writ runs on your own accounts, with your own credentials and data, on sites you are authorized to use.
Inbound webhooks (signed)
Every inbound hook has a signing secret, assigned when the hook is created — it cannot be cleared,
and unsigned calls are rejected. The signature is HMAC-SHA256, hex-encoded, over
"{timestamp}." + raw body:
POST /api/webhooks/hook/{token}
Content-Type: application/json
X-Writ-Timestamp: 1718980000
X-Writ-Signature: sha256=<hex>
{ "sku": "SKU-123" } X-Writ-Timestampis mandatory; missing, invalid or older than 300 seconds is answered401.- The same signature seen again within 300 seconds is rejected with
403— a captured call cannot be replayed. - A GitHub-style
X-Hub-Signature-256header is accepted as an alternative toX-Writ-Signature. - Each hook token is rate-limited to 30 calls per 60 seconds; beyond that the call is answered
429.
custom_path doors
A webhook trigger can also claim a custom_path — a readable path of up to 100
characters, unique within your workspace — served at a stable URL and authenticated with an API key
instead of a per-call signature:
POST /api/v1/webhooks/{custom_path}?wait=true&timeout=120
Authorization: Bearer wt_xxxxxxxxxxxx
Content-Type: application/json
{ "sku": "SKU-123" } Authorization: Bearerwith an API key is mandatory — calls without a valid key are answered401. The path resolves inside the calling key's workspace.- The door's
actionisrun_workflow(default) orcheck_target. - A
run_workflowdoor counts against your plan's published-endpoints quota. - Synchronous calls: set
wait_for_resulton the trigger (default false) withwait_timeout10–300 seconds (default 120) — or override per call with?wait=and?timeout=.
Outbound deliveries
The webhook notification channel posts results to your endpoint, signed so you can verify them. Deliveries behave predictably:
POSTorPUTonly, withUser-Agent: Writ-Webhook/1.0andX-Writ-Timestampon every request.- Verify
X-Writ-Signature-V1: it covers"{timestamp}." + raw body, the same material an inbound call signs, so one recipe serves both directions and a captured delivery expires with its timestamp. X-Writ-Signatureis sent alongside it and covers the JSON body only. It exists so handlers written before V1 keep working — do not reach for it in new code.- Redirects are never followed, and deliveries to private-network destinations are refused — a refused destination is not retried.
- Up to 3 attempts with a 30-second timeout each and exponential backoff capped at 30 seconds.
What next
- Monitors: the trigger pipeline, condition operators and template filters.
- Workflows: what a run_workflow action executes.
- Managed endpoints: the published-endpoints quota that custom_path doors share.
Two directions, two signatures
Webhooks flow both ways: an external system can start a Writ automation, and Writ can post back to your endpoint. Both directions are HMAC-signed — but they sign different material, so verify each the right way.
POST to your hook URL with a timestamp header and a signature over "{timestamp}." + the raw body. The timestamp must be fresh (within 300 seconds) and a repeated signature is rejected as a replay.
Writ delivers a JSON payload with a signature over the body only. The timestamp travels as a header beside the signature, not inside the MAC.
Sign an inbound trigger call
Compute HMAC-SHA256 with the hook’s secret over "{timestamp}." + body, hex-encode it, and send both headers. The signature is mandatory — unsigned calls are rejected, and the secret is assigned with the hook and cannot be turned off. A GitHub-style X-Hub-Signature-256 header is accepted as an alternative.
send.py
import hashlib, hmac, json, os, time
import requests
secret = os.environ["WEBHOOK_SECRET"] # shown when the inbound hook is created
body = json.dumps({"sku": "SKU-123"})
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
requests.post(
"https://api.usewrit.app/api/webhooks/hook/{token}",
data=body,
headers={
"Content-Type": "application/json",
"X-Writ-Timestamp": ts,
"X-Writ-Signature": f"sha256={sig}",
},
timeout=30,
) send.ts
import { createHmac } from "node:crypto";
const secret = process.env.WEBHOOK_SECRET!; // shown when the inbound hook is created
const body = JSON.stringify({ sku: "SKU-123" });
const ts = Math.floor(Date.now() / 1000).toString();
const sig = createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
await fetch("https://api.usewrit.app/api/webhooks/hook/{token}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Writ-Timestamp": ts,
"X-Writ-Signature": `sha256=${sig}`,
},
body,
}); send.sh
BODY='{"sku": "SKU-123"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST https://api.usewrit.app/api/webhooks/hook/$HOOK_TOKEN \
-H "Content-Type: application/json" \
-H "X-Writ-Timestamp: $TS" \
-H "X-Writ-Signature: sha256=$SIG" \
-d "$BODY" Verify an outbound delivery
Take X-Writ-Signature-V1, strip the sha256= prefix, recompute HMAC-SHA256 over "{timestamp}." + raw body with your endpoint’s secret, and compare in constant time. The older X-Writ-Signature covers the body alone and is still sent for handlers written before V1 — new code should verify V1.
verify.py
import hashlib, hmac, os
def verify(raw_body: bytes, signature: str) -> bool:
secret = os.environ["WRIT_WEBHOOK_SECRET"].encode()
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
# Constant-time compare - never use ==
return hmac.compare_digest(expected, signature) verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: Buffer, signature: string): boolean {
const expected = createHmac("sha256", process.env.WRIT_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
} verify.go
package writ
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
)
func Verify(rawBody []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(os.Getenv("WRIT_WEBHOOK_SECRET")))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
} verify.rs
use hmac::{Hmac, Mac};
use sha2::Sha256;
pub fn verify(raw_body: &[u8], signature: &str) -> bool {
let secret = std::env::var("WRIT_WEBHOOK_SECRET").unwrap_or_default();
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("key");
mac.update(raw_body);
let expected = hex::encode(mac.finalize().into_bytes());
// Constant-time compare
expected.len() == signature.len()
&& expected
.bytes()
.zip(signature.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
} Always verify before you act. Use the raw, unparsed body — parsing and re-serializing first will change the bytes and break the signature. Check X-Writ-Timestamp for freshness and skip payloads you have already processed.
What a delivery looks like
A change_detected delivery carries the event, a timestamp, the target, the selector that changed, and the content before and after with their hashes. Deliveries go out as POST or PUT, with User-Agent Writ-Webhook/1.0, and redirects are never followed.
POST /your/webhook/handler HTTP/1.1
Content-Type: application/json
User-Agent: Writ-Webhook/1.0
X-Writ-Timestamp: 1718980000
X-Writ-Signature-V1: sha256=6b3a9c…
X-Writ-Signature: sha256=9f86d0…
{
"event": "change_detected",
"timestamp": "2026-08-03T14:02:11Z",
"target": { "id": 42, "url": "https://example.com/pricing", "name": "Pricing page" },
"selector": { "css": ".price", "name": "price" },
"change": {
"content_before": "$129",
"content_after": "$119",
"content_hash": "…",
"previous_hash": "…"
}
} What fires an automation
An external system posts to your signed hook URL — or to a Bearer-authenticated custom_path door.
A monitor check finds real change against its baseline and the trigger pipeline dispatches the automation.
Workflow, AI session and crawl lifecycle events — started, completed, failed — and monitor health transitions.
See the full trigger and action model in automations and the watch-and-act pattern in monitors.