Skip to content

Release 1.1.0 — the /api/v1 surface, vendored from the 57826bad released contract - #6

Merged
AminDhouib merged 6 commits into
mainfrom
release/1.1
Sep 9, 2026
Merged

AminDhouib merged 6 commits into
mainfrom
release/1.1

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

1.1 was written against the contract as it stood on 2026-09-05. The platform has
since deployed 57826bad (2026-09-06), which changed 17 operations and 8 schemas
on the published wire, so this branch re-vendors that released contract and
follows it everywhere the SDK makes a claim about a body.

Version is unchanged at 1.1.0. Nothing is tagged or published here.

What a 1.0 caller has to act on

  • Domain.name is now Domain.domain, and the ready-made dkim list of
    {type, name, value} records is gone. SES hands back tokens, so tokens are
    what is published: dkimTokens, to publish as CNAME records.
  • DomainVerificationStatus reports one status per DNS record type. dkim
    and mxRecords are replaced by dkimStatus, spfStatus and dmarcStatus;
    domain, status and mailFromDomain are now required. status is SES's own
    raw DKIM state (Success, Pending), the *Status trio is the platform's own
    DNS check, and both are published because they can disagree.
  • GET /api/suppression answers a bare body. {"items", "nextCursor"}, no
    {"success", "data"} envelope, where it previously published
    {"success", "data", "hasMore", "cursor"}. suppression.list hands the body
    back untouched, so read page["items"].
  • webhooks.create nests the endpoint beside the secret. data is
    {"webhook", "secret"}; data["secret"] is unchanged, the id moved to
    data["webhook"]["id"].
  • Webhook.lastFour is gone. The record now states it never carries a
    secret, and a fragment of one is still a fragment of one.

Added

DmarcReportV1List.intake_configured (whether a report intake mailbox exists at
all, which is what tells an empty page from a clean one), Suppression.scope,
Template.currentVersion, Webhook.domains, Webhook.previousSecretExpiresAt,
format: uuid on every {id} path parameter, and a published 404 on the seven
operations that answered one without declaring it.

How the spec source was proven

The only source used was the committed contract at monorepo 57826bad — never
api.sendly.now, never docs.sendly.now, never any HTTP URL.

$ git show HEAD:tests/fixtures/openapi.json | sha256sum
18646fbefc7b837b252c708f90baa6a3b4e44f9b92478bf678157c0932f025ee  -

$ sha256sum contract-57826bad.json
18646fbefc7b837b252c708f90baa6a3b4e44f9b92478bf678157c0932f025ee  contract-57826bad.json

$ SENDLY_OPENAPI_URL=<that file> python scripts/sync_spec.py --check
sync_spec: vendored spec is in sync with <that file>

Byte-identical, and equal again after key-order-insensitive canonicalization:

whole-doc equal (canonical): true
ADDED ops: []          REMOVED ops: []         CHANGED ops: []
schemas added: [] removed: [] changed: []
parameters added: [] removed: [] changed: []
responses added: [] removed: [] changed: []
securitySchemes added: [] removed: [] changed: []

Commands run

Command Result
ruff check . all checks passed
ruff format --check . 54 files already formatted
mypy src (strict) no issues in 28 source files
pytest tests/test_contract.py 12 passed
pytest 274 passed

No skip list was touched: 146 operations and 92 paths on both sides, none added
or removed, so NOT_SDK_CALLABLE is unchanged and every key-callable operation
still has a method.

Fixtures

Three test fixtures described bodies the API no longer sends and passed anyway,
because each asserted only a URL or a method. They now carry the shape the route
returns, and the domain-verification and suppression tests read keys back off it
so a future regression fails here rather than in a caller.

…d the wire renames

The platform published `/api/v1` for contacts, lists, templates, domains,
webhooks and suppressions, added topics, snippets, bulk address validation and
deliverability, and renamed several wire-visible fields. This release tracks all
of it in one step, refreshes the vendored contract, and leaves
NOT_YET_IMPLEMENTED empty: every key-callable operation in the spec now has a
method.

DO NOT PUBLISH until the platform deploy that ships the renamed wire has gone
out. A 1.1 on PyPI before that deploy is a client that speaks a contract
production does not yet answer.

Breaking, all of it forced by the API:

- `Template.type` / `Campaign.type` are `emailCategory` / `email_category`, and
  the enum member `HEADLESS` is `SELF_MANAGED_UNSUBSCRIBE`.
- The custom-event body field `data` is `payload` on `events.record`.
- `Domain.mailFromStatus` is `mailFromDomainStatus`.
- `EmailGetResponse` splits into `EmailResponse` and `EmailDetailResponse`.
- `emails.get` answers an explicit field list plus its DELIVERY events. It used
  to hand back the whole row — `bodyHash`, `dedupKey`, `idempotencyKey`,
  `linkMap` and both SES message ids included — and its `events` array was the
  custom analytics relation rather than the delivery history it always promised.
  `to` is now filled from the joined contact, which the spec declared and the
  response never carried.
- `emails.cancel_schedule` answers the email rather than an empty envelope,
  which is what the contract has published all along.
- The double-opt-in confirmation route is `/api/lists/confirm-subscription`.

Added: the v1 half of the six legacy-only resources, under a `_v1` suffix so the
two dialects cannot be confused at a call site; `topics`, `snippets`,
`validation` and `deliverability` as new resources; campaign failure listing and
retry; the workflow graph, clone, pause and resume; and mailbox message
composition. Every cursor list has an `iter_*` companion.

