Part of the Webhook-Triggered Updates guide.
Operative rule: decouple the data-source event from CI by relaying it as a single repository_dispatch event — verify the incoming signature, then let the dispatch’s client_payload carry exactly what the rebuild needs, so the data source never knows anything about your build steps.
How repository_dispatch Works
A repository_dispatch event is a way to start a GitHub Actions workflow from outside the repository, over the REST API, carrying an arbitrary JSON client_payload. Where a push starts a build because code changed, repository_dispatch starts one because data changed somewhere else — a row landed in a database, a file was uploaded, an upstream feed published. You POST to the repository’s dispatches endpoint with an event_type string and a client_payload object; GitHub matches the event_type against any workflow that declares on: repository_dispatch: types: [...] and runs it. The event is fire-and-forget: the API returns 204 No Content whether or not a workflow matched, which is why the event_type and the workflow’s types list must agree exactly.
The value of this indirection is that the data source and the CI pipeline stay ignorant of each other. The data source only knows how to sign and send one webhook; the workflow only knows how to read a client_payload. Between them sits a thin relay whose entire job is trust: it verifies an HMAC signature over the raw body so that only a caller holding the shared secret can trigger a rebuild, then forwards the event with a server-held token that the public data source never sees. That same signature-verify pattern underpins other event sources — see triggering map refresh via Supabase webhooks for the database-native variant. When no external event exists and you just want a wall-clock cadence, reach for scheduled map rebuild workflows instead.
Production-Ready Implementation
Two pieces cooperate: a relay that verifies the signature and fires the dispatch, and the workflow that receives it. The relay below is framework-agnostic Python — drop the handler into any WSGI/ASGI app or a serverless function.
from __future__ import annotations
import hashlib
import hmac
import json
import os
import urllib.request
# Shared secret with the data source; NOT the GitHub token.
_WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()
# Fine-grained token with "Contents: read/write" on the target repo only.
_DISPATCH_TOKEN = os.environ["DISPATCH_TOKEN"]
_REPO = os.environ["TARGET_REPO"] # e.g. "acme/geo-dashboard"
_EVENT_TYPE = "data-changed" # must match the workflow's types: list
def _signature_ok(raw_body: bytes, header_sig: str) -> bool:
"""Constant-time verify an HMAC-SHA256 signature of the raw request body."""
expected = "sha256=" + hmac.new(_WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
# compare_digest avoids leaking match position via timing.
return hmac.compare_digest(expected, header_sig or "")
def _fire_dispatch(client_payload: dict[str, object]) -> int:
"""Call the repository_dispatch endpoint with a server-held token."""
body = json.dumps(
{"event_type": _EVENT_TYPE, "client_payload": client_payload}
).encode()
req = urllib.request.Request(
f"https://api.github.com/repos/{_REPO}/dispatches",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {_DISPATCH_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req) as resp:
return resp.status # 204 on success
def handle_webhook(raw_body: bytes, header_sig: str) -> tuple[int, str]:
"""Entry point: verify, then relay as a repository_dispatch event."""
if not _signature_ok(raw_body, header_sig):
return 401, "invalid signature"
event = json.loads(raw_body)
# Forward only what the rebuild needs — never the whole upstream payload.
payload = {
"dataset": event.get("dataset", "default"),
"changed_at": event.get("updated_at"),
"reason": "external-data-change",
}
status = _fire_dispatch(payload)
return (202, "rebuild dispatched") if status == 204 else (502, "dispatch failed")
The matching workflow declares the repository_dispatch trigger, reads client_payload, and gates the expensive rebuild behind a cheap condition so unrelated datasets do not burn CI minutes.
name: rebuild-tiles
on:
repository_dispatch:
types: [data-changed] # must equal the relay's _EVENT_TYPE
workflow_dispatch: # keep a manual escape hatch for reruns
inputs:
dataset:
description: "Dataset to rebuild"
default: "default"
jobs:
rebuild:
runs-on: ubuntu-latest
# Gate: only run when a real dataset was named.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.client_payload.dataset != ''
steps:
- uses: actions/checkout@v4
- name: Resolve dataset name
id: vars
run: |
DATASET="${{ github.event.client_payload.dataset || inputs.dataset }}"
echo "dataset=$DATASET" >> "$GITHUB_OUTPUT"
- name: Rebuild tiles
run: python build_tiles.py --dataset "${{ steps.vars.outputs.dataset }}"
Alternative Variants
Trigger a manual rerun with the CLI
workflow_dispatch shares the same workflow but is meant for humans and typed inputs. It is the escape hatch when a rebuild needs re-running without a fresh data event:
gh workflow run rebuild-tiles.yml -f dataset=parcels
repository_dispatch vs workflow_dispatch
| Aspect | repository_dispatch | workflow_dispatch |
|---|---|---|
| Who triggers | machine (external webhook) | human (UI / CLI) |
| Payload | arbitrary client_payload JSON |
fixed, typed inputs |
| API surface | POST /repos/{repo}/dispatches |
POST .../workflows/{id}/dispatches |
| Auth | token with Contents: write |
token or interactive session |
| Best for | data-change fan-in from many sources | reruns, backfills, manual ops |
Keep both on: triggers in one workflow so the automated path and the manual path share identical build steps.
Verification Steps
- Signature rejection — POST a body with a deliberately wrong signature and confirm the relay returns
401and fires no dispatch. - Happy path end-to-end — send a correctly signed event and confirm the relay returns
202, the dispatch API returns204, and a run appears under the workflow within seconds. - event_type match — temporarily change
_EVENT_TYPEto a value absent from the workflow’stypes:list; confirm the API still returns204but no run starts, proving the match is on the string. - Payload plumbing — echo
${{ toJSON(github.event.client_payload) }}in a debug step and confirmdatasetandchanged_atarrived intact. - Gate behaviour — dispatch an event with an empty
datasetand confirm theif:condition skips the rebuild job.
Common Errors & Fixes
Dispatch returns 204 but no workflow runs
The API accepted the event but nothing matched it. The two most common causes: the workflow is not on the repository default branch (repository_dispatch only matches workflows on the default branch), or the event_type you sent is not in the workflow’s on.repository_dispatch.types list. Fix: merge the workflow to the default branch and make the types: entry exactly equal to _EVENT_TYPE, including case.
Relay returns 401 for a request you believe is valid
The HMAC is being computed over a re-serialized body rather than the exact bytes received. Any framework that parses JSON and re-encodes it will change whitespace and key order, breaking the digest. Fix: capture the raw request body before any parsing and sign that byte string; only call json.loads after hmac.compare_digest passes.
Rebuild runs but client_payload fields are empty
client_payload is only populated for the repository_dispatch event. When the same workflow is started via workflow_dispatch, those fields are null and the expressions collapse to empty strings. Fix: fall back to inputs.* with the || operator as shown (client_payload.dataset || inputs.dataset) so both trigger paths resolve a value.
403 or resource-not-accessible from the dispatch call
The token lacks write access to the target repository, or a fine-grained token was scoped to the wrong repo. Fix: issue a fine-grained token granting Contents: read and write on exactly the target repository, store it as DISPATCH_TOKEN, and keep it server-side in the relay — never hand it to the public data source.
Related
- Webhook-Triggered Updates — parent guide to event-driven rebuilds and the trust boundary every webhook must enforce
- Triggering Map Refresh via Supabase Webhooks — the database-native event source that can feed this same relay
- Scheduled Map Rebuild Workflows — when no external event exists and a wall-clock cadence is the right trigger
- Deploying Generated Map Bundles with GitHub Actions CI/CD — publishing the artifacts the rebuild job produces