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.
When the job finishes, Link Skipper sends:
Respond quickly with a 2xx (a 204 with no body is ideal). Do heavy work asynchronously — a slow or failing endpoint triggers retries.

Events

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

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

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