Skip to content

feat(extras): receive SaaS webhooks, at flyte.extras.webhooks - #1512

Merged
cosmicBboy merged 8 commits into
mainfrom
nielsb/webhooks-core
Sep 3, 2026
Merged

feat(extras): receive SaaS webhooks, at flyte.extras.webhooks#1512
cosmicBboy merged 8 commits into
mainfrom
nielsb/webhooks-core

Conversation

@cosmicBboy

@cosmicBboy cosmicBboy commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

First of a stack that replaces the six per-product integration plugins (#1502#1507) with the parts that are genuinely Flyte's. Structured like plugins/agents: core holds the contract, each product is its own thin package.

Stack: this → github → slack → linear → clickup → jira → examples. Each PR's diff is just its own package.

What core owns

WebhookAppEnvironment One app: a dashboard and a verified receiver at /webhook/{provider}, for whichever providers it is handed
Provider The contract a product plugin implements
WebhookEvent The normalized event every provider parses into
run_once Launch a run once per event key, returning the run that covers it either way
EventType Base for the typed event constants each plugin ships
verification primitives constant_time_equals, hex_hmac_sha256, json_body, lower_headers
testing.assert_provider_conforms The conformance check every plugin runs

A product plugin owns only what is specific to it — usually under 150 lines. Core is tested against a stub provider, so it depends on no product package.

Two decisions worth reviewing

Identity lives on a run label, never a name. run_once launches only when no run carrying the same dedupe label is live or succeeded; when one is, it returns that run with created=False rather than launching a second or raising, so a handler can answer "already handled" and link to it. Names race against concurrent launches and cap how many runs one key can ever have, so the control plane assigns them. It is async-first behind @syncify — the blocking form stalls an app's event loop, and webhook senders time out in seconds. A test asserts four concurrent launches overlap; against a blocking implementation it measures 0.81s instead of 0.42s and fails.

constant_time_equals compares bytes. hmac.compare_digest raises TypeError on str operands containing non-ASCII, and these headers come off the wire — ASGI hands Starlette raw bytes which it decodes latin-1, so a crafted header would turn a clean 401 into a 500. Writing it once here is the main argument for a shared core.

The conformance harness

Each product plugin ships SAMPLE_DELIVERY, a real payload. The harness signs and replays it, so verify and parse are checked against an actual payload rather than against each other — without one, every other check is vacuous.

It earned its keep during development twice:

  • It caught that Jira's shared token is not bound to the request body, unlike the four HMAC providers. That is inherent to an unsigned webhook, not a bug, so signed=False now opts out of the body-tampering check and makes the dashboard say the product does not sign.
  • It caught a hole in itself: the hostile-header check sent a bare non-ASCII value, which GitHub and Slack reject on their scheme prefix before reaching the comparison — leaving the check vacuous for exactly the two providers it mattered most for. Hostile values now preserve the prefix, and a str-based compare_digest fails conformance as intended.

I verified it discriminates by injecting three regressions (plain str, Enum constants, a str comparison, a constant that no longer matches the parser); each one fails with a specific message.

Notes

No new dependencies land in flyte: the shared core lives in flyte.extras.webhooks behind the same soft-import pattern the other flyte.extras apps use, and each product plugin ships its own provider.

46 core tests plus each plugin's suite. make fmt, make mypy (864 files), make ty, ruff, and codespell pass.

The caveat from the review that prompted this: the parsers still encode payload-shape assumptions I cannot verify without live accounts. That surface is much smaller now — the six API clients and their ~213 field assumptions are gone — and SAMPLE_DELIVERY pins what remains, but one real pass per product is still the thing that would confirm it.

Product-agnostic machinery for turning an inbound webhook into a Flyte run, so
each flyteplugins-<product> package only has to say how to verify and parse its
own deliveries.

- WebhookAppEnvironment: one app serving a setup dashboard and a verified
  receiver at /webhook/{provider}, for whichever providers it is handed.
- WebhookEvent: the normalized event every provider parses into. Its dedupe key
  folds in the product's own timestamp, so a later change to one resource gets
  its own key rather than collapsing onto the first.
- idempotent_run: launch once per event key. Identity lives on a run *label*,
  never a name -- names race against concurrent launches and cap how many runs
  one key can ever have.
- Provider plus the verification primitives, written once because they are the
  part that is easy to get subtly wrong. constant_time_equals compares bytes:
  hmac.compare_digest raises TypeError on non-ASCII str operands, and these
  headers come off the wire, so a crafted one would turn a clean 401 into a 500.
- testing.assert_provider_conforms: the conformance check every product plugin
  runs, which replays a real sample delivery through verify and parse rather
  than trusting them to agree with each other.

No new dependencies. pydantic is already a runtime dependency; fastapi stays
behind the same lazy import flyte.app.extras._fastapi uses, so importing this
package never requires it -- only building the app does. Product plugins
declare their own [app] extra.

Tested against a stub provider, so the SDK depends on no product package.
examples/apps/webhook_custom_provider.py is a complete provider you can run
with no account at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk
Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
cosmicBboy added a commit that referenced this pull request Sep 2, 2026
…1518)

