Release 1.1.0 — the /api/v1 surface, vendored from the 57826bad released contract - #13
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 npm 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.cancelSchedule` resolves 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. Seventeen auto-pagination generators cover every cursor list. 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 `All` companions hide the difference; the resources that speak the second shape drive the page loop themselves, because the shared helper 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.listResults took `cursor` and answered `cursor` where every other v1 list takes `after`; the platform collapsed that, so both now route through paginateCursor like every other collection instead of hand-rolling a loop. Breaking for a caller driving either of those two by hand — pass `after`, read `next_cursor`. Anyone using listAll or listResultsAll is unaffected. Both per-file page fixtures in the tests are gone in favour of helpers.cursorPage. 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-envelope detection 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.cancelSchedule narrowed the same way emails.get did in the previous commit — the whole surface shared one leak and now shares one field list. cancelSchedule resolves EmailResponse, which the contract has always published for it. sentAt, deliveredAt and bouncedAt are newly declared on Email. The vendored spec now comes from apps/web/openapi/openapi.json, the RELEASED contract, which is the source scripts/sync-spec.mjs 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 66 added and 29 removed lines. 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 the contract tests read was describing an API that is no longer the one running. 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. src/types.generated.ts is `pnpm build:types` over the new file, nothing else.
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 `items` and `nextCursor` off it.
The types carry these already, because they are generated. What a generated type cannot say is why a caller should look at a field, and four of these are useless without that. - listDmarcReports: `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` array, 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 fields 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.
dist is tracked, so leaving it behind the regenerated types would ship a declaration file describing the pre-57826bad wire to anyone reading the package rather than the source. `pnpm build` over the new spec, no other change; release.yml rebuilds it from scratch before publishing anyway.
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-madedkimarray 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 body backuntouched, so read
page.items.webhooks.createnests the endpoint beside the secret.datais{ webhook, secret };data.secretis 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
pnpm format:checkpnpm lintpnpm check-typespnpm testpnpm build.d.tsbuiltThe bidirectional contract suite is included in that run. No 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 operation still has amethod.
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 fields back off
it so a future regression fails here rather than in a caller.