Coverage for polar/webhook/webhooks.py: 44%
372 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
1import inspect 1a
2import json 1a
3import typing 1a
4from datetime import datetime 1a
5from inspect import Parameter, Signature 1a
6from typing import Annotated, Any, Literal, assert_never, get_args, get_origin 1a
8from babel.dates import format_date 1a
9from fastapi import FastAPI 1a
10from fastapi.routing import APIRoute 1a
11from makefun import with_signature 1a
12from pydantic import ( 1a
13 Discriminator,
14 GetJsonSchemaHandler,
15 TypeAdapter,
16)
17from pydantic.json_schema import JsonSchemaValue 1a
18from pydantic_core import core_schema as cs 1a
20from polar.benefit.schemas import Benefit as BenefitSchema 1a
21from polar.benefit.schemas import BenefitGrantWebhook 1a
22from polar.checkout.schemas import Checkout as CheckoutSchema 1a
23from polar.customer.schemas.customer import Customer as CustomerSchema 1a
24from polar.customer.schemas.state import CustomerState as CustomerStateSchema 1a
25from polar.customer_seat.schemas import CustomerSeat as CustomerSeatSchema 1a
26from polar.exceptions import PolarError 1a
27from polar.integrations.discord.webhook import ( 1a
28 DiscordEmbedField,
29 DiscordPayload,
30 get_branded_discord_embed,
31)
32from polar.kit.schemas import IDSchema, Schema 1a
33from polar.models import ( 1a
34 Benefit,
35 BenefitGrant,
36 Checkout,
37 Customer,
38 CustomerSeat,
39 Order,
40 Organization,
41 Product,
42 Refund,
43 Subscription,
44 User,
45)
46from polar.models.order import OrderStatus 1a
47from polar.models.subscription import SubscriptionStatus 1a
48from polar.models.webhook_endpoint import WebhookEventType, WebhookFormat 1a
49from polar.order.schemas import Order as OrderSchema 1a
50from polar.organization.schemas import Organization as OrganizationSchema 1a
51from polar.product.schemas import Product as ProductSchema 1a
52from polar.refund.schemas import Refund as RefundSchema 1a
53from polar.subscription.schemas import Subscription as SubscriptionSchema 1a
55from .slack import SlackPayload, SlackText, get_branded_slack_payload 1a
57WebhookTypeObject = ( 1a
58 tuple[Literal[WebhookEventType.checkout_created], Checkout]
59 | tuple[Literal[WebhookEventType.checkout_updated], Checkout]
60 | tuple[Literal[WebhookEventType.customer_created], Customer]
61 | tuple[Literal[WebhookEventType.customer_updated], Customer]
62 | tuple[Literal[WebhookEventType.customer_deleted], Customer]
63 | tuple[Literal[WebhookEventType.customer_state_changed], CustomerStateSchema]
64 | tuple[Literal[WebhookEventType.customer_seat_assigned], CustomerSeat]
65 | tuple[Literal[WebhookEventType.customer_seat_claimed], CustomerSeat]
66 | tuple[Literal[WebhookEventType.customer_seat_revoked], CustomerSeat]
67 | tuple[Literal[WebhookEventType.order_created], Order]
68 | tuple[Literal[WebhookEventType.order_updated], Order]
69 | tuple[Literal[WebhookEventType.order_paid], Order]
70 | tuple[Literal[WebhookEventType.order_refunded], Order]
71 | tuple[Literal[WebhookEventType.subscription_created], Subscription]
72 | tuple[Literal[WebhookEventType.subscription_updated], Subscription]
73 | tuple[Literal[WebhookEventType.subscription_active], Subscription]
74 | tuple[Literal[WebhookEventType.subscription_canceled], Subscription]
75 | tuple[Literal[WebhookEventType.subscription_revoked], Subscription]
76 | tuple[Literal[WebhookEventType.subscription_uncanceled], Subscription]
77 | tuple[Literal[WebhookEventType.refund_created], Refund]
78 | tuple[Literal[WebhookEventType.refund_updated], Refund]
79 | tuple[Literal[WebhookEventType.product_created], Product]
80 | tuple[Literal[WebhookEventType.product_updated], Product]
81 | tuple[Literal[WebhookEventType.organization_updated], Organization]
82 | tuple[Literal[WebhookEventType.benefit_created], Benefit]
83 | tuple[Literal[WebhookEventType.benefit_updated], Benefit]
84 | tuple[Literal[WebhookEventType.benefit_grant_created], BenefitGrant]
85 | tuple[Literal[WebhookEventType.benefit_grant_updated], BenefitGrant]
86 | tuple[Literal[WebhookEventType.benefit_grant_cycled], BenefitGrant]
87 | tuple[Literal[WebhookEventType.benefit_grant_revoked], BenefitGrant]
88)
91class UnsupportedTarget(PolarError): 1a
92 def __init__( 1a
93 self,
94 target: User | Organization,
95 schema: type["BaseWebhookPayload"],
96 format: WebhookFormat,
97 ) -> None:
98 self.target = target
99 self.format = format
100 message = f"{schema.__name__} payload does not support target {type(target).__name__} for format {format}"
101 super().__init__(message)
104class SkipEvent(PolarError): 1a
105 def __init__(self, event: WebhookEventType, format: WebhookFormat) -> None: 1a
106 self.event = event
107 self.format = format
108 message = f"Skipping event {event} for format {format}"
109 super().__init__(message)
112class BaseWebhookPayload(Schema): 1a
113 type: WebhookEventType 1a
114 timestamp: datetime 1a
115 data: IDSchema 1a
117 def get_payload(self, format: WebhookFormat, target: User | Organization) -> str: 1a
118 match format:
119 case WebhookFormat.raw:
120 return self.get_raw_payload()
121 case WebhookFormat.discord:
122 return self.get_discord_payload(target)
123 case WebhookFormat.slack:
124 return self.get_slack_payload(target)
125 case _:
126 assert_never(format)
128 def get_raw_payload(self) -> str: 1a
129 return self.model_dump_json()
131 def get_discord_payload(self, target: User | Organization) -> str: 1a
132 # Generic Discord payload, override in subclasses for more specific payloads
133 fields: list[DiscordEmbedField] = [
134 {"name": "Object", "value": str(self.data.id)},
135 ]
136 if isinstance(target, User):
137 fields.append({"name": "User", "value": target.email})
138 elif isinstance(target, Organization):
139 fields.append({"name": "Organization", "value": target.name})
141 payload: DiscordPayload = {
142 "content": self.type,
143 "embeds": [
144 get_branded_discord_embed(
145 {
146 "title": self.type,
147 "description": self.type,
148 "fields": fields,
149 }
150 )
151 ],
152 }
154 return json.dumps(payload)
156 def get_slack_payload(self, target: User | Organization) -> str: 1a
157 # Generic Slack payload, override in subclasses for more specific payloads
158 fields: list[SlackText] = [
159 {"type": "mrkdwn", "text": f"*Object*\n{self.data.id}"},
160 ]
161 if isinstance(target, User):
162 fields.append({"type": "mrkdwn", "text": f"*User*\n{target.email}"})
163 elif isinstance(target, Organization):
164 fields.append({"type": "mrkdwn", "text": f"*Organization*\n{target.name}"})
166 payload: SlackPayload = get_branded_slack_payload(
167 {
168 "text": self.type,
169 "blocks": [
170 {
171 "type": "section",
172 "text": {
173 "type": "mrkdwn",
174 "text": self.type,
175 },
176 "fields": fields,
177 }
178 ],
179 }
180 )
182 return json.dumps(payload)
184 @classmethod 1a
185 def __get_pydantic_json_schema__( 1a
186 cls, core_schema: cs.CoreSchema, handler: GetJsonSchemaHandler
187 ) -> JsonSchemaValue:
188 json_schema = handler(core_schema) 1c
189 json_schema = handler.resolve_ref_schema(json_schema) 1c
191 # Force the example of the `type` field to be the event type literal value
192 type_field_annotation = cls.model_fields["type"].annotation 1c
193 if get_origin(type_field_annotation) is Literal: 193 ↛ 197line 193 didn't jump to line 197 because the condition on line 193 was always true1c
194 literal_value = get_args(type_field_annotation)[0] 1c
195 json_schema["properties"]["type"]["examples"] = [literal_value] 1c
197 return json_schema 1c
200class WebhookCheckoutCreatedPayload(BaseWebhookPayload): 1a
201 """
202 Sent when a new checkout is created.
204 **Discord & Slack support:** Basic
205 """
207 type: Literal[WebhookEventType.checkout_created] 1a
208 data: CheckoutSchema 1a
211class WebhookCheckoutUpdatedPayload(BaseWebhookPayload): 1a
212 """
213 Sent when a checkout is updated.
215 **Discord & Slack support:** Basic
216 """
218 type: Literal[WebhookEventType.checkout_updated] 1a
219 data: CheckoutSchema 1a
222class WebhookCustomerCreatedPayload(BaseWebhookPayload): 1a
223 """
224 Sent when a new customer is created.
226 A customer can be created:
228 * After a successful checkout.
229 * Programmatically via the API.
231 **Discord & Slack support:** Basic
232 """
234 type: Literal[WebhookEventType.customer_created] 1a
235 data: CustomerSchema 1a
238class WebhookCustomerUpdatedPayload(BaseWebhookPayload): 1a
239 """
240 Sent when a customer is updated.
242 This event is fired when the customer details are updated.
244 If you want to be notified when a customer subscription or benefit state changes, you should listen to the `customer_state_changed` event.
246 **Discord & Slack support:** Basic
247 """
249 type: Literal[WebhookEventType.customer_updated] 1a
250 data: CustomerSchema 1a
253class WebhookCustomerDeletedPayload(BaseWebhookPayload): 1a
254 """
255 Sent when a customer is deleted.
257 **Discord & Slack support:** Basic
258 """
260 type: Literal[WebhookEventType.customer_deleted] 1a
261 data: CustomerSchema 1a
264class WebhookCustomerStateChangedPayload(BaseWebhookPayload): 1a
265 """
266 Sent when a customer state has changed.
268 It's triggered when:
270 * Customer is created, updated or deleted.
271 * A subscription is created or updated.
272 * A benefit is granted or revoked.
274 **Discord & Slack support:** Basic
275 """
277 type: Literal[WebhookEventType.customer_state_changed] 1a
278 data: CustomerStateSchema 1a
281class WebhookCustomerSeatAssignedPayload(BaseWebhookPayload): 1a
282 """
283 Sent when a new customer seat is assigned.
285 This event is triggered when a seat is assigned to a customer by the organization.
286 The customer will receive an invitation email to claim the seat.
288 """
290 type: Literal[WebhookEventType.customer_seat_assigned] 1a
291 data: CustomerSeatSchema # type: ignore[assignment] 1a
294class WebhookCustomerSeatClaimedPayload(BaseWebhookPayload): 1a
295 """
296 Sent when a customer seat is claimed.
298 This event is triggered when a customer accepts the seat invitation and claims their access.
300 """
302 type: Literal[WebhookEventType.customer_seat_claimed] 1a
303 data: CustomerSeatSchema # type: ignore[assignment] 1a
306class WebhookCustomerSeatRevokedPayload(BaseWebhookPayload): 1a
307 """
308 Sent when a customer seat is revoked.
310 This event is triggered when access to a seat is revoked, either manually by the organization or automatically when a subscription is canceled.
311 """
313 type: Literal[WebhookEventType.customer_seat_revoked] 1a
314 data: CustomerSeatSchema # type: ignore[assignment] 1a
317class WebhookOrderPayloadBase(BaseWebhookPayload): 1a
318 """
319 Base class for order payloads.
320 """
322 type: ( 1a
323 Literal[WebhookEventType.order_created]
324 | Literal[WebhookEventType.order_updated]
325 | Literal[WebhookEventType.order_paid]
326 )
327 data: OrderSchema 1a
329 def get_discord_payload(self, target: User | Organization) -> str: 1a
330 if isinstance(target, User):
331 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
333 amount_display = self.data.get_amount_display()
335 fields: list[DiscordEmbedField] = [
336 {"name": "Product", "value": self.data.description},
337 {"name": "Amount", "value": amount_display},
338 {"name": "Customer", "value": self.data.customer.email},
339 ]
340 if self.data.subscription is not None:
341 fields.append({"name": "Subscription", "value": "Yes"})
343 payload: DiscordPayload = {
344 "content": "New Order",
345 "embeds": [
346 get_branded_discord_embed(
347 {
348 "title": "New Order",
349 "description": f"New order has been made to {target.name}.",
350 "fields": fields,
351 }
352 )
353 ],
354 }
356 return json.dumps(payload)
358 def get_slack_payload(self, target: User | Organization) -> str: 1a
359 if isinstance(target, User):
360 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
362 amount_display = self.data.get_amount_display()
364 fields: list[SlackText] = [
365 {"type": "mrkdwn", "text": f"*Product*\n{self.data.description}"},
366 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"},
367 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.customer.email}"},
368 ]
369 if self.data.subscription is not None:
370 fields.append({"type": "mrkdwn", "text": "*Subscription*\nYes"})
372 payload: SlackPayload = get_branded_slack_payload(
373 {
374 "text": "New Order",
375 "blocks": [
376 {
377 "type": "section",
378 "text": {
379 "type": "mrkdwn",
380 "text": f"New order has been made to {target.name}.",
381 },
382 "fields": fields,
383 }
384 ],
385 }
386 )
388 return json.dumps(payload)
391class WebhookOrderCreatedPayload(WebhookOrderPayloadBase): 1a
392 """
393 Sent when a new order is created.
395 A new order is created when:
397 * A customer purchases a one-time product. In this case, `billing_reason` is set to `purchase`.
398 * A customer starts a subscription. In this case, `billing_reason` is set to `subscription_create`.
399 * A subscription is renewed. In this case, `billing_reason` is set to `subscription_cycle`.
400 * A subscription is upgraded or downgraded with an immediate proration invoice. In this case, `billing_reason` is set to `subscription_update`.
402 <Warning>The order might not be paid yet, so the `status` field might be `pending`.</Warning>
404 **Discord & Slack support:** Full
405 """
407 type: Literal[WebhookEventType.order_created] 1a
410class WebhookOrderUpdatedPayload(WebhookOrderPayloadBase): 1a
411 """
412 Sent when an order is updated.
414 An order is updated when:
416 * Its status changes, e.g. from `pending` to `paid`.
417 * It's refunded, partially or fully.
419 **Discord & Slack support:** Full
420 """
422 type: Literal[WebhookEventType.order_updated] 1a
425class WebhookOrderPaidPayload(WebhookOrderPayloadBase): 1a
426 """
427 Sent when an order is paid.
429 When you receive this event, the order is fully processed and payment has been received.
431 **Discord & Slack support:** Full
432 """
434 type: Literal[WebhookEventType.order_paid] 1a
437class WebhookOrderRefundedPayload(BaseWebhookPayload): 1a
438 """
439 Sent when an order is fully or partially refunded.
441 **Discord & Slack support:** Full
442 """
444 type: Literal[WebhookEventType.order_refunded] 1a
445 data: OrderSchema 1a
447 def get_discord_payload(self, target: User | Organization) -> str: 1a
448 if isinstance(target, User):
449 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
451 amount_display = self.data.get_refunded_amount_display()
453 fields: list[DiscordEmbedField] = [
454 {"name": "Product", "value": self.data.description},
455 {"name": "Refunded", "value": amount_display},
456 {"name": "Customer", "value": self.data.customer.email},
457 ]
458 if self.data.subscription is not None:
459 fields.append({"name": "Subscription", "value": "Yes"})
461 if self.data.status == OrderStatus.refunded:
462 fields.append({"name": "Fully Refunded", "value": "Yes"})
464 payload: DiscordPayload = {
465 "content": "Order Refunded",
466 "embeds": [
467 get_branded_discord_embed(
468 {
469 "title": "Order Refunded",
470 "description": f"Order refunded for {target.name}.",
471 "fields": fields,
472 }
473 )
474 ],
475 }
477 return json.dumps(payload)
479 def get_slack_payload(self, target: User | Organization) -> str: 1a
480 if isinstance(target, User):
481 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
483 amount_display = self.data.get_refunded_amount_display()
485 fields: list[SlackText] = [
486 {"type": "mrkdwn", "text": f"*Product*\n{self.data.description}"},
487 {"type": "mrkdwn", "text": f"*Refunded*\n{amount_display}"},
488 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.customer.email}"},
489 ]
490 if self.data.subscription is not None:
491 fields.append({"type": "mrkdwn", "text": "*Subscription*\nYes"})
493 if self.data.status == OrderStatus.refunded:
494 fields.append({"type": "mrkdwn", "text": "*Fully Refunded*\nYes"})
496 payload: SlackPayload = get_branded_slack_payload(
497 {
498 "text": "Order Refunded",
499 "blocks": [
500 {
501 "type": "section",
502 "text": {
503 "type": "mrkdwn",
504 "text": f"Order refunded for {target.name}.",
505 },
506 "fields": fields,
507 }
508 ],
509 }
510 )
512 return json.dumps(payload)
515class WebhookSubscriptionCreatedPayload(BaseWebhookPayload): 1a
516 """
517 Sent when a new subscription is created.
519 When this event occurs, the subscription `status` might not be `active` yet, as we can still have to wait for the first payment to be processed.
521 **Discord & Slack support:** Full
522 """
524 type: Literal[WebhookEventType.subscription_created] 1a
525 data: SubscriptionSchema 1a
527 def get_discord_payload(self, target: User | Organization) -> str: 1a
528 if isinstance(target, User):
529 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
531 amount_display = self.data.get_amount_display()
533 fields: list[DiscordEmbedField] = [
534 {"name": "Product", "value": self.data.product.name},
535 {"name": "Amount", "value": amount_display},
536 {"name": "Customer", "value": self.data.user.email},
537 ]
538 payload: DiscordPayload = {
539 "content": "New Subscription",
540 "embeds": [
541 get_branded_discord_embed(
542 {
543 "title": "New Subscription",
544 "description": f"New subscription has been made to {target.name}.",
545 "fields": fields,
546 }
547 )
548 ],
549 }
551 return json.dumps(payload)
553 def get_slack_payload(self, target: User | Organization) -> str: 1a
554 if isinstance(target, User):
555 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
557 amount_display = self.data.get_amount_display()
559 fields: list[SlackText] = [
560 {"type": "mrkdwn", "text": f"*Product*\n{self.data.product.name}"},
561 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"},
562 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.user.email}"},
563 ]
564 payload: SlackPayload = get_branded_slack_payload(
565 {
566 "text": "New Subscription",
567 "blocks": [
568 {
569 "type": "section",
570 "text": {
571 "type": "mrkdwn",
572 "text": f"New subscription has been made to {target.name}.",
573 },
574 "fields": fields,
575 }
576 ],
577 }
578 )
580 return json.dumps(payload)
583class WebhookSubscriptionUpdatedPayloadBase(BaseWebhookPayload): 1a
584 """
585 Base class for subscription updated payloads.
586 """
588 type: ( 1a
589 Literal[WebhookEventType.subscription_updated]
590 | Literal[WebhookEventType.subscription_active]
591 | Literal[WebhookEventType.subscription_canceled]
592 | Literal[WebhookEventType.subscription_uncanceled]
593 | Literal[WebhookEventType.subscription_revoked]
594 )
595 data: SubscriptionSchema 1a
597 def _get_active_discord_payload(self, target: User | Organization) -> str: 1a
598 fields = self._get_discord_fields(target)
599 payload: DiscordPayload = {
600 "content": "Subscription is now active.",
601 "embeds": [
602 get_branded_discord_embed(
603 {
604 "title": "Active Subscription",
605 "description": "Subscription is now active.",
606 "fields": fields,
607 }
608 )
609 ],
610 }
612 return json.dumps(payload)
614 def _get_active_slack_payload(self, target: User | Organization) -> str: 1a
615 fields = self._get_slack_fields(target)
616 payload: SlackPayload = get_branded_slack_payload(
617 {
618 "text": "Subscription is now active.",
619 "blocks": [
620 {
621 "type": "section",
622 "text": {
623 "type": "mrkdwn",
624 "text": "Subscription is now active.",
625 },
626 "fields": fields,
627 }
628 ],
629 }
630 )
632 return json.dumps(payload)
634 def _get_canceled_discord_payload(self, target: User | Organization) -> str: 1a
635 fields = self._get_discord_fields(target)
636 if self.data.ends_at:
637 ends_at = format_date(self.data.ends_at, locale="en_US")
638 else:
639 ends_at = format_date(self.data.ended_at, locale="en_US")
640 fields.append({"name": "Ends At", "value": ends_at})
642 payload: DiscordPayload = {
643 "content": "Subscription has been canceled.",
644 "embeds": [
645 get_branded_discord_embed(
646 {
647 "title": "Canceled Subscription",
648 "description": "Subscription has been canceled.",
649 "fields": fields,
650 }
651 )
652 ],
653 }
655 return json.dumps(payload)
657 def _get_canceled_slack_payload(self, target: User | Organization) -> str: 1a
658 fields = self._get_slack_fields(target)
659 if self.data.ends_at:
660 ends_at = format_date(self.data.ends_at, locale="en_US")
661 else:
662 ends_at = format_date(self.data.ended_at, locale="en_US")
663 fields.append({"type": "mrkdwn", "text": f"*Ends At*\n{ends_at}"})
665 payload: SlackPayload = get_branded_slack_payload(
666 {
667 "text": "Subscription has been canceled.",
668 "blocks": [
669 {
670 "type": "section",
671 "text": {
672 "type": "mrkdwn",
673 "text": "Subscription has been canceled.",
674 },
675 "fields": fields,
676 }
677 ],
678 }
679 )
681 return json.dumps(payload)
683 def _get_uncanceled_discord_payload(self, target: User | Organization) -> str: 1a
684 payload: DiscordPayload = {
685 "content": "Subscription has been uncanceled.",
686 "embeds": [
687 get_branded_discord_embed(
688 {
689 "title": "Uncanceled Subscription",
690 "description": "Subscription has been restored.",
691 "fields": self._get_discord_fields(target),
692 }
693 )
694 ],
695 }
697 return json.dumps(payload)
699 def _get_uncanceled_slack_payload(self, target: User | Organization) -> str: 1a
700 payload: SlackPayload = get_branded_slack_payload(
701 {
702 "text": "Subscription has been uncanceled.",
703 "blocks": [
704 {
705 "type": "section",
706 "text": {
707 "type": "mrkdwn",
708 "text": "Subscription has been restored.",
709 },
710 "fields": self._get_slack_fields(target),
711 }
712 ],
713 }
714 )
716 return json.dumps(payload)
718 def _get_revoked_discord_payload(self, target: User | Organization) -> str: 1a
719 payload: DiscordPayload = {
720 "content": "Subscription has been revoked.",
721 "embeds": [
722 get_branded_discord_embed(
723 {
724 "title": "Revoked Subscription",
725 "description": "Subscription has been revoked.",
726 "fields": self._get_discord_fields(target),
727 }
728 )
729 ],
730 }
732 return json.dumps(payload)
734 def _get_revoked_slack_payload(self, target: User | Organization) -> str: 1a
735 payload: SlackPayload = get_branded_slack_payload(
736 {
737 "text": "Subscription has been revoked.",
738 "blocks": [
739 {
740 "type": "section",
741 "text": {
742 "type": "mrkdwn",
743 "text": "Subscription has been revoked.",
744 },
745 "fields": self._get_slack_fields(target),
746 }
747 ],
748 }
749 )
751 return json.dumps(payload)
753 def _get_discord_fields( 1a
754 self, target: User | Organization
755 ) -> list[DiscordEmbedField]:
756 amount_display = self.data.get_amount_display()
757 fields: list[DiscordEmbedField] = [
758 {"name": "Product", "value": self.data.product.name},
759 {"name": "Amount", "value": amount_display},
760 {"name": "Customer", "value": self.data.user.email},
761 {"name": "Status", "value": self.data.status},
762 ]
763 return fields
765 def _get_slack_fields(self, target: User | Organization) -> list[SlackText]: 1a
766 amount_display = self.data.get_amount_display()
767 fields: list[SlackText] = [
768 {"type": "mrkdwn", "text": f"*Product*\n{self.data.product.name}"},
769 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"},
770 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.user.email}"},
771 {"type": "mrkdwn", "text": f"*Status*\n{self.data.status}"},
772 ]
773 return fields
776class WebhookSubscriptionUpdatedPayload(WebhookSubscriptionUpdatedPayloadBase): 1a
777 """
778 Sent when a subscription is updated. This event fires for all changes to the subscription, including renewals.
780 If you want more specific events, you can listen to `subscription.active`, `subscription.canceled`, and `subscription.revoked`.
782 To listen specifically for renewals, you can listen to `order.created` events and check the `billing_reason` field.
784 **Discord & Slack support:** On cancellation and revocation. Renewals are skipped.
785 """
787 type: Literal[WebhookEventType.subscription_updated] 1a
788 data: SubscriptionSchema 1a
790 def get_discord_payload(self, target: User | Organization) -> str: 1a
791 if isinstance(target, User):
792 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
794 if SubscriptionStatus.is_revoked(self.data.status):
795 return self._get_revoked_discord_payload(target)
797 # Avoid to send notifications for subscription renewals (not interesting)
798 # TODO: Notify about upgrades and downgrades
799 if not self.data.ends_at and not self.data.ended_at:
800 raise SkipEvent(self.type, WebhookFormat.discord)
802 return self._get_canceled_discord_payload(target)
804 def get_slack_payload(self, target: User | Organization) -> str: 1a
805 if isinstance(target, User):
806 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
808 if SubscriptionStatus.is_revoked(self.data.status):
809 return self._get_revoked_discord_payload(target)
811 # Avoid to send notifications for subscription renewals (not interesting)
812 # TODO: Notify about upgrades and downgrades
813 if not self.data.ends_at and not self.data.ended_at:
814 raise SkipEvent(self.type, WebhookFormat.slack)
816 return self._get_canceled_slack_payload(target)
819class WebhookSubscriptionActivePayload(WebhookSubscriptionUpdatedPayloadBase): 1a
820 """
821 Sent when a subscription becomes active,
822 whether because it's a new paid subscription or because payment was recovered.
824 **Discord & Slack support:** Full
825 """
827 type: Literal[WebhookEventType.subscription_active] 1a
828 data: SubscriptionSchema 1a
830 def get_discord_payload(self, target: User | Organization) -> str: 1a
831 if isinstance(target, User):
832 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
834 return self._get_active_discord_payload(target)
836 def get_slack_payload(self, target: User | Organization) -> str: 1a
837 if isinstance(target, User):
838 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
840 return self._get_active_slack_payload(target)
843class WebhookSubscriptionCanceledPayload(WebhookSubscriptionUpdatedPayloadBase): 1a
844 """
845 Sent when a subscription is canceled.
846 Customers might still have access until the end of the current period.
848 **Discord & Slack support:** Full
849 """
851 type: Literal[WebhookEventType.subscription_canceled] 1a
852 data: SubscriptionSchema 1a
854 def get_discord_payload(self, target: User | Organization) -> str: 1a
855 if isinstance(target, User):
856 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
858 return self._get_canceled_discord_payload(target)
860 def get_slack_payload(self, target: User | Organization) -> str: 1a
861 if isinstance(target, User):
862 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
864 return self._get_canceled_slack_payload(target)
867class WebhookSubscriptionUncanceledPayload(WebhookSubscriptionUpdatedPayloadBase): 1a
868 """
869 Sent when a subscription is uncanceled.
871 **Discord & Slack support:** Full
872 """
874 type: Literal[WebhookEventType.subscription_uncanceled] 1a
875 data: SubscriptionSchema 1a
877 def get_discord_payload(self, target: User | Organization) -> str: 1a
878 if isinstance(target, User):
879 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
881 return self._get_uncanceled_discord_payload(target)
883 def get_slack_payload(self, target: User | Organization) -> str: 1a
884 if isinstance(target, User):
885 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
887 return self._get_uncanceled_slack_payload(target)
890class WebhookSubscriptionRevokedPayload(WebhookSubscriptionUpdatedPayloadBase): 1a
891 """
892 Sent when a subscription is revoked, the user loses access immediately.
893 Happens when the subscription is canceled, or payment is past due.
895 **Discord & Slack support:** Full
896 """
898 type: Literal[WebhookEventType.subscription_revoked] 1a
899 data: SubscriptionSchema 1a
901 def get_discord_payload(self, target: User | Organization) -> str: 1a
902 if isinstance(target, User):
903 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
905 return self._get_revoked_discord_payload(target)
907 def get_slack_payload(self, target: User | Organization) -> str: 1a
908 if isinstance(target, User):
909 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
911 return self._get_revoked_slack_payload(target)
914class WebhookRefundBase(BaseWebhookPayload): 1a
915 """
916 Base Refund
917 """
919 type: ( 1a
920 Literal[WebhookEventType.refund_created]
921 | Literal[WebhookEventType.refund_updated]
922 )
923 data: RefundSchema 1a
925 def _get_discord_fields( 1a
926 self, target: User | Organization
927 ) -> list[DiscordEmbedField]:
928 amount_display = self.data.get_amount_display()
929 fields: list[DiscordEmbedField] = [
930 {"name": "Amount", "value": amount_display},
931 {"name": "Status", "value": self.data.status},
932 ]
933 return fields
935 def _get_slack_fields(self, target: User | Organization) -> list[SlackText]: 1a
936 amount_display = self.data.get_amount_display()
937 fields: list[SlackText] = [
938 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"},
939 {"type": "mrkdwn", "text": f"*Status*\n{self.data.status}"},
940 ]
941 return fields
944class WebhookRefundCreatedPayload(WebhookRefundBase): 1a
945 """
946 Sent when a refund is created regardless of status.
948 **Discord & Slack support:** Full
949 """
951 type: Literal[WebhookEventType.refund_created] 1a
952 data: RefundSchema 1a
954 def get_discord_payload(self, target: User | Organization) -> str: 1a
955 if isinstance(target, User):
956 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
958 payload: DiscordPayload = {
959 "content": "Refund Created",
960 "embeds": [
961 get_branded_discord_embed(
962 {
963 "title": "Refund Created",
964 "description": f"Refund has been created for {target.name}.",
965 "fields": self._get_discord_fields(target),
966 }
967 )
968 ],
969 }
970 return json.dumps(payload)
972 def get_slack_payload(self, target: User | Organization) -> str: 1a
973 if isinstance(target, User):
974 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
976 payload: SlackPayload = get_branded_slack_payload(
977 {
978 "text": "Refund Created",
979 "blocks": [
980 {
981 "type": "section",
982 "text": {
983 "type": "mrkdwn",
984 "text": f"Refund has been created for {target.name}.",
985 },
986 "fields": self._get_slack_fields(target),
987 }
988 ],
989 }
990 )
991 return json.dumps(payload)
994class WebhookRefundUpdatedPayload(WebhookRefundBase): 1a
995 """
996 Sent when a refund is updated.
998 **Discord & Slack support:** Full
999 """
1001 type: Literal[WebhookEventType.refund_updated] 1a
1002 data: RefundSchema 1a
1004 def get_discord_payload(self, target: User | Organization) -> str: 1a
1005 if isinstance(target, User):
1006 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord)
1008 payload: DiscordPayload = {
1009 "content": "Refund Updated",
1010 "embeds": [
1011 get_branded_discord_embed(
1012 {
1013 "title": "Refund Updated",
1014 "description": f"Refund has been updated for {target.name}.",
1015 "fields": self._get_discord_fields(target),
1016 }
1017 )
1018 ],
1019 }
1020 return json.dumps(payload)
1022 def get_slack_payload(self, target: User | Organization) -> str: 1a
1023 if isinstance(target, User):
1024 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack)
1026 payload: SlackPayload = get_branded_slack_payload(
1027 {
1028 "text": "Refund Updated",
1029 "blocks": [
1030 {
1031 "type": "section",
1032 "text": {
1033 "type": "mrkdwn",
1034 "text": f"Refund has been updated for {target.name}.",
1035 },
1036 "fields": self._get_slack_fields(target),
1037 }
1038 ],
1039 }
1040 )
1041 return json.dumps(payload)
1044class WebhookProductCreatedPayload(BaseWebhookPayload): 1a
1045 """
1046 Sent when a new product is created.
1048 **Discord & Slack support:** Basic
1049 """
1051 type: Literal[WebhookEventType.product_created] 1a
1052 data: ProductSchema 1a
1055class WebhookProductUpdatedPayload(BaseWebhookPayload): 1a
1056 """
1057 Sent when a product is updated.
1059 **Discord & Slack support:** Basic
1060 """
1062 type: Literal[WebhookEventType.product_updated] 1a
1063 data: ProductSchema 1a
1066class WebhookOrganizationUpdatedPayload(BaseWebhookPayload): 1a
1067 """
1068 Sent when a organization is updated.
1070 **Discord & Slack support:** Basic
1071 """
1073 type: Literal[WebhookEventType.organization_updated] 1a
1074 data: OrganizationSchema 1a
1077class WebhookBenefitCreatedPayload(BaseWebhookPayload): 1a
1078 """
1079 Sent when a new benefit is created.
1081 **Discord & Slack support:** Basic
1082 """
1084 type: Literal[WebhookEventType.benefit_created] 1a
1085 data: BenefitSchema 1a
1088class WebhookBenefitUpdatedPayload(BaseWebhookPayload): 1a
1089 """
1090 Sent when a benefit is updated.
1092 **Discord & Slack support:** Basic
1093 """
1095 type: Literal[WebhookEventType.benefit_updated] 1a
1096 data: BenefitSchema 1a
1099class WebhookBenefitGrantCreatedPayload(BaseWebhookPayload): 1a
1100 """
1101 Sent when a new benefit grant is created.
1103 **Discord & Slack support:** Basic
1104 """
1106 type: Literal[WebhookEventType.benefit_grant_created] 1a
1107 data: BenefitGrantWebhook 1a
1110class WebhookBenefitGrantUpdatedPayload(BaseWebhookPayload): 1a
1111 """
1112 Sent when a benefit grant is updated.
1114 **Discord & Slack support:** Basic
1115 """
1117 type: Literal[WebhookEventType.benefit_grant_updated] 1a
1118 data: BenefitGrantWebhook 1a
1121class WebhookBenefitGrantCycledPayload(BaseWebhookPayload): 1a
1122 """
1123 Sent when a benefit grant is cycled,
1124 meaning the related subscription has been renewed for another period.
1126 **Discord & Slack support:** Basic
1127 """
1129 type: Literal[WebhookEventType.benefit_grant_cycled] 1a
1130 data: BenefitGrantWebhook 1a
1133class WebhookBenefitGrantRevokedPayload(BaseWebhookPayload): 1a
1134 """
1135 Sent when a benefit grant is revoked.
1137 **Discord & Slack support:** Basic
1138 """
1140 type: Literal[WebhookEventType.benefit_grant_revoked] 1a
1141 data: BenefitGrantWebhook 1a
1144WebhookPayload = Annotated[ 1a
1145 WebhookCheckoutCreatedPayload
1146 | WebhookCheckoutUpdatedPayload
1147 | WebhookCustomerCreatedPayload
1148 | WebhookCustomerUpdatedPayload
1149 | WebhookCustomerDeletedPayload
1150 | WebhookCustomerStateChangedPayload
1151 | WebhookCustomerSeatAssignedPayload
1152 | WebhookCustomerSeatClaimedPayload
1153 | WebhookCustomerSeatRevokedPayload
1154 | WebhookOrderCreatedPayload
1155 | WebhookOrderUpdatedPayload
1156 | WebhookOrderPaidPayload
1157 | WebhookOrderRefundedPayload
1158 | WebhookSubscriptionCreatedPayload
1159 | WebhookSubscriptionUpdatedPayload
1160 | WebhookSubscriptionActivePayload
1161 | WebhookSubscriptionCanceledPayload
1162 | WebhookSubscriptionUncanceledPayload
1163 | WebhookSubscriptionRevokedPayload
1164 | WebhookRefundCreatedPayload
1165 | WebhookRefundUpdatedPayload
1166 | WebhookProductCreatedPayload
1167 | WebhookProductUpdatedPayload
1168 | WebhookOrganizationUpdatedPayload
1169 | WebhookBenefitCreatedPayload
1170 | WebhookBenefitUpdatedPayload
1171 | WebhookBenefitGrantCreatedPayload
1172 | WebhookBenefitGrantUpdatedPayload
1173 | WebhookBenefitGrantCycledPayload
1174 | WebhookBenefitGrantRevokedPayload,
1175 Discriminator(discriminator="type"),
1176]
1177WebhookPayloadTypeAdapter: TypeAdapter[WebhookPayload] = TypeAdapter(WebhookPayload) 1a
1180class WebhookAPIRoute(APIRoute): 1a
1181 """
1182 Since FastAPI documents webhook through API routes with a body field,
1183 we might be in a situation where it generates `-Input` and `-Output` variants
1184 of the schemas because it sees them as "input" schemas.
1186 But we don't want that.
1188 The trick here is to force the body field to be in "serialization" mode, so we
1189 prevent Pydantic to generate the `-Input` and `-Output` variants.
1190 """
1192 def __init__(self, *args: Any, **kwargs: Any) -> None: 1a
1193 super().__init__(*args, **kwargs)
1194 if self.body_field is not None: 1194 ↛ exitline 1194 didn't return from function '__init__' because the condition on line 1194 was always true
1195 self.body_field.mode = "serialization"
1198def document_webhooks(app: FastAPI) -> None: 1a
1199 def _endpoint(body: Any) -> None: ... 1199 ↛ exitline 1199 didn't return from function '_endpoint' because
1201 webhooks_schemas: tuple[type[BaseWebhookPayload]] = typing.get_args(
1202 typing.get_args(WebhookPayload)[0]
1203 )
1204 for webhook_schema in webhooks_schemas:
1205 signature = Signature(
1206 [
1207 Parameter(
1208 name="body",
1209 kind=Parameter.POSITIONAL_OR_KEYWORD,
1210 annotation=webhook_schema,
1211 )
1212 ]
1213 )
1215 event_type_annotation = webhook_schema.model_fields["type"].annotation
1216 event_type: WebhookEventType = get_args(event_type_annotation)[0]
1218 endpoint = with_signature(signature)(_endpoint)
1220 app.webhooks.add_api_route(
1221 event_type,
1222 endpoint,
1223 methods=["POST"],
1224 summary=event_type,
1225 description=inspect.getdoc(webhook_schema),
1226 route_class_override=WebhookAPIRoute,
1227 )