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

# Webhooks

> Get resolve results pushed to your server with a signed POST instead of polling.

Instead of [polling a job](/docs/jobs), you can have Link Skipper push the result to your server
when a resolve reaches a terminal status. Pass a `webhook_url` on
[`POST /v1/resolve`](/docs/resolve) and we deliver a signed `POST` to that URL when the job is
`done`, `failed`, or `invalid`.

<Info>
  Webhooks are optional and per-request. There is no global webhook endpoint — each resolve
  decides where (if anywhere) its result is delivered via the `webhook_url` field.
</Info>

## Registering a webhook

Include `webhook_url` in the resolve body. It must be a public **HTTPS** URL; loopback,
private, link-local, and `.localhost` / `.internal` hosts are rejected at validation time.

```bash 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",
    "webhook_url": "https://api.your-server.com/webhooks/linkskipper"
  }'
```

When the job finishes, Link Skipper sends:

```http theme={null}
POST /webhooks/linkskipper HTTP/1.1
Host: api.your-server.com
Content-Type: application/json
X-LinkSkipper-Signature: t=1717603200,v1=5f1c…e9a2

{
  "event": "resolve.done",
  "created_at": "2026-06-05T12:00:00.000Z",
  "job_id": "9b1d7c0e-2f3a-4b5c-8d6e-1a2b3c4d5e6f",
  "status": "done",
  "target_url": "https://example.com/final",
  "provider": "ouo",
  "tier": "standard",
  "error": null,
  "credits_charged": 1,
  "balance": 248
}
```

Respond quickly with a `2xx` (a `204` with no body is ideal). Do heavy work asynchronously —
a slow or failing endpoint triggers retries.

## Events

<ResponseField name="resolve.done">
  The job resolved successfully. `target_url`, `provider`, `tier`, and `credits_charged` are
  populated; `error` is `null`.
</ResponseField>

<ResponseField name="resolve.failed">
  The job ended in `failed` or `invalid`. `target_url` is `null` and `error` carries the
  reason (`resolve_failed` or `unsupported_link`).
</ResponseField>

## Payload

The body is JSON with these fields:

<ResponseField name="event" type="string">
  `"resolve.done"` or `"resolve.failed"`.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO 8601 timestamp of when the event was generated.
</ResponseField>

<ResponseField name="job_id" type="string">
  The job UUID this event is about.
</ResponseField>

<ResponseField name="status" type="string">
  The terminal job status: `done`, `failed`, or `invalid`.
</ResponseField>

<ResponseField name="target_url" type="string | null">
  The resolved destination on success; `null` on failure.
</ResponseField>

<ResponseField name="provider" type="string | null">
  The provider that owned the link (e.g. `ouo`), or `null`.
</ResponseField>

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

<ResponseField name="error" type="string | null">
  `null` on success; otherwise `resolve_failed` or `unsupported_link`.
</ResponseField>

<ResponseField name="credits_charged" type="number">
  Credits spent on the job (`0` on failure).
</ResponseField>

<ResponseField name="balance" type="number | null">
  Your credit balance after the job, or `null` if unavailable.
</ResponseField>

<Tabs>
  <Tab title="resolve.done">
    ```json theme={null}
    {
      "event": "resolve.done",
      "created_at": "2026-06-05T12:00:00.000Z",
      "job_id": "9b1d7c0e-2f3a-4b5c-8d6e-1a2b3c4d5e6f",
      "status": "done",
      "target_url": "https://example.com/final",
      "provider": "ouo",
      "tier": "standard",
      "error": null,
      "credits_charged": 1,
      "balance": 248
    }
    ```
  </Tab>

  <Tab title="resolve.failed">
    ```json theme={null}
    {
      "event": "resolve.failed",
      "created_at": "2026-06-05T12:00:00.000Z",
      "job_id": "9b1d7c0e-2f3a-4b5c-8d6e-1a2b3c4d5e6f",
      "status": "failed",
      "target_url": null,
      "provider": "ouo",
      "tier": "standard",
      "error": "resolve_failed",
      "credits_charged": 0,
      "balance": 249
    }
    ```
  </Tab>
</Tabs>

## Verifying the signature

Every delivery carries an `X-LinkSkipper-Signature` header so you can confirm it really came
from Link Skipper and wasn't tampered with. The header has the form:

```
X-LinkSkipper-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
```

