AstroWay/api v2.325.2 · changelog
all systems operational

// product · release notes

Changelog what is new

Only changes that affect integrator code: new endpoints, SDK releases, /openapi.json schema changes, credit price changes, deprecations, breaking changes. Newest first. Internal site tweaks aren't tracked here.

Latest · v2.325.2 Endpoints · 865 CI tests · 888 API contract · v1 stable · 12-mo deprecation

2026-09-14 · A lapsed plan keeps answering for three days instead of returning 402

When a paid period ends, requests are now served on your plan for 3 more days. Before, 402 SUBSCRIPTION_EXPIRED arrived the second expires_at passed, so an integration stopped the moment a payment ran late.

What to do with your code. Nothing is required. To hear about a lapse before your users do, log X-Subscription-Status: grace and X-Subscription-Grace-Ends: they are sent only during those three days. After them the answer is 402 SUBSCRIPTION_EXPIRED as before, and its details now carry expired_at and grace_ended_at.

The same applies to add-on packs.

2026-09-13 · houseSystem refuses a code that does not exist

The field used to accept any string. The engine reads only its first letter, so an unknown letter quietly produced Placidus while houses.system in the response echoed whatever you sent. A chart computed on Placidus came back labelled with a system that was never used.

It now accepts exactly the 25 codes the engine implements: P K R C E W B M O A T V D F G H I i L N Q S U X Y. Case matters, I and i are different systems. Anything else gets a 400 that lists the set.

What to do with your code. Nothing, if you send a letter code or omit the field: the default is still P. If you were sending a name ("Placidus", "Koch"), those now return 400: they worked by accident because the first letter happened to match, and by the same accident "Zodiac" returned Placidus. Send the code instead.

Verified on production before the change: houseSystem: "ZZZ" returned cusp 2 at 192.865, byte for byte what "P" returns, while reporting system: "ZZZ".

2026-09-13 · allowlist is no longer listed in the spec

origin_restriction.type: "allowlist" is gone from openapi.json: the CreateKey and UpdateKey schemas now list two values, server-only and public.

What to do with your code. Nothing. The value is still accepted and still means what public means, so existing calls keep working. If you generate a client from the spec, the regenerated one simply loses a third option that duplicated the second.

This is the announced step referred to on 12 September, when both spellings started minting the same pk_ class. It was removed from the document rather than from what the API accepts: refusing a value the spec listed yesterday is worse than saying plainly that it is the older spelling.

2026-09-13 · A key can be limited to a list of endpoints

