diff --git a/examples/async/usage_async.py b/examples/async/usage_async.py new file mode 100644 index 0000000..c985dc2 --- /dev/null +++ b/examples/async/usage_async.py @@ -0,0 +1,20 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +async def main() -> None: + usage: resend.Usage.GetResponse = await resend.Usage.get_async() + + print(f"Daily emails used: {usage['emails']['daily']['used']}") + print(f"Monthly emails used: {usage['emails']['monthly']['used']}") + print(f"Contacts used: {usage['contacts']['used']}") + print(f"Rate limit: {usage['rate_limit']['limit']} per {usage['rate_limit']['duration']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/usage.py b/examples/usage.py new file mode 100644 index 0000000..11aa9d5 --- /dev/null +++ b/examples/usage.py @@ -0,0 +1,13 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +usage: resend.Usage.GetResponse = resend.Usage.get() + +print(f"Daily emails used: {usage['emails']['daily']['used']}") +print(f"Monthly emails used: {usage['emails']['monthly']['used']}") +print(f"Contacts used: {usage['contacts']['used']}") +print(f"Rate limit: {usage['rate_limit']['limit']} per {usage['rate_limit']['duration']}") diff --git a/resend/__init__.py b/resend/__init__.py index 0a9e082..301d91a 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -70,6 +70,10 @@ from .templates._templates import Templates from .topics._topic import Topic from .topics._topics import Topics +from .usage._usage import (AiCreditsUsage, AutomationRunsUsage, + BroadcastsUsage, ContactsUsage, DomainsUsage, + EmailsDailyUsage, EmailsMonthlyUsage, EmailsUsage, + RateLimitUsage, SegmentsUsage, Usage) from .version import __version__, get_version from .webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookHeaders, WebhookStatus) @@ -130,6 +134,7 @@ "OAuthGrants", "Suppressions", "SuppressionsBatch", + "Usage", # Types "Audience", "Automation", @@ -224,6 +229,16 @@ "EmailsReceiving", "EmailAttachments", "ContactsTopics", + "EmailsUsage", + "EmailsDailyUsage", + "EmailsMonthlyUsage", + "ContactsUsage", + "SegmentsUsage", + "BroadcastsUsage", + "AiCreditsUsage", + "AutomationRunsUsage", + "DomainsUsage", + "RateLimitUsage", # HTTP Clients "HTTPClient", # Default HTTP Client diff --git a/resend/usage/__init__.py b/resend/usage/__init__.py new file mode 100644 index 0000000..c5b542b --- /dev/null +++ b/resend/usage/__init__.py @@ -0,0 +1,19 @@ +from resend.usage._usage import (AiCreditsUsage, AutomationRunsUsage, + BroadcastsUsage, ContactsUsage, DomainsUsage, + EmailsDailyUsage, EmailsMonthlyUsage, + EmailsUsage, RateLimitUsage, SegmentsUsage, + Usage) + +__all__ = [ + "Usage", + "EmailsUsage", + "EmailsDailyUsage", + "EmailsMonthlyUsage", + "ContactsUsage", + "SegmentsUsage", + "BroadcastsUsage", + "AiCreditsUsage", + "AutomationRunsUsage", + "DomainsUsage", + "RateLimitUsage", +] diff --git a/resend/usage/_usage.py b/resend/usage/_usage.py new file mode 100644 index 0000000..31bcd6e --- /dev/null +++ b/resend/usage/_usage.py @@ -0,0 +1,331 @@ +from typing import Union + +from typing_extensions import TypedDict + +from resend import request +from resend._base_response import BaseResponse + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class EmailsDailyUsage(TypedDict): + """ + EmailsDailyUsage is the type that wraps the caller's daily email usage. + + Attributes: + used (int): The number of emails counted against the daily quota + limit (Union[int, None]): The daily email quota, or None if unlimited + sent (int): The number of emails sent today + received (int): The number of emails received today + resets_at (str): When the daily quota resets, as an ISO 8601 datetime + """ + + used: int + """ + The number of emails counted against the daily quota. + """ + limit: Union[int, None] + """ + The daily email quota, or None if unlimited. + """ + sent: int + """ + The number of emails sent today. + """ + received: int + """ + The number of emails received today. + """ + resets_at: str + """ + When the daily quota resets, as an ISO 8601 datetime. + """ + + +class EmailsMonthlyUsage(TypedDict): + """ + EmailsMonthlyUsage is the type that wraps the caller's monthly email usage. + + Attributes: + used (int): The number of emails counted against the monthly quota + limit (int): The monthly email quota + sent (int): The number of emails sent this month + received (int): The number of emails received this month + resets_at (str): When the monthly quota resets, as an ISO 8601 datetime + """ + + used: int + """ + The number of emails counted against the monthly quota. + """ + limit: int + """ + The monthly email quota. + """ + sent: int + """ + The number of emails sent this month. + """ + received: int + """ + The number of emails received this month. + """ + resets_at: str + """ + When the monthly quota resets, as an ISO 8601 datetime. + """ + + +class EmailsUsage(TypedDict): + """ + EmailsUsage is the type that wraps the caller's email usage. + + Attributes: + daily (EmailsDailyUsage): The caller's daily email usage + monthly (EmailsMonthlyUsage): The caller's monthly email usage + """ + + daily: EmailsDailyUsage + """ + The caller's daily email usage. + """ + monthly: EmailsMonthlyUsage + """ + The caller's monthly email usage. + """ + + +class ContactsUsage(TypedDict): + """ + ContactsUsage is the type that wraps the caller's contacts usage. + + Attributes: + used (int): The number of contacts counted against the quota + limit (int): The contacts quota + """ + + used: int + """ + The number of contacts counted against the quota. + """ + limit: int + """ + The contacts quota. + """ + + +class SegmentsUsage(TypedDict): + """ + SegmentsUsage is the type that wraps the caller's segments usage. + + Attributes: + used (int): The number of segments counted against the quota + limit (Union[int, None]): The segments quota, or None if unlimited + """ + + used: int + """ + The number of segments counted against the quota. + """ + limit: Union[int, None] + """ + The segments quota, or None if unlimited. + """ + + +class BroadcastsUsage(TypedDict): + """ + BroadcastsUsage is the type that wraps the caller's broadcasts usage. + + Attributes: + used (int): The number of broadcasts counted against the quota + limit (None): The broadcasts quota, always None (unlimited) + """ + + used: int + """ + The number of broadcasts counted against the quota. + """ + limit: None + """ + The broadcasts quota, always None (unlimited). + """ + + +class AiCreditsUsage(TypedDict): + """ + AiCreditsUsage is the type that wraps the caller's AI credits usage. + + Attributes: + used (int): The number of AI credits used + limit (Union[int, None]): The AI credits quota, or None if unlimited + next_increase_at (Union[str, None]): When the AI credits quota next \ + increases, as an ISO 8601 datetime, or None if not scheduled + """ + + used: int + """ + The number of AI credits used. + """ + limit: Union[int, None] + """ + The AI credits quota, or None if unlimited. + """ + next_increase_at: Union[str, None] + """ + When the AI credits quota next increases, as an ISO 8601 datetime, + or None if not scheduled. + """ + + +class AutomationRunsUsage(TypedDict): + """ + AutomationRunsUsage is the type that wraps the caller's automation runs usage. + + Attributes: + used (int): The number of automation runs counted against the quota + limit (int): The automation runs quota + resets_at (str): When the automation runs quota resets, as an ISO \ + 8601 datetime + """ + + used: int + """ + The number of automation runs counted against the quota. + """ + limit: int + """ + The automation runs quota. + """ + resets_at: str + """ + When the automation runs quota resets, as an ISO 8601 datetime. + """ + + +class DomainsUsage(TypedDict): + """ + DomainsUsage is the type that wraps the caller's domains usage. + + Attributes: + used (int): The number of domains counted against the quota + limit (Union[int, None]): The domains quota, or None if unlimited + """ + + used: int + """ + The number of domains counted against the quota. + """ + limit: Union[int, None] + """ + The domains quota, or None if unlimited. + """ + + +class RateLimitUsage(TypedDict): + """ + RateLimitUsage is the type that wraps the caller's API rate limit. + + Attributes: + limit (int): The number of requests allowed per duration window + duration (str): The rate limit window (e.g. "1000ms") + """ + + limit: int + """ + The number of requests allowed per duration window. + """ + duration: str + """ + The rate limit window (e.g. "1000ms"). + """ + + +class Usage: + + class GetResponse(BaseResponse): + """ + GetResponse type that wraps the caller's account-level usage and quota data + + Attributes: + object (str): The object type, always "usage" + emails (EmailsUsage): The caller's email usage + contacts (ContactsUsage): The caller's contacts usage + segments (SegmentsUsage): The caller's segments usage + broadcasts (BroadcastsUsage): The caller's broadcasts usage + ai_credits (AiCreditsUsage): The caller's AI credits usage + automation_runs (AutomationRunsUsage): The caller's automation runs usage + domains (DomainsUsage): The caller's domains usage + rate_limit (RateLimitUsage): The caller's API rate limit + """ + + object: str + """ + The object type, always "usage". + """ + emails: EmailsUsage + """ + The caller's email usage. + """ + contacts: ContactsUsage + """ + The caller's contacts usage. + """ + segments: SegmentsUsage + """ + The caller's segments usage. + """ + broadcasts: BroadcastsUsage + """ + The caller's broadcasts usage. + """ + ai_credits: AiCreditsUsage + """ + The caller's AI credits usage. + """ + automation_runs: AutomationRunsUsage + """ + The caller's automation runs usage. + """ + domains: DomainsUsage + """ + The caller's domains usage. + """ + rate_limit: RateLimitUsage + """ + The caller's API rate limit. + """ + + @classmethod + def get(cls) -> GetResponse: + """ + Retrieve the caller's account-level usage and quota data. + see more: https://resend.com/docs/api-reference/usage/get-usage + + Returns: + GetResponse: The usage object + """ + path = "/usage" + resp = request.Request[Usage.GetResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls) -> GetResponse: + """ + Retrieve the caller's account-level usage and quota data (async). + see more: https://resend.com/docs/api-reference/usage/get-usage + + Returns: + GetResponse: The usage object + """ + path = "/usage" + resp = await AsyncRequest[Usage.GetResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp diff --git a/resend/version.py b/resend/version.py index affdb22..434821a 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.47.0" +__version__ = "2.48.0" def get_version() -> str: diff --git a/tests/usage_async_test.py b/tests/usage_async_test.py new file mode 100644 index 0000000..67eb4ac --- /dev/null +++ b/tests/usage_async_test.py @@ -0,0 +1,64 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendUsageAsync(AsyncResendBaseTest): + async def test_usage_get_async(self) -> None: + self.set_mock_json( + { + "object": "usage", + "emails": { + "daily": { + "used": 258, + "limit": None, + "sent": 57, + "received": 201, + "resets_at": "2026-07-17T00:00:00.000Z", + }, + "monthly": { + "used": 5442, + "limit": 10000, + "sent": 1000, + "received": 4442, + "resets_at": "2026-08-01T00:00:00.000Z", + }, + }, + "contacts": {"used": 85000, "limit": 150000}, + "segments": {"used": 2, "limit": 3}, + "broadcasts": {"used": 100, "limit": None}, + "ai_credits": { + "used": 0, + "limit": 500, + "next_increase_at": "2026-07-18T09:00:00.000Z", + }, + "automation_runs": { + "used": 0, + "limit": 1000, + "resets_at": "2026-08-01T00:00:00.000Z", + }, + "domains": {"used": 1, "limit": 1000}, + "rate_limit": {"limit": 10, "duration": "1000ms"}, + } + ) + + usage: resend.Usage.GetResponse = await resend.Usage.get_async() + assert usage["object"] == "usage" + assert usage["emails"]["daily"]["used"] == 258 + assert usage["emails"]["monthly"]["limit"] == 10000 + assert usage["contacts"]["limit"] == 150000 + assert usage["ai_credits"]["next_increase_at"] == "2026-07-18T09:00:00.000Z" + assert usage["rate_limit"]["duration"] == "1000ms" + + async def test_should_get_usage_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Usage.get_async() diff --git a/tests/usage_test.py b/tests/usage_test.py new file mode 100644 index 0000000..6cdd070 --- /dev/null +++ b/tests/usage_test.py @@ -0,0 +1,76 @@ +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestResendUsage(ResendBaseTest): + def test_usage_get(self) -> None: + self.set_mock_json( + { + "object": "usage", + "emails": { + "daily": { + "used": 258, + "limit": None, + "sent": 57, + "received": 201, + "resets_at": "2026-07-17T00:00:00.000Z", + }, + "monthly": { + "used": 5442, + "limit": 10000, + "sent": 1000, + "received": 4442, + "resets_at": "2026-08-01T00:00:00.000Z", + }, + }, + "contacts": {"used": 85000, "limit": 150000}, + "segments": {"used": 2, "limit": 3}, + "broadcasts": {"used": 100, "limit": None}, + "ai_credits": { + "used": 0, + "limit": 500, + "next_increase_at": "2026-07-18T09:00:00.000Z", + }, + "automation_runs": { + "used": 0, + "limit": 1000, + "resets_at": "2026-08-01T00:00:00.000Z", + }, + "domains": {"used": 1, "limit": 1000}, + "rate_limit": {"limit": 10, "duration": "1000ms"}, + } + ) + + usage: resend.Usage.GetResponse = resend.Usage.get() + assert usage["object"] == "usage" + assert usage["emails"]["daily"]["used"] == 258 + assert usage["emails"]["daily"]["limit"] is None + assert usage["emails"]["daily"]["sent"] == 57 + assert usage["emails"]["daily"]["received"] == 201 + assert usage["emails"]["daily"]["resets_at"] == "2026-07-17T00:00:00.000Z" + assert usage["emails"]["monthly"]["used"] == 5442 + assert usage["emails"]["monthly"]["limit"] == 10000 + assert usage["contacts"]["used"] == 85000 + assert usage["contacts"]["limit"] == 150000 + assert usage["segments"]["used"] == 2 + assert usage["segments"]["limit"] == 3 + assert usage["broadcasts"]["used"] == 100 + assert usage["broadcasts"]["limit"] is None + assert usage["ai_credits"]["used"] == 0 + assert usage["ai_credits"]["limit"] == 500 + assert usage["ai_credits"]["next_increase_at"] == "2026-07-18T09:00:00.000Z" + assert usage["automation_runs"]["used"] == 0 + assert usage["automation_runs"]["limit"] == 1000 + assert usage["automation_runs"]["resets_at"] == "2026-08-01T00:00:00.000Z" + assert usage["domains"]["used"] == 1 + assert usage["domains"]["limit"] == 1000 + assert usage["rate_limit"]["limit"] == 10 + assert usage["rate_limit"]["duration"] == "1000ms" + + def test_should_get_usage_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Usage.get()