Last of the webhook plugin stack (#1512#1517), based on #1517 so every
import resolves.

Worked recipes for driving GitHub, Slack, Linear, ClickUp, and Jira from
Flyte, plus one app receiving webhooks from all five.

## The argument these examples make

There is deliberately **no Flyte client plugin** for these products.
Each vendor already ships (or the community maintains) a Python client
tested against the live API by people who get deprecation notices first,
and a task is just a function — so calling `PyGithub` or `slack_sdk`
from a task needs nothing in between. A wrapper would only add a surface
to keep in sync with someone else's release calendar.

What Flyte contributes, and what these use:

- `flyteplugins-webhooks-*` — authenticate an inbound delivery and
normalize it
- `idempotent_run` — launch a run once per event key
- `flyte.new_condition` — park a run on a human decision with a typed
payload back

| File | Shows | Client |
| --- | --- | --- |
| `webhook_receiver.py` | One app receiving from all five products |
`flyteplugins-webhooks-*` |
| `github_pr_review_gate.py` | Human-gated merge: a condition carrying
JSON, parsed into a typed decision | `PyGithub` |
| `github_triage_pr.py` | Label, comment, report a check run |
`PyGithub` |
| `slack_notify.py` | Post, thread, react, answer a mention |
`slack_sdk` |
| `linear_triage_issue.py` | Query a backlog and comment, over GraphQL |
`gql` |
| `clickup_manage_ticket.py` | Open and close tickets, with a status
pre-check | `httpx` |
| `jira_manage_ticket.py` | Open, transition, and search issues | `jira`
|

Linear and ClickUp ship no official Python SDK — Linear's API is a
single GraphQL endpoint so `gql` is the maintained client, and ClickUp's
is a handful of REST calls where `httpx` directly beats a thin
third-party wrapper.

The review gate is here rather than in a plugin because
`flyte.new_condition` is the only part of it that needed inventing; the
rest is PyGithub calls.

## One thing I fixed while writing them

Task names are **environment-qualified** — `triage_pr` in
`github_triage_pr.py` deploys as `github-triage.triage_pr`, which is
what the receiver looks up. Bare names never resolve, and both the
Linear and Jira recipes define a `triage_issue`, so the qualifier is
also what keeps them apart. There is a check that every name the
receiver launches is actually defined; the original per-product examples
this replaces had bare names throughout and would have failed at the
first real event.

## Verification

Verified mechanically rather than by eye: every referenced example path
resolves, every `flyte create secret` name matches the actual provider
`secret_env`, every `flyte deploy <file> <env>` names a real
`TaskEnvironment`, every `flyte run` task is defined, and every example
file executes (the receiver registers all five handlers).

`make fmt`, `make mypy`, `make ty`, ruff, and codespell pass.

Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy added a commit that referenced this pull request Sep 2, 2026
Part of the webhook plugin stack. Requires #1512
(`flyteplugins-webhooks-core`), which is this PR's base — the diff here
is just this package.

Receive Jira webhooks in Flyte.

## What it implements

The `Provider` contract from core: which environment variable holds the
secret, how to verify a delivery, how to parse one into a
`WebhookEvent`, plus typed constants for every event Jira sends.

**Verification:** **none** — Jira Cloud does not sign its webhooks.

Because there is no signature, this plugin authenticates with a shared
token in `X-Webhook-Token`, which something in front of the app has to
inject since Jira cannot send custom headers. `PROVIDER.signed` is
False, so the dashboard says the product does not sign rather than
implying a guarantee that is absent.

This is the plugin that made the conformance harness better: it caught
that a shared token, unlike an HMAC, is **not bound to the request
body**, so a tampered payload still verifies. That is inherent to an
unsigned webhook rather than a bug here, and `signed=False` now opts out
of that one check explicitly instead of the harness quietly passing.

## Conformance

Runs the shared `assert_provider_conforms`, which replays this plugin's
`SAMPLE_DELIVERY` — a real Jira payload — through `verify` and `parse`
rather than trusting them to agree with each other. It also asserts the
verifier returns False rather than raising on a hostile header, that
event constants render as wire values rather than enum names, and that
the sample parses to something the constants actually spell.

6 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass.

## What it does not do

Call the Jira API. Use the `jira` package directly from your tasks — the
recipes are in the examples PR at the end of this stack. This plugin
owns only the part that is Flyte's: authenticating an inbound delivery
and turning it into a run.

---------

Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy added a commit that referenced this pull request Sep 2, 2026
Part of the webhook plugin stack. Requires #1512
(`flyteplugins-webhooks-core`), which is this PR's base — the diff here
is just this package.

Receive ClickUp webhooks in Flyte.

## What it implements

The `Provider` contract from core: which environment variable holds the
secret, how to verify a delivery, how to parse one into a
`WebhookEvent`, plus typed constants for every event ClickUp sends.

**Verification:** HMAC-SHA256 over the raw body (`X-Clickup-Signature`).

The list id sits at the top level on list-scoped events and on the
nested task for task-scoped ones; the parser reads both, or a `scopes`
allowlist cannot attribute task events at all.

## Conformance

Runs the shared `assert_provider_conforms`, which replays this plugin's
`SAMPLE_DELIVERY` — a real ClickUp payload — through `verify` and
`parse` rather than trusting them to agree with each other. It also
asserts the verifier returns False rather than raising on a hostile
header, that event constants render as wire values rather than enum
names, and that the sample parses to something the constants actually
spell.

5 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass.

## What it does not do

Call the ClickUp API. Use `httpx` — ClickUp ships no Python SDK and its
API is a handful of REST calls directly from your tasks — the recipes
are in the examples PR at the end of this stack. This plugin owns only
the part that is Flyte's: authenticating an inbound delivery and turning
it into a run.

---------

Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy added a commit that referenced this pull request Sep 2, 2026
Part of the webhook plugin stack. Requires #1512
(`flyteplugins-webhooks-core`), which is this PR's base — the diff here
is just this package.

Receive Linear webhooks in Flyte.

## What it implements

The `Provider` contract from core: which environment variable holds the
secret, how to verify a delivery, how to parse one into a
`WebhookEvent`, plus typed constants for every event Linear sends.

**Verification:** HMAC-SHA256 over the raw body (`X-Linear-Signature`).

The dedupe key folds in the entity's `updatedAt`. Keyed on the entity
alone, every `Issue.update` after the first would collapse onto one key
and never launch. Comment and reaction payloads carry the team id only
on the nested issue, which the parser follows so a `scopes` allowlist
can still attribute them.

## Conformance

Runs the shared `assert_provider_conforms`, which replays this plugin's
`SAMPLE_DELIVERY` — a real Linear payload — through `verify` and `parse`
rather than trusting them to agree with each other. It also asserts the
verifier returns False rather than raising on a hostile header, that
event constants render as wire values rather than enum names, and that
the sample parses to something the constants actually spell.

5 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass.

## What it does not do

Call the Linear API. Use `gql` — Linear ships no Python SDK and its API
is a single GraphQL endpoint directly from your tasks — the recipes are
in the examples PR at the end of this stack. This plugin owns only the
part that is Flyte's: authenticating an inbound delivery and turning it
into a run.

---------

Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy added a commit that referenced this pull request Sep 2, 2026
Part of the webhook plugin stack. Requires #1512
(`flyteplugins-webhooks-core`), which is this PR's base — the diff here
is just this package.

Receive Slack webhooks in Flyte.

## What it implements

The `Provider` contract from core: which environment variable holds the
secret, how to verify a delivery, how to parse one into a
`WebhookEvent`, plus typed constants for every event Slack sends.

**Verification:** HMAC-SHA256 over `v0:{timestamp}:{body}`, with a
five-minute replay window.

Echoes the `url_verification` challenge, so Slack's Request URL field
verifies itself.

The signature covers the **raw bytes**. Decoding the body and
re-encoding it would corrupt any byte Slack signed but Python cannot
decode, and running the timestamp through `int()` would drop whatever
formatting Slack signed — a test pins both.

## Conformance

Runs the shared `assert_provider_conforms`, which replays this plugin's
`SAMPLE_DELIVERY` — a real Slack payload — through `verify` and `parse`
rather than trusting them to agree with each other. It also asserts the
verifier returns False rather than raising on a hostile header, that
event constants render as wire values rather than enum names, and that
the sample parses to something the constants actually spell.

6 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass.

## What it does not do

Call the Slack API. Use `slack_sdk` directly from your tasks — the
recipes are in the examples PR at the end of this stack. This plugin
owns only the part that is Flyte's: authenticating an inbound delivery
and turning it into a run.

---------

Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy and others added 7 commits September 1, 2026 22:38
)

