Skip to content

python(feat): webhooks resource - #789

Open
Brandon-Shippy wants to merge 8 commits into
mainfrom
python/webhooks-resource
Open

Brandon-Shippy wants to merge 8 commits into
mainfrom
python/webhooks-resource

Conversation

@Brandon-Shippy

@Brandon-Shippy Brandon-Shippy commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a webhooks resource to sift_client, and lets a rule call a webhook when it is violated.

Webhook and all associated classes and enums are made.
Implements get, list_, find, create, update, archive/unarchive, send_test_request

Validation

  • End to End testing with manual script written to test each new API feature.
  • Full pipeline tested against a local stack: created a webhook and a rule with a webhook action, streamed points breaking the rule, and confirmed the delivery in the receiving server

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Python docs preview: https://sift-stack.github.io/sift/python/pr-789/

Deployed from 21fdb14. The link may take up to a minute to become live as GitHub Pages propagates.

@Brandon-Shippy
Brandon-Shippy marked this pull request as ready for review September 16, 2026 17:37
@Brandon-Shippy
Brandon-Shippy requested review from alexluck-sift, solidiquis and wei-qlu and removed request for wei-qlu September 16, 2026 17:37
return cls(int(val))


class RuleAction(BaseType[RuleActionProto, "RuleAction"]):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a pretty inconvenient type to work with and doesn't follow the user-friendly patterns we do with other types. That is using the to_proto to convert to the proto friendly Ids. Can we address this as par tof this PR instead of carrying the tech debt forward?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@@ -0,0 +1,62 @@
"""References to other Sift resources.

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.

Ref[T] is just str | T with a new name to look up. Let's write the union inline like the other models do and drop this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i removed it union is now inline

return cls(action_type=RuleActionType.WEBHOOK, webhook=webhook)

@classmethod
def for_annotation(

@wei-qlu wei-qlu Sep 20, 2026

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.

Renaming RuleAction.annotation breaks existing callers. Let's keep it as is and name the new constructor RuleAction.webhook

in general, renaming existing fields is not backwards compatible and can cause unintentional/breaking changes (e.g., rule.action.tags now returns something else

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I kept RuleAction.annotation, tags_ids, default_assignee_user, and the tags property

Comment thread python/CHANGELOG.md Outdated

#### Webhooks API

New `client.webhooks` resource, and `RuleAction.for_webhook(...)` to attach a webhook to a rule.

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.

make sure the changelog is updated to reflect any changes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added logs for user-visible changes

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.

pre-existing, but lets allow asset objects here too (list[str | Asset]) so callers don't need asset._id_or_error

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

asset_ids now accepts list[str | Asset]

@Brandon-Shippy
Brandon-Shippy force-pushed the python/webhooks-resource branch from 2baa0a6 to cecc9b0 Compare September 21, 2026 16:00
@Brandon-Shippy
Brandon-Shippy force-pushed the python/webhooks-resource branch from cecc9b0 to a6ddb67 Compare September 21, 2026 16:04

@alexluck-sift alexluck-sift left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Audit pass on the webhooks surface. Nine comments, five of them nits. The first one is the only thing I'd call blocking, and it's a reviewer conflict rather than anything you did wrong.

annotation_type: RuleAnnotationType | None = None
tags_ids: list[str] | None = None
default_assignee_user: str | None = None
webhook_id: str | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RuleAction.webhook(wh) accepts an object, but the field it writes only takes a string, so anyone building a RuleAction directly is back to webhook._id_or_error. That's the thing my rule.py:277 thread asked to remove, and asset_ids gained list[str | Asset] 113 lines up in this same commit.

I'd take str | Webhook here and resolve it in _to_update_request. Worth settling with @wei-qlu first, since dropping the Ref alias was right but it took this with it. Same applies to tags_ids and default_assignee_user above, which are pre-existing so out of scope for this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

38c50f7

RuleAction.webhook_id now just takes str | Webhook

http_headers: list[WebhookHttpHeader]
created_date: datetime
modified_date: datetime
created_by_user_id: str

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

created_by_user_id, modified_by_user_id and organization_id have no accessor, so finding out who made a webhook is a second call the caller assembles. Rule.assets and Channel.asset are the shape: an ID field plus a property that resolves it.

Add created_by and modified_by backed by client.users. Heads up that the seven existing created_by/organization properties in sift_types/ all raise NotImplementedError, written before the Users resource existed, so they look like precedent and aren't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added Webhook.created_by and modified_by 38c50f7

webhook = await self._low_level_client.get_webhook(webhook_id=webhook_id)
return self._apply_client_to_instance(webhook)

async def list_(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No date ranges and no created_by/modified_by, so "webhooks added last week" isn't expressible. ListWebhooksRequest only filters on webhook_id, name, event_type, is_archived and archived_date, and CONTRIBUTING says a proto that can't serve the standard filter set should be changed rather than worked around.

Add created_date, modified_date, created_by_user_id and modified_by_user_id to the ListWebhooks filter fields, then expose the standard arguments here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ListWebhooks only supports webhook_id, name, event_type, is_archived, archived_date. Cannot filter this currently. I will make a ticket to fix this

grpc_webhook = cast("CreateWebhookResponse", response).webhook
return Webhook._from_proto(grpc_webhook)

async def update_webhook(self, update: WebhookUpdate) -> Webhook:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two concurrent webhooks.update calls each send a full Webhook here, so the second one reverts the first one's field. This is the only MergeMessage in low_level_wrappers/, and the proto declares a sparse masked patch with seven maskable fields named.

I'd send the patch and mask straight through. If the service genuinely rejects a sparse Webhook, file that as a server bug and link it from here, because the workaround costs a round trip and loses concurrent writes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Server-side bug. Making a ticket and will triage. UpdateWebhook cannot accept a partial webhook. So, it reads the webhook before writing the update. Concurrent webhooks will overwrite each other.

"""
return await self.update(webhook, WebhookUpdate(is_archived=False))

