Skip to main content

GitHub, Slack, and Linear Webhooks

The harness server exposes three HMAC-verified webhook endpoints — one each for GitHub, Slack, and Linear. Each endpoint converts inbound platform events into agent run actions (start a new run, steer a running one, or continue a completed one) without requiring any glue code beyond setting a single environment variable.

A fourth, source-agnostic endpoint (POST /v1/external/trigger) accepts the normalized envelope directly and is useful for custom sources or for triggering a Slack-sourced start action (see Starting a run from Slack).

Three signed endpoints

EndpointAuth mechanismEnabling env var
POST /v1/webhooks/githubHMAC-SHA256 (X-Hub-Signature-256)GITHUB_WEBHOOK_SECRET
POST /v1/webhooks/slackHMAC-SHA256 (X-Slack-Signature + timestamp)SLACK_SIGNING_SECRET
POST /v1/webhooks/linearHMAC-SHA256 raw hex (X-Linear-Signature)LINEAR_WEBHOOK_SECRET
POST /v1/external/triggerHMAC-SHA256 via ValidatorRegistryany of the above

Bearer token auth is bypassed on all four endpoints. Authentication is performed exclusively via the HMAC signature carried in the platform-specific header (or the X-Trigger-Signature header for the generic endpoint). Setting the corresponding environment variable both enables the endpoint and registers the validator.

When the environment variable is absent (or the adapter is not configured), the endpoint responds with 401.

# Enable all three webhook sources at startup
GITHUB_WEBHOOK_SECRET=ghsecret123 \
SLACK_SIGNING_SECRET=slacksecret456 \
LINEAR_WEBHOOK_SECRET=linearsecret789 \
go run ./cmd/harnessd

Signature schemes

Each platform uses a slightly different HMAC-SHA256 convention. The harness implements each scheme verbatim.

Required headers per source

SourceRequired headers
GitHubX-GitHub-Event, X-GitHub-Delivery, X-Hub-Signature-256
SlackX-Slack-Request-Timestamp, X-Slack-Signature
LinearX-Linear-Signature (optional — absence yields 401, not 400)

For GitHub and Slack, missing a required header returns 400. For Linear, a missing or invalid X-Linear-Signature returns 401; Linear's 400 responses indicate an unsupported event type or unrecognized action.

Supported event types and action mapping

The adapter for each source inspects the event type and action field to derive one of three trigger actions: start, steer, or continue. Unsupported combinations return 400 (empty action derived).

GitHub

Supported event types: issues, issue_comment, pull_request, pull_request_review.

Event typeGitHub actionTrigger action
issuesopened, labeledstart
issue_commentcreatedsteer
pull_requestopenedstart
pull_requestsynchronizesteer
pull_request_reviewsubmittedsteer

The ThreadID is the issue or PR number as a decimal string. Combined with repo_owner and repo_name, this produces a stable conversation identity across all events on the same issue or PR.

Slack

Supported outer envelope type: event_callback only.

Inner event types app_mention and message both pass through successfully; the parser does not filter by inner type.

Outer typeTrigger action
event_callbacksteer (always)

Slack always steers — it cannot start a new run via the webhook. All Slack event_callback events produce Action = "steer", which requires an existing run for the derived thread. To start a new Slack-sourced run, use POST /v1/external/trigger with "action": "start" (see Starting a run from Slack).

Slack url_verification is not handled. During initial Slack app setup, Slack sends a url_verification challenge that expects an immediate echo response. The webhook handler rejects any non-event_callback payload with 400. You must complete the URL verification through a separate mechanism (e.g., a temporary HTTP handler) before pointing your Slack app at this endpoint.

Linear

Supported event types: Issue, Comment.

Event typeLinear actionTrigger action
Issuecreatestart
Issueupdatesteer
Commentcreatesteer

The ThreadID is the issue identifier (e.g. ENG-123) when available, falling back to the internal issue UUID. This ensures all events on the same Linear issue map to the same harness conversation.

Action routing and thread mapping

When the harness receives a verified webhook, it:

  1. Derives a stable ExternalThreadID from the source, repo owner/name, and thread ID using a SHA256 hash — so the same issue or thread always maps to the same harness conversation, regardless of which event fires.
  2. Routes to one of three dispatch paths based on Action:
ActionBehavior
startAlways starts a new run
steerInjects a message into an existing running, queued, or waiting_for_user run
continueStarts a new run continuing from a completed or failed run

A steer or continue action with no matching run returns 404. A steer against a run in the wrong state (e.g., already completed) returns 409.

Response codes

All three webhook endpoints share the same response code semantics:

CodeMeaning
202Request accepted and routed
400Missing required headers, unsupported event type, or empty derived action
401Missing/invalid signature, or adapter not configured
404steer/continue action but no existing run for this thread
409Run state mismatch (e.g., steering a completed run)
501Run persistence (Store) not configured

