API conventions¶
Rules that hold across the whole REST surface, plus the
per-endpoint semantics that are easy to get wrong. The authoritative
contract is schemas/openapi.yaml; this document explains the parts a
schema cannot express.
Base path is /api/v1. Three surfaces sit deliberately outside it: /scim/v2
(the SCIM 2.0 spec fixes that path), /sso/{org} (a browser destination
people paste into address bars and IdP consoles, not a JSON operation), and
/internal/x/<name>/ (an extension's callbacks from a service of its own on
the private network, gated by a shared secret and never routed by an edge).
The last is not part of the public API and answers bare status codes.
Versioning¶
/api/v1 is additive: a CI gate (oasdiff) refuses a pull request that
removes or narrows anything in the contract. An agent is classified by its
modes (?mode= filters on it, and the mode facet counts it). A
version's manifestSchemaVersion says which manifest major its snapshot
follows, so a client reading a v1 snapshot applies the v1 mapping
(skill, prompt-agent → [autonomous]).
Errors are problem+json¶
Every error response is application/problem+json (RFC 9457) with type,
title and status; most also carry a detail. Read detail for the
human-facing reason; do not parse title. A 422 carries field-level
validation errors; a 409 means the request conflicts with current state
and the detail says how.
Three problems exist only where a deployment runs an admission extension
(the hosted product's subscription tiers); a core registry never answers
them. 402 credits-exhausted refuses a submission after every core check
has passed and before anything is stored, and carries balance,
resetsAt and account beside the standard members so a client can say
when to retry and whose balance is short — the submitter's own, or the
organization's when they are a member of one that pays for their work.
402 subscription-inactive refuses a submission whose payer is an
organization awaiting payment or lapsed, and names it in account too.
503 admission-unavailable means the check could not be reached; nothing
was recorded. 422 private-agent-cap refuses one more private agent in a
namespace at its cap, on create and on a PATCH that turns an agent
private; nothing already private is touched.
The organization problems, each with
its own slug so a client can offer the right next step. Answered by every
registry: 409 last-admin refuses the demotion or removal of an
organization's last admin, by an admin or by the organization's IdP over
SCIM, their leaving, and the erasure of their account (POST
/user/delete) — promote another member first; only a decommission
removes the last one. 403 email-unverified refuses POST /orgs and the
domain lookup from an account whose address is not verified. 409
org-pending refuses POST /orgs while an organization the account
created is still awaiting payment (only where an admission extension
runs; a core registry activates an organization as it is created).
403 seat-allowance refuses an invitation or an approval past the
organization's seats, carrying used, invited, pending and max; a
registry with no billing extension has no seat cap and never answers it.
403 feature-not-included refuses an act the organization's subscription
does not include at all, which is not the same as its allowance being
reached; it also refuses POST /user/ssh-keys where the
person's own subscription does not include adding SSH keys (a key already
held keeps working, listed and removable; a registry with no billing
extension includes it). 409 already-a-member refuses an invitation, an approval or
POST /orgs for someone who is an active member of another
organization: a person belongs to one at a time, and leaves it (DELETE
/orgs/{org}/members/{own handle}) before joining or founding another.
409 staff-org-domain refuses the deployment's staff organization
(AGENTRY_STAFF_ORG) an address outside its domain (AGENTRY_STAFF_DOMAIN),
at every door: an invitation, its accept, an approval, SCIM provisioning and
founding it; its members are platform staff.
409 domain-claimed refuses POST /orgs from an address whose domain
already belongs to an organization — recorded at that organization's
creation, or verified by it over DNS — and a DNS verification of a domain
another organization holds; it carries domain and org, the holder,
whose administrators can add the person (GET /user/domain-org says the
same, and POST /user/domain-org/request-access asks them, once a day —
429 rate-limited afterwards). 409 org-decommissioning refuses every
change to an organization in its decommission hold, and to anything under
it, carrying org and decommissionAt; reads, the audit log and its
export go on until then, and a member may still leave. A suspended organization, and everything under
it, answers 404 to everyone until platform staff reinstate it.
Unscanned private agents have three
refusals of their own, all 409. pipeline-skip-public and
pipeline-skip-personal refuse pipelineSkip: true on PATCH
/agents/{ns}/{name} for a public agent and for one in a personal
namespace; only a private agent of an organization's is admitted without
a scan, and only where the tier includes it (403 feature-not-included
otherwise). agent-unscanned refuses making an agent public while it
holds versions admitted unscanned that are published, quarantined or
blocked, carrying their count as unscanned; yanking them opens the way, since
only a version that passed the gate is ever public. A version admitted
without a scan carries scanStatus: skipped on the version and on
resolve, its report verdict: unscanned, and has no attestation.
A publisher puts away their own agent in two ways,
both open to the namespace's administrators. POST
/agents/{ns}/{name}/retire makes it private and marks it retiredAt.
Every submission to it is then refused with 409 agent-retired, as is
PATCH to public, and it holds no place under the private-agent cap.
POST …/restore returns the visibility it had, under the same rules as
any agent becoming public (409 agent-unscanned). Back to private it is
under the cap again (422 private-agent-cap). It answers 409
agent-not-retired when there is nothing to restore.
DELETE /agents/{ns}/{name} removes an agent that never had a version.
For one with version history the request needs confirm=<name>
(422 confirm-name), and the delete leaves a tombstone:
- every published or quarantined version is yanked, with the reason "The
publisher deleted this QuickAgent.";
- every route by name, GET /resolve, git and hosted runs answer the 404
a missing name gets, to everyone;
- nothing is removed, and the name is held for good, so creating it again
answers 409 name-held.
The delete waits (409) while a version is in the pipeline or in review.
It is refused while the agent carries advisories, abuse reports, a
quarantine or a block, because an advisory reaches its readers only through
the public feed. GET /agents?namespace= takes q, state (active,
retired) and sort (name, newest, installs, submitted). It never
lists a deleted agent.
A built-in agent is one of the packages the Agentry CLI
embeds, published for reading: builtin on the agent and on every search
hit says so, and clients label it Built-in in place of its run modes.
PATCH /agents/{ns}/{name} takes builtin from platform staff only (403
for anyone else, the namespace's own administrators included), and marks
an agent only in a first-party namespace (409 builtin-first-party
otherwise). Nothing but the label reads it. It is not the runner's
attestation state builtin, which only the CLI's embedded copy of a
package runs under: an install of a built-in agent from the registry is an
ordinary release.
Idempotency-Key¶
Send Idempotency-Key: <opaque string, ≤255 chars> on a mutating POST and a
retry cannot create a second resource.
Honoured on POST /orgs, POST /orgs/{org}/webhooks,
POST /orgs/{org}/webhooks/{id}/ping, POST /platform/ruleset-releases,
and POST /agents/{ns}/{name}/versions (a version upload with a key is a
multipart body; the key is pinned to it like any other).
Semantics:
- Scope is
(user, endpoint, key). Your key can never collide with, or read, another caller's. It is also per-endpoint: the same key on two different endpoints is two different claims. - The key is pinned to a hash of the request body. A retry with the same
body replays the recorded status and response, with
Idempotency-Replayed: trueon the replay. A retry with a different body gets409. The key is a promise about one request, and returning some other request's answer would be worse than an error. - A retry while the first attempt is still running gets
409rather than racing it. Back off and retry. - Records live 24 hours, then the key is reusable.
- A claim whose request failed is released, so one bad attempt cannot lock a key out for a day.
Pagination¶
The audit, advisory and activity lists are cursor-paginated: pass limit,
and follow the cursor. /search is offset-paged (offset, limit, with
total in the response) so a page can be addressed directly.
GET /api/v1/orgs/acme/audit?limit=100
→ 200 OK
Link: </api/v1/orgs/acme/audit?limit=100&cursor=eyJ0Ijoi…>; rel="next"
- Follow the
Linkheader'srel="next"target verbatim. It is path-relative on purpose. The API runs behind proxies that rewrite the host, so resolve it against the request URI. - No
Linkheader means no more pages. That is the termination condition; do not infer it from a short page. - Cursors are opaque. Do not construct, decode, or persist them across ruleset or schema changes.
Limits are clamped, not rejected¶
limit=100000 and a negative offset are clamped to the endpoint's
valid range and the request succeeds; limit=0 (or any value under 1)
gets the endpoint's default, and an offset past 10000 is capped there —
paging that deep is a crawler's, not a reader's; the cursor-paged lists have
no such cap. You will not get a 400
for asking for too much; you will get the maximum. Read the length of what
came back rather than assuming you got what you asked for. Each endpoint's
default and ceiling are in the OpenAPI document.
Rate limiting¶
Every response carries the rate-limit trio:
RateLimit-Reset is seconds until the window resets, not a timestamp.
On a 429 the same headers are present plus Retry-After, in seconds.
Honour it; the buckets are per-user for authenticated traffic and per-IP for
anonymous traffic, and burst and sustained budgets are separate. The
model-input routes draw from a budget of their own per caller, a staff
member or one hosted job (model-input.md).
Self-hosters behind a load balancer must configure trusted proxies or all anonymous traffic shares one bucket. See self-hosting.md.
Event streams¶
GET /agents/{ns}/{name}/versions/{version}/events is text/event-stream:
it streams a submission's pipeline progress (scanning, verdict, publication)
as server-sent events until the version reaches a terminal state.
agentry publish follows it and falls back to polling
GET /agents/{ns}/{name}/versions/{version} when the stream is unavailable;
do the same in a client of your own.
Search facets¶
GET /search returns facet counts alongside results. One filter behaves
less obviously than it looks: capability= matches only capabilities the
agent actually asks for. A manifest declaring network: {egress: none}
still serializes with the network key present, so key-existence alone
would match every agent ever published; the filter additionally requires the
value not to be none, null or empty, and for object-valued capabilities
that at least one member is non-empty.
SCIM 2.0¶
The subset is Users and Groups→teams. Four behaviours to code against:
PUT /scim/v2/Users/{id}is a full-resource replace, not a merge. Omit the active flag and it reads as false, so a partial PUT deactivates the user. Deactivation is a PUT of the whole user with"active": false; that revokes the org's PATs and sessions for that user in-request, inside 60 seconds by design.userNameis immutable. It is the account's email, and email changes are an identity operation, not a directory attribute update. A PUT that changes it gets400with a message telling you to deactivate and re-provision.- Membership-sync failures return 5xx. If a group create or PATCH writes
the group but cannot write its members, the response is
500so your IdP retries. Silently discarding the error would leave the IdP believing a group had synced when the team was empty. The one tolerated case is "this user is not an org member", which is a legitimate no-op rather than a lost write, and is skipped rather than failed. - Only
userName eq "…"is supported as a filter.
Account erasure (GDPR)¶
POST /user/delete requires an explicit decision about published
content whenever the account has any:
publishedContentis required (yankortransfer), and a request without it gets400naming the count of published public versions. There is no default.publishedContent=transferadditionally requirestransferOrg=<org handle>, and the caller must be an admin of that org.- Published versions are yanked or transferred, never deleted. A database trigger forbids deleting version rows: digests stay resolvable so existing installs can still be verified. PII is purged and credentials revoked; what remains is a leak-free tombstone.
- Every pending desktop sign-in code is consumed in the erasure transaction, so a code minted by another session just before the erasure cannot be exchanged for a token afterwards.
- An organization's last admin cannot be erased (
409 last-admin): promote another member first, or decommission the organization. The erased account's memberships go with it, so no seat is held by an account that is gone. - A transfer respects the organization's private-agent cap (
422 private-agent-cap, nothing erased, nothing moved) and is refused into an organization in its decommission hold. - An extension's rows go in the same transaction: an
extension that keeps rows about the account deletes them inside the
erasure's transaction, and the
user.deleteevent's detail counts them underextensions. If it cannot, the account is not erased and the answer is500(a yank or a transfer already made stands; both run before the erasure). - The
204carriesX-Agentry-Directory-Erasure: where the sign-in account at the directory stands —deleted,not-found,not-configured(erasure is registry-only),pending(the directory was unreachable and a job retries it), orfailed(the retry could not be queued; the operator is alerted). A client tells the person when it ispendingorfailed; the registry's erasure has happened either way.
Clearing an account's data¶
POST /user/clear-data (session only) empties an account and keeps it.
It revokes every personal access token and SSH key, deletes
every remembered browser (the ones that skip the second factor), consumes
every pending desktop sign-in code, ends every session except the caller's,
removes every account install, clears the display name, resets the
notification settings to their defaults and clears the report theme. The handle, the email, the sign-in
account at the directory, organization memberships, QuickAgents with their
versions and repositories, stars, the subscription and credits stay. It
answers 204, and the caller stays signed in.
- A token cannot call it (
403). A cookie request needs the session's CSRF header, like every other cookie mutation. - It writes one audit event,
user.data_clear, whose detail counts what it removed. - Extensions hear
user.data_clearedwith the user id after the commit, so an extension clears what it holds for the account (the hosted-runs extension deletes the account's run history). - An extension's rows go in the same transaction, as on
an erasure: an extension that keeps credentials or history for the
account deletes them inside the clear's transaction, the detail counts
them under
extensions, and if it cannot, nothing is cleared and the answer is500.
Organization lifecycle¶
An organization is created by POST /orgs from an account with a verified
address (its first admin), one per person: an active member of another
organization leaves it first. It is keyed on its creator's email domain
unless the body says domain: none or the domain is one no organization
owns (a public mail provider, a reserved name); a domain belongs to one
organization, and a further domain is claimed over DNS
(/orgs/{org}/domain-claims). GET /orgs/{org} carries domain,
verifiedDomains, state and, in a hold, decommissionAt.
POST /orgs/{org}/decommission (admin, session only, the handle repeated
in confirm) starts the decommission hold: the organization is
read-only for its members — its agents readable, its audit log listable
and exportable, every change 409 org-decommissioning, pushes refused
over git — for the deployment's hold (orgs.decommissionHold, default
30 days), then a sweep yanks every release, deletes the archives and
repositories, ends every membership and invitation, retires the agents,
scrubs the organization's row and releases its handle and domain, and
records org.erase in the audit log. Version rows are immutable and are
not deleted. Platform staff may POST /orgs/{org}/suspend (with a
reason) — the organization and everything under it answer 404 to
everyone — and POST /orgs/{org}/reinstate, which ends a suspension or
cancels a hold that has not run; an erased organization does not come
back.
Retire, delete and fork¶
A QuickAgent's namespace administrators (its owner, or an organization's admins) can put it away or end it, and anyone signed in can copy a public one.
Retire. POST /agents/{ns}/{name}/retire makes the agent private and
labels it retiredAt, keeping the visibility it had. A retired agent takes
no submission (409 agent-retired) and holds no place under the
private-agent cap. POST …/restore puts the visibility back, under the
rules of any agent becoming public (409 agent-unscanned) or of a private
one (422 private-agent-cap).
Delete. DELETE /agents/{ns}/{name} removes an agent with no version
outright, with its repository, and its name is free again. An agent with
version history is deleted as a tombstone, and only with ?confirm= set
to its name (422 confirm-name otherwise):
- every published or quarantined version is yanked;
- every route by name, the git transport and hosted runs answer the 404 a
missing name gets, to everyone;
- nothing is removed, because version rows are kept forever and the
archives, receipts and audit trail stay with them;
- the name stays held in its namespace: creating it again, or forking into
it, answers 409 name-held.
A delete is refused (409) while a version is in the pipeline or in review,
and while the agent carries trust & safety records (advisories, abuse
reports, a quarantine or a block), which outlive the publisher's wish to
remove it.
Fork. POST /agents/{ns}/{name}/fork {namespace, name?} copies a
public agent into a namespace the caller may create a private agent in:
their own, or an organization they are an active member of. The quota and
the private-agent cap apply. The fork takes the origin's name unless name
gives another. A name the namespace already has answers 409 name-taken.
The origin must be public, neither quarantined nor blocked (409
fork-source), and have a published version (409 nothing-to-fork). A fork:
- is private for good. PATCH to public answers 409
fork-stays-private, and the database refuses it as well. That keeps
Publisher Earnings with the origin's publisher;
- records its origin (forkedFrom: the namespace, the name, the version
it was taken from, and available, whether the origin is still public,
not deleted and not blocked). The origin is permanent: there is no
detach, and its name stays on the fork even after the origin is made
private, retired or deleted;
- is answered 202 with forkState: copying (or failed, with
forkDetail, when the copy could not even be started). A worker then copies
from the origin's repository the forked release's commit and its history,
and nothing else: none of the origin's branches, none of its other tags,
and no object only they reach, since whoever holds the fork can read every
object in its repository. The fork holds what anyone could clone from the
origin, whatever their role on it. The objects are copied rather than
shared, so retiring or deleting the origin later changes nothing in the
fork. It writes one commit, Fork of ns/name@version, that renames the
manifest into the fork's namespace. The fork's default branch and a
v<version> tag point at it. That tag is submitted through the pipeline
like any submission, charged like one; a fork's first version is always
scanned. The fork's history runs back through the origin's release. A
release the origin published by upload has no commit, and one that was
never scanned, or whose origin has since been quarantined, has none anyone
may clone, so the fork's first commit is then built from the release archive's files as they are, with no history
before it. forkState is then ready, or failed
with forkDetail saying why. Until then git refuses the fork's
repository (409) to those who may read it. A copy that has not finished
after two hours is marked failed;
- is a fork of an original. A fork is private, so a fork cannot itself
be forked, and lineage is one level deep.
A public agent's read carries forkCount: a count, never which forks.
GET /agents?namespace= takes forks=only or forks=none. When the
platform quarantines or blocks an origin, its forks are left as they are:
each went through its own pipeline. The act's audit record carries how many
forks there are.
Personal access tokens and their scopes¶
A personal access token (POST /user/tokens, or the Tokens page under the
account) is a least-privilege credential: it can do only what its scopes
allow, and the list is flat — no scope implies another. A browser session
carries the account's full authority instead and is gated by CSRF. The
scopes:
| Scope | What a token with it may do |
|---|---|
agent:read |
read QuickAgents, versions, reports and search |
agent:publish |
create QuickAgents and submit versions |
agent:manage |
take the owner actions on a QuickAgent the account administers: make it public or private, retire, restore and delete it; nothing else of its settings or grants (org:admin covers these too) |
repo:read |
fetch from a QuickAgent's git repository |
repo:write |
push to a QuickAgent's git repository |
user:profile |
read and change the account's own settings, tokens, keys, sessions and account installs |
org:admin |
manage an organization's members, teams and settings |
review:act |
work the review queue and record decisions (platform staff or a security reviewer) |
audit:read |
read an organization's audit log |
run:read |
read a hosted run of a QuickAgent, its log and its artefacts |
run:write |
start, feed and cancel a hosted run of a QuickAgent |
knowledge:read |
list and read the account's knowledge packs, their scan reports and sealed trees, export them, and have a pack's data key unwrapped |
knowledge:write |
create, upload and delete the account's knowledge packs and have a data key wrapped |
secret:write |
set and delete the account's hosted-run secrets; never list or read one |
voice:read |
read the account's Writing Voice: every version's text and its AI Review report |
voice:write |
put a new version of the account's Writing Voice for review, restore the previous one, drop the pending one, set always-attach and delete the voice |
run:read and run:write are consumed only by a registry that mounts the
runs extension (GET /meta lists it); on a registry without it a token
carrying only these two is refused everywhere a token without the right
scope is. Hosted runs is what those routes do, route by
route. secret:write is likewise consumed only by a registry that mounts
the vault extension, which is to admit it for setting and deleting a
secret and keeps the list of them for a
signed-in session. The desktop app's sign-in grant
asks for a fixed set (agent:read, agent:publish, agent:manage,
repo:read, repo:write, user:profile, run:read, run:write,
knowledge:read, knowledge:write, secret:write, voice:read,
voice:write). agent:manage lets the desktop's Publisher
section make a QuickAgent public or private, retire, restore and delete
it; the caller must still be the namespace itself or an admin of its
organization, and no other setting of the agent, grant or organization
route is open to it. A token minted before the grant carried it does not
gain it: sign in again.
Signing in¶
The registry keeps no credentials of its own: there is no register, password
or second-factor endpoint. People sign in through the deployment's OpenID
provider (docs/self-hosting.md "Sign in with your OpenID provider") at
GET /auth/oidc/{provider}/start, and a first sign-in creates the account.
Sessions, PATs and every authorization decision are the registry's own.
POST /auth/verify-email confirms an address from the link
POST /user/email-verification mails.
Desktop sign-in: a signed-in browser records a grant with
POST /user/tokens/desktop-grant (the app's state nonce), and the app
exchanges the resulting one-time code for its PAT at POST
/auth/desktop/exchange from its own process — the code is single-use and
valid for 60 seconds, and the token never rides a URL.
Dependency alerts and scanned MCP servers¶
GET /user/dependency-alerts lists the open dependency alerts on published
versions the caller submitted — a version that declares an MCP server
version which the daily sweep found to carry a newly known high or critical
vulnerability. The web app's sign-in banner and dashboard read it. An alert
closes on its own when the version is yanked, quarantined or blocked, or
the advisory is withdrawn; publishing a fixed version does not close the old
version's alert. The same alerts appear publicly as dependencyAlerts on
GET /agents/{ns}/{name}.
GET /mcp-servers/scan?ecosystem=npm|pypi&package=…&version=… is the
registry's last vulnerability check of one exact package version (safe
means "no known advisories at lastCheckedAt", never "safe"); 404 means
no manifest has ever declared that version. The runner asks here before it
installs a declared server.
Account installs¶
An account install is a signed-in person's record, on the registry, of a
QuickAgent release they accepted for their account: the version, the
declared capabilities they consented to as that release declared them,
and the unsigned or unscanned consents the release needed. It is an
acceptance, not a laying-down — nothing is written on any machine — and
it is distinct from a star (public approval, a public count) and from the
per-machine record agentry install and the desktop app keep in
installed.yaml, which is never synced with it. An account install bumps
no download counter: installsTotal and installs30d on GET
/agents/{ns}/{name}/stats stay download rollups. One record per
QuickAgent per account.
PUT /user/installs/{ns}/{name} with {version, acceptCapabilities,
acceptUnsigned?, acceptUnscanned?} records a published release the caller
can read, or moves an existing record to it. A caller who cannot read the
QuickAgent gets the missing name's 404, never a 401 or 403 that
would confirm the name; an unpublished version is 409
version-not-published (carrying status), a blocked QuickAgent 410
blocked. The request must carry the acceptances the release needs, or
it is 409 install-consent-required with missing naming them:
capabilities on a first install and on an update whose declared
capabilities widened against the accepted snapshot (widened then lists
what did, one line each, the way the CLI and the desktop phrase it — an
update whose contract is unchanged or narrower asks nothing);
unsigned whenever the release carries no attestation; unscanned
whenever it was admitted without a scan. A PUT on an existing record is
the update, to any published version, an older one included; the
response is the record composed as the list composes it. DELETE
/user/installs/{ns}/{name} removes the caller's own row and reads no
QuickAgent, so it answers 204 whether or not a record existed and
whether or not the name resolves — nothing about a QuickAgent is learned
there, and a record whose QuickAgent the caller can no longer read is
removed by the ref the list shows for it. GET
/user/installs lists the caller's records, newest change first: ref,
installedVersion with its capabilities and the consents recorded
(acceptedUnsigned, acceptedUnscanned), latestVersion (the newest
published), the installed version's verdict (scanned with
findingCounts, or unscanned; absent while a scan is pending) and
signed, the QuickAgent's quarantineState, installedAt and
updatedAt. A record whose QuickAgent the caller can no longer read —
made private to them, its organization suspended — is the bare ref
with readable: false and nothing else, and that ref is the name the
caller installed it under, never where the QuickAgent has gone since, so
the list tells a former reader nothing they did not already know. A
blocked QuickAgent stays readable and the row says so in
quarantineState. An organization's erasure removes the records of its
QuickAgents, as it does their stars.
The three routes are the caller's own account data, so a token needs
user:profile — and agent:read to record a private QuickAgent, since
the PUT reads it first. The actions are audited as agent.install and
agent.uninstall beside agent.star, so GET /user/activity shows
them; the QuickAgent's own activity feed does not, at any role — whose
account holds a QuickAgent is that person's business, where a star is
public approval.
Enterprise SSO¶
Beyond the browser destinations (/sso/{org}, the SAML acs and metadata
endpoints, the OIDC start/callback pair): POST
/orgs/{org}/sso/connections/{kind}/test exercises a stored connection —
metadata, certificate, issuer — without enforcing it, and
/orgs/{org}/sso/saml/slo accepts the identity provider's single-logout
request over both the redirect and POST bindings, ending the registry
session for that user. SSH keys registered at /user/ssh-keys are a git
credential only (they never authenticate to the API).
Downloads and version states¶
410 Gone: the agent or version is blocked by trust & safety.409 Conflict: the version is quarantined pending review; thedetailsays so.- Yanked versions still resolve by exact pin, with a warning, and are excluded from range resolution.
Webhooks¶
Deliveries carry an HMAC signature and travel through an SSRF guard that re-resolves per attempt, requires every resolved address to be public unicast, dials the verified IP to defeat DNS rebinding, and follows no redirects.
POST /orgs/{org}/webhooks/{id}/ping records its delivery with event type
ping, which is deliberately absent from the subscribable event catalogue;
nobody subscribes to a ping. It otherwise travels the ordinary path, same
envelope and guard and retry ladder, so the test answers "does my endpoint
work" using the code that will actually call it.