Skip to main content
Instead of polling a job, 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 and we deliver a signed POST to that URL when the job is done, failed, or invalid.
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.

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

resolve.done
The job resolved successfully. target_url, provider, tier, and credits_charged are populated; error is null.
resolve.failed
The job ended in failed or invalid. target_url is null and error carries the reason (resolve_failed or unsupported_link).

Payload

The body is JSON with these fields:
event
string
"resolve.done" or "resolve.failed".
created_at
string
ISO 8601 timestamp of when the event was generated.
job_id
string
The job UUID this event is about.
status
string
The terminal job status: done, failed, or invalid.
target_url
string | null
The resolved destination on success; null on failure.
provider
string | null
The provider that owned the link (e.g. ouo), or null.
tier
string | null
"standard", "premium", or null.
error
string | null
null on success; otherwise resolve_failed or unsupported_link.
credits_charged
number
Credits spent on the job (0 on failure).
balance
number | null
Your credit balance after the job, or null if unavailable.
{
  "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
}

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:
1

Parse the header

Split on , into t=<seconds> and v1=<hex>.
2

Check the timestamp

Reject the request if |now - t| exceeds the tolerance (300 seconds by default). This prevents replay of an old capture.
3

Recompute the HMAC

Compute HMAC-SHA256 over the string `${t}.${rawBody}` using your per-key whsec_… secret, hex-encoded.
4

Compare in constant time

Compare your computed hex digest with v1 using a constant-time comparison (timingSafeEqual / hash_equals). Only then parse the JSON.
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.

With the SDK

The official SDKs ship a verifier that does all four steps and returns the typed event.
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;
      }
    }
  },
);
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.

Without the SDK

If you can’t use an SDK, the verification is short. Note the HMAC input is `${t}.${body}`.
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);
}

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