PSXI API
A public, read-only JSON API for FFXI private-server data: market snapshots, per-mob drop rates with Treasure Hunter scaling, and crafting success and HQ rates, all scoped per server. The drops and crafting endpoints require a free API token; the market endpoint is keyless for now (see the deprecation notice below), except for its per-sale transaction rows, which need a token today.
Authentication
The drops and crafting endpoints require an API token. Generate one under Settings → Account → API Access and pass it as a bearer token:
Authorization: Bearer psxi_...- One token per user account. Tokens are prefixed
psxi_. - Rotate or revoke anytime in Settings. Both take effect immediately.
- A request with no token returns
401 token_missing; a malformed or unknown token returns401 token_invalid. - Keep your token secret. All endpoints are CORS-enabled (
Access-Control-Allow-Origin: *), but a token exposed in browser code is visible to anyone.
curl https://www.psxi.gg/api/v1/drops/horizonxi/item/640 \
-H "Authorization: Bearer psxi_your_token_here"Rate limits & tiers
Limits are keyed to your token across two fixed windows, per minute and per day. Both windows scale together by tier. Tiers come from your Patreon membership, synced to Discord roles on the account you log in with. Anonymous market requests are limited by IP at the Free rate.
| Tier | Source | Multiplier | Per minute | Per day |
|---|---|---|---|---|
| Free | No Patreon role | 0.5x | 25 | 100 |
| Tier 1 | Patreon tier 1 | 1x | 50 | 200 |
| Tier 2 | Patreon tier 2 | 3x | 150 | 600 |
| Tier 3 | Patreon tier 3 | 5x | 250 | 1000 |
| Tier 4 | Patreon tier 4 | 10x | 500 | 2000 |
| Tier 5 | Patreon tier 5 | 25x | 1250 | 5000 |
Tier resolves live from your Discord roles; the highest matching tier wins. After changing your Patreon membership, use "Refresh tier" in Settings to re-fetch your roles without waiting for a re-login. Every response (success or 429) carries the current window state in headers:
| Header | Meaning | |
|---|---|---|
| X-RateLimit-Limit-Minute | int | Your per-minute quota. |
| X-RateLimit-Remaining-Minute | int | Calls left this minute. |
| X-RateLimit-Reset-Minute | int | Unix seconds when the minute window resets. |
| X-RateLimit-Limit-Day | int | Your per-day quota. |
| X-RateLimit-Remaining-Day | int | Calls left today. |
| X-RateLimit-Reset-Day | int | Unix seconds when the day window resets. |
| Retry-After | int | On 429 only: seconds until you can retry. |
These headers are CORS-exposed, so browser clients can read them. Exceeding either window returns 429 rate_limited.
Errors
All errors share one envelope: an error object with a machine-readable code, a human message, and a docs link. Branch on code, not on the message text.
{
"error": {
"code": "token_missing",
"message": "Provide an API token via the Authorization: Bearer header. Generate one in Settings.",
"docs": "https://www.psxi.gg/developers"
}
}| Status | Code | When |
|---|---|---|
| 400 | bad_request | Malformed path or query param (bad id, out-of-range th). |
| 401 | token_missing | No Authorization header on a token-gated endpoint. |
| 401 | token_invalid | Token is malformed or not recognized. |
| 404 | server_not_found | Unknown or not-yet-live server slug. |
| 404 | item_not_found | No data for the requested item. |
| 404 | mob_not_found | No mob with the requested key. |
| 404 | recipe_not_found | No recipe with the requested id. |
| 429 | rate_limited | Minute or day window exceeded. See Retry-After. |
| 405 | method_not_allowed | Only GET is supported. |
Drop rates
Two token-gated endpoints, scoped by server slug:
GET https://www.psxi.gg/api/v1/drops/{slug}/item/{itemId}GET https://www.psxi.gg/api/v1/drops/{slug}/mob/{mobKey}The item endpoint lists every mob that drops an item; the mob endpoint lists everything a mob drops. Both accept an optional ?th= query param (Treasure Hunter tier, an integer 0 to 14, default 0) which scales kill-drop rates. effectivePercent already reflects the requested TH; basePercent is TH 0. Steal and despoil rates do not scale with TH. Rates are returned as a fraction (0 to 1), a percent, and the raw per-mille value from the data.
Live servers: horizonxi. Rates are scoped to that server's preset, reported under preset.
Item response
{
"server": { "slug": "horizonxi", "name": "HorizonXI" },
"preset": { "id": "horizon", "name": "Horizon" },
"th": 4,
"item": { "id": 640, "name": "Mythril Sheet", "baseSell": 1310, "stackSize": 12 },
"mobs": [
{
"mob": {
"key": 17064451, "name": "Goblin Smithy", "family": "Goblin",
"zoneId": 199, "zoneName": "Meriphataud Mountains",
"minLevel": 30, "maxLevel": 33, "respawnSeconds": 300,
"spawnType": 0, "spawnKind": "normal", "isNm": false
},
"dropType": "normal",
"rate": {
"base": 0.15, "effective": 0.213,
"basePercent": 15, "effectivePercent": 21.3, "raw": 150
},
"lottery": null
}
]
}Mob response
{
"server": { "slug": "horizonxi", "name": "HorizonXI" },
"preset": { "id": "horizon", "name": "Horizon" },
"th": 0,
"mob": {
"key": 17064451, "name": "Goblin Smithy", "family": "Goblin",
"zoneId": 199, "zoneName": "Meriphataud Mountains",
"minLevel": 30, "maxLevel": 33, "respawnSeconds": 300,
"spawnType": 0, "spawnKind": "normal", "isNm": false,
"lottery": null
},
"drops": [
{
"item": { "id": 640, "name": "Mythril Sheet", "baseSell": 1310, "stackSize": 12 },
"dropType": "normal",
"rate": { "base": 0.15, "effective": 0.15, "basePercent": 15, "effectivePercent": 15, "raw": 150 }
},
{
"item": { "id": 4096, "name": "Bomb Arm", "baseSell": 8, "stackSize": 12 },
"dropType": "steal",
"rate": { "base": 0.25, "effective": 0.25, "basePercent": 25, "effectivePercent": 25, "raw": 250 }
}
]
}| Field | Type | Description |
|---|---|---|
| th | number | The Treasure Hunter tier applied to this response. |
| mob.key | number | Mob pool key; use it against the /mob endpoint. |
| mob.spawnKind | string | Classified spawn: "normal", "lottery", "timed", etc. |
| mob.isNm | boolean | Whether the mob is a notorious monster. |
| dropType | string | "normal", "grouped", "steal", or "despoil". |
| groupId | number? | Present only for grouped drops (one item per group per kill). |
| rate.base | number | Drop fraction at TH 0 (0 to 1). |
| rate.effective | number | Drop fraction at the requested TH. |
| rate.raw | number | Underlying per-mille rate from the source data. |
| lottery | object|null | NM lottery/placeholder info, or null for non-lottery mobs. |
Crafting rates
Two token-gated endpoints, scoped by server slug:
GET https://www.psxi.gg/api/v1/craft/{slug}/recipe/{recipeId}GET https://www.psxi.gg/api/v1/craft/{slug}/item/{itemId}The recipe endpoint returns one recipe; the item endpoint returns every recipe that produces an item (under a recipes array). Each recipe carries a tier curve: outcomes at four skill brackets above the recipe cap (T0 through T3), since higher skill shifts results toward HQ. There is no skill param; you get the whole curve.
Outcome fractions (break, nq, hq1/hq2/hq3) sum to 1. lossProbability is the chance of losing ingredients on a break. Crafting rates diverge per server, so results are scoped to the server's preset. A tier that no ingredient craft can reach is returned with reachable: false and null outcomes.
{
"server": { "slug": "horizonxi", "name": "HorizonXI" },
"preset": { "id": "horizon", "name": "Horizon" },
"recipe": {
"id": 1234,
"result": { "id": 640, "name": "Mythril Sheet", "qty": 1 },
"crystal": { "id": 4100, "name": "Fire Crystal" },
"ingredients": [ { "id": 641, "name": "Mythril Ore" } ],
"skills": { "Smithing": 53 },
"desynth": false
},
"tiers": [
{
"tier": 0, "label": "T0 (0-10 over)", "reachable": true,
"outcomes": { "break": 0.09, "nq": 0.63, "hq1": 0.19, "hq2": 0.06, "hq3": 0.03 },
"outcomesPercent": { "break": "9.0%", "nq": "63.0%", "hq1": "19.0%", "hq2": "6.0%", "hq3": "3.0%" },
"lossProbability": 0.09
},
{
"tier": 3, "label": "T3 (51+ over)", "reachable": false,
"outcomes": null, "outcomesPercent": null, "lossProbability": null
}
]
}| Field | Type | Description |
|---|---|---|
| recipe.result | object | Produced item: id, name, and qty per synth. |
| recipe.crystal | object | Crystal required for the synth. |
| recipe.ingredients | object[] | Ingredient item refs (id, name). |
| recipe.skills | object | Craft-to-level map, e.g. { Smithing: 53 }. |
| recipe.desynth | boolean | Whether this is a desynthesis recipe. |
| tiers[].tier | number | Skill bracket 0 to 3 above cap. |
| tiers[].label | string | Human label, e.g. "T1 (11-30 over)". |
| tiers[].reachable | boolean | False when the bracket exceeds a required craft's cap. |
| tiers[].outcomes | object|null | break/nq/hq1/hq2/hq3 fractions, or null if unreachable. |
| tiers[].lossProbability | number|null | Chance of losing ingredients on break. |
Market data (keyless aggregates)
401. Responses already carry a Deprecation: true header, a Sunset header with that date, a Link; rel="deprecation" pointer to this page, and a _notice field in the JSON body. Add a token now to keep working through the cutover.One GET endpoint returns a snapshot of every AH and bazaar item on a server:
GET https://www.psxi.gg/api/v1/market/{slug}Live servers: horizonxi. Top-level meta plus a data array, one entry per item (sorted by itemId). Any field is null when there is no data.
Add ?transactions=N (1 to 50) to attach the N most recent AH sales to every item as a transactions array, newest first. It is opt-in because it is a big payload: at transactions=50 a busy server's response is roughly 25 MB uncompressed, versus about 2 MB without it, and it is sent chunked (no Content-Length). Omit the param (or send 0) and the response is exactly what it has always been. Out-of-range or non-integer values return 400 bad_request.
Authorization: Bearer token today. A keyless request carrying transactions above 0 returns 401 token_missing. Tokens are free - generate one in Settings.seller and buyer come back censored to the first letter plus six asterisks (e.g. P******) unless that player has chosen to show their history publicly. On other servers names are public unless the player hides them. Treat a censored name as "not disclosed" rather than as a player identifier, and do not try to re-identify hidden players by correlating rows.curl -H "Authorization: Bearer $PSXI_TOKEN" \
"https://www.psxi.gg/api/v1/market/horizonxi?transactions=30"{
"meta": {
"server": "horizonxi",
"generatedAt": "2026-06-25T22:00:00.000Z",
"statsWindowDays": 7,
"itemCount": 4123,
"transactionsPerItem": 30,
"disclaimer": "Bazaar figures are estimates..."
},
"data": [
{
"itemId": 640,
"itemName": "Mythril Sheet",
"categorySlug": "smithing",
"asOf": "2026-06-25T21:12:00.000Z",
"ah": {
"currentStock": 12,
"currentStackStock": 3,
"single": { "lastSale": 8000, "lastSaleDate": "2026-06-25T19:14:00.000Z",
"avg": 8200, "median": 8000, "volume": 41, "min": 7000, "max": 9500 },
"stack": { "lastSale": 90000, "lastSaleDate": "2026-06-24T08:02:00.000Z",
"avg": 91000, "median": 90000, "volume": 6, "min": 85000, "max": 99000 }
},
"bazaar": {
"median": 8500, "avg": 8600, "minPrice": 8000, "maxPrice": 9900,
"listingCount": 5, "lastSuspectedSale": "2026-06-25T11:30:00.000Z", "estSaleVolume7d": 3
},
"transactions": [
{ "date": "2026-06-25T19:14:00.000Z", "price": 8000,
"seller": "Haber", "buyer": "Candie", "isStack": false },
{ "date": "2026-06-25T16:02:00.000Z", "price": 7800,
"seller": "Zevon", "buyer": "Holyness", "isStack": false }
]
}
],
"_notice": "This keyless endpoint is deprecated. An API token will be required starting 2026-11-01..."
}AH statistics (avg, median, volume) cover a rolling 7-day window; lastSale is the most recent sale of all time. Prices are in gil.
| Field | Type | Description |
|---|---|---|
| itemId | number | FFXI item ID. |
| itemName | string | Human-readable item name. |
| categorySlug | string|null | AH category slug (e.g. smithing). |
| asOf | string|null | When the scanner last read this item. Everything else on the item is only as current as this. Null if never scanned. |
| ah.currentStock | number|null | Single items currently listed on the AH. |
| ah.currentStackStock | number|null | Stacks currently listed on the AH. |
| ah.single / ah.stack | object | Sale stats split by single-item vs full-stack listings. |
| ah.*.lastSale | number|null | Most recent sale price (all-time). |
| ah.*.lastSaleDate | string|null | ISO 8601 timestamp of the last sale. |
| ah.*.avg | number|null | Mean sale price over the last 7 days. |
| ah.*.median | number|null | Median sale price over the last 7 days. |
| ah.*.volume | number | Number of sales in the last 7 days (0 if none). |
| ah.*.min / ah.*.max | number|null | Min/max sale price over the last 7 days. |
| bazaar.median / avg | number|null | Across current ONLINE listings (asking prices, not sales). |
| bazaar.minPrice / maxPrice | number|null | Cheapest / priciest current online listing. |
| bazaar.listingCount | number | Current online listings (one per seller). |
| bazaar.lastSuspectedSale | string|null | When a listing last disappeared (estimate). |
| bazaar.estSaleVolume7d | number | Listings that vanished in the last 7 days (estimate). |
| transactions | array? | Recent AH sales, newest first. Only present when ?transactions=N was sent; [] if the item has never sold. |
| transactions[].date | string | ISO 8601 timestamp of the sale. |
| transactions[].price | number | Gil paid for this sale. |
| transactions[].seller / buyer | string|null | Character names; null when the scanner did not capture one, and censored to the first letter plus six asterisks ("P******") when the name is private - the default on HorizonXI unless the player opted to show it. |
| transactions[].isStack | boolean | True if a full stack sold, false for a single. Prices are per listing, not per unit. |
avg/median reflect current asking prices, and lastSuspectedSale/estSaleVolume7d are inferred from listings that disappeared, which may have sold or simply been delisted. Treat them as directional, not authoritative. AH numbers come from real sale records.The snapshot is built once and replayed to everyone who asks for it, so polling it repeatedly returns the same payload; use meta.generatedAt to tell when it was built. When you hold a paid tier, your request rebuilds it if the scanner has written anything since - that rebuild takes a few seconds, and later requests served by the same instance read the result. Free and keyless requests read whatever the last rebuild produced, and trigger one themselves only once it is an hour old.
A rebuild only re-reads the database; it does not make the underlying rows younger. Every number on an item is as old as that item's asOf, which is set by the scanner, not by this endpoint. Read asOf rather than meta.generatedAt when you care about freshness - they are usually hours apart. Responses are sent with Cache-Control: private, no-store (not CDN-cached), as are the token-gated drops and crafting endpoints, so cache on your side and mind your rate limits.
Batched items (token required)
Same data as the bulk snapshot, for a set of ids you name, read straight from the database instead of from a cached body. Built for keeping a curated watchlist current without pulling the whole server or scraping item pages.
GET https://www.psxi.gg/api/v1/market/{slug}/items?items={ids}asOf on each item and decide for yourself whether the row is current enough; polling faster than the scanner moves returns identical data and just spends your rate limit.itemsis required: comma-separated item ids, at most 500 per request. Batch hard - one call for 200 items, not 200 calls for one.transactions=Nworks exactly as it does on the bulk endpoint (0 to 50, default 0).- Unknown or unscanned ids come back in
meta.missingrather than as empty items, so a typo is visible instead of silent. - Duplicate ids are collapsed;
meta.requestedcounts what you sent after dedupe. - Requires a token. Unlike the bulk snapshot there is no keyless grace period, since every call does its own database work.
curl -H "Authorization: Bearer $PSXI_TOKEN" \
"https://www.psxi.gg/api/v1/market/horizonxi/items?items=640,641,4096&transactions=10"{
"meta": {
"server": "horizonxi",
"generatedAt": "2026-07-28T14:25:48.462Z",
"statsWindowDays": 7,
"requested": 4,
"returned": 3,
"missing": [12345],
"transactionsPerItem": 10,
"disclaimer": "Bazaar figures are estimates..."
},
"data": [
{ "itemId": 640, "itemName": "Mythril Sheet", "categorySlug": "smithing",
"asOf": "2026-07-28T09:45:35.591Z",
"ah": { "...": "same shape as the bulk snapshot" },
"bazaar": { "...": "same shape as the bulk snapshot" },
"transactions": [ { "date": "...", "price": 8000, "seller": "Das",
"buyer": "Nox", "isStack": false } ] }
]
}Worst case (500 items with transactions=50) is about 2 MB and roughly half a second. Item entries are the same MarketItem objects documented above, so a client can share one parser with the bulk endpoint.