POST /v1/keys and PATCH /v1/keys/{id} take allowed_endpoints: a list of paths under /v1, each exact (chart) or a namespace (embed/*). A key with a list may call what the list names and nothing else, whatever its plan includes.

What to do with your code. Nothing until you set a list: without one behaviour is unchanged, and no key carries one today. If you set one, handle 403 ENDPOINT_NOT_IN_SCOPE; details carries the list itself. null clears the limit, and an empty list is refused, because it would read equally well as “everything” and as “nothing”.

The limit applies to both key classes, and to /v1/public/* and /v1/embed/* as well. Account reads (/v1/auth/keys/me, /v1/keys/usage) are not exempt from it, unlike credits: a scope is a permission you wrote on the key rather than a matter of metering, and a key in page source should not hand out the account’s spending.

2026-09-13 · The browser key is its own class, pk_

POST /v1/keys with origin_restriction.type: "public" now returns a key prefixed pk_live_ or pk_test_, and requires a non-empty allowed_origins: without a list it answers 400. For that class the origin rule inverts. A request with no Origin and no Referer gets 403 ORIGIN_REQUIRED instead of passing as a server call, and Origin: null from an <iframe sandbox> counts as no origin rather than as permission.

What to do with your code. Nothing, if you use ordinary keys: aw_live_ and aw_test_ behave exactly as before, the server-only and allowlist modes are unchanged, and no existing key has been converted. If you parse the key prefix on your side, add pk_. If you create keys through the API with type: "public", add the list of domains to the request.

That mode used to mean “any origin”, so a key meant to sit in page source also worked from curl with no headers at all. Keys created before 13 September keep working as they did; what changes is what gets issued from now on.

A key sent to /v1/public/* or /v1/embed/* is now actually checked. Those two prefixes mount ahead of the auth check, so the origin policy never ran on them: the same server-only key got 403 on /v1/keys/usage and 200 on /v1/embed/moon-phase with the identical hostile Origin. A request to those prefixes without a key is unchanged: still anonymous, still IP-limited, because that is how every widget <iframe> reaches us.

type: "allowlist" now issues the same pk_ class as type: "public". That mode used to create a secret key which listed origins could use, and which also passed with no origin at all, because that is the ordinary server-to-server pass. So the very mode the documentation recommended for widgets stayed the weaker one. The value has not gone away and is still accepted, so generated clients keep working: it simply produces what public produces, and the response says so, with a pk_ key prefix and origin_restriction.type: "public". Existing keys with a list are not converted and behave as they did.

2026-09-13 · The rate limit is divided between allowed origins

When a key carries a list of allowed origins, each entry in that list now gets its own share of the rate limit: the key’s limit divided by the number of entries, rounded up. Until now any one origin could take the whole limit.

What to do with your code. Nothing, if your list has a single entry: the share equals the limit and behaviour is unchanged. If it has several, note that one of your sites can no longer take the others’ quota. There is a new 429 naming the entry that ran out, and two headers, X-RateLimit-Origin-Limit and X-RateLimit-Origin-Remaining, sent only on requests admitted by an entry in the list.

The counter is kept per list entry, not per host that arrived: *.example.com is one counter for every subdomain beneath it. Otherwise the limit could be walked around by inventing a new subdomain per request.

2026-09-13 · The compatibility model was recalculated, and the numbers moved

Compatibility now runs on a new model. The fields are the same (score, label, harmony, tension) and so are their types, but the numbers differ for every pair. One field is new: top, the five heaviest aspects behind the score, each with its strength and its sign.

Affected: /v1/synastry, /v1/match/score, /v1/group-synastry, /v1/public/synastry, and the widgets under /v1/embed/*.

What to do with your code. If you stored a score, recompute it: the same pair now returns a different number. If you compare or rank pairs by it, nothing changes, the scale is still 0 to 100. The label thresholds are untouched: challenging below 40, mixed below 52, balanced below 64, harmonious above.

Why. The old formula was harmony / (harmony + tension), and it broke at the edges: a single trine returned exactly 100, a single square exactly 0, and a Sun conjunct Saturn counted as harmony. The new model weighs the aspect and the pair of bodies, counts minor aspects, reads a conjunction by body rather than by pair, and normalises so that the middle of the scale is the middle.

In the same release /v1/match/score stopped contradicting itself. Its greenFlags and redFlags were still classifying conjunctions by the old rule, so one aspect could be listed as a green flag while counting toward tension in the same answer. The flags and the score now say the same thing.

The order of topAspects changed there too. It was ranked by a weight formula of its own, so the “top” aspects were not the ones the score was built from. It now uses the model’s weight, in the same order as top. If you read topAspects[0] as the headline aspect for a pair, it is now a different aspect.

2026-09-12 · A credit budget for a single key

Credits pool across the account, so any one key could spend all of them. PATCH /v1/keys/{id} now takes credits_cap_cycle: a ceiling for that key alone, in credits per cycle.

What to do with your code. Nothing, if you set no budget: without one, behaviour is unchanged. If you set one, handle the new 429 KEY_BUDGET_EXHAUSTED. It arrives when the key has spent its own budget, even while the account still has credits, and even on a plan with overage: a budget you can overspend for money is not a budget.

null clears it and 0 means a key that may spend nothing. The counter is the one credits already use, and it resets with the billing cycle. While a budget is set, every answer carries X-Key-Budget-Limit and X-Key-Budget-Remaining.

2026-09-12 · A browser key’s domain list is now editable

PATCH /v1/keys/{id} took only name. It now takes origin_restriction as well, so a domain can be added to a live pk_ key without creating a second one.

What to do with your code. Nothing, if your list of domains does not change. If you were creating a new key every time you needed to add a domain, one PATCH now does it, and the old key no longer has to be left alive just in case.

The new list applies from the next request, the id and the key string stay the same, and rotate keeps the list. The class is not editable: a pk_ key cannot become server-only, a secret aw_ key cannot be given a list, and an empty list is not accepted. All three answer 400 with the reason rather than rewriting the row quietly.

Separately: the answer to an unknown key now names both classes, aw_ for the server and pk_ for the browser, instead of only “Invalid API key”.

2026-09-11 · BaZi: the full classical apparatus, true solar time, and one deprecation

Eight new paths. POST /v1/bazi/chart returns the whole chart; beside it sit the single-purpose hidden-stems (藏干), na-yin, life-stages (十二長生), symbolic-stars (神煞), interactions (刑冲合害) and strength. POST /v1/chinese/true-solar-time corrects the clock for the birth longitude.

Where the transmissions differ, both readings ship rather than one: the yin-stem direction through the twelve stages, both yin-blade placements, the 庚 nobleman, the element of the 午未 harmony. The strength thresholds are named as our convention rather than canon.

Deprecation. elementCounts, dominantElement and missingElements on /v1/bazi/element-balance count the year and month only, four characters of eight, and never open the branches. They keep answering unchanged until 2027-09-08 and now carry Deprecation and Sunset headers. The full count lives in the new balance object.

What to do with your code. If you read elementCounts, move to balance.visible (the same eight characters as integers) or balance.weighted (with the hidden stems distributed). If you do not, nothing: everything else is additive. The trueSolarTime option requires longitude, time and timezoneOffset together, or it returns 400.

2026-09-11 · Biorhythm now returns data, not only a picture

POST /v1/render/biorhythm drew three curves, but there was no way to read the numbers out of an SVG. POST /v1/wellness/biorhythm returns the same cycles as JSON.

What to do with your code. Nothing, if the picture is enough: /render/biorhythm is unchanged and draws exactly what it drew before. If you were parsing the SVG or recomputing the cycles yourself, you can read the values directly now.

Each day carries the three cycle values in the range -1 to 1 (multiply by 100 for the percentage form) and the day of life. Critical days come as their own list, and they are the days a cycle crosses zero rather than the days where the value happens to be small. The birthday is critical for all three cycles at once, because all three start at zero.

birthDate is required, rangeStart and rangeEnd are not: without them you get today plus 29 days. The range includes both ends, caps at 366 days, and a reversed range returns 400 rather than an empty table.

The method has no evidential basis: it is a late-19th-century numerological construct. The response says so in its own disclaimer, and we do not suggest building anything on it beyond entertainment.

2026-09-10 · Seven published response schemas described fields the API does not return

The response schema in /openapi.json is built from the example we write for each endpoint. Twelve of those examples described fields the API does not return. They are captures of a live answer now.

EndpointThe schema saidWhat comes back
POST /v1/keysapiKey, rateLimitapi_key, api_key_masked, rate_limit, credits_limit, credits_used, origin_restriction
GET /v1/keys/usagetopEndpoints[].totaltopEndpoints[].requests and .credits, plus a credits block
GET /v1/statusuptime, ephemerisLoaded, wasmReady, epheFiles[]key, usage, limits, endpoints, daily; and it answers 400 MISSING_KEY without a key, which the schema said was not needed
GET /v1/healthstatus, timestampplus version and uptime_seconds, which it was already returning
POST /v1/interpret/natalsummary, sections{}interpretation, disclaimer, model, language, chart_summary
POST /v1/interpret/synastrysummary, dynamics, outlookinterpretation, disclaimer, model, language
POST /v1/interpret/elementelementinterpretation, element_balance
POST /v1/interpret/placementplacementinterpretation, requested_planet
POST /v1/synastryaspects[] with body1, chart1PlanetcrossAspects[] with planet1, planet2, isApplying, plus chart1, chart2, compatibility
POST /v1/horoscope/dailytitle, body, transitFacts[]horoscope, disclaimer, model, language
POST /v1/planetsplanets[].speedspeedLong, speedLat, speedDist, plus name, isRetrograde, declination
80 /v1/numerology/* pathslifepath, soulurgelifePath, soulUrge: the hyphen was stripped instead of camel-cased

The answers themselves did not change: the description was wrong, not the API.

Sixteen more operations carried the same shape of error: a 200 schema naming no field at all. Fourteen were tarot catalogue listings whose examples read [...78 cards...] and could not be parsed, plus /health and /version, which carried an example and no schema. Every example is a capture from the live endpoint now. The last two, POST /v1/mcp/streaming and POST /v1/mcp/tool-call-stream, were not empty by mistake: they answer text/event-stream rather than JSON, and the spec says so now and describes the shape of each frame. A generator will no longer promise you parseable JSON where a stream arrives.

What to do with your code. If you generated types from /openapi.json for these endpoints, regenerate them: the fields you typed never arrived, and code that relied on them was reading undefined. The common case is the key created by POST /v1/keys, which is read as api_key.

2026-09-09 · Lilith is drawn on every Western image, and bodies arrived

The Lilith point (the Moon’s mean apogee) came back in the /v1/chart response as mean Apogee but reached no image: the glyph and the name mapping both existed, and the renderers’ body lists did not carry it. It is now drawn as ⚸ on wheel-western, bi-wheel, tri-wheel, composite, cosmogram and aspect-grid. The three Vedic formats deliberately do not carry it: Black Moon Lilith is not a Jyotish body.

The /v1/reports/stellaforge poster printed a raw ephemeris label, ♆ ☌ mean Apogee, in its aspect list. It prints the glyph now.

What to do with your code. If you want the previous set of bodies, ask for it: options.bodies takes a list and narrows the renderer’s default set. The previous picture is ["Sun","Moon","Mercury","Venus","Mars","Jupiter","Saturn","Uranus","Neptune","Pluto","North Node","Chiron"]. Absent means the default, so if you change nothing, nothing breaks. An empty list returns 400 rather than the default: a list comes out empty by accident far more often than on purpose, and quietly falling back would hide that. A name outside the set of bodies we can draw returns 400 as well, rather than vanishing quietly.

2026-09-04 · Public endpoints: a separate quota for sites that render server-side

Public access without a key was counted per IP only: 30 requests per hour. For a browser that is right, because every visitor brings their own IP and their own quota. For a site calling from its own server it does not work: one machine fetches on behalf of every visitor and burns the 30 in a handful of page loads.

Now, when a request carries the X-AstroWay-Site-URL header with your site address, a different quota applies: 300 requests per hour per site, with a 600 per hour ceiling per IP. Without the header nothing changes, 30 per hour per IP.

What to do with your code. If you call from a browser or embed the /v1/embed/* iframe widgets, do nothing: behaviour is unchanged and the quota still belongs to each visitor separately. If the calls come from your server, add X-AstroWay-Site-URL: https://your-domain/ and cache the responses on your side. The header was already allowed in CORS, so no extra configuration is needed.

The header is a declaration, not a credential, which is why the IP ceiling stays: however many domains one address names, no more than 600 requests per hour will come out of it in total. Both counters are needed at once. The site counter binds when one site calls from many addresses (CDN, load balancer); the IP counter binds when many names come from one address.

Responses gained an X-RateLimit-Scope header with the value ip or site. It tells you which counter is currently the tightest, and the remaining X-RateLimit-* headers describe that one.

2026-09-03 · Public synastry without a key, and planetary hours stop lying about the current hour

POST /v1/public/synastry returns the compatibility score and the cross-aspect grid for two birth charts without a key. The response is deliberately small:

{ "score": 68, "label": "harmonious",
"aspects": [{ "planetA": "Pluto", "planetB": "Saturn", "aspect": "Square", "orb": 0.0509, "applying": false }],
"count": 45 }

Aspects are sorted by orb, tightest first, so the first six are the six a widget shows. Everything else the authenticated POST /v1/synastry returns stays behind a key: the per-aspect interpretations, the house overlay, composite and Davison charts, and the two natal charts themselves.

The call costs 3 units of the public bucket rather than 1. It computes two charts and a 45-cell matrix, so counting it like a moon phase would be wrong. The limits are unchanged: 10 such calls an hour from an undeclared address, 100 per declared site, 200 per address. The response carries X-RateLimit-Cost: 3.

POST /v1/planetary-hours: isCurrent is no longer an empty promise. It was published on all 24 hours and was false on all 24, because the route never passed a current hour to the engine. The caller decides it now: send atLocalHour (decimal local hours, e.g. 15.2) and the hour containing that moment comes back with isCurrent: true. Without it the field is absent entirely, because the response is cacheable and a flag from the server’s clock would be stale by the time anyone renders it.

Polar day and polar night no longer return 24 empty hours. The response used to say planetary hours are undefined and then return a table whose spans were all zero. hours is empty now, and sunTimes.warning says why. The values of sunTimes.polarState are documented: normal, polar-day, polar-night.

What to do with your code. If you read isCurrent, it was always false for you: add atLocalHour to the request, or work out the current hour yourself from startHour and endHour. If you render the hour table, handle an empty array at polar latitudes.

2026-08-31 · Ayanamsa is accepted by name, and ten documentation pages were corrected

Vedic endpoints have always read the numeric ayanamsaId. The documentation meanwhile showed "ayanamsa": "lahiri" on ten pages, and the schemas are passthrough, so the string validated and was silently ignored: the answer came back computed with Lahiri whatever you sent. Measured on /v1/vedic/dashas/vimshottari/maha: "lahiri" and "raman" returned the same sidereal Moon, while ayanamsaId 1 and 3 return different nakshatras.

The name works now. Accepted: fagan-bradley, lahiri, de-luce, raman, krishnamurti (also kp), yukteshwar, jn-bhasin, true-citra, djwhal-khul. An explicit ayanamsaId wins when both are sent. An unknown name now returns 400 INVALID_FIELD listing the valid ones instead of quietly falling back to Lahiri. Both fields are described in /v1/openapi.json, so they reach generated clients.

What to do with your code. If you were sending ayanamsa with any name other than lahiri, your responses were computed with Lahiri and will now be computed with the school you asked for, so the numbers change. If you were sending a name outside the list above, the request will start returning 400.

Separately, ten documentation pages whose examples did not work were corrected: POST /v1/keys takes name, not label; /v1/horoscope/daily is sign + date + language, not a birth chart; /v1/transit-calendar wants the birth data nested under natal; /v1/vedic/muhurat/* takes search_window_start / search_window_end / topN. /v1/interpret/aspects and /v1/vedic/vargas do not exist; for the latter each divisional chart has its own path, for example /v1/vedic/varga/D9.

Two further pages told you to pass zodiac=tropical for tropical Vedic. No such field exists, and the Vedic endpoints compute sidereally regardless, ignoring zodiacType in the body. If you were sending it, it did nothing; tropical positions come from the Western branch (/v1/chart).

2026-08-28 · Request parameters that were never validated are now declared and checked

Six endpoints read a field their schema never declared. Such a field was neither validated nor present in /openapi.json, so it reached the calculation raw and broke the response in three different ways, always under a 200.

What to do with your code. If you were sending valid values, do nothing: behaviour is unchanged and numeric strings still coerce. If you were sending something invalid, you now get a 400 with INVALID_INPUT instead of a quietly corrupted response. The new parameters are declared in /openapi.json, so they will appear in the SDKs and MCP after the next regeneration.

orb on /v1/parallel-aspects and /v1/midpoint-trees was the dangerous one: a non-numeric value made every comparison false, so instead of six aspects or ten trees you got an empty result. An empty array reads as “nothing found” rather than “bad request”, so there was no way to notice it on your side.

targetDate on /v1/vedic/jaimini/dasha-summary and /v1/vedic/doshas/kp/sade-sati returned every dasha as null. key and maxAge on /v1/primary-directions quietly skewed the search: key here is a rate in degrees per year, not a mode name, and is now a bounded number.

A second pass closed the class. parts on /v1/arabic-parts and maxYears on /v1/firdaria emptied the result the same way, 20 lots and 63 periods down to none. termsData and decansData on /v1/almuten, /v1/essential-dignities, /v1/hyleg and /v1/receptions threw a 500. /v1/hd/incarnation-cross never checked its gate numbers at all, and /v1/human-design/transits took a natal date the schema never declared and overlaid the transits on the wrong chart, with a response that looked entirely normal.

2026-08-27 · The spec now describes every request body, and the public responses

/v1/openapi.json published an empty request body for 246 paths: {"type": "object"} with no fields at all. The endpoints worked and validated their input as always, but the spec said nothing about that input, so a client generated from it had no type for the request and an agent reading the schema could not see the field names. That count is now zero.

The largest groups: numerology (50 paths), tarot (47), the Chinese block (14), pet (14), vargas (16), Zi Wei (13), business (11), BaZi (11), financial (10), wellness (9), Mayan (8). Twenty-six paths whose handlers read no body at all (the 16 geomancy figures, the 5 palmistry lines, four streams, the runes catalogue) now point at EmptyBody: the spec used to say the body could be anything, and now says the truth, that there is none.

PATCH and PUT had no request body in the spec at all. The generator described one for POST only, so PATCH /v1/whitelabel/config published nothing while its route validates every call. It has a schema now.

Eight public routes had no response schema. Across the spec 733 of 758 operations had one, and the ones without it were exactly those read server-side without a key: POST /v1/public/chart, POST /v1/public/human-design, the three horoscopes, GET /v1/public/tarot/daily, /moon-phase and /planet-of-day. Each now describes ok, data and _footer down to the fields, and both POSTs take their schema from their authenticated twin, so a field added to /v1/chart cannot go missing from /v1/public/chart.

The printed examples that were wrong are corrected with it: public tarot showed position and card as strings when both are objects, and /public/moon-phase and /public/planet-of-day omitted localized entirely. The /v1/chart and /v1/human-design examples were missing seven and six top-level fields, and channels was printed in a shape the engine has never returned.

What to do with your code. Nothing, if you write requests by hand: no field was renamed, added or removed, and validation is unchanged. If you generate a client from the spec, regenerate it: where you had unknown you now get a type with fields, and the public responses are typed too.

2026-08-24 · Vedic compatibility docs corrected, the body takes chart1/chart2

The /vedic/compatibility/ page showed a request body with person1 and person2. None of the six endpoints has ever accepted those: the schema has always required chart1 and chart2, so every example on that page returned 400 INVALID_INPUT. The schema did not change, the documentation did.

Also corrected on the same page:

  • /vedic/compatibility/full returns data.manglik.partner1, partner2 and verdict. There are no person1.present, cancelled or bhrigu keys; Bhrigu-Bindu is its own endpoint, /bhrigu-match.
  • The /ashtakoot response is data.{method, school, boy, girl, total, max, threshold, points{}, doshas[], recommendation}. passing_threshold, verdict, breakdown[] and warnings[] do not exist.
  • Cost: mangal-match, bhrigu-match and full are 50 credits each, not 20, 30 and 100. Prices did not change, the table had the wrong numbers.

The examples on the page have now been run against production, and the JSON response is a verbatim capture for the same pair the request above uses.

2026-08-22 · A repeated request served from cache no longer spends credits

Our cost page and conventions page say a response carrying X-Cache: HIT costs no credits. It cost the full price: the charge happens before the cache and nothing undid it. Measured with three identical calls to /v1/acg/by-category: two came back X-Cache: HIT, and the balance fell by 50 each time.

An identical request repeated within 5 minutes now costs 0, and X-Credits-Used and X-Credits-Remaining say so honestly.

The same request in another language could come back in someone else’s. The cache key covered ?lang=, which rides in the query string, but not the Accept-Language header. Two clients on one plan with the same body and different languages shared an entry. A request with Accept-Language: de returned the Ukrainian answer, which additionally claimed requestedLanguage: "uk". Language is part of the key now.

/v1/interpret/* no longer passes through this cache. It has its own AI cache with an hour TTL and the documented 50% discount on a repeat, and that is what should answer, but the exclusion list held interpret rather than interpret/*, so the general cache always won. /v1/horoscope/* stays here on purpose: its handler calls the model on every request, and this cache is the only thing between a repeat and a second generation.

What to do with your code. Nothing. If you were defeating the cache by adding something random to the body to avoid paying twice for one answer, you no longer need to.