API Stability
Our deprecation promise, the rolling sunset window, and the live stability scorecard.
API Stability
We keep aliases around. We don't pull paths out from under you.
If we rename an endpoint, the old path keeps working. There is no fixed calendar date for removal — the only signal you'll ever see is a rolling sunset in the response headers that tells you the earliest we could remove it. As long as your traffic keeps arriving, that floor rolls forward.
This page documents the policy, the headers, the live deprecations endpoint, and the internal scorecard we grade ourselves against release-by-release.
Our deprecation promise
- Aliases are cheap. When we rename a path, we add the old name to a rewrite table and leave it there. Removal is a deliberate human decision, never a calendar trigger.
- No fixed sunset dates. The
Sunset:header publishes a rolling floor — the earliest we could remove this path. It never points backward and rolls forward as your traffic continues. - One global policy. Every alias follows the same window
(
/api/v1/deprecationsshows the current values). Special cases — a path we genuinely need to remove on a tighter timeline for security reasons — are filed as separate policy decisions, not per-entry knobs. - Human-in-the-loop removal. Even when our internal cron flags an alias as removal-eligible, the default action is to leave it alone.
How the rolling window works
Three policy constants live in apps/web/lib/api-stability/policy.ts:
| Constant | Default | Meaning |
|---|---|---|
minimumGraceDays | 180 | Floor — no entry is ever removal-eligible before addedAt + 180d. |
inactivityDays | 60 | Hits must be zero for this many consecutive days. |
noticeWindowDays | 60 | Per-caller: ≥ this many days since their last deprecation notice. |
The Sunset: header on a deprecated response is computed as:
floor = addedAt + minimumGraceDays
activityEnd = lastHitAt + (inactivityDays + noticeWindowDays)
sunset = max(floor, activityEnd, now + noticeWindowDays)So:
- Path just deprecated, still seeing traffic →
Sunset = max(floor, lastHit + 120d). Honest about "earliest possible" and rolls forward every time you hit it. - Path quiet for many weeks → tightens, but never closer than 60 days from now.
Response headers on deprecated paths
Every response from an aliased path carries:
Deprecation: true RFC 9745
Link: </api/v1/numbers/buy>; rel="successor-version", RFC 8288
<https://x402.dial.wtf/docs/api-reference/stability>; rel="deprecation",
<https://x402.dial.wtf/docs/api-reference/stability>; rel="deprecation-policy"
Sunset: Sun, 16 Nov 2026 00:00:00 GMT RFC 8594 (rolling floor)
Warning: 299 - "deprecated; rolling sunset — see Link rel=deprecation-policy"GET / HEAD requests get 308 Permanent Redirect with these headers.
Body-bearing methods (POST / PUT / PATCH / DELETE) are rewritten
transparently on the same request — the body reaches the successor
handler intact, and the headers are stamped on the response.
Programmatic check — GET /api/v1/deprecations
curl https://x402.dial.wtf/api/v1/deprecations{
"policy": {
"minimumGraceDays": 180,
"inactivityDays": 60,
"noticeWindowDays": 60,
"docs": "https://x402.dial.wtf/docs/api-reference/stability"
},
"entries": [
{
"from": "/api/v1/lines/buy",
"successor": "/api/v1/numbers/buy",
"reason": "renamed numbers/buy",
"addedAt": "2026-05-20",
"minimumSunset": "2026-11-16",
"estimatedSunset": "2026-11-16",
"lastHitAt": null,
"uniqueCallersLast30d": null
}
]
}Cached 5 minutes. No auth.
In @dial/api:
import { DialClient } from "@dial/api";
const client = new DialClient({ baseUrl: "https://x402.dial.wtf" });
const { policy, entries } = await client.apiStability.deprecations();The SDK also emits a one-time console.warn per deprecated
(path → successor) pair seen in your process — so a renamed path
shows up the first time you hit it without you having to wire anything
up.
How notifications reach you
Today, in-band: the headers above + the SDK warning + the
/api/v1/deprecations endpoint. A daily cron (legacy-rewrite-review)
aggregates the structured api.legacy_path_hit events that every alias
emits, and is wired to email + dashboard banner pipelines as they land
(tracked at #119). Org-level Slack / Discord delivery lands with #46.
Stability scorecard
Every row is a question we want to answer "yes" to before we tell a paying customer "the API is stable." We grade ourselves honestly so the gap turns into a roadmap.
| Mark | Meaning |
|---|---|
| ✅ | Shipped, enforced in CI / middleware / code. |
| 🟡 | Partially done — works in some places, not enforced. |
| ❌ | Not started. |
Versioning & naming
| # | Standard | Status |
|---|---|---|
| 1 | Major version in the path (/v1/, /v2/) — never in headers only | ✅ |
| 2 | Minor/patch additions are additive-only within a major | 🟡 |
| 3 | Path renames add a rewrite alias and keep it indefinitely | ✅ |
| 4 | Endpoint shape changes ship as a new path; old path stays | 🟡 policy doc, this page |
| 5 | Pre-1.0 surfaces labelled experimental / preview in docs and OpenAPI | ❌ |
Deprecation signalling (HTTP)
| # | Standard | Status |
|---|---|---|
| 6 | Deprecation: true on every response from a deprecated path (RFC 9745) | ✅ |
| 7 | Sunset: published as a rolling floor (RFC 8594) | ✅ |
| 8 | Link: ...; rel="successor-version" (RFC 8288) | ✅ |
| 9 | Warning: 299 informational on the deprecated response | ✅ |
| 10 | OpenAPI "deprecated": true auto-derived from the rewrite table | ✅ |
| 11 | Per-method deprecation supported by the table shape | 🟡 table extensible |
Rolling sunset window
| # | Standard | Status |
|---|---|---|
| 12 | Minimum grace floor in months (default 180d) | ✅ |
| 13 | Activity-gated removal: zero-hit ≥60d and per-caller notice ≥60d | ✅ headers; ❌ persistence pending #98 |
| 14 | Per-caller notification pipeline triggered by observed traffic | ❌ pending #98 + #119 |
| 15 | Removal is a human-in-the-loop confirmation, never automated | ✅ by design |
| 16 | Single global policy, no per-entry overrides | ✅ |
Telemetry
| # | Standard | Status |
|---|---|---|
| 17 | Hit counter, tagged by path / caller / version / UA | ✅ stderr events; ❌ KV counter pending #98 |
| 18 | Admin dashboard: which deprecated paths still see traffic, by caller | ❌ |
| 19 | Alert: a new caller (UA never seen) hits a deprecated path | ❌ |
| 20 | Top callers per deprecated path — auto-drafts a notification | ❌ pending #119 |
Contract testing & breaking-change detection
| # | Standard | Status |
|---|---|---|
| 21 | OpenAPI spec is the source of truth, generated from Zod + catalog | ✅ |
| 22 | CI diffs OpenAPI between main and the PR, fails on breaking changes | ❌ #117 |
| 23 | Generated client (@dial/api) regenerated + version-bumped in same PR | 🟡 drift check exists, semver bumping not enforced |
| 24 | Backwards-compat snapshot suite per route per version | ❌ |
| 25 | Mock server published from the OpenAPI spec | ❌ |
Idempotency, retries, and write safety
| # | Standard | Status |
|---|---|---|
| 26 | All write endpoints accept Idempotency-Key | 🟡 x402 settlement hashes only — #96 |
| 27 | Idempotency keys persist ≥24h | 🟡 same |
| 28 | Idempotency conflicts (same key, different body) → 409 | ❌ |
| 29 | Documented retry-safe vs. non-retry-safe error codes | 🟡 |
Errors
| # | Standard | Status |
|---|---|---|
| 30 | Error codes are stable strings, never repurposed | ✅ |
| 31 | Central error-code registry / docs page | ❌ |
| 32 | Errors include a request_id echoing X-Request-Id | ✅ |
| 33 | Errors use a stable { type, code, message } triplet | 🟡 |
Rate limiting & quotas
| # | Standard | Status |
|---|---|---|
| 34 | RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers | ❌ #98 |
| 35 | 429 responses include Retry-After | ✅ |
| 36 | Quota state queryable at /account / /usage | 🟡 |
Webhooks & async surfaces
| # | Standard | Status |
|---|---|---|
| 37 | Webhook payload format is versioned independently from REST | 🟡 #114 |
| 38 | Non-2xx webhook deliveries are replayed with exponential backoff | ❌ #114 |
| 39 | Webhook signing-key rotation is zero-downtime + documented | ❌ #114 |
Documentation & change communication
| # | Standard | Status |
|---|---|---|
| 40 | Machine-readable changelog (CHANGELOG.md or /api/changelog JSON) | ❌ #119 |
| 41 | Human changelog page with "Migration from X to Y" guides | ❌ #119 |
| 42 | Email / in-dashboard notice triggered by observed traffic on a deprecated path | ❌ pending #119 |
| 43 | Status page (uptime + incidents) | ❌ #106 |
| 44 | Public roadmap | 🟡 GitHub Issues |
Authentication & key lifecycle
| # | Standard | Status |
|---|---|---|
| 45 | Long-lived programmatic API keys with scopes | ❌ #61 |
| 46 | Key rotation: create new → swap → revoke old, zero-downtime | ❌ #61 |
| 47 | Test-mode vs. live-mode keys, distinguishable by prefix | ❌ #61 |
| 48 | Keys can be restricted to source IPs / scopes | ❌ #61 |
SDK lifecycle
| # | Standard | Status |
|---|---|---|
| 49 | Official SDK auto-regenerated from OpenAPI on every release | 🟡 #117 |
| 50 | SDK pins a minimum supported API version | ❌ |
| 51 | SDK surfaces deprecation warnings | ✅ warn-once per (path → successor) |
| 52 | SDK has its own semver + changelog independent of the API | 🟡 #117 / #119 |
API Keys (Long-Lived Bearer)
Mint dial_live_* keys from the dashboard for bots and scripts that cannot refresh a Privy session every hour.
Buy Phone Line POST
Buy an exclusive phone line — no KYC required. $100 activation includes the first month and 10,000 free texts. Renews at $60/mo with 5,000 free texts per month. Full send and receive SMS. Manage from the dashboard with a Privy session.