Part of the webhook plugin stack. Requires #1512
(`flyteplugins-webhooks-core`), which is this PR's base — the diff here
is just this package.

Receive GitHub webhooks in Flyte.

## What it implements

The `Provider` contract from core: which environment variable holds the
secret, how to verify a delivery, how to parse one into a
`WebhookEvent`, plus typed constants for every event GitHub sends.

**Verification:** HMAC-SHA256 over the raw body (`X-Hub-Signature-256`).

Answers GitHub's `ping` automatically, so a green check in *Recent
Deliveries* means the app is reachable.

Comment and review events fold the comment id into `resource_id`, so two
comments on one issue are two events rather than a redelivery of the
first — the bug that prompted this whole restructure.

## Conformance

Runs the shared `assert_provider_conforms`, which replays this plugin's
`SAMPLE_DELIVERY` — a real GitHub payload — through `verify` and `parse`
rather than trusting them to agree with each other. It also asserts the
verifier returns False rather than raising on a hostile header, that
event constants render as wire values rather than enum names, and that
the sample parses to something the constants actually spell.

7 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass.

## What it does not do

Call the GitHub API. Use `PyGithub` directly from your tasks — the
recipes are in the examples PR at the end of this stack. This plugin
owns only the part that is Flyte's: authenticating an inbound delivery
and turning it into a run.

