Skip to content

[SK-2311] feat(actions): verify and parse AgentKit trigger events - #222

Open
ravibits wants to merge 10 commits into
mainfrom
fix/sdk-method/triggers-receive-events
Open

ravibits wants to merge 10 commits into
mainfrom
fix/sdk-method/triggers-receive-events

Conversation

@ravibits

@ravibits ravibits commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Description

AgentKit triggers deliver events to your endpoint as signed HTTP requests (webhook-id, webhook-timestamp, webhook-signature headers, the same scheme as other Scalekit webhooks). This PR adds a helper that verifies a delivery and returns it as a typed event:

# standalone, needs no client
def verify_trigger_event(body: bytes | str, /, *, headers: HeadersLike, secret: str) -> TriggerEvent

# same contract on the client
client.actions.triggers.verify_event(body, /, *, headers, secret) -> TriggerEvent

What it checks, in order:

  • Argument types: wrong types raise TypeError.
  • Headers: names are matched case-insensitively, and repeated headers are allowed. Each webhook-signature value is a candidate. webhook-id and webhook-timestamp may repeat only with the same value. A missing header raises "Missing required headers".
  • Secret: the key after whsec_ must be non-empty, padded standard base64. An empty or malformed key raises "Invalid secret", so a misconfigured secret can't verify a forged event.
  • Timestamp: must be digits only and within 5 minutes.
  • Body encoding: the body must be valid UTF-8 with no byte-order mark.
  • Signature: v1 HMAC-SHA256 over id.timestamp.body, compared in constant time. Malformed candidates are skipped.
  • Event: the body is parsed into TriggerEvent.

Errors:

  • Every verification failure raises WebhookVerificationError.
  • A correctly signed body that is not a valid trigger event raises the new subclass ScalekitTriggerEventParseException, so existing except WebhookVerificationError handlers catch it.

TriggerEvent:

  • An immutable pydantic model with typed fields: dedupe_key, delivery_scope, occurred_at (timezone-aware UTC, fractional seconds truncated to microseconds), resource_id, the connected account and connection, and payload.
  • DeliveryScope, DetectionMode and PayloadState are open enums: unknown values from newer servers are kept, not rejected.
  • Unknown fields are kept on the model.

Shared code: signature checking moved into scalekit/common/_webhook_signature.py. verify_webhook_payload and verify_interceptor_payload behave exactly as before: the stricter rules apply only to trigger events, and regression tests pin the old behaviour.

Usage

import os
from flask import Flask, request
from scalekit import verify_trigger_event
from scalekit.common.exceptions import WebhookVerificationError

app = Flask(__name__)

@app.post("/triggers")
def triggers():
    try:  # pass the raw body, before any JSON parsing
        event = verify_trigger_event(request.get_data(), headers=request.headers, secret=os.environ["SCALEKIT_TRIGGER_SECRET"])
    except WebhookVerificationError:  # bad signature, stale timestamp or malformed event
        return "", 400
    enqueue(event)  # your handler: deduplicate on event.dedupe_key + the connected account
    return "", 204

Testing

  • make lint: passes. mypy --strict reports no errors in the new modules, and ruff check / ruff format --check are clean at line length 100.
  • python -m unittest tests.test_trigger_events: 91 tests pass. They cover:
    • fixtures shared with the Node SDK (tests/fixtures/trigger_events/)
    • signature, timestamp, header, secret, encoding and occurred_at edge cases
    • argument type errors
    • real Werkzeug headers
    • type-checking the documented snippets with mypy
    • the unchanged behaviour of the legacy verifiers
  • The offline suites pass: call timeout, retry behaviour, and the Django, FastAPI and Flask middleware, session crypto and session manager suites.
  • The fragment's Flask snippet was run against signed requests: 204 for a valid event, and 400 for a bad signature, a stale timestamp and a malformed body.
  • Live integration tests were not run locally. This change makes no network calls.
  • release.py check and release.py verify --labels skip-release: pass.

