> ## 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.

# Resolve a link

> POST /v1/resolve — submit a shortener URL and get the destination synchronously or as a queued job.

<Note>
  **`POST https://api.linkskipper.app/v1/resolve`** · Requires the `resolve` scope.
</Note>

Submit a supported shortener URL. If the link has already been resolved, the destination
comes back immediately with HTTP `200`. Otherwise the link is queued for background
resolution and you get HTTP `202` with a job to poll (or a webhook delivery).

## Request

### Headers

<ParamField header="Authorization" type="string" required>
  `Bearer sk_live_…`. See [Authentication](/docs/authentication).
</ParamField>

<ParamField header="Content-Type" type="string" required>
  `application/json`.
</ParamField>

<ParamField header="Idempotency-Key" type="string">
  Optional. De-duplicates retries. Equivalent to the `idempotency_key` body field — if both
  are present, the body field wins. Max 128 characters. See [Idempotency](#idempotency).
</ParamField>

### Body

<ParamField body="url" type="string" required>
  The shortener URL to resolve. Max 2048 characters. Must be a link from a
  [supported provider](/docs/providers); anything else returns `unsupported_link` (`422`).
</ParamField>

<ParamField body="idempotency_key" type="string">
  Optional de-duplication token, max 128 characters. A repeated request with the same key
  returns the existing job's current state instead of starting a new resolve. Takes
  precedence over the `Idempotency-Key` header.
</ParamField>

<ParamField body="webhook_url" type="string">
  Optional public **HTTPS** callback. When the job reaches a terminal status, Link Skipper
  sends a signed `POST` to this URL. Max 2048 characters. Loopback, private, link-local, and
  `.localhost` / `.internal` hosts are rejected at validation time. See [Webhooks](/docs/webhooks).
</ParamField>

```json Request body theme={null}
{
  "url": "https://ouo.io/abc123",
  "idempotency_key": "order-42",
  "webhook_url": "https://api.your-server.com/webhooks/linkskipper"
}
```

## Response

The endpoint returns one of two shapes depending on whether the link was already cached.

### 200 — resolved (cached)

Returned when the link was already in cache. `job_id` is `null` because no job was created,
`cached` is `true`, and `credits_charged` is `0` (cached reads are free).

<ResponseField name="job_id" type="string | null">
  `null` for a cached, synchronous resolve.
</ResponseField>

<ResponseField name="status" type="string">
  `"done"`.
</ResponseField>

<ResponseField name="url" type="string">
  The URL you submitted (echoed back).
</ResponseField>

<ResponseField name="target_url" type="string">
  The final destination the shortener points to.
</ResponseField>

<ResponseField name="provider" type="string">
  The provider that owns the link (e.g. `ouo`). See [Providers](/docs/providers).
</ResponseField>

<ResponseField name="tier" type="string">
  `"standard"` or `"premium"`.
</ResponseField>

<ResponseField name="credits_charged" type="number">
  `0` for a cached resolve.
</ResponseField>

<ResponseField name="cached" type="boolean">
  `true` for a cached resolve.
</ResponseField>

<ResponseField name="balance" type="number | null">
  Your remaining credit balance after the call.
</ResponseField>

```json 200 OK theme={null}
{
  "job_id": null,
  "status": "done",
  "url": "https://ouo.io/abc123",
  "target_url": "https://example.com/final",
  "provider": "ouo",
  "tier": "standard",
  "credits_charged": 0,
  "cached": true,
  "balance": 248
}
```

### 202 — queued

Returned when the link must be resolved in the background. Poll the `poll_url` (the
[jobs endpoint](/docs/jobs)) until the job is terminal, or supply a `webhook_url` to be notified.

<ResponseField name="job_id" type="string">
  The UUID of the queued job. Use it with [`GET /v1/jobs/{job_id}`](/docs/jobs).
</ResponseField>

<ResponseField name="status" type="string">
  `"queued"`.
</ResponseField>

<ResponseField name="queue_position" type="number">
  Approximate position in the resolve queue at submission time.
</ResponseField>

<ResponseField name="poll_url" type="string">
  Relative path to poll for the result, e.g. `/v1/jobs/{job_id}`.
</ResponseField>

```json 202 Accepted theme={null}
{
  "job_id": "9b1d7c0e-2f3a-4b5c-8d6e-1a2b3c4d5e6f",
  "status": "queued",
  "queue_position": 3,
  "poll_url": "/v1/jobs/9b1d7c0e-2f3a-4b5c-8d6e-1a2b3c4d5e6f"
}
```

## Examples

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.linkskipper.app/v1/resolve \
    -H "Authorization: Bearer sk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://ouo.io/abc123"}'
  ```

  ```ts JavaScript theme={null}
  import { LinkSkipper } from "@linkskipper/sdk";

  const client = new LinkSkipper({ apiKey: process.env.LINKSKIPPER_API_KEY! });

  const result = await client.resolve("https://ouo.io/abc123");
  if (result.status === "done") {
    console.log(result.targetUrl); // cached hit
  } else {
    console.log(result.jobId); // queued — poll with getJob()
  }
  ```

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

  use LinkSkipper\LinkSkipper;
  use LinkSkipper\Enum\ResolveStatus;

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

  $result = $client->resolve("https://ouo.io/abc123");
  if ($result->status === ResolveStatus::Done) {
      echo $result->targetUrl, PHP_EOL; // cached hit
  } else {
      echo $result->jobId, PHP_EOL; // queued — poll with getJob()
  }
  ```
