Store Builder

Public API (/api/v1)

Authenticate with an API key, read and write your store over HTTP, and understand the shape of every response and every error.

The API a partner, app or agency integration uses. Everything here is reachable with an API key or an installed app token — no session, no cookie, no user login.

This document did not exist until now. The API shipped without it, which is why nobody could use it: an endpoint nobody can find is not a shipped feature.


1. Get a key

In the app: Manage store → Settings → API keys (vi: Quản lý cửa hàng → Cài đặt → Khoá API), at /manage/{siteId}/settings.

Press New key (vi: Tạo khoá), give it a name, and tick the scopes it needs.

This page is written in English, and the app ships in English and Vietnamese. Where it names a button you have to press, the Vietnamese label follows in (vi: …) — a path you cannot find in your own session is not an instruction.

The secret is shown exactly once, at creation. It is stored hashed (SHA-256), so nobody — including this platform's own operators — can recover it afterwards. If it is lost, revoke the key and mint another. The list screen only ever shows the prefix (wbk_a1b2c3…), which is enough to tell keys apart and useless as a credential.

A key belongs to one store. There is no key that spans stores; an agency operating five clients holds five keys.


1b. Two pages, two jobs

This page is for reading. It answers what a key reaches, what PUT replaces, and where a price actually lives — the questions you have before your first call works.

/partner-docs/index.html is the console. A live Swagger UI of exactly this surface, generated from the handlers, with try-it-out: paste a key, fire a request at a real server, read the real response. It is the better tool the moment you know what you are sending, and it has never told anyone how to start — a list of operations cannot.

Neither replaces the other, so both are served and each links to the other.

/app-docs is the third page, and it is for a different person. If you are building an app that merchants install — rather than integrating against one store you already control — start there instead: it covers the OAuth flow, the wbc_/wbs_ client credentials, the wba_ token this surface then accepts, and the embedded screen. Everything below applies to an app unchanged once it holds a token; nothing below tells you how to get one.

The console contains only the seven /api/v1 paths. That is deliberate: it is built with --tags v1, so an integrator reading it can tell what their key may do. The FULL internal spec (100+ paths, most of them /api/sites/...) is at /swagger and is dev-only — it used to be public, which both leaked the internal API's shape and left an agency unable to tell which routes were theirs.

Regenerate after touching any annotation:

npm run docs:api        # the full internal spec
npm run docs:partner    # the v1-only partner spec

Two tests fail if the second is forgotten or if a private route leaks into it — see internal/server/tests/public_api_boundary_test.go.

2. Call it

curl https://api.your-host/api/v1/products \
  -H "Authorization: Bearer wbk_your_secret_here"

Two credentials, and one that is still refused

Prefix What it is Who holds it
wbk_ An API key a merchant minted for their own tooling (§1) The merchant, or whoever they gave it to
wba_ An app access token, issued when a merchant installs a marketplace app The app's own server
wbr_ An app refresh token — NOT accepted here The app; exchange it at /oauth/token first

Both accepted kinds belong to exactly one store, are scoped, and are revocable. They behave identically once past the door: the same endpoints, the same scope rules, the same errors.

Where a wba_ comes from: a merchant installing your app, through /oauth/authorize/oauth/token. That flow, end to end, is /app-docs — this page starts one step after it.

A user's access token is still refused, and that part was always the decision rather than a gap — accepting one would make this surface as powerful as whoever pasted it. Sending one answers 401 api_key_required.

This surface accepted API keys ALONE until the app marketplace shipped. The rule is recorded as having CHANGED rather than quietly rewritten, because the old one was a deliberate decision that an integrator may have read.

Three rules that explain most of the surface:

The store is implied by the key. There is no {siteId} anywhere in a v1 path, ever. A key that could be pointed at another store by editing a URL would be a key whose blast radius depends on the caller's honesty.

Every response is enveloped.

Shape Body
A list {"products": [...], "total": 42}
One item {"product": {...}}
Any error {"error": "human message", "code": "machine_code"}

total is the count before paging, so a client can render page numbers. Errors are always JSON — never plain text — so a failure can be branched on rather than merely displayed.

Paging is ?limit= + ?offset=. limit maxes out at 200; asking for more is clamped, not refused.


3. What is there

Seven resources today: products, orders, customers, pages, media, the blog (articles + categories) and webhooks.

Products — full CRUD

GET    /api/v1/products              list
POST   /api/v1/products              create
GET    /api/v1/products/{id}         read
PUT    /api/v1/products/{id}         replace
DELETE /api/v1/products/{id}         delete

Query: ?q= (name, slug, SKU, tags) · ?status=draft|active|archived · ?sort=number|name|price|stock|created|updated (default number) · ?dir=asc|desc · ?limit= · ?offset=

An unrecognised sort falls back to the default rather than erroring: a bad filter should not turn a read into a failure.

# Everything active, newest first
curl "https://api.your-host/api/v1/products?status=active&sort=created&dir=desc&limit=20" \
  -H "Authorization: Bearer $WB_KEY"

# Create one. PRICE LIVES ON A VARIANT, never on the product: a product's own
# priceCents is the lowest visible variant price, computed on write. Sent at the
# top level it is dropped, and you get a product worth nothing.
curl -X POST https://api.your-host/api/v1/products \
  -H "Authorization: Bearer $WB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Cotton T-shirt","status":"active","variants":[{"sku":"TS-M","priceCents":250000,"stock":10}]}'

The server owns identity and derived data: ids, timestamps, the sequential number, minimum price, stock totals. Sending them is not an error — they are ignored, because silently accepting half of what a caller sent is worse than having a shape that says what it takes.

Orders — read, plus a status patch

GET    /api/v1/orders                list
GET    /api/v1/orders/{id}           read
PATCH  /api/v1/orders/{id}           update status only

There is no POST, and that is a decision rather than an omission. Creating an order is checkout: it redeems a discount under a lock, enforces usage limits, and computes totals from a live catalogue. A plain CRUD create would either bypass all of that — minting revenue records nothing reconciles — or quietly become a second checkout that drifts from the real one. A POST returns 405 with "code": "orders_are_read_only" so an integration author knows the boundary is deliberate and not a missing route.

One wrinkle that surprises people, including whoever writes the client: a POST from a key without orders.write returns 403 insufficient_scope, not the 405. The scope check runs first, on purpose (see §4) — you only meet the read-only boundary once you hold the scope that would otherwise let you past it.

The PATCH takes only status, payment, fulfillment, and every field is optional — an absent field means leave it:

# A warehouse marking a parcel shipped. It knows nothing about payment,
# and cannot clobber it by omission.
curl -X PATCH https://api.your-host/api/v1/orders/ord_123 \
  -H "Authorization: Bearer $WB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fulfillment":"fulfilled"}'

Line items, totals and the customer snapshot are history and cannot be rewritten through this route.

Customers — full CRUD

GET    /api/v1/customers             list
POST   /api/v1/customers             create
GET    /api/v1/customers/{id}        read
PUT    /api/v1/customers/{id}        replace
DELETE /api/v1/customers/{id}        delete

ordersCount and totalSpentCents are computed at read time, so a loyalty integration does not have to pull every order per customer.

The merchant's private note is not exposed. Support notes are written for colleagues, not for whatever a partner integration displays back to the customer.

Pages — metadata CRUD, plus publish

GET    /api/v1/pages                 list
POST   /api/v1/pages                 create
GET    /api/v1/pages/{id}            read
PATCH  /api/v1/pages/{id}            update (a real patch — absent means "leave it")
DELETE /api/v1/pages/{id}            delete
POST   /api/v1/pages/{id}/publish    compile the draft into the live page

The page body is not here, and will not be. A page's document is the builder's node tree: a graph whose shape belongs to the editor↔renderer render contract and changes whenever an element is added. Publishing it would make every new element a breaking change to a frozen contract, and the first schema migration would break every integration at once.

So a page is addressed here as a thing with a URL — list, create, rename, re-slug, reorder, delete, publish. Lay the page out in the editor; drive the lifecycle from here.

# Create a page and put it live in two calls.
curl -X POST https://api.your-host/api/v1/pages \
  -H "Authorization: Bearer wbk_…" -H 'Content-Type: application/json' \
  -d '{"name":"Autumn sale","settings":{"seoTitle":"Autumn sale"}}'

curl -X POST https://api.your-host/api/v1/pages/pg_123/publish \
  -H "Authorization: Bearer wbk_…"

Three things worth knowing before the first call:

  • The first page of a site becomes its homepage automatically, and so does any page sent with isHomepage: true — which demotes whichever page held it. That is the one field here with a side effect on another page.
  • settings is passed through verbatim. Its inner keys are deliberately not part of the frozen contract; the field promises only that what you store comes back. Read it, change the keys you know, send the whole thing back.
  • publish cascades: a page sharing a global section with others republishes them too, because a header edited once must not go live on one page and stay stale on the rest.

Media — the library, including upload

GET    /api/v1/media                 list assets
POST   /api/v1/media                 upload (multipart/form-data)
GET    /api/v1/media/{id}            read
PATCH  /api/v1/media/{id}            rename and/or re-file
DELETE /api/v1/media/{id}            move to trash
POST   /api/v1/media/{id}/restore    take it back out
GET    /api/v1/media-folders         the folder tree
POST   /api/v1/media-folders         create a folder

The upload is the only request on this surface that is not JSON:

curl -X POST https://api.your-host/api/v1/media \
  -H "Authorization: Bearer wbk_…" \
  -F file=@hero.jpg -F folderId=mdf_123 -F name="Autumn hero"

Images, videos and fonts (woff2/woff/ttf/otf), up to 25 MiB. The size that counts against the store's quota is measured from the uploaded part itself — there is no field you can send to influence it. Over quota answers 413 with used, limit and incoming in bytes, refused before anything is stored, so it costs the store nothing.

DELETE trashes; it does not destroy, and it does not free the quota. A published page may still be serving the file and there is no reference counting, so an API that could hard-delete would be an API that can put a hole in a live storefront from a script. The permanent purge stays in the app, beside the screen that can show what a delete is about to break.

Assets carry a url, never an object key: the URL prefix is storage configuration composed at read time, which is what lets the CDN move without rewriting rows. It is the one field here you should not cache forever.

Blog — articles and categories, full CRUD

GET    /api/v1/articles              list
POST   /api/v1/articles              create
GET    /api/v1/articles/{id}         read
PUT    /api/v1/articles/{id}         replace
DELETE /api/v1/articles/{id}         delete

GET    /api/v1/blog-categories       list
POST   /api/v1/blog-categories       create
GET    /api/v1/blog-categories/{id}  read
PUT    /api/v1/blog-categories/{id}  replace
DELETE /api/v1/blog-categories/{id}  delete

Both sit under the same permission pair, blog.read / blog.write.

content is sanitised on write — script tags and event handlers are stripped, because this body is rendered into a storefront page verbatim. Read the response rather than assuming what you sent is what will be served.

Two asymmetries that will otherwise surprise you:

  • An article slug is derived from the title when omitted and de-duplicated on collision. A category slug is required, and is neither. That is the domain's rule, not this API's: a category slug is a URL segment an author picks.
  • An article whose bodyType is page carries a builder document instead of HTML. Reading one is fine; writing it answers 409 unsupported_body_type, because that body travels in no field of the write shape — a PUT would not modify it, it would delete it, on a request that looked like an ordinary edit.

Unknown categoryIds are dropped rather than refused, so one stale reference does not fail a whole import. Compare the response to see which stuck.

Webhooks — register a URL to be called

GET    /api/v1/webhooks              list
POST   /api/v1/webhooks              register (answers with the endpoint AND its signing secret)
GET    /api/v1/webhooks/{id}         read
PUT    /api/v1/webhooks/{id}         replace
DELETE /api/v1/webhooks/{id}         remove
GET    /api/v1/webhooks/{id}/deliveries  delivery history — see §8

This is the reason a store is reachable through this API at all rather than a polling loop: register a URL once, and this platform calls it — signed — the moment order.created, product.updated and the rest of the catalogue happen. It is also the whole justification for a key belonging to one store: an agency running fifty stores registers fifty times through this route, one call each, instead of clicking through fifty settings screens.

curl -X POST https://api.your-host/api/v1/webhooks \
  -H "Authorization: Bearer $WB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/webbuilder","events":["order.created","order.updated"]}'

The response carries the signing secret, and it is the only response on this whole surface that does. A receiver cannot verify a delivery's signature without it, so it is handed over exactly once, as an explicit sibling of webhook in the create response:

{ "webhook": { "id": "whe_…", "url": "…", "events": [...], "status": "active", … },
  "secret": "whsec_…" }

There is no route on this surface to read it back — GET, list and PUT all answer the same webhook shape with no secret field at all, not even an empty one. Lose it and the fix is to delete the endpoint and register a new one; that boundary exists because webhooks.read reaches the lowest role on a store, and a GET that could return a signing secret would let that role forge deliveries into the merchant's own receiver.

The URL is checked before anything is stored, on both create and replace. A loopback address, a link-local one (the cloud metadata service lives at one), or a hostname that resolves to either answers 400 blocked_url — this is exactly where an attacker would prefer to point a server-side fetch, so the same guard the app's own settings screen runs applies here too. events must name at least one type this platform emits; an endpoint subscribed to nothing would never fire, with no error anywhere to notice by.

PUT is a replace, like the product endpoint: send every field you want kept, because an omitted one resets rather than survives.

status accepts active and disabled always. failing is a server-set label (this platform raises it after three consecutive delivery failures) and a PUT that tries to SET it is refused with 400 invalid_status — with one exception: if the endpoint you are replacing is ALREADY failing, sending "status":"failing" back unchanged is accepted. That is what makes the read-modify-write flow this whole section recommends — GET the endpoint, change what you actually want to change, PUT it back — work on the exact endpoint you are most likely to be looking at: one the platform has already flagged as failing.


4. Scopes

A key holds a set of <domain>.read / <domain>.write permissions. The domains are products, orders, customers, discounts, blog, integrations, pages, theme, media, codefiles, webhooks — the same vocabulary the app's own roles use, so a key can never be granted something the product does not model.

Two bounds apply on every request, and the scope is checked first:

  1. the key's own scopes must contain the permission, and
  2. the member who created the key must still hold it through their role.

The second is what keeps a key safe over time: a key minted by an admin who is later demoted to viewer loses write access on the next request, with no revocation sweep and no bookkeeping on the integration's side. Permissions are resolved live, never captured at mint time.

A key missing a scope gets 403 with "code": "insufficient_scope" — not 404, so the caller can tell "you may not" from "it is not there".


5. Errors worth branching on

Status code Means
401 missing_credential No Authorization header at all
401 api_key_required A header, but neither a wbk_ key nor a wba_ app token (a user's session token, say)
401 refresh_token_not_accepted A wbr_ refresh token. Exchange it at /oauth/token for a wba_ access token first
401 invalid_credential Looks like a key, matches none
401 key_revoked It existed and was turned off
401 app_token_invalid An app token that is forged, expired, or whose app is no longer installed on that store. The three are ONE answer on purpose: the fix is the same — reinstall — and splitting them would reveal whether a given install exists
503 resource_unavailable An app token on a server with no marketplace configured. The route and the credential may both be fine; this deployment cannot check it
403 insufficient_scope The key was never granted this
403 key_unusable The key's stored scopes no longer validate — mint a new one. Not the caller's fault, and said so
404 not_found No such row in this key's store
404 unknown_resource No such v1 resource
405 orders_are_read_only Deliberate boundary, not a missing route
409 duplicate_email / duplicate_sku / duplicate_slug / duplicate_name Which field clashed
409 unsupported_body_type This article's body is a builder document; edit it in the editor
409 nothing_to_publish The page has no saved draft yet
409 cycle_detected A category moved under its own descendant
413 quota_exceeded Out of storage. The body carries used, limit, incoming in bytes
429 rate_limited Slow down; the limit is per key
503 resource_unavailable The route is real; this server has not configured that resource (media with no object store, say). Not 404 — retrying later is the right move
400 invalid_body / invalid_status / invalid_price / invalid_slug The request
400 missing_file / empty_file / unsupported_type The upload
400 blocked_url A webhook URL resolves somewhere this platform must not be pointed at (SSRF guard)
400 no_events / invalid_event A webhook with no subscriptions, or one naming an event type this platform does not emit

The 401s are separated on purpose: "you sent nothing", "you sent the wrong kind of thing", "you sent the wrong HALF of an app's credential pair", "this key is not real" and "this key was revoked" need different fixes, and one generic unauthorized would make an integration guess between them.


6. What is not here yet

Seven resources cover the headless core — a storefront, an ERP sync, a loyalty tool, a content migration and a page-building agency between them need products, orders, customers, pages, media, the blog and webhooks.

It is still a growing set, not a designed boundary. Four domains (discounts, theme, integrations, codefiles) exist in the app's private API and in the scope vocabulary; what they do not have is a v1 DTO. That is the actual work per resource:

  1. a frozen public shape — the public API must never leak a domain struct, because a struct that serialises today publishes every field it grows tomorrow;
  2. an allow-listed query parser;
  3. a route case;
  4. a key-set test that fails the moment a field appears or disappears, and a strict-decode test that fails the moment the docs and the response disagree.

internal/publicapi/products.go is the reference for a full-CRUD resource, pages.go for one whose domain exposes no query, and media.go for one whose write carries bytes.

Two things are absent by decision, not for want of a DTO, and both are restated here because they are the first questions an integrator asks:

  • The page document and the page-bodied article body. They are builder node trees. Freezing their shape into a partner contract would freeze the render contract with them, and every new canvas element would become a breaking API change. If that boundary ever moves it moves through a separately versioned document endpoint — not by widening Page or Article.
  • Permanently deleting a media asset. DELETE trashes. Purging reaches storage and can break a live published page, and there is no reference counting to tell you which — so it stays in the app, next to the screen that can show what is about to break.

7. Boundaries the tests enforce

These are not conventions; internal/server/tests/public_api_boundary_test.go fails the build if they are broken.

  • The private API is unversioned and stays that way. /api/v1 is the partner surface; /api/sites/... is the app talking to itself and may change whenever the app does.
  • Every v1 handler authorizes through AuthorizePrincipal, never the user-shaped Authorize — a delegated caller must go through the path that checks scopes.
  • No v1 route takes a {siteId}. The store comes from the key.

8. Webhooks

Section 3 covers registering an endpoint. This section covers what actually lands on it once you have one — the delivery contract that is the whole reason this API exists rather than a polling loop.

The catalogue

Eight event types, and the list is a frozen public contract: adding one is free, but renaming or removing one breaks every receiver at once with no deprecation path — a receiver's switch on type lives on the other side of nothing this platform controls.

Event Fires when
order.created An order is placed
order.updated An order's status, payment or fulfillment changes
customer.created A customer is added
customer.updated A customer record changes
product.created A product is created
product.updated A product changes
product.deleted A product is removed
page.published A page goes live (POST /pages/{id}/publish, §3)

events on a registered endpoint (§3) must name at least one of these; anything else answers 400 invalid_event.

The envelope

Every delivery is a POST with this body, whatever the event:

{
  "id": "evt_01h...",
  "type": "order.created",
  "createdAt": "2026-08-14T10:00:00Z",
  "data": { "...": "the event's own payload" }
}

id is stable across every attempt of the same delivery — it is also the X-WB-Event-Id header below, and it is the field to key a dedupe table on (see Guarantees, next).

Five headers ride alongside the body:

Header Carries
X-WB-Signature sha256=<hex> — see Verifying it, below
X-WB-Timestamp Send time, RFC3339 — also the value folded into the signature
X-WB-Event-Id Same string as the envelope's id
X-WB-Event-Type Same string as the envelope's type
X-WB-Attempt 1 on the first try, incrementing on every retry

Delivery guarantees, stated as guarantees

At-least-once, never exactly-once. A delivery this platform believes failed — a timeout, a dropped connection, a 5xx — is retried in full, even when the receiver actually processed it and only the response back to us was lost. Duplicates will happen. A receiver that turns order.created straight into a charge, an email, or a stock decrement without checking id first will eventually do it twice. id (equal to X-WB-Event-Id) is the dedupe key: record the ones already handled, and no-op a repeat.

Unordered. Deliveries go out in the order they become due, not the order the events happened in. A retry sits behind its backoff delay, so a fresh event for the same resource can be sent — and land — before it. Do not infer sequence from arrival order; if you need one, read the resource's own updatedAt.

The retry schedule

Seven attempts total, the first one immediate, each next one further out:

Attempt Sent
1 Immediately
2 +1 minute
3 +5 minutes
4 +30 minutes
5 +2 hours
6 +6 hours
7 +24 hours

That is roughly 32.6 hours from the first attempt to the last — long enough to outlast a deploy, an expired certificate or a night's outage, short enough that nothing is still being retried next week. After attempt 7 fails the delivery is marked dead and nothing further is sent for it. The endpoint is not disabled by this — it keeps receiving new events, because an integration that comes back on its own should catch up by itself rather than be switched off silently.

Verifying a delivery is really from us

This is the paragraph that matters: a signature scheme nobody can implement is a signature scheme nobody uses.

The string that is signed is <unix-seconds>.<raw body> — and that is not X-WB-Timestamp verbatim. The header is RFC3339 (2026-08-14T10:00:00Z); the string HMAC'd is the same instant as Unix seconds. Parse the header, take its epoch seconds, and build the string yourself:

const crypto = require('crypto');

function isGenuine(secret, timestampHeader, rawBody, signatureHeader) {
  const unixSeconds = Math.floor(new Date(timestampHeader).getTime() / 1000);
  const signedString = `${unixSeconds}.${rawBody}`;
  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret).update(signedString).digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  // Constant-time, not `===`. A byte-by-byte compare returns the moment it
  // finds the first mismatch, so its timing leaks how much of the prefix an
  // attacker already has right — a working attack, not a theoretical one.
  // Always use your language's fixed-time comparison here.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

rawBody must be the exact bytes received, before any JSON parsing — a framework that re-serialises the body before your handler sees it (different key order, different spacing) produces a string that never matches, on a signature that was never wrong.

The timestamp is inside the signed string on purpose. A signature over the body alone never expires: capture one genuine delivery once, and it replays forever, indistinguishable from the original. Folding the timestamp in lets a receiver also reject anything more than a few minutes old, closing that window. This platform does not enforce a tolerance on your behalf — that decision, and its width, is yours to make.

Managing endpoints — three things that will surprise you

All three are stated in §3 and restated here, because they are the first questions anyone building against this asks:

  • The secret comes back exactly once — in the response to POST /api/v1/webhooks, as an explicit sibling of webhook, never on a GET. There is no reveal route on v1 today; a lost secret means delete the endpoint and register a new one.
  • PUT is a full replace, not a patch. Every field of WebhookInput is sent, not merged — an omitted description clears it, the same rule the product endpoint follows.
  • status: "failing" survives a PUT only when it was already there. You cannot SET failing on a healthy endpoint — that answers 400 invalid_status — but reading a failing endpoint and PUTting it back with that same status while you fix its url works, which is what makes read-modify-write usable on the one endpoint you are actually repairing.

Checking whether it actually arrived

GET /api/v1/webhooks/{id}/deliveries is the answer to the question registering an endpoint immediately raises: is this actually working? One page of that endpoint's attempts, newest first — no ?limit gets you 50, the same default every list on this surface applies rather than the endpoint's whole retained history:

{ "deliveries": [
    { "id": "whd_…", "endpointId": "whe_…", "eventId": "evt_…",
      "eventType": "order.created", "attempt": 2, "status": "delivered",
      "nextAttemptAt": "…", "lastStatusCode": 200, "lastError": "",
      "createdAt": "…", "deliveredAt": "…" }
  ],
  "total": 1 }

payload is never included. webhooks.read is grantable on its own — the whole reason webhooks carries its own scope rather than riding orders.read — and a delivery's payload is the order or customer record that triggered it; returning it here would make that independence a fiction. If you need to know exactly what was sent, the envelope your receiver already got (§8, The envelope) is the payload, verified by the same signature you checked on arrival.

There is no ?offset on this route, and that is permanent, not unfinished. Every other list on this surface pages with limit + offset; this one takes limit only. You always get the endpoint's MOST RECENT deliveries — older ones are not reachable through this API once a busy endpoint has produced more than limit since you last checked. If you need the full history, watch id (X-WB-Event-Id) on your own receiver as deliveries arrive; this route is for "is it working right now", not an archive.

The SSRF refusal

A webhook URL is checked before it is stored, on both create and replace: private, loopback and link-local addresses are refused, and — because a hostname's DNS answer can change after that check runs — the resolved address is checked again at dial time, with redirects never followed. Point one at an internal address and the answer is 400 blocked_url immediately; there is nothing on your end to debug an afternoon away on.

Updated 22/08/2026