Starting a run from Slack

Because the Slack webhook always derives steer, use the generic POST /v1/external/trigger endpoint when you need to start a new run from a Slack event. Build the envelope yourself (without a signature field), compute the Slack HMAC over that exact body, and send it in the X-Trigger-Signature header:

TIMESTAMP=$(date +%s)
BODY='{"source":"slack","source_id":"manual-001","thread_id":"C01234567:1234567890.000000","action":"start","message":"Run the nightly eval suite"}'

# Compute the Slack HMAC: HMAC-SHA256("v0:{timestamp}:{body}", SLACK_SIGNING_SECRET)
# The HMAC is computed over BODY *before* any signature field is added.
SIG=$(printf "v0:%s:%s" "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "$SLACK_SIGNING_SECRET" -hex \
| awk '{print "v0="$2}')

# Send the packed timestamp:sig in the X-Trigger-Signature header, NOT in the body.
# Including the signature inside the JSON body would change the bytes being hashed
# and cause every HMAC check to fail with 401.
curl -s -X POST http://localhost:8080/v1/external/trigger \
-H "Content-Type: application/json" \
-H "X-Trigger-Signature: ${TIMESTAMP}:${SIG}" \
-d "$BODY"

Always send the signature in the X-Trigger-Signature header, never in the JSON body. The server computes HMAC over the full raw request body (env.RawBody). If you embed the signature field inside the JSON body, the bytes being hashed differ from the bytes you signed, and validation returns 401. The header is the only channel where the signature value does not change what is being hashed.

HARNESS_AUTH_DISABLED only disables Bearer-token middleware and does not waive signature validation on webhook or trigger routes. To test without a real Slack app you must set SLACK_SIGNING_SECRET and supply a matching HMAC signature in the X-Trigger-Signature header.

Enabling webhooks: step-by-step

  1. Set the signing secret environment variable

    Set one or more of GITHUB_WEBHOOK_SECRET, SLACK_SIGNING_SECRET, or LINEAR_WEBHOOK_SECRET before starting harnessd. Each non-empty secret registers the corresponding adapter and validator automatically.

    export GITHUB_WEBHOOK_SECRET="your-github-secret"
    go run ./cmd/harnessd
  2. Configure run persistence

    The steer and continue actions require a run store to look up existing runs. Set HARNESS_RUN_DB to a SQLite path:

    export HARNESS_RUN_DB=".harness/runs.db"

    Without persistence, routing returns 501.

  3. Point the platform at your endpoint

    Register your server's public URL in the platform's webhook settings:

    • GitHub: Repository or organization Settings → Webhooks → Add webhook. Set the Payload URL to https://your-server/v1/webhooks/github. Choose application/json content type and paste your secret.
    • Slack: App configuration → Event Subscriptions → Request URL: https://your-server/v1/webhooks/slack. Add the app_mention or message event subscriptions.
    • Linear: Workspace Settings → API → Webhooks → Create webhook. Set the URL to https://your-server/v1/webhooks/linear and paste your secret.
  4. Verify with a test event

    Each platform provides a "send test event" button. A successful delivery returns HTTP 202. If you see 401, double-check that the secret in the platform matches GITHUB_WEBHOOK_SECRET / SLACK_SIGNING_SECRET / LINEAR_WEBHOOK_SECRET exactly.

Example payloads

GitHub issues event

{
"action": "opened",
"issue": {
"number": 42,
"title": "Agent fails on empty input",
"body": "Steps to reproduce: send an empty prompt."
},
"repository": {
"name": "myrepo",
"owner": { "login": "myorg" }
}
}

With X-GitHub-Event: issues and action: opened, this produces trigger action start.

Slack event_callback

{
"type": "event_callback",
"event_id": "Ev123ABC",
"team_id": "T012AB3C4",
"event": {
"type": "app_mention",
"user": "U012AB3C4",
"text": "<@UBOT123> do something useful",
"ts": "1234567890.123456",
"thread_ts": "1234567890.000000",
"channel": "C01234567"
}
}

This produces trigger action steer with thread ID derived from C01234567:1234567890.000000.

Linear issue created

{
"type": "Issue",
"action": "create",
"organizationId": "org-uuid",
"data": {
"id": "issue-uuid",
"identifier": "ENG-123",
"title": "Add dark mode support",
"description": "Users have requested a dark mode.",
"teamId": "team-uuid"
}
}

With action: create, this produces trigger action start with thread ID ENG-123.

Next steps

  • See External Trigger API for the full POST /v1/external/trigger request schema and all response codes.
  • See Runs and Events for the SSE event stream you can subscribe to once a run starts.
  • To schedule recurring agent runs on a cron expression instead of reacting to platform events, see Cron Scheduling.