---------

Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
default_secret_env is now a class property on the provider, and
WebhookAppEnvironment mounts it. Wiring up an app no longer repeats the
environment variable the provider already knows:

    app_env = WebhookAppEnvironment(
        name="acme-webhooks",
        providers=[AcmeProvider()],
        image=...,
    )

Anything the caller does declare wins, which is how you point a provider at a
secret stored under a different key:

    secrets=[flyte.Secret("my-gh-key", as_env_var="GITHUB_WEBHOOK_SECRET")]

Detection matches on the environment variable a secret actually lands as, not
on its key, so `secrets=["GITHUB_WEBHOOK_SECRET"]` and an explicit as_env_var
both count as declared and neither produces a duplicate mount. Unrelated
secrets are kept alongside.

The module-level DEFAULT_SECRET_ENV constants are gone; the value lives on the
class that owns it, and instances still take a secret_env override.

Conformance now requires a plugin to declare default_secret_env and to default
to it, because the app mounting the value means a plugin that omits it hands
users an app with no secret and no error -- verified to fail loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk
Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
Anything else a launched run needs -- queue, env_vars, interruptible,
service_account, notifications -- now reaches with_runcontext:

    await idempotent_run.aio(
        task,
        key=event.dedupe_key(),
        runcontext_kwargs={"queue": "webhooks", "labels": {"team": "platform"}},
    )

Labels merge with the dedupe label rather than replacing it, so extra labels
are fine. Setting dedupe yourself is refused: honouring it silently would be
indistinguishable from turning idempotency off, and the error points at the
key= argument instead. Passing copy_style both directly and in the dict is
refused for the same reason -- one of them would have to lose quietly.

The caller's dict is copied, since a handler is likely to reuse one across
events.

This also removes the branch that existed only because with_runcontext takes
copy_style as a literal rather than an Optional, so None could not be passed
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk
Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
The name promised idempotency the function does not provide. An idempotent
operation returns the same result when repeated -- HTTP PUT, or a Stripe
idempotency key replaying the original response. This one raises DuplicateRun
instead, which is a uniqueness constraint, not idempotency. Nine call sites
wrap it in try/except; a genuinely idempotent call would need no branch.

It was also the only symbol outside the module's own vocabulary, which is
otherwise consistent: the label is dedupe, the event method is dedupe_key(),
the error is DuplicateRun.

run_once reads correctly at the call site and sits next to flyte.run:

    try:
        run = await run_once.aio(task, key=event.dedupe_key())
    except DuplicateRun as exc:
        return {"skipped": exc.url}

Behavior is unchanged. Raising is kept deliberately: the duplicate case is
meaningful to a webhook handler, which wants to answer "already handled" with
a link. Returning the existing run instead would make callers compare run
identities to notice. The module docstring now says so.

Prose keeps the word idempotent where it describes the system property --
delivering an event twice yields one run -- and drops it where it described
the function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk
Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
A duplicate delivery no longer raises. run_once returns the run that covers
the key either way, paired with a flag saying whether this call launched it:

    result = await run_once.aio(task, key=event.dedupe_key())
    if not result.created:
        return {"skipped": result.run.name, "url": result.run.url}
    return {"run": result.run.name}

RunOnceResult is a NamedTuple, so it also unpacks as run, created = ... . A
caller that does not care which happened can use result.run unconditionally,
which the exception did not allow -- the old contract forced every call site
through try/except just to reach the run it already knew about.

The flag exists because dropping the signal entirely would cost the handlers
something real: each one answers "already handled" and links to the run, and
without a flag they cannot tell a fresh launch from a found one. Run phase
cannot substitute -- a just-launched run and an existing one both report a
live phase.

DuplicateRun is removed rather than kept unraised. All 13 call sites across
the plugins, examples and docs move to the flag check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk
Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
@cosmicBboy
cosmicBboy merged commit 34f44f9 into main Sep 3, 2026
63 checks passed
@cosmicBboy
cosmicBboy deleted the nielsb/webhooks-core branch September 3, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant