Skip to main content

Confirm

Are you sure?

Async & sync modes

By default, URLpipe processes every request asynchronously — it accepts the request instantly and hands you a token. Collect the result at a webhook we POST to, or fetch it by that token whenever you like. Add sync: true to any request to get the result back inline in the HTTP response instead.

Choosing a mode

Pick the mode per request with the sync parameter. Omit it (or send sync=false) for async — the default, which suits long-running work like /lighthouse and high-volume batches, since you never hold a connection open. Send sync=true for sync — simplest for quick, interactive calls where you want the answer right away.

Typical response times

How long a request takes depends mostly on the target page — its weight, how much JavaScript it runs, and how fast its own server responds. The figures below are drawn from production traffic for non-cached requests: p50 is the median and p90 is the 90th percentile, so 9 in 10 requests finish at or under it.

Endpoint
Type
p50
p90
Web
3.5 s
4.7 s
Web
4.0 s
7.6 s
AI
5.8 s
8.1 s
Web
6.2 s
6.7 s
AI
6.3 s
10.8 s
AI
7.9 s
11.6 s
AI
9.3 s
14.8 s
Web
15.3 s
20.5 s
Cache hits return in well under a second — they do no work, so tuning max_age is the simplest way to make repeat requests fast. AI endpoints add an LLM pass on top of the page fetch, and /lighthouse runs a full audit — the slowest by design, and a natural fit for async.

Async requests

Async is the default, so no sync parameter is needed. The endpoint responds immediately with a token, and the result reaches you one of two ways: we POST it to your webhook, or you fetch it with GET /result/:token when you are ready. Both are always available — the token in this response is what makes the second one work, whether or not a webhook is configured.

That immediate response also carries the usual metadata headers: the token, whether the result was already cached (and how old it is), and your remaining quota. The one it can't carry is X-Processing-Time-Ms — it measures how long the work took, and on a cache miss the work hasn't started. The number arrives with the result, in the webhook's meta object or on the GET /result/:token response. On a cache hit there was nothing to run, so the accept carries it already.

Where results go

Add report_to to a request to have that result delivered there. Set a default endpoint for the project (Settings → Default Webhook Endpoint) to have every async result delivered without repeating the URL on each call. Both are optional, and the request wins over the project:

Request has report_to
Project has a default
Where the result goes
yes
either
the request's report_to
no
yes
the project's default endpoint
no
no
nowhere — fetch it with GET /result/:token

A request that names no endpoint and belongs to a project with no default is still accepted and processed — it simply produces no webhook. That is the right shape if you poll, or if you queue tokens and collect results in your own time. Naming an endpoint we cannot deliver to, on the other hand, is a 422: it is a mistake worth reporting, whereas naming none is a choice.

Which endpoints we accept

A webhook endpoint is held to the same rules as the URL you ask us to analyse — both are requests we make from our own infrastructure. It must be a public http/https address on the scheme's default port, at a real domain name.

  • No IP addresses, in any notation — including decimal, octal, hex, short forms and IPv6
  • No localhost, bare hostnames, or internal suffixes like .local and .internal
  • No credentials in the URL — we would store them and show them back to you
  • No custom ports: https://hooks.example.com:8443/x is refused, https://hooks.example.com/x is fine

This applies to the report_to parameter and to the project default alike, and it is enforced again at delivery time — so an endpoint that stops qualifying is refused rather than called. A refused delivery is recorded on the Webhooks page with the reason, and the result stays available from GET /result/:token.

Developing against a local endpoint? Point report_to at a public tunnel (ngrok, Cloudflare Tunnel and the like), or skip the webhook entirely and poll GET /result/:token — which needs no endpoint at all.
Changing the project default affects requests made after the change. Every async request resolves its destination when we accept it and records it, so a delivery already in flight is never retargeted — and the Webhooks page in your dashboard shows exactly where each result was sent, with every attempt.

Request attributes

  • Name
    url
    Type
    string
    Required
    Required
    Description
    The page to process.
  • Name
    report_to
    Type
    string
    Description
    Webhook URL — an http or https address URLpipe POSTs the result to when it's ready. Optional: without it we deliver to your project's default endpoint if it has one, and otherwise send no webhook at all — the result still waits for you at GET /result/:token. A value we cannot deliver to returns 422. Ignored on a sync=true request. Deliveries can be signed so your endpoint can verify they came from us.
  • Name
    sync
    Type
    boolean
    Description
    Set to true to process the request synchronously instead. Defaults to false (async). Does not affect the cached result a request maps to.
  • Name
    max_age
    Type
    string | integer
    Description
    How fresh a cached result must be to be accepted. Either an integer number of seconds (3600) or a duration string of the form "<number> <unit>" — units s/min/h/d/w (e.g. "2 hours", "3 days", "30m"). Defaults to 7 days, clamped to a max of 30 days; 0 always bypasses the cache. See Caching for all accepted units.
POST /markdown
curl -X POST https://urlpipe.dev/markdown \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "report_to": "https://your-app.com/webhooks/urlpipe"
  }'
Immediate response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Result-Token: 0Zx3…9aQ
X-Cache: miss
X-Quota-Category: ai
X-Quota-Limit: 50
X-Quota-Remaining: 43
X-Quota-Reset: 2026-08-31T23:59:59Z

{
  "token": "0Zx3…9aQ",
  "status": "accepted"
}

The webhook delivery

When processing finishes, URLpipe sends a POST with a JSON body to the endpoint the request resolved to. The token matches the one from the immediate response, so you can pair it with your original request. A request with no endpoint skips this entirely; the same fields come back from GET /result/:token instead.

Payload fields

  • Name
    token
    Type
    string
    Description
    Correlates with the token returned when you made the request.
  • Name
    operation
    Type
    string
    Description
    The endpoint that ran, e.g. markdown.
  • Name
    success
    Type
    boolean
    Description
    Whether the operation succeeded.
  • Name
    result
    Type
    string | object | null
    Description
    The result on success (a string or JSON value, depending on the endpoint); null on failure.
  • Name
    error
    Type
    string | null
    Description
    A human-readable message on failure; null on success.
  • Name
    meta
    Type
    object
    Description
    The same metadata a synchronous response returns in its X- headers — an async accept cannot carry facts about work it hasn't done, so they ride here instead, with the result. Present on success and failure alike. Keys: cache, cache_age, processing_time_ms and quota (category, limit, remaining, resets_at). A value we don't have is null rather than absent.
On success
{
  "token": "0Zx3…9aQ",
  "operation": "markdown",
  "success": true,
  "result": "# Example Domain\n\nThis domain is for use…",
  "error": null,
  "meta": {
    "cache": "miss",
    "cache_age": null,
    "processing_time_ms": 4182,
    "quota": {
      "category": "ai",
      "limit": 50,
      "remaining": 43,
      "resets_at": "2026-08-31T23:59:59Z"
    }
  }
}
On failure
{
  "token": "0Zx3…9aQ",
  "operation": "markdown",
  "success": false,
  "result": null,
  "error": "The request timed out.",
  "meta": {
    "cache": "miss",
    "cache_age": null,
    "processing_time_ms": 60104,
    "quota": {
      "category": "ai",
      "limit": 50,
      "remaining": 44,
      "resets_at": "2026-08-31T23:59:59Z"
    }
  }
}

Signing & verifying webhooks

Your report_to URL has to accept an unauthenticated POST from the internet. Turn on webhook signing and every delivery carries an HMAC signature instead, so your endpoint can prove the request came from URLpipe and not from someone who learned the URL.

It is off by default and enabled per project under Settings → Webhook Signing in your dashboard, which is also where the signing secret appears once it is on (it starts with whsec_). One secret per project, used for every endpoint's deliveries.

Headers on a signed delivery

  • Name
    X-URLpipe-Timestamp
    Type
    string
    Description
    When we signed this attempt, as Unix seconds. It is part of what is signed, so it cannot be altered — reject anything older than your tolerance (5 minutes is a good default) to bound replays.
  • Name
    X-URLpipe-Signature
    Type
    string
    Description
    One or more signatures, comma-separated, each prefixed with its scheme version: v1=<hex>,v1=<hex>. Split the header and accept the delivery if any value matches — during a secret rotation we sign with both the new and the old secret, and matching on the prefix rather than the whole string is what keeps a future v2 from breaking your handler.

How it's computed

HMAC-SHA256 over the timestamp, a literal ., and the raw request body, keyed with your secret.

Delivery headers
POST /webhooks/urlpipe HTTP/1.1
Content-Type: application/json
X-URLpipe-Timestamp: 1756568400
X-URLpipe-Signature: v1=3a1f9c…7b2e
Signed string
signed = "1756568400" + "." + raw_body
signature = HMAC_SHA256(whsec_…, signed)
Verify against the raw request body — the exact bytes we sent. Parsing the JSON and re-serializing it changes key order and whitespace, and the digest with it. In Rails that is request.raw_post; in Express, express.raw() rather than express.json() on this route.
Verifying a delivery
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

