---
title: "Spec reference"
description: "Every field of a routine file with its type, default, limits and exact validation message."
section: "routines"
url: "https://forkline.dev/docs/routines/spec"
markdown: "https://forkline.dev/docs/routines/spec.md"
source: "src/content/docs/routines/spec.mdx"
---

# Spec reference

> [!TIP]
> **Agent hint**
> For agents writing routines. Follow these rules and the routine validates the first time:
>
> - Write **one routine per file**.
> - **Unknown fields are rejected.** A typo is an error, not an ignored setting.
> - **Filters must apply to every listed event.** When the events need different filters, write two routines.
> - Use **only the template variables that every chosen event provides**. See [Prompt templates and variables](https://forkline.dev/docs/routines/templates).
> - Run `forkline routine validate <file>`, then `forkline routine backtest <file>`, before `forkline routine push`.
> - Schedule and webhook routines accept no filters except `when`. On a schedule routine `when` sees only `repository`; on a webhook routine it also sees `payload`.
> - `limits.resetOn` and the events and variables in [Not supported yet](#not-supported-yet) are rejected.
> - `filters.bots` and `filters.self` judge the **actor** (`sender`, who pushed, commented or opened), not the author of the pull request. To skip pull requests **written by** a bot, use `when: '!pr.authorIsBot'` (pull request events only; on GitLab only when the merge request is first opened, see [Authors and labels](https://forkline.dev/docs/routines/filters#authors-and-labels)). The defaults are `bots: exclude` and `self: exclude`: other bots **and** Forkline's own bot are skipped. To skip only Forkline's own activity and keep other bots, set `bots: include` and leave `self` at its default. See [self and bots](https://forkline.dev/docs/routines/filters#self-and-bots-the-actor-not-the-author).
> - `limits.maxRunsPerPr` defaults to **3** and counts every run for a pull request, replaced ones too, and never resets. A routine on `pr.updated` that must run on every commit needs a higher value (at most 50), or it stops after its third run with outcome `run_limit`.
> - `ci.failed` carries a pull request only when the failed run belongs to one. If the prompt or filters need it, add `when: 'pr.number != null'`; without a pull request `pr.*` is empty and `maxRunsPerPr` does not apply.
> - There is no changed-files count, diff or file list for pull requests. See [Not available variables](https://forkline.dev/docs/routines/templates#variables-that-do-not-exist).

A routine is a file that says **what starts it** (`on`), **which repositories** it covers (`repositories`), **which events to ignore** (`filters`) and **what the agent does** (`agent`). This page lists every field. The other pages explain the parts in depth: [Events](https://forkline.dev/docs/routines/events), [Filters](https://forkline.dev/docs/routines/filters), [Prompt templates and variables](https://forkline.dev/docs/routines/templates), [Repository selection](https://forkline.dev/docs/routines/repositories), [Concurrency and limits](https://forkline.dev/docs/routines/concurrency-and-limits) and [Validation errors](https://forkline.dev/docs/routines/errors).

## The routine file

A routine file is a YAML or JSON mapping. A file that ends in `.json` is read as JSON, any other file as YAML. It starts with `name` and `description`, followed by the spec keys.

| Key | Type | Required | Notes |
|-----|------|----------|-------|
| `name` | string | yes | 1 to 100 characters: letters, digits, `-` and `_`. `forkline routine push` matches files to routines by name. A bad name is refused with `400` (see [Validation errors](https://forkline.dev/docs/routines/errors#errors-that-need-more-than-the-file)). |
| `description` | string | no | Free text for people. |
| `on` | object | yes | What starts the routine: [`on`](#on) |
| `repositories` | object | yes | [`repositories`](#repositories) |
| `filters` | object | no | [`filters`](#filters) |
| `agent` | object | yes | [`agent`](#agent) |
| `concurrency` | object | no | [`concurrency`](#concurrency). Optional; the defaults apply when it is missing. |
| `limits` | object | no | [`limits`](#limits) |

Every object rejects unknown fields. All keys are camelCase. Saving an invalid routine returns `422` with one message and pointer per problem, see [Validation errors](https://forkline.dev/docs/routines/errors). In the web editor, in the API and in the pointers below, `name` and `description` are separate fields next to the spec; the pointers start at the spec.

## A complete example

```yaml routine
name: review-prs                   # letters, digits, - and _
description: Review pull requests that target main
on:
  events: [pr.opened, pr.updated]  # 1 to 8 events
repositories:
  include:
    - name: "acme/*"               # owner/name, * and ** are wildcards
  exclude:
    - name: "acme/sandbox"
filters:                           # optional; every filter must fit every event
  branches:
    include: [main, "release/*"]   # the pull request base branch
  labels:
    exclude: [no-review]
  draft: exclude                   # exclude (default), include or only
  fork: exclude
  bots: exclude                    # the ACTOR is another bot (default), not the PR author
  self: exclude                    # the ACTOR is Forkline's own bot (default)
  when: pr.title.startsWith("WIP") == false   # a typed CEL condition
agent:
  prompt: |
    Review pull request #{{ pr.number }} "{{ pr.title }}" in {{ repository.fullName }}.
    Focus on correctness and tests. Post one summary comment.
  model: anthropic/claude-sonnet-4-5
  runnerSize: advanced             # basic, advanced or pro
  ref: "{{ pr.headBranch }}"       # what the agent checks out
  timeoutMinutes: 45               # 1 to 240
limits:
  maxRunsPerPr: 3                  # 1 to 50
```

## `on`

Set **exactly one** of `events`, `schedule` and `webhook`.

| Pointer | Type | Required | Default | Constraint | Message when violated |
|---------|------|----------|---------|------------|-----------------------|
| `/on` | object | yes | | Exactly one of `events`, `schedule`, `webhook` | `` set exactly one of `events`, `schedule` and `webhook` `` |
| `/on/events` | string[] | one of three | | 1 to 8 event names | `` list between 1 and 8 events `` |
| `/on/events/{i}` | string | | | One of the 8 [event names](https://forkline.dev/docs/routines/events), each at most once | `` Unknown event `{name}`. Did you mean `{near}`? Events: `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed`, `issue.opened`, `comment.created`, `ci.failed`, `push`. `` (the suggestion only when one name is close), `` `{name}` is listed twice ``, `` `{name}` is not supported yet `` |
| `/on/schedule` | object | one of three | | Exactly one of `cron` and `at` | `` set exactly one of `cron` and `at` `` |
| `/on/schedule/cron` | string | one of `cron`, `at` | | Standard cron: five fields (minute hour day month weekday), or six or seven with leading seconds. The weekday is `0` or `7` for Sunday, `1` for Monday up to `6` for Saturday, or `MON` to `SUN` in any case, with ranges, lists and steps. When both the day of the month and the day of the week are restricted (neither starts with `*` nor is `?`), the routine runs when either matches. When either day field starts with `*` or is `?`, both must match | `` `{expression}` is not a cron expression: {detail} `` |
| `/on/schedule/timezone` | string | no | `UTC` | An IANA name such as `Europe/Berlin`. Only with `cron` | `` `{timezone}` is not an IANA timezone, use a name like `Europe/Berlin` ``, `` `timezone` only applies to `cron`; `at` carries its own offset `` |
| `/on/schedule/at` | string | one of `cron`, `at` | | An RFC 3339 timestamp such as `2026-12-01T09:00:00Z`. Runs once, then the routine is disabled. A time in the past is not rejected | `` `{at}` is not an RFC 3339 timestamp: {detail} `` |
| `/on/webhook` | object | one of three | | `{}` is allowed and means a bearer token in `Authorization`. Other fields: `auth`, `idempotencyHeader`, `example` | `` Unknown field `{key}`. Allowed here: `auth`, `idempotencyHeader`, `example`. `` (plus "Did you mean" for a near miss) at `/on/webhook/{key}` |
| `/on/webhook/auth` | object | no | `{}` (token in `Authorization`) | `type` is `token` (default) or `hmac` | `` `{value}` is not allowed here. Use `token` or `hmac`. `` at `/on/webhook/auth/type` |
| `/on/webhook/auth/header` | string | no | `Authorization` | Only with `type: token`. 1 to 64 bytes: letters, digits and ``!#$%&'*+-.^_`|~``. Not a header the platform sets: `Host`, `Cookie`, `Connection`, `Transfer-Encoding`, `X-Real-IP`, `Forwarded`, `Traceparent`, `Content-*`, `X-Forwarded-*` and `X-Auth-Request-*`. Names are matched without regard to case | `` `{name}` is not a valid header name: use letters, digits and ``!#$%&'*+-.^_`|~`` and at most 64 bytes. ``, `` `{name}` is set by the platform and cannot be used here. ``, `` `header` is only for `type: token`; an HMAC preset fixes its header. `` |
| `/on/webhook/auth/preset` | string | with `type: hmac` | | `forkline`, `github` or `sentry`. Only with `type: hmac` | `` `preset` is required with `type: hmac`. Presets: ... ``, `` Unknown preset `{name}`. Did you mean `{near}`? Presets: ... ``, `` `preset` is only for `type: hmac`. `` |
| `/on/webhook/idempotencyHeader` | string | no | `Idempotency-Key` | Same header-name rules. Not the header that carries the credential. Names are matched without regard to case | `` `{name}` already carries the credential; use another header for the idempotency key. `` |
| `/on/webhook/example` | any JSON | no | | A sample body for the Run drawer. At most 16 KiB, measured as compact JSON. Never read when a call arrives | `` The example is {size} bytes, over the 16 KiB limit. `` |

### `events`

```yaml
on:
  events: [pr.opened, pr.updated]
```

The events are `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed`, `issue.opened`, `comment.created`, `ci.failed` and `push`. The list of what each one means on GitHub, GitLab and Forgejo, and the fields each one carries, is on the [Events](https://forkline.dev/docs/routines/events) page.

### `schedule`

```yaml
on:
  schedule:
    cron: "0 6 * * 1"          # five fields, every Monday at 06:00
    timezone: Europe/Berlin    # IANA name, default UTC
```

```yaml
on:
  schedule:
    at: "2026-12-01T09:00:00Z" # once, RFC 3339; the routine is then disabled
```

The weekday follows standard cron: `0` or `7` is Sunday, `1` is Monday and `6` is Saturday. Names work too (`MON-FRI`, `SUN,SAT`, case does not matter), and so do ranges, lists and steps (`1-5`, `*/2`). When both the day of the month and the day of the week are restricted (neither starts with `*` nor is `?`), the routine runs when **either** matches: `0 8 1 * 1` runs on the 1st of each month and on every Monday, and only once when the 1st is a Monday. When either day field starts with `*` (`*`, `*/2`) or is `?`, both must match: `0 9 */10 * 1` runs only on Mondays that are the 1st, 11th, 21st or 31st, and `0 9 1 * */2` only on a 1st that is a Sunday, Tuesday, Thursday or Saturday.

`timezone` only applies to `cron`. A cron routine fires when a scheduled time has passed since it last fired. If Forkline was unavailable, the routine fires **once** as soon as it is back, for the most recent time it missed; earlier missed times are not replayed. A routine never fires twice for the same scheduled time, and it is not owed times from before it was last edited or published. If a scheduled time cannot be started because of a temporary error, it is retried at the next check, about a minute later. If the routine can no longer be read as a valid routine, the scheduled time is recorded as an `error` run at stage `fan_out` and is not retried. A scheduled time is only retried while it has not been started: if a server restart lands exactly between claiming the scheduled time and starting its run, that time is skipped rather than retried, and the routine fires at its next scheduled time. A schedule runs once for each selected repository, see [Repository selection](https://forkline.dev/docs/routines/repositories#fan-out).

### `webhook`

```yaml
on:
  webhook:
    auth: { type: hmac, preset: sentry }
```

A custom webhook routine runs when your system calls its URL. `webhook: {}` is valid and means a bearer token in `Authorization`. See [Custom webhooks](#custom-webhooks) for the authentication options.

## `repositories`

Which of your connected repositories the routine covers. The result is everything in `include` that is not in `exclude`. See [Repository selection](https://forkline.dev/docs/routines/repositories).

| Pointer | Type | Required | Default | Constraint | Message when violated |
|---------|------|----------|---------|------------|-----------------------|
| `/repositories` | object | yes | | | `` `repositories` is required. `` |
| `/repositories/include` | `all` or selector[] | yes | | The word `all`, or 1 to 50 selectors | `` `{value}` is not valid here, use "all" or a list of selectors ``, `` use "all" or list at least one selector ``, `` at most 50 selectors, this has {n} `` |
| `/repositories/exclude` | selector[] | no | none | 1 to 50 selectors | `` at most 50 selectors, this has {n} `` |
| `/repositories/{include or exclude}/{i}` | selector | | | Exactly one of `id` and `name` | `` set exactly one of `id` and `name` `` |
| `.../{i}/id` | string | one of `id`, `name` | | The UUID of a repository of yours | `` UUID parsing failed: {detail} `` |
| `.../{i}/name` | string | one of `id`, `name` | | `owner/name`, at most 256 bytes. `*` and `?` match inside one segment, a whole segment `**` matches several. Case-insensitive | `` use the form `owner/name`, with `*` and `**` as wildcards ``, `` the pattern is longer than 256 bytes `` |

```yaml
repositories:
  include: all                   # or a list
```

```yaml
repositories:
  include:
    - name: "acme/api"
    - name: "acme/web-*"
    - id: 3f2b8c1e-1c2d-4e5f-8a9b-0c1d2e3f4a5b
  exclude:
    - name: "acme/web-legacy"
```

For schedule and webhook routines the selection must match between 1 and 200 of your repositories when you save. See [Errors that need more than the file](https://forkline.dev/docs/routines/errors#errors-that-need-more-than-the-file).

## `filters`

Optional. All filters must pass for a run to start. Filters only apply to routines started by events; a schedule or webhook routine can use only `when`. There `when` sees `repository`, and on a webhook routine also `payload`. Each filter has its own page section in [Filters](https://forkline.dev/docs/routines/filters).

| Pointer | Type | Default | Applies to |
|---------|------|---------|------------|
| `/filters/branches` | `{include?, exclude?}` of globs | none | PR events (base branch), `push` (pushed branch), `ci.failed` (branch) |
| `/filters/paths` | `{include?, exclude?}` of globs | none | `push` |
| `/filters/labels` | `{include?, exclude?}` of strings | none | PR events, `issue.opened`, `comment.created` |
| `/filters/authors` | `{include?, exclude?}` of strings | none | PR events, `issue.opened`, `comment.created`, `push` |
| `/filters/draft` | `exclude`, `include` or `only` | `exclude` | PR events |
| `/filters/fork` | `exclude`, `include` or `only` | `exclude` | PR events |
| `/filters/bots` | `exclude`, `include` or `only` | `exclude` | every event except `ci.failed` |
| `/filters/self` | `exclude`, `include` or `only` | `exclude` | every event except `ci.failed` |
| `/filters/command` | `{names?, mention?}` | none | `comment.created` |
| `/filters/when` | CEL string | none | every event; on a schedule routine only `repository` is visible, on a webhook routine `repository` and `payload` |

Rules shared by all filters:

| Rule | Message |
|------|---------|
| Every filter must apply to every listed event (pointer `/filters/{name}`) | `` `{name}` does not apply to {events}; every filter must apply to every listed event, so split the routine `` |
| A schedule or webhook routine cannot use event filters other than `when` | `` `{name}` only applies to routines started by events `` |
| `include` and `exclude` lists: at least one of the two, not empty, at most 50 entries, each entry 1 to 256 bytes | `` set `include`, `exclude` or both ``, `` must not be empty, remove it instead ``, `` at most 50 entries, this has {n} ``, `` must not be empty ``, `` is longer than 256 bytes `` |
| `command`: `names` (1 to 20, each starts with a letter, has letters, digits, `-` and `_`, is at most 64 characters, and is written without the `/`) and `mention: true`, at least one of them | `` set `names` or `mention: true` ``, `` list between 1 and 20 command names ``, `` a command name starts with a letter and has only letters, digits, `-` and `_`, written without the leading `/` ``, `` `{name}` is listed twice `` |

For the command matching semantics (line-start requirement, mention interaction), see [Command](https://forkline.dev/docs/routines/filters#command).
| `draft`, `fork`, `bots`, `self` must be one of the three words | `` `{value}` is not allowed here. Use one of: `exclude`, `include`, `only`. `` |

### `when`

`when` is one boolean [CEL](https://cel.dev) expression over the typed fields of the event, for example `pr.labels.size() > 0 && !pr.draft`. It is checked when you save: a field that none of the listed events carries, an unsupported function, a result that is not a boolean or an expression that is too expensive is an error at `/filters/when`.

| Limit | Value | Message |
|-------|-------|---------|
| Empty expression | not allowed | `` must not be empty `` |
| Size | 4096 bytes | `` filter is {n} bytes, the limit is 4096 `` |
| Nesting | 16 levels | `` filter nests expressions more than 16 levels deep `` |
| Expression count | 512 | `` filter is too large, it may use at most 512 expressions `` |
| Estimated worst-case cost | 50,000 units | `` the filter's estimated worst-case cost {n} exceeds the budget of 50000 by a factor of {f}; ... `` |

The full list of fields and functions is in [Filters](https://forkline.dev/docs/routines/filters#when-conditions).

## `agent`

What the agent does when the routine fires.

| Pointer | Type | Required | Default | Constraint | Message when violated |
|---------|------|----------|---------|------------|-----------------------|
| `/agent` | object | yes | | | `` `agent` is required. `` |
| `/agent/prompt` | [template](https://forkline.dev/docs/routines/templates) string | yes | | Not empty, at most 32 KiB (32768 bytes). Every variable must exist for every listed event | `` `prompt` is required. ``, `` must not be empty ``, `` is {n} bytes, the limit is 32768 ``, `` the template does not parse: {detail} ``, `` `{path}` isn't available for `{event}`. ``, `` `{path}` isn't available for {events}; every chosen event must provide it. `` |
| `/agent/model` | string | no | the runner default | 1 to 200 characters. Only the length is checked | `` must be between 1 and 200 characters `` |
| `/agent/runnerSize` | `basic`, `advanced` or `pro` | no | the runner default (`basic`) | One of the three | `` `{value}` is not allowed here. Use one of: `basic`, `advanced`, `pro`. `` |
| `/agent/ref` | template string | no | the natural ref of the event, see below | 1 to 1024 characters. Checked like the prompt. A ref that renders empty means no ref | `` must be between 1 and 1024 characters `` |
| `/agent/timeoutMinutes` | integer | no | 30 | 1 to 240 | `` must be between 1 and 240 `` |

```yaml
agent:
  prompt: |
    CI "{{ ci.name }}" failed on {{ pr.headBranch }}. Fix it and push to the PR branch.
  model: anthropic/claude-sonnet-4-5
  runnerSize: advanced
  ref: "{{ pr.headBranch }}"
  timeoutMinutes: 45
```

When `ref` is not set, the agent works on:

| Event | Default ref |
|-------|-------------|
| `pr.*` events | The pull request head branch. For a pull request from a fork, the base branch, because the agent cannot push to the fork |
| `push` | The new commit of the push |
| `ci.failed` | The head branch of the pull request the run belongs to, otherwise the branch of the CI run |
| everything else | The default branch of the repository |

## `concurrency`

`concurrency` is enforced for every run: events, schedule ticks, Run now and custom webhooks. It is written like this:

```yaml routine
name: concurrency-shape
description: Every concurrency key with its default
on:
  events: [pr.opened, pr.updated]
repositories:
  include: all
agent:
  prompt: Review pull request #{{ pr.number }}.
concurrency:
  group: pr            # pr, issue, repository or routine
  onConflict: replace  # replace or skip
  maxActive: 10        # 1 to 50
```

| Pointer | Type | Required | Default | Constraint | Message when violated |
|---------|------|----------|---------|------------|-----------------------|
| `/concurrency/group` | `pr`, `issue`, `repository` or `routine` | no | derived from the events, see below | `pr` needs events that all belong to a pull request (`pr.*`, `comment.created`, `ci.failed`). `issue` needs `issue.opened` and `comment.created` only. `repository` and `routine` always work | ``group `{group}` needs a routine that triggers on {allowed events}, use `repository` or `routine` instead`` |
| `/concurrency/onConflict` | `replace` or `skip` | no | `replace` when the group is `pr` or `issue`, `skip` when it is `repository` or `routine` | One of the two | an unknown value is an enum error at this pointer |
| `/concurrency/maxActive` | integer | no | 10 | 1 to 50 | a value outside the range is a range error at this pointer |

The default `group` is `pr` when every event is `pr.*`, `comment.created` or `ci.failed` and at least one is `pr.*` or `ci.failed`. Otherwise it is `issue` when every event is `issue.opened` or `comment.created`, and otherwise `repository`. Schedule and webhook routines default to `repository`.

`maxActive` does not count tasks from the same occurrence (one event, schedule tick, Run now or custom webhook call). On `replace`, a run that cannot start because the owner's pending-run cap is reached cancels nothing. Details: [Concurrency and limits](https://forkline.dev/docs/routines/concurrency-and-limits).

## `limits`

```yaml
limits:
  maxRunsPerPr: 3
```

| Pointer | Type | Required | Default | Constraint | Message when violated |
|---------|------|----------|---------|------------|-----------------------|
| `/limits/maxRunsPerPr` | integer | no | 3 | 1 to 50 | `` must be between 1 and 50 `` |
| `/limits/resetOn` | | reserved | | not supported yet | `` `limits.resetOn` is not supported yet `` |

`maxRunsPerPr` is the most runs one routine starts for the same pull request, in total: replaced runs count and it never resets. It stops loops between a routine and a bot or CI. The default 3 also stops a routine on `pr.updated` after the pull request's third run (outcome `run_limit`); raise it, up to 50, to run on every commit. Events without a pull request are not counted.

## Limits at a glance

| What | Limit |
|------|-------|
| Routine name | 1 to 100 characters |
| Events in `on.events` | 8 |
| Selectors in `repositories.include` and in `.exclude` | 50 each |
| Repositories of one schedule or webhook run | 1 to 200 |
| Entries in one filter list | 50, each at most 256 bytes |
| Command names in `filters.command.names` | 20 |
| `filters.when` | 4096 bytes, 16 levels, 512 expressions, worst-case cost 50,000, 256 bytes per `matches()` pattern |
| Fields `filters.when` reads from `payload` | weight 4,096, 16 levels, 1,000 items, 64 KiB of text |
| Prompt | 32 KiB (32768 bytes); the rendered prompt at most 512 KiB |
| `agent.model` | 1 to 200 characters |
| `agent.ref` | 1 to 1024 characters |
| `agent.timeoutMinutes` | 1 to 240, default 30 |
| `limits.maxRunsPerPr` | 1 to 50, default 3 |
| `concurrency.maxActive` | 1 to 50, default 10 |
| Routines per owner | 25 published, 100 in total |
| Webhook body | 5 MiB |
| Run now and test payload | 5 MiB (request 6 MiB) |
| Run now | 10 real runs a minute per owner |

Sizes, rates and how calls behave at them: [Webhook and Run now limits](https://forkline.dev/docs/routines/concurrency-and-limits#webhook-and-run-now-limits).

## Not supported yet

These names are reserved. Forkline refuses them when you save, with the message shown, instead of ignoring them.

| Name | Pointer | Message |
|------|---------|---------|
| `limits.resetOn` | `/limits/resetOn` | `` `limits.resetOn` is not supported yet `` |
| Event `pr.conflicted` | `/on/events/{i}` | `` `pr.conflicted` is not supported yet `` |
| Event `ci.succeeded` | `/on/events/{i}` | `` `ci.succeeded` is not supported yet `` |
| Event `pr.labeled` | `/on/events/{i}` | `` `pr.labeled` is not supported yet `` |
| Event `issue.labeled` | `/on/events/{i}` | `` `issue.labeled` is not supported yet `` |
| Event `issue.assigned` | `/on/events/{i}` | `` `issue.assigned` is not supported yet `` |
| Event `pr.review_requested` | `/on/events/{i}` | `` `pr.review_requested` is not supported yet `` |
| Variable `comment.prompt` | `/agent/prompt` or `/agent/ref` | `` `comment.prompt` is not supported yet `` |
| Variable `comment.path` | `/agent/prompt` or `/agent/ref` | `` `comment.path` is not supported yet `` |
| Variable `comment.requestedModel` | `/agent/prompt` or `/agent/ref` | `` `comment.requestedModel` is not supported yet `` |
| Variable `thread.comments` | `/agent/prompt` or `/agent/ref` | `` `thread.comments` is not supported yet `` |

## Custom webhooks

A routine with `on.webhook` runs when another system calls its URL:

```
POST https://<your Forkline host>/webhooks/custom/<routine id>
```

The routine must be published. The call proves who it is with a secret that belongs to this routine. There is no token in the URL: access logs record URLs, so a secret there would end up in logs. The secret goes in a header.

To start a routine yourself instead, use [Run now](https://forkline.dev/docs/routines/testing-and-activity#run-now) (web, CLI or API). The same limits apply to both.

### `on.webhook.auth`

| `auth` | The call carries | Secret |
|--------|------------------|--------|
| `{}` or omitted | `Authorization: Bearer <secret>` | Generated by Forkline |
| `{type: token, header: X-Alert-Token}` | The secret itself, as the raw value of that header | Generated by Forkline |
| `{type: hmac, preset: forkline}` | `X-Signature-256: sha256=<hex>` | Generated by Forkline |
| `{type: hmac, preset: github}` | `X-Hub-Signature-256: sha256=<hex>` | Pasted by you |
| `{type: hmac, preset: sentry}` | `Sentry-Hook-Signature: <hex>` | Pasted by you |

- **Token.** The default. With `Authorization` the scheme `Bearer` is required (any letter case). With a custom `header` the value is the secret alone.
- **HMAC.** The sender signs the raw request body with HMAC-SHA256 and sends the hex digest. `<hex>` can be lower or upper case. The preset fixes the header and the prefix, so `header` is not allowed with `type: hmac`.
- **Which preset.** `forkline` is for your own code. `github` and `sentry` match what those senders already send: set the Forkline secret to the signing secret the sender holds (GitHub: the webhook secret; Sentry: the integration's client secret). For GitHub, set the webhook's Content type to `application/json`: with `application/x-www-form-urlencoded`, the body arrives as form text in `payload.raw_body` and the event's fields are not in `payload`. Sentry sends no idempotency key: each delivery starts a call.
- A wrong or missing secret answers `401`: `Missing credentials` or `Invalid credentials` for a token, `Missing signature` or `Invalid signature` for HMAC. Nothing is started. A routine that has no secret yet answers the same `Missing` message.

```yaml routine
name: alert-triage
on:
  webhook:
    auth: { type: hmac, preset: forkline }
    idempotencyHeader: X-Delivery-Id
    example:
      title: High error rate
      level: error
repositories:
  include: [{ name: "acme/api" }]
agent:
  prompt: |
    Investigate this alert in {{ repository.fullName }}: {{ payload.title }}
```

```bash
BODY='{"title":"High error rate"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST https://<host>/webhooks/custom/<routine id> \
  -H "X-Signature-256: sha256=$SIG" \
  -H "X-Delivery-Id: alert-4711" \
  -d "$BODY"
```

With the default `auth: {}`, send the secret instead:

```bash
curl -X POST https://<host>/webhooks/custom/<routine id> \
  -H "Authorization: Bearer $WEBHOOK_SECRET" \
  -H "Idempotency-Key: alert-4711" \
  -d '{"title":"High error rate"}'
```

### The secret

- **Generated** (token and `forkline`). Forkline creates it when you create the routine and returns it **once** in that response (`webhook_secret`). It never appears in the spec. Reading the routine later only shows `webhook_secret_configured`, `webhook_secret_origin` (`generated`) and `webhook_secret_updated_at`.
- **Rotate it** with `forkline routine webhook <routine> --rotate-secret` or `POST /v1/routines/{id}/webhook/rotate-secret`. The new secret is shown once and the old one stops working immediately. There is no grace period.
- **Pasted** (`github`, `sentry`). The sender owns the secret, so you give it to Forkline with `forkline routine webhook <routine> --set-secret <file|->` or `PUT /v1/routines/{id}/webhook/secret` with `{"secret": "..."}` (16 to 512 bytes, surrounding whitespace trimmed). Forkline never shows it back, and a routine without it cannot be published or enabled until it is set. Rotate is refused for a pasted secret: rotate it at the sender and paste the new value.
- **Changing `auth`.** Switching between a token and `forkline` keeps the secret. Moving to `github` or `sentry`, or between them, clears it: paste the new one. Moving from a pasted secret to a token or `forkline` generates one, shown once. A routine that is already published stays published, and answers `401 Missing signature` until you paste the secret.
- `{}` on an older routine was rewritten to `{type: hmac, preset: forkline}`, so existing senders keep working.

### Other settings and limits

- **`idempotencyHeader`.** The header that carries the retry key (default `Idempotency-Key`). Send it so a repeated delivery starts no second task. Without it, every call starts a run. The key is trimmed and at most 200 bytes; a longer key is ignored, so every call runs. Forkline keeps the key with the run, so a repeat is skipped however late it comes. Sentry sends no key. Run now does not use this setting: it always reads `Idempotency-Key`.
- **`example`.** A sample body, at most 16 KiB serialized as compact JSON. The Run drawer fills its payload box with it. A real call never reads it.
- **The body.** Any JSON (anything else is passed as `payload.raw_body`). It is available as `payload` in templates, and in [`when`](https://forkline.dev/docs/routines/filters#reading-the-webhook-payload-with-payload) within the limits listed there.
- **Size.** The body is at most 5 MiB. A larger body is refused with `413` `PAYLOAD_TOO_LARGE` (`The request body is larger than 5 MiB`) and starts nothing.
- **Errors.** An unexpected failure answers `500` with the usual JSON envelope, `{"error": "Internal server error", "code": "INTERNAL_ERROR"}`; nothing is said about the cause. Retry later.
- **Response.** An accepted call answers `200` with `task_ids` and `session_urls`. Both are empty when nothing starts, and `reason` says why. `event_id` finds the call in [Recent events](https://forkline.dev/docs/routines/testing-and-activity#recent-events). An unknown routine, a draft, a disabled routine and a routine without a webhook trigger answer `404`.
- **Concurrency.** The routine's [`concurrency`](#concurrency) applies: with the defaults, a second call while a run is active starts nothing and is recorded as `skipped_active`.
- **Rate limit.** A routine accepts 600 calls a minute (bursts of 200), and one sender IP address 1,200 a minute (bursts of 400), counted per Forkline replica. A sender above either gets `429` with `{"success": false, "message": "Webhook rate limit exceeded; retry later"}` and a `Retry-After` header. Nothing is started: retry after the delay.
- **Overload.** While Forkline is overloaded the endpoint answers `429` with `{"error":"Forkline is busy; retry shortly","code":"TOO_MANY_REQUESTS","retry_after_secs":5}` and a `Retry-After: 5` header. Nothing was started: retry after the delay.
- **All limits.** [Webhook and Run now limits](https://forkline.dev/docs/routines/concurrency-and-limits#webhook-and-run-now-limits) lists every size and rate in one place.

`forkline routine webhook <routine>` prints the URL, the authentication method, and whether a secret is configured.