Release

No release (skip-release). The notes wait in .changes/unreleased:

scalekit.verify_trigger_event(body, headers=..., secret=...) and client.actions.triggers.verify_event(...) verify the signature of a trigger event delivered to your endpoint and return it as a typed TriggerEvent; the standalone function needs no client. Any verification failure raises WebhookVerificationError, and a correctly signed body that is not a valid trigger event raises the new subclass ScalekitTriggerEventParseException. Delivery is at least once, so deduplicate on dedupe_key plus the connected account.

Scope

  • Sibling PR: scalekit-inc/scalekit-sdk-node, verifyTriggerEvent. Both SDKs behave the same on the same inputs.
  • Compatibility: additive. The new functions, model, enums and exception subclass are new. The legacy webhook verifiers keep their signatures, behaviour and error messages.
  • Not done: wrappers for the trigger management APIs (listing and subscribing) are a separate change.

🤖 Generated with Claude Code


Generated by Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by CodeRabbit

  • New Features
    • Added standalone and client-based trigger-event verification, returning typed event data after signature and timestamp checks.
    • Verification failures are distinguished from invalid event bodies that pass signature verification.
    • Added public access to trigger-event types and verification helpers, including delivery and detection details, timestamps, and payloads. Unknown enum values are preserved.
  • Documentation
    • Added guides and examples for verification, delivery scopes, redelivery handling, and deduplication using the deduplication key and connected account.

claude added 9 commits October 9, 2026 16:58
Add scalekit.verify_trigger_event(body, /, *, headers, secret) and
client.actions.triggers.verify_event(...). Both check the webhook
signature first (whsec_ secret, v1 HMAC-SHA256, five-minute window,
case-insensitive header names, strict UTF-8), then parse the body into
an immutable TriggerEvent model with DeliveryScope, DetectionMode and
PayloadState enums that pass unknown values through as str.

A correctly signed but malformed event raises the new
ScalekitTriggerEventParseException, a WebhookVerificationError subclass.

The existing signature logic moves into a private shared module;
verify_webhook_payload and verify_interceptor_payload keep their
behaviour, covered by new regression tests. Shared JSON test vectors
live in tests/fixtures/trigger_events/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify_trigger_event and actions.triggers.verify_event now type
`headers` as HeadersLike, a small protocol (get + items) that dict and
the header objects of Flask/Werkzeug, Django, Starlette/FastAPI and
aiohttp satisfy, so the documented Flask example type-checks.

TriggerEvent.payload keeps the value json.loads produced without
re-validating it, so payloads of any nesting depth parse; nesting too
deep for the JSON decoder raises ScalekitTriggerEventParseException
instead of RecursionError. correlation_id is documented as linking
follow-up work back to the event.

