A method the SDK does not cover is one Saloon Request away. Sent through SteamConnector, it gets what the shipped requests get: the API key, the rate limit, retries, hooks, debug() with the key masked, and SDK exceptions.

src/Steam/GetCmListRequest.php
use Fkrzski\SteamApiSdk\Contracts\SendsNoApiKey;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
final class GetCmListRequest extends Request implements SendsNoApiKey
{
protected Method $method = Method::GET;
public function __construct(
public readonly int $cellId,
) {}
public function resolveEndpoint(): string
{
return '/ISteamDirectory/GetCMList/v1/';
}
protected function defaultQuery(): array
{
return ['cellid' => $this->cellId];
}
/**
* @return list<string>
*/
public function createDtoFromResponse(Response $response): array
{
return $response->json('response.serverlist');
}
}
$servers = $connector->send(new GetCmListRequest(0))->dto();

sendAsync(), pool() and a SteamFake or MockClient take it as they take the SDK’s own requests.

The connector puts the configured key on every request, and refuses one locally with ApiKeyNotConfiguredException when the config carries none. Two marker interfaces change that:

Request Key configured No key configured
plain Request sent ApiKeyNotConfiguredException, nothing goes out
implements SendsOptionalApiKey sent sent without one
implements SendsNoApiKey stripped from the query sent

Mark a method Steam serves anonymously SendsNoApiKey, so the key stays off a URL that does not need it, and the request off the key’s budget and cool-down (limited apart). SendsOptionalApiKey is for one that answers both ways but differently, as GetSupportedAPIList does.

HasLanguage lets the default language reach a method that reads Steam’s l parameter. It asks for a public ?Language $language, and the connector fills l only while that is null, so the request sends its own:

use Fkrzski\SteamApiSdk\Contracts\HasLanguage;
use Fkrzski\SteamApiSdk\Enums\Language;
final class LocalisedRequest extends Request implements HasLanguage
{
public function __construct(
public readonly int $appId,
public readonly ?Language $language = null,
) {}
protected function defaultQuery(): array
{
return array_filter(['appid' => $this->appId, 'l' => $this->language?->value]);
}
// ...
}

A request with no mapping of its own fails as the status table says for any endpoint: a key error raises InvalidApiKeyException, any other 401 or 403 raises ProfileNotPublicException, and every other 4xx or 5xx raises SteamApiException. A 5xx and a request Steam never answered are retried.

The request’s own getRequestException() runs before the connector’s, and null hands the response back to it, so map only the statuses you know. Extending SteamApiException keeps one catch covering everything, and handing it the response sets the code and the method prefix:

use Fkrzski\SteamApiSdk\Exceptions\SteamApiException;
final class CmListUnavailableException extends SteamApiException {}
public function getRequestException(Response $response, ?Throwable $senderException): ?Throwable
{
return $response->status() === 403
? new CmListUnavailableException('Steam refused the cell.', $response)
: null;
}

hasRequestFailed() flags a failure Steam reports in a 200 payload. It raises SteamApiException with code 200, or what getRequestException() returns, and is never retried:

public function hasRequestFailed(Response $response): bool
{
return $response->json('response.result') !== 1;
}