</CodeGroup>

<Note>
  The SDK `resolve()` methods submit the URL and (optionally) an idempotency key. To pass a
  `webhook_url`, send a raw HTTP request, or use the dashboard. To resolve **and wait** for
  the result in one call, use [`resolveAndWait`](/docs/sdks/javascript#resolveandwait).
</Note>

## Synchronous vs queued

<CardGroup cols={2}>
  <Card title="Cached → 200" icon="bolt">
    The link was resolved before. The destination comes back in the same response,
    `cached: true`, and you are **not** charged.
  </Card>

  <Card title="New → 202" icon="clock">
    The link is queued. You get a `job_id` and `poll_url`. Credits are only charged when the
    job succeeds.
  </Card>
</CardGroup>

You don't choose between the two — the API returns whichever applies. Always branch on the
HTTP status (or the `status` field) so your code handles both.

## Idempotency

A `POST /v1/resolve` is not naturally idempotent: a naive retry after a dropped connection
could queue a second job and charge twice. Send an idempotency key to make retries safe.

When you supply `idempotency_key` (body) or `Idempotency-Key` (header):

* The **first** request with that key starts the resolve and remembers the resulting job.
* Any **repeat** with the same key returns that job's **current** state as-is — it does not
  start a new resolve and does not charge again. While the job is still running you get the
  `202` queued shape back; once it finishes you get the resolved `200` shape.
* Idempotency keys are scoped per API key. Use a stable, unique value per logical operation
  (an order id, a content id, a UUID you generate).

```bash Safe retry theme={null}
# Re-running this exact request never opens a second job.
curl https://api.linkskipper.app/v1/resolve \
  -H "Authorization: Bearer sk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-42" \
  -d '{"url": "https://ouo.io/abc123"}'
```

<Info>
  A replay of a key whose job ended in failure surfaces the same terminal error:
  `resolve_failed` for a failed job, `unsupported_link` for an invalid one.
</Info>

## Errors

| Code               | Status | When                                                  |
| ------------------ | ------ | ----------------------------------------------------- |
| `invalid_request`  | 400    | Missing/unknown field, or `url` over 2048 chars.      |
| `invalid_key`      | 401    | Missing, malformed, or revoked API key.               |
| `out_of_credits`   | 402    | Balance is below the link's cost. Includes `balance`. |
| `forbidden_scope`  | 403    | Key lacks the `resolve` scope.                        |
| `link_removed`     | 410    | The destination has been removed by the shortener.    |
| `unsupported_link` | 422    | The URL is not a supported shortener (or has no URL). |
| `rate_limited`     | 429    | Per-minute limit hit. Includes `Retry-After`.         |
| `quota_exceeded`   | 429    | Daily quota reached. Includes `Retry-After`.          |
| `resolve_failed`   | 502    | The resolver could not resolve the link.              |
| `provider_down`    | 503    | The provider is temporarily unavailable.              |

See [Errors](/docs/errors) for the full envelope and handling guidance, and
[Rate limits](/docs/rate-limits) for `Retry-After` behavior.
