diff --git a/api/openapi.yaml b/api/openapi.yaml index 3d49b67b7..f7dcc584e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -7801,6 +7801,7 @@ paths: tags: - domains post: + description: "Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict." operationId: registerDomain requestBody: content: diff --git a/docs/api.md b/docs/api.md index c4aad7739..0434e08fc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -579,7 +579,10 @@ of `deleted: true`. Domain deletion adds the durable, open-set Custom sending/receiving domains and their DNS verification. - `GET /v1/domains`, `POST /v1/domains` — list / register (returns required MX + - TXT records and the DKIM selector/key). + TXT records and the DKIM selector/key). Registering a domain the account + already owns is idempotent: it returns the existing row (still 201) and + does not count against the domain cap. A different account's claim is a + 409 `domain_taken` conflict. - `GET /v1/domains/{domain}`, `DELETE /v1/domains/{domain}?confirm=DELETE` — fetch / delete (delete deprovisions the sending identity; irreversible). The deletion receipt's open `sending_teardown` value is the DNS-release diff --git a/docs/design/api-v1-redesign.md b/docs/design/api-v1-redesign.md index bba87c4e6..799a12869 100644 --- a/docs/design/api-v1-redesign.md +++ b/docs/design/api-v1-redesign.md @@ -1017,7 +1017,8 @@ review diligence — a #206-style omission can't merge. > exposes an optional `idempotency_key` tool arg so hosted-MCP callers can retry > safely (the MCP hop is a network boundary the SDK auto-mint can't span). The > create tools don't qualify: `create_agent` (email) and `register_domain` -> (domain) have natural unique keys (retry → 409, never a duplicate), and creates +> (domain) have natural unique keys (a retry reclaims the existing row with +> 201, never a duplicate), and creates > are not the frequent/high-harm path — the rule is *mutating + duplicate-harmful > + no natural dedup*, not call frequency. (`rotate_webhook_secret`, the one > rare-but-dangerous non-create, is already idempotent from the GA-blockers work.) @@ -1258,8 +1259,9 @@ worth making while we're reshaping the contract anyway, roughly in priority: adding it to `create_agent`/`create_webhook`/`register_domain`; on review, idempotency stays scoped to the send-family. The create tools don't meet the bar (*mutating + duplicate-harmful + no natural dedup*): `create_agent` (email) - and `register_domain` (domain) have natural unique keys → retry is a 409, not a - duplicate. The send-family already carries it (and exposes the optional tool + and `register_domain` (domain) have natural unique keys: a retry reclaims + the existing row (201), not a duplicate. The send-family already carries it (and + exposes the optional tool arg so hosted-MCP callers retry safely). Matches how mature email APIs scope it. 8. **Consistent vocabulary — resolved.** `send_email` mixed "email" with `reply_to_message`/`forward_message`. Standardize the noun on the API diff --git a/internal/httpapi/domains.go b/internal/httpapi/domains.go index c8bef9076..9d6c5592b 100644 --- a/internal/httpapi/domains.go +++ b/internal/httpapi/domains.go @@ -314,7 +314,8 @@ func (s *Server) registerDomains() { registerOp(s.API, huma.Operation{ OperationID: "registerDomain", Method: http.MethodPost, Path: "/v1/domains", Summary: "Register a domain", Tags: []string{"domains"}, - Security: []map[string][]string{{"bearer": {}}}, DefaultStatus: http.StatusCreated, + Description: "Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict.", + Security: []map[string][]string{{"bearer": {}}}, DefaultStatus: http.StatusCreated, Responses: map[string]*huma.Response{ "402": s.limitExceededResponse(), "409": s.jsonResponse(reflect.TypeOf(ErrorEnvelope{}), "ErrorEnvelope", diff --git a/sdks/python/src/e2a/v1/generated/api/domains_api.py b/sdks/python/src/e2a/v1/generated/api/domains_api.py index 6a3cffeb3..7c3117af4 100644 --- a/sdks/python/src/e2a/v1/generated/api/domains_api.py +++ b/sdks/python/src/e2a/v1/generated/api/domains_api.py @@ -898,6 +898,7 @@ async def register_domain( ) -> DomainView: """Register a domain + Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. :param register_domain_request: (required) :type register_domain_request: RegisterDomainRequest @@ -966,6 +967,7 @@ async def register_domain_with_http_info( ) -> ApiResponse[DomainView]: """Register a domain + Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. :param register_domain_request: (required) :type register_domain_request: RegisterDomainRequest @@ -1034,6 +1036,7 @@ async def register_domain_without_preload_content( ) -> RESTResponseType: """Register a domain + Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. :param register_domain_request: (required) :type register_domain_request: RegisterDomainRequest diff --git a/sdks/typescript/src/v1/generated/apis/DomainsApi.ts b/sdks/typescript/src/v1/generated/apis/DomainsApi.ts index 4fcac12eb..7372fbf17 100644 --- a/sdks/typescript/src/v1/generated/apis/DomainsApi.ts +++ b/sdks/typescript/src/v1/generated/apis/DomainsApi.ts @@ -158,6 +158,7 @@ export class DomainsApiRequestFactory extends BaseAPIRequestFactory { } /** + * Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. * Register a domain * @param registerDomainRequest */ diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index 1372a3a37..0d189dc96 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -1628,6 +1628,7 @@ export class ObjectDomainsApi { } /** + * Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. * Register a domain * @param param the request object */ @@ -1636,6 +1637,7 @@ export class ObjectDomainsApi { } /** + * Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. * Register a domain * @param param the request object */ diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index 79e8d8390..86aee8e6c 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -1610,6 +1610,7 @@ export class ObservableDomainsApi { } /** + * Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. * Register a domain * @param registerDomainRequest */ @@ -1634,6 +1635,7 @@ export class ObservableDomainsApi { } /** + * Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. * Register a domain * @param registerDomainRequest */ diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index fdd0aa185..dac391c85 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -1165,6 +1165,7 @@ export class PromiseDomainsApi { } /** + * Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. * Register a domain * @param registerDomainRequest */ @@ -1175,6 +1176,7 @@ export class PromiseDomainsApi { } /** + * Registering a domain the account already owns is idempotent: it returns the existing row (still 201) and does not count against the domain cap. Registering a domain owned by a different account is a 409 domain_taken conflict. * Register a domain * @param registerDomainRequest */