> ## Documentation Index
> Fetch the complete documentation index at: https://linkskipper.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP SDK

> linkskipper/sdk — a typed PHP client with retries, polling, typed exceptions, and webhook verification.

The official PHP SDK wraps the REST API with typed models, automatic retries, a
resolve-and-wait helper, a per-code exception hierarchy, and webhook verification.

<CardGroup cols={2}>
  <Card title="Packagist" icon="box" href="https://packagist.org/packages/linkskipper/sdk">
    `linkskipper/sdk` · v0.2.1
  </Card>

  <Card title="GitHub" icon="github" href="https://github.com/linkskipper/sdk-php">
    Source, issues, and changelog.
  </Card>
</CardGroup>

## Install

```bash theme={null}
composer require linkskipper/sdk
```

Requires PHP 8.1+ (the SDK uses enums and readonly properties). It works with the built-in
cURL transport out of the box, or any PSR-18 HTTP client.

<Warning>
  Use the SDK from your **server**, never client-side, so your API key stays secret. See
  [Authentication](/docs/authentication).
</Warning>

## Initialize

```php theme={null}
<?php

use LinkSkipper\LinkSkipper;

require __DIR__ . "/vendor/autoload.php";

$client = LinkSkipper::create(getenv("LINKSKIPPER_API_KEY"));
```

`LinkSkipper::create(string $apiKey, string $baseUrl = …)` is the quick constructor. For
full control over timeouts, polling defaults, retry policy, transport, or clock, build a
`Config` and pass it to `new LinkSkipper($config)`:

```php theme={null}
<?php

use LinkSkipper\Config;
use LinkSkipper\LinkSkipper;

$client = new LinkSkipper(new Config(
    apiKey: getenv("LINKSKIPPER_API_KEY"),
    timeoutMs: 30000,
    pollIntervalMs: 2000,
    maxWaitMs: 120000,
));
```

### Config options

<ParamField path="apiKey" type="string" required>
  Your `sk_live_…` key. Throws `ConfigurationException` if empty.
</ParamField>

<ParamField path="baseUrl" type="string" default="https://api.linkskipper.app">
  Override the API base URL.
</ParamField>

<ParamField path="timeoutMs" type="int" default="30000">
  Per-request timeout in milliseconds.
</ParamField>

<ParamField path="pollIntervalMs" type="int" default="2000">
  Default poll interval for `resolveAndWait`.
</ParamField>

<ParamField path="maxWaitMs" type="int" default="120000">
  Default overall deadline for `resolveAndWait`.
</ParamField>

<ParamField path="retryPolicy" type="RetryPolicy">
  Retry tuning. Retries `429` and `5xx`, honoring `Retry-After`, with exponential backoff.
</ParamField>

<ParamField path="transport" type="Transport">
  HTTP transport. Defaults to `CurlTransport`; pass `Psr18Transport` to use your own client.
</ParamField>

## Methods

### resolve

`resolve(string $url, ?string $idempotencyKey = null): ResolveResult`

Submits a URL to `POST /v1/resolve`. Returns immediately — a cached link comes back with
`status === ResolveStatus::Done`, a new one queued with a `jobId`.

```php theme={null}
<?php

use LinkSkipper\Enum\ResolveStatus;

$result = $client->resolve("https://ouo.io/abc123", "order-42");

if ($result->status === ResolveStatus::Done) {
    echo $result->targetUrl, PHP_EOL;
    echo $result->cached ? "cached" : "fresh", PHP_EOL;
} else {
    echo "queued: ", $result->jobId, PHP_EOL;
}
```

`ResolveResult` exposes `jobId`, `status`, `url`, `targetUrl`, `provider`, `tier`,
`creditsCharged`, `cached`, `balance`, `queuePosition`, `pollUrl`, and an `isDone()` helper.

### resolveAndWait

```php theme={null}
resolveAndWait(
    string $url,
    ?string $idempotencyKey = null,
    ?int $pollIntervalMs = null,
    ?int $maxWaitMs = null,
): ResolvedLink
```

The high-level helper: submits the resolve, polls the job until terminal, and returns the
resolved link. A cached hit returns without polling.

```php theme={null}
<?php

use LinkSkipper\Exception\JobFailedException;
use LinkSkipper\Exception\TimeoutException;

try {
    $link = $client->resolveAndWait(
        "https://ouo.io/abc123",
        maxWaitMs: 60000,
        pollIntervalMs: 2000,
    );

    echo $link->targetUrl, PHP_EOL;
    printf(
        "%s (%s) · %d credits · cached=%s\n",
        $link->provider,
        $link->tier->value,
        $link->creditsCharged,
        $link->cached ? "yes" : "no",
    );
} catch (JobFailedException $exception) {
    fwrite(STDERR, "Resolve failed: " . $exception->reason . PHP_EOL);
} catch (TimeoutException $exception) {
    fwrite(STDERR, "Timed out, still pending: " . $exception->jobId . PHP_EOL);
}
```

`ResolvedLink` has a guaranteed `targetUrl` plus `jobId`, `provider`, `tier`,
`creditsCharged`, `balance`, and `cached`.