async def test(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: this sends a real HTTP request to a customer endpoint, and test reads like the opposite to anyone scanning autocomplete, on top of colliding with test-suite vocabulary. webhook is also optional and positional, which fixes the parameter position permanently.

I'd go with send_test_request and move webhook behind the *. The rename touches sift_types/webhook.py and the sync stubs, so it needs doing by hand.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

renamed test to send_test_request 38c50f7

class WebhookEventType(Enum):
"""Enum for the events that trigger a webhook."""

UNSPECIFIED = WebhookEventTypeProto.WEBHOOK_EVENT_TYPE_UNSPECIFIED # 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: WEBHOOK_EVENT_TYPE_UNSPECIFIED is the proto's zero value and never something a caller should pass. No other enum in sift_types/ exposes its unspecified member.

Drop it and map an unset event_type to None in _from_proto.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

dropped and maps properly now 38c50f7

"""

http_response_code: int
http_response_body: bytes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: bytes is the right call for the field, since the body comes from an arbitrary endpoint and may not be text at all, but it leaves every caller writing the same decode.

Add a text property returning self.http_response_body.decode(errors="replace"), matching AnnotationCommentElement.text in #792.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added WebhookTestResult.text 38c50f7

return cls(name=proto.name, value=proto.value)


class WebhookTestResult(BaseModel):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: this is the library's first struct returned from a method, and #793 is adding a second one the same week with a different suffix, UserDefinedFunctionValidation.

No strong view on which wins, but the two PRs should agree. If *Result stays here, #793 wants UserDefinedFunctionValidationResult.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

dc8d34e

Decided on dropping the suffix

return value

@model_validator(mode="after")
def _validate_target_url(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: this validator sits on WebhookBase, which doesn't declare target_url, so it reads through getattr(self, "target_url", None). Rename the field on either subclass and validation silently passes everything, with a bad URL only failing at the server.

Declare target_url: str | None = None on the base so the attribute access is checked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the URL check is field_validator on each subclass

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants