VRChat publishes no specification. Every path, field, type, and example here is reverse-engineered from live traffic, so a confident sentence nobody checked is the failure this repo produces most.
test/arazzo.yaml drives ~500 workflows against the live API and writes the
capture to test/.out/har/arazzo.har. That capture is the evidence. Query it
before writing anything:
jq -r '[.log.entries[] | select(.request.url | contains("/users/")) | .response.content.text | fromjson] | .[0]' test/.out/har/arazzo.har- Every property, type, enum value,
requiredentry, and example comes from a captured response. Never add one from a field name, a sibling schema, or the client's behaviour. - A field that is always
nullestablishes nothing about its type. Write{}rather than guessing from the name. Never write a barenullable: true: Ajv rejects it without atype, and the schema check fails. nullable: truemeans JSONnullwas captured. A field that is sometimes absent is not nullable.- Close an
enumonly when the set is known. Two observed values do not make a set; a generated client will reject the third. Until it is known, write the values seen so far as theenumand mark itx-incomplete: true:vrchat/incomplete-enumskeeps theenumindist/openapi-test.yaml, so the suite fails on a new value, and turns it intoexampleseverywhere else, so clients stay open to it. Every bundle keepsx-incomplete, so a generator can still offer the known values, as TypeScript's'a' | 'b' | (string & {}). /configreturns decoy properties, plausible-looking names VRChat rerolls at random. Never document one, and keepAPIConfigadditionalProperties: true.
Adding needs evidence; removing needs far more. A capture records one account in
one state, and a response changes shape with that state — presence carries
nine keys for an offline account and nineteen for an online one.
A property missing from a capture is a coverage gap, not a phantom. Add the workflow that reaches the state carrying it, and judge the property once that workflow has run. Deleting on the strength of one capture removes what the suite merely failed to reach.
{}, a value only ever null, and a property no response has carried all mean
the same thing: no workflow reaches the case that would settle it. Add the
workflow to test/arazzo.yaml; never close the gap by guessing.
Write such a value as $ref: Unknown, and as anyOf: [$ref: Unknown, {type: "null"}] once a null is captured: null is evidence, not an unknown. vrchat/reject-unknown makes Unknown reject every value in dist/openapi-test.yaml, so the suite fails wherever it captures one, until the spec describes it.
required cuts both ways. A property every observed response carries but that
required omits is either required, or missing the workflow that shows it
absent. A property in required that some response omits is neither.
Documenting only what the traffic has shown is a rule about what reaches a
description, not about what you are allowed to think. Inference is how you
find the traffic worth capturing. Guess widely, then let a request settle it.
- An ID prefix names its resource, and a resource with its own prefix usually
has a collection of its own.
icatandivibin a validation message unpacked toGET /instanceCategoriesandGET /instanceVibes, two routes no capture had ever touched. - Send a deliberately wrong value to make the API name the format it wants.
categoryId: "x"answeredcategoryId must be a 'icat' ID. - Take the spelling from paths the description already carries. VRChat writes
top-level collections as camelCase plurals:
/avatarStyles,/tokenBundles,/moderationReports. - A candidate path an existing route swallows answers with that route's error.
/instances/categoriesreturns the 400 fromgetInstanceparsingcategoriesasworldId:instanceId, which rules out every nested spelling in one request. - Read a field where it is populated, not where it is empty.
languagesanduserIconsare[]on every instance the suite creates and full on a busy public one. - Probe candidates in a batch and read the status codes. A 200 establishes a route.
An inference no request settles stays out of the description. Say it to the user as an open question instead.
A validation error that lists the allowed values settles the set: sort must be one of ["purchaseDate"‚"expireDate"‚"name"]‚ not "x" closes the enum on getProductPurchases. Give the workflow that provokes it a condition on the exact message, copied from the capture, so a value VRChat adds or removes fails that workflow instead of passing unseen:
successCriteria:
- condition: $statusCode == 400
- condition: $response.body#/error/message == 'sort must be one of ["purchaseDate"‚"expireDate"‚"name"]‚ not "x"'A message that also reports other fields, such as the required ones a probe left out, gets a type: regex criterion on the clause naming the set instead, so the pin follows the set and nothing else:
successCriteria:
- condition: $statusCode == 400
- context: $response.body#/error/message
condition: region must be one of ["us"‚"use"‚"eu"‚"jp"]‚ not "x"
type: regexEvery closed enum a request can carry gets such a workflow, unless the API answers a wrong value without naming the set.
One curl answers what a route does now, without running the suite. The session
cache holds the cookie:
curl -s -A "specification-test/1 (https://vrchat.community)" \
-b "auth=$(cat test/.out/session)" \
https://api.vrchat.cloud/api/1/economy/storesUse it to check a single route, to confirm what a capture only implies, or to separate an API behaviour from a tooling artifact: when curl and the suite disagree on the same URL, the client differs, not VRChat.
A curl records one moment. Never write a description from one alone. Add the
workflow to test/arazzo.yaml, then document what it captures.
VRChat answers {"error":"The endpoint you're looking for is not implemented by our system."} when no route matches. A removed endpoint and a malformed path
both produce it: /api/1//css/app.css and a percent-encoded dot return it
byte-for-byte. This body never establishes that a route is gone. Request the
exact path directly before marking anything deprecated.
A failing step ends its workflow, so every operation after it goes unexercised and its coverage disappears without saying so. Give each operation its own workflow. Chain steps only where a later one needs state an earlier one created: a create-read-delete lifecycle belongs together, a run of independent reads does not.
- Never write about the evidence. No "observed", "unverified", "appears to", "not established", "no other value seen".
- Never assert what the wire cannot show: lifetimes, units, defaults, causes, what the client does with a value.
- State a rule once. Where a response component explains a status code, the operations returning it stay silent.
Copy the body from the capture verbatim. VRChat substitutes lookalike
characters in validation messages — ˸ (U+02F8) for a colon, ‚ (U+201A) for a
comma, ․ (U+2024) for a full stop — and a retyped example normalises them,
which is how you can tell nobody captured it.
Mark x-deleted: <date> only where every observed call answers the generic
"not implemented" 404. Keep the operation and its tag: the published bundles
drop it, the test bundle keeps it, so the suite goes on calling the route and
a revival shows up as a failing not-found workflow.
The date is the day the route started answering that 404, which the suite now
records the morning it happens. For a route that died before the suite existed,
vrchatapi/specification-test commits one response file per operation daily
back to 2023, so git log -S '\404 Not Found`'over the file dates it. Where neither covers the route, fall back to the day it was marked and say no more than that. Never date one fromvrchatapi/monitor: a path outlives its endpoint in the client bundle, and /economy/seller/eligibility` sat there months after
it stopped answering.
Mark deprecated: true where the route still answers but VRChat has replaced
it or the client no longer uses it. Keep the original tag and add deprecated.
An operation id is a public URL. vrchat.community/reference/<kebab-case-id>
is what the changelogs, the release notes and every link anyone has saved point
at, and generated clients take their method names from the same string. Renaming
or deleting one breaks all of that, so open a matching PR on
vrchatapi/vrchat.community adding a redirect from the old id before the rename
lands, and fix the links in any release notes that named it.
Every object schema, and every union of two or more types, lives in its own file under openapi/components/schemas and is reached by $ref. One written inline inside another schema has no name, so each generated client invents its own, and a second schema that needs the same shape has to restate it. A $ref made nullable with anyOf: [$ref, {type: "null"}] is not a union: clients read it as the referenced type.
vrchat/no-nested-object is what catches this. A schema in its own file carries a title taken from that file, so one without a title is still nested somewhere; the rule reports each one once, against the file it is written in. Pull the schema out rather than adding a title in place, which would silence the rule without moving anything.
A schema pulled out for one field of another is named after that parent, then the field, dropping the word they share: the productType of CreateProductRequest is CreateProductRequestType, the region of CreateInstanceRequest is CreateInstanceRequestRegion. Where a request accepts fewer values than the shared schema its responses use, it gets one of these rather than narrowing the shared one, and the shared one extends it with what only responses carry: InstanceRegion is anyOf CreateInstanceRequestRegion and const: unknown. JSON Schema has no other way to extend an enum (json-schema-spec#979), and openapi-generator merges such an anyOf back into one enum by default (SIMPLIFY_ONEOF_ANYOF_ENUM).
Never add a YAML comment. A fact about the API belongs in a description.
respectcloses any schema that omitsadditionalProperties, so a genuinely open one must sayadditionalProperties: true. Thevrchat/close-schemasdecorator changesdriftonly, and does not reach the schema check.respectmasks secrets in the capture and sends the real value.********in a HAR is evidence about nothing.openapi.jsonandopenapi-internal.jsoncarry the source's OpenAPI version. The-3.xbundles pass throughdownlevel/openapifrompackages/redocly-plugin-downlevel, which rewrites them as that version.dist/openapi-test.yamlcarries the source as written. Check which one you are validating against before believing a finding.drift'ssecurity-baselineflags every unauthenticated workflow for the missing cookie it exists to assert. Every finding it produces is a 401 and none is actionable, sotest:driftnames the rules it wants instead of taking the default set. It passes--serverfor the same reason: the localhttpexecthe suite drives shares the capture, andundocumented-endpointreports all twelve of its exchanges against a description that never had them.- A
oneOfthat matches no branch reports every property in the body as unevaluated, so one missing property produces hundreds of findings naming innocent ones. Fix the branch that should have matched, never the properties the finding names. respectrejects--skipalongside--workflow, and runs only what you name, so an authenticated workflow needs-w sessiontoo, and-w loginas well whentest/.out/sessionis missing or stale.- The suite resolves
operationIdagainst the bundle, so a new path needs an entry inopenapi/components/paths.yamlas well as its file underopenapi/components/paths/. Without it every step naming the operation fails asUnknown operationId, andvrchat/no-unlisted-pathsfails the lint. pnpm testvalidates againstdist/, neveropenapi/. Runpnpm bundlefirst: a stale bundle passes checks the current schema fails, and hides the schema drift a fresh one reports.- A
-wrun writestest/.out/partial.harand leaves the suite capture alone.coverageanddriftread the suite capture, so run them after a full run. - A response file may already serve another operation. Check with
grep -rnbefore rewriting one: overwritingFavoriteGroupListResponse.yamlfor a new route silently broke the three workflowsgetFavoriteGroupsruns through it. - One
schema checkmessage carries every failure the step found, separated by code frames. Reading only the first hides the rest, so three undocumented properties get found one suite run apart instead of together. - Ajv reports
unevaluatedPropertiesat the outermost object that fails and does not descend, so fixing a property on a shelf can reveal others nested under it.driftand the suite's schema check disagree on which they name. jq'sgsubover a multi-megabyte string takes minutes. A coloured code frame in a check message reached 14MB. Filter lines by substring before any regex touches them.
pnpm bundle |
build dist/ |
pnpm lint |
redocly, eslint, arazzo |
pnpm test |
drive the suite against the live API |
pnpm test -w session -w <workflowId> |
one workflow, plus the session it reads |
pnpm test:drift |
where traffic and description disagree, and how much of the description it reached |
pnpm test:downlevel |
test and build packages/redocly-plugin-downlevel |
pnpm test:versions |
lint the downleveled bundles as the version they declare |
sh test/report.sh |
the last run's failures as Markdown |
sh test/report.sh --format github |
the same, as CI annotations |
pnpm test reuses the cached session in test/.out/; delete
test/.out/session to force a fresh login.
Every CI test run uploads four artifacts, kept for the repository's artifact retention: redocly-test.har, the capture; redocly-test.json, the respect report; redocly-test-drift.json, every drift finding, where the log shows at most a thousand; and redocly-test-coverage.json, each operation's covered and missing parameters, properties and response codes. Read what CI saw with gh run download <run-id> -n redocly-test.har -D <dir> rather than rerunning the suite. CI and a local run drive the same account and fixture group, so never start a full local run while a CI test job is in progress.
oasdiff (~/go/bin/oasdiff) diffs a release bundle against dist/. Read
docs/writing-changelogs.md before writing release notes.
presence has a legacy shape and a current one, and which you get depends on the
account. themes, worldFavoriteLists and the frontend branches are empty on the
test account and populated elsewhere. When a field resists the suite, ask a user
for their /auth/user body and validate it against dist/openapi-test.yaml.
docs/next.md carries the snippet that collects one.