To verify:

<Steps>
  <Step title="Parse the header">
    Split on `,` into `t=<seconds>` and `v1=<hex>`.
  </Step>

  <Step title="Check the timestamp">
    Reject the request if `|now - t|` exceeds the tolerance (**300 seconds** by default).
    This prevents replay of an old capture.
  </Step>

  <Step title="Recompute the HMAC">
    Compute `HMAC-SHA256` over the string `` `${t}.${rawBody}` `` using your per-key
    `whsec_…` secret, hex-encoded.
  </Step>

  <Step title="Compare in constant time">
    Compare your computed hex digest with `v1` using a constant-time comparison
    (`timingSafeEqual` / `hash_equals`). Only then parse the JSON.
  </Step>
</Steps>

<Warning>
  Verify against the **raw request body bytes**, exactly as received. If your framework
  re-serializes JSON before you hash it, the signature won't match. Read the raw body
  (e.g. `express.raw`, `php://input`) before any JSON parsing.
</Warning>

### With the SDK

The official SDKs ship a verifier that does all four steps and returns the typed event.

<CodeGroup>
  ```ts JavaScript (SDK) theme={null}
  import express from "express";
  import { verifyWebhook, WebhookVerificationError } from "@linkskipper/sdk";

  app.post(
    "/webhooks/linkskipper",
    express.raw({ type: "application/json" }),
    (req, res) => {
      try {
        const event = verifyWebhook(
          req.body.toString("utf8"),
          req.header("X-LinkSkipper-Signature"),
          process.env.LINKSKIPPER_WEBHOOK_SECRET,
        );
        if (event.event === "resolve.done") {
          saveTarget(event.job_id, event.target_url);
        }
        res.sendStatus(204);
      } catch (error) {
        if (error instanceof WebhookVerificationError) {
          res.sendStatus(400);
        } else {
          throw error;
        }
      }
    },
  );
  ```

  ```php PHP (SDK) 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);
  }
  ```
</CodeGroup>

<Note>
  `verifyWebhook` / `Webhook::verify` accept an optional fourth argument to override the
  300-second tolerance. They throw `WebhookVerificationError` /
  `WebhookVerificationException` on a malformed header, stale timestamp, signature mismatch,
  or invalid payload.
</Note>

### Without the SDK

If you can't use an SDK, the verification is short. Note the HMAC input is `` `${t}.${body}` ``.

<CodeGroup>
  ```js JavaScript (raw) theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verify(payload, header, secret, toleranceSeconds = 300) {
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=")),
    );
    const t = Number(parts.t);
    if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) {
      throw new Error("stale_timestamp");
    }
    const expected = createHmac("sha256", secret)
      .update(`${parts.t}.${payload}`, "utf8")
      .digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(parts.v1 ?? "");
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      throw new Error("bad_signature");
    }
    return JSON.parse(payload);
  }
  ```

  ```php PHP (raw) theme={null}
  <?php

  function verify(string $payload, string $header, string $secret, int $tolerance = 300): array
  {
      parse_str(str_replace(",", "&", $header), $parts);
      $t = (int) ($parts["t"] ?? 0);
      if (abs(time() - $t) > $tolerance) {
          throw new RuntimeException("stale_timestamp");
      }
      $expected = hash_hmac("sha256", "$t." . $payload, $secret);
      if (!hash_equals($expected, $parts["v1"] ?? "")) {
          throw new RuntimeException("bad_signature");
      }
      return json_decode($payload, true);
  }
  ```
</CodeGroup>

## Delivery, retries, and idempotency

* Link Skipper retries failed deliveries (non-`2xx` or connection errors) with backoff, so
  your endpoint may receive the **same event more than once**. Treat handling as idempotent —
  key on `job_id` and ignore an event you've already processed.
* Always verify the signature **before** trusting any field in the body.
* Return a fast `2xx`. If you need to do slow work, enqueue it and acknowledge immediately;
  a slow response counts as a failed delivery and is retried.
* The webhook secret is `whsec_…`, found in the dashboard next to your key. Rotate it there
  if it leaks. See [Authentication](/docs/authentication#the-webhook-signing-secret).

<Info>
  Webhooks complement polling — they don't replace it. If a webhook is never acknowledged
  you can still read the final result from [`GET /v1/jobs/{job_id}`](/docs/jobs).
</Info>
