Release 1.1.0 — the /api/v1 surface, vendored from the 57826bad released contract - #6
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 schemason 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.nameis nowDomain.domain, and the ready-madedkimlist of{type, name, value}records is gone. SES hands back tokens, so tokens arewhat is published:
dkimTokens, to publish as CNAME records.DomainVerificationStatusreports one status per DNS record type.dkimand
mxRecordsare replaced bydkimStatus,spfStatusanddmarcStatus;domain,statusandmailFromDomainare now required.statusis SES's ownraw DKIM state (
Success,Pending), the*Statustrio is the platform's ownDNS check, and both are published because they can disagree.
GET /api/suppressionanswers a bare body.{"items", "nextCursor"}, no{"success", "data"}envelope, where it previously published{"success", "data", "hasMore", "cursor"}.suppression.listhands the bodyback untouched, so read
page["items"].webhooks.createnests the endpoint beside the secret.datais{"webhook", "secret"};data["secret"]is unchanged, the id moved todata["webhook"]["id"].Webhook.lastFouris gone. The record now states it never carries asecret, and a fragment of one is still a fragment of one.
Added
DmarcReportV1List.intake_configured(whether a report intake mailbox exists atall, which is what tells an empty page from a clean one),
Suppression.scope,Template.currentVersion,Webhook.domains,Webhook.previousSecretExpiresAt,format: uuidon every{id}path parameter, and a published404on the sevenoperations that answered one without declaring it.
How the spec source was proven
The only source used was the committed contract at monorepo
57826bad— neverapi.sendly.now, neverdocs.sendly.now, never any HTTP URL.Byte-identical, and equal again after key-order-insensitive canonicalization:
Commands run
ruff check .ruff format --check .mypy src(strict)pytest tests/test_contract.pypytestNo skip list was touched: 146 operations and 92 paths on both sides, none added
or removed, so
NOT_SDK_CALLABLEis unchanged and every key-callable operationstill 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.