diff --git a/examples/webhook_receiver.py b/examples/webhook_receiver.py index 69a755d..9f7ddd5 100644 --- a/examples/webhook_receiver.py +++ b/examples/webhook_receiver.py @@ -53,9 +53,9 @@ def webhook_handler() -> Tuple[Dict[str, Union[str, bool]], int]: "signature": request.headers.get("svix-signature", ""), } - # Verify the webhook + # Verify the webhook. On success, returns the parsed payload. try: - resend.Webhooks.verify( + payload = resend.Webhooks.verify( { "payload": body, "headers": headers, @@ -66,27 +66,23 @@ def webhook_handler() -> Tuple[Dict[str, Union[str, bool]], int]: print(f"Webhook verification failed: {e}") return {"error": "Webhook verification failed"}, 400 - # Parse the verified payload - try: - payload = json.loads(body) - except json.JSONDecodeError as e: - print(f"Error parsing JSON: {e}") - return {"error": "Invalid JSON payload"}, 400 - # Process the webhook event event_type = payload.get("type") - print("✓ Webhook verified successfully!") + print("Webhook verified successfully!") print(f"Event Type: {event_type}") print(f"Payload: {json.dumps(payload, indent=2)}") - print(f"Event: {payload.get('data', {}).get('email_id')}") + data = payload.get("data", {}) + print(f"Event email_id: {data.get('email_id')}") + if data.get("message_id"): + print(f"Event message_id: {data.get('message_id')}") return {"success": True}, 200 if __name__ == "__main__": port = int(os.getenv("PORT", 5000)) - print(f"🚀 Webhook receiver listening on http://localhost:{port}/webhook") + print(f"Webhook receiver listening on http://localhost:{port}/webhook") print("Send a POST request with Resend webhook headers to test verification") print(f"Using webhook secret: {WEBHOOK_SECRET[:15]}...") app.run(host="0.0.0.0", port=port, debug=True) diff --git a/resend/__init__.py b/resend/__init__.py index 83ec6d1..fe87b27 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -22,7 +22,8 @@ from .contacts._contact_topic import ContactTopic, TopicSubscriptionUpdate from .contacts._contacts import Contacts from .contacts._topics import Topics as ContactsTopics -from .contacts.imports._contact_import import ContactImport, ContactImportCounts +from .contacts.imports._contact_import import (ContactImport, + ContactImportCounts) from .contacts.imports._contact_imports import ContactImports from .contacts.segments._contact_segment import ContactSegment from .contacts.segments._contact_segments import ContactSegments @@ -61,6 +62,19 @@ from .version import __version__, get_version from .webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookHeaders, WebhookStatus) +from .webhooks._webhook_event import (BaseEmailEventData, ContactCreatedEvent, + ContactDeletedEvent, ContactEventData, + ContactUpdatedEvent, DomainCreatedEvent, + DomainDeletedEvent, DomainEventData, + DomainUpdatedEvent, EmailBouncedEvent, + EmailClickedEvent, EmailComplainedEvent, + EmailDeliveredEvent, + EmailDeliveryDelayedEvent, + EmailFailedEvent, EmailOpenedEvent, + EmailReceivedEvent, EmailScheduledEvent, + EmailSentEvent, EmailSuppressedEvent, + ReceivedEmailEventData, + WebhookEventPayload) from .webhooks._webhooks import Webhooks # Type for clients that support both sync and async @@ -143,9 +157,31 @@ "Variable", "Webhook", "WebhookEvent", + "WebhookEventPayload", "WebhookHeaders", "WebhookStatus", "VerifyWebhookOptions", + "BaseEmailEventData", + "ReceivedEmailEventData", + "ContactEventData", + "DomainEventData", + "EmailSentEvent", + "EmailScheduledEvent", + "EmailDeliveredEvent", + "EmailDeliveryDelayedEvent", + "EmailComplainedEvent", + "EmailBouncedEvent", + "EmailOpenedEvent", + "EmailClickedEvent", + "EmailReceivedEvent", + "EmailFailedEvent", + "EmailSuppressedEvent", + "ContactCreatedEvent", + "ContactUpdatedEvent", + "ContactDeletedEvent", + "DomainCreatedEvent", + "DomainUpdatedEvent", + "DomainDeletedEvent", "Topic", "OAuthGrant", "OAuthGrantClient", diff --git a/resend/webhooks/_webhook_event.py b/resend/webhooks/_webhook_event.py new file mode 100644 index 0000000..42d66cf --- /dev/null +++ b/resend/webhooks/_webhook_event.py @@ -0,0 +1,513 @@ +from typing import Dict, List, Optional, Union + +from typing_extensions import Literal, NotRequired, TypedDict + +# Functional syntax required because ``from`` is a reserved keyword. +_FromField = TypedDict( + "_FromField", + { + "from": str, + }, +) + + +class EmailBounce(TypedDict): + """ + Bounce details included on ``email.bounced`` webhook events. + + Field names match the API payload (camelCase). + """ + + diagnosticCode: NotRequired[str] # noqa: N815 + """ + Diagnostic code returned by the receiving server, when available. + """ + message: str + """ + Detailed bounce message from the receiving server. + """ + subType: str # noqa: N815 + """ + Bounce sub-type (e.g. ``Suppressed``, ``MessageRejected``). + """ + type: str + """ + Bounce type (e.g. ``Permanent``, ``Temporary``). + """ + + +class EmailClick(TypedDict): + """ + Click tracking details included on ``email.clicked`` webhook events. + + Field names match the API payload (camelCase). + """ + + ipAddress: str # noqa: N815 + """ + IP address of the user who clicked the link. + """ + link: str + """ + The URL that was clicked. + """ + timestamp: str + """ + ISO 8601 timestamp when the click occurred. + """ + userAgent: str # noqa: N815 + """ + User agent string of the browser that clicked the link. + """ + + +class EmailFailed(TypedDict): + """ + Failure details included on ``email.failed`` webhook events. + """ + + reason: str + """ + Reason for the email failure (e.g. ``reached_daily_quota``). + """ + + +class EmailSuppressed(TypedDict): + """ + Suppression details included on ``email.suppressed`` webhook events. + """ + + message: str + """ + Suppression message. + """ + type: str + """ + Suppression type. + """ + + +class ReceivedEmailAttachment(TypedDict): + """ + Attachment metadata included on ``email.received`` webhook events. + """ + + id: str + """ + The attachment ID. + """ + filename: Optional[str] + """ + The filename of the attachment. + """ + content_type: str + """ + The content type of the attachment. + """ + content_disposition: Optional[str] + """ + The content disposition of the attachment. + """ + content_id: Optional[str] + """ + The content ID for inline attachments. + """ + + +class BaseEmailEventData(_FromField): + """ + Shared ``data`` fields for outbound email webhook events. + + See: https://resend.com/docs/webhooks/emails/sent + """ + + created_at: str + """ + ISO 8601 timestamp when the email was created. + """ + email_id: str + """ + Unique identifier for the specific email. + """ + message_id: str + """ + RFC Message-ID header value for the email. + """ + to: List[str] + """ + Array of impacted recipient email addresses. + """ + subject: str + """ + Email subject line. + """ + broadcast_id: NotRequired[str] + """ + Unique identifier for the broadcast campaign (if applicable). + """ + template_id: NotRequired[str] + """ + Unique identifier for the template used (if applicable). + """ + tags: NotRequired[Dict[str, str]] + """ + Tag key-value pairs associated with the email. + """ + + +class EmailBouncedEventData(BaseEmailEventData): + """ + ``data`` payload for ``email.bounced`` events. + """ + + bounce: EmailBounce + """ + Bounce details from the receiving server. + """ + + +class EmailClickedEventData(BaseEmailEventData): + """ + ``data`` payload for ``email.clicked`` events. + """ + + click: EmailClick + """ + Click tracking details. + """ + + +class EmailFailedEventData(BaseEmailEventData): + """ + ``data`` payload for ``email.failed`` events. + """ + + failed: EmailFailed + """ + Failure details. + """ + + +class EmailSuppressedEventData(BaseEmailEventData): + """ + ``data`` payload for ``email.suppressed`` events. + """ + + suppressed: EmailSuppressed + """ + Suppression details. + """ + + +class ReceivedEmailEventData(_FromField): + """ + ``data`` payload for ``email.received`` events. + + See: https://resend.com/docs/webhooks/emails/received + """ + + email_id: str + """ + Unique identifier for the specific email. + """ + created_at: str + """ + ISO 8601 timestamp when the email was created. + """ + to: List[str] + """ + Array of recipient email addresses. + """ + bcc: List[str] + """ + BCC recipient email addresses. + """ + cc: List[str] + """ + CC recipient email addresses. + """ + received_for: List[str] + """ + Recipient addresses the email was forwarded for. + """ + message_id: str + """ + RFC Message-ID header value for the email. + """ + subject: str + """ + Email subject line. + """ + attachments: List[ReceivedEmailAttachment] + """ + Attachment metadata (bodies are fetched separately). + """ + + +class ContactEventData(TypedDict): + """ + ``data`` payload for contact webhook events. + """ + + id: str + """ + The contact ID. + """ + audience_id: str + """ + The audience ID. + """ + segment_ids: List[str] + """ + Segment IDs associated with the contact. + """ + created_at: str + """ + When the contact was created. + """ + updated_at: str + """ + When the contact was last updated. + """ + email: str + """ + The contact email address. + """ + unsubscribed: bool + """ + Whether the contact is unsubscribed. + """ + first_name: NotRequired[str] + """ + The contact's first name. + """ + last_name: NotRequired[str] + """ + The contact's last name. + """ + + +class DomainRecord(TypedDict): + """ + DNS record included on domain webhook events. + """ + + record: str + """ + The record purpose (e.g. ``SPF``, ``DKIM``). + """ + name: str + """ + The DNS record name. + """ + type: str + """ + The DNS record type. + """ + ttl: str + """ + The DNS record TTL. + """ + status: str + """ + The DNS record status. + """ + value: str + """ + The DNS record value. + """ + priority: NotRequired[int] + """ + Optional MX priority. + """ + + +class DomainEventData(TypedDict): + """ + ``data`` payload for domain webhook events. + """ + + id: str + """ + The domain ID. + """ + name: str + """ + The domain name. + """ + status: str + """ + The domain status. + """ + created_at: str + """ + When the domain was created. + """ + region: str + """ + The domain region. + """ + records: List[DomainRecord] + """ + DNS records associated with the domain. + """ + + +class EmailSentEvent(TypedDict): + """Webhook payload for ``email.sent``.""" + + type: Literal["email.sent"] + created_at: str + data: BaseEmailEventData + + +class EmailScheduledEvent(TypedDict): + """Webhook payload for ``email.scheduled``.""" + + type: Literal["email.scheduled"] + created_at: str + data: BaseEmailEventData + + +class EmailDeliveredEvent(TypedDict): + """Webhook payload for ``email.delivered``.""" + + type: Literal["email.delivered"] + created_at: str + data: BaseEmailEventData + + +class EmailDeliveryDelayedEvent(TypedDict): + """Webhook payload for ``email.delivery_delayed``.""" + + type: Literal["email.delivery_delayed"] + created_at: str + data: BaseEmailEventData + + +class EmailComplainedEvent(TypedDict): + """Webhook payload for ``email.complained``.""" + + type: Literal["email.complained"] + created_at: str + data: BaseEmailEventData + + +class EmailBouncedEvent(TypedDict): + """Webhook payload for ``email.bounced``.""" + + type: Literal["email.bounced"] + created_at: str + data: EmailBouncedEventData + + +class EmailOpenedEvent(TypedDict): + """Webhook payload for ``email.opened``.""" + + type: Literal["email.opened"] + created_at: str + data: BaseEmailEventData + + +class EmailClickedEvent(TypedDict): + """Webhook payload for ``email.clicked``.""" + + type: Literal["email.clicked"] + created_at: str + data: EmailClickedEventData + + +class EmailReceivedEvent(TypedDict): + """Webhook payload for ``email.received``.""" + + type: Literal["email.received"] + created_at: str + data: ReceivedEmailEventData + + +class EmailFailedEvent(TypedDict): + """Webhook payload for ``email.failed``.""" + + type: Literal["email.failed"] + created_at: str + data: EmailFailedEventData + + +class EmailSuppressedEvent(TypedDict): + """Webhook payload for ``email.suppressed``.""" + + type: Literal["email.suppressed"] + created_at: str + data: EmailSuppressedEventData + + +class ContactCreatedEvent(TypedDict): + """Webhook payload for ``contact.created``.""" + + type: Literal["contact.created"] + created_at: str + data: ContactEventData + + +class ContactUpdatedEvent(TypedDict): + """Webhook payload for ``contact.updated``.""" + + type: Literal["contact.updated"] + created_at: str + data: ContactEventData + + +class ContactDeletedEvent(TypedDict): + """Webhook payload for ``contact.deleted``.""" + + type: Literal["contact.deleted"] + created_at: str + data: ContactEventData + + +class DomainCreatedEvent(TypedDict): + """Webhook payload for ``domain.created``.""" + + type: Literal["domain.created"] + created_at: str + data: DomainEventData + + +class DomainUpdatedEvent(TypedDict): + """Webhook payload for ``domain.updated``.""" + + type: Literal["domain.updated"] + created_at: str + data: DomainEventData + + +class DomainDeletedEvent(TypedDict): + """Webhook payload for ``domain.deleted``.""" + + type: Literal["domain.deleted"] + created_at: str + data: DomainEventData + + +WebhookEventPayload = Union[ + EmailSentEvent, + EmailScheduledEvent, + EmailDeliveredEvent, + EmailDeliveryDelayedEvent, + EmailComplainedEvent, + EmailBouncedEvent, + EmailOpenedEvent, + EmailClickedEvent, + EmailReceivedEvent, + EmailFailedEvent, + EmailSuppressedEvent, + ContactCreatedEvent, + ContactUpdatedEvent, + ContactDeletedEvent, + DomainCreatedEvent, + DomainUpdatedEvent, + DomainDeletedEvent, +] +""" +Union of all Resend webhook event payload shapes returned by ``Webhooks.verify``. +""" diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index 3fe6674..abbd99d 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -1,5 +1,6 @@ import base64 import hmac +import json import time from hashlib import sha256 from typing import Any, Dict, List, Optional, cast @@ -17,13 +18,13 @@ pass from resend.webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookStatus) +from resend.webhooks._webhook_event import WebhookEventPayload # Default tolerance for timestamp validation (5 minutes) DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300 class Webhooks: - class ListParams(TypedDict): limit: NotRequired[int] """ @@ -263,10 +264,14 @@ def remove(cls, webhook_id: str) -> DeleteWebhookResponse: return resp @classmethod - def verify(cls, options: VerifyWebhookOptions) -> None: + def verify(cls, options: VerifyWebhookOptions) -> WebhookEventPayload: """ Verify validates a webhook payload using HMAC-SHA256 signature verification. This implements manual verification without external dependencies. + + On success, returns the parsed JSON payload (same contract as the Node SDK + and https://resend.com/docs/webhooks/verify-webhooks-requests). + see more: https://docs.svix.com/receiving/verifying-payloads/how-manual Args: @@ -276,10 +281,11 @@ def verify(cls, options: VerifyWebhookOptions) -> None: - webhook_secret: The webhook signing secret (starts with whsec_) Raises: - ValueError: If verification fails or required parameters are missing + ValueError: If verification fails, required parameters are missing, + or the verified payload is not valid JSON Returns: - None: Returns None on successful verification, raises ValueError otherwise + WebhookEventPayload: The parsed webhook event after successful verification """ # Validate required parameters if not options: @@ -350,7 +356,10 @@ def verify(cls, options: VerifyWebhookOptions) -> None: received_signature = parts[1] if hmac.compare_digest(expected_signature, received_signature): - return # Signature matches + try: + return cast(WebhookEventPayload, json.loads(options["payload"])) + except json.JSONDecodeError as e: + raise ValueError(f"failed to parse webhook payload: {e}") from e raise ValueError("no matching signature found") diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 8b695bf..45fbe73 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -110,12 +110,17 @@ def _generate_test_signature( return f"v1,{signature}" def test_verify_valid_webhook(self) -> None: - """Test successful webhook verification""" + """Test successful webhook verification returns the parsed payload""" # Use a base64-encoded secret secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") msg_id = "msg_123" timestamp = str(int(time.time())) - payload = '{"type":"email.sent","data":{"email_id":"123"}}' + payload = ( + '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z",' + '"data":{"email_id":"123","message_id":"<111@example.com>",' + '"from":"Acme ","to":["delivered@resend.dev"],' + '"subject":"Hello","created_at":"2026-02-22T23:41:11.894719+00:00"}}' + ) signature = self._generate_test_signature(secret, msg_id, timestamp, payload) @@ -131,8 +136,11 @@ def test_verify_valid_webhook(self) -> None: "webhook_secret": secret, } - # Should not raise an exception - resend.Webhooks.verify(options) + event = resend.Webhooks.verify(options) + assert event["type"] == "email.sent" + assert event["data"]["email_id"] == "123" + assert event["data"]["message_id"] == "<111@example.com>" + assert event["data"]["from"] == "Acme " def test_verify_invalid_signature(self) -> None: """Test webhook verification with invalid signature""" @@ -281,7 +289,92 @@ def test_verify_multiple_signatures(self) -> None: } # Should not raise an exception (finds the valid signature) - resend.Webhooks.verify(options) + event = resend.Webhooks.verify(options) + assert event["type"] == "email.sent" + + def test_verify_returns_received_email_payload(self) -> None: + """Test verify returns typed inbound email fields including message_id""" + secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") + msg_id = "msg_456" + timestamp = str(int(time.time())) + payload = ( + '{"type":"email.received","created_at":"2026-02-22T23:41:12.126Z",' + '"data":{"email_id":"567","created_at":"2026-02-22T23:41:11.894719+00:00",' + '"from":"onboarding@resend.dev","to":["delivered@resend.dev"],' + '"bcc":[],"cc":[],"received_for":["forwarded@example.com"],' + '"message_id":"<111-222-333@email.example.com>",' + '"subject":"Inbound","attachments":[]}}' + ) + + signature = self._generate_test_signature(secret, msg_id, timestamp, payload) + options: resend.VerifyWebhookOptions = { + "payload": payload, + "headers": { + "id": msg_id, + "timestamp": timestamp, + "signature": signature, + }, + "webhook_secret": secret, + } + + event = resend.Webhooks.verify(options) + assert event["type"] == "email.received" + assert event["data"]["message_id"] == "<111-222-333@email.example.com>" + assert event["data"]["received_for"] == ["forwarded@example.com"] + + def test_verify_returns_bounced_payload(self) -> None: + """Test verify returns the nested bounce object including diagnosticCode""" + secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") + msg_id = "msg_bounce" + timestamp = str(int(time.time())) + payload = ( + '{"type":"email.bounced","created_at":"2026-02-22T23:41:12.126Z",' + '"data":{"email_id":"123","message_id":"<111@example.com>",' + '"from":"Acme ","to":["bounced@resend.dev"],' + '"subject":"Hello","created_at":"2026-02-22T23:41:11.894719+00:00",' + '"bounce":{"diagnosticCode":"smtp; 550 5.1.1 user unknown",' + '"message":"The recipient does not exist.",' + '"subType":"General","type":"Permanent"}}}' + ) + + signature = self._generate_test_signature(secret, msg_id, timestamp, payload) + options: resend.VerifyWebhookOptions = { + "payload": payload, + "headers": { + "id": msg_id, + "timestamp": timestamp, + "signature": signature, + }, + "webhook_secret": secret, + } + + event = resend.Webhooks.verify(options) + assert event["type"] == "email.bounced" + bounce = event["data"]["bounce"] + assert bounce["diagnosticCode"] == "smtp; 550 5.1.1 user unknown" + assert bounce["type"] == "Permanent" + assert bounce["subType"] == "General" + + def test_verify_invalid_json_payload(self) -> None: + """Test verify raises when the signature is valid but the body is not JSON""" + secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") + msg_id = "msg_789" + timestamp = str(int(time.time())) + payload = "not-json" + + signature = self._generate_test_signature(secret, msg_id, timestamp, payload) + options: resend.VerifyWebhookOptions = { + "payload": payload, + "headers": { + "id": msg_id, + "timestamp": timestamp, + "signature": signature, + }, + "webhook_secret": secret, + } + + with pytest.raises(ValueError, match="failed to parse webhook payload"): + resend.Webhooks.verify(options) def test_verify_tampered_payload(self) -> None: """Test webhook verification with tampered payload"""