Tests cover real Werkzeug, Starlette and Django header objects, a mypy
check of the documented snippet (skipped when mypy is not installed),
1000-level payloads, and that patching
scalekit.client.webhook_tolerance_in_seconds still changes the window
of the existing verifiers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the trigger event path, a webhook-signature candidate with no comma,
another version, invalid base64 or the wrong decoded length is now
skipped, so a later valid candidate still verifies; a header with no
matching candidate raises WebhookVerificationError("Invalid
signature"). verify_webhook_payload and verify_interceptor_payload keep
their current behaviour, covered by regression tests.

TriggerEvent.payload is typed Annotated[JsonValue, SkipValidation]:
precise for type checkers, still not re-validated at runtime. The
deep-payload tests use 500 levels, which the stdlib JSON decoder
accepts on every supported Python; the docstring notes that Python
3.10/3.11 reject about 1000 levels. The documented example picks the
payload with one conditional expression so it type-checks whatever the
fetch helper returns. HeadersLike docs list only tested frameworks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
base64.b64decode(validate=True) raises a plain ValueError (not
binascii.Error) for non-ASCII text, so a candidate such as "v1,éééé"
rejected the whole header instead of being skipped. The trigger path
now skips any candidate that fails to decode; the existing webhook
verifiers are unchanged.

The documented-snippet type check now sets MYPYPATH to the checkout and
asks mypy for absolute paths, so it checks this checkout and keeps its
errors whatever the working directory or installed package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the trigger path only (verify_webhook_payload and
verify_interceptor_payload are unchanged):

- Collect every matching header value, case-insensitively. All
  webhook-signature values are signature candidates. A repeated
  webhook-id or webhook-timestamp must carry the same value (values
  joined with "," are split first); different values raise
  "Multiple <name> headers with different values".
- Require webhook-timestamp to be ASCII digits only. Any other value,
  including an empty one, raises "Invalid Signature Headers". Header
  values are not trimmed.
- Reject a body that starts with a UTF-8 byte order mark as a parse
  error.
- occurred_at: reject offset minutes of 60 or more and years outside
  0001-9999, also after normalising to UTC. Document that fractional
  seconds are truncated to microseconds.
- Document the positional-only body in the verify_event signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the trigger path only (verify_webhook_payload and
verify_interceptor_payload are unchanged):

- The key after the first "_" of the secret must be non-empty, padded
  standard base64 that decodes to at least one byte; anything else
  raises "Invalid secret". Before, secrets such as "whsec_",
  "whsec_!!!!" or "whsec_====" decoded to an empty HMAC key, so an
  event signed with an empty key verified. A suffix after a second "_"
  is no longer ignored. The secret is checked after the required
  headers and before the timestamp format.
- verify_trigger_event raises TypeError when headers has no callable
  items() and get(), instead of an AttributeError.
- REFERENCE.md example branches on both delivery scopes and on unknown
  scopes.
- Tests pin the ",[ \t]*" separator for repeated header values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the trigger path, check the argument types and read the signature
headers (which raise TypeError for non-str values) before decoding the
body, so a wrong argument type raises TypeError even when the body is
not valid UTF-8. The body is still decoded before the signature check.

Document that TypeError is raised for arguments of the wrong type, and
that WebhookVerificationError covers verification failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c5a92b5b-c8e4-49aa-86de-ac8c1a4cbea0

📥 Commits

Reviewing files that changed from the base of the PR and between 525cf33 and 42b5b72.


📒 Files selected for processing (1)
  • REFERENCE.md

🚧 Files skipped from review as they are similar to previous changes (1)
  • REFERENCE.md

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.



Walkthrough

The SDK adds typed trigger-event models and standalone and client-based signature verification. It adds shared webhook-signature helpers used by existing verification methods, exports the trigger API, and documents event handling and delivery semantics.

Changes

Trigger Event Verification

Layer / File(s) Summary
Shared webhook signature verification
scalekit/common/_webhook_signature.py, scalekit/client.py, tests/test_trigger_events.py
Shared helpers verify signatures and timestamps. Existing client methods delegate to these helpers. Regression tests cover legacy verifier behavior.
Trigger event model and parsing contract
scalekit/actions/models/trigger_event.py, scalekit/common/exceptions.py, tests/fixtures/trigger_events/*, tests/test_trigger_events.py
Adds immutable TriggerEvent and string enums, with validation for enum values and timestamps. Adds a parse exception and tests for valid, invalid, and deeply nested payloads.
Trigger event verification flow
scalekit/actions/triggers.py, tests/test_trigger_events.py
Adds header handling and standalone signature verification. The helper verifies the signature before parsing the body. ActionTriggers.verify_event delegates to it.
Client access, exports, and usage documentation
scalekit/actions/actions.py, scalekit/actions/__init__.py, scalekit/__init__.py, AGENTKIT.md, REFERENCE.md, .changes/unreleased/*, tests/test_trigger_events.py
Adds the cached ActionClient.triggers property and public exports. Documentation and the changelog describe verification, event handling, and delivery semantics.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant FlaskEndpoint
  participant ActionTriggers
  participant verify_trigger_event
  participant verify_payload_signature
  participant TriggerEvent
  FlaskEndpoint->>ActionTriggers: verify_event(body, headers, secret)
  ActionTriggers->>verify_trigger_event: delegate verification
  verify_trigger_event->>verify_payload_signature: verify headers, timestamp, and body signature
  verify_payload_signature-->>verify_trigger_event: signature verified
  verify_trigger_event->>TriggerEvent: validate parsed JSON object
  TriggerEvent-->>FlaskEndpoint: return typed event
Loading

Merge Risk: ⚪ Minimal · up to 42b5b

The connected-account wording does not present an identified merge risk; the change is mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 525cf

The new verifier checks signatures before parsing events, and no introduced authentication bypass was identified. Risk remains low rather than minimal because production request handling, signing-secret scope, and downstream authorization and deduplication were not available for verification.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Possession of a configured signing key permits authenticated event claims for consumers trusting that key. The verifier itself grants no service credentials or performs actions, but it does not constrain signed account, connection, or subscription IDs against external ownership state. The number of tenants or assets sharing a key is unknown.

Security Findings and Attack Paths

  • observed — Legacy webhook verification retains lenient secret decoding, including configurations that decode to an empty HMAC key. This behavior is present at the PR base and remains confined to legacy verification defaults; the new trigger path rejects it. No introduced or worsened exposure was established.

Trust Boundaries and Controls

  • observed — The SDK boundary authenticates supplied body and header values before returning typed event data. Secret selection, original-request provenance, tenant binding, and authorization of subsequent resource fetches or actions are not implemented by this API and remain unresolved for production integrations.

Resilience and Maintainability Implications

  • observed — The documented consumer checks whether an event was processed, performs its action, then marks it processed. The example provides no atomicity or recovery mechanism, so it does not establish protection against concurrent delivery or interruption after an action. Those guarantees depend on the application's helper implementations.

Hardening Proposals

  • proposed — Integrating applications should bind signing-secret selection to the receiving deployment and authorized account or connection scope, reject unsupported scope semantics, and use crash-safe, account-scoped idempotency for privileged effects. These are integration safeguards, not verified vulnerabilities in this PR.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 14.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 145 functions across 9 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: adding verification and parsing for AgentKit trigger events.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

Full details: Docstring Coverage

Explanation

Docstring coverage is 14.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 145 functions across 9 files. (1 skipped: 1 unsupported.)


  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

I’m a rabbit with a webhook to check,
I verify each signature, then inspect.
A typed event hops into view,
With UTC time and payload too.
I save the dedupe key with care,
Then bound away through fields of JSON there.

Comment @coderabbitai help to get the list of available commands.

@ravibits ravibits added the skip-release label Oct 9, 2026 — with Claude

@saif-at-scalekit saif-at-scalekit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried verifying a signed AgentKit trigger in Python and branching on who it is for (one connected account vs the whole connection).

After this ships, a Flask (or FastAPI) handler can trust the signature and get a typed TriggerEvent without standing up a client.

Requests

  1. In the REFERENCE usage sample, label the account-scope branch “connected account” — not bare “account” — so it matches connected_account_id and the AGENTKIT walk. (REFERENCE.md Trigger Events usage)

Sent by pocket agent saif-waiting-review

…ger sample

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor Author

@saif-at-scalekit Done in 42b5b72. The account-scope branch in the REFERENCE.md usage sample now prints "one connected account:", which matches connected_account_id and AGENTKIT.md.


Generated by Claude Code

@saif-at-scalekit saif-at-scalekit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked your update against the earlier requests.

  1. Label the account-scope branch “connected account” — not bare “account” — in the REFERENCE usage sample — done

All 1 look done.

Sent by pocket agent saif-waiting-review

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants