# Webhooks

Webhook subscriptions through webhookCreate and its siblings; Linear's data change events on the egress log; signed delivery with retries and disabling.

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:

```bash
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.":

```bash
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 `teamId` nor `allPublicTeams: true`: "Please select a team or \"all public teams\".".
- An unknown resource type: `INVALID_INPUT` with 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`, `update` or `remove`.
- `type`, `actor`, `data` and `url`.
- `createdAt` and `organizationId`.
- `updatedFrom`, on an update only: the previous value of each field the write changed.

```bash
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:

```python
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:

```bash
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
```

---

Source: https://www.rystic.ai/docs/simulators/linear/features/webhooks · Markdown: https://www.rystic.ai/docs/simulators/linear/features/webhooks.md
