From 59a6e64e291629ae7c02a4a70cf5b934f2a458e4 Mon Sep 17 00:00:00 2001 From: drish Date: Fri, 3 Jul 2026 17:50:48 -0300 Subject: [PATCH] feat: add domain claims endpoints Adds Domains.Claims sub-resource with create, get, and verify methods (plus _async variants) covering the new Domain Claims API: - POST /domains/claim - GET /domains/{domain_id}/claim - POST /domains/{domain_id}/claim/verify --- examples/domain_claims.py | 33 +++++ resend/__init__.py | 5 + resend/domains/_domains.py | 4 + resend/domains/claims/__init__.py | 4 + resend/domains/claims/_domain_claim.py | 70 ++++++++++ resend/domains/claims/_domain_claims.py | 162 ++++++++++++++++++++++++ tests/domain_claims_async_test.py | 109 ++++++++++++++++ tests/domain_claims_test.py | 129 +++++++++++++++++++ 8 files changed, 516 insertions(+) create mode 100644 examples/domain_claims.py create mode 100644 resend/domains/claims/__init__.py create mode 100644 resend/domains/claims/_domain_claim.py create mode 100644 resend/domains/claims/_domain_claims.py create mode 100644 tests/domain_claims_async_test.py create mode 100644 tests/domain_claims_test.py diff --git a/examples/domain_claims.py b/examples/domain_claims.py new file mode 100644 index 0000000..d85a042 --- /dev/null +++ b/examples/domain_claims.py @@ -0,0 +1,33 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +# Start a claim for a domain that another Resend account has already verified. +claim_params: resend.Domains.Claims.CreateParams = { + "name": "example.com", + "region": "us-east-1", +} +claim: resend.DomainClaim = resend.Domains.Claims.create(claim_params) +print(f"Created domain claim: {claim['id']}") +print(f"Status: {claim['status']}") +if claim.get("record"): + record = claim["record"] + print( + f"Add this TXT record to prove ownership: {record['name']} = {record['value']}" + ) + +# Get: poll the claim until the TXT record has been added and verification can run. +domain_id = claim["domain_id"] +if domain_id: + retrieved_claim: resend.DomainClaim = resend.Domains.Claims.get(domain_id=domain_id) + print(f"Retrieved domain claim status: {retrieved_claim['status']}") + + # Verify: trigger asynchronous DNS verification and ownership transfer. + verified_claim: resend.DomainClaim = resend.Domains.Claims.verify( + domain_id=domain_id + ) + print(f"Verification triggered, claim status: {verified_claim['status']}") diff --git a/resend/__init__.py b/resend/__init__.py index 7b4b4e8..5cad220 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -28,6 +28,8 @@ from .contacts.segments._contact_segments import ContactSegments from .domains._domain import Domain from .domains._domains import Domains +from .domains.claims._domain_claim import DomainClaim, DomainClaimRecord +from .domains.claims._domain_claims import DomainClaims from .emails._attachment import Attachment, RemoteAttachment from .emails._attachments import Attachments as EmailAttachments from .emails._batch import Batch, BatchValidationError @@ -81,6 +83,7 @@ "Emails", "ApiKeys", "Domains", + "DomainClaims", "Batch", "Audiences", "Automations", @@ -121,6 +124,8 @@ "ContactTopic", "TopicSubscriptionUpdate", "Domain", + "DomainClaim", + "DomainClaimRecord", "ApiKey", "Log", "Email", diff --git a/resend/domains/_domains.py b/resend/domains/_domains.py index b488924..aa5f2ca 100644 --- a/resend/domains/_domains.py +++ b/resend/domains/_domains.py @@ -6,6 +6,7 @@ from resend._base_response import BaseResponse from resend.domains._domain import Domain from resend.domains._record import Record +from resend.domains.claims._domain_claims import DomainClaims from resend.pagination_helper import PaginationHelper # Async imports (optional - only available with pip install resend[async]) @@ -19,6 +20,9 @@ class Domains: + class Claims(DomainClaims): + pass + class ListParams(TypedDict): limit: NotRequired[int] """ diff --git a/resend/domains/claims/__init__.py b/resend/domains/claims/__init__.py new file mode 100644 index 0000000..330ff5b --- /dev/null +++ b/resend/domains/claims/__init__.py @@ -0,0 +1,4 @@ +from ._domain_claim import DomainClaim, DomainClaimRecord +from ._domain_claims import DomainClaims + +__all__ = ["DomainClaims", "DomainClaim", "DomainClaimRecord"] diff --git a/resend/domains/claims/_domain_claim.py b/resend/domains/claims/_domain_claim.py new file mode 100644 index 0000000..ebd0009 --- /dev/null +++ b/resend/domains/claims/_domain_claim.py @@ -0,0 +1,70 @@ +from typing import Optional + +from typing_extensions import Literal, TypedDict + +from resend._base_response import BaseResponse + +DomainClaimStatus = Literal[ + "pending", + "verified", + "completed", + "blocked", + "expired", + "superseded", + "canceled", + "failed", +] + +DomainClaimBlockedReason = Literal[ + "grace_period", + "recent_owner_activity", + "pending_scheduled_emails", +] + + +class DomainClaimRecord(TypedDict): + """ + DomainClaimRecord is the TXT record to add to your DNS to prove ownership of the claimed domain. + + Attributes: + type (str): The DNS record type. Always 'TXT' for domain claims. + name (str): The name of the DNS record (the domain being claimed). + value (str): The value of the TXT record. + ttl (str): The time to live for the record. + """ + + type: str + name: str + value: str + ttl: str + + +class DomainClaim(BaseResponse): + """ + DomainClaim represents a claim for a domain that another Resend account has already verified. + + Attributes: + object (str): Always 'domain_claim'. + id (str): The ID of the claim. + name (str): The name of the domain being claimed. + status (DomainClaimStatus): The status of the claim. + domain_id (Optional[str]): The ID of the placeholder domain created for the claim. + region (Optional[str]): The region where the claimed domain will send from. + record (DomainClaimRecord): The TXT record to add to your DNS to prove ownership. + blocked_reason (Optional[DomainClaimBlockedReason]): Why the claim is currently blocked, if applicable. + failure_reason (Optional[str]): Why the claim failed, if applicable. + created_at (str): The date and time the claim was created. + expires_at (str): The date and time the claim expires if not verified. + """ + + object: str + id: str + name: str + status: DomainClaimStatus + domain_id: Optional[str] + region: Optional[str] + record: DomainClaimRecord + blocked_reason: Optional[DomainClaimBlockedReason] + failure_reason: Optional[str] + created_at: str + expires_at: str diff --git a/resend/domains/claims/_domain_claims.py b/resend/domains/claims/_domain_claims.py new file mode 100644 index 0000000..1beb383 --- /dev/null +++ b/resend/domains/claims/_domain_claims.py @@ -0,0 +1,162 @@ +from typing import Any, Dict, cast + +from typing_extensions import NotRequired, TypedDict + +from resend import request + +from ._domain_claim import DomainClaim + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class DomainClaims: + class CreateParams(TypedDict): + name: str + """ + The name of the domain you want to claim. + """ + region: NotRequired[str] + """ + The region where emails will be sent from. + Possible values: 'us-east-1' | 'eu-west-1' | 'sa-east-1' | 'ap-northeast-1' + """ + custom_return_path: NotRequired[str] + """ + By default, Resend will use the `send` subdomain for the Return-Path address. + You can change this by setting the optional `custom_return_path` parameter. + """ + tracking_subdomain: NotRequired[str] + """ + The custom subdomain used for click and open tracking links (e.g., "links"). + """ + click_tracking: NotRequired[bool] + """ + Track clicks within the body of each HTML email. + """ + open_tracking: NotRequired[bool] + """ + Track the open rate of each email. + """ + + @classmethod + def create(cls, params: CreateParams) -> DomainClaim: + """ + Start a claim for a domain that another Resend account has already verified. + The domain is recreated under your account with fresh DKIM keys, so the + previous account's DNS records cannot be reused. + see more: https://resend.com/docs/api-reference/domains/claim-domain + + Args: + params (CreateParams): The domain claim parameters + + Returns: + DomainClaim: The created domain claim, or an identical pending claim + if one already existed + """ + path = "/domains/claim" + resp = request.Request[DomainClaim]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + def get(cls, domain_id: str) -> DomainClaim: + """ + Retrieve the latest claim for the placeholder domain created by the claim. + see more: https://resend.com/docs/api-reference/domains/get-domain-claim + + Args: + domain_id (str): The ID of the placeholder domain created by the claim + + Returns: + DomainClaim: The domain claim object + """ + path = f"/domains/{domain_id}/claim" + resp = request.Request[DomainClaim]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def verify(cls, domain_id: str) -> DomainClaim: + """ + Trigger asynchronous DNS verification and ownership transfer for a domain + claim. The claim stays 'pending' while verification runs; poll `get` for + status. + see more: https://resend.com/docs/api-reference/domains/verify-domain-claim + + Args: + domain_id (str): The ID of the placeholder domain created by the claim + + Returns: + DomainClaim: The domain claim object + """ + path = f"/domains/{domain_id}/claim/verify" + resp = request.Request[DomainClaim]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> DomainClaim: + """ + Start a claim for a domain that another Resend account has already verified + (async). The domain is recreated under your account with fresh DKIM keys, so + the previous account's DNS records cannot be reused. + see more: https://resend.com/docs/api-reference/domains/claim-domain + + Args: + params (CreateParams): The domain claim parameters + + Returns: + DomainClaim: The created domain claim, or an identical pending claim + if one already existed + """ + path = "/domains/claim" + resp = await AsyncRequest[DomainClaim]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, domain_id: str) -> DomainClaim: + """ + Retrieve the latest claim for the placeholder domain created by the claim + (async). + see more: https://resend.com/docs/api-reference/domains/get-domain-claim + + Args: + domain_id (str): The ID of the placeholder domain created by the claim + + Returns: + DomainClaim: The domain claim object + """ + path = f"/domains/{domain_id}/claim" + resp = await AsyncRequest[DomainClaim]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def verify_async(cls, domain_id: str) -> DomainClaim: + """ + Trigger asynchronous DNS verification and ownership transfer for a domain + claim (async). The claim stays 'pending' while verification runs; poll + `get_async` for status. + see more: https://resend.com/docs/api-reference/domains/verify-domain-claim + + Args: + domain_id (str): The ID of the placeholder domain created by the claim + + Returns: + DomainClaim: The domain claim object + """ + path = f"/domains/{domain_id}/claim/verify" + resp = await AsyncRequest[DomainClaim]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp diff --git a/tests/domain_claims_async_test.py b/tests/domain_claims_async_test.py new file mode 100644 index 0000000..02613ef --- /dev/null +++ b/tests/domain_claims_async_test.py @@ -0,0 +1,109 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestDomainClaimsAsync(AsyncResendBaseTest): + async def test_domain_claims_create_async(self) -> None: + self.set_mock_json( + { + "object": "domain_claim", + "id": "dacf4072-4119-4d88-932f-6c6126d3a9d1", + "name": "example.com", + "status": "pending", + "domain_id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "region": "us-east-1", + "record": { + "type": "TXT", + "name": "example.com", + "value": "resend-domain-verification=3f8a1c2d4e5b6a7f8091a2b3c4d5e6f7", + "ttl": "Auto", + }, + "blocked_reason": None, + "failure_reason": None, + "created_at": "2026-06-16T17:12:02.059593+00:00", + "expires_at": "2026-06-23T17:12:02.059593+00:00", + } + ) + + params: resend.Domains.Claims.CreateParams = { + "name": "example.com", + "region": "us-east-1", + } + claim = await resend.Domains.Claims.create_async(params) + assert claim["id"] == "dacf4072-4119-4d88-932f-6c6126d3a9d1" + assert claim["status"] == "pending" + assert claim["record"]["type"] == "TXT" + + async def test_should_create_domain_claim_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Domains.Claims.CreateParams = {"name": "example.com"} + with pytest.raises(NoContentError): + _ = await resend.Domains.Claims.create_async(params) + + async def test_domain_claims_get_async(self) -> None: + self.set_mock_json( + { + "object": "domain_claim", + "id": "dacf4072-4119-4d88-932f-6c6126d3a9d1", + "name": "example.com", + "status": "blocked", + "domain_id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "region": "us-east-1", + "blocked_reason": "grace_period", + "failure_reason": None, + "created_at": "2026-06-16T17:12:02.059593+00:00", + "expires_at": "2026-06-23T17:12:02.059593+00:00", + } + ) + + claim = await resend.Domains.Claims.get_async( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + ) + assert claim["id"] == "dacf4072-4119-4d88-932f-6c6126d3a9d1" + assert claim["status"] == "blocked" + assert claim["blocked_reason"] == "grace_period" + + async def test_should_get_domain_claim_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Domains.Claims.get_async( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + ) + + async def test_domain_claims_verify_async(self) -> None: + self.set_mock_json( + { + "object": "domain_claim", + "id": "dacf4072-4119-4d88-932f-6c6126d3a9d1", + "name": "example.com", + "status": "pending", + "domain_id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "region": "us-east-1", + } + ) + + claim = await resend.Domains.Claims.verify_async( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + ) + assert claim["id"] == "dacf4072-4119-4d88-932f-6c6126d3a9d1" + assert claim["status"] == "pending" + + async def test_should_verify_domain_claim_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Domains.Claims.verify_async( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + ) diff --git a/tests/domain_claims_test.py b/tests/domain_claims_test.py new file mode 100644 index 0000000..0a4d1a5 --- /dev/null +++ b/tests/domain_claims_test.py @@ -0,0 +1,129 @@ +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestDomainClaims(ResendBaseTest): + def test_domain_claims_create(self) -> None: + self.set_mock_json( + { + "object": "domain_claim", + "id": "dacf4072-4119-4d88-932f-6c6126d3a9d1", + "name": "example.com", + "status": "pending", + "domain_id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "region": "us-east-1", + "record": { + "type": "TXT", + "name": "example.com", + "value": "resend-domain-verification=3f8a1c2d4e5b6a7f8091a2b3c4d5e6f7", + "ttl": "Auto", + }, + "blocked_reason": None, + "failure_reason": None, + "created_at": "2026-06-16T17:12:02.059593+00:00", + "expires_at": "2026-06-23T17:12:02.059593+00:00", + } + ) + + params: resend.Domains.Claims.CreateParams = { + "name": "example.com", + "region": "us-east-1", + } + claim: resend.DomainClaim = resend.Domains.Claims.create(params) + assert claim["id"] == "dacf4072-4119-4d88-932f-6c6126d3a9d1" + assert claim["object"] == "domain_claim" + assert claim["name"] == "example.com" + assert claim["status"] == "pending" + assert claim["domain_id"] == "d91cd9bd-1176-453e-8fc1-35364d380206" + assert claim["record"]["type"] == "TXT" + assert ( + claim["record"]["value"] + == "resend-domain-verification=3f8a1c2d4e5b6a7f8091a2b3c4d5e6f7" + ) + assert claim["blocked_reason"] is None + assert claim["failure_reason"] is None + + def test_domain_claims_create_with_options(self) -> None: + self.set_mock_json( + { + "object": "domain_claim", + "id": "dacf4072-4119-4d88-932f-6c6126d3a9d1", + "name": "example.com", + "status": "pending", + } + ) + + params: resend.Domains.Claims.CreateParams = { + "name": "example.com", + "region": "us-east-1", + "custom_return_path": "send", + "open_tracking": True, + "click_tracking": False, + "tracking_subdomain": "links", + } + claim = resend.Domains.Claims.create(params) + assert claim["id"] == "dacf4072-4119-4d88-932f-6c6126d3a9d1" + + def test_should_create_domain_claim_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + params: resend.Domains.Claims.CreateParams = {"name": "example.com"} + with self.assertRaises(NoContentError): + _ = resend.Domains.Claims.create(params) + + def test_domain_claims_get(self) -> None: + self.set_mock_json( + { + "object": "domain_claim", + "id": "dacf4072-4119-4d88-932f-6c6126d3a9d1", + "name": "example.com", + "status": "blocked", + "domain_id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "region": "us-east-1", + "blocked_reason": "grace_period", + "failure_reason": None, + "created_at": "2026-06-16T17:12:02.059593+00:00", + "expires_at": "2026-06-23T17:12:02.059593+00:00", + } + ) + + claim = resend.Domains.Claims.get( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + ) + assert claim["id"] == "dacf4072-4119-4d88-932f-6c6126d3a9d1" + assert claim["status"] == "blocked" + assert claim["blocked_reason"] == "grace_period" + + def test_should_get_domain_claim_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Domains.Claims.get( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + ) + + def test_domain_claims_verify(self) -> None: + self.set_mock_json( + { + "object": "domain_claim", + "id": "dacf4072-4119-4d88-932f-6c6126d3a9d1", + "name": "example.com", + "status": "pending", + "domain_id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "region": "us-east-1", + } + ) + + claim = resend.Domains.Claims.verify( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + ) + assert claim["id"] == "dacf4072-4119-4d88-932f-6c6126d3a9d1" + assert claim["status"] == "pending" + + def test_should_verify_domain_claim_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Domains.Claims.verify( + domain_id="d91cd9bd-1176-453e-8fc1-35364d380206", + )