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 returns 401 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.

TierSourceMultiplierPer minutePer day
FreeNo Patreon role0.5x25100
Tier 1Patreon tier 11x50200
Tier 2Patreon tier 23x150600
Tier 3Patreon tier 35x2501000
Tier 4Patreon tier 410x5002000
Tier 5Patreon tier 525x12505000

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:

HeaderMeaning
X-RateLimit-Limit-MinuteintYour per-minute quota.
X-RateLimit-Remaining-MinuteintCalls left this minute.
X-RateLimit-Reset-MinuteintUnix seconds when the minute window resets.
X-RateLimit-Limit-DayintYour per-day quota.
X-RateLimit-Remaining-DayintCalls left today.
X-RateLimit-Reset-DayintUnix seconds when the day window resets.
Retry-AfterintOn 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"
  }
}
StatusCodeWhen
400bad_requestMalformed path or query param (bad id, out-of-range th).
401token_missingNo Authorization header on a token-gated endpoint.
401token_invalidToken is malformed or not recognized.
404server_not_foundUnknown or not-yet-live server slug.
404item_not_foundNo data for the requested item.
404mob_not_foundNo mob with the requested key.
404recipe_not_foundNo recipe with the requested id.
429rate_limitedMinute or day window exceeded. See Retry-After.
405method_not_allowedOnly 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 }
    }
  ]
}
FieldTypeDescription
thnumberThe Treasure Hunter tier applied to this response.
mob.keynumberMob pool key; use it against the /mob endpoint.
mob.spawnKindstringClassified spawn: "normal", "lottery", "timed", etc.
mob.isNmbooleanWhether the mob is a notorious monster.
dropTypestring"normal", "grouped", "steal", or "despoil".
groupIdnumber?Present only for grouped drops (one item per group per kill).
rate.basenumberDrop fraction at TH 0 (0 to 1).
rate.effectivenumberDrop fraction at the requested TH.
rate.rawnumberUnderlying per-mille rate from the source data.
lotteryobject|nullNM 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
    }
  ]
}
FieldTypeDescription
recipe.resultobjectProduced item: id, name, and qty per synth.
recipe.crystalobjectCrystal required for the synth.
recipe.ingredientsobject[]Ingredient item refs (id, name).
recipe.skillsobjectCraft-to-level map, e.g. { Smithing: 53 }.
recipe.desynthbooleanWhether this is a desynthesis recipe.
tiers[].tiernumberSkill bracket 0 to 3 above cap.
tiers[].labelstringHuman label, e.g. "T1 (11-30 over)".
tiers[].reachablebooleanFalse when the bracket exceeds a required craft's cap.
tiers[].outcomesobject|nullbreak/nq/hq1/hq2/hq3 fractions, or null if unreachable.
tiers[].lossProbabilitynumber|nullChance of losing ingredients on break.

Market data (keyless aggregates)

Deprecation notice. The market endpoint is keyless today but will require an API token starting 2026-11-01. After that date, requests without a token return 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.

transactions=N needs a token. The aggregates stay keyless until the 2026-11-01 cutover, but the per-sale rows require an Authorization: Bearer token today. A keyless request carrying transactions above 0 returns 401 token_missing. Tokens are free - generate one in Settings.
Hidden players. On HorizonXI, player names in transaction history are private by default: every sale still appears - the price, date and item are real market data - but 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.

FieldTypeDescription
itemIdnumberFFXI item ID.
itemNamestringHuman-readable item name.
categorySlugstring|nullAH category slug (e.g. smithing).
asOfstring|nullWhen the scanner last read this item. Everything else on the item is only as current as this. Null if never scanned.
ah.currentStocknumber|nullSingle items currently listed on the AH.
ah.currentStackStocknumber|nullStacks currently listed on the AH.
ah.single / ah.stackobjectSale stats split by single-item vs full-stack listings.
ah.*.lastSalenumber|nullMost recent sale price (all-time).
ah.*.lastSaleDatestring|nullISO 8601 timestamp of the last sale.
ah.*.avgnumber|nullMean sale price over the last 7 days.
ah.*.mediannumber|nullMedian sale price over the last 7 days.
ah.*.volumenumberNumber of sales in the last 7 days (0 if none).
ah.*.min / ah.*.maxnumber|nullMin/max sale price over the last 7 days.
bazaar.median / avgnumber|nullAcross current ONLINE listings (asking prices, not sales).
bazaar.minPrice / maxPricenumber|nullCheapest / priciest current online listing.
bazaar.listingCountnumberCurrent online listings (one per seller).
bazaar.lastSuspectedSalestring|nullWhen a listing last disappeared (estimate).
bazaar.estSaleVolume7dnumberListings that vanished in the last 7 days (estimate).
transactionsarray?Recent AH sales, newest first. Only present when ?transactions=N was sent; [] if the item has never sold.
transactions[].datestringISO 8601 timestamp of the sale.
transactions[].pricenumberGil paid for this sale.
transactions[].seller / buyerstring|nullCharacter 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[].isStackbooleanTrue if a full stack sold, false for a single. Prices are per listing, not per unit.
Bazaar numbers are estimates. Bazaars have no sale receipts, so 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}
This is not a live game read. Nothing here queries the game on demand. You get whatever the scanner last recorded, and scan cadence is the hard floor on freshness - typically hours for a normal item, and longer for the long tail. Read 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.
  • items is required: comma-separated item ids, at most 500 per request. Batch hard - one call for 200 items, not 200 calls for one.
  • transactions=N works exactly as it does on the bulk endpoint (0 to 50, default 0).
  • Unknown or unscanned ids come back in meta.missing rather than as empty items, so a typo is visible instead of silent.
  • Duplicate ids are collapsed; meta.requested counts 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.