Run Version Sunset from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads every version's dates as of a chosen day, measures each notice window against the floor for the audience, counts the versions alive at once, reads the Deprecation, Sunset and successor-version headers, classifies each queued change by the breaking-change definition, ranks the version scheme, and works out the dates and headers for the next retirement, then hands it all to the run as facts. The run writes the policy or the sunset plan; the page then holds it to its own facts (verreply.js): the ranked scheme, the notice floor and the version cap, the four stages in order, a human channel, every queued change as the definition reads it; the named version, dates in order that keep a published sunset or respect the floor, headers that encode those dates exactly, waves that reach humans, a migration guide in steps, and a 410 grace period.
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"version-sunset"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, api, versions, facts) is missing. The page always sends all five of task, api, versions, carry, facts; carry is an empty string for policy. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - the rules, then one retirement:
policy- the versioning and deprecation policy: two or three candidate policies and a pick, the scheme, the breaking-change definition, the windows and the clock, staged communication, enforcement at the sunset date, governance, exceptions, and a verdict on every queued change.sunset- the plan to retire one version: dated stages from announcement to removal, the Deprecation, Sunset and successor-version headers, communication waves, the migration guide, the 410 grace period, the default switch, and how to know the migration is done.
A missing or unknown task is answered as policy. Both lanes send the same five fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the policy's decisions into carry for the sunset lane.
The input
| Field | Required | What |
|---|---|---|
task | yes | policy or sunset. If it is missing or neither, do policy and set "task": "policy". |
api | yes | the API's name and purpose, the interview answers (paradigm, audience, mobile or enterprise consumers, current state, breaking-change pressure, reachability, deadline, one-off or standing), the current scheme, clock, enforcement, governance and communication, and the as-of date; "not given" means not given. |
versions | yes | the versions with their dates, traffic and default, a deprecated response's headers, and the changes queued. |
carry | send it (empty for policy) | sunset task only: the policy's decisions, when the policy task ran first; may be empty. |
facts | yes | the page's checks, one line each as area | id | status | evidence, the notice floor, and for each task what the page settled. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @samber/api-versioning-policy - the only standard the run applies:
- The paradigm sets the default: REST versions and sunsets; GraphQL evolves one schema field by field (@deprecated is a documentation signal - removal waits for usage to reach zero); gRPC keeps the major version in the proto package. The policy layer - definition, windows, governance - applies to all three.
- The breaking-change definition is written down. Breaking: removing or renaming a field or endpoint, changing a field's type, removing an enum value, adding a required request parameter, changing the status code or content type for the same scenario. Non-breaking: new resources, new optional parameters, new response properties, property order, the length or format of opaque strings, new event types. Declare which enums are open. Keep a narrow, written security exception. Say what is not contractual (message text, ordering, timing). More than about two breaking changes a year means the scheme or the discipline is wrong.
- The scheme, ranked: URI major version by default; header versioning when versions ship on a calendar or URI stability matters (require the header - never pin silently); no versioning (additive-only) for reachable consumers with strong review; date-based account pinning only for frequent breaking changes and integrators who cannot be forced to migrate. No versioning is deleted, not demoted, when consumers are unreachable and breaking changes ship quarterly or faster. A hard deadline promotes URI. Never serve unversioned calls as a fallback.
- Notice follows reachability: at least 12 months for a fully public API, at least 24 when consumers ship mobile apps or are enterprises, the contract's number for partners, contract tests instead of notice for internal consumers. At most 2 or 3 versions alive at once. Write down when the clock starts. Published dates are commitments; benchmarks are floors.
- Deprecation is staged: announce, deprecate (both versions serving, signals live), sunset, removal. The Deprecation header first (RFC 9745, @ and Unix seconds), the Sunset header once the date is firm (RFC 8594, an HTTP-date), Link rel="successor-version" (RFC 8288), deprecated: true in the OpenAPI document - and never headers alone: a changelog, a dashboard banner, email to the accounts still calling, or in-response warnings. Whoever deprecates owns the migration: a guide with steps, tooling where feasible, support.
- At the sunset date: a hard cutoff with a described error by default; fall-forward only when silent continuity is genuinely safer, and then documented loudly. A grace period answering 410 Gone follows the sunset.
- Governance grows with blast radius: an automated diff in CI plus a named engineer's sign-off at least, a review board with a charter next, a full guideline program at platform scale. No breaking change ships without that approval - business-motivated ones least of all.
- Failure modes: a version both deprecated and the default; unversioned access; changes that skip the process; moving sunset dates; headers-only communication; announce-and-walk-away; undocumented fall-forward; an undefined clock; GraphQL @deprecated treated as removal. Migration is complete by usage, never by the calendar.
House rules
- Use only the input. Never invent a customer, a traffic figure, a date the input does not lead to, a vendor's policy or a claim about the API. The skill's benchmarks (12 and 24 months, 2 or 3 versions, about twice a year, 410 Gone) may be stated, always as floors ("at least").
- The page's checks are settled: never call a failed check fine or a passed one broken.
- Dates are YYYY-MM-DD. No URL with a domain - links are paths.
- Write in the language of the input; keep the JSON keys, header names and version ids in English.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
policy
Write the versioning and deprecation policy.
summary- one sentence, at most 40 words: what the policy fixes first.candidates- two or three coherent policies to choose between:name,scheme,windows,enforcement,governance,tradeoff(one sentence each).pick- the name of the candidate you recommend.scheme-choice(the facts' scheme:uri,header,none,date,graphqlorgrpc),why(one sentence).breaking-breakingandnon_breaking(short strings),open_enums(one sentence),security_exception(one sentence),not_contractual(short strings).windows-notice_months(the notice every public consumer gets - at least the facts' floor, which is already 24 when consumers ship mobile apps or are enterprises; 0 for partner or internal),mobile_months(the window for mobile and enterprise consumers, at least 24, when they exist, else 0),clock(release,next-releaseorannouncement),concurrent_max(2 or 3),partner(one sentence: what partners and internal consumers get instead).communication-stages(["announce", "deprecate", "sunset", "removal"]),headers(one sentence naming Deprecation, Sunset and the successor-version link),channels(the human channels, as short strings),migration(one sentence: who owns it and how).enforcement-style(hard-cutofforfall-forward),error(what a retired version answers),grace_days(a number).governance-stage(ci-signoff,review-boardorguideline-program),why(one sentence).exceptions- one sentence: the only reasons to break a version early.changes- one object per queued change, in order:change(as given),verdict(breakingornon-breaking),why(one sentence).caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "policy",
"summary": "one sentence",
"candidates": [ { "name": "Calendar", "scheme": "one sentence", "windows": "one sentence", "enforcement": "one sentence", "governance": "one sentence", "tradeoff": "one sentence" } ],
"pick": "Calendar",
"scheme": { "choice": "uri", "why": "one sentence" },
"breaking": { "breaking": [ "removing a field" ], "non_breaking": [ "adding an optional parameter" ], "open_enums": "one sentence", "security_exception": "one sentence", "not_contractual": [ "error message text" ] },
"windows": { "notice_months": 12, "mobile_months": 0, "clock": "announcement", "concurrent_max": 3, "partner": "one sentence" },
"communication": { "stages": [ "announce", "deprecate", "sunset", "removal" ], "headers": "one sentence", "channels": [ "changelog" ], "migration": "one sentence" },
"enforcement": { "style": "hard-cutoff", "error": "one sentence", "grace_days": 90 },
"governance": { "stage": "ci-signoff", "why": "one sentence" },
"exceptions": "one sentence",
"changes": [ { "change": "as given", "verdict": "breaking", "why": "one sentence" } ],
"caveats": [ ]
}
The page holds it to its own facts (check() in verreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; two or three candidates, each complete, and a pick among them; the ranked scheme, never a deleted one; at least three breaking and three non-breaking kinds of change, the open-enum rule, the security exception and what is not contractual; notice at least the floor, 24 months for mobile and enterprise consumers, a known clock, 2 or 3 versions at once, a word for partners and internal consumers; the stages announce, deprecate, sunset, removal; a headers sentence naming Deprecation, Sunset and the successor link; a human channel; a migration owner; a known enforcement style with its error and a grace period (a fall-forward documented); a governance stage; an exceptions clause; and one verdict per queued change, in order, agreeing with the definition wherever it is not unclear.
sunset
Plan the retirement of the version the facts name.
summary- one sentence, at most 40 words.version- exactly the facts' version to retire.timeline- four objects in order, announce, deprecate, sunset, removal:stage,date(YYYY-MM-DD),what(one sentence). Announce falls on or before the deprecation; when the deprecation starts on the as-of day, both may share that date.headers-deprecation(the Deprecation header value, @ and Unix seconds),sunset(the Sunset header value, an HTTP-date),link(the successor-version Link header value).waves- three or more communication waves in order:when(YYYY-MM-DD),channel,audience,message(one sentence).migration-owner(one sentence),guide(the steps, as short strings),tooling(one sentence).enforcement-style(hard-cutofforfall-forward),after_sunset(what callers get),grace_until(YYYY-MM-DD).default_switch- one sentence: when the default moves off this version (or that it is not the default).metrics- two to four strings: how to tell the migration is done, by usage.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "sunset",
"summary": "one sentence",
"version": "v2",
"timeline": [ { "stage": "announce", "date": "2026-11-02", "what": "one sentence" } ],
"headers": { "deprecation": "@1793577600", "sunset": "Tue, 02 Nov 2027 00:00:00 GMT", "link": "</v3/>; rel=\"successor-version\"" },
"waves": [ { "when": "2026-11-02", "channel": "changelog", "audience": "every integrator", "message": "one sentence" } ],
"migration": { "owner": "one sentence", "guide": [ "a step" ], "tooling": "one sentence" },
"enforcement": { "style": "hard-cutoff", "after_sunset": "one sentence", "grace_until": "2028-01-31" },
"default_switch": "one sentence",
"metrics": [ "one string" ],
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; no URL; no backticks; the version the page names; the four stages in order with dates in order; a version already deprecated keeps its deprecation date and a published sunset is never moved, otherwise the sunset respects the floor; nothing in the past except a deprecation that already happened; a Deprecation value of @ and the deprecate date's Unix seconds; a Sunset value that is the sunset date's HTTP-date, weekday included; a successor link to the page's successor; three or more waves, none in the past, one reaching humans; a migration owner and guide of three steps or more; a known enforcement style, 410 Gone for the grace period, and a grace_until after the sunset; a default switch when the version is the default; and metrics by usage. A wave after the sunset is a note that does not flag.
Worked examples
Three runs from the page's free samples (invented APIs, versions and figures, all checked as of 2026-10-31): the policy for a public shipping API, the sunset plan for the same API carrying that policy, and the sunset plan for a partner ledger API whose published sunset date is kept. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the versions block, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
policy - A public shipping API
The page read it as: integrators will be surprised (7 fail, 8 warn, 2 pass); scheme by rank: date; floor: at least 24 months - consumers ship mobile apps or are enterprises; failing checks: default, notice, fallback, clock, comms, enforce, govern.
Input (facts is 2747 characters; the first 1399 are shown):
{
"task": "policy",
"api": "API: Parcelora API\nWhat it is: Rates, labels and tracking for online merchants; many merchants call it from their own mobile apps.\nParadigm: REST/HTTP\nAudience: Fully public\nConsumers ship mobile apps or are enterprises: yes\nCurrent state: Several versions already live\nBreaking changes in the past year: Roughly quarterly\nCan identify and contact every consumer: no\nHard deadline: no\nOne-off or standing: a standing policy\nCurrent scheme: URI major version (/v1/...)\nUnversioned calls served: yes\nNotice clock: Not stated\nEnforcement: Not decided\nGovernance: Nobody signs off\nCommunication: Headers only\nAs of: 2026-10-31",
"versions": "Versions:\n- v1 | released 2019-04-01 | deprecated 2024-01-15 | sunset 2024-07-15 | traffic 3%\n- v2 | released 2021-09-01 | deprecated 2026-03-01 | sunset - | traffic 41% | default\n- v3 | released 2024-05-01 | deprecated - | sunset - | traffic 50%\n- v4 | released 2026-08-01 | deprecated - | sunset - | traffic 6%\n\nA deprecated response's headers:\nHTTP/1.1 200 OK\nContent-Type: application/json\nDeprecation: true\nX-Request-Id: pcl-88a1\n\nChanges queued:\n- Remove the legacy_id field from shipments\n- Add optional label_format parameter to POST /labels\n- Add enum value held to shipment.status\n- Rename rate.service to rate.service_level\n- Speed up label generation",
"carry": "",
"facts": "The page's checks as of 2026-10-31 (7 fail, 8 warn, 2 pass, 0 n/a):\n- Lifecycle | concurrent | pass | 3 versions alive on 2026-10-31: v2, v3, v4\n- Lifecycle | default | fail | v2 is deprecated and still handed to new integrators\n- Lifecycle | notice | fail | v1: 6 months\n- Lifecycle | undated | warn | v2 deprecated with no sunset date - the Deprecation header may ship first, but the date must follow\n- Lifecycle | residue | warn | v1 still carries 3% after 2024-07-15\n- Lifecycle | overlap | pass | v1 -> v2: 34 months\n- Lifecycle | fallback | fail | unversioned calls are served - removing that later is itself a breaking change\n- Deprecation headers | deprecation | warn | \"true\" - the RFC form is @ followed by Unix seconds (\"true\" is the old draft)\n- Deprecation headers | sunset | warn | missing - add it once the date is firm\n- Deprecation headers | successor | warn | no successor-version link - tooling cannot find the replacement\n- Queued changes | breaking | warn | 3 breaking changes queued - each needs a new version: Remove the legacy_id field from shipments; Add enum value held to shipment.status; Rename rate.service to rate.service_level\n- Queued changes | unclear | warn | Speed up label generation\n- Queued changes | pressure | warn | Roughly quarterly - the scheme or the compatibility discipline is wrong\n- Policy | clock | fail | Not stated\n- Policy | comms | fail | Headers\n\n[... 1348 more characters cut from this display ...]"
}
Reply:
{
"task": "policy",
"summary": "Stop handing the deprecated v2 to new merchants, refuse unversioned calls after notice, pin each account to a dated version, and put a review and a human channel in front of every breaking change.",
"candidates": [
{
"name": "Pinned dates",
"scheme": "Each account is pinned to the dated version it signed up on and upgrades on its own schedule, with an optional version header to test a newer date per request.",
"windows": "At least 24 months from the deprecation announcement before any pinned date is retired, with at most 3 dates alive at once.",
"enforcement": "A hard cutoff that answers a described error naming the account's next date, then 410 Gone for a 90-day grace period.",
"governance": "A review board with a charter approves each breaking change, fed by an automated OpenAPI diff in CI.",
"tradeoff": "Absorbs quarterly breaking changes without forcing merchants whose mobile apps update slowly, at the cost of maintaining per-date transformation layers."
},
{
"name": "Required header calendar",
"scheme": "A required version header carries a calendar release, with calls that omit it refused rather than pinned silently.",
"windows": "At least 24 months from announcement per calendar release, at most 3 releases alive at once.",
"enforcement": "A hard cutoff with a described error and 410 Gone for 90 days afterwards.",
"governance": "An automated diff in CI plus a named engineer's sign-off on every breaking change.",
"tradeoff": "Keeps URIs stable and ships on a calendar, but every merchant must send the header on every call and unreachable consumers who forget it break at once."
},
{
"name": "Fewer URI majors",
"scheme": "Keep the /v1-style URI majors and batch breaking changes so a new major ships at most about twice a year.",
"windows": "At least 24 months from announcement per major, at most 3 majors alive at once.",
"enforcement": "A hard cutoff with a described error and 410 Gone for 90 days afterwards.",
"governance": "A review board that refuses any breaking change outside the batch.",
"tradeoff": "Simplest to read and cache, but it fights a quarterly breaking-change rate that the business has shown no sign of slowing."
}
],
"pick": "Pinned dates",
"scheme": {
"choice": "date",
"why": "Breaking changes ship roughly quarterly and merchants cannot all be reached or forced to migrate, so an account pinned to a dated version is the only scheme that stops each change from breaking someone."
},
"breaking": {
"breaking": [
"removing or renaming a field, endpoint or parameter",
"changing a field's type or format",
"removing an enum value, or adding one to a closed enum",
"adding a required request parameter",
"changing the status code or content type for the same scenario"
],
"non_breaking": [
"adding a resource or endpoint",
"adding an optional request parameter",
"adding a response property",
"changing property order",
"changing the length or format of opaque strings such as ids and tracking tokens",
"adding a webhook event type"
],
"open_enums": "Enums are closed unless the reference marks them open; from the next dated version shipment.status and rate service levels are declared open, so clients must handle unknown values.",
"security_exception": "A change that closes an actively exploited vulnerability or a data leak may ship without notice, approved by the review board and announced in the changelog and by email the same day.",
"not_contractual": [
"error message text",
"the order of items in a list unless a sort is requested",
"response timing and label generation latency",
"rate limit headroom above the documented limit"
]
},
"windows": {
"notice_months": 24,
"mobile_months": 24,
"clock": "announcement",
"concurrent_max": 3,
"partner": "Merchants under a separate contract get the notice in that contract if it is longer; internal consumers get contract tests in CI instead of a notice window."
},
"communication": {
"stages": [
"announce",
"deprecate",
"sunset",
"removal"
],
"headers": "Every response from a deprecated version carries Deprecation as @ and Unix seconds, a Sunset HTTP-date once the date is firm, and a Link with rel=\"successor-version\" to the replacement, and the OpenAPI document marks it deprecated: true.",
"channels": [
"changelog entry",
"dashboard banner for accounts on the version",
"email to every account still calling it",
"in-response warning on deprecated calls"
],
"migration": "The team that deprecates a version owns its migration - a step-by-step guide with a field-by-field diff, a test mode to try the next date, and a support queue until usage reaches zero."
},
"enforcement": {
"style": "hard-cutoff",
"error": "A retired version answers 410 Gone with a JSON error naming the version, its sunset date and the guide's path, such as /docs/migrations.",
"grace_days": 90
},
"governance": {
"stage": "review-board",
"why": "Nobody signs off today while breaking changes ship quarterly to unreachable merchants, so a chartered board with an automated diff in CI must approve every breaking change, business-driven ones included."
},
"exceptions": "Only the narrow security exception may break a version early; commercial deadlines, cost and internal refactors never do.",
"changes": [
{
"change": "Remove the legacy_id field from shipments",
"verdict": "breaking",
"why": "Clients may read legacy_id, so removing it ships only in a new dated version."
},
{
"change": "Add optional label_format parameter to POST /labels",
"verdict": "non-breaking",
"why": "An optional parameter leaves every existing request valid, so it can ship to all versions."
},
{
"change": "Add enum value held to shipment.status",
"verdict": "breaking",
"why": "shipment.status is not declared open today, so held ships in a new dated version that also declares the enum open."
},
{
"change": "Rename rate.service to rate.service_level",
"verdict": "breaking",
"why": "A rename removes rate.service for existing clients, so it ships in a new dated version."
},
{
"change": "Speed up label generation",
"verdict": "non-breaking",
"why": "Timing is declared not contractual and no field, status or content type changes, so it ships to all versions."
}
],
"caveats": [
"Unversioned calls must be announced and then refused under the same 24-month notice, since removing them is itself a breaking change.",
"v1 got 6 months of notice and still carries 3% after its sunset - retire it by usage with direct email, not by the calendar."
]
}
What step 5 prints for it: date | 24 months | hard-cutoff
The page's check: kept - nothing to flag
sunset - A public shipping API
The input carries the saved policy's decisions (R.laneForm fills carry from the policy reply), as the page's handoff does. The page retires v2: deprecation 2026-03-01, sunset 2028-03-01 (the floor).
Input (facts is 2575 characters; the first 1399 are shown):
{
"task": "sunset",
"api": "API: Parcelora API\nWhat it is: Rates, labels and tracking for online merchants; many merchants call it from their own mobile apps.\nParadigm: REST/HTTP\nAudience: Fully public\nConsumers ship mobile apps or are enterprises: yes\nCurrent state: Several versions already live\nBreaking changes in the past year: Roughly quarterly\nCan identify and contact every consumer: no\nHard deadline: no\nOne-off or standing: a standing policy\nCurrent scheme: URI major version (/v1/...)\nUnversioned calls served: yes\nNotice clock: Not stated\nEnforcement: Not decided\nGovernance: Nobody signs off\nCommunication: Headers only\nAs of: 2026-10-31",
"versions": "Versions:\n- v1 | released 2019-04-01 | deprecated 2024-01-15 | sunset 2024-07-15 | traffic 3%\n- v2 | released 2021-09-01 | deprecated 2026-03-01 | sunset - | traffic 41% | default\n- v3 | released 2024-05-01 | deprecated - | sunset - | traffic 50%\n- v4 | released 2026-08-01 | deprecated - | sunset - | traffic 6%\n\nA deprecated response's headers:\nHTTP/1.1 200 OK\nContent-Type: application/json\nDeprecation: true\nX-Request-Id: pcl-88a1\n\nChanges queued:\n- Remove the legacy_id field from shipments\n- Add optional label_format parameter to POST /labels\n- Add enum value held to shipment.status\n- Rename rate.service to rate.service_level\n- Speed up label generation",
"carry": "Scheme: date\nNotice: 24 months (24 for mobile and enterprise), clock from announcement\nVersions alive at once: at most 3\nEnforcement: hard-cutoff, grace 90 days - A retired version answers 410 Gone with a JSON error naming the version, its sunset date and the guide's path, such as /docs/migrations.\nChannels: changelog entry, dashboard banner for accounts on the version, email to every account still calling it, in-response warning on deprecated calls\nMigration: The team that deprecates a version owns its migration - a step-by-step guide with a field-by-field diff, a test mode to try the next date, and a support queue until usage reaches zero.",
"facts": "The page's checks as of 2026-10-31 (7 fail, 8 warn, 2 pass, 0 n/a):\n- Lifecycle | concurrent | pass | 3 versions alive on 2026-10-31: v2, v3, v4\n- Lifecycle | default | fail | v2 is deprecated and still handed to new integrators\n- Lifecycle | notice | fail | v1: 6 months\n- Lifecycle | undated | warn | v2 deprecated with no sunset date - the Deprecation header may ship first, but the date must follow\n- Lifecycle | residue | warn | v1 still carries 3% after 2024-07-15\n- Lifecycle | overlap | pass | v1 -> v2: 34 months\n- Lifecycle | fallback | fail | unversioned calls are served - removing that later is itself a breaking change\n- Deprecation headers | deprecation | warn | \"true\" - the RFC form is @ followed by Unix seconds (\"true\" is the old draft)\n- Deprecation headers | sunset | warn | missing - add it once the date is firm\n- Deprecation headers | successor | warn | no successor-version link - tooling cannot find the replacement\n- Queued changes | breaking | warn | 3 breaking changes queued - each needs a new version: Remove the legacy_id field from shipments; Add enum value held to shipment.status; Rename rate.service to rate.service_level\n- Queued changes | unclear | warn | Speed up label generation\n- Queued changes | pressure | warn | Roughly quarterly - the scheme or the compatibility discipline is wrong\n- Policy | clock | fail | Not stated\n- Policy | comms | fail | Headers\n\n[... 1176 more characters cut from this display ...]"
}
Reply:
{
"task": "sunset",
"summary": "Move the default off v2 to v3 now, fix v2's Deprecation header and add the Sunset and successor signals, then retire v2 on 2028-03-01 - 24 months after its deprecation - with 410 Gone until 2028-05-30.",
"version": "v2",
"timeline": [
{
"stage": "announce",
"date": "2026-03-01",
"what": "v2's deprecation was announced with v3 as its successor; the policy now restates it in the changelog with a firm sunset date."
},
{
"stage": "deprecate",
"date": "2026-03-01",
"what": "v2 keeps serving alongside v3 and every v2 response carries Deprecation, Sunset and the successor-version link, with v2 marked deprecated: true in the OpenAPI document."
},
{
"stage": "sunset",
"date": "2028-03-01",
"what": "v2 stops serving at 00:00 UTC; calls get 410 Gone with a JSON error naming v3 and the path /docs/migrate-v2."
},
{
"stage": "removal",
"date": "2028-05-30",
"what": "After the 90-day 410 grace period the v2 routes and their code are removed."
}
],
"headers": {
"deprecation": "@1772323200",
"sunset": "Wed, 01 Mar 2028 00:00:00 GMT",
"link": "</v3/>; rel=\"successor-version\""
},
"waves": [
{
"when": "2026-11-02",
"channel": "changelog and email",
"audience": "every account calling v2",
"message": "v2 is deprecated since 2026-03-01 and will be retired on 2028-03-01; new integrations now default to v3 and the guide is at /docs/migrate-v2."
},
{
"when": "2026-11-02",
"channel": "dashboard banner",
"audience": "accounts with v2 traffic",
"message": "Your account still calls v2, which retires on 2028-03-01 - see the migration guide and test against v3."
},
{
"when": "2027-09-01",
"channel": "email",
"audience": "accounts still calling v2",
"message": "Six months remain before v2 retires on 2028-03-01; here is your v2 call volume and the steps to move to v3."
},
{
"when": "2027-12-01",
"channel": "in-response warning",
"audience": "every v2 caller",
"message": "v2 responses add a warning that the version returns 410 Gone from 2028-03-01."
},
{
"when": "2028-02-01",
"channel": "email",
"audience": "accounts still calling v2",
"message": "Final notice: v2 stops serving on 2028-03-01 and support will help with any remaining calls."
}
],
"migration": {
"owner": "The team that deprecated v2 owns the migration, staffs a support queue and contacts each account still calling v2 until its traffic reaches zero.",
"guide": [
"Change the base path from /v2/ to /v3/ in a test environment.",
"Apply the field-by-field diff from v2 to v3 to every shipment, rate and label call.",
"Handle unknown values in shipment.status and rate service levels.",
"Run the merchant's label and tracking flows against v3 test mode.",
"Release mobile app builds that call v3 well before 2028-03-01, so installed versions have time to update.",
"Watch the dashboard until the account shows no v2 calls."
],
"tooling": "A per-account dashboard view of v2 call volume by endpoint, plus a request linter in test mode that flags v2-only fields."
},
"enforcement": {
"style": "hard-cutoff",
"after_sunset": "Every v2 call answers 410 Gone with a JSON error naming v3, the sunset date and /docs/migrate-v2, until 2028-05-30.",
"grace_until": "2028-05-30"
},
"default_switch": "v2 is still the default even though it was deprecated on 2026-03-01; the default moves to v3 now, before any further wave, so no new merchant starts on v2.",
"metrics": [
"v2's share of traffic falls from 41% toward zero, tracked weekly.",
"The number of accounts making any v2 calls in the last 30 days.",
"Requests per endpoint on v2, to find the integrations that lag.",
"Zero v2 requests for 30 days before the routes are removed."
],
"caveats": [
"v2 has sent Deprecation: true since 2026-03-01; switch it to @1772323200 and add the Sunset and Link headers now.",
"v2, v3 and v4 already fill the 3-version cap, so the next version carrying the queued breaking changes waits until v2's usage reaches zero."
]
}
What step 5 prints for it: 2026-03-01 announce | 2026-03-01 deprecate | 2028-03-01 sunset | 2028-05-30 removal
The page's check: kept - nothing to flag
sunset - A partner ledger API
The input carries the saved policy's decisions (R.laneForm fills carry from the policy reply), as the page's handoff does. The page retires 2025-01: deprecation 2026-09-01, sunset 2027-09-01 (the published date, kept).
Input (facts is 2032 characters; the first 1399 are shown):
{
"task": "sunset",
"api": "API: Brightledger Partner API\nWhat it is: Ledger sync for accounting partners under contract; every partner has a named technical contact.\nParadigm: REST/HTTP\nAudience: Partners / B2B under contract\nConsumers ship mobile apps or are enterprises: no\nCurrent state: Several versions already live\nBreaking changes in the past year: One or two a year\nCan identify and contact every consumer: yes\nHard deadline: no\nOne-off or standing: a standing policy\nCurrent scheme: Version or date in a request header\nUnversioned calls served: no\nNotice clock: From the next version's release\nEnforcement: Hard cutoff with an error\nGovernance: Diff/linter in CI plus a named engineer\nCommunication: Headers plus a human channel\nAs of: 2026-10-31",
"versions": "Versions:\n- 2024-01 | released 2024-01-10 | deprecated 2025-07-01 | sunset 2026-07-01 | traffic 0%\n- 2025-01 | released 2025-01-15 | deprecated 2026-09-01 | sunset 2027-09-01 | traffic 22%\n- 2026-01 | released 2026-01-12 | deprecated - | sunset - | traffic 78% | default\n\nA deprecated response's headers:\nHTTP/1.1 200 OK\nContent-Type: application/json\nDeprecation: @1788220800\nSunset: Wed, 01 Sep 2027 00:00:00 GMT\nLink: </2026-01/ledgers>; rel=\"successor-version\"\n\nChanges queued:\n- Add response field reconciled_at to ledger entries\n- Make currency required on POST /journal-entries\n- Add new endpoint GET /ledgers/{id}/snapshots",
"carry": "Scheme: header\nNotice: 0 months, clock from next-release\nVersions alive at once: at most 2\nEnforcement: hard-cutoff, grace 90 days - A retired version answers 410 Gone with a JSON error naming the version, its sunset date and the guide's path.\nChannels: direct email to each partner's named technical contact, partner changelog, partner dashboard banner\nMigration: The team that deprecates a version owns the migration - a step-by-step guide, sandbox access to the new version and a check-in with each technical contact whose traffic remains.",
"facts": "The page's checks as of 2026-10-31 (0 fail, 1 warn, 12 pass, 1 n/a):\n- Lifecycle | concurrent | pass | 2 versions alive on 2026-10-31: 2025-01, 2026-01\n- Lifecycle | default | pass | 2026-01 is the default\n- Lifecycle | notice | na | set per partner contract\n- Lifecycle | residue | pass | none\n- Lifecycle | fallback | pass | unversioned calls are refused (as stated)\n- Deprecation headers | deprecation | pass | @1788220800 = 2026-09-01\n- Deprecation headers | sunset | pass | 2027-09-01 - matches the table\n- Deprecation headers | successor | pass | </2026-01/ledgers>; rel=\"successor-version\"\n- Queued changes | breaking | warn | 1 breaking change queued - each needs a new version: Make currency required on POST /journal-entries\n- Queued changes | pressure | pass | One or two a year\n- Policy | clock | pass | From the next version's release\n- Policy | comms | pass | Headers plus a human channel\n- Policy | enforce | pass | Hard cutoff with an error\n- Policy | govern | pass | Diff/linter in CI plus a named engineer\nNotice floor: the number in each partner's contract or SLA.\nVersion to retire: 2025-01; successor 2026-01; traffic 22%.\nDates the page works from: deprecation 2026-09-01 (already happened - the deprecate stage keeps this date, and announce comes on or before it), sunset 2027-09-01 (the table's published date - keep it; never move a published sunset), grace until 2027-11-30\n\n[... 633 more characters cut from this display ...]"
}
Reply:
{
"task": "sunset",
"summary": "Retire 2025-01 on its published sunset of 2027-09-01, keep its correct headers, work each remaining partner's technical contact by email, and answer 410 Gone until 2027-11-30.",
"version": "2025-01",
"timeline": [
{
"stage": "announce",
"date": "2026-09-01",
"what": "Partners were told 2025-01 is deprecated in favour of 2026-01, with the sunset date from their contracts."
},
{
"stage": "deprecate",
"date": "2026-09-01",
"what": "2025-01 keeps serving alongside 2026-01 with Deprecation, Sunset and successor-version headers on every response and deprecated: true in the OpenAPI document."
},
{
"stage": "sunset",
"date": "2027-09-01",
"what": "Requests sending the 2025-01 version header get 410 Gone with a JSON error naming 2026-01 and /docs/migrate-202501."
},
{
"stage": "removal",
"date": "2027-11-30",
"what": "After the 90-day 410 grace period the 2025-01 handlers and transforms are removed."
}
],
"headers": {
"deprecation": "@1788220800",
"sunset": "Wed, 01 Sep 2027 00:00:00 GMT",
"link": "</2026-01/>; rel=\"successor-version\""
},
"waves": [
{
"when": "2026-11-02",
"channel": "direct email",
"audience": "the named technical contact of each partner still sending 2025-01",
"message": "Your integration still sends 2025-01, which retires on 2027-09-01; the guide is at /docs/migrate-202501 and a 2026-01 sandbox is ready."
},
{
"when": "2027-03-01",
"channel": "partner dashboard banner",
"audience": "partners with 2025-01 traffic",
"message": "2025-01 retires on 2027-09-01 - six months remain to switch the version header to 2026-01."
},
{
"when": "2027-06-01",
"channel": "direct email and a scheduled call",
"audience": "technical contacts of partners still on 2025-01",
"message": "Three months remain; we will walk through your remaining 2025-01 calls with you."
},
{
"when": "2027-08-02",
"channel": "direct email",
"audience": "technical contacts of partners still on 2025-01",
"message": "Final notice: 2025-01 answers 410 Gone from 2027-09-01."
},
{
"when": "2027-09-01",
"channel": "partner changelog",
"audience": "every partner",
"message": "2025-01 is retired and answers 410 Gone until its code is removed on 2027-11-30."
}
],
"migration": {
"owner": "The team that deprecated 2025-01 owns the migration and checks in with each technical contact whose traffic remains until it reaches zero.",
"guide": [
"Point the integration at the 2026-01 sandbox.",
"Change the version request header from 2025-01 to 2026-01.",
"Apply the documented field differences between 2025-01 and 2026-01 to ledger and journal calls.",
"Run the partner's reconciliation tests against the sandbox.",
"Switch production and confirm on the dashboard that no 2025-01 calls remain."
],
"tooling": "A per-partner view of requests by version header in the partner dashboard, plus the sandbox for contract tests."
},
"enforcement": {
"style": "hard-cutoff",
"after_sunset": "Requests sending 2025-01 get 410 Gone with a JSON error naming 2026-01, the sunset date and /docs/migrate-202501, until 2027-11-30.",
"grace_until": "2027-11-30"
},
"default_switch": "2025-01 is not the default; 2026-01 already is, and requests without a version header stay refused.",
"metrics": [
"2025-01's share of requests falls from 22% to zero.",
"The number of partners sending the 2025-01 header in the last 30 days.",
"Zero 2025-01 requests for 30 days before the handlers are removed."
],
"caveats": []
}
What step 5 prints for it: 2026-09-01 announce | 2026-09-01 deprecate | 2027-09-01 sunset | 2027-11-30 removal
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./verkit.js"); require("./verreply.js")), then:
const K = VerKit, R = VerReply;
const f = K.form(Object.assign(K.blank(), { name: "Acme API", asof: "2026-10-31", audience: "public", mobile: "no",
versions: "v1 | 2023-01-10 | - | - | 30 |\nv2 | 2025-02-01 | - | - | 70 | default", changes: "Rename order.total to order.amount" }));
// f.rows (every check: area, id, label, status, evidence), f.floor, f.rec (the ranked scheme), f.chg (the classifier)
// f.plan - the next retirement: version, successor, deprecation and sunset dates, and its three headers
const b = K.buildInput(f, "policy"); // or "sunset" (R.laneForm(form, "sunset", policy) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "policy");
// R.check(reply, b) // {status: kept or flagged, notes}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://version-sunset.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://version-sunset.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// version-sunset.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://version-sunset.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://version-sunset.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// Version Sunset.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class Version Sunset {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://version-sunset.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://version-sunset.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://version-sunset.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://version-sunset.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"version-sunset"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "version-sunset"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "version-sunset" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "version-sunset"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "version-sunset"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "version-sunset" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "version-sunset"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "version-sunset" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "policy", "api": "API: Parcelora API\nParadigm: REST/HTTP\n...", "versions": "Versions:\n- v1 | released 2019-04-01 | ...", "carry": "", "facts": "The page's checks as of 2026-10-31 (7 fail, 8 warn, ...):\n- Lifecycle | default | fail | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "policy", "api": "API: Parcelora API\nParadigm: REST/HTTP\n...", "versions": "Versions:\n- v1 | released 2019-04-01 | ...", "carry": "", "facts": "The page's checks as of 2026-10-31 (7 fail, 8 warn, ...):\n- Lifecycle | default | fail | ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "policy" or "sunset", api, versions, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses version-sunset:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A policy reply carries summary, candidates, pick, scheme, breaking, windows, communication, enforcement, governance, exceptions, changes and caveats; a sunset reply carries summary, version, timeline, headers, waves, migration, enforcement, default_switch, metrics and caveats. The code below prints the gist of either: the scheme, the notice and the enforcement, or each dated stage.
KEY="version-sunset:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "sunset" then (.timeline[] | "\(.date) \(.stage)")
else "\(.scheme.choice), \(.windows.notice_months) months, \(.enforcement.style)" end' reply.json
import hashlib, time
key = "version-sunset:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "sunset":
for t in reply["timeline"]:
print(t["date"], t["stage"])
else:
print(reply["scheme"]["choice"], reply["windows"]["notice_months"], "months", reply["enforcement"]["style"])
import { createHash } from "node:crypto";
const key = `version-sunset:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "sunset") for (const t of reply.timeline) console.log(t.date, t.stage);
else console.log(reply.scheme.choice, reply.windows.notice_months, "months", reply.enforcement.style);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "version-sunset:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
if reply["task"] == "sunset" {
ts, _ := reply["timeline"].([]any)
for _, t := range ts {
tt := t.(map[string]any)
fmt.Println(tt["date"], tt["stage"])
}
} else {
sc, _ := reply["scheme"].(map[string]any)
wn, _ := reply["windows"].(map[string]any)
fmt.Println(sc["choice"], wn["notice_months"], "months")
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "version-sunset:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("sunset")) {
for (JsonNode t : reply.path("timeline")) System.out.println(t.path("date").asText() + " " + t.path("stage").asText());
} else {
System.out.println(reply.path("scheme").path("choice").asText() + " " + reply.path("windows").path("notice_months").asInt() + " months");
}
require "digest"
key = "version-sunset:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "sunset"
reply["timeline"].each { |t| puts "#{t["date"]} #{t["stage"]}" }
else
puts "#{reply.dig("scheme", "choice")} #{reply.dig("windows", "notice_months")} months"
end
$key = "version-sunset:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "sunset") { foreach ($reply["timeline"] as $t) echo $t["date"], " ", $t["stage"], "\n"; }
else { echo $reply["scheme"]["choice"], " ", $reply["windows"]["notice_months"], " months\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"version-sunset:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "sunset") { foreach (var t in reply["timeline"]!.AsArray()) Console.WriteLine($"{t!["date"]} {t["stage"]}"); }
else Console.WriteLine($"{reply["scheme"]?["choice"]} {reply["windows"]?["notice_months"]} months");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: version-sunset:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "policy", "api": "API: Parcelora API\nParadigm: REST/HTTP\n...", "versions": "Versions:\n- v1 | released 2019-04-01 | ...", "carry": "", "facts": "The page's checks as of 2026-10-31 (7 fail, 8 warn, ...):\n- Lifecycle | default | fail | ..."}
# - see the input table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "version-sunset:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `version-sunset:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "version-sunset:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "version-sunset:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "version-sunset:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: version-sunset:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"version-sunset:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);