PHPackages                             braseidon/vaal-api - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. braseidon/vaal-api

ActiveLibrary[API Development](/categories/api)

braseidon/vaal-api
==================

PHP client for the Path of Exile API with OAuth 2.0, rate limiting, and full endpoint coverage

20PHPCI passing

Since Mar 23Pushed 1mo agoCompare

[ Source](https://github.com/braseidon/vaal-api)[ Packagist](https://packagist.org/packages/braseidon/vaal-api)[ RSS](/packages/braseidon-vaal-api/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependenciesVersions (1)Used By (0)

Vaal API
========

[](#vaal-api)

[![Tests](https://github.com/braseidon/vaal-api/actions/workflows/tests.yml/badge.svg)](https://github.com/braseidon/vaal-api/actions/workflows/tests.yml)[![Latest Stable Version](https://camo.githubusercontent.com/1ec93a492209913a625216c64020b0cde218e63d3eb79af829528b49c4d80d34/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f627261736569646f6e2f7661616c2d6170692e737667)](https://packagist.org/packages/braseidon/vaal-api)[![License](https://camo.githubusercontent.com/907570189b28df05e0f6acc63292293409965553bb49ad832714d9b3b09562c2/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f627261736569646f6e2f7661616c2d6170692e737667)](https://packagist.org/packages/braseidon/vaal-api)[![PHP Version](https://camo.githubusercontent.com/3dcfdd87d065e5b546ee249e5d45f6d1c61b09a6183f75bc92b36d859b9d38f6/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f627261736569646f6e2f7661616c2d6170692e737667)](https://packagist.org/packages/braseidon/vaal-api)

PHP client for GGG's Path of Exile API. Wraps both the OAuth 2.0 API and the public API with rate limiting, automatic token refresh, and typed DTOs.

Built on [league/oauth2-client](https://github.com/thephpleague/oauth2-client) and Guzzle.

Requirements
------------

[](#requirements)

- PHP 8.2+
- A GGG developer application ([register here](https://www.pathofexile.com/developer/apps))

Installation
------------

[](#installation)

```
composer require braseidon/vaal-api
```

Laravel auto-discovers the service provider. To publish the config:

```
php artisan vendor:publish --tag=vaal-api-config
```

Configuration
-------------

[](#configuration)

Add these to your `.env`:

```
POE_CLIENT_ID=your-client-id
POE_CLIENT_SECRET=your-client-secret
POE_REDIRECT_URI=https://yoursite.com/auth/poe/callback
POE_API_CONTACT=you@example.com
```

### Rate limiting options

[](#rate-limiting-options)

```
# What to do when a rate limit is about to be exceeded
# Options: sleep (default), exception, callback, log
POE_RATE_LIMIT_STRATEGY=sleep

# Margin to avoid riding the limit. 0.2 = treat a 10-request limit as 8.
POE_RATE_LIMIT_SAFETY_MARGIN=0.2

# Automatically retry on 429/503 responses
POE_RATE_LIMIT_AUTO_RETRY=true
POE_RATE_LIMIT_MAX_RETRIES=3
```

Rate limiting works in two layers:

1. **Pre-flight checks** track state from previous responses and predict whether the next request will exceed a limit. The configured strategy controls what happens: `sleep` waits it out, `exception` throws `RateLimitException`, `callback` calls your closure, and `log` logs a warning and continues anyway.
2. **Retry middleware** catches 429/503 responses that slip through pre-flight checks (e.g. on cold start when no state exists). Reads the `Retry-After` header and retries automatically.

### Rate limit strategy: callback

[](#rate-limit-strategy-callback)

The `callback` strategy lets you handle rate limits yourself. Pass a closure in the config array:

```
use Braseidon\VaalApi\Client\ApiClient;
use Braseidon\VaalApi\RateLimit\RateLimitResult;

$client = new ApiClient([
    ...config('vaal-api'),
    'rate_limit' => [
        'strategy' => 'callback',
        'callback' => function (RateLimitResult $result) {
            Log::warning("Rate limit approaching: {$result->reason}", [
                'policy'  => $result->policy,
                'wait'    => $result->waitSeconds,
            ]);

            // You decide what to do: sleep, queue the job for later, etc.
            if ($result->waitSeconds < 5) {
                sleep($result->waitSeconds);
            } else {
                throw new \RuntimeException("Rate limit too long: {$result->waitSeconds}s");
            }
        },
    ],
]);
```

The `RateLimitResult` tells you everything you need: whether the request can proceed (`$result->canProceed`), how long to wait (`$result->waitSeconds`), which policy triggered it (`$result->policy`), and a human-readable reason (`$result->reason`).

Usage
-----

[](#usage)

### OAuth login flow

[](#oauth-login-flow)

GGG uses OAuth 2.0 with PKCE (S256). The provider handles PKCE automatically.

```
use Braseidon\VaalApi\Client\ApiClient;
use Braseidon\VaalApi\Auth\Token;

$client   = app(ApiClient::class);
$provider = $client->getAuthProvider();

// 1. Generate the authorization URL
//    You can pass scope strings directly, or use the Scope enum:
use Braseidon\VaalApi\Enums\Scope;

$authUrl = $provider->getAuthorizationUrl([
    'scope' => implode(' ', Scope::allAccount()), // all account scopes
    // or pick specific ones:
    // 'scope' => implode(' ', [Scope::Characters->value, Scope::Stashes->value]),
]);

// Store the PKCE verifier and state in the session
session(['oauth2_pkce_code' => $provider->getPkceCode()]);
session(['oauth2_state' => $provider->getState()]);

return redirect($authUrl);
```

In your callback handler:

```
// 2. Exchange the authorization code for a token
$provider->setPkceCode(session('oauth2_pkce_code'));

$accessToken = $provider->getAccessToken('authorization_code', [
    'code' => $request->get('code'),
]);

// 3. Wrap it in the Vaal Token DTO
$token = Token::fromAccessToken($accessToken);

// $token->username  => "PlayerName#1234"
// $token->sub       => account UUID (stable across name changes)

// 4. Persist it however you want
$user->update($token->toArray());
```

### Token helpers

[](#token-helpers)

The `Token` class has a few methods for checking state before you make requests:

```
$token->isExpired();              // has the access token expired?
$token->needsRefresh();           // will it expire within 5 minutes? (buffer is configurable)
$token->needsRefresh(600);        // will it expire within 10 minutes?
$token->hasScope(Scope::Stashes); // did the user grant this scope?
$token->hasScope('account:characters'); // string works too
```

The client handles token refresh automatically before each request, so you don't need to check `needsRefresh()` yourself for normal API calls. These are more useful for application logic - hiding UI elements when a scope wasn't granted, or skipping a queued job if the token is expired and has no refresh token.

### Fetching characters

[](#fetching-characters)

```
use Braseidon\VaalApi\VaalApi;
use Braseidon\VaalApi\Auth\Token;

// Hydrate a token from your database
$token = Token::fromArray($user->only([
    'access_token', 'refresh_token', 'expires_at', 'scope', 'username', 'sub',
]));

$api = VaalApi::for($token, config('vaal-api'));

// Register a callback so you don't lose the new token after a refresh.
// GGG refresh tokens are single-use: once refreshed, the old one is dead.
$api->onTokenRefresh(function (Token $newToken) use ($user) {
    $user->update($newToken->toArray());
});

// List all characters (rate limit: 2 req/10s - tightest limit in the API)
$characters = $api->characters()->list();

foreach ($characters as $summary) {
    echo $summary->name() . ' - Level ' . $summary->level() . ' ' . $summary->class() . "\n";
    // Note: class() returns the ascendancy name, not the base class.
    // "Necromancer", not "Witch". See gotchas below.
}

// Get full character data (equipment, passives, jewels - 200-320KB response)
$character = $api->characters()->get('MyCharacterName');

$character->level();
$character->equipment();
$character->passiveHashes();      // allocated node IDs
$character->masteryEffects();     // node hash => effect hash
$character->banditChoice();       // "kraityn", "alira", "oak", or "eramir"
$character->alternateAscendancy(); // bloodline ascendancy if selected
```

### Fetching stash tabs

[](#fetching-stash-tabs)

Stash endpoints are PoE1 only and require a league name.

```
// List all stash tabs in Mirage league
$stashes = $api->stashes('Mirage')->list();

foreach ($stashes as $tab) {
    echo $tab->name() . ' (' . $tab->type() . ")\n";
    // $tab->color() returns "ff0000", not "#ff0000" - no hash prefix
}

// Get a single stash tab with all its items (~207KB)
$stash = $api->stashes('Mirage')->get($tab->id());

foreach ($stash->items() as $item) {
    // full item data
}

// Nested tabs (e.g. quad stash sub-tabs)
$stash = $api->stashes('Mirage')->get($tabId, $substashId);
```

### Caching responses in Laravel

[](#caching-responses-in-laravel)

The package doesn't include caching, so you wire it up however fits your app. Character list is the most important one to cache since it has the tightest rate limit.

```
use Illuminate\Support\Facades\Cache;

$characters = Cache::remember(
    "poe:characters:{$user->id}",
    now()->addMinutes(5),
    fn () => $api->characters()->list(),
);

// For stash tabs, longer TTL is usually fine
$stashList = Cache::remember(
    "poe:stashes:{$user->id}:Mirage",
    now()->addMinutes(15),
    fn () => $api->stashes('Mirage')->list(),
);
```

### Public API (no auth)

[](#public-api-no-auth)

```
use Braseidon\VaalApi\VaalApi;

$public = VaalApi::public(config('vaal-api'));

$leagues = $public->leagues()->list();
$tradeResults = $public->trade()->search('Mirage', $queryPayload);
$items = $public->trade()->fetch($tradeResults->id(), $tradeResults->itemIds());
```

### Realm support

[](#realm-support)

Most endpoints accept an optional realm. Defaults to PC when omitted.

```
use Braseidon\VaalApi\Enums\Realm;

$api->characters(Realm::Xbox)->list();
$api->stashes('Mirage', Realm::Sony)->list();
```

### Error handling

[](#error-handling)

```
use Braseidon\VaalApi\Exceptions\RateLimitException;
use Braseidon\VaalApi\Exceptions\AuthenticationException;
use Braseidon\VaalApi\Exceptions\ResourceNotFoundException;
use Braseidon\VaalApi\Exceptions\ServerException;

try {
    $character = $api->characters()->get('SomeName');
} catch (RateLimitException $e) {
    $e->getRetryAfter();        // seconds to wait
    $e->getRateLimitResult();   // full RateLimitResult DTO
} catch (AuthenticationException $e) {
    // Token expired/invalid, or missing required scope
} catch (ResourceNotFoundException $e) {
    // Character doesn't exist or is private
} catch (ServerException $e) {
    // GGG's servers are having a bad day
}
```

Available endpoints
-------------------

[](#available-endpoints)

### OAuth (authenticated)

[](#oauth-authenticated)

ResourceMethodDescriptionScopeGame`profile()``get()`Account profile`account:profile`Both`characters()``list()`All account characters`account:characters`Both`characters()``get($name)`Full character detail`account:characters`Both`itemFilters()``list()`, `get()`, `create()`, `update()`Item filters`account:item_filter`Both`leagues()``list()`, `get()`League data`service:leagues`Both`leagues()``ladder()`, `eventLadder()`League ladders`service:leagues:ladder`PoE1`currencyExchange()`Exchange market historyCurrency rates`service:cxapi`Both`stashes($league)``list()`All stash tabs in a league`account:stashes`PoE1`stashes($league)``get($id, $substashId?)`Single stash with items`account:stashes`PoE1`accountLeagues()``list()`Account's leagues`account:leagues`PoE1`leagueAccount($league)``get()`Atlas passives`account:league_accounts`PoE1`guild()`Guild stash endpointsGuild data`account:guild:stashes`PoE1`publicStashTabs()`Public stash streamRiver-style stream`service:psapi`PoE1`pvpMatches()`PvP match dataPvP`service:pvp_matches`PoE1### Public (no auth)

[](#public-no-auth)

ResourceMethodDescriptionGame`public()->leagues()``list()`Public league listBoth`public()->characters($account)``list()`Account's public charactersBoth`public()->stashTabs()``list()`Public stash tab streamPoE1`public()->trade()``search()`, `fetch()`, `items()`, `stats()`, `static()`Trade APIBothGGG's PoE2 API coverage is still limited. Endpoints marked "Both" accept `Realm::Poe2`, but the response structures have some PoE2-specific fields (and are missing some PoE1-specific ones like `masteryEffects` and `banditChoice`). See GGG's [API reference](https://www.pathofexile.com/developer/docs/api-resource-description) for the full field breakdown.

> Only endpoints the author has access to have been tested. The others follow the same patterns and match GGG's docs, but haven't been verified against live responses. If something is off, open an issue.

GGG API gotchas
---------------

[](#ggg-api-gotchas)

Things that will bite you if you don't know about them.

- **Character `class` is the ascendancy name**, not the base class. `"Necromancer"` not `"Witch"`. You need a lookup table to get the base class from the ascendancy.
- **`current` field is absence-based.** Only present as `true` on the last-played character. The key is missing on all other characters, not set to `false`.
- **Character list has the tightest rate limit.** 2 requests per 10 seconds. Cache this endpoint. The character detail endpoint is more generous at 5 req/10s.
- **Authorization codes expire in 30 seconds.** Exchange them for a token immediately in your callback. If you have any slow middleware or redirects between receiving the code and exchanging it, you'll get failures.
- **Refresh tokens are single-use.** After refreshing, the old refresh token is immediately invalid. If you don't persist the new token, you've lost access. Use `onTokenRefresh()` to handle this.
- **Stash tab color has no `#` prefix.** `"ff0000"` not `"#ff0000"`. Prepend it yourself if you need it for CSS.
- **`metadata.public` is absence-based.** The key only exists when `true`. Check with `isset()` or `?? false`, not strict equality.

License
-------

[](#license)

MIT

###  Health Score

21

—

LowBetter than 19% of packages

Maintenance63

Regular maintenance activity

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/c1f8a0e8ab0844794fe24ab9856ff7cfa9c92b757490547f26a8ff4a675ef5be?d=identicon)[braseidon](/maintainers/braseidon)

---

Top Contributors

[![braseidon](https://avatars.githubusercontent.com/u/5483062?v=4)](https://github.com/braseidon "braseidon (5 commits)")

### Embed Badge

![Health badge](/badges/braseidon-vaal-api/health.svg)

```
[![Health](https://phpackages.com/badges/braseidon-vaal-api/health.svg)](https://phpackages.com/packages/braseidon-vaal-api)
```

###  Alternatives

[stripe/stripe-php

Stripe PHP Library

4.0k143.3M475](/packages/stripe-stripe-php)[twilio/sdk

A PHP wrapper for Twilio's API

1.6k92.9M270](/packages/twilio-sdk)[knplabs/github-api

GitHub API v3 client

2.2k15.8M186](/packages/knplabs-github-api)[facebook/php-business-sdk

PHP SDK for Facebook Business

90121.9M34](/packages/facebook-php-business-sdk)[meilisearch/meilisearch-php

PHP wrapper for the Meilisearch API

73813.7M114](/packages/meilisearch-meilisearch-php)[google/gax

Google API Core for PHP

263103.1M452](/packages/google-gax)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