Two cursor dialects now exist and the contract guard knows both: most v1 lists
take `after` and answer `next_cursor`, while topics and validation results take
`cursor` and answer `cursor`. The `iter_*` companions hide the difference; the
resources that speak the second shape drive the page loop themselves, because
`iterate_cursor` sends the wrong parameter and reads the wrong field.
Re-vendors the released contract after the platform closed the second pagination
dialect and narrowed the three /api/emails handlers.

Pagination is uniform again. topics.list and validation.list_results took
``cursor`` and answered ``cursor`` where every other v1 list takes ``after``; the
platform collapsed that, so both iterators route through iterate_cursor like
every other collection instead of being written out by hand. Breaking for a
caller driving either of those two by hand -- pass ``after``, read
``next_cursor``. Anyone using iter_list or iter_list_results is unaffected.

iterate_cursor picked up the "stop if a page hands back the cursor it was given"
guard that the two hand-rolled walkers had. Absorbing them must not drop a stop
condition the resources relying on it had, and now every collection gets it.

Both per-file page fixtures in the tests are gone in favour of support.cursor_page,
which also widened to Sequence so a resource can pass its own element type. A
local fixture that describes a second dialect is exactly how the second dialect
stayed invisible, so they delegate now rather than each restating the shape. The
contract guard's CURSOR_ENVELOPES drops the second envelope it had to learn, and
still keys on shape rather than an endpoint list, so a resource that reintroduces
the dialect fails rather than being assumed away.

emails.list and emails.cancel_schedule narrowed the same way emails.get did in
the previous commit -- the whole surface shared one leak and now shares one field
list. sentAt, deliveredAt and bouncedAt are newly declared on the email body.

The vendored spec now comes from apps/web/openapi/openapi.json, the RELEASED
contract, which is the source scripts/sync_spec.py documents. It was taken from
the wiki copy last round; the two are semantically identical and differ only in
key order, which is why this diff is large and its real content is small.

Production was never a source. Do not publish before the platform deploy that
ships this wire.
The 1.1 branch vendored the contract as it stood on 2026-09-05. The platform
has since deployed 57826bad (2026-09-06), and that deploy changed seventeen
operations and eight schemas on the published wire, so the copy
tests/test_contract.py reads was describing an API that is no longer the one
running -- and that copy is the fixed reference the whole suite compares
against.

Taken from apps/web/openapi/openapi.json at 57826bad, not from api.sendly.now:
a spec synced from the deployed API makes the SDK mirror what is RUNNING
rather than what the platform DECLARES, which launders code-vs-contract drift
into "correct" and destroys the one job the vendored copy has.
Three fixtures described responses the API no longer sends. Every one of them
asserted only a URL or a method, so all three kept passing while standing for
a body that had changed underneath them -- and a fixture is the nearest thing
this suite has to a written claim about the wire.

- The legacy domain record keys the host as `domain`; `name` is gone.
- Domain verification reports one status per DNS record type, and `status` is
  SES's own raw DKIM state (`Success`, `Pending`) rather than a VERIFIED
  verdict for the whole domain. The new fixture reads two of them back, so a
  future collapse of the three into one verdict fails here.
- GET /api/suppression answers a bare {"items", "nextCursor"} with no
  {"success", "data"} envelope, so the fixture carries the body the route
  returns and the test reads both keys off it.
Every response alias in this SDK is dict[str, Any] on purpose, so a docstring
is the only place a caller learns a field exists at all -- and four of these
are useless without knowing why to look at them.

- list_dmarc_reports: `intake_configured` is the field that makes an empty page
  readable. "data": [] used to mean either "no receiver reported a failure" or
  "no intake mailbox exists, so nothing can ever arrive", and reporting the
  second as the first is how a silent misconfiguration gets called healthy.
- domains.create/verify: the response carries `dkimTokens` to publish as CNAME
  records, and one status per record type. `status` is SES's raw DKIM state,
  the `*Status` trio is our own DNS check, and they can disagree.
- suppression.list: alone among the legacy reads it answers no envelope.
- templates.update: `currentVersion` moves only when the rendered content does,
  which is what makes it comparable against the version a campaign sent.
- webhooks.create: the endpoint sits beside the secret now, so the id is at
  data["webhook"]["id"] rather than data["id"].
The release note told a reader to hold 1.1 back until the platform shipped the
renamed wire. It has -- 57826bad, deployed 2026-09-06 -- so the note now says
that, names the commit the vendored spec came from, and keeps the one warning
that still applies: an SDK sending `emailCategory` at a pre-57826bad platform
is still answered 422 on every template and campaign write.

The changelog gains the five wire changes a 1.0 caller has to act on (the
`Domain.name` rename and the dropped `dkim` list, the per-record verification
statuses, the enveloped suppression list becoming a bare body, the nested
webhook create response, and the dropped `Webhook.lastFour`) and the six
additive ones. The README examples now read the keys that exist: DKIM tokens
and per-record statuses on a domain, `intake_configured` before anyone calls a
DMARC page clean, `items`/`nextCursor` off the suppression list, and
data["webhook"]["id"] out of a create.
@AminDhouib
AminDhouib merged commit 4f417a7 into main Sep 9, 2026
2 checks passed
@AminDhouib
AminDhouib deleted the release/1.1 branch September 9, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant