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. settingsis 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.publishcascades: 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
bodyTypeispagecarries a builder document instead of HTML. Reading one is fine; writing it answers409 unsupported_body_type, because that body travels in no field of the write shape — aPUTwould 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:
- the key's own scopes must contain the permission, and
- 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:
- 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;
- an allow-listed query parser;
- a route case;
- 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
PageorArticle. - Permanently deleting a media asset.
DELETEtrashes. 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/v1is 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-shapedAuthorize— 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 ofwebhook, never on aGET. There is no reveal route onv1today; a lost secret means delete the endpoint and register a new one. PUTis a full replace, not a patch. Every field ofWebhookInputis sent, not merged — an omitteddescriptionclears it, the same rule the product endpoint follows.status: "failing"survives a PUT only when it was already there. You cannot SETfailingon a healthy endpoint — that answers400 invalid_status— but reading a failing endpoint and PUTting it back with that same status while you fix itsurlworks, 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