TOLERANCE = 5 * 60
SECRET = os.environ["URLPIPE_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/urlpipe")
def urlpipe_webhook():
    # request.get_data() is the raw body; request.json is not.
    body = request.get_data()
    timestamp = request.headers.get("X-URLpipe-Timestamp", "")
    header = request.headers.get("X-URLpipe-Signature", "")

    # While rolling verification out, before signing is enabled,
    # accept a delivery that carries no signature:
    #   if not header: return process(body)
    if not header or not timestamp.isdigit():
        abort(401)
    if abs(time.time() - int(timestamp)) > TOLERANCE:
        abort(401)

    signed = timestamp.encode() + b"." + body
    expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    # Any one of the comma-separated signatures may match: during a
    # rotation we send the new and the old.
    if not any(hmac.compare_digest(s.strip(), expected) for s in header.split(",")):
        abort(401)

    return process(body)

def process(body):
    enqueue(json.loads(body)["token"])
    return "", 200

Turning signing on and off

Both take effect on the next delivery, including a retry of a webhook whose earlier attempts went out the other way. The two directions are not equally safe.

Turning it on is safe at any time. The signature rides in headers and never changes the payload — same method, same Content-Type, byte-identical body — so an endpoint that ignores the headers is unaffected. Enable it first, then add verification whenever you are ready. Doing it the other way round means your endpoint rejects unsigned deliveries before we sign any, so keep the no signature branch in the examples below until signing is on.

Turning it off is the breaking direction. Deliveries stop carrying the headers at once, so an endpoint that requires a signature will reject every result. Remove or relax your verification before you disable it.

Rotating the secret

Rotating in Settings opens a 24-hour grace window rather than cutting over. For those 24 hours every delivery carries two signatures — the new secret first, then the one it replaced — so an endpoint still holding the old secret keeps verifying while you deploy the new one, in either order and with nothing in flight rejected. After the window, only the new secret is sent.

That window is not what you want for a leaked secret: signing with the old key is exactly what keeps a leaked key useful to a forger, for as long as your endpoint honours it. Rotate, deploy the new secret, then use Stop accepting old secret in Settings to end the window immediately.

Delivery guarantees

  • A result that resolved to an endpoint is delivered there as a JSON POST. One that resolved to none is not delivered at all, and waits for you at GET /result/:token
  • Webhook delivery uses a 30-second timeout.
  • If delivery fails, the result is still stored and remains visible in your project's history in the dashboard, so nothing is lost.
  • Each request gets a unique token, so concurrent jobs never collide.
  • With signing enabled, every delivery — and every retry of one — is signed with your project's secret, carrying a fresh timestamp, so a retry passes a freshness check like any other delivery. With it off, no signature headers are sent.
  • A failed delivery can be sent again by hand from the Webhooks page in your dashboard, at any time — there is no window on it, which matters because the automatic attempts are long finished by the time an endpoint is fixed.
  • A delivery your endpoint rejects — a failed signature check included — is retried with backoff, up to six attempts in all spread over roughly twenty minutes. After that we stop and email your organization's admins. The result is not lost either way: it stays in your history and behind GET /result/:token for the full retention window.
  • The meta object is additive — every other payload field keeps its name, type and meaning, so existing consumers need no changes.
  • The figures in meta.quota are taken when the webhook is delivered, not when the analysis finished, so a retried delivery reports your allowance as of that attempt.
  • Prefer to pull instead of receive a webhook? Fetch the result any time with GET /result/:token.
Make your webhook endpoint idempotent and key it on token — treat a repeated token as the same result rather than a new one.

Sync mode

Send sync=true to process a request synchronously. Webhooks play no part: report_to and the project's default endpoint are both ignored, because the response IS the delivery. The request is processed inline and the result comes straight back in the HTTP response body, in the content type documented for each endpoint. Failures return a 422 with an error field.

Every response — sync or async — carries an X-Result-Token header identifying the request, so you can re-fetch the result later via GET /result/:token. It arrives alongside the cache status, the processing time and your remaining quota: see Response headers.

Sync timeouts

A sync request waits up to 60 seconds for the analysis to finish. If it isn't ready in time, the endpoint returns 504 Gateway Timeout with the token in the body — but the analysis keeps running in the background. Retrieve the result once it completes via GET /result/:token, or use async mode (omit sync) for consistently long-running work.

504 Gateway Timeout
{
  "error": "processing_timeout",
  "message": "The analysis is taking longer than expected. Retrieve it later via GET /result/:token, or use async mode for long operations.",
  "token": "0Zx3…9aQ"
}
A 504 comes back with the analysis still running, so it carries no X-Processing-Time-Ms header — there is no finished work to report yet. An async accept is the same, unless the result was already cached: then the lookup was the whole of the work and the accept carries the number already. Otherwise it arrives with the result — in the webhook's meta object, or on the GET /result/:token response that collects it.