Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,50 @@

Notable changes to `dodomain-sdk`. The import package is `dodomain`.

## 0.5.0

Parity with `@dodomain/node` 0.7.0 and the `/v1` contract changes it tracked:
webhook-endpoint auto-pause (DoDomain PR #342) and the apps list's white-label
connect-flow settings. Purely additive, so upgrading from 0.4.0 is a drop-in.

### Added

* **`webhook_endpoints.resume(endpoint_id)`** (sync and async) —
`POST /api/v1/webhook-endpoints/{endpointId}/resume`. doDomain now pauses an
endpoint by itself once it has had no successful delivery for 7 days and at
least 5 dead-lettered deliveries in that span; this clears the pause and
restarts the 7-day clock. Idempotent: an endpoint that is not paused comes back
unchanged with a 200. It does not resend the deliveries skipped while paused —
redrive those from the dashboard. Another app's endpoint id is a 404, as
everywhere else.
* **`WebhookEndpoint.paused_at` / `WebhookEndpointWithSecret.paused_at`** — when
the endpoint was auto-paused, or `None` while it is delivering. Carried through
`WebhookEndpointWithSecret.endpoint`.
* **`App.connect_headline`, `.connect_subheadline`, `.connect_success_cta_label`,
`.connect_success_redirect_url`, `.connect_font_preset`,
`.hide_connect_footer_help`** — the white-label connect-flow settings the
apps list has returned since 2026-09-23, as stored (`None` / `False` until
configured). They render on the hosted flow only while the plan includes
white-label.
* **`ConnectFontPreset`** — the `Literal["system", "humanist", "serif", "rounded"]`
alias for `App.connect_font_preset`.

### Changed

* `webhook_endpoints.update` documents that a url which actually changes also
resumes an auto-paused endpoint.
* The OpenAPI contract guard (`tests/test_openapi_contract.py`) now also pins
`WebhookEndpointSummary` and `WebhookEndpointSecretResponse`, so a new required
field on either fails the suite when the fixture is regenerated.

### Notes

* New response fields parse tolerantly, as before: a body recorded before the
field existed reads as `None` / `False`, while a field present with the wrong
type still fails loudly.
* Delivery statuses (including the new `skipped`) are not part of the public
`/v1` contract and are not modelled by this SDK.

## 0.4.0

Parity with `@dodomain/node` 0.5.0 and 0.6.0, and with the `/v1` contract changes
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,12 +254,14 @@ endpoint = client.webhook_endpoints.create(url="https://acme.example/webhooks/do
endpoint.secret # "whsec_…" — SHOWN ONCE. Store it now.

for e in client.webhook_endpoints.list():
print(e.id, e.url) # never carries a secret
print(e.id, e.url, e.paused_at) # never carries a secret; paused_at is None unless auto-paused

client.webhook_endpoints.update("whe_123", url="https://acme.example/v2") # secret unchanged
rotated = client.webhook_endpoints.rotate_secret("whe_123")
rotated.secret # the new one, also shown once

client.webhook_endpoints.resume("whe_123") # clears paused_at; a no-op if it is not paused

client.webhook_endpoints.delete("whe_123")
```

Expand All @@ -275,6 +277,19 @@ Three things that will bite if assumed away:
read-one route — so it costs one list request, and the `NotFoundError` it raises
carries `status_code == 0` because no 404 came back from the server.

### Auto-paused endpoints

doDomain pauses an endpoint by itself once it has had **no successful delivery
for 7 days and at least 5 dead-lettered deliveries** in that span; `paused_at`
holds when that happened. While paused, new events are still recorded as
deliveries but marked *skipped* and never sent.

`resume(endpoint_id)` clears `paused_at` and restarts the 7-day clock. It is
idempotent — an endpoint that is not paused comes back unchanged, so a
reconciler may call it unconditionally. It does **not** resend the skipped
deliveries; redrive those from the dashboard. An `update` that actually changes
the url resumes the endpoint too.

## Rotating your secret key

```python
Expand Down Expand Up @@ -339,6 +354,7 @@ check.guide.steps # copy-ready manual instructions
for app in client.apps.list():
print(app.id, app.name, app.public_key, app.sandbox)
print(app.tls_issuer_ca) # the CA your certificates are issued with, or None
print(app.connect_headline, app.connect_font_preset) # white-label settings, or None
```

A secret key sees exactly its own app — listing siblings would widen a single
Expand Down
4 changes: 3 additions & 1 deletion src/dodomain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from __future__ import annotations

__version__ = "0.4.0"
__version__ = "0.5.0"

from ._client import AsyncDoDomain, DoDomain
from ._transport import DEFAULT_BASE_URL, RateLimitSnapshot
Expand All @@ -46,6 +46,7 @@
CheckDomainResult,
ComposedRecord,
Confidence,
ConnectFontPreset,
Connection,
ConnectionPage,
ConnectionStatus,
Expand Down Expand Up @@ -89,6 +90,7 @@
"ComposedRecord",
"Confidence",
"ConflictError",
"ConnectFontPreset",
"Connection",
"ConnectionPage",
"ConnectionStatus",
Expand Down
45 changes: 45 additions & 0 deletions src/dodomain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"CheckDomainResult",
"ComposedRecord",
"Confidence",
"ConnectFontPreset",
"Connection",
"ConnectionPage",
"ConnectionStatus",
Expand Down Expand Up @@ -88,6 +89,10 @@
#: ``packages/core/src/schemas.ts``; the server refuses anything else.
RotationOverlapHours = Literal[0, 1, 24]

#: The hosted connect flow's white-label typeface. Transcribed from
#: ``CONNECT_FONT_PRESETS`` in the app repo (``packages/core/src/schemas.ts``).
ConnectFontPreset = Literal["system", "humanist", "serif", "rounded"]

#: Every DNS record type a connect session may request, from the app repo's one
#: record-type home (``packages/core/src/record-capabilities.ts``).
RECORD_TYPES: tuple[str, ...] = ("A", "AAAA", "CNAME", "TXT", "MX")
Expand All @@ -106,6 +111,10 @@
#: ``Literal`` above only makes at type-check time.
OVERLAP_HOURS_VALUES: tuple[int, ...] = (0, 1, 24)

#: The font presets, for the runtime check :data:`ConnectFontPreset` only makes
#: at type-check time.
CONNECT_FONT_PRESETS: tuple[str, ...] = ("system", "humanist", "serif", "rounded")


# ── payload readers ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -143,6 +152,15 @@ def _req_bool(payload: dict[str, Any], key: str) -> bool:
return value


def _opt_bool(payload: dict[str, Any], key: str) -> bool | None:
value = payload.get(key)
if value is None:
return None
if not isinstance(value, bool):
raise _fail(f"field {key!r} should be a boolean or null", payload)
return value


def _req_int(payload: dict[str, Any], key: str) -> int:
value = payload.get(key)
if isinstance(value, bool) or not isinstance(value, int):
Expand Down Expand Up @@ -899,6 +917,16 @@ class App:
#: rather than the weaker ``caa_restricts_issuance``. Additive on the wire,
#: so it carries a default and sits after the original fields.
tls_issuer_ca: str | None = None
#: White-label connect-flow settings (Pro and Scale), as stored; ``None`` until
#: configured. They render on the hosted connect flow only while your plan
#: includes white-label. Additive on the wire, so they carry defaults.
connect_headline: str | None = None
connect_subheadline: str | None = None
connect_success_cta_label: str | None = None
#: Where the success button goes when a session has no ``return_url`` of its own.
connect_success_redirect_url: str | None = None
connect_font_preset: ConnectFontPreset | None = None
hide_connect_footer_help: bool = False
raw: dict[str, Any] | None = field(default=None, compare=False, repr=False)

@classmethod
Expand All @@ -913,6 +941,12 @@ def _from_api(cls, payload: Any) -> App:
brand_color=_opt_str(data, "brandColor"),
created_at=_req_datetime(data, "createdAt"),
tls_issuer_ca=_opt_str(data, "tlsIssuerCa"),
connect_headline=_opt_str(data, "connectHeadline"),
connect_subheadline=_opt_str(data, "connectSubheadline"),
connect_success_cta_label=_opt_str(data, "connectSuccessCtaLabel"),
connect_success_redirect_url=_opt_str(data, "connectSuccessRedirectUrl"),
connect_font_preset=_literal(data, "connectFontPreset", (*CONNECT_FONT_PRESETS, None)),
hide_connect_footer_help=_opt_bool(data, "hideConnectFooterHelp") or False,
raw=data,
)

Expand Down Expand Up @@ -976,6 +1010,12 @@ class WebhookEndpoint:
#: trailing-slash variant comes back canonical.
url: str
created_at: datetime
#: When doDomain AUTO-PAUSED this endpoint — no successful delivery for 7 days
#: and at least 5 dead-lettered deliveries in that span — or ``None`` while it
#: is delivering normally. While paused, new events are recorded as *skipped*
#: deliveries and never sent, until ``webhook_endpoints.resume`` (or an
#: ``update`` that actually changes the url) clears it.
paused_at: datetime | None = None
raw: dict[str, Any] | None = field(default=None, compare=False, repr=False)

@classmethod
Expand All @@ -986,6 +1026,7 @@ def _from_api(cls, payload: Any) -> WebhookEndpoint:
app_id=_req_str(data, "appId"),
url=_req_str(data, "url"),
created_at=_req_datetime(data, "createdAt"),
paused_at=_opt_datetime(data, "pausedAt"),
raw=data,
)

Expand Down Expand Up @@ -1016,6 +1057,8 @@ class WebhookEndpointWithSecret:
#: ``whsec_…`` — store it now. Kept out of ``repr`` so an exception traceback
#: or a debug print of this object cannot spill the signing secret into a log.
secret: str = field(repr=False)
#: See :attr:`WebhookEndpoint.paused_at`.
paused_at: datetime | None = None
raw: dict[str, Any] | None = field(default=None, compare=False, repr=False)

@property
Expand All @@ -1026,6 +1069,7 @@ def endpoint(self) -> WebhookEndpoint:
app_id=self.app_id,
url=self.url,
created_at=self.created_at,
paused_at=self.paused_at,
raw=self.raw,
)

Expand All @@ -1038,6 +1082,7 @@ def _from_api(cls, payload: Any) -> WebhookEndpointWithSecret:
url=_req_str(data, "url"),
created_at=_req_datetime(data, "createdAt"),
secret=_req_str(data, "secret"),
paused_at=_opt_datetime(data, "pausedAt"),
raw=data,
)

Expand Down
37 changes: 36 additions & 1 deletion src/dodomain/resources/webhook_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ def _spec_update(endpoint_id: str, url: str, idempotency_key: str | None) -> Req
)


def _spec_resume(endpoint_id: str, idempotency_key: str | None) -> RequestSpec:
# A verb sub-path with no body, like rotate-secret: resuming is an action, not a
# property you set.
return RequestSpec(
"POST", _endpoint_path(endpoint_id, "/resume"), idempotency_key=idempotency_key
)


def _parse_list(payload: Any) -> tuple[WebhookEndpoint, ...]:
items = payload.get("endpoints") if isinstance(payload, dict) else None
if not isinstance(items, list):
Expand Down Expand Up @@ -162,7 +170,8 @@ def update(
"""Repoint an endpoint at a new URL.

The signing secret is untouched — moving hosts must not force a receiver to
re-key. ``url`` is the only mutable field an endpoint has.
re-key. ``url`` is the only mutable field an endpoint has. A ``url`` that
actually changes also resumes an auto-paused endpoint (see :meth:`resume`).
"""
return WebhookEndpoint._from_api(
self._client.request(_spec_update(endpoint_id, url, idempotency_key))
Expand Down Expand Up @@ -202,6 +211,24 @@ def rotate_secret(
)
)

def resume(self, endpoint_id: str, *, idempotency_key: str | None = None) -> WebhookEndpoint:
"""Resume an endpoint doDomain paused automatically.

doDomain pauses an endpoint once it has had no successful delivery for 7
days **and** at least 5 dead-lettered deliveries in that span;
:attr:`~dodomain.models.WebhookEndpoint.paused_at` is set while it is. This
clears ``paused_at`` and restarts the 7-day clock.

**Idempotent:** an endpoint that is not paused comes back unchanged (a 200,
not an error). Events that happened while it was paused were recorded as
*skipped* deliveries and are **not** resent by this call — redrive them
from the dashboard. An :meth:`update` that changes the url resumes the
endpoint too.
"""
return WebhookEndpoint._from_api(
self._client.request(_spec_resume(endpoint_id, idempotency_key))
)


class AsyncWebhookEndpoints:
"""``client.webhook_endpoints`` on :class:`~dodomain.AsyncDoDomain`."""
Expand Down Expand Up @@ -257,3 +284,11 @@ async def rotate_secret(
)
)
)

async def resume(
self, endpoint_id: str, *, idempotency_key: str | None = None
) -> WebhookEndpoint:
"""Resume an auto-paused endpoint. See :meth:`WebhookEndpoints.resume`."""
return WebhookEndpoint._from_api(
await self._client.request(_spec_resume(endpoint_id, idempotency_key))
)
Loading
Loading