Simulators
Webhooks
A webhook subscribes to the data change events of one team, or of all public teams, for the resource types it names, as it does on Linear. webhookCreate, webhookUpdate, webhookDelete, webhook and webhooks manage webhooks. A new webhook is enabled, with a minted lin_wh_ signing secret unless you give one:
curl -s "$RYSTIC_URL/graphql" -H 'Authorization: lin_placeholder' -H 'Content-Type: application/json' \
-d '{"query":"mutation($t: String!) { webhookCreate(input: {url: \"https://example.com/linear\", teamId: $t, resourceTypes: [\"Issue\", \"Comment\"]}) { success webhook { enabled resourceTypes secret } } }","variables":{"t":"5b0a4d00-7ea0-4a00-8b00-000000000001"}}' | jq -c .data.webhookCreate
# -> {"success":true,"webhook":{"enabled":true,"resourceTypes":["Issue","Comment"],"secret":"lin_wh_62ccb857-b363-44ce-8000-000000000001"}}
What a webhook refuses
Creating or updating a webhook with a URL that is not public HTTPS (http, localhost, a loopback or private address) returns Linear’s INVALID_INPUT “A public HTTPS URL is required.”:
curl -s "$RYSTIC_URL/graphql" -H 'Authorization: lin_placeholder' -H 'Content-Type: application/json' \
-d '{"query":"mutation { webhookCreate(input: {url: \"http://localhost:4242\", allPublicTeams: true, resourceTypes: [\"Issue\"]}) { success } }"}' | jq -c '.errors[0] | {code: .extensions.code, shown: .extensions.userPresentableMessage}'
# -> {"code":"INVALID_INPUT","shown":"A public HTTPS URL is required."}
The other refusals return these errors:
- A URL another webhook already holds: “url not unique”.
- Creating a webhook with neither
teamIdnorallPublicTeams: true: “Please select a team or “all public teams”.”. - An unknown resource type:
INVALID_INPUTwith the accepted list. - An unknown or deleted webhook id: “Entity not found: Webhook”.
Events
Every issue, comment, label and project write records Linear’s data change event on the egress log, whether or not delivery is on. Each event carries these fields:
action:create,updateorremove.type,actor,dataandurl.createdAtandorganizationId.updatedFrom, on an update only: the previous value of each field the write changed.
curl -s "$RYSTIC_URL/graphql" -H 'Authorization: lin_placeholder' -H 'Content-Type: application/json' \
-d '{"query":"mutation { issueUpdate(id: \"ENG-101\", input: {title: \"Renamed\", priority: 3}) { success } }"}' > /dev/null
curl -s "$RYSTIC_URL/_rystic/egress" | jq -c '.events[-1].payload | {action, type, identifier: .data.identifier, updatedFrom}'
# -> {"action":"update","type":"Issue","identifier":"ENG-101","updatedFrom":{"priority":1,"title":"Flaky retry loop at checkout","updatedAt":"2026-01-31T16:40:00.000Z"}}
Delivery
With RYSTIC_WEBHOOK_DELIVERY=on, the simulator POSTs each event to every enabled webhook that subscribes to its type and team.
Differs from Linear: no event reaches your endpoints until you set RYSTIC_WEBHOOK_DELIVERY=on; until then each one lands only on the egress log.
Each delivery carries these headers:
Linear-Delivery: a v4 UUID.Linear-Event: the entity type.Linear-Signature: the hex HMAC-SHA256 of the raw body, keyed by the webhook’s secret.Linear-Timestamp: the send time in Unix milliseconds.
The body adds webhookId and webhookTimestamp. Verify the signature over the raw bytes, as Linear’s docs show:
import hashlib, hmac
ok = hmac.compare_digest(headers["Linear-Signature"], hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest())
A delivery that gets no 2xx within 5 seconds is retried after 1 minute, 1 hour and 6 hours on the simulator’s clock: advance it with POST /_rystic/clock to fire them. A webhook whose event fails every retry is disabled, and webhook(id:) reads enabled: false until webhookUpdate sets enabled: true again. GET /_rystic/egress lists each attempt under delivery.
Linear refuses a local URL, so register a local receiver by seeding the webhook instead:
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -H 'Content-Type: application/json' \
-d '{"state_webhook": {"hook-local": {"id": "hook-local", "url": "http://127.0.0.1:4242/linear", "secret": "lin_wh_local", "enabled": true, "resourceTypes": ["Issue"], "teamId": null, "allPublicTeams": true}}}' -o /dev/null -w '%{http_code}\n'
# -> 204