### getJob

`getJob(string $jobId): Job`

Reads `GET /v1/jobs/{jobId}` once. The returned `Job` has a `JobStatus` enum `status` with
an `isTerminal()` helper.

```php theme={null}
<?php

use LinkSkipper\Enum\JobStatus;

$job = $client->getJob("9b1d7c0e-2f3a-4b5c-8d6e-1a2b3c4d5e6f");
if ($job->status === JobStatus::Done) {
    echo $job->targetUrl, PHP_EOL;
}
```

### account

`account(): Account`

Reads `GET /v1/account`: `telegramId`, `balance`, `subscriptionUntil`, and `providers`.

```php theme={null}
<?php

$account = $client->account();
printf("%d credits left\n", $account->balance);
```

### providers

`providers(): array` — a `list<ProviderEntry>`

Reads `GET /v1/providers`. Each `ProviderEntry` has `provider`, `label`, `hosts`,
`tier` (enum), and `latency`.

```php theme={null}
<?php

foreach ($client->providers() as $p) {
    printf("%s: %s\n", $p->label, implode(", ", $p->hosts));
}
```

## Errors

Every failed API call throws an `ApiException` subclass keyed to the response
[error code](/docs/errors), under the `LinkSkipper\Exception` namespace. All extend
`LinkSkipperException`.

| Exception                      | Triggered by                                              |
| ------------------------------ | --------------------------------------------------------- |
| `InvalidRequestException`      | `invalid_request` (400)                                   |
| `InvalidKeyException`          | `invalid_key` (401)                                       |
| `OutOfCreditsException`        | `out_of_credits` (402) — `balance()`                      |
| `ForbiddenScopeException`      | `forbidden_scope` (403)                                   |
| `NotFoundException`            | `not_found` (404)                                         |
| `LinkRemovedException`         | `link_removed` (410)                                      |
| `UnsupportedLinkException`     | `unsupported_link` (422)                                  |
| `RateLimitedException`         | `rate_limited` (429) — `retryAfter()`                     |
| `QuotaExceededException`       | `quota_exceeded` (429) — `retryAfter()`                   |
| `ResolveFailedException`       | `resolve_failed` (502)                                    |
| `ProviderDownException`        | `provider_down` (503)                                     |
| `JobFailedException`           | A polled job ended `failed`/`invalid` — `$reason`, `$job` |
| `TimeoutException`             | `resolveAndWait` deadline passed — `$jobId`               |
| `NetworkException`             | Transport failure after retries                           |
| `ConfigurationException`       | Bad client configuration (e.g. empty key)                 |
| `WebhookVerificationException` | `Webhook::verify` rejected a payload                      |

Every `ApiException` exposes the parsed problem via methods: `status()`, `errorCode()`
(an `ErrorCode` enum), `detail()`, `retryAfter()`, `balance()`, and the raw
`$exception->problem`.

```php theme={null}
<?php

use LinkSkipper\Exception\ApiException;
use LinkSkipper\Exception\OutOfCreditsException;
use LinkSkipper\Exception\RateLimitedException;

try {
    $client->resolveAndWait("https://ouo.io/abc123");
} catch (OutOfCreditsException $e) {
    fwrite(STDERR, "Top up — balance: " . $e->balance() . PHP_EOL);
} catch (RateLimitedException $e) {
    fwrite(STDERR, "Back off for " . $e->retryAfter() . " seconds" . PHP_EOL);
} catch (ApiException $e) {
    fwrite(STDERR, $e->errorCode()->value . " " . $e->status() . " " . $e->detail() . PHP_EOL);
}
```

## Webhook verification

`Webhook::verify(string $payload, string $signatureHeader, string $secret, int $toleranceSeconds = 300): WebhookEvent`
checks the `X-LinkSkipper-Signature` header, the timestamp tolerance, and the HMAC, then
returns a typed `WebhookEvent`. It throws `WebhookVerificationException` on any failure.

```php theme={null}
<?php

use LinkSkipper\Webhook;
use LinkSkipper\Enum\WebhookEventName;
use LinkSkipper\Exception\WebhookVerificationException;

$payload   = file_get_contents("php://input");
$signature = $_SERVER["HTTP_X_LINKSKIPPER_SIGNATURE"] ?? "";

try {
    $event = Webhook::verify($payload, $signature, getenv("LINKSKIPPER_WEBHOOK_SECRET"));
    if ($event->event === WebhookEventName::ResolveDone) {
        save_target($event->jobId, $event->targetUrl);
    }
    http_response_code(204);
} catch (WebhookVerificationException $exception) {
    http_response_code(400);
}
```

The `WebhookEvent` exposes `event` (a `WebhookEventName` enum), `createdAt`, `jobId`,
`status` (a `JobStatus` enum), `targetUrl`, `provider`, `tier`, `error`, `creditsCharged`,
and `balance`.

<Warning>
  Read the **raw** request body with `php://input` before any JSON parsing, or the signature
  won't match. See [Webhooks](/docs/webhooks).
</Warning>
