migrate: 39 vendors, each starting with a call that runs

Fifteen guides became thirty-nine, and every one now opens with the shortest
path from nothing to a working request: mint a key, the first useful call, the
call that makes it useful, then the two or three capabilities to learn first.

Added: netlify render fly heroku · clerk workos okta · supabase firebase
planetscale upstash algolia pinecone · datadog sentry posthog grafana · openai
replicate modal langsmith · stripe temporal vault.

Written one CTO per vendor with an adversarial verifier on each, which is what
caught the defects: fly cited POST on a route the spec serves GET-only;
supabase counted /v1/tasks operations that are all 307 redirects and mapped
match_documents() onto /v1/ai/vectors, which is store CRUD rather than the
query (POST /v1/ai/rag/query); datadog's own table contradicted the example
below it.

Then checked independently rather than trusting the reports: all 238
api.hanzo.ai calls across the guides resolve against openapi.yaml, matching
templated paths so .../projects/acme/apps/web/deploy is recognised as
.../{project}/apps/{app}/deploy. Zero bad paths. Every table well-formed.

The onboarding step mints at POST /v1/account/keys. /v1/keys does not exist —
it is what hanzoai/ci's bin/site prints in its own error message, so anyone
following that hits nothing.

index.mdx and meta.json are written by hand, not by the fleet: 39 agents
contending over two shared files is the one reliable way to corrupt them. Nav,
files and index cross-check at 39 each with no orphan in any direction.
This commit is contained in:
2026-08-22 12:13:31 -07:00
parent 575d3d06e0
commit 4047bbbc36
41 changed files with 6273 additions and 86 deletions
@@ -10,6 +10,41 @@ credential, and `/v1/channels` (7) is the conversation plane, inbound and
outbound. Neither is a mailbox. If inbound email is the reason you use
AgentMail, read [what does not carry](#what-does-not-carry) before anything else.
## Start here
Mint a key, send one email through the org's own credential, then read the
conversation feed — there is no mailbox to create first.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. send. `sync=true` is required and rides on the URL, not the body
curl -sS -X POST "https://api.hanzo.ai/v1/notify/send/email?sync=true" \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"to":["[email protected]"],"subject":"Your report is ready","body":"It is attached."}'
# 3. read what people have sent the org's bots — oldest first
curl -sS "https://api.hanzo.ai/v1/channels/inbox?limit=100" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
The send answers `{message_id, status}` while you wait; a provider failure is a
200 whose `status` is `failed`, so read `status` rather than the HTTP code. The
feed proves the conversation plane is live — it carries chat, not mail, which is
why replies to that email land nowhere on this surface.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/notify` | Sends email and SMS through the org's own provider credential, read from KMS, and fails closed when none is configured | 4 |
| `/v1/channels` | The conversation plane across Discord, Slack, Teams and Telegram — inbound feed, outbound send, allowlist and pairing | 7 |
## Nouns
| AgentMail | Hanzo |
+229
View File
@@ -0,0 +1,229 @@
---
title: Algolia
description: Algolia hosts search indexes and ranks documents against a query. Here that is /v1/index (17 operations), which speaks the Meilisearch dialect, plus /v1/search (1) when a lexical index and a vector leg should answer as one ranking.
---
Algolia stores your records, builds an index over them, and ranks them against a
query with typo tolerance and facets. `/v1/index` (17 operations) is that half,
and it speaks the Meilisearch dialect — point an existing Meilisearch client at
`https://api.hanzo.ai/v1/index` and it works unchanged, which is a door Algolia's
own SDK does not open.
The structural difference is that indexing is not a queue. Algolia's `taskID`
names real background work and `waitTask` polls until the record is published.
Here the write is applied before the response is written, so the `enqueuedAt`,
`startedAt` and `finishedAt` on `GET /v1/index/tasks/{uid}` are the same instant,
`isIndexing` on `GET /v1/index/stats` is always false, and a document is
searchable the moment its write returns.
## Start here
Two calls after the key: write a document, then search it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. write a document — the index is created by this write, so no create call first
curl -sS -X POST https://api.hanzo.ai/v1/index/indexes/products/documents \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '[{"id":"1","name":"Wool coat","brand":"Acme"}]'
# 3. search it — nothing to poll between this and the write above
curl -sS -X POST https://api.hanzo.ai/v1/index/indexes/products/search \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"q":"wool cot","limit":20}'
```
Step 2 created `products` on its way through and answered `"status":"enqueued"`,
which is dialect compatibility rather than pending work; step 3 returns the coat
on the first try, matching `cot` because typos are forgiven. That is the whole
difference from Algolia in three calls — one host, one credential, no
application id, and no `waitTask` between writing and reading.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/index` | Stores documents and ranks them against a query with typos forgiven, in the Meilisearch dialect. The query is `POST /v1/index/indexes/{uid}/search` | 17 |
| `/v1/search` | One query across that index, the org's knowledge and its repositories, lexical and vector legs fused by reciprocal rank | 1 |
| `/v1/event` | Records what was searched, clicked and converted — the Insights half — read back at `GET /v1/event/insights/events` | 12 |
## Nouns
### The index and its documents
| Algolia | Hanzo |
|---|---|
| Application ID | Nothing. The org is minted from the validated key's owner claim |
| Index | Index — `POST /v1/index/indexes`, body `uid`. Idempotent |
| `objectID` | `primaryKey` — nominated on create, or established by the first write |
| `saveObjects`, replacing a record whole | `POST /v1/index/indexes/{uid}/documents` |
| `updateObjects` | `PUT /v1/index/indexes/{uid}/documents` — the same upsert, both spellings served |
| `getObject` | `GET /v1/index/indexes/{uid}/documents/{id}` |
| `deleteObject` | `DELETE /v1/index/indexes/{uid}/documents/{id}` |
| `deleteObjects`, a list of ids | `POST /v1/index/indexes/{uid}/documents/delete-batch` |
| `browse` | `GET /v1/index/indexes/{uid}/documents` — insertion order, `limit` and `offset` |
| `listIndices` | `GET /v1/index/indexes` |
| `deleteIndex` | `DELETE /v1/index/indexes/{uid}` — deleting one that is absent succeeds |
| `getTask`, then `waitTask` | `GET /v1/index/tasks/{uid}` — always `succeeded`, so the poll ends on call one |
| Record count per index | `GET /v1/index/stats` |
| Status and monitoring API | `GET /v1/index/health`, which 503s on an unreadable store, and `GET /v1/index/version` |
### Querying
| Algolia | Hanzo |
|---|---|
| `POST /1/indexes/{name}/query` | `POST /v1/index/indexes/{uid}/search` |
| `query` | `q` — typos forgiven, a prefix matches, empty matches everything |
| `filters`, spelled `brand:Acme` | `filter`, spelled `brand = Acme`; an array of them is combined with AND |
| `attributesForFaceting` | `filterableAttributes` — `PATCH /v1/index/indexes/{uid}/settings` |
| `hitsPerPage` and `page` | `limit` (default 20, ceiling 1000) and `offset` |
| `nbHits` | `estimatedTotalHits` — every hit is materialised, so for the page it is exact |
| `processingTimeMS` | `processingTimeMs` |
| NeuralSearch, keyword and vector together | `POST /v1/search` — legs fused by reciprocal rank, each hit naming which found it |
| A multi-index query | `POST /v1/search` — one query across the index, the org's knowledge and its repositories |
| Re-ranking a candidate set | `POST /v1/rerank`, and `POST /v1/embeddings` for a pipeline you own |
### Around the index
| Algolia | Hanzo |
|---|---|
| Admin key and search-only key | `POST /v1/iam/keys` — the secret half is shown once, the publishable half ships in the browser |
| Secured API key carrying a tenant filter | The org is the key; one end user inside it is a `filter` over `filterableAttributes` |
| Insights events — click, conversion, view | `POST /v1/event` (12), read back at `GET /v1/event/insights/events` |
| A/B test between two index configurations | `/v1/experiment` (7) — `POST /v1/experiment` starts it, `GET /v1/experiment/{id}/assign` buckets, `POST /v1/experiment/{id}/decide` promotes |
| Latency and error dashboards | `/v1/o11y` (381) |
| Crawler | `POST /v1/crawl` for one URL to markdown; `/v1/knowledge/connectors` for a source that keeps syncing |
| Data connectors and scheduled ingestion | `/v1/sync` (6) |
| Related-item lookups over a graph you hold | `POST /v1/graph/neighbors` — bounded walk from a seed set |
| Search over source code | `GET /v1/code/search` — lexical, symbolic and semantic tiers, fused |
| Docsearch over public projects | `GET /v1/catalog` — the one surface here that returns facet counts |
| Semantic retrieval over org knowledge | `POST /v1/knowledge/search` |
| Dashboard | console.hanzo.ai |
## The call
Algolia. Two hosts, two keys, an application id in both the hostname and a
header, and a poll between writing and reading:
```bash
curl -sS -X PUT \
"https://$ALGOLIA_APP_ID.algolia.net/1/indexes/products/settings" \
-H "X-Algolia-Application-Id: $ALGOLIA_APP_ID" \
-H "X-Algolia-API-Key: $ALGOLIA_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"attributesForFaceting":["brand"]}'
curl -sS -X POST \
"https://$ALGOLIA_APP_ID.algolia.net/1/indexes/products/batch" \
-H "X-Algolia-Application-Id: $ALGOLIA_APP_ID" \
-H "X-Algolia-API-Key: $ALGOLIA_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"requests":[{"action":"addObject","body":{"objectID":"1","name":"Wool coat","brand":"Acme"}}]}'
# -> {"taskID":844761822}
curl -sS "https://$ALGOLIA_APP_ID.algolia.net/1/indexes/products/task/844761822" \
-H "X-Algolia-Application-Id: $ALGOLIA_APP_ID" \
-H "X-Algolia-API-Key: $ALGOLIA_ADMIN_KEY"
# -> {"status":"notPublished"} repeat until "published"
curl -sS -X POST \
"https://$ALGOLIA_APP_ID-dsn.algolia.net/1/indexes/products/query" \
-H "X-Algolia-Application-Id: $ALGOLIA_APP_ID" \
-H "X-Algolia-API-Key: $ALGOLIA_SEARCH_KEY" \
-H 'Content-Type: application/json' \
-d '{"params":"query=wool%20cot&filters=brand:Acme"}'
```
Hanzo. One host, one key, no application id, and the last call is the search:
```bash
curl -sS -X PATCH \
https://api.hanzo.ai/v1/index/indexes/products/settings \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"filterableAttributes":["brand"]}'
curl -sS -X POST \
https://api.hanzo.ai/v1/index/indexes/products/documents \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '[{"id":"1","name":"Wool coat","brand":"Acme"}]'
curl -sS -X POST \
https://api.hanzo.ai/v1/index/indexes/products/search \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"q":"wool cot","filter":"brand = Acme","limit":20}'
```
Three calls against one host with one credential, and no `POST
/v1/index/indexes` among them: an index is a row rather than a table, so both the
settings write and the first document write create it when it is missing, and
creating one that exists returns the same receipt and changes nothing. That is
what lets a client write before it configures without a check first.
The `202` and its `enqueued` status are dialect compatibility, not a promise of
later work — a client calling `waitForTask` resolves on its first poll because
the write is already durable. The tenant is the key's owner claim and never a
header or a body field, so two orgs both holding an index named `products` cannot
see each other, and there is no second identifier to keep in step with the first
or to leak into a browser bundle.
When the keyword index alone is not enough, `POST /v1/search` runs the same
`products` index as its lexical leg beside a vector leg and fuses them by
reciprocal rank. Its `backends` array reports each leg as `ok`, `degraded`,
`disabled` or `skipped`, so a thin result set says which leg was missing rather
than reading as "nothing matched":
```bash
curl -sS -X POST https://api.hanzo.ai/v1/search \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"query":"warm winter jacket","index":"products","mode":"hybrid","limit":10}'
```
## What does not carry
**No synonyms, and no ranking to configure.** Algolia gives you
`synonyms/batch`, `searchableAttributes`, `ranking`, `customRanking`,
`typoTolerance` and the dictionaries. The whole settings surface here is
`filterableAttributes`. Typos are forgiven and that is not a dial, so a part
number that must match exactly is a `filter` on a filterable attribute rather
than a search term. What replaces synonym expansion is the semantic leg of `POST
/v1/search` — a vector match finds "notebook" for "laptop" with no list to
maintain — but a curated pair you depend on today has nowhere to go.
**No `copyIndex` or `moveIndex`, so no atomic reindex swap.** Algolia's standard
rebuild is: write into a temporary index, then move it over the live one in one
step. Nothing here renames or copies an index. Because writes apply before their
response there is no window where a background pass has half-built the index, but
there is one where old and new documents coexist. Either version the uid and
switch which uid your app reads, or `DELETE /v1/index/indexes/{uid}` and rewrite.
**No partial record update.** `partialUpdateObjects` merges the attributes you
name into the stored record and leaves the rest. Both write spellings here
replace the document whole on its primary key. Read it, merge, write it back.
**No facet counts.** `filterableAttributes` makes an attribute filterable;
nothing counts how many documents sit behind each value, so a facet rail showing
numbers is yours to aggregate. `GET /v1/catalog` does return facet counts, but
over the fleet's own corpus of projects and sites, not over your index.
**No highlighting or snippets.** Algolia returns `_highlightResult` and
`_snippetResult` with the matched span marked up. Documents come back exactly as
they were stored, with no annotated copy beside them, so marking the match is the
client's job.
**No Rules, Personalization or Query Suggestions.** Merchandising rules that pin
a result for a given query, per-user affinity profiles, and a suggestions index
built from past searches are three Algolia products with no counterpart here.
The measuring half exists — `POST /v1/event` records what was searched and
clicked, `/v1/experiment` (7) runs the A/B test and promotes the winner — so you
keep the evidence and lose the levers.
+37 -1
View File
@@ -13,6 +13,42 @@ an upgrade here.** Auth0 sells Organizations as a tier on top of tenants. In
Hanzo IAM the org is the tenant — `owner` is a column on every user, and every
other Hanzo capability scopes on that same value.
## Start here
Mint a key, confirm it resolves to somebody, then write your first Auth0 user across.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. who does that key speak for?
curl -sS https://api.hanzo.ai/v1/iam/whoami \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. move one Auth0 user across — idempotent, so the export loop can re-run
curl -sS -X POST https://api.hanzo.ai/v1/iam/admin/users/upsert \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"owner":"acme","name":"ada","email":"[email protected]","displayName":"Ada Lovelace"}'
```
Step 2 is the cheapest proof the key works: it names the caller, or says plainly
that nobody is signed in rather than failing. Step 3 lands a user in the org
named by `owner` — there is no org header because the address carries it — and
omitting `password` keeps whatever that user already had, so running the export
loop twice rotates nobody's credentials.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/iam` | Users, orgs, roles, applications, and the OIDC endpoints in front of them | 159 |
| `/v1/risk` | Scores one sign-in against your org's own model, and holds the regime it decides under | 11 |
| `/v1/audit` | Your org's own trail, narrowed by actor, action or resource | 1 |
## Nouns
| Auth0 | Hanzo |
@@ -28,7 +64,7 @@ other Hanzo capability scopes on that same value.
| `/oauth/token` | `POST /v1/iam/oauth/token` |
| `/userinfo` | `GET /v1/iam/oauth/userinfo` |
| `/.well-known/openid-configuration` | The same path, under `/v1/iam` |
| SCIM (Enterprise) | `/v1/iam/scim/v2/Users` — five operations, not a tier |
| SCIM (Enterprise) | `/v1/iam/scim/v2/Users` — six operations, not a tier |
`{owner}/{name}` is the shape of every identity address: the org, then the
thing. It is why a user and an org can share a name without colliding, and why
+41
View File
@@ -13,6 +13,47 @@ That last point is the design: **the cap table is derived.** Ownership percentag
are not a column anybody writes. They fall out of the issuances, so they cannot
drift from them.
## Start here
A Carta export is a roster and a set of issuances; the roster loads in one call.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. load the roster — the body is one stakeholder or an array of them
curl -sS -X POST https://api.hanzo.ai/v1/captable/stakeholders \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '[
{"name":"A Founder","email":"[email protected]",
"stakeholderType":"INDIVIDUAL","currentRelationship":"FOUNDER"},
{"name":"Seed Fund I","email":"[email protected]",
"stakeholderType":"INSTITUTION","currentRelationship":"INVESTOR"}
]'
# 3. read the table — computed, not stored
curl -sS https://api.hanzo.ai/v1/captable/summary \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Step 3 returns both holders under `byStakeholder` at zero: nothing has been
issued yet, and a percentage is arithmetic over issuances rather than a field you
loaded. Email is the identity within a company, so re-running step 2 skips the
rows already there and reports how many it actually inserted — an import that
died halfway resumes by running it again.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/captable` | Stakeholders, share classes, issuances, options, SAFEs, notes and rounds, with `summary` computed over them | 31 |
| `/v1/esign` | Sends the documents an issuance rests on for signature, each with its own audit trail | 13 |
| `/v1/dataroom` | Diligence rooms: documents behind tracked links, with per-link view analytics | 26 |
## Nouns
| Carta | Hanzo |
+237
View File
@@ -0,0 +1,237 @@
---
title: Clerk
description: Clerk is the drop-in auth layer for a React app — components, users, orgs, sessions, JWT. Here that is /v1/iam — 159 operations, OIDC end to end, and the org is the owner half of every address rather than a claim your frontend switches into.
---
Clerk authenticates people, groups them into organizations, and gives you React
components that render the whole flow. `/v1/iam` (159 operations) does the
identity half at the addresses OIDC names them, and serves the login screen's
contents rather than shipping you a component to hold them.
The structural difference to know before you port anything: **Clerk's
organization is a claim the browser switches into and your backend reads back
off the request.** `setActive({ organization })` changes it client-side, the
session token carries `org_id`, and every org-scoped Backend API call takes that
id in the path. Here the org is the `owner` half of the address, and on most
listing routes it is not a parameter at all — it comes from the credential you
authenticated with, so there is no id to pass, keep in step, or get wrong.
## Start here
Mint a key, find the application people sign in to, then read the login screen it
should draw.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. list the applications in your org — each carries its own OAuth client id
curl -sS "https://hanzo.id/v1/iam/applications?owner=acme" \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. read what a login screen for one of them should render
curl -sS "https://hanzo.id/v1/iam/get-app-login?clientId=$CLIENT_ID&responseType=code" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Step 3 is the `<SignIn />` replacement: one application's branding plus every
sign-in method it has switched on, client secrets masked, served before anyone
has signed in. The only identifiers in play are `acme` and an OAuth client id —
no `org_2xxx` anywhere, and switching a provider on changes what step 3 returns
without a frontend release.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/iam` | Users, orgs, applications, sessions, OIDC, MFA, SCIM — Clerk's Backend API, plus the contents its components used to hold | 159 |
| `/v1/webhook` | Subscribe by subject pattern (`commerce.order.>`), delivered with retries; per-endpoint secret rotation, delivery log and test send | 8 |
| `/v1/account` | The key you minted in step 1, the avatar, and the orgs one identity may act in | 10 |
## Nouns
### The tenant and the people in it
| Clerk | Hanzo |
|---|---|
| Instance (development, production) | Nothing to port. The org is the tenant |
| Application | Application — `/v1/iam/applications`, addressed `{owner}/{name}` |
| User, `user_2xxx` | `/v1/iam/users`, addressed `{owner}/{name}` |
| Organization, `org_2xxx` | `/v1/iam/organizations`, addressed `{owner}/{name}` |
| Organization membership and its role | `POST /v1/iam/memberships`, `POST /v1/iam/delete-membership` |
| Organization invitation | `POST /v1/iam/invitations` — org taken from your key |
| `org:admin`, custom permissions | `/v1/iam/roles`, `/v1/iam/permissions` |
| Impersonation, actor tokens | `POST /v1/iam/assume`, then `POST /v1/iam/release` |
| Audit logs (Enterprise) | `GET /v1/iam/audit-logs` — and `POST` to write your own events into the same trail |
### Signing in
| Clerk | Hanzo |
|---|---|
| `<SignIn />`, `<SignUp />` | `GET /v1/iam/get-app-login` — branding and every enabled method, from the server |
| Which factors are switched on | `GET /v1/iam/auth/methods` |
| `signIn.create`, `signUp.create` | `POST /v1/iam/login`, `POST /v1/iam/signup`, then `POST /v1/iam/signin` |
| Hosted account portal | `GET /v1/iam/oauth/authorize`, hosted at hanzo.id |
| Session | `/v1/iam/sessions`, one per `{owner}/{name}/{application}` |
| Session token (JWT) | `POST /v1/iam/oauth/token` |
| Networkless verification | `GET /v1/iam/.well-known/jwks` |
| Is this token still good | `POST /v1/iam/oauth/introspect` — verification and revocation in one answer |
| `signOut()` | `POST /v1/iam/oauth/revoke`, or `/v1/iam/oauth/logout` for the browser |
| Email code, SMS code | `POST /v1/iam/send-verification-code` |
| Passkeys | `GET /v1/iam/webauthn/signin/begin`, `/v1/iam/webauthn-credentials` |
| TOTP, backup codes | `POST /v1/iam/mfa/setup/initiate`, then `/v1/iam/mfa/setup/enable` |
| Web3 wallet sign-in | `GET /v1/iam/web3/nonce`, `POST /v1/iam/web3/verify` |
| Sign-in on a TV or a CLI | `POST /v1/iam/oauth/device` |
### Around the edges
| Clerk | Hanzo |
|---|---|
| `<UserProfile />` | `GET` and `PUT /v1/iam/account`, `POST /v1/account/avatar` |
| `<OrganizationSwitcher />` | `GET /v1/iam/memberships?user=acme/dana` — the orgs one identity may act in |
| External accounts (social connections) | `/v1/iam/linked-accounts`, `POST /v1/iam/link`, `POST /v1/iam/unlink` |
| SAML connections, enterprise SSO | `/v1/iam/providers` — configured once, switched on per application |
| SCIM directory sync (Enterprise) | `/v1/iam/scim/v2/Users` — not a tier |
| Machines and M2M API keys | `POST /v1/iam/keys`, `/v1/iam/service-accounts` |
| Publishable key, `pk_live_…` | `GET /v1/iam/keys/org` — resolves to an org and never to a person |
| Webhooks (`user.created`, via Svix) | `/v1/webhook` (8 operations) |
| Bulk import for migration | `POST /v1/iam/admin/users/upsert` — idempotent, one person per call |
| Signing key rotation | `/v1/iam/certs` — stage the next `kid` before it signs |
## The call
Clerk, inviting somebody into an organization:
```bash
curl -sS -X POST \
"https://api.clerk.com/v1/organizations/org_2b9KpQ/invitations" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{
"email_address": "[email protected]",
"inviter_user_id": "user_2c7QwZ",
"role": "org:member"
}'
```
Hanzo:
```bash
curl -sS -X POST https://hanzo.id/v1/iam/invitations \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "dana",
"email": "[email protected]",
"application": "acme-web",
"signupGroup": "member",
"quota": 1
}'
```
Two opaque identifiers left the request. The organization is not in the path
because the listing and the write both read it off the validated credential —
`owner` is an optional query parameter on `/v1/iam/invitations`, `/v1/iam/roles`,
`/v1/iam/certs` and `/v1/iam/audit-logs`, and leaving it out is the normal case.
The inviter is gone for the same reason: the credential already names who is
asking, so a second field naming them again could only ever disagree with it.
Support access is the sharper case. Clerk mints an actor token and then walks a
browser through a ticket to start a session:
```bash
curl -sS -X POST https://api.clerk.com/v1/actor_tokens \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"user_id":"user_2c7QwZ","actor":{"sub":"user_2aAdRn"}}'
# then send a browser to the returned ticket URL to open the session
```
Hanzo:
```bash
curl -sS -X POST https://hanzo.id/v1/iam/assume \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"org":"acme"}'
curl -sS -X POST https://hanzo.id/v1/iam/release \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"org":"acme"}'
```
One call, no browser, and what comes back is the operator's own token re-scoped
to the tenant — it still names the operator and records the org it was scoped
to, so every write made with it is attributed to the person who made it rather
than to the customer. `release` hands back the credential they held before, so
stepping out is a call rather than waiting for something to expire. Only a
platform operator may step in, and the attempt is recorded whether or not it
succeeds.
Token checks work the same way in both places until revocation. Clerk verifies
the session JWT locally against a JWKS and bounds a revoked session by its
60-second lifetime — you wait for it. The same offline verification is here at
`GET /v1/iam/.well-known/jwks`, and when you need the answer now:
```bash
curl -sS -X POST https://hanzo.id/v1/iam/oauth/introspect \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "token=$ACCESS_TOKEN"
```
A token is active only if it verifies **and** has not been revoked, so a revoked
token reads dead on the next call rather than at the end of its window. That is
also why refresh is rotating: a refresh returns a new refresh token and retires
the one you sent, and presenting a retired one revokes the whole chain on the
assumption it was copied.
## What does not carry
**The rendered components do not port.** `<SignIn />`, `<UserButton />` and
`clerkMiddleware()` are Clerk's product — nothing here draws a screen for you.
The React around them does port: `@hanzo/iam/react` publishes `IamProvider` in
place of `<ClerkProvider>`, plus `useIam` and `useOrganizations`, so it is an
OIDC client with React bindings rather than a component library. What replaces
the screens is the other half of the same problem: `GET /v1/iam/get-app-login`
returns one application's branding and every sign-in method it has switched on,
with client secrets masked, so a screen you draw yourself grows a new provider
button the moment somebody enables one. The hosted alternative is hanzo.id.
**No JWT templates.** Clerk mints a differently-shaped token per integration —
Supabase, Firebase, Hasura — from a template you edit in the dashboard. Tokens
here carry the standard OIDC claims plus `owner`, signed by a certificate you
register at `/v1/iam/certs`. A third party that needs a bespoke claim shape gets
it from a service of yours that has already verified the real token.
**The three metadata buckets collapse to one.** `publicMetadata`,
`privateMetadata` and `unsafeMetadata` differ by who may read and who may write
them. The user record here has `properties`, `tag` and `externalId`, and they
are read through the same admin-scoped user routes as everything else — there is
no bag the browser reads directly and no bag the user writes. Settings a person
owns live at `/v1/iam/preferences`, and their privacy answers at
`/v1/iam/consent`; both are writable only by that person. Application state about
a user belongs in your store, keyed on `{owner}/{name}`.
**`user_2xxx` and `org_2xxx` do not survive the move.** The address here is
`{owner}/{name}` — a username inside an org — and there is no route that accepts
a Clerk id. Carry the old id in `externalId` on the user record and rewrite your
foreign keys once, rather than maintaining a lookup table forever.
**Password digests carry only if they are bcrypt or argon2id.** The user record
holds `passwordHash`, `passwordSalt` and `passwordType`, and the scheme field is
what lets a legacy argon2id row still verify and be re-hashed to bcrypt on the
next sign-in. Clerk's other importable hashers have no verifier here. Export the
users, and for any other scheme move those people onto a provider or a password
reset.
**Bot protection is not a switch on the sign-up form.** Clerk fronts sign-up
with Smart CAPTCHA. `/v1/iam` models no CAPTCHA provider and
`POST /v1/iam/send-verification-code` accepts `captchaType` and ignores it. Rate
limiting and origin rules are `/v1/gateway/config`, and the abuse score is
`/v1/risk/score` — separate capabilities with their own vocabulary, so this is a
thing to configure rather than a checkbox you tick back on.
+48 -11
View File
@@ -1,13 +1,50 @@
---
title: Composio
description: Composio gives an agent authenticated tools across third-party apps. Here that is /v1/integrations for the org's credential, /v1/connectors for a user's own, and /v1/tools for the actions they reach.
description: Composio gives an agent authenticated tools across third-party apps. Here that is /v1/integrations for the org's credential, /v1/integrations/connectors for a user's own, and /v1/tools for the actions they reach.
---
Composio holds OAuth credentials for third-party apps and hands an agent the
actions those credentials reach. Two capabilities answer it: `/v1/integrations`
(40 operations) holds the credential an **org** connects, `/v1/connectors` (8)
holds the one a **user** connects, and every action either one reaches shows up
in `/v1/tools`.
(40 operations) holds the credential an **org** connects,
`/v1/integrations/connectors` (8) holds the one a **user** connects, and every
action either one reaches shows up in `/v1/tools`.
## Start here
A fresh key already reaches the platform's own tools, so you can dispatch one
before connecting any provider.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. what this key can already call, before any provider is connected.
# Each row carries a name, an inputSchema and its source.
curl -sS "https://api.hanzo.ai/v1/tools?activated=true" \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. dispatch one, by the exact name step 2 printed
curl -sS -X POST https://api.hanzo.ai/v1/tools/call \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"NAME_FROM_STEP_2","arguments":{}}'
```
Three calls and no OAuth. The key carried the org, the project and the user, so
step 2 answered with the set this caller can actually dispatch and step 3 would
have refused anything outside it with a 403 — which is why Composio's `userId`
argument has no counterpart here.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/integrations` | The credential an **org** connects to a provider, and the callbacks that acquire it | 40 |
| `/v1/integrations/connectors` | The same credential held per **user** — device flow, refresh, token read | 8 |
| `/v1/tools` | One flat set of callable actions across every source, plus dispatch and activation | 19 |
## Nouns
@@ -16,7 +53,7 @@ in `/v1/tools`.
| Toolkit — an app such as GitHub or Slack | Provider — `GET /v1/integrations/{provider}` returns one with this org's connection status |
| Auth config — the OAuth app you register per toolkit | The deployment's own provider configuration. A connect against a provider it lacks answers 503 |
| Connected account, shared by a team | Connection — `POST /v1/integrations/{provider}/connect` |
| Connected account, one per end user | Connector — `GET /v1/connectors`, rows keyed by (org, user) |
| Connected account, one per end user | Connector — `GET /v1/integrations/connectors`, rows keyed by (org, user) |
| `userId` passed on every call | The validated principal in the key |
| Tool | Tool — `GET /v1/tools`, one flat set across every source, each with an `inputSchema` |
| Executing a tool | `POST /v1/tools/call` |
@@ -25,9 +62,9 @@ in `/v1/tools`.
Two scopes, not one. Composio scopes a connected account by the `userId` you
pass; here the scope is structural. An `/v1/integrations` connection belongs to
the org and every member reaches it. A `/v1/connectors` row belongs to one
person and is invisible to everyone else, including their own org's admins. Pick
by who owns the account, not by a field.
the org and every member reaches it. A `/v1/integrations/connectors` row belongs
to one person and is invisible to everyone else, including their own org's
admins. Pick by who owns the account, not by a field.
## The call
@@ -80,11 +117,11 @@ operations, the actions of its connected providers, its authored skills, its
agents and functions, and the tools of every external MCP server it
registered. Check `GET /v1/integrations` for the
providers this deployment carries before you plan a cutover. A provider that is
not there is added by registering its MCP server with `POST /v1/mcp/servers`, not
by waiting for a catalog entry.
not there is added by registering its MCP server with
`POST /v1/tools/mcp/servers`, not by waiting for a catalog entry.
**Triggers have no equivalent.** Composio subscribes you to events inside the
third-party app — a new Gmail message, a closed GitHub issue. `/v1/webhooks`
third-party app — a new Gmail message, a closed GitHub issue. `/v1/webhook`
delivers Hanzo's own events, not the provider's. Inbound app events you keep
yourself: register the webhook with the provider and take the delivery.
+245
View File
@@ -0,0 +1,245 @@
---
title: Datadog
description: Datadog collects metrics, traces and logs from one agent and gives each signal its own search API. Here that is /v1/o11y — 381 operations behind one query door — with /v1/event (12) for the browser and /v1/metrics (11) for an in-process store.
---
Datadog collects metrics, traces and logs from one agent, then gives each signal
its own search endpoint, its own query dialect and its own retention.
**`/v1/o11y`** (381 operations) covers that ground — ingestion keys, dashboards,
alert rules, downtimes, notification channels, host and Kubernetes views, error
tracking, log pipelines, retention — with **`/v1/event`** (12) for the browser tag
and session replay, and **`/v1/metrics`** (11) for a store small enough to run
inside the process it measures.
The one difference that shapes the migration is the read. Datadog searches
metrics at `/api/v1/query`, logs at `/api/v2/logs/events/search` and spans at
`/api/v2/spans/events/search`: three requests, three dialects, and three time
windows you keep in step by hand. `POST /v1/o11y/query_range` takes an array of
queries over any signal, evaluates formulas across them where the data is, and
answers one result for one window.
## Start here
Mint an API key to read with, mint an ingestion key for your exporter to write
to, then read a series back.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. mint the ingestion key your OpenTelemetry exporter ships to
curl -sS -X POST https://api.hanzo.ai/v1/o11y/gateway/ingestion_keys \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"otel-collector","tags":["prod"]}'
# 3. read a series back — instant PromQL, the /api/v1/query swap
curl -sS -G https://api.hanzo.ai/v1/o11y/query \
-H "Authorization: Bearer $HANZO_API_KEY" \
--data-urlencode 'query=sum by (service_name) (rate(http_server_duration_count[5m]))'
```
Two keys now exist and they do opposite jobs: the bearer reads, the ingestion key
writes, and every signal the exporter ships goes through the one key. Datadog
wants `DD-API-KEY` and `DD-APPLICATION-KEY` together on every read plus a
region-specific host; step 3 is one bearer against one host, and the org is a
claim inside the key rather than a header you set.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/o11y` | Metrics, logs and traces in one store behind one query door, plus dashboards, alert rules, downtimes, notification channels, host and Kubernetes views | 381 |
| `/v1/event` | Browser tag, session replay and the Sentry wire — pageviews, uncaught errors, custom events | 12 |
| `/v1/metrics` | A write-and-query store small enough to run inside the process it measures | 11 |
## Nouns
### Reading telemetry
| Datadog | Hanzo |
|---|---|
| `POST /api/v2/query/scalar` for one value | `GET /v1/o11y/query` — instant PromQL |
| `GET /api/v1/query` over a range | `GET /v1/o11y/query_range` — PromQL with `start`, `end`, `step` |
| `POST /api/v2/logs/events/search` | `POST /v1/o11y/query_range`, a builder query with `signal: logs` |
| `POST /api/v2/spans/events/search` | `POST /v1/o11y/query_range` with `signal: traces` |
| Three searches you correlate by hand | One `compositeQuery.queries` array, one window |
| Formula inside a dashboard widget | A `builder_formula` over sibling queries, evaluated server-side |
| Metrics Summary `GET /api/v1/metrics` | `GET /v1/o11y/metrics` — name, type, unit, temporality, monotonicity |
| Cardinality inspection | `POST /v1/o11y/metrics/inspect`, `POST /v1/o11y/metrics/treemap` |
| Metrics without Limits tag config | `POST /v1/o11y/metric_reduction_rules`, dry-run at `/preview` |
| Facets behind a search bar | `GET /v1/o11y/fields/keys`, `GET /v1/o11y/filter_suggestions` |
| CSV export from a notebook | `POST /v1/o11y/export_raw_data` — chunked, with a completion trailer |
| Log index retention | `POST /v1/o11y/settings/ttl` — default TTL plus per-label rules |
### Dashboards, monitors, paging
| Datadog | Hanzo |
|---|---|
| `POST /api/v1/dashboard` | `POST /v1/o11y/dashboards` |
| Clone, read-only mode, public URL | `POST .../{id}/clone`, `PUT .../{id}/lock`, `POST .../{id}/public` |
| Saved view in an explorer | `POST /v1/o11y/explorer/views`, `POST /v1/o11y/dashboard_views` |
| `POST /api/v1/monitor` | `POST /v1/o11y/rules` |
| `POST /api/v1/monitor/validate` — syntax only | `POST /v1/o11y/rules/test` — answers how many series would alert |
| Monitor status history | `GET /v1/o11y/rules/{id}/history/timeline`, with related logs and traces per entry |
| Triggered monitors | `GET /v1/o11y/alerts` |
| `POST /api/v2/downtime` | `POST /v1/o11y/downtime_schedules` |
| A separate integration API per destination | One `POST /v1/o11y/channels`, nineteen receiver types from PagerDuty to Jira |
| Send a test notification | `POST /v1/o11y/channels/test` |
| Apdex settings per service | `POST /v1/o11y/settings/apdex` |
### APM, hosts and ingestion
| Datadog | Hanzo |
|---|---|
| APM service list | `POST /v1/o11y/services` — p99, average, call rate, error rate |
| Service Map | `POST /v1/o11y/dependency_graph` — every parent-child edge with its own latency |
| Resources and top endpoints | `POST /v1/o11y/service/top_operations` |
| Trace search | `GET /v1/o11y/traces`, then `GET /v1/o11y/traces/{traceId}` |
| Flame graph and waterfall | `POST /v1/o11y/traces/{traceId}/flamegraph`, `POST .../waterfall` |
| Funnel over spans | `POST /v1/o11y/trace-funnels/new` |
| Outgoing HTTP by domain | `POST /v1/o11y/third-party-apis/overview/list` |
| `GET /api/v1/hosts` | `POST /v1/o11y/hosts/list`, `POST /v1/o11y/infra_monitoring/hosts` |
| Kubernetes explorer | `POST /v1/o11y/infra_monitoring/pods` and ten sibling views |
| AWS, GCP and Azure integrations | `POST /v1/o11y/cloud_integrations/{cloud_provider}/accounts` |
| `DD_API_KEY` on the agent | `POST /v1/o11y/gateway/ingestion_keys`, per-signal caps at `POST .../{keyId}/limits` |
| Log pipelines and processors | `POST /v1/o11y/logs/pipelines`, live tail at `GET /v1/o11y/logs/livetail` |
| Usage and ingestion metering | `GET /v1/o11y/usage` |
| Roles, users and service accounts | `POST /v1/o11y/roles`, `POST /v1/o11y/service_accounts` |
### RUM, errors and LLM observability
| Datadog | Hanzo |
|---|---|
| RUM browser SDK snippet | `GET /v1/event/tag.js` — autocaptures pageviews and uncaught errors |
| `POST /api/v2/rum/events/search` for pageviews | `GET /v1/event/top`, `GET /v1/event/timeseries` |
| Session Replay | `POST /v1/event/replay` — rrweb batches |
| RUM error stream | `GET /v1/event/errors` |
| Error Tracking issues | `GET /v1/o11y/errortracking/issues`, `GET /v1/o11y/sentinel/issues` |
| Resolve, ignore, reopen, assign | `POST /v1/o11y/errortracking/issues/{id}` |
| A vendor SDK per language | `POST /v1/event/{project}/envelope` takes the Sentry wire; repoint an existing DSN |
| Custom event `POST /api/v1/events` | `POST /v1/event` — answers `{accepted, dropped}` totalling what was sent |
| LLM Observability traces and spans | `GET /v1/o11y/llm/traces`, `GET /v1/o11y/llm/observations` |
| LLM sessions and end users | `GET /v1/o11y/llm/sessions`, `GET /v1/o11y/llm/users` |
| Evaluations and human feedback | `POST /v1/o11y/llm/scores`, `POST /v1/o11y/llm/annotation`, plus `/v1/eval` (16) |
| Per-model cost mapping | `PUT /v1/o11y/llm_pricing_rules` |
## The call
The question is "what is checkout's error rate, and what did the logs say" over
the last hour. Datadog:
```bash
# Metrics, on a host that encodes your region.
curl -sS -G "https://api.datadoghq.com/api/v1/query" \
-H "DD-API-KEY: $DD_API_KEY" -H "DD-APPLICATION-KEY: $DD_APP_KEY" \
--data-urlencode "from=1755820800" --data-urlencode "to=1755824400" \
--data-urlencode "query=sum:trace.http.request.errors{service:checkout}.as_rate()"
# Spans: a second dialect, a second endpoint, a window respelled.
curl -sS -X POST "https://api.datadoghq.com/api/v2/spans/events/search" \
-H "DD-API-KEY: $DD_API_KEY" -H "DD-APPLICATION-KEY: $DD_APP_KEY" \
-H 'Content-Type: application/json' \
-d '{"data":{"attributes":{"filter":{"query":"service:checkout status:error","from":"now-1h","to":"now"}}}}'
# Logs: a third.
curl -sS -X POST "https://api.datadoghq.com/api/v2/logs/events/search" \
-H "DD-API-KEY: $DD_API_KEY" -H "DD-APPLICATION-KEY: $DD_APP_KEY" \
-H 'Content-Type: application/json' \
-d '{"filter":{"query":"service:checkout status:error","from":"now-1h","to":"now"}}'
```
Hanzo, one request:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/o11y/query_range \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @- <<'JSON'
{
"schemaVersion": "v1",
"start": 1755820800000,
"end": 1755824400000,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{ "type": "builder_query", "spec": {
"name": "A", "signal": "traces", "stepInterval": "60s",
"aggregations": [{ "expression": "countIf(has_error = true)" }],
"filter": { "expression": "service.name = 'checkout'" },
"groupBy": [{ "fieldContext": "resource", "name": "service.name" }] } },
{ "type": "builder_query", "spec": {
"name": "B", "signal": "traces", "stepInterval": "60s",
"aggregations": [{ "expression": "count()" }],
"filter": { "expression": "service.name = 'checkout'" },
"groupBy": [{ "fieldContext": "resource", "name": "service.name" }] } },
{ "type": "builder_formula", "spec": {
"name": "error_rate", "expression": "A / B * 100" } },
{ "type": "builder_query", "spec": {
"name": "C", "signal": "logs", "stepInterval": "60s",
"aggregations": [{ "alias": "log_errors", "expression": "count()" }],
"filter": { "expression": "severity_text = 'ERROR' AND service.name = 'checkout'" },
"groupBy": [{ "fieldContext": "resource", "name": "service.name" }] } }
]
}
}
JSON
```
One request because the signals share one field catalog: a builder query names a
`signal` and filters with an expression over OpenTelemetry attribute names, so
`service.name` means the same thing to a span, a log record and a metric label.
`error_rate` is divided where the data is, over one `start` and one `end`, rather
than by a client reconciling two responses that resolved their own windows. There
is no site host and no second application key: the org is the claim in the bearer,
and `GET /v1/o11y/traces` is explicit that the tenant is the validated principal
and never an input — there is no field to point at another tenant. Send the same
body to `POST /v1/o11y/query_range/preview` and it renders the statements it would
run without running them, which is how a generated query gets checked before it
costs anything.
## What does not carry
**Query syntax is not portable, and it is the migration.** Datadog's monitor query
(`avg(last_5m):sum:trace.http.request.errors{env:prod}.as_rate() > 0.05`), its log
search syntax and its span search syntax are three vendor dialects, and nothing
here parses any of them. Filters are expressions over OpenTelemetry attribute
names, metrics are PromQL or a builder aggregation, and raw reads are SQL against
the datastore. Every monitor, dashboard widget and saved search is rewritten by
hand. Size that before anything else.
**No synthetic tests.** Datadog runs scheduled browser and API checks from managed
locations. Nothing here dials your endpoint from outside — `GET /v1/o11y/availability`
and `GET /v1/o11y/status` report Hanzo's own fleet, not yours. The port is a
schedule on `/v1/tasks` (5) that calls your endpoint and writes the result through
`POST /v1/metrics/write`, then an alert rule on that series.
**No stored SLO.** `/api/v1/slo` holds a target, a window and an error budget as
state you read back, and `/api/v1/slo/{id}/history` reads the burn.
`POST /v1/o11y/settings/apdex` stores a per-service threshold and the status codes
excluded from it; everything past that is a query. You compute the budget from
`POST /v1/o11y/query_range` each time rather than reading it off an objective.
**Incidents live in the tool that pages you.** Datadog's `/api/v2/incidents` carries
state, severity, a timeline and a postmortem. Here a rule fires into a channel and
the incident record belongs to incident.io, PagerDuty, Opsgenie or Jira — all four
are receiver types on `POST /v1/o11y/channels`. `GET /v1/o11y/summary` reports
Hanzo's own ongoing incidents from fleet health probes, which is a different fact
about a different system.
**The flame graph is spans, not stacks.** `POST /v1/o11y/traces/{traceId}/flamegraph`
lays out one trace's spans bucketed by depth. Datadog's Continuous Profiler samples
CPU and heap inside the running process and has no counterpart here.
`PUT /v1/o11y/zeus/profiles` records a deployment's onboarding profile despite the
name, and is not it.
**What you did not label, you cannot group by.** Datadog's agent decorates
everything it ships with host tags and unified service tags. Grouping here is over
the resource and span attributes the exporter actually set — a `groupBy` of
`fieldContext: resource` and `name: service.name` needs the resource to carry it.
`GET /v1/o11y/logs/fields` and `GET /v1/o11y/traces/fields` show which fields are
materialized columns and let you promote one; an unpromoted field is still
queryable and still a scan.
+49 -8
View File
@@ -12,6 +12,44 @@ not have an account here. `GET /v1/esign/o/{org}/sign/{token}` is the whole
session — the token names the org, the document and the recipient, and the
signing routes carry no other authentication.
## Start here
Three calls: mint a key, upload the PDF, add the person who signs it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. upload the PDF — 201, and the document is in DRAFT
ID=$(curl -sS -X POST https://api.hanzo.ai/v1/esign/documents \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"Mutual NDA\",\"pdfBase64\":\"$(base64 < nda.pdf | tr -d '\n')\"}" \
| jq -r .id)
# 3. add the signer — the response carries their token
curl -sS -X POST "https://api.hanzo.ai/v1/esign/documents/$ID/recipients" \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"Dana Lee","role":"SIGNER"}'
```
You have a DRAFT document and one recipient, and step 3 answered a `token` —
minted before anything is sent, and the entire credential the signing door
accepts. That is the shape difference from DocuSign in one call: the signer has
no account, only the link built from that token.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/esign` | Upload a PDF, add recipients and fields, send, read the audit trail | 13 |
| `/v1/dataroom` | Holds the documents and the links that share them — where a DocuSign template's reusable shape lives | 26 |
| `/v1/compliance` | Subjects, verifications and accreditation decisions — the identity check you run before handing over a link | 17 |
## Nouns
| DocuSign | Hanzo |
@@ -21,7 +59,7 @@ signing routes carry no other authentication.
| Document (inside an envelope) | The document itself. One per envelope |
| Recipient / Signer | `POST /v1/esign/documents/{id}/recipients` |
| Tab (SignHere, DateSigned, Text) | Field — `POST /v1/esign/documents/{id}/fields` |
| Routing order | `order` on the recipient |
| Routing order | `signingOrder` on the recipient |
| Send envelope | `POST /v1/esign/documents/{id}/send` |
| Signing ceremony URL | `GET /v1/esign/o/{org}/sign/{token}` |
| Certificate of completion | `GET /v1/esign/documents/{id}/audit` |
@@ -44,22 +82,25 @@ curl -sS -X POST \
Hanzo — the same thing, as four calls that each do one thing:
```bash
# 1. the document (the body is the file itself)
# 1. the document — title and the base64 PDF, both required
ID=$(curl -sS -X POST https://api.hanzo.ai/v1/esign/documents \
-H "Authorization: Bearer $HANZO_API_KEY" \
--data-binary @contract.pdf | jq -r .id)
-H 'Content-Type: application/json' \
-d "{\"title\":\"Services Agreement\",\"pdfBase64\":\"$(base64 < contract.pdf | tr -d '\n')\"}" \
| jq -r .id)
# 2. who signs it
curl -sS -X POST "https://api.hanzo.ai/v1/esign/documents/$ID/recipients" \
# 2. who signs it — the response carries the recipient id and their token
RCPT=$(curl -sS -X POST "https://api.hanzo.ai/v1/esign/documents/$ID/recipients" \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"A Signer","order":1}'
-d '{"email":"[email protected]","name":"A Signer","signingOrder":1}' \
| jq -r .id)
# 3. where they sign
# 3. where they sign — a field is placed against that recipient
curl -sS -X POST "https://api.hanzo.ai/v1/esign/documents/$ID/fields" \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"kind":"signature","page":1,"x":120,"y":640}'
-d "{\"recipientId\":\"$RCPT\",\"type\":\"SIGNATURE\",\"page\":1,\"positionX\":120,\"positionY\":640}"
# 4. send
curl -sS -X POST "https://api.hanzo.ai/v1/esign/documents/$ID/send" \
@@ -7,6 +7,43 @@ Doppler stores secrets for a team and serves them to the things that need them.
`/v1/kms` (5 operations) is the custody half of that: store a secret sealed, list
what you hold, and exchange a machine credential for a token.
## Start here
Mint a key, write one secret under an explicit `path` and `env`, then list what
the org holds.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. store one secret, sealed — env is required and has no default
curl -sS -X POST https://api.hanzo.ai/v1/kms/secrets \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"path":"backend","env":"prd","name":"SIGNING_SECRET","value":"s3cr3t"}'
# 3. list what those coordinates now hold
curl -sS "https://api.hanzo.ai/v1/kms/secrets?path=backend&env=prd" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Step 2 sealed the value under a fresh per-secret data key and answered with the
name and environment it wrote, not the value. Step 3 returns descriptors — name,
path, environment, sealing scheme — and proves the write landed under the
coordinates a reader resolves. Writing needs org admin; a member, and any machine
credential exchanged at `POST /v1/kms/auth/login`, gets step 3 only.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/kms` | Stores a secret sealed, lists what the org holds without values, exchanges a machine credential for a bearer | 5 |
| `/v1/account` | Mints, lists and revokes the keys a service presents — what a Doppler service token becomes | 10 |
| `/v1/iam` | Org membership and admin authority — who reads a secret and who may replace one | 159 |
## Nouns
| Doppler | Hanzo |
+44 -4
View File
@@ -7,6 +7,46 @@ E2B leases an isolated computer, runs code in it, and gives you its filesystem.
`/v1/sandbox` (19 operations) is the same thing: lease, run, read, write, end.
`POST /v1/exec` is the short path — one snippet, one answer, no lease to manage.
## Start here
Mint a key, run one whole program, and take a lease only when the work has to
outlive its own call.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. run a program in a throwaway sandbox — no lease to manage, nothing to clean up
curl -sS -X POST https://api.hanzo.ai/v1/exec \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"lang":"py","code":"print(1 + 1)"}'
# 3. hold the machine instead — dev attaches the project's disk, exec keeps nothing
curl -sS -X POST https://api.hanzo.ai/v1/sandbox/lease \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"class":"dev","project":"demo","ttlSec":900}'
```
Step 2 answers `stdout`, `stderr`, the files the program wrote, and the
`session_id` of the sandbox it leased for you — send that id with the next run
and it sees the same filesystem, which is the whole of `runCode()`. Step 3 is
that computer held open under a name you keep: `id` is the argument `run`,
`read`, `write` and `end` all take, and `workdir` is what a relative path in
them resolves against.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/sandbox` | Leases a real computer with its own filesystem, runs commands in it, reads and writes its files, and ends it. Terminal and screen are WebSockets behind a ticket. | 19 |
| `/v1/exec` | Runs one whole program in a throwaway sandbox across thirteen languages, returns what it printed and what it wrote, and keeps the filesystem when you reuse `session_id`. | 4 |
| `/v1/account/keys` | Mints, lists and revokes the sk- and pk- keys every call above authenticates with. Minting a class again rotates it in one step. | 3 |
## Nouns
| E2B | Hanzo |
@@ -18,7 +58,7 @@ E2B leases an isolated computer, runs code in it, and gives you its filesystem.
| `sandbox.files.read(path)` | `POST /v1/sandbox/read`, `data` base64, or `entries` for a directory |
| `sandbox.kill()` | `POST /v1/sandbox/end` |
| `timeoutMs` / `timeout` | `ttlSec` on the lease |
| Template | `class` — `exec`, `dev`, `desktop` or `android` — with `image` to override its default |
| Template | `class` — `exec`, `dev`, `desktop` or `android` — a closed set, not an image you build |
| Persistent template data | `project` on the lease, which names the volume the work persists on |
| `@e2b/code-interpreter` `runCode()` | `POST /v1/exec` with `lang` and `code` |
| Interactive terminal (PTY) | `POST /v1/sandbox/{id}/terminal/ticket`, then the socket at `/v1/sandbox/{id}/terminal/ws` |
@@ -74,7 +114,7 @@ If all you want is one snippet, skip the lease entirely:
curl -X POST https://api.hanzo.ai/v1/exec \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"lang":"python","code":"print(1 + 1)"}'
-d '{"lang":"py","code":"print(1 + 1)"}'
```
A non-zero exit is a 200 carrying a failed program. "The tests failed" and "the
@@ -93,8 +133,8 @@ the sandbox is gone; `purge` on the end call decides whether the project volume
goes with it.
**No template build.** E2B builds a template from your Dockerfile and caches it.
Here `image` on the lease overrides the class default, so you bring a container
image you built elsewhere. There is no template registry to push to.
Here `class` picks from a closed set of four, and the image behind the one you
pick is the fleet's. There is no template registry to push to.
**No environment variables on the lease.** The lease body is `class`, `id`,
`project`, `runtime` and `ttlSec` — nothing else. Set variables in the command
+40 -1
View File
@@ -8,12 +8,51 @@ and music beside it. `/v1/audio` (5 operations) covers the same four verbs:
`speech` and `transcriptions` in the OpenAI shape, and `voice`, `music` and
`foley` for the Zen family's own.
## Start here
Two calls after the key: turn a sentence into an MP3, then turn the MP3 back
into the sentence.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. synthesize — the bytes stream back, so write them to a file
curl -sS -X POST https://api.hanzo.ai/v1/audio/speech \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3-tts-voicedesign","input":"The first move is what sets everything in motion.","response_format":"mp3"}' \
-o out.mp3
# 3. read it back — multipart, file and model
curl -sS -X POST https://api.hanzo.ai/v1/audio/transcriptions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-F [email protected] \
-F model=whisper-1
```
Step 3 returns the sentence from step 2. The round trip proves the key carries,
that `model` resolved to a provider in both directions, and that both halves are
OpenAI-shaped — so an OpenAI client pointed at `https://api.hanzo.ai/v1` reaches
them with no other change.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/audio` | Speech from text, text from speech, and the Zen family's own voice, music and foley | 5 |
| `/v1/models` | The ids this gateway resolves right now, per modality | 4 |
| `/v1/account` | Mints, lists and revokes keys — `/v1/account/keys` issued the `sk-` in step 1 | 10 |
## Nouns
| ElevenLabs | Hanzo |
|---|---|
| `client.text_to_speech.convert(...)` | `POST /v1/audio/speech` — `model`, `input`, optional `voice` |
| `POST /v1/text-to-speech/{voice_id}` | The same route; the voice is a body field, not a path segment |
| `POST .../text-to-speech/{voice_id}` | The same route; the voice is a body field, not a path segment |
| `model_id`, e.g. `eleven_v3` | `model`, e.g. `qwen3-tts-voicedesign` |
| `output_format`, e.g. `mp3_44100_128` | `response_format` — `mp3` by default |
| `client.speech_to_text.convert(...)` | `POST /v1/audio/transcriptions` — multipart `file` and `model`, with `language` and `response_format` |
+268
View File
@@ -0,0 +1,268 @@
---
title: Firebase
description: Firebase is six products behind one project id — sign-in is /v1/iam (159 operations), the data is /v1/kv (6) and /v1/provisioning (28), files are /v1/s3 (6), code is /v1/functions (11), hosting is /v1/projects (27), and the project id stops being something you carry.
---
Firebase bundles authentication, a document store, object storage, functions,
hosting and push behind one project and one web API key. Every one but push has
an answer here, and they are separate capabilities rather than facets of a
project: `/v1/iam` (159 operations) signs people in, `/v1/kv` (6) and
`/v1/provisioning` (28) hold data, `/v1/s3` (6) holds files, `/v1/functions`
(11) runs code, `/v1/projects` (27) and `/v1/platform` (37) serve it. The
structural difference that costs the most work to port: Firebase names the
project in every path and every config field, and here the tenant is derived
from the validated key, so no request field can name one.
## Start here
Three calls put a document in storage, and none of them names a project.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. the collection — a bucket keeping 5 revisions of every key
curl -sS -X POST https://api.hanzo.ai/v1/kv/rooms \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"history":5}'
# 3. the document
curl -sS -X PUT https://api.hanzo.ai/v1/kv/rooms/general \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"value":"{\"title\":\"General\",\"members\":12}"}'
```
The write answers with the revision it created, and `GET /v1/kv/rooms/general/history`
reads it back beside every earlier one. Neither call carries a project id or a
database name — `rooms` is your org's `rooms` because the key said so, which is
most of what `firebaseConfig`'s eight fields were carrying.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/iam` | Signs people in, and holds the accounts, roles, providers, MFA and service accounts behind it | 159 |
| `/v1/kv` | Buckets of versioned keys — the documents, with per-key revision history | 6 |
| `/v1/projects` | Serves the built app — deployments, releases, custom domains | 27 |
## Nouns
### Sign-in
Firebase Auth is the Identity Toolkit REST API plus an Admin SDK. It is
`/v1/iam` here, and it is ordinary OpenID Connect.
| Firebase | Hanzo |
|---|---|
| Project, and the project id in every path | Your org, taken from the validated key |
| `accounts:signUp` | `POST /v1/iam/signup` |
| `accounts:signInWithPassword` | `POST /v1/iam/login` — password compared against a stored hash, second factor asked for here |
| `accounts:signInWithIdp` — Google, Apple, GitHub | `GET /v1/iam/oauth/authorize`, providers at `/v1/iam/providers` |
| `securetoken.googleapis.com/v1/token` | `POST /v1/iam/oauth/token` — the same address as the code exchange |
| `accounts:sendOobCode` — verify, reset | `POST /v1/iam/send-verification-code`, then `PUT /v1/iam/password` |
| Admin SDK `createCustomToken` | `POST /v1/iam/issue-user-token` |
| Admin SDK `verifyIdToken` | `GET /v1/iam/.well-known/jwks`, or `POST /v1/iam/oauth/introspect` |
| Custom claims | Roles and permissions — `/v1/iam/roles`, `/v1/iam/permissions` |
| Multi-factor enrolment | `POST /v1/iam/mfa/setup/initiate`, then `/v1/iam/mfa/setup/enable` |
| Passkeys | `GET /v1/iam/webauthn/signin/begin`, `POST /v1/iam/webauthn/signin/finish` |
| Identity Platform tenants | `/v1/iam/organizations` — the same primitive keys and billing scope to |
| SAML and OIDC providers, SCIM | `/v1/iam/providers`, `/v1/iam/scim/v2/Users` |
| The service account JSON file | `POST /v1/iam/service-accounts`, keys at `/v1/iam/service-accounts/{name}/keys` |
### The data
| Firebase | Hanzo |
|---|---|
| Firestore document | `PUT /v1/kv/{bucket}/{key}` — one bucket per collection, every write a revision |
| Realtime Database node | The same bucket; `GET /v1/kv/{bucket}/{key}/history` reads the retained revisions |
| `onSnapshot`, EventSource on a `.json` path | Watchers on the bus port `:4222`; fan out with `POST /v1/pubsub/publish` |
| `documents:runQuery`, composite indexes | `POST /v1/index/indexes/{uid}/search` over `/v1/index` (17) |
| A collection you want a real query language over | `POST /v1/provisioning/docdb` — your org's own instance, MongoDB wire protocol |
| Data Connect (Postgres and GraphQL) | `POST /v1/provisioning/sql`, and `POST /v1/graph/graphql` |
| Vector search extension | `POST /v1/provisioning/vector`, `POST /v1/embeddings` |
| `firestore.rules` | `POST /v1/authz/check` — one subject, object and action, one allow or deny |
| Point-in-time recovery | Per-key revision history — `GET /v1/kv/{bucket}/{key}/history` |
| The do-everything app backend | Hanzo Base, the embedded engine that answers behind `/v1/base/health` |
### Files, functions, hosting
| Firebase | Hanzo |
|---|---|
| Cloud Storage bucket, `gs://` | `POST /v1/s3/buckets` — S3, so your existing client speaks it unchanged |
| `POST /v0/b/{bucket}/o?name=` upload | `POST /v1/s3/buckets/{bucket}/objects` — mints a presigned PUT you upload to directly |
| Listing a folder | `GET /v1/s3/buckets/{bucket}/objects?prefix=` |
| `storage.rules` | `POST /v1/authz/check`, plus the presigned URL's own bucket, key and expiry |
| A dedicated bucket you provision | `POST /v1/provisioning/s3` |
| HTTPS function | `POST /v1/functions`, called at `POST /v1/functions/{name}/invoke` |
| Function logs and invocations | `GET /v1/functions/{name}/logs`, `GET /v1/functions/{name}/invocations` |
| `onSchedule` | `/v1/tasks` — a durable engine, not a property of a deployment |
| `onMessagePublished` | `POST /v1/pubsub/publish`, with `POST /v1/auto/hooks/{source}/{event}` to run work off it |
| Hosting, a static build | `/v1/projects` (27) — upload a directory, activate a release |
| Hosting rewrites into a server | `/v1/platform` (37) — an app that is built and run |
| Preview channel | `POST /v1/platform/projects/{project}/apps/{app}/preview` |
| Hosting rollback | `POST /v1/projects/{slug}/releases/{release}/activate` |
| Custom domain | `POST /v1/projects/{slug}/domains`, then `POST /v1/projects/{slug}/domains/{host}/verify` |
Publishing a Hosting version is four calls against three resources — create the
version, populate files, finalize, then release. The static plane here is two:
`POST /v1/projects/{slug}/deployments` hands back an upload grant, and
`POST /v1/projects/{slug}/deployments/{id}/complete` closes it.
### Messaging and the rest of the console
| Firebase | Hanzo |
|---|---|
| Cloud Messaging to a device token | Not here — see below |
| Transactional mail and SMS | `POST /v1/notify/send` — the channel is a body field, provider credential read from KMS |
| Reaching a team room | `POST /v1/channels/{channel}/send` — Slack, Discord, Teams, Telegram |
| Remote Config | `PUT /v1/flags/defs/{key}`, decided at `POST /v1/flags/decide` |
| A/B Testing | `/v1/experiment` (7) — `/v1/experiment/{id}/assign`, `/decide`, `/analyze` |
| Google Analytics for Firebase | `/v1/event` (12), browser tag at `GET /v1/event/tag.js` |
| Crashlytics | `GET /v1/o11y/errortracking/issues`, and `GET /v1/event/errors` |
| Performance Monitoring | `/v1/o11y` (381) — dashboards, traces, alerts |
| App Check | `/v1/gateway` (3) — the CORS allowlist, per-IP flood cap and rate ceiling in force |
| Dynamic Links | Not here — `/v1/link` is AI provider-account linking, not short links |
| Extensions | `/v1/integrations` (48) — connectors and the providers behind them |
| Genkit, Vertex AI in Firebase | `/v1/ai` (272) — Zen models on our own inference, `POST /v1/chat/completions` |
| Test Lab, somewhere to run code | `/v1/sandbox` (19), `POST /v1/exec` |
| Firebase Console | console.hanzo.ai |
## The call
Firebase signs a person in at one host and refreshes them at another, with the
web API key in the query string of both:
```bash
curl -sS -X POST \
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=$FIREBASE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"s3cr3t","returnSecureToken":true}'
curl -sS -X POST "https://securetoken.googleapis.com/v1/token?key=$FIREBASE_API_KEY" \
-d "grant_type=refresh_token&refresh_token=$REFRESH"
```
Hanzo, at one host, in the OpenID Connect grammar your library already has:
```bash
curl -sS https://api.hanzo.ai/v1/iam/.well-known/openid-configuration
curl -sS -X POST https://api.hanzo.ai/v1/iam/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=authorization_code \
-d "code=$CODE" -d "client_id=$CLIENT_ID" -d "code_verifier=$VERIFIER"
curl -sS -X POST https://api.hanzo.ai/v1/iam/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=refresh_token \
-d "refresh_token=$REFRESH" -d "client_id=$CLIENT_ID"
```
The `firebaseConfig` object holds eight values a client has to keep in step —
API key, auth domain, project id, storage bucket, sender id, app id, database
URL, measurement id. Here there is a client id and one host, and every other
address is read out of the discovery document rather than pasted in. A refresh
returns a new refresh token and retires the one you sent; present a retired one
and the whole chain is revoked, so a stolen refresh token is worth one use.
Tokens verify against `/v1/iam/.well-known/jwks`, which means no vendor admin
SDK sits in your verification path.
Writing one document. Firestore carries the project, the database and a type
per field:
```bash
curl -sS -X PATCH \
"https://firestore.googleapis.com/v1/projects/$PROJECT/databases/(default)/documents/rooms/general" \
-H "Authorization: Bearer $ID_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"fields":{"title":{"stringValue":"General"},"members":{"integerValue":"12"}}}'
```
```bash
curl -sS -X PUT https://api.hanzo.ai/v1/kv/rooms/general \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"value":"{\"title\":\"General\",\"members\":12}"}'
```
The project and the database are gone from the request because a bucket's
physical name is derived from the validated org — there is no field in the body
that could point at another tenant's `rooms`, so cross-tenant addressing is not
refused, it is unwritable. The write answers with the revision it created and
the earlier ones stay readable at `GET /v1/kv/rooms/general/history`.
Config that changes behaviour. Remote Config is published once, then reaches a
device when that device's own fetch interval elapses and something calls
`activate()`:
```bash
curl -sS -X PUT \
"https://firebaseremoteconfig.googleapis.com/v1/projects/$PROJECT/remoteConfig" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "If-Match: $ETAG" \
-H 'Content-Type: application/json; UTF-8' \
--data-binary @remoteconfig.json
```
```bash
curl -sS -X PUT https://api.hanzo.ai/v1/flags/defs/new-editor \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"key":"new-editor","active":true,"filters":{"groups":[{"rollout_percentage":25}]}}'
curl -sS -X POST https://api.hanzo.ai/v1/flags/decide \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"distinct_id":"u1","person_properties":{"plan":"pro"}}'
```
`POST /v1/flags/decide` evaluates in-process over the caller's own definitions —
no network hop and no shared cache between the write and the read — so the
second call returns the definition the first call wrote. Which version a client
is running is not a question you have to ask.
## What does not carry
**No push notification.** FCM's job — a token per device, topics, APNs and web
push fan-out — has no answer here. `/v1/notify/send` delivers email and SMS
through your org's own provider credential, synchronously, and
`/v1/channels/{channel}/send` reaches a Slack, Discord, Teams or Telegram room.
A phone that is asleep is reachable by none of them. Port this to a push vendor
directly.
**A function has one trigger and it is HTTP.** `GET /v1/functions/triggers`
says as much: every function's trigger is its own invoke endpoint.
`onDocumentWritten`, `onObjectFinalized` and `beforeUserCreated` do not port as
a property of the function. Publish the event to `POST /v1/pubsub/publish` or
`POST /v1/auto/hooks/{source}/{event}` and the wiring becomes a row you can
read rather than a decorator that ships with a deploy.
**Live data comes off the bus, not off the REST address.** A KV write is seen
by watchers on `:4222`. There is no `onSnapshot` on the HTTP path and no
EventSource on a `.json` URL, so a browser that wants live rows goes through
your own server or a bus client.
**Rules are a call you place, not a wall that is always there.**
`firestore.rules` and `storage.rules` compile into the datastore and run on
every read whether or not you remembered them. `POST /v1/authz/check` answers
one question when your handler asks it — which is auditable and cheap to test,
and which means a path you never check is never checked.
**A KV value is opaque text.** Firestore types every field because it indexes
and queries them; `/v1/kv` stores UTF-8 and does not query it, so there is no
`where` clause and no composite index over a bucket. Query moves to
`POST /v1/index/indexes/{uid}/search` or to your own instance from
`POST /v1/provisioning/docdb`. Deciding which is the real work of this
migration — the rest is transcription.
**Every identity is a record.** Firebase mints a durable uid for a caller who
has supplied nothing at all. Accounts here exist in `/v1/iam/users`. A
logged-out visitor is instead a `distinct_id` you choose, which is what
`POST /v1/flags/decide` and `POST /v1/event` take — a stable analytics identity,
and not an account. The browser credential that carries it is a publishable
`pk-` key, which resolves the tenant a beacon belongs to and can read nothing
back.
+198
View File
@@ -0,0 +1,198 @@
---
title: Fly.io
description: Fly runs Firecracker machines under an app namespace, in regions you name. Here that is /v1/platform (37 operations) for an app that deploys and rolls back, and /v1/visor (34) for a machine placed in a region — with the tenant coming from your key rather than an org_slug field.
---
On Fly the app is a namespace that holds no compute and the machine is the unit
that runs, so you keep three names in step: `org_slug`, `app_name`, and the hex
id the machine create mints. Two capabilities answer that here. **`/v1/platform`**
(37 operations) is an application you deploy, stop, promote and roll back;
**`/v1/visor`** (34) is a metered machine you place in a named region and
address by the name you gave it. The structural difference to know before you
port anything: the org is never in the request. It comes from the validated key,
which is what decides who pays and which namespace the objects land in.
## Start here
One call takes a container image to a live HTTPS URL; everything after that is
the ordinary application plane.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. image to a running URL — the 202 body carries it
curl -sS -X POST https://api.hanzo.ai/v1/platform/run \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"acme-web","image":"ghcr.io/acme/web:1.4.2","port":8080,"minScale":1}'
# 3. put your own hostname on it
curl -sS -X POST https://api.hanzo.ai/v1/platform/projects/default/apps/acme-web/domains \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"host":"acme.example.com"}'
```
Step 2 answers with the address it created, so there is no `<app>.fly.dev`
convention to assemble and no `org_slug` to send — the key already resolved the
org. Step 3 proves what step 2 made: an ordinary application, reachable at the
same `/v1/platform/projects/{project}/apps/{app}/...` routes that deploy, stop
and roll back a git app.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/platform` | Applications you deploy, stop, promote and roll back. `run` is the one-call path from image to URL | 37 |
| `/v1/visor` | Metered machines placed in a named region, addressed by the name you chose, priced before you spend | 34 |
| `/v1/ingress` | The edge `fly ips` and `fly certs` reach — routes, services, middlewares, TLS | 18 |
## Nouns
### Running things
| Fly.io | Hanzo |
|---|---|
| Organization — `org_slug`, required on every app create | Your org, taken from the validated key. Never a field in the request |
| App — a namespace holding no compute of its own | Application under a project — `POST /v1/platform/projects/{project}/apps` |
| Machine, addressed by a minted hex id | Machine, addressed by the name you chose — Visor keys on owner plus name, so `/v1/visor/machines/{id}` takes `edge-1` |
| `POST /v1/apps/{app}/machines` with a config block | `POST /v1/visor/machines`, or `POST /v1/platform/run` to go from image to live URL in one call |
| Remote builders building your Dockerfile | An in-cluster BuildKit Job, launched by `POST .../apps/{app}/deploy` for a git app and billed in wall-clock build minutes |
| `fly deploy` | `POST .../apps/{app}/deploy` — 202 with a monotonically versioned deployment record |
| `fly machine start` and `stop` | `POST .../apps/{app}/start` and `.../stop` — back to the configured replicas, or to zero |
| `fly releases` | `GET /v1/platform/releases` — the versions that actually reached the cluster; a failed or still-building deploy is not one |
| `fly releases --rollback` | `POST .../apps/{app}/rollback` — redeploys the old image under a NEW version, appending to history rather than rewinding it |
| `fly scale count` | `replicas` on the app record; `minScale` and `maxScale` on a run |
| `auto_stop_machines`, `auto_start_machines` | `maxScale` above `minScale` declares the autoscaling ceiling; `maxScale: 0` is a fixed run at the floor |
| `fly platform regions`, `fly platform vm-sizes` | `GET /v1/visor/compute/regions` and `GET /v1/visor/compute/sizes` |
| GPU machines (a100, l40s) | A GPU size on `POST /v1/visor/machines`; per-accelerator inventory at `GET /v1/visor/gpus` |
| `fly ssh console`, `fly machine exec` | `POST /v1/sandbox/lease`, then `POST /v1/sandbox/run`; a self-contained browser terminal at `GET /v1/sandbox/{id}/terminal` |
| `fly machine run` for a one-off | `POST /v1/exec` — a throwaway sandbox across thirteen languages, answering stdout and the files the program left |
### State and edge
| Fly.io | Hanzo |
|---|---|
| Volume — its own id, region, snapshots, fork | `storageGb` on the app record. Absent means stateless, and there is no second object |
| `fly postgres create` — an app in your org that you operate | `POST /v1/provisioning/sql` — your org's own instance, `postgres://` DSN returned once |
| `fly redis create` | `POST /v1/provisioning/kv` — your org's own instance, `kv://` DSN |
| Tigris object storage | `POST /v1/provisioning/s3` to make a bucket, `/v1/s3` (6) to read and write it |
| LiteFS, SQLite replicated between machines | Hanzo Base — SQLite per org and per project, health at `GET /v1/base/health` |
| `fly secrets` at rest | `/v1/kms` (5) — sealed per secret, and `GET /v1/kms/secrets` is structurally incapable of emitting a value |
| `fly certs add` | `POST .../apps/{app}/domains`, then `.../domains/{host}/verify`; edge-wide ACME intent at `GET /v1/ingress/tls` |
| `fly ips allocate`, the Anycast edge | `/v1/ingress` (18) — routes, services, middlewares, TLS |
| `fly wireguard`, 6PN, `.internal` DNS | `/v1/network` (4) — the org's Zero Trust overlay and the edge services on it |
| Rate limiting you write into your own app | `/v1/gateway` (3) — CORS allowlist, pre-auth per-IP flood cap, per-org rate ceiling |
| `fly logs` | `GET .../deployments/{id}/logs` for build then app output, and `GET /v1/metrics/logs/query` |
| Fly's managed Grafana and Prometheus | `/v1/o11y` (381) and `/v1/metrics` (11) |
| Scheduled machines standing in for cron | `/v1/tasks` (5) — a durable engine that outlives any one deploy |
| Domains bought elsewhere and pointed at Fly | `/v1/domain` (7) — search, register, renew, transfer |
## The call
Fly, from an image to something serving traffic:
```bash
curl -sS -X POST https://api.machines.dev/v1/apps \
-H "Authorization: Bearer $FLY_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"app_name":"acme-web","org_slug":"acme"}'
curl -sS -X POST https://api.machines.dev/v1/apps/acme-web/machines \
-H "Authorization: Bearer $FLY_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"region": "iad",
"config": {
"image": "ghcr.io/acme/web:1.4.2",
"guest": {"cpu_kind":"shared","cpus":1,"memory_mb":512},
"services": [{
"protocol": "tcp",
"internal_port": 8080,
"ports": [{"port":443,"handlers":["tls","http"]}]
}]
}
}'
```
Two calls, three identifiers, and the address you hand a user is
`https://acme-web.fly.dev` — a convention over the app name that neither
response states.
Hanzo:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/platform/run \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"acme-web","image":"ghcr.io/acme/web:1.4.2","port":8080,"minScale":1}'
```
One call, and the 202 body carries `url`: the live HTTPS address that was
created, not a hostname you assemble. There is no org in the body because the
validated key already resolves it, so there is no field to get wrong and no way
to write another tenant's name down. `name` is the address — re-running it
updates that run in place rather than minting a second machine — so nothing hex
is ever stored. What comes out is an ordinary application, not a special object:
`POST /v1/platform/projects/default/apps/acme-web/deploy`, `/stop` and
`/rollback` all take it.
When you want a specific region, that is the machine plane, and you can price it
before you spend anything:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/visor/machines \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"edge-1","size":"s-2vcpu-4gb","region":"sfo3","dryRun":true}'
```
`dryRun: true` answers 200 with the quote and launches nothing. Drop it and the
same body launches; `GET /v1/visor/machines/edge-1` and `DELETE` on the same
address reach it for the rest of its life, because the key is owner plus name
rather than the provider's id. A machine that is recreated is still `edge-1`.
## What does not carry
**A region is a machine's property, not an app's.** Neither `POST
/v1/platform/run` nor the app record carries a region field — an app lands in
your org's `tenant-<org>` namespace. `GET /v1/visor/compute/regions` is the
catalog `POST /v1/visor/machines` places against. `fly scale count 2 --region
syd` has no app-plane equivalent: spread on the machine plane and put the
results behind `/v1/ingress` routes yourself.
**No Anycast edge, and nothing replays a request.** Fly lands a request in the
nearest region holding a machine and lets a `fly-replay` header bounce it to
another. `/v1/ingress` routes by host and path at one edge. Nothing here reads a
response header and re-issues the request elsewhere.
**A volume is a number, not a resource.** `storageGb` is the persistent disk in
GiB, clamped to the deployment's limit rather than refused, and absent means no
volume at all. There is no volume id, no snapshot, no fork, no extend, no
detaching one and attaching it to something else. What you do get is a read:
`/v1/o11y` reports each claim's capacity, usage and inodes. Data that has to be
a resource in its own right belongs on `/v1/provisioning`, where sql, kv, s3,
vector, search, docdb and datastore each answer with an `rs_`-prefixed handle.
**Changing env does not restart anything.** `fly secrets set` updates the
machines. `PUT .../apps/{app}/env` re-declares the sealed sync immediately, so
the operator re-materialises the Secret, but running pods keep the environment
they started with until their next deploy. It also REPLACES rather than merges —
a variable absent from the body is gone — so port the whole set, never a delta.
**The Postgres connection string comes back once.** `POST /v1/provisioning/sql`
launches your org's own instance and returns the DSN with its credential in that
one response; afterwards the password is sealed in KMS and no read carries it.
It is a dedicated instance rather than a Fly app you operate, which cuts both
ways — no `pg_autoctl` to tune, and no `fly pg` to reach in with either.
**No `fly.toml` in the repo deciding the deploy.** The app record is the
configuration: `source`, `buildType`, `port`, `replicas`, `storageGb`, `env` and
`domains`, set at `POST .../apps` and changed through the routes. A git app
builds with zero-config `pack` unless you name `dockerfile`, and the deploy
routes read that record rather than the tree. Push-to-deploy through the forge
is a separate lane, which a repo opts into by committing `hanzo.yml`.
+39 -4
View File
@@ -8,6 +8,40 @@ registered. `/v1/domain` (7 operations) does the same five things — check,
search, buy, renew, transfer — plus list what you own and report whether the
registrar is reachable.
## Start here
Mint a key, price the name you want, and you are one POST from owning it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. check exact names — comma-separate to do several in one call
curl -sS "https://api.hanzo.ai/v1/domain/availability?domain=example.com,example.ai" \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. taken? build candidates from a keyword, narrowed to the TLDs you'd take
curl -sS "https://api.hanzo.ai/v1/domain/search?q=example&tld=com,ai" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Two GETs, nothing charged and nothing held — a quote is not a reservation, and
the price is re-quoted at purchase. Both answer the same shape: `available`,
`premium`, first-term and renewal price in cents. Priced names back mean the key
is valid and the registrar credentials are live; a deployment missing them
answers 503, not an empty list.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/domain` | Check, search, buy, renew and transfer names, and list what the org holds | 7 |
| `/v1/projects` | Put a site on the name — deploy, release, attach a host and verify it | 27 |
| `/v1/seo` | Audit one live page, and pull keywords, rankings, backlinks and competitors | 7 |
## Nouns
| GoDaddy | Hanzo |
@@ -18,8 +52,8 @@ registrar is reachable.
| `quoteToken` | Nothing. There is no token to carry between calls |
| `POST /v3/domains/registrations` | `POST /v1/domain/register` — `domain`, `years`, `contacts` |
| `GET /v3/domains/registrations/{registrationId}` — poll to `COMPLETED` | Nothing to poll. The register call answers the ownership record |
| `GET /v1/domains` (their v1) | `GET /v1/domain/domains` |
| `POST /v1/domains/{domain}/renew` (their v1) | `POST /v1/domain/renew` — `domain`, `years` |
| List your domains (their v1) | `GET /v1/domain/domains` |
| Renew a name you hold (their v1) | `POST /v1/domain/renew` — `domain`, `years` |
| Transfer with an auth code (their v1) | `POST /v1/domain/transfer` — `domain`, `authCode`, `years` |
| `{"currencyCode": "USD", "value": 1199}` | `priceCents` and `renewalPriceCents`, with `currency` |
| Registrant, admin, tech and billing contacts | `contacts.registrant`, `.admin`, `.tech`, `.billing` |
@@ -78,7 +112,7 @@ curl -sS -X POST https://api.hanzo.ai/v1/domain/register \
"firstName": "Ada",
"lastName": "Lovelace",
"email": "[email protected]",
"phone": "+12125550100",
"phone": "+1.2125550100",
"address1": "251 W 30th St",
"city": "New York",
"state": "NY",
@@ -94,7 +128,8 @@ The record carries `domain`, `expiresAt`, `nameservers`, `priceCents`,
new expiry alongside `paidCents`.
`GET /v1/domain/health` reports registrar reachability without flattering it: it
is `ok` only when the wholesale credentials are actually present.
is `ok` only when the wholesale credentials are present and the registrar
accepted them on a live call.
## What does not carry
+231
View File
@@ -0,0 +1,231 @@
---
title: Grafana
description: Grafana renders dashboards and alerts over Mimir, Loki and Tempo. Here that is /v1/o11y — 381 operations over one store, so a panel that crosses metrics, logs and traces is one query instead of three datasources.
---
Grafana is a rendering and alerting layer over stores you run or rent: Mimir for
metrics, Loki for logs, Tempo for traces. Each is registered as a datasource with
its own host, its own credential and its own UID, and every panel names that UID.
`/v1/o11y` (381 operations across 287 routes) is the store and the layer together —
dashboards, alert rules, log pipelines, trace waterfalls and Kubernetes inventory
over one datastore. That is the difference which shapes the rest: there is no
datasource object, so `POST /v1/o11y/query_range` takes builder queries over
traces, logs and metrics, formulas, PromQL and the datastore's SQL in a single
composite request.
## Start here
Mint a key, ask the store which metrics it already holds, then evaluate PromQL
against one of them — three calls, one host, no datasource to register first.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. what is in the store — name, type, unit, temporality, monotonicity
curl -sS -G https://api.hanzo.ai/v1/o11y/metrics \
-H "Authorization: Bearer $HANZO_API_KEY" \
--data-urlencode 'searchText=http_server' \
--data-urlencode 'limit=20'
# 3. evaluate PromQL over a name from step 2, unchanged from Grafana
curl -sS -G https://api.hanzo.ai/v1/o11y/query_range \
-H "Authorization: Bearer $HANZO_API_KEY" \
--data-urlencode 'query=sum by (service) (rate(http_server_duration_count[5m]))' \
--data-urlencode 'start=1755840000' \
--data-urlencode 'end=1755843600' \
--data-urlencode 'step=60s'
```
Step 2 names every metric in the window and carries its type, unit, temporality and
monotonicity, so step 3 has a real series to put in the query. Nothing was registered
between the two calls: no datasource object, no UID to paste, no per-signal instance
id and no `X-Scope-OrgID` — the key carries the org.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/o11y` | Dashboards, alert rules, PromQL and composite queries, log pipelines, trace waterfalls, Kubernetes inventory — one store behind all of it | 381 |
| `/v1/metrics` | The ingest doors — metric, log and trace writes, plus the reads over that bounded ring | 11 |
| `/v1/notify` | Sends the SMS and email an escalation ends in | 4 |
## Nouns
Grafana is four products sold as one. The left column is their surface; the right
is what answers it.
### Dashboards
| Grafana | Hanzo |
|---|---|
| `POST /api/dashboards/db` | `POST /v1/o11y/dashboards` — a Perses spec at schema version 6 |
| `GET /api/dashboards/uid/{uid}` | `GET /v1/o11y/dashboards/{id}` |
| Re-post the whole dashboard to edit one panel | `PATCH /v1/o11y/dashboards/{id}` — an RFC 6902 patch over panels, queries, variables, layouts and tags |
| `POST /api/datasources` | None. The store is the datasource; a query names a signal |
| Mixed-datasource panel | `POST /v1/o11y/query_range` — one composite query across all three signals |
| `POST /api/folders`, folder permissions | Tags, plus a filter DSL on the list — `?query=name:cpu source:user` |
| Provisioned dashboard, read-only in the UI | `PUT /v1/o11y/dashboards/{id}/lock`, released with DELETE |
| `POST /api/library-elements` | `POST /v1/o11y/dashboards/{id}/clone`, then patch the copy |
| Snapshot, public dashboard | `POST /v1/o11y/dashboards/{id}/public` |
| Starred dashboard | `PUT /v1/o11y/users/me/dashboards/{id}/pins` |
| Explore, saved queries | `POST /v1/o11y/explorer/views` |
| Dashboard variable query | `POST /v1/o11y/variables/query` |
| Panel CSV download | `POST /v1/o11y/export_raw_data` — chunked, with a trailer saying whether it completed |
### Logs, traces and metrics
| Grafana | Hanzo |
|---|---|
| `POST /loki/api/v1/push` | `POST /v1/metrics/logs/write` |
| `GET /loki/api/v1/query_range` with LogQL | `GET /v1/metrics/logs/query` — a label matcher plus a substring test |
| Recent lines in a window | `GET /v1/o11y/logs` |
| `/loki/api/v1/tail` | `GET /v1/o11y/logs/livetail` |
| Promtail pipeline stages | `POST /v1/o11y/logs/pipelines` — versioned, and the posted set replaces the current one |
| Loki label indexing | `POST /v1/o11y/logs/fields` — promote a field to an indexed column |
| Tempo push | `POST /v1/metrics/traces/write` |
| `GET /api/search` with TraceQL | `GET /v1/o11y/traces?range=3600&minDurationMs=2000` |
| `GET /api/traces/{traceID}` | `GET /v1/o11y/traces/{traceId}`, or `GET /v1/metrics/traces/trace` |
| Trace view | `POST /v1/o11y/traces/{traceId}/waterfall` and `POST /v1/o11y/traces/{traceId}/flamegraph` |
| Prometheus remote write | `POST /v1/metrics/write`, or `POST /v1/metrics/batch` |
| `GET /prometheus/api/v1/query_range` | `GET /v1/o11y/query_range`; the instant read is `GET /v1/o11y/query` |
| Metric catalogue | `GET /v1/o11y/metrics` — name, type, unit, temporality, monotonicity |
| Adaptive Metrics | `POST /v1/o11y/metric_reduction_rules`, costed first at `POST /v1/o11y/metric_reduction_rules/preview` |
| Per-stack retention | `POST /v1/o11y/settings/ttl` — per signal, ordered per-label rules, cold storage |
### Alerting and paging
| Grafana | Hanzo |
|---|---|
| `/api/v1/provisioning/alert-rules` | `POST /v1/o11y/rules` |
| Save the rule, then wait for an evaluation | `POST /v1/o11y/rules/test` — answers how many series would fire, without saving |
| Alert state history | `GET /v1/o11y/rules/{id}/history/timeline`, with stats and top contributors beside it |
| `/api/v1/provisioning/contact-points` | `POST /v1/o11y/channels`, fired once at `POST /v1/o11y/channels/test` |
| `/api/v1/provisioning/policies` | `POST /v1/o11y/route_policies` |
| Silences and mute timings | `POST /v1/o11y/downtime_schedules` |
| `GET /api/alertmanager/grafana/api/v2/alerts` | `GET /v1/o11y/alerts` |
| Grafana OnCall paging | `POST /v1/o11y/alerts/{receiver}` — an Alertmanager delivery that pages, and answers 503 when no egress accepted it |
| SMS and email escalation | `POST /v1/notify/send/sms`, `POST /v1/notify/send/email` |
| Webhook contact point | `/v1/webhook` (8) — signed, with 7-day delivery and failure counts per endpoint |
### The rest of the platform
| Grafana | Hanzo |
|---|---|
| Kubernetes Monitoring | `POST /v1/o11y/infra_monitoring/pods` and nine siblings — nodes, clusters, deployments, daemonsets, statefulsets, jobs, namespaces, pvcs, hosts |
| Agent onboarding, is-it-working | `GET /v1/o11y/infra_monitoring/checks` — names each metric the collector is not sending |
| Application Observability, RED | `POST /v1/o11y/services`; apdex thresholds at `POST /v1/o11y/settings/apdex` |
| Service graph | `POST /v1/o11y/dependency_graph` — every parent-to-child edge with rates and latency percentiles |
| Faro frontend errors | `GET /v1/o11y/errortracking/issues`; the browser SDK posts to `/v1/o11y/api/{project_id}/store/` |
| Cloud integrations, AWS and friends | `POST /v1/o11y/cloud_integrations/{cloud_provider}/accounts`, turned on at `POST /v1/o11y/integrations/install` |
| Access policy token | `POST /v1/o11y/gateway/ingestion_keys`, with per-key limits |
| `/api/serviceaccounts` | `/v1/o11y/service_accounts`; the org identity itself is `/v1/iam` (159) |
| `/api/org/users`, teams | `POST /v1/o11y/users` and `POST /v1/o11y/roles` |
| Grafana LLM plugin | `GET /v1/o11y/llm/traces`, `GET /v1/o11y/llm/scores`, `GET /v1/o11y/llm_pricing_rules` |
| status.grafana.com | `GET /v1/o11y/summary` — open, no credential, 503 when it cannot tell |
| Outbound dependency view | `POST /v1/o11y/third-party-apis/overview/list` |
| Funnel over a user journey | `POST /v1/o11y/trace-funnels/new` |
## The call
Grafana Cloud, finding a slow trace and reading it:
```bash
curl -sS -u "$TEMPO_USER:$GRAFANA_TOKEN" \
"https://tempo-prod-04-prod-us-east-0.grafana.net/api/search?q=%7Bduration%3E2s%7D&limit=50"
curl -sS -u "$TEMPO_USER:$GRAFANA_TOKEN" \
"https://tempo-prod-04-prod-us-east-0.grafana.net/api/traces/$TRACE_ID"
```
The logs and metrics for that same incident are two more hosts and two more numeric
instance ids — `logs-prod-006.grafana.net` with `$LOKI_USER`, and the Mimir host with
`$MIMIR_USER` — against the one token.
Hanzo:
```bash
curl -sS "https://api.hanzo.ai/v1/o11y/traces?range=3600&limit=50&minDurationMs=2000" \
-H "Authorization: Bearer $HANZO_API_KEY"
curl -sS "https://api.hanzo.ai/v1/o11y/traces/$TRACE_ID" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
One host, one key, no per-signal instance id, and a duration filter that is a number
rather than a TraceQL string you have to escape. There is no `X-Scope-OrgID` to set,
because the tenant is the validated principal: every row is the caller's own org, and
a caller has no field in which to write another one.
PromQL you already have keeps working, unchanged:
```bash
curl -sS -G https://api.hanzo.ai/v1/o11y/query_range \
-H "Authorization: Bearer $HANZO_API_KEY" \
--data-urlencode 'query=sum by (service) (rate(http_server_duration_count[5m]))' \
--data-urlencode 'start=1755840000' \
--data-urlencode 'end=1755843600' \
--data-urlencode 'step=60s'
```
And a rule is decided before it is stored:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/o11y/rules/test \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @rule.json
```
That answers how many series would alert, right now, on a rule that does not exist
yet. Grafana's provisioning API stores the rule first and tells you the same fact at
the next evaluation interval, once it is already live. The split runs through the
rest of the surface: `POST /v1/o11y/metric_reduction_rules/preview` estimates a
volume-control rule's saving and names the dashboards and alerts it would touch
before anything is written, and `POST /v1/o11y/channels/test` fires one notification
at a receiver carried in the body rather than at a saved one. A decision that can be
made before the write is made before the write, because a background loop's answer
arrives after the thing is in production.
## What does not carry
**Grafana dashboard JSON does not import.** A Grafana panel carries a `datasource`
UID and a `targets` array written in that datasource's own language. Dashboards here
are the Perses spec at schema version 6, and there is no UID to substitute at import,
so a dashboard is rewritten rather than converted. `PATCH /v1/o11y/dashboards/{id}`
takes an RFC 6902 patch, which at least lets the rewrite go panel by panel instead of
in one post.
**LogQL and TraceQL do not carry. PromQL does.** `GET /v1/o11y/query` and
`GET /v1/o11y/query_range` evaluate PromQL as written. Log and trace queries are the
builder shape or the datastore's SQL dialect — a stored LogQL string has no evaluator
here, and a TraceQL duration predicate becomes `minDurationMs=2000`.
**No annotation overlay.** Grafana's `/api/annotations` writes a marker that panels
draw over a time range: deploys, incidents, releases. Nothing here writes a marker
onto a panel. `POST /v1/o11y/llm/annotation` is a different noun — a human's label on
one LLM trace or observation, in a review queue, not a band drawn across a chart.
**No continuous profiling, and no probes in other regions.** Pyroscope's per-process
CPU and heap profiles have no ingest door. `POST /v1/o11y/traces/{traceId}/flamegraph`
buckets one trace's spans by depth, which draws like a flame graph and measures
something else. Synthetic Monitoring's checks from probe locations and k6's load tests
have no equivalent either — `GET /v1/o11y/summary` and `GET /v1/o11y/status` report
Hanzo's own fleet health, which is a different fact from your endpoint's.
**Two ingest doors, both counting in nanoseconds.** `/v1/metrics/logs/write` and
`/v1/metrics/traces/write` hold a bounded ring per org — 1,048,576 records and
1,048,576 spans, oldest evicted — and take the tenant from the gateway-minted
`X-Org-Id`; a series written to `/v1/metrics/write` keeps its newest 65,536 samples.
The `/v1/o11y` reads run over the collector-fed datastore, whose retention you set per
signal at `POST /v1/o11y/settings/ttl`. Choose the door deliberately. Note also that
Prometheus remote write sends milliseconds while both doors here read nanoseconds, and
a sample sent with no timestamp is stored at 0 and then falls outside any query with a
lower bound — that is the usual reason a write which answered 200 does not read back.
**Log aggregate buckets are not computed yet.** `GET /v1/o11y/logs/aggregate` answers
the empty set: the schema is the contract, the numbers are not there. Bucketed
log-rate panels come from `POST /v1/o11y/query_range` instead.
+230
View File
@@ -0,0 +1,230 @@
---
title: Heroku
description: Heroku builds a repo into a slug and runs it as dynos. Here that is /v1/platform — 37 operations from app create through build, promote and rollback — with the add-ons split out into /v1/provisioning (28).
---
Heroku takes a repo, builds it into a slug, and runs that slug as dynos.
`/v1/platform` (37 operations) is the like-for-like replacement: create an app,
deploy it, read the build's logs, promote, roll back, set env, attach a domain.
Add-ons are not part of it — they are `/v1/provisioning` (28), one route per kind.
The structural difference to plan around: a Heroku app is a globally unique name
holding several process types, and here an app is one image under
`org → project → app`. The org comes from your key and is never a field, the
slug only has to be free inside the project, and a `worker` process type is a
second app rather than a second line in a Procfile.
## Start here
With an image already built, one call puts an app on a live HTTPS URL — no
project to create, no team, no region.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. run the image — answers with the app's live URL
curl -sS -X POST https://api.hanzo.ai/v1/platform/run \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"web","image":"oci.hanzo.ai/acme/web:1.4.2","port":8080,"minScale":1}'
# 3. give it a database — the DSN lands in that app's web-addons secret
curl -sS -X POST https://api.hanzo.ai/v1/provisioning/sql \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"web-db","instance":"web"}'
```
Step 2 answers 202 with `url` and `status: deploying`, and what it created is an
ordinary app in your org's `default` project — listable at
`GET /v1/platform/projects/default/apps`, stoppable and redeployable like any
other. Step 3 returns `connectionString` once and never again; together the two
are `heroku create` plus `addons:create heroku-postgresql`, with no `team` and no
`region` to name because the org is the claim in the key.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/platform` | App create, build, deploy, promote, rollback, env, domains | 37 |
| `/v1/provisioning` | The seven add-on kinds: sql, kv, s3, search, docdb, vector, datastore | 28 |
| `/v1/o11y` | Logs, live tail and traces, in place of a log drain and an APM add-on | 381 |
## Nouns
### The app, its builds and its releases
| Heroku | Hanzo |
|---|---|
| Team, Enterprise Account | Your org, taken from the validated key. Never a field in the request |
| App | `POST /v1/platform/projects/{project}/apps` — creating does not deploy; it lands in `draft` |
| Buildpack | `buildType: pack`, the zero-config default that detects the project. `dockerfile` is the explicit escape hatch |
| Slug, the build artifact | An OCI image in your org's own registry namespace — `/v1/registry` (6) |
| `git push heroku main` | `POST /v1/git/{org}/{repo}/git-receive-pack`, and `POST /v1/platform/hook` turns the push into a build |
| Build | `POST .../apps/{app}/deploy`, whose build record is listed at `GET /v1/platform/builds` |
| Release | `GET /v1/platform/releases` — only deployments that genuinely reached the cluster |
| `releases:rollback` | `POST .../apps/{app}/rollback` — with no body, the newest earlier deployment that carries a real image |
| Config vars | `PUT .../apps/{app}/env` — replaces the whole set; `secret: true` seals into KMS |
| Pipeline | `GET /v1/platform/pipelines` — a projection of an app plus its newest deployment, not a record you create |
| Staging and production stages | `GET /v1/platform/environments` — derived from what apps name, so nothing creates or deletes one |
| `pipelines:promote` | `POST .../apps/{app}/promote` — redeploys a built image and never builds |
| Review app | `POST .../apps/{app}/preview` — a branch on its own URL, as a first-class app named `<app>-<branch>` |
| `heroku ps`, `ps:scale web=0`, maintenance mode | `GET .../apps/{app}` reads live phase and health from the cluster; `.../apps/{app}/stop` and `.../apps/{app}/start` scale to zero and back, and metering stops with the pods |
| `heroku logs --tail` | `GET .../apps/{app}/deployments/{id}/logs` for build then app, `GET /v1/o11y/logs/livetail` for the stream |
| Custom domain and ACM | `POST .../apps/{app}/domains`, then `POST .../apps/{app}/domains/{host}/verify` |
The canonical default host is seeded at create, so an app has a working HTTPS
address the moment it first deploys. A custom host is the one thing that cannot
be attached at create — it goes through add-domain and DNS verification first.
### Add-ons
| Heroku | Hanzo |
|---|---|
| Heroku Postgres | `POST /v1/provisioning/sql` — your org's own instance, `postgres://` returned once |
| Heroku Key-Value Store | `POST /v1/provisioning/kv` for a dedicated instance, `/v1/kv` (6) for a hosted bucket with history |
| Heroku Kafka | `/v1/mq` (15) — streams, consumers, and a `next` that acknowledges on delivery |
| Object storage add-ons, Bucketeer | `POST /v1/provisioning/s3`, and `/v1/s3` (6) to work the buckets |
| Search add-ons, Bonsai | `POST /v1/provisioning/search` |
| MongoDB add-ons | `POST /v1/provisioning/docdb` |
| Vector add-ons, Pinecone | `POST /v1/provisioning/vector` |
| Warehouse and analytics add-ons | `POST /v1/provisioning/datastore` |
| Heroku Scheduler | `/v1/tasks` (5) — a durable engine, not a dyno that wakes on a clock |
| Log drains, Papertrail, Logentries | `/v1/o11y` (381) — logs, live tail, pipelines and retention |
| APM add-ons, New Relic, Scout | `GET /v1/o11y/traces` with flamegraph and waterfall, plus `/v1/metrics` (11) |
| Error trackers, Sentry, Rollbar | `/v1/event` (12) |
| Flag add-ons, LaunchDarkly, Split | `/v1/flags` (8) — a definition and a deterministic decision |
| Heroku Managed Inference and Agents | `/v1/ai` (272) — the Zen models themselves, not a broker to somebody else's — plus `/v1/agents` (37) for the agent runtime |
| Elements marketplace, Heroku Button | `/v1/marketplace` (6) to install one tool, `/v1/blueprint` (3) for a whole stack with its monthly cost |
### The rest of the account
| Heroku | Hanzo |
|---|---|
| Add-on attachment writing `DATABASE_URL` | `instance` on the provision body — the DSN lands in that app's `<instance>-addons` secret |
| Container Registry, `container:push` | `source: image` on create, and `/v1/registry` (6) to list images and mint a pull token |
| App webhooks | `/v1/webhook` (8) — the signing secret leaves the server on create and on rotate, never on a read |
| Collaborators, teams, API keys | `/v1/iam` (159) |
| Invoices and usage | `/v1/billing` (45) and `/v1/usage` (5) |
| Private Space, Shield networking | `/v1/network` (4) for the org's overlay; every app already lands in its own `tenant-<org>` namespace |
| Buying the domain itself | `/v1/domain` (7) |
| Routing, TLS and middleware in front of the app | `/v1/ingress` (18) |
| `heroku run bash` | `POST /v1/sandbox`, then `POST /v1/sandbox/{id}/exec` — `/v1/sandbox` (19) |
| A function rather than a dyno | `/v1/functions` (11) |
## The call
Heroku, creating and running an app through the Platform API:
```bash
H=(-H "Authorization: Bearer $HEROKU_API_KEY"
-H 'Accept: application/vnd.heroku+json; version=3'
-H 'Content-Type: application/json')
curl -sS "${H[@]}" -X POST https://api.heroku.com/apps \
-d '{"name":"acme-web","team":"acme","region":"us"}'
curl -sS "${H[@]}" -X PATCH https://api.heroku.com/apps/acme-web/config-vars \
-d '{"STRIPE_KEY":"sk_live_51abc"}'
curl -sS "${H[@]}" -X POST https://api.heroku.com/apps/acme-web/builds \
-d '{"source_blob":{"url":"'"$SOURCE_URL"'","version":"a1b2c3d"}}'
curl -sS "${H[@]}" -X PATCH https://api.heroku.com/apps/acme-web/formation \
-d '{"updates":[{"type":"web","quantity":3,"size":"standard-1x"}]}'
```
Four calls, after a fifth to `POST /sources` produced `$SOURCE_URL`, and four
identifiers to keep in step: an app name that has to be free across every Heroku
customer, a `team`, a source version, and a formation `type` that has to match a
line in the Procfile.
Hanzo, the same app, in two:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/platform/projects/acme/apps \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "web",
"source": "git",
"repo": {"url": "https://git.hanzo.ai/acme/web.git", "branch": "main"},
"replicas": 3,
"env": [{"key": "STRIPE_KEY", "value": "sk_live_51abc", "secret": true}]
}'
curl -sS -X POST https://api.hanzo.ai/v1/platform/projects/acme/apps/web/deploy \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"commit":"main"}'
```
When the image already exists, one call, and it answers with the live URL:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/platform/run \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"web","image":"oci.hanzo.ai/acme/web:1.4.2","port":8080,"minScale":3,"maxScale":10}'
```
There is no `team` and no `region`. The org is the claim in the key, so `web`
only has to be free inside the `acme` project, and no request field exists for a
caller to write another tenant down. Env goes in at create because `secret: true`
seals the value into KMS before the app is ever persisted — it reads back as
`""` from then on, where a Heroku config var is readable again through
`GET /config-vars`. Deploys are ordered by a version that counts up per app and
one goes live only if no higher version already has, so a build that finishes
late is recorded `superseded` instead of overwriting the newer release; that is
also why `promote` and `rollback` redeploy an existing image rather than
building a second one.
## What does not carry
**A Procfile's process types become separate apps.** Heroku's app holds `web`,
`worker` and `release` and scales each with a formation entry. An app here is one
image running one process, so `worker` is a second app in the same project with
its own slug, env and replica count. No release-phase command runs between the
build and the release either — put migrations in a scheduled task on `/v1/tasks`
or run them from a sandbox, which is a separate pod with its own filesystem and
none of the app's environment.
**There is no dyno size to choose.** `shape` is accepted for the client contract
and echoed straight back; the operator sizes CPU and memory. The knobs that do
bite are counts and capacity: `replicas` on an app, `minScale` and `maxScale` on
`POST /v1/platform/run`, `storageGb` for a persistent volume — absent means
stateless, which a Heroku dyno filesystem always is — and `gpu` on a run. Each is
clamped to the deployment's limit rather than refused, so an oversized ask lands
smaller instead of erroring.
**Changing env does not restart anything.** `PATCH /config-vars` cuts a new
Heroku release and cycles every dyno. `PUT .../apps/{app}/env` re-declares the
secret sync immediately, but running pods keep the environment they started with
until their next deploy or restart. It also replaces the whole set: a variable
absent from the body is gone.
**Review apps are not automatic.** Heroku opens one per pull request from
`app.json` and destroys it on merge. `POST .../apps/{app}/preview` is a call you
make, it needs an image that already exists because it does not build, and it
carries no environment variables at all — a preview never inherits production's
secrets. Re-previewing a branch converges the same target rather than stacking
another one.
**Backing services are first-party instances, and the credential comes back
once.** The seven provisioning kinds are the whole catalog — sql, kv, s3, search,
docdb, vector and datastore — so there is no third-party vendor plan to subscribe
to and no vendor dashboard to be handed off to for a database.
`POST /v1/provisioning/sql` launches a dedicated instance in your own namespace
and returns the DSN in that single response; no later read carries it. Name
`instance` and it lands in that app's `<instance>-addons` secret, which is the
attachment; leave it out and you hold the string yourself.
**The CI board is not wired.** `GET /v1/platform/ci` answers 501 rather than an
empty list, because a deployment with no forge Actions client and a forge with no
runs must not look the same. Runs do happen — repos build from `.hanzo/workflows`
on git.hanzo.ai — the API just does not enumerate them yet. Builds triggered by a
deploy are a different record and are listed at `GET /v1/platform/builds`.
+77 -16
View File
@@ -1,33 +1,94 @@
---
title: Migrate
description: Fifteen vendors, each mapped to the Hanzo capability that answers it — nouns, the call before and after, and what does not carry.
description: 39 vendors, each mapped to the Hanzo capability that answers it — how to start, nouns, the call before and after, and what does not carry.
icon: ArrowRightLeft
---
Fifteen guides, one per vendor. Each names the capability that answers it, maps
the vendor's nouns onto ours, shows the same call both ways, and says plainly
what you lose.
39 guides, one per vendor. Each one starts with the shortest path to a working
call, names the capability that answers the vendor, maps their nouns onto ours,
shows the same call both ways, and says plainly what you lose.
Every capability lives at one address — `/v1/<name>` — and the operation counts
below are read from the API document this site is built from.
## Hosting and delivery
| From | What it does | Capability |
|---|---|---|
| [Composio](/docs/migrate/composio) | Authenticated third-party tools for agents | `/v1/integrations` (40) · `/v1/connectors` (8) |
| [E2B](/docs/migrate/e2b) | Sandboxes for code an agent wrote | `/v1/sandboxes` (19) · `/v1/exec` (2) |
| [Amazon SQS](/docs/migrate/sqs) | Queues between services | `/v1/mq` (15) · `/v1/pubsub` (8) |
| [Twilio](/docs/migrate/twilio) | Phone numbers, SMS and calls | `/v1/tel` (10) · `/v1/channels` (7) · `/v1/notify` (4) |
| [Vercel](/docs/migrate/vercel) | Building a repo and serving it on a domain | `/v1/platform` (37) · `/v1/projects` (27) |
| [Netlify](/docs/migrate/netlify) | Static hosting, builds, edge functions and forms | `/v1/projects` (27) · `/v1/functions` (11) |
| [Render](/docs/migrate/render) | Apps, cron and managed data stores | `/v1/platform` (37) · `/v1/provisioning` (28) |
| [Fly.io](/docs/migrate/fly) | Machines placed in named regions | `/v1/platform` (37) · `/v1/visor` (34) |
| [Heroku](/docs/migrate/heroku) | Dynos, buildpacks, pipelines and add-ons | `/v1/platform` (37) · `/v1/provisioning` (28) |
## Identity
| From | What it does | Capability |
|---|---|---|
| [Auth0](/docs/migrate/auth0) | The identity provider your app redirects to | `/v1/iam` (159) |
| [Clerk](/docs/migrate/clerk) | Drop-in auth components, users, orgs and sessions | `/v1/iam` (159) · `/v1/account` (10) |
| [WorkOS](/docs/migrate/workos) | SSO, SCIM directory sync and audit logs | `/v1/iam` (159) · `/v1/audit` (1) |
| [Okta](/docs/migrate/okta) | Enterprise SSO, lifecycle provisioning and MFA | `/v1/iam` (159) · `/v1/authz` (3) |
## Data and storage
| From | What it does | Capability |
|---|---|---|
| [Supabase](/docs/migrate/supabase) | Postgres with auth, storage, realtime and vectors | `/v1/provisioning` (28) · `/v1/iam` (159) |
| [Firebase](/docs/migrate/firebase) | Auth, documents, storage, functions and messaging | `/v1/iam` (159) · `/v1/kv` (6) |
| [PlanetScale](/docs/migrate/planetscale) | A database with schema branches and deploy requests | `/v1/provisioning` (28) · `/v1/git` (44) |
| [Upstash](/docs/migrate/upstash) | Serverless Redis, Kafka, scheduling and vectors | `/v1/kv` (6) · `/v1/mq` (15) |
| [Algolia](/docs/migrate/algolia) | Search indexes with ranking, facets and typo tolerance | `/v1/index` (17) |
| [Pinecone](/docs/migrate/pinecone) | Vector indexes, namespaces and metadata filters | `/v1/index` (17) · `/v1/knowledge` (9) |
## Observability and product
| From | What it does | Capability |
|---|---|---|
| [Datadog](/docs/migrate/datadog) | Metrics, traces, logs, dashboards and monitors | `/v1/o11y` (381) · `/v1/metrics` (11) |
| [Sentry](/docs/migrate/sentry) | Error tracking, releases and performance | `/v1/event` (12) · `/v1/o11y` (381) |
| [PostHog](/docs/migrate/posthog) | Product analytics, flags, experiments and replay | `/v1/event` (12) · `/v1/flags` (8) |
| [Grafana](/docs/migrate/grafana) | Dashboards, logs, traces and alerting | `/v1/o11y` (381) · `/v1/notify` (4) |
| [LaunchDarkly](/docs/migrate/launchdarkly) | Which users see which behaviour | `/v1/flags` (8) · `/v1/experiment` (7) |
## Agents and inference
| From | What it does | Capability |
|---|---|---|
| [OpenAI platform](/docs/migrate/openai) | Models, assistants and the platform around them | `/v1/ai` (272) · `/v1/agents` (37) |
| [Replicate](/docs/migrate/replicate) | Running a model under a name | `/v1/ml` (7) · `/v1/ai` (272) |
| [Modal](/docs/migrate/modal) | Serverless GPU functions, sandboxes and jobs | `/v1/functions` (11) · `/v1/sandbox` (19) |
| [LangSmith](/docs/migrate/langsmith) | LLM tracing, datasets and evaluations | `/v1/o11y` (381) · `/v1/eval` (16) |
| [Composio](/docs/migrate/composio) | Authenticated third-party tools for agents | `/v1/integrations` (48) |
| [E2B](/docs/migrate/e2b) | Sandboxes for code an agent wrote | `/v1/sandbox` (19) · `/v1/exec` (4) |
| [Tavily](/docs/migrate/tavily) | Web search and page extraction for agents | `/v1/websearch` (6) · `/v1/crawl` (1) |
## Messaging
| From | What it does | Capability |
|---|---|---|
| [Amazon SQS](/docs/migrate/sqs) | Queues between services | `/v1/mq` (15) · `/v1/pubsub` (2) |
| [Twilio](/docs/migrate/twilio) | Phone numbers, SMS and calls | `/v1/tel` (10) · `/v1/channels` (7) |
| [AgentMail](/docs/migrate/agentmail) | Mailboxes an agent can send and read | `/v1/notify` (4) · `/v1/channels` (7) |
| [ElevenLabs](/docs/migrate/elevenlabs) | Speech synthesis and transcription | `/v1/audio` (5) |
| [Tavily](/docs/migrate/tavily) | Web search and page extraction for agents | `/v1/websearch` (6) · `/v1/crawl` (1) · `/v1/scrape` (1) |
| [GoDaddy](/docs/migrate/godaddy) | Buying and holding domains | `/v1/domain` (7) |
| [Doppler](/docs/migrate/doppler) | Secrets, held for an org | `/v1/kms` (5) |
| [Lago](/docs/migrate/lago) | Metered billing and invoices | `/v1/billing` (45) · `/v1/usage` (11) · `/v1/pricing` (25) |
| [Auth0](/docs/migrate/auth0) | The identity provider your app redirects to | `/v1/iam` (159) |
| [Vercel](/docs/migrate/vercel) | Building a repo and serving it on a domain | `/v1/projects` (27) |
| [LaunchDarkly](/docs/migrate/launchdarkly) | Which users see which behaviour | `/v1/flags` (8) · `/v1/experiment` (7) |
| [DocuSign](/docs/migrate/docusign) | Legally binding signatures on a document | `/v1/esign` (13) |
## Money and business
| From | What it does | Capability |
|---|---|---|
| [Stripe](/docs/migrate/stripe) | Payments, subscriptions and invoices | `/v1/commerce` (181) · `/v1/billing` (45) |
| [Lago](/docs/migrate/lago) | Metered billing and invoices | `/v1/billing` (45) · `/v1/usage` (11) |
| [DocuSign](/docs/migrate/docusign) | Legally binding signatures on a document | `/v1/esign` (13) · `/v1/dataroom` (26) |
| [Carta](/docs/migrate/carta) | Who owns what in a company | `/v1/captable` (31) |
| [GoDaddy](/docs/migrate/godaddy) | Buying and holding domains | `/v1/domain` (7) |
## Secrets and automation
| From | What it does | Capability |
|---|---|---|
| [HashiCorp Vault](/docs/migrate/vault) | Secrets, dynamic credentials and transit encryption | `/v1/kms` (5) |
| [Doppler](/docs/migrate/doppler) | Secrets, held for an org | `/v1/kms` (5) |
| [Temporal](/docs/migrate/temporal) | Durable workflows, retries and schedules | `/v1/auto` (17) · `/v1/tasks` (5) |
## The key carries the tenant
+46 -4
View File
@@ -6,7 +6,49 @@ description: Lago meters usage and turns it into invoices. Here /v1/billing rais
Lago is a billing engine: you define what you charge for, feed it usage, and it
produces invoices and collects them. Three capabilities cover the same ground —
`/v1/billing` (45 operations) for invoices, subscriptions and prepaid credit,
`/v1/usage` (11) for what was consumed, `/v1/pricing` (25) for the rate card.
`/v1/usage` (5) for what was consumed, `/v1/pricing` (28) for the rate card.
## Start here
Two calls after the key: raise a draft from its lines, then issue it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. raise the draft — there is no total to send, the lines are the total
INV=$(curl -sS -X POST https://api.hanzo.ai/v1/billing/invoices \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"userId": "usr_7f3a",
"currency": "usd",
"lines": [
{"description":"API calls, March","quantity":12000,"unitPrice":1,"amount":12000}
]
}' | jq -r .id)
# 3. issue it — moves it to open, assigns the number, makes it collectible
curl -sS -X POST "https://api.hanzo.ai/v1/billing/invoices/$INV/issue" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Step 2 answers 201 with `status` draft, `subtotalCents` 12000 summed from the
lines, and no `number` — a draft is readable and correctable, and owes nothing.
Step 3 is the state change that issuing exists for: open, numbered, collectible,
after which `POST /v1/billing/invoices/$INV/collect` draws credits, then balance,
then card. No plan, metric or customer object had to exist first.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/billing` | Raises drafts, issues, collects and voids them; subscriptions, credit balance, top-ups, spend alerts | 45 |
| `/v1/usage` | Takes consumption samples and reads them back as the org's footprint over a window | 5 |
| `/v1/pricing` | The platform's own rate card for models, compute, storage and tools; 25 of the 28 are reads | 28 |
## Nouns
@@ -60,7 +102,7 @@ client.events().create(Event(
Hanzo, raising and collecting an invoice:
```bash
# 1. Draft. Answers the row with subtotalCents, amountDueCents and a number.
# 1. Draft. Answers the row with subtotalCents and amountDueCents — no number.
INV=$(curl -sS -X POST https://api.hanzo.ai/v1/billing/invoices \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
@@ -115,8 +157,8 @@ subscribe to, not a place to define one. Graduated, package, volume and
percentage pricing, charge models, add-ons, coupons, taxes and credit notes have
no counterpart.
**`/v1/pricing` is a rate card, not your rate card.** Twenty-five operations,
twenty-four of them reads: what this platform charges for models, compute,
**`/v1/pricing` is a rate card, not your rate card.** Twenty-eight operations,
twenty-five of them reads: what this platform charges for models, compute,
storage, IAM, hosting and per-use tools. It is not a place to publish prices for
your own customers.
@@ -0,0 +1,247 @@
---
title: LangSmith
description: LangSmith records what an LLM app did and grades it against datasets. Here that is /v1/o11y (381) for the traces, scores and review queues, /v1/eval (16) for the datasets and runs, and /v1/prompt (6) for the library.
---
LangSmith is a recorder standing beside your app. Your process calls a model
somewhere, then posts a run to `api.smith.langchain.com` describing the call it
just made. Three capabilities answer that here — `/v1/o11y` (381 operations) for
traces, sessions, scores and review queues, `/v1/eval` (16) for datasets and
evaluation runs, `/v1/prompt` (6) for the library — and one structural
difference runs through all three: the recorder is underneath the model, not
beside it. A call served by `/v1/ai` (272) is already a `gen_ai` span, so you
instrument what you serve yourself and nothing else.
## Start here
A call served by `/v1/ai` is already a trace, so the first step is not
instrumentation — it is looking at what you already recorded.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. the sessions already recorded for you — no SDK, no post-run call
curl -sS "https://api.hanzo.ai/v1/o11y/llm/sessions?limit=20" \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. a dataset to grade against
curl -sS -X POST https://api.hanzo.ai/v1/eval/datasets \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"support-qa","description":"answers we expect"}'
```
Step 2 returns rows without your app having posted anything. That is the whole
difference: LangSmith records what your process says it did, and this records
what the model actually served.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/o11y` | Traces, sessions, scores and review queues | 381 |
| `/v1/eval` | Datasets and evaluation runs | 16 |
| `/v1/prompt` | The prompt library | 6 |
## Nouns
### Tracing
| LangSmith | Hanzo |
|---|---|
| Workspace, selected by `X-Tenant-Id` on a service key | Your org, taken from the validated key. Not a header, not a body field |
| Project, sent as `session_name` on every run | The project scope the credential carries; the LLM reads are narrowed to it |
| Run: `POST /runs`, then `PATCH /runs/{id}` to close it | One span, closed on the write: `POST /v1/metrics/traces/write` carries `startNs` and `endNs` together |
| `dotted_order`, the ancestor chain encoded into a string | `parentId`. A span names its parent and derives nothing |
| `POST /runs/batch` | `POST /v1/event` — one ingest door; an authenticated body is offered to the observability plane, which claims LLM ingestion batches |
| LLM run list | `GET /v1/o11y/llm/observations` — model, token counts, cost and latency per call |
| Trace view | `GET /v1/o11y/llm/traces`; the waterfall at `POST /v1/o11y/traces/{traceId}/waterfall` |
| Threads | `GET /v1/o11y/llm/sessions` — spans grouped by `session.id` |
| End-user metadata | `GET /v1/o11y/llm/users` — spans grouped by `user.id`, with their cost |
| Feedback on a run | `POST /v1/o11y/llm/scores`, or `POST /v1/eval/scores` to attach one to a run or a dataset item |
| Inline comment on a trace | `POST /v1/o11y/llm/annotation` |
| Annotation queue | `POST /v1/o11y/reviews`; enqueue with `POST /v1/o11y/reviews/{id}/items` |
| Mapping your field names onto the ones the views read | `POST /v1/o11y/span_mapper_groups`, rules under `.../{groupId}/span_mappers` |
| Model price table behind the cost column | `PUT /v1/o11y/llm_pricing_rules` |
| Monitoring charts | `GET /v1/eval/metrics` — totals, gap-filled series, per-model breakdown, latency percentiles |
| Alerting on a filter | `GET,POST /v1/o11y/rules`; dry-run one with `POST /v1/o11y/rules/test` |
### Datasets and evaluation
| LangSmith | Hanzo |
|---|---|
| Dataset: `POST /datasets` answers a UUID you carry from then on | `POST /v1/eval/datasets` — the name is the key, so re-posting it edits and never duplicates |
| Example: `POST /examples`, with `dataset_id` in the body | `POST /v1/eval/datasets/{name}/items` — the set is the path, not a field |
| Splits | `status` on the item, ACTIVE or ARCHIVED. Only ACTIVE examples are fed to a run |
| Experiment: you call the target yourself and post the runs | `POST /v1/eval/runs` — it calls the model, records the trace, calls the judge and answers the summary |
| `evaluate()` with a grader function in your process | `POST /v1/eval/evaluators` — a stored judge: judge model, written criteria, the score name it files under |
| Feedback key with a schema | `POST /v1/eval/rubrics` — data type, numeric bounds, allowed categories, enforced at write time |
| Experiment results table | `GET /v1/eval/runs` — dataset, model, judge, items attempted, items scored, average |
| Per-example scores | `GET /v1/eval/scores`, narrowed by `runName`, `name` or `traceId` |
| The traces one experiment produced | `GET /v1/eval/traces`, narrowed by `runName` or `datasetName` |
| Two models head to head | `GET /v1/benchmark/compare` — paired on the items both completed, with an exact-McNemar p |
### Prompts
| LangSmith | Hanzo |
|---|---|
| Prompt hub repo, `/repos/{owner}/{repo}` | `POST /v1/prompt` — the name is the handle and the URL segment |
| Commit, `/commits/{owner}/{repo}` | A version. Re-posting a name appends one rather than overwriting |
| Reading a prompt | `GET /v1/prompt/{name}` — the current body plus the number, type and time of every version |
| The public hub | `GET /v1/prompt/catalog` — the starter library shipped with the binary, read-only, never mixed into yours |
| Prompt usage stats | `GET /v1/prompt/metrics` — versions per prompt, which one is current, when it last changed |
| Playground | Hanzo Chat and Studio; over the API, `POST /v1/chat/completions` |
| Rolling a prompt change onto live traffic | `/v1/experiment` (7): create, `GET /v1/experiment/{id}/assign`, `POST /v1/experiment/{id}/analyze`, `POST /v1/experiment/{id}/decide` |
`assign` is a deterministic hash of the subject, so the same subject gets the
same arm on every call for as long as the flag definition is unchanged. It is a
pure read and records nothing, including the exposure — your own event does
that, or the analysis has an empty denominator.
## The call
LangSmith, tracing one call. The run is opened, then closed, and a child would
also carry a `dotted_order` naming its ancestors:
```bash
RUN=$(uuidgen)
curl -sS -X POST https://api.smith.langchain.com/runs \
-H "x-api-key: $LANGSMITH_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"id": "'"$RUN"'",
"trace_id": "'"$RUN"'",
"dotted_order": "20260820T120000000000Z'"$RUN"'",
"session_name": "support-bot",
"name": "answer",
"run_type": "llm",
"start_time": "2026-08-20T12:00:00Z",
"inputs": {"question": "how do I rotate a key?"}
}'
curl -sS -X PATCH "https://api.smith.langchain.com/runs/$RUN" \
-H "x-api-key: $LANGSMITH_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"end_time": "2026-08-20T12:00:02Z", "outputs": {"answer": "…"}}'
```
Hanzo. The call is the trace, and the second command is a read, not a write:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/chat/completions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"zen5","messages":[{"role":"user","content":"how do I rotate a key?"}]}'
curl -sS "https://api.hanzo.ai/v1/o11y/llm/traces?limit=20" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Work you serve yourself is a span you append — one write, already closed:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/metrics/traces/write \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"spans":[{
"traceId": "9f2c7a1e",
"spanId": "0a1b2c3d",
"parentId": "",
"name": "retrieve",
"startNs": 1766232000000000000,
"endNs": 1766232000420000000,
"attrs": {"gen_ai.system": "hanzo", "gen_ai.request.model": "zen5"}
}]}'
```
The evaluation is where the call count separates. Three commands, start to
graded result:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/eval/datasets \
-H "Authorization: Bearer $HANZO_API_KEY" -H 'Content-Type: application/json' \
-d '{"name":"support-qa","description":"answers we grade each release against"}'
curl -sS -X POST https://api.hanzo.ai/v1/eval/datasets/support-qa/items \
-H "Authorization: Bearer $HANZO_API_KEY" -H 'Content-Type: application/json' \
-d '{
"id": "rotate-key",
"input": {"question": "how do I rotate a key?"},
"expectedOutput": {"answer": "POST /v1/kms/secrets with the new value"}
}'
curl -sS -X POST https://api.hanzo.ai/v1/eval/runs \
-H "Authorization: Bearer $HANZO_API_KEY" -H 'Content-Type: application/json' \
-d '{
"dataset": "support-qa",
"model": "zen5",
"runName": "rc-41",
"judge": {"model": "zen5-mini", "criteria": "the answer names the right endpoint", "name": "correctness"}
}'
```
The third command is the whole evaluation. For each ACTIVE example it calls the
model under test, records a trace, calls the judge, files the judge's score with
its reasoning, and answers `items`, `scored`, `avgScore` and one row per example
— synchronous work, not a job id, so there is no polling loop to write and a run
where nothing scored is a 502 rather than a 200 with an empty table. The same
sequence against LangSmith is seven calls: create the dataset, create the
example, call the model yourself, post the run, patch it closed, grade it
yourself, post the feedback. It also leaves you holding six identifiers that
have to agree — dataset id, run id, trace id, dotted order, project name and the
workspace header. Here there are two names: the dataset's, and the item's if you
want the write idempotent. The run drives the gateway with your own bearer, so
it can call exactly the models you can call, and only a non-reversible hash of
that credential is written onto the traces.
## What does not carry
**No `@traceable`, and no callback handler.** LangSmith's client wraps a
function and mints the run tree around it. What we serve is recorded because we
served it; what your own process does is an OpenTelemetry span you append, and
the `gen_ai.*` attributes on it are what the LLM views project from. The Hanzo
SDKs are generated API clients — they do not decorate your code. If your
attribute names differ from the ones the views read, declare the translation
once at `POST /v1/o11y/span_mapper_groups` instead of rewriting the emitters.
**A run is bounded, deliberately.** `POST /v1/eval/runs` scores 20 examples by
default and 100 at most; a `limit` above 100 falls back to the default of 20
rather than clamping to 100. An org may have 4 runs in flight before the fifth
is a 429, and the whole run is capped at ten minutes — examples past the deadline
come back with an error rather than a score, and `scored` counts successes only.
LangSmith evaluates thousands of examples with concurrency you own. A sweep here
is several runs against one dataset, each with its own `runName`.
**Nothing samples production traffic into a dataset for you.** LangSmith
automations take a filter and a sampling rate and run an online evaluator on
what they catch. Choosing which traces matter is an explicit act here:
`GET /v1/o11y/llm/traces` reads them, `POST /v1/o11y/reviews/{id}/items` queues
them for a human, `POST /v1/o11y/llm/scores` files a machine score, and
`/v1/auto` (17) is where you hang that off a trigger.
**Old prompt bodies are not readable back.** A repeated `POST /v1/prompt`
appends a version, and `GET /v1/prompt/{name}` returns the current text plus
each version's number, type and timestamp — the history, not the bodies. There
is no route that fetches version 3, and no tag alias that resolves to one.
LangSmith pulls a prompt at a named commit. Pin the text in whatever cuts your
release, or keep a variant under its own name.
**The paired significance test is on the benchmark plane, not on your dataset.**
`GET /v1/benchmark/compare` pairs two models on the items both completed and
answers rescue and damage counts with an exact-McNemar p, over the benchmark
catalog. Two runs across your own eval dataset compare by `avgScore` from
`GET /v1/eval/runs` and per-item scores from `GET /v1/eval/scores` — the
numbers, without the test on top.
**The two trace stores keep different amounts.** What
`POST /v1/metrics/traces/write` appends into is a ring of 1048576 spans per org:
past that the oldest are evicted, so a long-lived trace can lose its early spans
while its later ones survive, and a waterfall read is best-effort against
retention rather than a guarantee. The full plane
holds its own retention at `GET,POST /v1/o11y/settings/ttl`. Neither is
LangSmith's per-trace extended-retention upgrade, so port a retention
expectation as a setting, not as an assumption.
+49 -11
View File
@@ -12,18 +12,55 @@ your process and decide locally. Here the decision is a call, and it is
**deterministic**: the same subject and key always land in the same bucket,
because the bucket is a hash of them rather than a roll.
## Start here
Three calls: mint a key, store one definition, ask what it says for a subject.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. store one definition — the key in the URL wins over any "key" in the body
curl -sS -X PUT https://api.hanzo.ai/v1/flags/defs/new-editor \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"active": true, "filters": {"groups": [{"rollout_percentage": 25}]}}'
# 3. ask what it says for one identity
curl -sS -X POST https://api.hanzo.ai/v1/flags/decide \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"distinct_id": "u1", "person_properties": {"plan": "pro"}}'
```
Step 3 answers `{"featureFlags": {"new-editor": true}, "featureFlagPayloads":
{}, "errorsWhileComputingFlags": false}` — every flag for that identity in one
call, not one flag per call. Run it twice and `u1` gets the same answer, which
is the property a streaming SDK was buying you.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/flags` | Stores definitions and evaluates them for one identity, in-process over your own org and project store | 8 |
| `/v1/experiment` | Registers an experiment with its assignment flag, buckets subjects, and reports lift, z and p against the control arm | 7 |
| `/v1/event` | Takes the per-subject outcome rows that `POST /v1/experiment/{id}/analyze` folds into per-variant samples | 12 |
## Nouns
| LaunchDarkly | Hanzo |
|---|---|
| Project | Your org and project, from the validated key |
| Environment | `env` on the definition |
| Environment | No such field. A definition is scoped to one org and project, so a second environment is a second project |
| Feature flag | Definition — `/v1/flags/defs/{key}` |
| Variation | A variant on the definition, with a weight |
| Targeting rule | Rules on the definition |
| Percentage rollout | Weights. The subject hash decides, not chance |
| `client.variation(key, user, default)` | `POST /v1/flags/decide` |
| User / context | `subject` plus `props` on the decide call |
| User / context | `distinct_id` plus `person_properties` on the decide call |
| Audit log | `GET /v1/flags/activity` |
| Experiment | `/v1/experiment`, which composes flags for assignment |
@@ -35,16 +72,16 @@ LaunchDarkly, in-process:
const show = await client.variation('new-checkout', { key: 'user-42' }, false)
```
Hanzo:
Hanzo — one call, every flag for that identity, and you read out the one you
came for:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/flags/decide \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"key": "new-checkout",
"subject": "user-42",
"props": {"plan": "pro"}
"distinct_id": "user-42",
"person_properties": {"plan": "pro"}
}'
```
@@ -55,14 +92,15 @@ curl -sS -X PUT https://api.hanzo.ai/v1/flags/defs/new-checkout \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"env": "prod",
"variants": [
{"value": true, "weight": 10},
{"value": false, "weight": 90}
]
"active": true,
"filters": {"groups": [{"rollout_percentage": 10}]}
}'
```
The body is the definition document itself, kept byte-for-byte, so edit one by
reading it back with `GET /v1/flags/defs/{key}` and writing the whole thing —
not by rebuilding it from the parts you recognise.
Because the assignment is a hash of `(key, subject)`, a subject keeps its variant
across processes, restarts and languages without any shared state — and an
experiment reading the same flag sees the same split.
+38 -12
View File
@@ -1,28 +1,54 @@
{
"title": "Migrate",
"description": "15 vendors, each mapped to the capability that answers it.",
"description": "39 vendors, each mapped to the capability that answers it.",
"icon": "ArrowRightLeft",
"pages": [
"---[Rocket]Hosting and delivery---",
"vercel",
"netlify",
"render",
"fly",
"heroku",
"---[KeyRound]Identity---",
"auth0",
"clerk",
"workos",
"okta",
"---[Database]Data and storage---",
"supabase",
"firebase",
"planetscale",
"upstash",
"algolia",
"pinecone",
"---[Activity]Observability---",
"datadog",
"sentry",
"posthog",
"grafana",
"launchdarkly",
"---[Boxes]Agents & inference---",
"openai",
"replicate",
"modal",
"langsmith",
"composio",
"e2b",
"tavily",
"---[Radio]Messaging---",
"sqs",
"twilio",
"agentmail",
"elevenlabs",
"---[Globe]Web---",
"tavily",
"godaddy",
"---[KeyRound]Identity & secrets---",
"auth0",
"doppler",
"---[Rocket]Delivery---",
"vercel",
"launchdarkly",
"---[Building]Business---",
"---[Building]Money & business---",
"stripe",
"lago",
"docusign",
"carta"
"carta",
"godaddy",
"---[Lock]Secrets & automation---",
"vault",
"doppler",
"temporal"
]
}
+220
View File
@@ -0,0 +1,220 @@
---
title: Modal
description: Modal deploys Python functions onto GPUs and calls them from a client. Here that is /v1/functions (11 operations) for the function, /v1/sandbox (19) for a sandbox and /v1/visor (34) for the accelerators underneath — with the tenant coming from your key rather than a workspace.
---
Modal runs a Python function on a GPU and hands you a handle to call it.
`/v1/functions` (11 operations) is the same unit: publish a record, invoke it by
name, read what it did. The structural difference to know before porting
anything is that Modal deploys a **graph** — an app holding functions holding
images, volumes and secrets, all declared in one file and shipped together by
`modal deploy` — where here each of those is a separate record with its own
address that a function refers to by name. The org is not one of those names. It
comes from the validated key, so there is no workspace and no app namespace to
keep in step with the function's.
## Start here
Publish a function record, then call it by name — two requests, no deploy step.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. publish the function — name is the only required field
curl -sS -X POST https://api.hanzo.ai/v1/functions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"render","runtime":"python","code":"import sys; print(sys.stdin.read().strip().upper())"}'
# 3. invoke it — input arrives on stdin, the record comes back
curl -sS -X POST https://api.hanzo.ai/v1/functions/render/invoke \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"input":"abc"}'
```
Step 2 answers 201 with the stored record; step 3 answers 200 with a real
invocation — an id, a duration and how the run ended; what it printed is at
`GET /v1/functions/{name}/logs`. That is `modal deploy` plus `.remote()` in two
calls, with no app namespace to name and no client whose Python has to match the
container's.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/functions` | Publish once, then call it at `POST /v1/functions/{name}/invoke` | 11 |
| `/v1/sandbox` | Lease a container at `POST /v1/sandbox/lease`, then exec, read, write | 19 |
| `/v1/visor` | The GPUs a `fleet` function runs on, listed at `GET /v1/visor/gpus` | 34 |
## Nouns
### The function
| Modal | Hanzo |
|---|---|
| Workspace, from `modal token new` | Your org, taken from the validated key. Never a field in the request |
| App — `modal.App("thumbs")`, the namespace a function deploys under | No app namespace. `name` is org-unique and addresses the function directly |
| Environment — `modal environment create` | `namespace` on the record, which is cosmetic grouping. The org is the isolation key |
| `@app.function(...)` plus `modal deploy` | `POST /v1/functions` — one record carrying runtime, code, limits and secret names |
| `.remote(x)` | `POST /v1/functions/{name}/invoke`, `input` arriving on the program's stdin |
| Web endpoint and its `*.modal.run` URL | `endpoint` on the record; every function's is listed at `GET /v1/functions/triggers` |
| `gpu="H100"` | `target: "fleet"` — the org's own GPU fleet, inventoried at `GET /v1/visor/gpus` |
| `timeout=900` | `timeoutSec` — 30 by default, clamped at 900 rather than reset to the default |
| `memory=`, `cpu=` | `memoryLimit` — 256Mi by default, and the multiplier on the GB-seconds charge |
| `image=modal.Image...` | `image` — a prebuilt image, listed at `GET /v1/registry/images` |
| `secrets=[modal.Secret.from_name("s3")]` | `envNames` — the secret NAMES only. Values stay in `/v1/kms` (5) and never ride the definition |
| `modal app list` | `GET /v1/functions/deployments` — a function has one record, and that record IS its live deployment |
| `modal app logs` | `GET /v1/functions/{name}/logs` — the most recent run's output, and nothing older |
| The function's dashboard page | `GET /v1/functions/{name}` — definition, 7-day rollup, trigger, last twenty invocations, secret names, in one round-trip |
| Call history | `GET /v1/functions/{name}/invocations` — real recorded rows, newest first, never a projection |
| The usage graphs | `GET /v1/functions/metrics` — counted invocations per bucket, with the cost column left null rather than guessed |
### Sandboxes and shells
| Modal | Hanzo |
|---|---|
| `modal.Sandbox.create()` | `POST /v1/sandbox/lease` — `/v1/sandbox` is 19 operations |
| `sb.exec("pytest")` | `POST /v1/sandbox/run` — a non-zero exit is a 200 carrying a failed program |
| `sb.open(path)` for read and write | `POST /v1/sandbox/read` and `POST /v1/sandbox/write`, `data` base64 |
| `sb.terminate()` | `POST /v1/sandbox/end`; `purge` decides whether the disk goes with it |
| `modal shell` | `POST /v1/sandbox/{id}/terminal/ticket`, then the self-contained page at `GET /v1/sandbox/{id}/terminal` |
| `modal run` for a one-off script | `POST /v1/exec` — thirteen languages, a throwaway sandbox, stdout plus the files the program left |
| `modal container list`, `modal container exec` | `GET /v1/sandbox`, then `POST /v1/sandbox/{id}/exec` |
| A sandbox with a display | `class: "desktop"` on the lease, screen at `POST /v1/sandbox/{id}/screen/ticket` |
### State, schedules and the rest
| Modal | Hanzo |
|---|---|
| `modal.Volume` or `modal.NetworkFileSystem` mounted into a function | The sandbox's `project` disk for working state; object storage at `/v1/s3` (6), made with `POST /v1/provisioning/s3`. There is no second filesystem object |
| `modal.Dict` | `/v1/kv` (6) — a bucket per name, revisions per key, TTL per bucket |
| `modal.Queue` | `/v1/mq` (15) — durable streams with consumers, pulled at `POST /v1/mq/stream/{stream}/consumer/{name}/next` |
| `modal.Cron`, `modal.Period` | `/v1/tasks` (5) — a durable engine that outlives any one function record |
| `modal secret create` | `POST /v1/kms/secrets`; `GET /v1/kms/secrets` enumerates names and is structurally incapable of emitting a value |
| Building and holding that image | `POST /v1/platform/runner` builds it in-cluster and answers 202 with the build job id; `/v1/registry` (6) holds it in the org's own OCI namespace, filtered server-side so a listing can only hold your images |
| `min_containers`, an always-warm service | `POST /v1/platform/run` — an image in, a live URL back, `minScale` floor and `maxScale` ceiling, idempotent by name |
| Modal's GPU classes and pricing page | `GET /v1/visor/compute/sizes` for what can be launched, `GET /v1/visor/fleet` for what you hold |
| Adding capacity | `POST /v1/visor/machines`, or your own host dialing in with `hanzo link` |
| The GPU work queue | `GET /v1/visor/fleet/jobs` — per-node lanes plus a shared any-GPU lane, cancelled at `POST /v1/visor/fleet/jobs/{id}/cancel` |
| Training and fine-tuning runs you put on Modal | `POST /v1/ai/finetune/jobs`, then `POST /v1/ai/finetune/deploy` |
| Serving your own weights behind an endpoint | `POST /v1/ml/models`, inference at `POST /v1/ml/models/{name}/predict` |
| Calling a hosted model instead of hosting one | `/v1/ai` (272) — the Zen family, served on the same key |
| Modal Notebooks | A `dev` sandbox with the browser terminal at `GET /v1/sandbox/{id}/terminal`, or Hanzo Desktop locally |
| Logs, traces and dashboards | `/v1/o11y` (381) and `/v1/metrics` (11), queried at `GET /v1/metrics/logs/query` |
| What you have spent | `GET /v1/billing/balance` and `/v1/usage` (5) |
## The call
Modal — a file, a deploy, and a client that looks the function up:
```python
# thumbs.py
import modal
app = modal.App("thumbs")
image = modal.Image.debian_slim().pip_install("pillow")
@app.function(image=image, gpu="H100", timeout=900,
secrets=[modal.Secret.from_name("s3")])
def render(seed: str) -> str:
return seed.upper()
# $ modal deploy thumbs.py
# then, from any other process:
# f = modal.Function.from_name("thumbs", "render")
# f.remote("abc")
```
Hanzo — publish the record, then invoke it:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/functions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "render",
"runtime": "python",
"target": "fleet",
"timeoutSec": 900,
"memoryLimit": "4Gi",
"envNames": ["S3_KEY"],
"code": "import sys; print(sys.stdin.read().strip().upper())"
}'
curl -sS -X POST https://api.hanzo.ai/v1/functions/render/invoke \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"input":"abc"}'
```
One name, `render`, and it is org-unique — there is no workspace, no app and no
deployment id to keep aligned with it, because which org owns `render` is
decided by the key rather than by a field anyone can write. The argument goes in
on stdin and the answer is the invocation record, so nothing is pickled and the
caller's Python and library versions never have to match the container's; `curl`
is a first-class client.
The record comes back whatever happened: 200 when your code ran clean, 502 when
it ran and failed, 503 when this deployment has no sandbox to run code in. A
failed run still has an id, a duration and the status code your own program
answered with, instead of an exception on one path and an error envelope on the
other. Billing follows the same split — the flat per-invocation fee is
authorized against the org's prepaid balance **before** any compute starts, so
an unfunded org gets a 402 and nothing executes, and the GB-seconds debit lands
after the run. A sandbox that was unreachable ran no billable compute and is not
charged; a program that exited non-zero is, because that is a successful
invocation of a failing program.
Reading it back is one call, not a tour of the dashboard:
```bash
curl -sS https://api.hanzo.ai/v1/functions/render \
-H "Authorization: Bearer $HANZO_API_KEY"
```
That answers the definition, the 7-day rollup, the trigger, the twenty most
recent invocations and the names of the secrets it mounts. Rollup fields are
absent rather than zero when the function has not run in the window, so a
console draws an em dash instead of a fabricated 0.
## What does not carry
**Arguments are bytes, not Python objects.** Modal serializes your call
arguments and the function's closure, which is why the client and the container
have to agree on versions. `input` here is opaque to this surface and lands on
stdin; structured output is whatever your program prints. A typed signature does
not port — write the encode and decode yourself.
**`.map()` and `.spawn()` have no counterpart.** Modal fans a function across an
iterable and hands back a `FunctionCall` you poll. `POST /v1/functions/{name}/invoke`
is synchronous and answers when the run is over. Fan-out is a queue you drive:
`/v1/mq` (15) for the work, one invoke per item, and `GET /v1/visor/fleet/jobs`
to watch what the GPU lanes are holding.
**`gpu="H100:8"` is not a field on a function.** `target` is `sandbox` or
`fleet`, and `fleet` means the org's own accelerators — the ones at
`GET /v1/visor/gpus`, added by `POST /v1/visor/machines` or by a host running
`hanzo link`. You choose the fleet, not the card, and `fleet` accepts
`runtime: python` only.
**No image builder DSL.** `modal.Image.debian_slim().pip_install(...)` builds and
caches a layer per method call. `image` here names an image that already exists,
built by `POST /v1/platform/runner` or by your own CI. Nothing is installed for
you at publish time; the image is the environment.
**A function mounts no volume.** There is no `volumes=` on the definition and no
`.commit()`. Persistent working state is the sandbox's `project` disk, and
anything a function must read or write goes through `/v1/s3` (6) or `/v1/kv` (6)
from inside the code.
**Warm pools are a different plane.** A function record carries a timeout and a
memory limit, and no replica floor — it starts when it is called. An always-on
autoscaled service is `POST /v1/platform/run`, which takes `minScale` and
`maxScale` and is idempotent by name. Port a Modal function with
`min_containers` there and keep `/v1/functions` for work that begins on a
request.
+239
View File
@@ -0,0 +1,239 @@
---
title: Netlify
description: Netlify serves a built directory, runs functions beside it, collects form posts and signs visitors in. Here that is /v1/projects (27) for the site, /v1/functions (11) for the code and /v1/iam (159) for the people — and a deploy is one call, because the bytes never pass through the API.
---
Netlify does five things: it serves a built directory, builds that directory
from a repo, runs functions beside it, collects form posts, and signs visitors
in. Three capabilities answer those — `/v1/projects` (27) is the site,
`/v1/functions` (11) is the code, `/v1/iam` (159) is the people — and a form
post lands in the project's own Base data space, which is provisioned when the
project is created.
The structural difference to plan for is the deploy. Netlify negotiates a
file-digest manifest and then streams every changed file back through its API,
so one deploy is one POST, N PUTs and a poll. Here it is one archive POST — or,
for a site too large for one body, one call that hands back a scoped storage
grant and one that says the build finished.
## Start here
Create the project to claim the slug, then send it a built directory — the site
is live on the second call.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. claim the slug — it is the host label, the storage prefix and the handle
curl -sS -X POST https://api.hanzo.ai/v1/projects \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"Acme WWW","slug":"acme-www"}'
# 3. take a built directory live at https://acme-www.hanzo.app
curl -sS -X POST https://api.hanzo.ai/v1/projects/acme-www/deploy \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/octet-stream' \
--data-binary @dist.zip
```
Step 3 answers with the finished deployment and the URL it serves at — no digest
manifest, no PUT per file, no poll. Neither call names a team or an account
slug, because the key already carried the org.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/projects` | The site: slug, archive deploy, releases, custom domains, cache purge | 27 |
| `/v1/functions` | The code beside it: node, python or deno, invoked by name | 11 |
| `/v1/iam` | The people: signup, OAuth, SCIM, roles, invitations | 159 |
## Nouns
The left column is Netlify's product surface; the right is the capability that
answers it.
### The site
| Netlify | Hanzo |
|---|---|
| Team, addressed by account slug | Your org, taken from the validated key |
| Site, addressed by `site_id` | Project, addressed by `slug` — `POST /v1/projects` |
| `POST /api/v1/sites/{site_id}/deploys` with a zip | `POST /v1/projects/{slug}/deploy` — same archive, one call |
| Digest deploy, then a PUT per required file | `POST /v1/projects/{slug}/deployments`, then `.../complete` |
| Deploy history | `GET /v1/projects/{slug}/deployments` |
| The published deploy | The active release — `GET /v1/projects/{slug}/releases` |
| Restore an earlier deploy | `POST /v1/projects/{slug}/releases/{release}/activate` |
| `*.netlify.app` subdomain | `<slug>.hanzo.app`, the same slug as a hostname label |
| Custom domain and its DNS check | `POST /v1/projects/{slug}/domains`, then `/{host}/verify` |
| Cache invalidation | `POST /v1/projects/{slug}/purge` — no redeploy, origin untouched |
| Snippet injection | `GET /v1/projects/tags` — the first-party tag set, per site |
| Open Graph preview image | `GET /v1/projects/{slug}/shot` — keyed by the deployment |
| Deploy-to-Netlify button, starter templates | `POST /v1/projects/fork` |
| Netlify Drop | `POST /v1/projects/sites/deploy` — a file manifest, no archive |
### Build and functions
| Netlify | Hanzo |
|---|---|
| Build from a linked repo | `repo` on the project; a push to it rebuilds |
| Build hook URL | `POST /v1/platform/hook` — HMAC over the raw bytes, checked before parsing |
| A build whose output needs a server | `/v1/platform` (37) — build, deploy, promote, rollback |
| Build environment variables | `PUT /v1/platform/projects/{project}/apps/{app}/env` |
| Sensitive values in a build env | `/v1/kms` (5) — sealed at rest, listed by name only |
| Deploy log | `GET /v1/platform/projects/{project}/apps/{app}/deployments/{id}/logs` |
| Deploy notification to Slack or a URL | `/v1/webhook` (8), with `deliveries` and a `test` |
| Serverless Function | `POST /v1/functions` — node, python or deno |
| Edge Function, Deno runtime | Same surface, `runtime` set to `deno` |
| `/.netlify/functions/{name}` | `POST /v1/functions/{name}/invoke` |
| Function log, function metrics | `GET /v1/functions/{name}/logs`, `GET /v1/functions/metrics` |
| Scheduled function | `/v1/tasks` (5) — a durable engine that outlives any one deploy |
| Netlify Blobs | `/v1/kv` (6) for values with history, `/v1/s3` (6) for objects |
### Forms, identity and the rest
| Netlify | Hanzo |
|---|---|
| A form declared with `data-netlify` in the HTML | A form definition — `POST /v1/ai/forms` |
| Form submissions | The project's Base data space — `space` on the project row |
| Reading submissions | `GET /v1/ai/forms/data` |
| Submission notification email | `POST /v1/notify/send/email` |
| Submission posted to a URL | `/v1/webhook` (8) |
| A submission that is a sales lead | `POST /v1/crm/contacts` |
| Identity signup and login | `POST /v1/iam/signup`, `POST /v1/iam/login` |
| GoTrue token exchange | `POST /v1/iam/oauth/token` |
| Identity external providers | `GET /v1/iam/oauth/authorize` |
| Identity invite | `/v1/iam/invitations` |
| Roles in the token, checked per request | `/v1/iam/roles`, then `POST /v1/authz/check` |
| Netlify Analytics | `/v1/event` (12), with the browser tag at `GET /v1/event/tag.js` |
| Log drains | `/v1/o11y` (381) — `GET /v1/o11y/logs/livetail` is the tail |
| Split testing across branches | `/v1/experiment` (7) — `GET /v1/experiment/{id}/assign` |
| Rate limiting, CORS, accepted methods | `PUT /v1/gateway/config` |
| A domain you buy and hold | `/v1/domain` (7) |
Split testing is where the two models diverge most. Netlify splits traffic
between branch deploys and remembers the choice in a cookie. Here bucketing is a
deterministic hash of the subject you pass, evaluated in-process against your own
org's flag definitions — no cookie, no shared KV, no network hop, and the same
subject gets the same arm on every call for as long as the definition is
unchanged.
## The call
Netlify, deploying a built directory through the API:
```bash
# 1. open a deploy with a sha1 per file; Netlify answers with the ones it wants
curl -sS -X POST "https://api.netlify.com/api/v1/sites/$SITE_ID/deploys" \
-H "Authorization: Bearer $NETLIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"files":{"/index.html":"907d14fb…","/assets/app.css":"5f2b91…"}}'
# 2. one PUT per required file, through their API
curl -sS -X PUT "https://api.netlify.com/api/v1/deploys/$DEPLOY_ID/files/index.html" \
-H "Authorization: Bearer $NETLIFY_TOKEN" \
-H 'Content-Type: application/octet-stream' \
--data-binary @dist/index.html
# 3. poll until state is "ready"
curl -sS "https://api.netlify.com/api/v1/deploys/$DEPLOY_ID" \
-H "Authorization: Bearer $NETLIFY_TOKEN"
```
Hanzo, the same site:
```bash
curl -sS -X POST "https://api.hanzo.ai/v1/projects/acme-www/deploy" \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/octet-stream' \
--data-binary @dist.zip
```
The answer is the finished deployment with the URL it serves at, so there is no
state to poll.
Past the 16 MiB edge body limit the bytes leave the API entirely:
```bash
ID=$(curl -sS -X POST https://api.hanzo.ai/v1/projects/acme-www/deployments \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"commit":"'"$GIT_SHA"'"}' | jq -r .id)
# the 202 carries bucket, prefix and a 30-minute presigned POST policy that S3
# confines to this deployment's prefix — CI writes its own files against it
curl -sS -X POST "https://api.hanzo.ai/v1/projects/acme-www/deployments/$ID/complete" \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"status":"live","keys":["index.html","assets/app.css"]}'
```
Rolling back is one statement, and nothing is rebuilt:
```bash
curl -sS -X POST \
"https://api.hanzo.ai/v1/projects/acme-www/releases/$REL/activate" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Three identifiers become one. Netlify addresses the site by `site_id`, the
deploy by `deploy_id`, and environment variables by the account slug with the
site id as a query parameter — four things to keep in step across CI. Here the
slug is the site, the public host label and the object-store key segment at
once, and the org comes from the key, so it is never a field in the request.
The digest negotiation exists because Netlify's API sits in the byte path. Ours
does not: the grant is prefix-scoped and expires in 30 minutes, so a build writes
its own files and holds no standing bucket credential. That grant authorizes
writes only, which is why the completion carries `keys` — cloud reconciles the
prefix against that manifest, so a page deleted from the build actually stops
serving without a build ever being able to delete.
A release id is a SHA-256 over its own sorted manifest. Republishing unchanged
bytes is the same release with no copy at all, and activating an older id is a
pointer flip against bytes that never moved.
## What does not carry
**The build is a hint, not a script.** A linked `repo` does build here — a push
to its `branch` rebuilds and CI completes the deployment — but `framework` is a
closed set, so Netlify's build image, its `[build]` command block and its build
plugins have no counterpart. Anything past a known framework you build in your
own CI and send as a directory. An app that needs a server is `/v1/platform`
(37), which builds, deploys, promotes and rolls back.
**No `_redirects` or `_headers` file.** Netlify compiles both into edge rules at
deploy time. The one per-project edge knob here is `cacheControl`, the
`Cache-Control` policy the HTML is served under; assets are content-addressed
and are not governed by it. Redirect and header logic that must run per request
belongs in a function, or in `/v1/ingress` (18) if you are on the app plane.
**No branch deploy, and no per-pull-request hostname.** Netlify mints
`deploy-preview-42--site.netlify.app` for every PR without being asked. A static
project has one active release at a time. The preview that exists is
`POST /v1/platform/projects/{project}/apps/{app}/preview`, and it belongs to the
app plane, not this one.
**No image transform in the request path.** `/.netlify/images` resizes and
re-encodes on the fly. Nothing here rewrites an image as it is served — pre-size
at build. `POST /v1/images/generations` makes new images; it does not resize the
ones you deployed.
**The Identity widget does not port; the identity does.** GoTrue answers under
your own site at `/.netlify/identity`, so every call is same-origin and the
widget assumes it. `/v1/iam` is a real provider at its own address, with OAuth,
SCIM at `/v1/iam/scim/v2/Users`, WebAuthn and MFA. Rewrite the
`netlifyIdentity.*` calls; the users, roles and external providers all have a
home.
**Form spam filtering is a policy, not a checkbox.** Netlify runs a honeypot and
Akismet on every submission by default. Here a submission lands in the project's
data space and scoring it is `POST /v1/risk/score` against a policy you write at
`PUT /v1/risk/policy`. That is more work on day one and a rule you can read on
day one hundred.
+229
View File
@@ -0,0 +1,229 @@
---
title: Okta
description: Okta signs a workforce in, provisions it, challenges it and gates it with policy. Here that is /v1/iam — 159 operations covering OIDC, SAML, SCIM and MFA — plus /v1/authz/check for the decision.
---
Okta does four jobs for a workforce: sign people in, keep the directory in step
with the system of record, ask for a second factor, and decide what is
permitted. `/v1/iam` (159 operations) answers the first three at the addresses
the standards give them — `oauth/token`, `scim/v2/Users`, `mfa/setup/initiate` —
and the fourth splits in two.
The structural difference to know before you port anything: **there is no policy
object here.** Okta's sign-on, password and MFA policies are first-class
resources, each a priority-ordered stack of rules resolved per sign-in. The same
constraints live as columns on two rows — the organization and the application —
and the authorization question is a separate call, `POST /v1/authz/check`, which
takes a subject, an object and an action and answers `allow`. Nothing is
ordered, so there is no precedence to reason about.
## Start here
Mint a key, provision a person through SCIM, then ask the authorization question
about them.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. provision a person — SCIM, the body Okta's Lifecycle Management already sends
curl -sS -X POST https://api.hanzo.ai/v1/iam/scim/v2/Users \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/scim+json' \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "ada",
"emails": [{"value": "[email protected]", "primary": true}],
"active": true
}'
# 3. ask what that person may do — the half Okta answered with a policy stack
curl -sS -X POST https://api.hanzo.ai/v1/authz/check \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"sub": "acme/ada", "obj": "repo:web", "act": "write"}'
```
Step 3 asks about `acme/ada` — the org from your key and the `userName` you sent
in step 2 — so no id is carried between the calls, and it refuses until a grant
exists, which `POST /v1/iam/memberships` writes. That is the shape of the port:
the directory is one write, the decision is a separate question, and neither is a
rule in an ordered stack.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/iam` | Directory, SCIM provisioning, OIDC and SAML sign-in, sessions, MFA — Okta's Users, Apps, Factors and Lifecycle APIs at one address | 159 |
| `/v1/authz` | The decision a sign-on policy used to render: subject, object and action in, `allow` out | 3 |
| `/v1/auto` | Flows, connectors and runs you can resume — where Okta Workflows, group rules and inline hooks land | 17 |
## Nouns
Okta is four products under one console. The left column is that surface; the
right is what answers it.
### Directory and lifecycle
| Okta | Hanzo |
|---|---|
| Org, `dev-12345.okta.com` | Your org, taken from the validated key. Never a field in the request |
| Universal Directory user | User — `/v1/iam/users`, addressed `{owner}/{name}` |
| User id `00u1a2b3c4d5e6f7g8h9` | `{owner}/{name}` — the org from your key and the username you chose |
| Group | Role — `/v1/iam/roles`, whose row carries `users` and `groups` |
| Assign an app or a group to someone | `POST /v1/iam/memberships` — repeating it changes nothing |
| Lifecycle Management (SCIM provisioning) | `/v1/iam/scim/v2/Users` — eleven operations, not a SKU |
| `POST /api/v1/users/{id}/lifecycle/deactivate` | `DELETE /v1/iam/scim/v2/Users/{owner}/{name}`; sessions stop immediately |
| Suspend without deleting | `isForbidden` on the user, written with `PUT /v1/iam/users/{owner}/{name}` |
| Self-service registration, activation mail | `/v1/iam/invitations` — a code carrying a role, an expiry and a seat count |
| Profile mappings and attribute transforms | `userMapping` on the provider — `/v1/iam/providers` |
| System Log | `/v1/audit` for the platform trail, `/v1/iam/audit-logs` for the identity one |
| API token (SSWS) | `POST /v1/iam/service-accounts`, then `/v1/iam/service-accounts/{name}/keys` |
### Sign-in
| Okta | Hanzo |
|---|---|
| Okta as OIDC provider to your app | `/v1/iam/oauth/authorize`, `/v1/iam/oauth/token`, `/v1/iam/oauth/userinfo` |
| Introspection and revocation | `POST /v1/iam/oauth/introspect`, `POST /v1/iam/oauth/revoke` |
| `/.well-known/openid-configuration` | The same path, under `/v1/iam` |
| Okta as SAML IdP to your app | The application row — `samlAttributes`, `samlReplyUrl`, `samlHashAlgorithm` |
| Inbound IdP: SAML, OIDC, ADFS, social | Provider — `POST /v1/iam/providers`, then switched on per application |
| App integration (OIDC, SAML, SWA) | Application — `/v1/iam/applications`, one client id and secret each |
| Custom Authorization Server | One issuer. Scopes and claims are `customScopes` and `tokenAttributes` on the application |
| Okta FastPass, WebAuthn | `/v1/iam/webauthn/signin/begin`, then `/v1/iam/webauthn/signin/finish` |
| Device Authorization grant | `POST /v1/iam/oauth/device` |
| Sessions API | `/v1/iam/sessions` reads them; `DELETE /v1/iam/sessions/{owner}/{name}/{application}` ends one |
| Key rollover | `/v1/iam/certs` stages the next `kid`, `/v1/iam/.well-known/jwks` publishes it |
| Sign-In Widget | [hanzo.id](https://hanzo.id), branded by domain. `GET /v1/iam/auth/methods` says which buttons to draw |
### Factors, policy, and the rest of the console
| Okta | Hanzo |
|---|---|
| Enroll a factor, then activate it | `POST /v1/iam/mfa/setup/initiate`, then `POST /v1/iam/mfa/setup/enable` |
| Admin resets a factor after a lost phone | `DELETE /v1/iam/mfa` — naming no factor turns off all of them |
| Default factor | `POST /v1/iam/mfa/preferred` — only a factor the account actually holds |
| Step-up after a federated sign-in | `POST /v1/iam/oauth/federation/mfa` |
| Recovery question | Recovery codes, returned once by the first `mfa/setup/enable` |
| Password policy | `passwordOptions` and `passwordExpireDays` on the organization |
| Sign-on policy, lockout half | `failedSigninLimit` and `failedSigninFrozenTime`, on the org and the application |
| Network zone | `ipWhitelist` and `ipRestriction`, on the same two rows |
| Session lifetime | `expireInHours`, `refreshExpireInHours`, `cookieExpireInHours` on the application |
| Authorization decision | `POST /v1/authz/check` — subject, object, action in, `allow` out |
| ThreatInsight, Behavior Detection | `/v1/risk` (11) — a score citing the policy version it was decided under |
| Event Hooks | `/v1/webhook` (8) — signing secret returned once, deliveries counted per endpoint |
| Okta Workflows | `/v1/auto` (17) — flows, connectors, and runs you can resume |
| Access Gateway | `/v1/ingress` (18) — routes, header middlewares and TLS in front of the app |
| Admin Console, and acting as a tenant | console.hanzo.ai; an operator steps in with `POST /v1/iam/assume`, which keeps naming the operator |
`{owner}/{name}` is the address of everything in identity: the org, then the
thing. It is why no request needs an org field, and why the second call below is
written from what you already had.
## The call
Okta, hiring somebody and giving them an app. Two calls, and the second is
written from the id the first returned:
```bash
curl -sS -X POST "https://$OKTA_DOMAIN/api/v1/users?activate=true" \
-H "Authorization: SSWS $OKTA_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"profile": {
"firstName": "Ada", "lastName": "Lovelace",
"email": "[email protected]", "login": "[email protected]"
}
}'
# => {"id":"00u1a2b3c4d5e6f7g8h9", "status":"ACTIVE", ...}
curl -sS -X POST "https://$OKTA_DOMAIN/api/v1/apps/0oa8x1qk2wPqR3v0h7/users" \
-H "Authorization: SSWS $OKTA_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"id":"00u1a2b3c4d5e6f7g8h9","scope":"USER"}'
```
Hanzo, the same two steps:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/iam/scim/v2/Users \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/scim+json' \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "ada",
"name": {"givenName": "Ada", "familyName": "Lovelace"},
"emails": [{"value": "[email protected]", "primary": true}],
"active": true
}'
curl -sS -X POST https://api.hanzo.ai/v1/iam/memberships \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"user": "acme/ada", "org": "acme"}'
```
Nothing is carried between the two calls. `acme/ada` is the org from the key and
the `userName` you sent, so the second request is writable before the first one
returns — there is no minted `00u…` to store, and therefore no pair of
identifiers to hold in step when your HR record and the directory disagree.
Repeating the second call changes nothing, so the reconcile loop and the first
grant are the same request.
The decision is its own call, and it is a function of the grants rather than of
which rule matched first:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/authz/check \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"sub": "acme/ada", "obj": "repo:web", "act": "write"}'
```
The reply is `allow`, with `sub`, `obj` and `act` echoed beside it, so a logged
or cached decision carries the question it answered. The org comes from the
credential and picks that tenant's enforcer; a request arriving with no org is
refused rather than answered from a shared default set.
## What does not carry
**Conditional sign-on rules have nowhere to live.** Okta's policy holds several
rules with conditions — this network, that group, this client — and priorities
that resolve them. Here `failedSigninLimit`, `ipWhitelist` and `mfaItems` are
columns on the organization and the application. Two rows, no ordering, no
conditions. "Require MFA only from outside the office" does not port: `ipRestriction`
is an allow or a refusal, not a step-up trigger.
**No Inline Hooks.** Okta calls your endpoint mid-transaction to add a claim,
veto a registration or import a password, and waits for the answer. Nothing here
runs customer code inside a sign-in — a login that can call out is a login that
can be made to hang. Put the work in `/v1/auto`, where a failure does not take
the sign-in with it.
**Group rules do not re-evaluate.** Okta assigns membership from an expression
over profile attributes and re-runs it when an attribute changes.
`POST /v1/iam/memberships` is a write. The replacement is a flow in `/v1/auto`
triggered by the change — an explicit job you can read the runs of, rather than
an invisible one.
**SCIM is Users only.** Eleven operations under `/v1/iam/scim/v2/`: six on
`Users`, five of discovery. There is no `/Groups`, so Okta's group push has no
receiver — roles and memberships are written through `/v1/iam/roles` and
`/v1/iam/memberships`. In exchange, `PUT` replaces only the attributes SCIM
describes, so a nightly sync cannot strip somebody's second factor.
**No password-hash import.** Okta accepts a foreign hash with its algorithm and
salt, so a migration is invisible to the user. `POST /v1/iam/admin/users/upsert`
hashes a password you send and keeps the current one when you omit it; no route
takes a hash you already hold. The drain instead of a reset is to register Okta
as an inbound provider — `okta` is a column on the user record, beside `adfs`,
`azuread` and `ldap` — and let people link as they sign in.
**Access certification is reads, not a campaign.** `/v1/iam/memberships` answers
who may act where, `/v1/audit` what they did, `/v1/trust/controls` which control
you are evidencing. Nothing schedules a quarterly review or chases the
reviewers; that is a flow you build on `/v1/auto`.
+235
View File
@@ -0,0 +1,235 @@
---
title: OpenAI
description: OpenAI serves models and the platform grown around them. Here that is /v1/ai (272 operations) for the models, their files and their routing, and /v1/agents (37) for the assistant that runs, with the OpenAI-shaped inference routes at the addresses your client already calls.
---
OpenAI is a model API with a platform grown around it: chat and embeddings at the
centre, then assistants, files, vector stores, fine-tuning, batch and realtime.
Two capabilities answer it. `/v1/ai` (272 operations) holds the models, the files
they read, the stores they search, fine-tuning and the router; `/v1/agents` (37)
holds the assistant, its runs and its live sessions. The OpenAI-shaped routes are
at the addresses your client already calls — `POST /v1/chat/completions`,
`/v1/embeddings`, `/v1/responses`, `/v1/images/generations`, `/v1/audio/speech` —
so for plain inference the base URL is the whole edit. The structural difference
sits above that: an assistant here is one identifier and one call to run, not
three identifiers and a poll.
## Start here
Mint a key, change the host, and the request body you already send works
unchanged.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. the call you already make, at a different host
curl -sS -X POST https://api.hanzo.ai/v1/chat/completions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"zen5","messages":[{"role":"user","content":"summarise Q3"}]}'
# 3. the catalogue the model name in step 2 comes from
curl -sS https://api.hanzo.ai/v1/models
```
Step 2 is your existing request with a new host and a new key — no field in the
body changed, so plain inference is a base URL and a secret. Step 3 is where the
names it accepts are published, and it answers with no `Authorization` header at
all, which is why a 200 there says nothing about your key and step 2's answer
does.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/ai` | Models, files, stores, RAG, fine-tuning, memory and the router | 272 |
| `/v1/agents` | The assistant — create it, run it, stream it, list every run in the org | 37 |
| `/v1/tools` | The named tools an agent may call, and the MCP servers behind them | 19 |
## Nouns
Four tables, because OpenAI is four products stacked on one another.
### Inference
| OpenAI | Hanzo |
|---|---|
| `POST /v1/chat/completions` | The same address, `POST /v1/chat/completions` |
| `POST /v1/responses` | `POST /v1/responses` — converted, then completed by the chat path |
| `POST /v1/completions` | `POST /v1/completions` |
| `POST /v1/embeddings` | `POST /v1/embeddings`, or `zen-embedding` at 8K context |
| `GET /v1/models` | `GET /v1/models` — 432 models from 57 providers, 14 of them Zen |
| `POST /v1/images/generations` | `POST /v1/images/generations` |
| `POST /v1/audio/speech` and `/v1/audio/transcriptions` | The same two addresses, `/v1/audio` (5) |
| `POST /v1/moderations` | `zen-guard`, a 128K-context safety classifier, called like any other model |
| No rerank endpoint | `POST /v1/rerank` — a native provider when the model routes to one, cosine over embeddings otherwise |
| `OpenAI-Organization` and `OpenAI-Project` headers | Neither. The org is the validated key's, and there is no field for it |
| A form to request access to a gated model | `POST /v1/models/{model}/access`, and `GET` for your own standing |
| The Anthropic-shaped client you also maintain | `POST /v1/messages`, with `POST /v1/messages/count_tokens` beside it |
### The assistant
| OpenAI | Hanzo |
|---|---|
| Assistant | Agent — `POST /v1/agents`, carrying `model`, `instructions` and `tools` |
| `assistant_id` | The `agent_…` id or the org-unique name you chose. Either resolves the same agent |
| Thread, and `thread_id` | `conversationId` on `POST /v1/agents/chat`, listed at `/v1/agents/chat/conversations` |
| Run, then poll `GET /v1/threads/{t}/runs/{r}` | `POST /v1/agents/{ref}/run` — answers the finished run |
| Reading the answer back off the thread | The run's `output`, in that same response |
| A run's per-assistant history | `GET /v1/agents/{ref}` — the agent plus its 20 most recent runs |
| No feed across assistants | `GET /v1/agents/runs` — every run in the org, newest first, filterable by status |
| Tool of type `function` | A name from `GET /v1/tools` (19), dispatched with `POST /v1/tools/call` |
| Tool of type `code_interpreter` | `POST /v1/sandbox/run` (19) — exit code, stdout and stderr returned as data |
| Tool of type `file_search` | `POST /v1/ai/rag/query` over what `POST /v1/ai/rag/ingest` indexed |
| A remote MCP server named on a response | `POST /v1/tools/mcp/servers` registers it; `POST /v1/mcp` is the door |
| Streaming run events | `GET /v1/agents/sessions/stream` — server-sent events, filtered on tenant before fan-out |
| Scheduling a run yourself | `executionMode: long-running` with a 5-field cron, parsed at create |
### The data the model reads
| OpenAI | Hanzo |
|---|---|
| File | `POST /v1/ai/files`, bytes through `POST /v1/ai/files/upload` |
| Listing what you uploaded | `GET /v1/ai/files`, scoped to the caller |
| Vector store | Store — `POST /v1/ai/stores`, vectors at `/v1/ai/stores/{owner}/{name}/vectors` |
| Chunking and embedding you drive yourself | `POST /v1/ai/rag/ingest` — parse, chunk, embed, into the vector and keyword index at once |
| A connector you write to pull in Drive or Notion | `/v1/knowledge/connectors` (6), synced |
| Fine-tuning job | `POST /v1/ai/finetune/jobs`, status refreshed on `GET` |
| Serving the fine-tuned result | `POST /v1/ai/finetune/deploy` — registers it as a routable model name |
| Prompt object, referenced by id and version | `POST /v1/prompt` (6) — a repeated name appends a version rather than overwriting |
| Per-user context you keep in your own database | `POST /v1/ai/memory/remember`, read with `GET /v1/ai/memory/recall` |
### Around the model
| OpenAI | Hanzo |
|---|---|
| Project, and who is in it | Org, from `/v1/iam` (159) |
| Service account | `POST /v1/iam/service-accounts/{name}/keys` |
| API key | `/v1/iam/keys` |
| Usage and Costs in the dashboard | `GET /v1/usage/summary` and `GET /v1/billing/usage` (45) |
| Rate-limit tier | `GET /v1/billing/tier` |
| Spend limit and its email | `/v1/billing/alerts` |
| Evals — dataset, grader, run | `/v1/eval` (16): `datasets`, `evaluators`, `runs`, `traces` |
| Webhooks | `/v1/webhook` (8). The signing secret is returned once, on create |
| Web search folded into a response | `POST /v1/websearch` (7), a call of its own |
| Your own provider key, used by you | `POST /v1/ai/connections` — sealed into KMS, never echoed back |
| Model choice hardcoded in your caller | `PUT /v1/ai/router/policy`, or the model name `router:general` |
## The call
OpenAI's assistant, end to end. Six requests and three identifiers:
```bash
ASSISTANT=$(curl -sS https://api.openai.com/v1/assistants \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H 'OpenAI-Beta: assistants=v2' -H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","name":"helper","instructions":"be terse"}' | jq -r .id)
THREAD=$(curl -sS -X POST https://api.openai.com/v1/threads \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H 'OpenAI-Beta: assistants=v2' | jq -r .id)
curl -sS https://api.openai.com/v1/threads/$THREAD/messages \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H 'OpenAI-Beta: assistants=v2' -H 'Content-Type: application/json' \
-d '{"role":"user","content":"summarise Q3"}'
RUN=$(curl -sS https://api.openai.com/v1/threads/$THREAD/runs \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H 'OpenAI-Beta: assistants=v2' -H 'Content-Type: application/json' \
-d "{\"assistant_id\":\"$ASSISTANT\"}" | jq -r .id)
# poll this until .status stops saying queued or in_progress
curl -sS https://api.openai.com/v1/threads/$THREAD/runs/$RUN \
-H "Authorization: Bearer $OPENAI_API_KEY" -H 'OpenAI-Beta: assistants=v2'
# then read the answer back off the thread
curl -sS https://api.openai.com/v1/threads/$THREAD/messages \
-H "Authorization: Bearer $OPENAI_API_KEY" -H 'OpenAI-Beta: assistants=v2'
```
`POST /v1/threads/runs` collapses the middle three into one. The poll and the
read-back stay.
Hanzo. Two requests and one identifier, which is the name you picked:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/agents \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"helper","model":"zen5","instructions":"be terse"}'
curl -sS -X POST https://api.hanzo.ai/v1/agents/helper/run \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"input":"summarise Q3"}'
```
Plain inference is the base URL and nothing else:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/chat/completions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"zen5","messages":[{"role":"user","content":"summarise Q3"}]}'
```
The run call answers the run — id, status, model, `output`, duration, error —
because the work is finished when the response is written. There is nothing to
poll, so there is no window in which a reader can see a stale one. The name is
the address, so `helper` is reachable the moment create returns and
nothing has to be stored client-side to find it again; the org is the key's, so
an agent in another tenant is a 404 rather than a 403, which is a property of the
lookup rather than of a check that could be skipped. The balance is authorised
before any inference, so an unfunded org gets 402 and no free compute, and only a
successful run is billed — attributed to the model that actually answered, which
after a failover is not the one it started on.
## What does not carry
**No Batch API, and no batch discount.** OpenAI's `/v1/batches` takes a JSONL
file, promises a 24-hour window and prices at half. There is no equivalent
address and no discounted asynchronous tier: a fan-out is a loop you write, and
`/v1/mq` (15) gives that loop a durable queue when the work must survive a
restart. Per-token price is what `GET /v1/models` publishes, the same for ten
thousand calls as for one.
**Realtime is not a socket.** OpenAI's Realtime API is a bidirectional WebSocket
carrying audio both ways with server-side turn detection. Speech here is request
and response — `POST /v1/audio/transcriptions` in, `POST /v1/audio/speech` out —
and the sockets that do exist carry sessions and streams, not audio. For a spoken
agent on a phone line, `POST /v1/tel/calls` dials one and names the agent that
answers it.
**Conversation state and runs are two surfaces, not one.** OpenAI keeps the
messages on a thread and every run against them in the same place.
`POST /v1/agents/{ref}/run` is one shot: it composes the agent's stored
instructions with your `input` and returns. Multi-turn state lives on
`POST /v1/agents/chat` with a `conversationId`, listed at
`/v1/agents/chat/conversations`. Pick by whether the turn needs the last one.
**A tool is a name the org holds, not a schema you send.** OpenAI takes the
function definition in the request body, so the callable set is whatever that
request says. An agent's `tools` is a list of names resolved from `GET /v1/tools`
at run time, and omitting it grants none — that default is the agent's authority
and nothing widens it later. Your own function goes in by publishing an MCP
server and registering it with `POST /v1/tools/mcp/servers`; it then appears in
the same listing.
**Fine-tuning is still a job you poll.** No parity claim here.
`POST /v1/ai/finetune/jobs` submits a training job and `GET /v1/ai/finetune/jobs`
refreshes live status, the same shape you already have. What differs is the end:
`POST /v1/ai/finetune/deploy` serves the checkpoints and registers the result as a
routable model name, so the trained model answers on `/v1/chat/completions` like
any other. A submit that fails is saved with status `failed` and the reason,
never as a job that quietly never starts.
**`GET /v1/models` does not authenticate.** OpenAI's model list sits behind your
key. This one is public and says so out loud: the catalogue is identical for
every caller, and an `Authorization` header is read for exactly one thing —
annotating gated models with your own standing. A dead key gets 200 here, so it
is the wrong probe for "is my auth working". Use a route that acts on your org.
+229
View File
@@ -0,0 +1,229 @@
---
title: Pinecone
description: Pinecone is a managed vector database with a control plane, a per-index data host and a namespace on every call; here that is /v1/ai (272) for the vectors and the ingest path, /v1/index (17) for the lexical half, and one hostname with the tenant taken from the key.
---
Pinecone stores vectors and returns the nearest ones to a query. Here that lives
inside `/v1/ai` (272 operations) — stores, vectors, and an ingest path that
parses, chunks and embeds in one call — with `/v1/index` (17) as the lexical
half and `POST /v1/search` fusing both by reciprocal rank. The structural
difference is where the tenant comes from: Pinecone partitions with a
`namespace` string passed on every data-plane call, so isolation is a discipline
your code keeps, while here the org is minted from the validated key's owner
claim and no request body can name another one.
## Start here
Ingest a document and search it. There is no index to create first — the store
is named on the call, and the org it belongs to comes from the key.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. ingest — parse, chunk and embed in one call, no embedding step of your own
curl -sS -X POST https://api.hanzo.ai/v1/ai/rag/ingest \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"store":"docs","text":"Hanzo IAM issues OIDC tokens.","metadata":{"src":"handbook"}}'
# 3. search it — semantic and lexical fused by reciprocal rank
curl -sS -X POST https://api.hanzo.ai/v1/search \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"query":"how do tokens get issued","stores":["docs"]}'
```
Three calls and you have retrieval. Pinecone's equivalent needs an index
created on the control plane, its host read back, an embedding model called
yourself, and a namespace passed on every data-plane call after that.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/ai` | Stores, vectors, and the ingest path that parses, chunks and embeds | 272 |
| `/v1/index` | The lexical half — terms, filters and facets | 17 |
| `/v1/knowledge` | Collections built on top of both, for an agent to read | 9 |
## Nouns
### The index, and the namespace inside it
`/v1/provisioning` (28) · `/v1/ai` (272) · `/v1/index` (17)
| Pinecone | Hanzo |
|---|---|
| Project | Your org, taken from the validated key |
| `POST /indexes` — name, dimension, metric, cloud, region | `POST /v1/provisioning/vector` — a name, and nothing else to decide |
| `GET /indexes`, `GET /indexes/{name}` | `GET /v1/provisioning/vector`, `GET /v1/provisioning/vector/{name}` |
| `DELETE /indexes/{name}` | `DELETE /v1/provisioning/vector/{name}` |
| The index `host`, read back before the first write | `host` on the create receipt, and it is the public gateway — one address for every collection |
| Namespace, on every data-plane call | Not a field. The org is minted from the key; `project` narrows within it |
| A store the assistant reads from | `POST /v1/ai/stores`, listed at `GET /v1/ai/stores` |
| Sparse index, for the keyword half of hybrid | `POST /v1/index/indexes` — a lexical index, written at `.../documents` |
The physical collection name is derived from your slug under an org-namespacing
hash, so it is not the slug and two orgs cannot land on one.
### Records
`/v1/ai` (272) · `/v1/index` (17)
| Pinecone | Hanzo |
|---|---|
| `POST /vectors/upsert` | `POST /v1/ai/stores/{owner}/{name}/vectors`, or `POST /v1/ai/vectors` for one |
| Integrated records — upsert text, embedded server-side | `POST /v1/ai/rag/ingest` — parse, chunk, embed and index in one call |
| Re-embedding one document | `POST /v1/ai/rag/embed` — the same `file_id` replaces its own chunks |
| `GET /vectors/fetch` | `GET /v1/ai/vectors/{owner}/{name}`, `GET /v1/index/indexes/{uid}/documents/{id}` |
| `GET /vectors/list`, paged by id prefix | `GET /v1/ai/rag/context` — every stored chunk of one `file_id` |
| Walking the corpus | `GET /v1/index/indexes/{uid}/documents` — insertion order, with the index total |
| `POST /vectors/delete` by id | `DELETE /v1/ai/vectors/{owner}/{name}`, `POST /v1/index/indexes/{uid}/documents/delete-batch` |
| Deleting everything one document put in | `POST /v1/ai/rag/delete`, by `file_id` |
| `POST /bulk/imports` from object storage | `POST /v1/ai/rag/ingest` with an `s3`, `github` or `crawl` source |
| `POST /describe_index_stats` | `GET /v1/index/stats` — per-index counts and the org's total |
### Query, and the inference beside it
`/v1/ai` (272) · `/v1/search` (1) · `/v1/knowledge` (9)
| Pinecone | Hanzo |
|---|---|
| `POST /query` with a vector and `topK` | `POST /v1/search` with text — `query`, `mode`, `limit` |
| Dense and sparse indexes, merged in your client | `POST /v1/search` with `mode` hybrid — both legs, fused by reciprocal rank on the server |
| `filter` such as `{"user":{"$eq":"alice"}}` | `filter` on `POST /v1/index/indexes/{uid}/search`, written `user = alice` |
| Declaring which metadata is indexed | `PATCH /v1/index/indexes/{uid}/settings` — `filterableAttributes`, replaced whole |
| `POST /records/.../search` over text | `POST /v1/ai/rag/query`, or `POST /v1/ai/rag/query-multiple` over a set of files |
| Searching a private corpus | `POST /v1/knowledge/search` — the org's pages, memories and synced sources |
| Inference `POST /embed` | `POST /v1/embeddings` — `zen-embedding`, 8K context |
| Inference `POST /rerank` | `POST /v1/rerank` — `zen-rerank` |
| `GET /models` | `GET /v1/models` |
| Assistant — upload files, chat over them | `POST /v1/ai/files/upload`, then `POST /v1/chat/completions` |
| Keeping an index fresh from a source | `POST /v1/knowledge/connectors/{provider}/sync` — github, slack, google, notion |
| Importing a vault of notes | `POST /v1/knowledge/import` — obsidian, notion, roam or evernote |
## The call
Pinecone, from nothing to a first result:
```bash
# 1. create the index — dimension and metric are fixed here, permanently
curl -sS -X POST https://api.pinecone.io/indexes \
-H "Api-Key: $PINECONE_API_KEY" \
-H 'X-Pinecone-API-Version: 2025-04' \
-H 'Content-Type: application/json' \
-d '{"name":"docs","dimension":1024,"metric":"cosine",
"spec":{"serverless":{"cloud":"aws","region":"us-east-1"}}}'
# 2. poll until ready, and read back the host this index answers on
HOST=$(curl -sS https://api.pinecone.io/indexes/docs \
-H "Api-Key: $PINECONE_API_KEY" \
-H 'X-Pinecone-API-Version: 2025-04' | jq -r .host)
# 3. embed on one hostname, write on the other
curl -sS -X POST https://api.pinecone.io/embed \
-H "Api-Key: $PINECONE_API_KEY" \
-H 'X-Pinecone-API-Version: 2025-04' \
-H 'Content-Type: application/json' \
-d '{"model":"multilingual-e5-large","inputs":[{"text":"the Q3 roadmap"}]}'
curl -sS -X POST "https://$HOST/vectors/upsert" \
-H "Api-Key: $PINECONE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"namespace":"acme","vectors":[
{"id":"doc-1","values":[0.02,0.31],"metadata":{"user":"alice"}}]}'
# 4. query, reusing the vector rather than embedding the text again
curl -sS -X POST "https://$HOST/query" \
-H "Api-Key: $PINECONE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"namespace":"acme","topK":5,"vector":[0.02,0.31],
"filter":{"user":{"$eq":"alice"}},"includeMetadata":true}'
```
Hanzo, the same thing:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/ai/rag/ingest \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"source":"upload","store":"docs",
"documents":[{"id":"doc-1","text":"the Q3 roadmap"}]}'
curl -sS -X POST https://api.hanzo.ai/v1/search \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"query":"roadmap","mode":"hybrid","limit":5}'
```
Five calls across two hostnames become two against one, and it is five only
because the query reuses the vector from the upsert instead of embedding the
query text again. There is no index host to resolve, because an index is a row
rather than an appliance; and no `namespace` in either body, because the org is
minted from the validated bearer's owner claim, so two orgs both holding a
`docs` index have no way to write each other's name down. The write is applied
before its response — `GET /v1/index/tasks/{uid}` reports `succeeded` on the
first poll and `isIndexing` in `GET /v1/index/stats` is always false — so the
query that follows a write sees it, rather than seeing it soon. Pinecone asks
you to keep the index name, the resolved host, the namespace, the record id, the
metadata key and the embedding model's output width in step; here it is the
store name and the document id.
The metadata filter is the one place that takes two calls, because an attribute
has to be declared filterable before a query may constrain it:
```bash
curl -sS -X PATCH https://api.hanzo.ai/v1/index/indexes/docs/settings \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"filterableAttributes":["user","conversationId"]}'
curl -sS -X POST https://api.hanzo.ai/v1/index/indexes/docs/search \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"q":"roadmap","filter":"user = alice","limit":20}'
```
## What does not carry
**Namespace is not a field, and the one-call namespace drop goes with it.**
Pinecone puts thousands of end-user partitions in one index and deletes one with
a single call. The org here comes from the key, `project` is the one narrowing
coordinate beneath it, and per-end-user scoping is a filterable attribute — a
filter, not a partition. Deleting one user's records is a list of keys at
`POST /v1/index/indexes/{uid}/documents/delete-batch`, not a namespace drop.
**You cannot query by a vector you computed yourself.** Every query door in this
document takes text: `query` on `POST /v1/search`, `q` on the index search,
`query` on `POST /v1/knowledge/search`. If you embed with your own model —
images, a fine-tune, anything outside `POST /v1/embeddings` — the answer is
`POST /v1/provisioning/vector`, which hands back a `host`, a `port` and a
connection string once, and you speak the backend's own protocol to it.
**No sparse values on either side.** Pinecone's hybrid is a dense vector and a
sparse one you build and send together. Here hybrid is two engines — a lexical
index and a semantic one — fused by reciprocal rank at `POST /v1/search`, which
reports each leg's status and timing in `backends`. That is a different
mechanism, not a different spelling: there is nowhere to put a sparse encoding
you built.
**No dimension and no metric to choose.** `POST /v1/provisioning/vector` takes a
name; `POST /v1/index/indexes` takes a `uid` and an optional `primaryKey`.
Nothing takes a width or a distance function. The semantic leg is cosine —
`score` on a knowledge hit is documented as cosine similarity from -1 to 1 —
and changing embedding model means re-ingesting, because nothing converts
vectors of one width into another.
**No snapshot and restore.** Pinecone backs an index up to a collection or a
backup and restores it into a new index. No route here does that. A corpus is
rebuilt from its source instead: `POST /v1/ai/rag/ingest` naming `github`,
`crawl` or `s3`, or `POST /v1/knowledge/import` for an exported vault.
**No capacity to size.** Pods, replicas, `pod_type` and `configure_index` have
no equivalent. A create body is a name plus an optional instance binding; a
shared-backend create answers `ready`, and a dedicated one answers 201 while it
is still launching and reaches ready only when a later read reconciles it
against the operator's live record. Nothing sets throughput.
@@ -0,0 +1,214 @@
---
title: PlanetScale
description: PlanetScale runs a managed database with schema branches and deploy requests. Here the database is /v1/provisioning (28) and the branch, the review and the deploy are /v1/git (44), where the migration already lives.
---
PlanetScale gives you a managed database, a branch of its schema, and a deploy
request that puts the branch back onto production. Two capabilities answer that.
`/v1/provisioning` (28 operations) makes the database — `POST /v1/provisioning/sql`
launches your org's own PostgreSQL instance on port 5432 and answers with a
`postgres://` DSN. `/v1/git` (44) carries the other half, because the schema change
is a file in a repo and that repo already has a branch, a proposal and a merge.
The structural difference is that one. PlanetScale hands you a second branch —
inside the database, with its own host, its own password and its own deploy
request number — that has to be kept in step with the git branch holding the same
migration. Here there is one branch.
## Start here
Mint a key, create the database, read it back until it says ready.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. launch your org's own PostgreSQL instance — the DSN is in this answer only
curl -sS -X POST https://api.hanzo.ai/v1/provisioning/sql \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"orders"}'
# 3. read it back until status goes from provisioning to ready
curl -sS https://api.hanzo.ai/v1/provisioning/sql/orders \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Step 2 answers 201 with `connectionString` while the instance is still booting;
step 3 reconciles status against the operator's live view, so `ready` there means
`psql` will connect. Neither path carries an organization slug — the key does.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/provisioning` | Makes the database. Seven kinds share one body and one result shape: `sql`, `datastore`, `docdb`, `kv`, `search`, `vector`, `s3` | 28 |
| `/v1/git` | The branch, the pull request and the merge that carry the migration file. `POST /v1/git/repos/{name}/pulls/{number}/merge` is the deploy request | 44 |
| `/v1/o11y` | What Insights showed: `POST /v1/o11y/service/top_operations` for latency, call count and errors per operation | 381 |
## Nouns
### The database
| PlanetScale | Hanzo |
|---|---|
| Organization | Your org, taken from the validated key. Never a field in the request |
| Database | `POST /v1/provisioning/sql` — your org's own PostgreSQL instance, port 5432 |
| List databases | `GET /v1/provisioning/sql` |
| Database detail | `GET /v1/provisioning/sql/{name}` — host, port, username, status, never the password |
| Delete a database | `DELETE /v1/provisioning/sql/{name}` — a bound app reverts to Base first, then the instance goes |
| Connection string | `connectionString` on the create result, returned once |
| Password with a role, per branch | One admin credential per instance, minted at create and sealed in `/v1/kms` (5) |
| Putting the credential in your app | `instance` in the create body — the DSN lands in that app's addons Secret |
| The `pscale connect` proxy | Nothing in between. The DSN is what `psql` and your driver take |
Six other kinds sit on the same two routes: `datastore` for analytics,
`docdb` on the MongoDB wire protocol, `kv`, `search`, `vector` and `s3`. Same
body, same result shape, same one-name-per-resource rule.
### The schema change
| PlanetScale | Hanzo |
|---|---|
| Branch of a database | A branch of the repo the migration lives in — `GET /v1/git/repos/{name}/refs` |
| `pscale branch push`, schema applied to a branch | `POST /v1/git/repos/{name}/push` — files land as one commit, and it fires what a real push fires |
| Deploy request | A pull request — `POST /v1/git/repos/{name}/pulls` |
| Open deploy requests | `GET /v1/git/repos/{name}/pulls?state=open` |
| Deploying it | `POST /v1/git/repos/{name}/pulls/{number}/merge` |
| What landed, and when | `GET /v1/git/repos/{name}/commits` |
| Running the migration | The build the merge fires, or `POST /v1/platform/projects/{project}/apps/{app}/deploy` |
| Build history | `GET /v1/platform/builds` — part of `/v1/platform` (37) |
### Insights, and the rest
| PlanetScale | Hanzo |
|---|---|
| Insights, per-statement p50 p95 p99 | `POST /v1/o11y/service/top_operations` — latency, call count and error count per operation |
| Where one slow statement sits among its peers | `POST /v1/o11y/span_percentile` |
| Slow query log | `GET /v1/o11y/logs` |
| Alert when a threshold is crossed | `/v1/o11y/rules` |
| Insights dashboards | `/v1/o11y/dashboards` — part of `/v1/o11y` (381) |
| Service tokens, OAuth applications, members | `/v1/iam` (159) — `/v1/iam/service-accounts` and `/v1/iam/keys` |
| Audit log | `GET /v1/iam/audit-logs`, and `GET /v1/audit` (1) for the org trail |
| Database webhooks | `/v1/webhook` (8) — singular, with `GET /v1/webhook/{id}/deliveries` |
| Invoices and usage | `/v1/billing` (45) and `/v1/usage` (5) |
## The call
PlanetScale, from nothing to a deployed schema change:
```bash
curl -sS -X POST https://api.planetscale.com/v1/organizations/acme/databases \
-H "Authorization: $PLANETSCALE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"orders","region":"us-east"}'
curl -sS -X POST \
https://api.planetscale.com/v1/organizations/acme/databases/orders/branches \
-H "Authorization: $PLANETSCALE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"add-line-items","parent_branch":"main"}'
curl -sS -X POST \
https://api.planetscale.com/v1/organizations/acme/databases/orders/branches/add-line-items/passwords \
-H "Authorization: $PLANETSCALE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"app","role":"readwriter"}'
curl -sS -X POST \
https://api.planetscale.com/v1/organizations/acme/databases/orders/deploy-requests \
-H "Authorization: $PLANETSCALE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"branch":"add-line-items","into_branch":"main"}'
curl -sS -X POST \
https://api.planetscale.com/v1/organizations/acme/databases/orders/deploy-requests/12/deploy \
-H "Authorization: $PLANETSCALE_TOKEN"
```
Five calls, and five identifiers that must agree: `acme`, `orders`,
`add-line-items`, `main`, `12`. A sixth is the git branch carrying the same
migration, which this API never sees and cannot check.
Hanzo. The database, once:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/provisioning/sql \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"orders","instance":"checkout"}'
```
```json
{
"id": "rs_7c1f",
"kind": "sql",
"name": "orders",
"port": 5432,
"status": "provisioning",
"connectionString": "postgres://..."
}
```
There is no `acme` in that path. The store root and the instance both come from
the org claim in the key, so a caller has no way to write another tenant's name
down. `instance` binds the result: the `checkout` app's addons Secret receives
`SQL_URL`, so the DSN reaches the process that needs it without a copy step.
Omit `instance` and you keep the DSN yourself.
Then the schema change, on the branch you already pushed:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/git/repos/orders/pulls \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"head":"add-line-items","base":"main","title":"add line items"}'
curl -sS -X POST https://api.hanzo.ai/v1/git/repos/orders/pulls/4/merge \
-H "Authorization: Bearer $HANZO_API_KEY"
```
The merge is a fast-forward or nothing. It moves `base` only when `base` is
already an ancestor of `head` — the case where nothing is lost and nothing is
invented — and answers 409 when `base` has moved on independently, saying so.
A deploy request is a state machine you poll through queued and in-progress; this
answers with the revision `base` now points at. The build it fires is the same one
a `git push` of those bytes fires, so merging is not a second road into
production with its own rules. Proposing the same head into the same base twice
while the first is open is a 409, so a retried run leaves one thing to review.
## What does not carry
**No database branch.** PlanetScale forks the schema, and on a data branch the
rows, into a copy with its own host and password. A name here is one instance and
one DSN. A staging database is a second `POST /v1/provisioning/sql` under a second
name, seeded by the same migrations rather than forked from production.
**No deploy request, no schema diff, no revert window.** PlanetScale reads your
DDL, shows you the diff, applies it outside the request path and holds a window in
which you can put it back. Nothing here reads your DDL. The merge moves a ref and
the build runs your migration, so a bad migration is undone by another migration.
**No backup or restore route.** The API document has zero paths matching backup,
restore or point-in-time. Restoring last night into a fresh branch, which is one
of the better things PlanetScale sells, has no equivalent call. Schedule a dump
on `/v1/tasks` (5) and land it in `/v1/s3` (6).
**No region, no size, no read replica.** `provisionRequest` has exactly two
fields, `name` and `instance`. There is nowhere to write `us-east`, ask for more
memory, or add a read-only region. The instance lands in your org's tenant
namespace and scales up rather than out.
**No Vitess.** No keyspaces, no vindexes, no horizontal sharding, and no online
DDL running a ghost table behind your statement. The engine is PostgreSQL, so
MySQL dialect, inline `ENUM` columns and `SHOW` statements go with it. If you are
already on PlanetScale for Postgres, only the branch model changes.
**The credential is returned once.** `connectionString` and `password` come back
at create and no read carries them again — `GET /v1/provisioning/sql/{name}`
answers host, port, username and status, never the password — and there is no
rotate route beside it. PlanetScale mints a new password whenever you ask. Lose
this one and you provision again.
+249
View File
@@ -0,0 +1,249 @@
---
title: PostHog
description: PostHog is product analytics, feature flags, experiments and session replay behind one SDK. Here that is /v1/event (12), /v1/flags (8) and /v1/experiment (7) — the same wire on all three, with the tenant taken from the key rather than an api_key field.
---
PostHog captures events, decides flags, runs experiments and records sessions.
Four capabilities answer it: **`/v1/event`** (12 operations) for capture and the
read lenses, **`/v1/flags`** (8) for decisions, **`/v1/experiment`** (7) for
trials, and **`/v1/o11y`** (381) for dashboards, error tracking, alerting and
query. The wire is the one you already send — `POST /v1/event` accepts PostHog's
capture body verbatim, `distinct_id` and all, and `POST /v1/flags/decide` accepts
PostHog's `/decide` body and answers `featureFlags`, `featureFlagPayloads` and
`errorsWhileComputingFlags` — so pointing an SDK here is a base URL.
The structural difference is where the tenant comes from. PostHog carries
`api_key` in every capture body and a numeric project id in every REST path.
Neither exists here: the credential resolves the org, so there is nothing to
keep in step and no field a caller could point at somebody else's project.
## Start here
Three calls: mint a key, post a PostHog capture body, read the row back — no
project id in any path and no `api_key` in any body.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. capture an event — PostHog's capture body, minus api_key
curl -sS -X POST https://api.hanzo.ai/v1/event \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"event":"checkout_completed","distinct_id":"user-42","properties":{"plan":"pro","revenue":49}}'
# {"accepted":1,"dropped":0}
# 3. read the row back — a real bearer only
curl -sS 'https://api.hanzo.ai/v1/event/insights/events?limit=5' \
-H "Authorization: Bearer $HANZO_API_KEY"
```
The body in step 2 is PostHog's, `distinct_id` and all; `api_key` is gone because
the header already named the org. The receipt totals what was sent — a nonzero
`dropped` beside a nonzero `accepted` is a partial batch, not a failed one — and
step 3 returns that row from the same table `GET /v1/event/tag.js` fills from a
browser, which is what proves the write is yours to read.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/event` | Capture, replay ingest, and the fixed read lenses: overview, top, timeseries, live events, errors | 12 |
| `/v1/flags` | Decide flags for a subject, and hold the definitions plus an actor-stamped activity log | 8 |
| `/v1/experiment` | Create a trial that is already bucketing, assign arms, analyze, promote a winner | 7 |
## Nouns
### Capture and read
| PostHog | Hanzo |
|---|---|
| Project API key `phc_…` | A publishable `pk-` key. It attributes a write and reads nothing, on any route |
| Personal API key `phx_…` | A bearer, minted at `POST /v1/account/keys` |
| Project, by numeric id in every path | Your org and project, from the validated key |
| `POST /capture/` | `POST /v1/event` |
| `POST /batch/` | The same door. A batch is a body, not a path — an array already is one |
| `$pageview`, `$identify`, custom events | The same names, the same door |
| `posthog-js` snippet | `GET /v1/event/tag.js` — one script tag carrying `data-key="pk-…"` |
| Autocapture | The tag captures pageviews, initial and SPA, plus uncaught errors |
| Web analytics tiles | `GET /v1/event/overview` — pageviews, visitors, sessions for one window |
| Path, referrer and UTM breakdowns | `GET /v1/event/top` — five ranked lenses at once, each row with its share of the window |
| Activity / live events | `GET /v1/event/insights/events` |
| Group analytics | `groupType` and `groupId` on the event; `groups` on the decide call |
| Error tracking | `GET /v1/event/errors`. Sentry SDKs report unchanged at `POST /v1/event/{project}/envelope` |
| Replay chunks, sent as `$snapshot` events | `POST /v1/event/replay` — its own door, rrweb carried byte for byte |
| Data pipelines / destinations | `/v1/destination` (5) — ga4, meta, tiktok, linkedin, x, reddit |
### Flags and experiments
| PostHog | Hanzo |
|---|---|
| `POST /decide?v=3` | `POST /v1/flags/decide` — same body, same answer |
| `distinct_id`, `person_properties`, `groups` | The same three fields, unchanged |
| Feature flag | `PUT /v1/flags/defs/{key}` — the definition document, stored byte for byte |
| Multivariate flag | Variants and weights inside that document |
| Local evaluation, polled from a payload | Evaluation is already in-process over your own definitions. No poll, no shared cache |
| Flag activity log | `GET /v1/flags/activity` — every create, update and delete, with the actor |
| Create experiment, then set `start_date` to launch | `POST /v1/experiment` — 201 means the arms are bucketing |
| Experiment results, refreshed | `POST /v1/experiment/{id}/analyze` — exposed, conversions, rate, lift, z, p |
| Which variant is this user in | `GET /v1/experiment/{id}/assign` |
| Ship a winner | `POST /v1/experiment/{id}/decide`, org admin only |
| Cohort | `POST /v1/marketing/audiences`, evaluated live at `GET /v1/marketing/audiences/{id}/preview` |
| Survey | `/v1/ai/forms` holds the form, `GET /v1/ai/forms/data` the responses |
### Dashboards, query and the rest
| PostHog | Hanzo |
|---|---|
| Dashboard | `/v1/o11y/dashboards`, shared read-only at `GET /v1/o11y/public/dashboards/{id}` |
| Saved insight | `/v1/o11y/explorer/views` |
| HogQL query | `POST /v1/o11y/query_range` — builder queries, PromQL and Datastore SQL over traces, logs and metrics |
| Funnel | `POST /v1/o11y/trace-funnels/new`, then its `analytics/steps` reads |
| Error tracking issues | `GET /v1/o11y/errortracking/issues` |
| LLM analytics | `/v1/o11y/llm/traces`, `/v1/o11y/llm/observations`, `/v1/o11y/llm/scores` |
| Alerts and subscriptions | `/v1/o11y/rules`, delivered through `/v1/o11y/channels` |
| Batch export | `POST /v1/o11y/export_raw_data` — CSV or JSONL with a trailer that says whether it completed |
| Ingestion keys, and their rate limits | `/v1/o11y/gateway/ingestion_keys` |
| Data retention | `/v1/o11y/settings/ttl` |
| Webhook subscription | `/v1/webhook` (8) — with deliveries and a rotatable signing secret |
## The call
PostHog, capturing and deciding. Two hosts, and `api_key` in both bodies:
```bash
curl -sS -X POST https://us.i.posthog.com/capture/ \
-H 'Content-Type: application/json' \
-d '{
"api_key": "phc_xxx",
"event": "checkout_completed",
"distinct_id": "user-42",
"properties": {"plan": "pro", "revenue": 49}
}'
curl -sS -X POST 'https://us.i.posthog.com/decide?v=3' \
-H 'Content-Type: application/json' \
-d '{
"api_key": "phc_xxx",
"distinct_id": "user-42",
"person_properties": {"plan": "pro"}
}'
```
Hanzo, the same two bodies with the key moved to the header:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/event \
-H "Authorization: Bearer $HANZO_INGEST_KEY" \
-H 'Content-Type: application/json' \
-d '{
"event": "checkout_completed",
"distinct_id": "user-42",
"properties": {"plan": "pro", "revenue": 49}
}'
# {"accepted":1,"dropped":0}
curl -sS -X POST https://api.hanzo.ai/v1/flags/decide \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"distinct_id": "user-42",
"person_properties": {"plan": "pro"}
}'
# {"featureFlags":{"checkout-copy":"urgent"},"featureFlagPayloads":{},"errorsWhileComputingFlags":false}
```
An experiment is where the call counts diverge. PostHog registers it in draft,
then launches it by writing a start date — two calls, a numeric project id, and
a numeric experiment id you carry from the first to the second:
```bash
curl -sS -X POST https://us.posthog.com/api/projects/1234/experiments/ \
-H "Authorization: Bearer $POSTHOG_PERSONAL_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "Checkout copy",
"feature_flag_key": "checkout-copy",
"parameters": {"feature_flag_variants": [
{"key": "control", "rollout_percentage": 50},
{"key": "urgent", "rollout_percentage": 50}
]}
}'
curl -sS -X PATCH https://us.posthog.com/api/projects/1234/experiments/567/ \
-H "Authorization: Bearer $POSTHOG_PERSONAL_KEY" \
-H 'Content-Type: application/json' \
-d '{"start_date": "'"$(date -u +%FT%TZ)"'"}'
```
Here it is one call, and it is already running when it answers:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/experiment \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"id": "checkout-copy",
"metricEvent": "checkout_completed",
"variants": [
{"key": "control", "weight": 50, "control": true},
{"key": "urgent", "weight": 50}
]
}'
```
Creating is starting: the assignment flag is written live at 100% rollout with
the declared weights before the 201 comes back, and it fails closed on that
write — an experiment whose flag does not exist would assign nobody, so nothing
is registered rather than a trial that measures zero. The flag key defaults to
`exp_<id>`, so there is no second name to keep matched. The org, the project and
the creator are stamped from the credential and are not body fields, which is
also why the `api_key` above has nowhere to go: a `pk-` resolves which tenant a
beacon belongs to and nothing more, so a leaked browser key lets a stranger
write into your stream and never read out of it.
## What does not carry
**No HogQL, and no insight builder over product events.** PostHog gives you SQL
against `events` and `persons`, and a UI that composes trends, retention, paths
and stickiness from the same rows. The product-event reads here are fixed
lenses — `overview`, `top`, `timeseries`, `insights/events`, `errors` — each
with its own window and limit. `POST /v1/o11y/query_range` does run Datastore
SQL, but its subject is traces, logs and metrics, and `trace-funnels` steps over
traces rather than over what `POST /v1/event` stored.
**A cohort is one event and a window.** `POST /v1/marketing/audiences` takes a
name, an event and `windowDays`, and `GET /v1/marketing/audiences/{id}/preview`
evaluates it live — reporting count, deliverable and unmatched, so a cohort of
500 that reaches 3 mailboxes says so. A PostHog cohort is a nested condition
tree over person properties and behaviour. That nesting does not port; what
saves here is the single filter the marketing plane acts on.
**A survey is a form, not a targeted popup.** `/v1/ai/forms` holds the
definition and `GET /v1/ai/forms/data` the responses. PostHog injects the survey
into your page and picks who sees it. Rendering it is yours —
`POST /v1/flags/decide` answers who should get it.
**`assign` records no exposure.** `GET /v1/experiment/{id}/assign` buckets the
subject and returns the arm, and it is a pure read: nothing is written. The
analysis denominator is whatever `exposureEvent` names, defaulting to
`$feature_flag_called`, which is the marker a PostHog SDK already sends. Keep
sending it. Without it every arm reports zero exposed and the analysis is empty.
**Read the analysis before you decide.** `analyze` joins each subject to its arm
by re-evaluating the assignment flag *at analysis time*, not from what was in
force during the window, and `decide` rewrites that flag to 100% for the winner.
Analyse after promoting and every subject re-buckets into the promoted arm, the
control collapses to zero exposed, and the numbers mean nothing. Deciding is
also not terminal and does not revert: a second call overwrites the winner with
no record of the first, and restoring a split is a flag write.
**Replay is an ingest door, not a search.** `POST /v1/event/replay` takes rrweb
batches keyed by `sessionId` — 70 characters at most, 512 KiB a batch — and is
all-or-nothing, so a 200 means the recording is durable rather than buffered.
Filtering recordings by rage click or console error is not an API route, and a
Hanzo Team workspace token is refused outright: a full-fidelity screen recording
has no reduced form safe to write into a host org.
+238
View File
@@ -0,0 +1,238 @@
---
title: Render
description: Render builds a repo, runs the result, and provisions a database beside it. Here that is /v1/platform (37) for the app, /v1/provisioning (28) for Postgres and Key Value, /v1/projects (27) for a static site and /v1/tasks (5) for cron.
---
Render takes a repository, builds it, runs it, and gives you managed Postgres and
Key Value next to it. Four capabilities answer that surface: **`/v1/platform`**
(37 operations) for the app, **`/v1/provisioning`** (28) for the data,
**`/v1/projects`** (27) for a static site, and **`/v1/tasks`** (5) for cron.
The structural difference that shows up in every call is `ownerId` — Render makes
the tenant a field you send, on every create and as a query parameter on every
list. Here the org is read off the validated key and the cluster namespace
`tenant-<org>` is derived from it, so there is no field to write another tenant
into and nothing to keep in step between calls.
## Start here
An image you already have becomes a running URL in one call, and the database
beside it in a second.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. run the image — no owner lookup, no service id. 202 with the live URL
curl -sS -X POST https://api.hanzo.ai/v1/platform/run \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"api","image":"oci.hanzo.ai/acme/api:1.4.2","port":8080,"minScale":1}'
# 3. a Postgres of your own beside it — instance binds SQL_URL into the app
curl -sS -X POST https://api.hanzo.ai/v1/provisioning/sql \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"orders","instance":"api"}'
```
Two calls, and neither holds an identifier the other minted: the org is read off
the key, the app is addressed by the `name` you chose, and `instance` places the
DSN in the app's environment at create. Render's equivalent is five — list owners
for `ownerId`, create the service, poll the deploy, fetch `connection-info`, then
`PUT` the whole env-var array back. Re-running step 2 with the same name updates
that app in place, so a pipeline stores nothing between runs.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/platform` | The app — create, deploy, roll back, stop, scale, domains, env | 37 |
| `/v1/provisioning` | A dedicated instance in your namespace across seven kinds: sql, kv, vector, search, s3, docdb, datastore | 28 |
| `/v1/projects` | Static sites — a content-addressed release, published rather than built | 27 |
## Nouns
Render's `type` field picks between five service kinds. Two of them are separate
capabilities here, and the rest are one app.
### Services and deploys
| Render | Hanzo |
|---|---|
| Owner, `ownerId` on every call | Your org, taken from the validated key. Never a field in the request |
| Project, Environment | Project — IAM owns it; apps live under it. `environment` is a label on the app |
| `type: web_service` | App — `POST /v1/platform/projects/{project}/apps`, source `git` or `image` |
| `type: static_site` | `/v1/projects` (27) — a content-addressed release. It does not build |
| `type: cron_job` | `/v1/tasks` (5) — an engine, not a service that happens to hold a schedule |
| `type: background_worker` | The same app. There is one app shape, whatever it listens on |
| `POST /v1/services/{id}/deploys` | `POST .../apps/{app}/deploy` — builds first when the source is `git`, answers 202 |
| `POST .../rollback` with a `deployId` | `POST .../apps/{app}/rollback` — omit `deploymentId` for the previous release |
| `POST .../suspend` and `/resume` | `POST .../apps/{app}/stop` and `.../start` — compute metering stops with the pods |
| `POST .../scale`, `numInstances` | `replicas` at create; `minScale` and `maxScale` on `POST /v1/platform/run` |
| Autoscaling | `maxScale` above the floor. `maxScale: 0` means no autoscaler — a fixed run |
| Disk | `storageGb` on the app. Absent means stateless, no volume at all |
| `PUT /v1/services/{id}/env-vars` | `PUT .../apps/{app}/env` — also replaces, and seals anything secret into KMS |
| Custom Domain, then verify | `POST .../apps/{app}/domains`, then `.../domains/{host}/verify` |
| Pull Request Previews | `POST .../apps/{app}/preview` — a real app named `<app>-<branch>`, carrying no production env |
| Deploy logs | `GET .../apps/{app}/deployments/{id}/logs` — the build pod's output, then the app's |
### Data, edge and the rest
| Render | Hanzo |
|---|---|
| Render Postgres | `POST /v1/provisioning/sql` — your org's own instance, `postgres://` on the 201 |
| Render Key Value, formerly Redis | `POST /v1/provisioning/kv` — your org's own instance, `kv://` on the 201 |
| `GET /v1/postgres/{id}/connection-info` | No second call. The DSN is on the create, once |
| Persistent object storage | `POST /v1/provisioning/s3`, or buckets at `/v1/s3/buckets` |
| Environment Groups | `/v1/kms` (5) — a secret written at a `path` and an `env`, read by whatever holds the key |
| Registry Credentials | `/v1/registry` (6) — `POST /v1/registry/token` mints a short-lived pull-only token for one repository |
| Blueprints, the gallery | `/v1/blueprint` (3) — stacks with a service count and a monthly compute figure |
| `GET /v1/logs`, `/v1/logs/subscribe` | `GET /v1/o11y/logs`, and `GET /v1/o11y/logs/livetail` to follow |
| `GET /v1/metrics/cpu` and friends | `/v1/o11y` (381) — `GET /v1/o11y/metrics`, boards at `/v1/o11y/dashboards` |
| Webhooks | `/v1/webhook` (8) — `POST /v1/webhook/{id}/test`, receipts at `/v1/webhook/{id}/deliveries` |
| Notification settings | `/v1/notify` (4) for delivery, `/v1/o11y/alerts` for what fires |
| DDoS protection, rate limits | `/v1/gateway` (3) — `GET /v1/gateway/config` returns the policy actually in force |
| Connected GitHub or GitLab repo | `/v1/git` (44) — the forge itself, or `POST /v1/git/repos/{name}/mirror` to import one |
| Instance types and their prices | `/v1/pricing` (28), including `GET /v1/pricing/cloud/plans`. The bill is `/v1/billing` (45) |
## The call
Render, standing up a service. Three requests, because the create needs an owner
you have to go and find, and the deploy it starts has to be polled:
```bash
curl -sS https://api.render.com/v1/owners \
-H "Authorization: Bearer $RENDER_API_KEY"
curl -sS -X POST https://api.render.com/v1/services \
-H "Authorization: Bearer $RENDER_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"type": "web_service",
"name": "api",
"ownerId": "own-cq1e8h5f2p",
"repo": "https://github.com/acme/api",
"branch": "main",
"serviceDetails": {"env": "docker"}
}'
curl -sS "https://api.render.com/v1/services/srv-cq1e8j/deploys/dep-cq1e8k" \
-H "Authorization: Bearer $RENDER_API_KEY"
```
Hanzo, from an image that already exists. One request:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/platform/run \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "api",
"image": "oci.hanzo.ai/acme/api:1.4.2",
"port": 8080,
"minScale": 1,
"maxScale": 4
}'
```
From a repo, where there is something to build, it is create then deploy:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/platform/projects/acme/apps \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "api",
"source": "git",
"repo": {"url": "https://github.com/acme/api", "branch": "main"},
"buildType": "pack",
"port": 8080,
"storageGb": 10
}'
curl -sS -X POST \
https://api.hanzo.ai/v1/platform/projects/acme/apps/api/deploy \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"commit":"main"}'
```
The database, and its wiring, is one call:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/provisioning/sql \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"orders","instance":"api"}'
```
And a rollback takes an empty body:
```bash
curl -sS -X POST \
https://api.hanzo.ai/v1/platform/projects/acme/apps/api/rollback \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' -d '{}'
```
Count what each side has to remember. Render's flow carries `own-`, then `srv-`,
then `dep-`, and a pipeline has to store the service id between runs to know
whether to POST or PATCH. Here an app is addressed by project and slug, a
provisioned resource by the org-unique `name` you chose, and `POST /v1/platform/run`
is idempotent by name — re-running it updates that app in place, so a pipeline
holds no identifier at all. The 201 from `/v1/provisioning/sql` carries
`connectionString` in the body, and `instance` binds the resource to an app whose
`<instance>-addons` Secret receives the `SQL_URL`; Render answers its create
without the DSN and makes you fetch `connection-info`, then `PUT` the whole
env-var array back. The rollback is the same shape: Render wants a `deployId` you
looked up first, and here an empty body resolves to the newest earlier deployment
that carries a real built image and did not error, skipping the release currently
live.
## What does not carry
**`render.yaml` has no equivalent.** Render's Blueprint is a file in the repo
that declares services, databases and env groups, and syncing it creates them.
`hanzo.yml` in a repo declares how that repo *builds* — the images and binaries —
and committing it is how a repo opts into push-to-deploy, rather than owning a
webhook of its own. The service's shape is API state: replicas, disk, domains and
env are calls, not lines in a file. `/v1/blueprint` (3) is a catalog of ready-made
stacks, which is a different thing to your repo declaring itself.
**There is no private service.** Every app is seeded with its canonical host,
`<slug>.<org>.<sites host>`, at create, and that host cannot be removed —
re-adding it is a 409. So an app is always reachable by name. Restrict who may
call it with `/v1/gateway` (3), which is edge policy rather than absence from
DNS, and have the app authenticate its callers.
**Backups and point-in-time recovery are not in the provisioning API.** Render
gives Postgres `GET /v1/postgres/{id}/backups`, `POST .../recovery` and a managed
export. `/v1/provisioning` (28) is create, read and drop across seven kinds — sql,
kv, vector, search, s3, docdb and datastore. The instance runs in your own
namespace and no other tenant shares the process, which also means its backup
schedule is yours to run.
**The credential is returned once.** The 201 carries `connectionString` and
`password`, and no read beside it does: `GET /v1/provisioning/sql/{name}` answers
the status, the instance address and the admin user Postgres booted with, never
the password. Render's `connection-info` can be fetched whenever you like. Bind
the resource with `instance` at create, or put the DSN into `/v1/kms` (5)
yourself.
**Env groups do not fan out, and a write does not restart anything.** Render's
env group is one object many services inherit, and editing it redeploys them all.
`PUT .../apps/{app}/env` replaces one app's whole set — a variable absent from the
body is gone — and running pods keep the environment they started with until
their next deploy. A value two apps share is written into both, or held in
`/v1/kms` and placed by whatever runs your deploy.
**A refused deploy leaves no record at all.** Deploys are capped per org: over the
concurrent limit is 429 and nothing is written, so there is no phantom in the
history to reconcile. An unreachable cluster is 503 but *does* record an `error`
deployment, because an attempt that failed must not be indistinguishable from one
never made. Render queues instead of refusing, so port a retry rather than
assuming the deploy is somewhere in a backlog.
@@ -0,0 +1,224 @@
---
title: Replicate
description: Replicate runs containerised models behind one API and lets you push your own. Here that is /v1/ml (7 operations) — deploy a model under a name, then call it — with /v1/ai (272) for the hosted models and the training that produces them.
---
Replicate packages a model as a container, stamps every build with an immutable
version hash, and runs it as a prediction you create and then poll. `/v1/ml`
(7 operations) is that job with the identifiers collapsed: deploy a model under a
name, `POST /v1/ml/models/{name}/predict`, and the predictor's own reply comes
back on that call. The hosted models Replicate is better known for — image,
video, audio, chat — are `/v1/ai` (272), at OpenAI-shaped addresses. The
structural difference is the prediction itself: Replicate's is a stored object
with an id, a status and a poll loop, and here the inference call returns the
inference.
## Start here
Deploy a model under a name, then call that name — there is no version hash to
resolve and no prediction id to poll.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. deploy one model — spec is a kserve InferenceService spec, passed through unchanged
curl -sS -X POST https://api.hanzo.ai/v1/ml/models \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"sentiment","spec":{"predictor":{"model":{"modelFormat":{"name":"sklearn"},"storageUri":"s3://models/sentiment"}}}}'
# 3. run it — the predictor's own reply comes back on this call
curl -sS -X POST https://api.hanzo.ai/v1/ml/models/sentiment/predict \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"inputs":[{"name":"text","shape":[1],"datatype":"BYTES","data":["the shipment arrived early"]}]}'
```
Step 2 answers 201 with the model as Kubernetes admitted it — an unfunded org is
refused there, before anything is created — and step 3 answers with the
predictor's own status code, body bytes and Content-Type, unchanged. The name in
the path is the only identifier either call needs; a 503 `not ready` from step 3
means deployed but not yet serving, and `GET /v1/ml/health` says whether the
cluster holds a serving runtime at all.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/ml` | Deploys your own model under a name and runs inference against it, verbatim in both directions | 7 |
| `/v1/ai` | The hosted models Replicate is better known for, and the fine-tuning that produces new ones | 272 |
| `/v1/registry` | The images those models serve from — list, tag, and mint a pull-only token that expires in minutes | 6 |
## Nouns
Three tables. Replicate is a model registry, an inference runtime, and a training
service sold as one thing.
### Deploying and running your own model
| Replicate | Hanzo |
|---|---|
| Model, addressed `owner/name` | Model, a name in your org — `POST /v1/ml/models` |
| Version, a 64-hex hash of the build | The `spec` on that model. There is no second identifier |
| Prediction, created and then polled | `POST /v1/ml/models/{name}/predict` — the predictor answers on the call |
| The `input` object Cog validates | Whatever the serving runtime's protocol takes, relayed unchanged |
| Deployment, a named endpoint with its own scale | The model itself. Replica count lives in its spec |
| Hardware tier chosen at deploy time | Resource requests in the spec, changed with `PATCH /v1/ml/models/{name}` |
| Rolling a deployment onto a new version | The same `PATCH` — image, replicas or resources, without a teardown |
| Listing what you have running | `GET /v1/ml/models` — each entry carrying kserve's live status |
| Reading one model's config and readiness | `GET /v1/ml/models/{name}` |
| Deleting a deployment | `DELETE /v1/ml/models/{name}` — 204, and serving stops with it |
| The status page | `GET /v1/ml/health` — a live cluster call, not a flag set at boot |
### The hosted models, and training one
| Replicate | Hanzo |
|---|---|
| Running a language model | `POST /v1/chat/completions`, OpenAI-shaped |
| Running an image model | `POST /v1/images/generations` |
| Running a video model | `POST /v1/videos/generations`, then `GET /v1/videos/{id}/content` |
| Running a speech or music model | `POST /v1/audio/speech`, `/transcriptions`, `/voice`, `/music`, `/foley` |
| Browsing what you can run | `GET /v1/models` — public, and it does not authenticate |
| Asking for access to a gated model | `POST /v1/models/{model}/access` |
| A training run against a version | `POST /v1/ai/finetune/jobs` — submits a real training job |
| Polling that training | `GET /v1/ai/finetune/job` |
| Cancelling it | `POST /v1/ai/finetune/cancel` — meters the GPU-hours already spent, then cancels |
| The trained version you then run | `POST /v1/ai/finetune/deploy` — serves the checkpoints as a routable model |
| Picking a base model and a dataset | `GET /v1/ai/finetune/presets`, `/hf/models`, `/hf/datasets` |
### Around the model
| Replicate | Hanzo |
|---|---|
| `webhook` URL set on each prediction | An endpoint registered once — `POST /v1/webhook` |
| `webhook_events_filter` | The `events` list on that endpoint, matched as a subject pattern |
| The default webhook secret you fetch | Returned on create, and after that only by `POST /v1/webhook/{id}/secret` |
| Wondering whether a delivery landed | `GET /v1/webhook/{id}/deliveries` — one row per attempt, narrowable to failed |
| Proving an endpoint is reachable | `POST /v1/webhook/{id}/test` — one signed event now, outcome inline |
| `cog push` to `r8.im` | `POST /v1/platform/runner` builds and pushes; `GET /v1/registry/images` lists |
| Pulling that image from a build | `POST /v1/registry/token` — pull-only, one image, expires in minutes |
| Files API for large inputs and outputs | `/v1/s3` (6) — `POST /v1/s3/buckets/{bucket}/objects` mints a presigned PUT |
| Prediction logs | `/v1/o11y` (381) — `GET /v1/o11y/logs`, live at `/v1/o11y/logs/livetail` |
| What a run cost | `GET /v1/billing/ledger`, with the balance at `GET /v1/billing/balance` |
| Running arbitrary code beside a model | `/v1/sandbox` (19) — lease, exec, read, write, end |
| Scoring model output against a fixed set | `/v1/eval` (16) and `/v1/benchmark` (9) |
## The call
Replicate, running your own model once it is pushed:
```bash
# resolve the version hash a prediction has to name
curl -sS https://api.replicate.com/v1/models/acme/sentiment \
-H "Authorization: Bearer $REPLICATE_API_TOKEN"
# create the prediction against that hash
curl -sS -X POST https://api.replicate.com/v1/predictions \
-H "Authorization: Bearer $REPLICATE_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"version": "5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa",
"input": {"text": "the shipment arrived early"},
"webhook": "https://acme.example/hooks/replicate",
"webhook_events_filter": ["completed"]
}'
# poll until status stops being starting or processing
curl -sS "https://api.replicate.com/v1/predictions/$ID" \
-H "Authorization: Bearer $REPLICATE_API_TOKEN"
```
Hanzo, deploying once:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/ml/models \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "sentiment",
"spec": {
"predictor": {
"model": {
"modelFormat": {"name": "sklearn"},
"storageUri": "s3://models/sentiment"
}
}
}
}'
```
Then every call after that:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/ml/models/sentiment/predict \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"inputs":[{"name":"text","shape":[1],"datatype":"BYTES","data":["the shipment arrived early"]}]}'
```
Three calls become one, and three identifiers become one. Replicate needs
`owner/name` to resolve a version, the version hash to create a prediction, and
the prediction id to read the answer — three values that have to stay in step,
and a stale hash in a config file quietly runs last month's model. Here the name
is the only thing you hold, and the org is in neither the path nor the body: it
is derived from the validated key, so a request has no field in which to name
another tenant.
The reply is the predictor's own — status code, body bytes and Content-Type,
unchanged — so a model that raises comes back as the model's error rather than
this layer's paraphrase of it. Two decisions are settled before anything runs
and neither is eventually consistent: an unfunded org is refused before the
InferenceService is created, so nothing runs unpaid and nothing is billed for a
resource that was never made; and a model that exists but has no serving address
yet answers 503 `not ready` rather than a connection error, because deployed and
serving are separate facts.
## What does not carry
**There is no public directory of other people's models.** Replicate's shop
window is tens of thousands of community models addressed as `owner/name` and
runnable on the first call with no deploy. `GET /v1/models` is the routing
table's catalogue and `/v1/ml/models` holds only what your org put there;
nothing runs a model that is in neither. `/v1/marketplace` (6) does publish and
price other people's work per call — `POST /v1/marketplace/listings`, installed
with `POST /v1/marketplace/install` — but the unit it sells is a tool or an
agent, not a model image.
**A prediction is not a stored object.** Replicate's has an id, a status, a
`urls.get` and a `urls.cancel`, and stays listable for an hour. `predict`
answers and keeps nothing: no id to poll, no history to page through, no cancel.
The record of what ran is a log line at `GET /v1/o11y/logs`, not a resource you
re-read by id. Video is the one place the asynchronous shape survives —
`POST /v1/videos/generations` returns `queued` immediately and you poll
`GET /v1/videos/{id}` — because that work outlives a request.
**No immutable version hash.** Every Cog build gets a content hash you can pin,
roll back to, and run alongside its predecessor. A model here has a name and a
mutable spec; `PATCH` moves it forward and there is no earlier one to reactivate.
Two versions side by side means two names. The pin you keep is the image tag
inside the spec, and `GET /v1/registry/tags` is what answers for it.
**Cog's convention does not port, though the build does.** A `cog.yaml` plus a
`predict.py` becomes a GPU image with a typed input schema derived from your
function signature, and that derivation has no counterpart. `POST /v1/platform/runner`
builds a container from a repo and pushes it to your org's registry namespace, so
getting an image is answered; what you write yourself is the serving contract the
image speaks. `GET /v1/ml/health` reports whether the cluster actually holds a
runtime to run one, because a cluster with the CRD and no runtime accepts a
deploy and then never schedules it.
**The webhook is a subscription, not a request field.** Replicate takes a URL per
prediction, so a fire-and-forget client can be told about that one run. Here you
register an endpoint once and subscribe it to an event pattern; it is
HMAC-signed, the secret leaves the server only on create and on rotate, and every
attempt is in `GET /v1/webhook/{id}/deliveries`. A per-call callback URL has
nowhere to go — and `predict` returning the answer is why it does not need one.
**`predict` reads the predictor's body up to a fixed ceiling.** A synchronous
relay has to bound what it buffers. Output measured in hundreds of megabytes —
long video, a large batch of frames — should be written to object storage by the
predictor and fetched from `/v1/s3` (6), rather than returned inline.
+203
View File
@@ -0,0 +1,203 @@
---
title: Sentry
description: Sentry catches an exception off a running application and groups it into an issue. Here that is /v1/o11y (381 operations) to read and /v1/event (12) to ingest, and the ingest speaks Sentry's own wire.
---
Sentry takes a stack trace off a running application, groups it by fingerprint
into an issue, and keeps the traces, logs and recordings around it. Two
capabilities answer that: **`/v1/o11y`** (381 operations) holds the issues,
traces, alert rules and dashboards, and **`/v1/event`** (12 operations) is the
ingest door, which accepts the Sentry envelope byte-for-byte — an application
already instrumented reports here by changing its DSN and nothing else.
The structural difference is where the tenant comes from. Sentry names the
organization in nearly every path (`/api/0/projects/{org}/{project}/issues/`).
Here it never appears: on ingest the org is derived from the DSN key, on reads
from the validated bearer. The only identifier you carry is the project, and on
most reads it is optional.
## Start here
One key and one project: the project's DSN drops into `Sentry.init`, and the
issues read back with no org slug anywhere.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. create the project — the DSN comes back with it
curl -sS -X POST https://api.hanzo.ai/v1/o11y/sentinel/projects \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"backend","platform":"python"}'
# 3. read the grouped issues back
curl -sS "https://api.hanzo.ai/v1/o11y/sentinel/issues?status=unresolved&period=24h" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
Step 2 returns `data.dsn`. Paste it into `Sentry.init` and an already-instrumented
application reports to `POST /v1/event/{project}/envelope` with nothing else
changed — that door is credentialed by the DSN key, not by the bearer above. Step
3 is the read side: the org came from the key, so `project` is an optional
narrowing rather than a required path segment.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/o11y` | Issues, traces, alert rules, dashboards and logs — everything you read | 381, across 287 paths |
| `/v1/event` | The ingest door. Takes the Sentry envelope byte-for-byte, keyed by the DSN | 12 |
| `/v1/webhook` | Where an issue leaves the platform. Each endpoint carries its 7-day delivery and failure counts | 8 |
## Nouns
### Errors and issues
| Sentry | Hanzo |
|---|---|
| Organization | Your org — from the DSN key on ingest, the bearer on reads. Never a path segment |
| Project | `POST /v1/o11y/sentinel/projects` — you set name and platform, the server assigns id and key |
| DSN, client key | The `dsn` field on the project, derived fresh on every read of it |
| Rotating a client key | `POST /v1/o11y/sentinel/projects/{id}/keys/rotate` |
| Envelope ingest, current SDKs | `POST /v1/event/{project}/envelope` |
| `store` ingest, pre-envelope SDKs | `POST /v1/event/{project}/store` |
| The `/api/{id}/envelope/` path an SDK builds | `POST /v1/o11y/api/{project_id}/envelope/` — received verbatim, not published |
| Issue, grouped by fingerprint | `GET /v1/o11y/sentinel/issues`, also `GET /v1/o11y/errortracking/issues` |
| Resolve, ignore, reopen, assign | `PUT /v1/o11y/sentinel/issues/{id}` — one route, `status` and `assignee` |
| An issue's occurrences | `GET /v1/o11y/sentinel/issues/{id}/events` |
| One event by id | `GET /v1/o11y/sentinel/events/{id}` |
| Discover query | `POST /v1/o11y/sentinel/discover` — filters, groupings and aggregations in, columns and rows out |
| Event-rate graph for a project | `GET /v1/o11y/sentinel/stats` |
| Full-text over raw events | `GET /v1/o11y/sentinel/logs` |
| Deleting a project | `DELETE /v1/o11y/sentinel/projects/{id}` — the DSN stops resolving at once, retained events are untouched |
### Performance
| Sentry | Hanzo |
|---|---|
| Transaction, trace | `GET /v1/o11y/traces`, one at `GET /v1/o11y/traces/{traceId}` |
| Span waterfall | `POST /v1/o11y/traces/{traceId}/waterfall` |
| Flame graph | `POST /v1/o11y/traces/{traceId}/flamegraph` |
| Service latency and error rate | `GET /v1/o11y/services/list` |
| Slowest operations in one service | `POST /v1/o11y/service/top_operations` — p50, p95 and p99 each |
| Apdex threshold | `GET /v1/o11y/settings/apdex`, set with `POST` on the same path |
| Where one span sits among its peers | `POST /v1/o11y/span_percentile` |
| Outbound HTTP dependencies | `POST /v1/o11y/third-party-apis/overview/list` |
| Which requests are failing | `GET /v1/o11y/sentinel/traces` — traces a project's errors landed on, error count each |
| Every error on one trace | `GET /v1/o11y/sentinel/traces/{id}` |
| Funnel across a trace | `GET /v1/o11y/trace-funnels/list` |
### Releases, replay and the rest
| Sentry | Hanzo |
|---|---|
| Release | `GET /v1/platform/releases` — the versions that reached the cluster, observed rather than declared |
| Session Replay ingest | `POST /v1/event/replay` — an rrweb batch keyed by `sessionId`, stored before the call answers |
| Metric alert rule | `GET /v1/o11y/rules`, created with `POST`, dry-run at `POST /v1/o11y/rules/test` |
| Currently firing | `GET /v1/o11y/alerts`, most recent at `GET /v1/o11y/alerts/last` |
| Where an alert is delivered | `GET /v1/o11y/channels`, checked with `POST /v1/o11y/channels/test` |
| Dashboard | `GET /v1/o11y/dashboards`, shared read-only at `/v1/o11y/dashboards/{id}/public` |
| Logs, and Live Tail | `GET /v1/o11y/logs`, `GET /v1/o11y/logs/livetail` |
| Retention and quota | `POST /v1/o11y/settings/ttl` — default TTL in days, per-label rules, cold storage |
| Spike protection, per-key rate limit | `POST /v1/o11y/gateway/ingestion_keys/{keyId}/limits` |
| Cron monitors and check-ins | `/v1/tasks` (5) — a durable engine, not a heartbeat beside an issue |
| Issue webhooks | `/v1/webhook` (8) — every endpoint carries its 7-day delivery and failure counts |
| Installing a third-party integration | `POST /v1/o11y/integrations/install` |
| Browser errors from a page with no bundler | `GET /v1/event/tag.js` — one script tag and a publishable `pk-` key |
| Data export | `POST /v1/o11y/export_raw_data` |
| LLM spans, tokens and cost | `GET /v1/o11y/llm/traces` |
## The call
Sentry. The DSN comes out of the project settings page, and reads name the org
and the project in the path:
```bash
curl -sS "https://sentry.io/api/0/projects/acme/backend/issues/?query=is%3Aunresolved&statsPeriod=24h" \
-H "Authorization: Bearer $SENTRY_TOKEN"
curl -sS -X PUT "https://sentry.io/api/0/issues/5551212/" \
-H "Authorization: Bearer $SENTRY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"status":"resolved"}'
```
Hanzo. Create the project and the DSN comes back with it:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/o11y/sentinel/projects \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"backend","platform":"python"}'
```
Copy `data.dsn` into `Sentry.init` and the application is migrated. The SDK
appends its own `/api/<project>/envelope/` to whatever DSN it holds, so no code
below that one string changes.
Reading back, and resolving:
```bash
curl -sS "https://api.hanzo.ai/v1/o11y/sentinel/issues?status=unresolved&period=24h" \
-H "Authorization: Bearer $HANZO_API_KEY"
curl -sS -X PUT https://api.hanzo.ai/v1/o11y/sentinel/issues/5551212 \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"id":"5551212","status":"resolved","assignee":"[email protected]"}'
```
Three things follow from that shape. `acme` appears nowhere, so there is no org
slug to keep in step with the token that reads it — and because the org is the
key's, `project` is an optional narrowing rather than a required coordinate:
drop it and you read every project's unresolved issues, which in Sentry is a
second endpoint under a different scope. The DSN is derived from the project on
each read rather than stored beside it, so there is no second record to keep
current; `DELETE` on the project stops the DSN resolving in the same instant.
And rotation bumps a watermark that keys below stop verifying against, so the
old key is dead on the next request rather than once a revocation has reached
every relay.
## What does not carry
**No source maps and no symbolication.** Sentry uploads artifact bundles and
debug files and un-minifies the stack server-side. Nothing here does — there is
no upload route and no symbolicator, so a minified frame is stored and shown
minified. If your frontend stacks are the reason you read Sentry, this is the
one to weigh before anything else.
**A release is observed, not declared.** `GET /v1/platform/releases` lists the
deployments that genuinely reached the cluster; one that failed or is still
building is excluded. Error tracking never takes a release declaration: deploying
through Hanzo's own PaaS does mint one (`POST /v1/projects/{slug}/releases`), but
no route attaches commits to a release or ties one to an issue — so `sentry-cli
releases new`, `set-commits` and `finalize` have nothing to call, and the
suspect-commit attribution and regression detection built on that graph do not
come with it.
**No release health.** Session tracking, crash-free rate and adoption per
release are Sentry counting sessions the SDK opens. The envelope wire carries
those session items and they are ingested, but no route reads a crash-free
number back.
**Grouping is the server's.** Issues group by fingerprint and there is no route
that edits a grouping rule, so Sentry's per-project stack-trace rules and
fingerprint overrides have no destination. What the SDK sends is what it groups
by.
**Attachments and user feedback arrive but do not read back.** The envelope is
accepted whole, attachments included. There is no route that lists or downloads
an attachment, and no crash-report or user-feedback endpoint to point a feedback
widget at.
**Replay ingests through the API and plays back in the console.** `POST
/v1/event/replay` takes the recording — 512 KiB per batch, so a recorder chunks
a long session — and a player in console.hanzo.ai reads the derived summary.
Pulling a raw recording out programmatically is not an API call. A reduced Hanzo
Team workspace token is refused here with `insufficient_capability`, because a
full-fidelity screen recording has no safe projected form for a guest to write
into a host org.
+38 -1
View File
@@ -5,9 +5,46 @@ description: SQS is a queue between services. Here that is /v1/mq for durable st
SQS holds messages between a producer and a worker. Two capabilities answer it:
`/v1/mq` (15 operations) owns the durable half — streams, their consumers and
their retained messages — and `/v1/pubsub` (8) owns the send, plus a request
their retained messages — and `/v1/pubsub` (2) owns the send, plus a request
that waits for one reply.
## Start here
A stream first, then a publish into it — that is the whole path from an empty org
to a durable message.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. create the stream that will capture the subject
curl -sS -X POST https://api.hanzo.ai/v1/mq/stream \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"orders","subjects":["orders.>"],"retention":"workqueue","storage":"file"}'
# 3. publish into it
curl -sS -X POST https://api.hanzo.ai/v1/pubsub/publish \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"subject":"orders.created","data":"{\"id\":1}","headers":{"Nats-Msg-Id":"order-1"}}'
```
Step 2 answers 201 with the stream; step 3's receipt names that stream and a
sequence number, which is the proof the message reached storage. Publish without
step 2 and the receipt is a bare `{ok}` — sent to whoever was listening, kept
nowhere.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/mq` | Streams, durable consumers, and the messages a stream retains | 15 |
| `/v1/pubsub` | Puts one message on a subject; `request` waits for one reply | 2 |
## Nouns
| Amazon SQS | Hanzo |
+268
View File
@@ -0,0 +1,268 @@
---
title: Stripe
description: Stripe takes payments and bills for them. Here that is /v1/commerce (181 operations) for taking the money and /v1/billing (45) for what is owed and what is left, with the payer resolved from the key instead of carried as a customer id.
---
Stripe is a payments API with a billing product on top: charge a card, hold a
customer, run subscriptions and invoices against that customer, meter usage, pay
out through Connect. Two capabilities answer it here — **`/v1/commerce`** (181
operations) takes money and runs the storefront, **`/v1/billing`** (45) owns what
a customer owes, holds, and has left to spend.
The structural difference is who the payer is. Every Stripe money object names a
`cus_…` you created and must keep in step with a `pm_…`, a `price_…` and a
`sub_…`; here the paying org comes from the validated key and is never a field in
the request. A charge is an amount, a currency and a card token.
## Start here
Mint a key, read which processor account and environment your org is on, then
charge the card token a browser mints against it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. the public processor ids a browser tokenizes with, and which bucket you are in
curl -sS https://api.hanzo.ai/v1/billing/settings \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. charge the single-use token that came back from the browser
curl -sS -X POST https://api.hanzo.ai/v1/commerce/payments \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"amountCents": 5000,
"currency": "usd",
"sourceId": "cnon:card-nonce-from-the-browser",
"idempotencyKey": "first-charge-1"
}'
```
Step 2 names the processor account the charge will land on and `live` says
whether step 3 moves real money; step 3 answers a ledger transaction id,
`processorRef` for reconciling at the processor, and `test` echoing that same
bucket. No `cus_…`, no `pm_…`, no `price_…` had to exist first — the org
credited is the key that made the call, so $50 cannot land in someone else's
books.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/commerce` | Takes the money: payments, cart, checkout, catalog, discounts, storefronts | 181 |
| `/v1/billing` | What is owed and what is left: cards on file, subscriptions, invoices, usage, ledger | 45 |
| `/v1/webhook` | Endpoints, signing secrets, delivery attempts and replays | 8 |
## Nouns
The left column is Stripe's product surface, not just its payments core.
### Taking money
| Stripe | Hanzo |
|---|---|
| Customer | Your org, taken from the validated key. Never a field in the request |
| PaymentIntent, confirmed | `POST /v1/commerce/payments` — amount, currency, card token, idempotency key |
| Charge, read back | `GET /v1/commerce/payments/{id}`, by the ledger transaction id |
| PaymentMethod attached to a customer | `POST /v1/billing/methods` — vaulted at the processor, always the caller's own |
| SetupIntent, to charge later | The same `POST /v1/billing/methods`. A card already on file answers 200, a new one 201 |
| Publishable key for Elements | `GET /v1/billing/settings` — the ids a browser tokenizes with, and which environment |
| Checkout Session | `POST /v1/commerce/cart`, then `POST /v1/commerce/store/{storeid}/checkout/charge` |
| Authorize now, capture later | `.../checkout/authorize`, then `.../checkout/capture/{orderid}` |
| Cart line edit | `POST /v1/commerce/cart/{id}/item` — quantity is the result, not a delta; zero removes |
| Coupon and promotion code | `/v1/commerce/discount/` — flat, percent, free shipping, free item or bulk |
| Balance and balance transactions | `GET /v1/billing/balance`, `GET /v1/billing/transactions` |
### Subscriptions, invoices and metering
| Stripe | Hanzo |
|---|---|
| Product and Price | `/v1/commerce/product/` and `/v1/commerce/variant/`; the sellable catalog is `GET /v1/billing/plans` |
| Subscription | `POST /v1/billing/subscribe/card`, listed at `GET /v1/billing/subscriptions` |
| Cancel at period end | `POST /v1/billing/subscriptions/{id}/cancel` — `atPeriodEnd` defaults true |
| Resume a canceled subscription | `POST /v1/billing/subscriptions/{id}/reactivate` |
| Trial without a card | `POST /v1/commerce/store/{storeid}/trial` — seven days, entitlement per store |
| Invoice: draft, finalize, pay | `POST /v1/billing/invoices`, then `/issue`, then `/collect` |
| Void, and the PDF | `POST /v1/billing/invoices/{id}/void`, `GET /v1/billing/invoices/{id}/pdf` |
| Meter events and usage records | Priced by the rate authority at `/v1/commerce/rates/entries`, read back at `GET /v1/billing/usage` |
| Credit grants | `GET /v1/billing/credits` for every grant, `GET /v1/billing/credit-balance` for what is spendable |
| Active entitlements and features | `GET /v1/entitlement` (3), plus `GET /v1/commerce/store/access` per storefront |
| Free-tier quota beside the composer | `GET /v1/allowance` (1) — reading it does not spend |
| Billing portal | `GET /v1/team/billing/ui`, the session-gated wallet page |
### Ledger, reporting and events
| Stripe | Hanzo |
|---|---|
| Balance transaction export | `GET /v1/billing/ledger` — signed double-entry postings over a 24h, 7d, 30d or 90d window |
| Usage grouped by product | `GET /v1/billing/usage?groupBy=product` |
| The month against the plan | `GET /v1/billing/usage/rollup` — plan allotment and wallet, reported separately |
| Which tier, and what is left | `GET /v1/billing/tier` — compare `effectiveAvailable` against zero, nothing else |
| Billing alerts and spend thresholds | `/v1/billing/alerts`, enforced per request at `GET /v1/billing/alerts/authorize` |
| Webhook endpoints | `/v1/webhook` (8) — the signing secret leaves the server on create and on rotate only |
| Delivery attempts and replays | `GET /v1/webhook/{id}/deliveries`, `POST /v1/webhook/{id}/test` |
| Inbound provider events | `POST /v1/commerce/webhooks/{provider}` — the signature is the authentication |
| Sigma and Reporting | `GET /v1/books/gl` and `GET /v1/books/pnl`, from the 25 accounting operations |
| Dashboard graphs | `/v1/o11y` (381) |
`amount` on a usage row is whole cents, rounded; `decimal` beside it is the same
debit exact, to 18 places. Sum `decimal`. A page of sub-cent inference calls
totals correctly there and totals zero in `amount`, and that difference is real
money.
### Marketplace, risk and identity
| Stripe | Hanzo |
|---|---|
| Radar fraud scoring | `POST /v1/risk/score` — your org's own model, from `/v1/risk` (11) |
| Radar rules | `GET` and `PUT /v1/risk/policy` |
| Chargeback and refund outcomes as training signal | `POST /v1/risk/learn` — a label carries `seen` as well as `at`, so a chargeback landing 90 days late cannot leak backwards |
| Identity verification sessions | `POST /v1/compliance/verifications` (17), ruled on at `/{id}/decision` |
| Connect account onboarding | `/v1/compliance/subjects` and `/v1/compliance/accreditation` |
| Marketplace listings and installs | `/v1/marketplace` (6) |
| Transfer to a connected account | `/v1/commerce/transfer/` — a record that a payable was paid, not an executor |
| Payouts | `GET /v1/billing/payouts` — amount, destination, status, failure reason |
| Revenue share to partners | `/v1/treasury` (2) for the reserve and its policy, `/v1/affiliate` (10) for earnings |
| Crypto acceptance | `POST /v1/billing/crypto/deposit`, assets at `GET /v1/billing/crypto/options` |
| Wire top-up | `GET /v1/billing/wire` — the serving brand's bank, and your own payment reference |
| Machine-to-machine payment receipt | `GET /v1/x402/settlements/{id}` (1) |
## The call
Charging a customer $50. Stripe needs three identifiers to exist and agree:
```bash
curl -sS https://api.stripe.com/v1/customers \
-u "$STRIPE_SECRET_KEY:" \
-d [email protected]
curl -sS https://api.stripe.com/v1/payment_methods/pm_1234/attach \
-u "$STRIPE_SECRET_KEY:" \
-d customer=cus_1234
curl -sS https://api.stripe.com/v1/payment_intents \
-u "$STRIPE_SECRET_KEY:" \
-H "Idempotency-Key: $KEY" \
-d amount=5000 \
-d currency=usd \
-d customer=cus_1234 \
-d payment_method=pm_1234 \
-d confirm=true
```
Hanzo:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/commerce/payments \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"amountCents": 5000,
"currency": "usd",
"sourceId": "cnon:card-nonce-from-the-browser",
"idempotencyKey": "'"$KEY"'"
}'
```
Three identifiers become none. The org credited is the validated principal and
is not readable from the body, so a payment can only ever credit the account of
whoever made the call — there is no `customer` to mistype into someone else's
books. The amount is bounded server-side, floor $1 and ceiling $5,000 by
default, and the charge is risk-screened before the card is touched: 403 is a
refusal, 503 is a screen that could not reach a decision and is worth retrying,
and no money moved either way. The receipt carries `processorRef` for
reconciliation against the processor and `test` for which bucket it credited,
so a sandbox charge can never be read as a live one. Omitting `idempotencyKey`
is survivable rather than dangerous: the server falls back to a key derived from
amount and currency, and a double-submit inside 15 minutes collapses onto one
charge.
Invoicing is the same collapse. Stripe builds the invoice from items you create
first, then finalizes, then pays:
```bash
curl -sS https://api.stripe.com/v1/invoiceitems -u "$STRIPE_SECRET_KEY:" \
-d customer=cus_1234 -d amount=250000 -d currency=usd \
-d description="Advisory retainer"
curl -sS https://api.stripe.com/v1/invoices -u "$STRIPE_SECRET_KEY:" -d customer=cus_1234
curl -sS https://api.stripe.com/v1/invoices/in_1234/finalize -u "$STRIPE_SECRET_KEY:"
curl -sS https://api.stripe.com/v1/invoices/in_1234/pay -u "$STRIPE_SECRET_KEY:"
```
```bash
curl -sS -X POST https://api.hanzo.ai/v1/billing/invoices \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"userId": "u_412",
"currency": "usd",
"lines": [{"description":"Advisory retainer — August","amount":250000}]
}'
curl -sS -X POST "https://api.hanzo.ai/v1/billing/invoices/$ID/issue" \
-H "Authorization: Bearer $HANZO_API_KEY"
curl -sS -X POST "https://api.hanzo.ai/v1/billing/invoices/$ID/collect" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
The lines are the invoice: subtotal and amount due are computed from them, so
there is no total to send and none to disagree with itself. `collect` runs one
waterfall — credit grants, then prepaid balance, then the card on file — and
answers `creditUsedCents`, `balanceUsedCents` and `cardChargedCents` on a single
receipt, where Stripe's customer balance, credit grants and card are three
things configured apart and applied apart. A decline is `paid=false` with a
reason and the invoice still open, not an error, because a decline is a normal
business outcome and dunning must stay retryable. Only a successful collection
is sealed, so retrying a paid invoice replays the receipt rather than charging
again.
## What does not carry
**Refunds and disputes are settled at the processor.** No route reverses a
settled charge. `void` covers a draft or issued invoice and refuses a paid one —
money moved, and the correction for that is a refund, not an erasure.
`/v1/commerce/return/` records the RMA, with the money living in its line items
and fulfillment pricing rather than as a refund amount on the return itself.
`/v1/risk/learn` takes `dispute` and `chargeoff` as label sources, but there is
no evidence-submission route behind them. Reconcile with `processorRef`.
**Connect's payout rail is a record here, not an executor.** `GET
/v1/billing/payouts` reads outbound payouts; creating one is not on this
surface. `/v1/commerce/transfer/` is the annotation a human writes after paying
out of band, it moves no money, and nothing enforces uniqueness on its
reference — post the same transfer twice and the payable settles twice. The
pieces around the rail do exist: `/v1/marketplace` for listings, `/v1/treasury`
for the reserve and revenue-share policy, `/v1/affiliate` for partner earnings,
`/v1/compliance` for KYC and KYB of a counterparty. The money leaving is the
part you still run at your bank.
**Test mode is not a second key.** Stripe hands out `sk_test_` and `sk_live_`
and you switch by swapping a string. Here there is one key, and `POST
/v1/billing/mode` moves an org between sandbox and real money — platform
authority, never the org itself, because an org that could put itself in test
mode could take priced work for free. Every payment receipt states `test`
outright, so the two can never be confused after the fact.
**Products and variants are not kept in step for you.** A product carries its
variants as a denormalized JSON snapshot alongside the standalone
`/v1/commerce/variant/` rows, and writing one does not update the other. Stripe's
`price` hangs off `product` by reference and cannot drift. Pick which side is
authoritative and write both.
**Sales tax is a number on the cart, not a filing product.** `taxCents` stays
zero until checkout resolves the shopper's region, and the total is subtotal
plus shipping plus tax. There is no registrations-and-returns surface on this
API; `/v1/books` and `GET /v1/billing/ledger` give you the books to file from.
**You do not post your own meter events.** Stripe lets you meter your product's
own units and price them against a `price_…`. Here the rate authority at
`/v1/commerce/rates/entries` is SuperAdmin-only — a rate is cross-tenant money —
and the platform's meter prices calls as they happen, with `GET
/v1/billing/usage` reading the charged rows back. `POST /v1/usage` is a
different thing: it ingests a developer's own AI-account samples. To bill your
own units, put them on invoice lines with `quantity` and `unitPrice`.
+214
View File
@@ -0,0 +1,214 @@
---
title: Supabase
description: Supabase is one Postgres instance with auth, storage, realtime, functions and vectors bolted to it. Here those are separate capabilities behind one host — /v1/iam (159 operations) signs people in, /v1/provisioning (28) mints the database, and /v1/functions (11), /v1/s3 (6), /v1/mq (15) and /v1/index (17) do the rest.
---
Supabase is a Postgres instance with the rest of a backend attached to it: your
users sit in an `auth.users` table, row-level security lives in your policies,
realtime tails the write-ahead log, and a vector is a column type. Here those are
separate capabilities behind one host — `/v1/iam` (159 operations) is the largest
single piece of the migration, `/v1/provisioning` (28) mints the database itself,
and `/v1/functions` (11), `/v1/s3` (6), `/v1/mq` (15) and `/v1/index` (17) cover
the rest.
The structural difference is that one: you cannot `JOIN` your users table,
because your users are not in your database. What the coupling buys Supabase —
a policy no client can go around, because it runs inside the query plan — is the
thing you rebuild deliberately, and it is the first bullet at the bottom.
## Start here
Three calls: mint a key, mint your own Postgres instance, then ask the policy
question that used to live in an RLS clause.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. mint your org's own Postgres instance
curl -sS -X POST https://api.hanzo.ai/v1/provisioning/sql \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"acme"}'
# 3. ask the question the RLS clause used to answer
curl -sS -X POST https://api.hanzo.ai/v1/authz/check \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"sub":"u_314","obj":"task:9f2","act":"write"}'
```
Step 2 answers 201 with `connectionString`, `host`, `port` 5432, `username` and
`password` — a dedicated instance your org alone runs, and the credential is on
that response and on no read beside it. Step 3 is the half Supabase compiled
into the query plan for you, now a call your service makes on the path that
reads the row, and neither body names an org because the key carries it.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/provisioning` | Mints the Postgres instance, and the vector, search, kv, s3, docdb and datastore backends beside it | 28 |
| `/v1/iam` | Everything `auth.users` held: signup, sign-in, OAuth, MFA, passkeys, SCIM, service accounts | 159 |
| `/v1/authz` | One policy question at a time, asked by your service instead of by the query plan | 3 |
## Nouns
### The database
| Supabase | Hanzo |
|---|---|
| Organization | Your org, taken from the validated key. Never a field in the request |
| Project | Project — `/v1/iam/projects`; resources beneath it are org-unique slugs |
| Project ref, and its `.supabase.co` host | No per-project API host. One host, capability in the path |
| Postgres instance | `POST /v1/provisioning/sql` — your org's own instance, port 5432 |
| Connection string from the dashboard | `connectionString` on the 201, returned once |
| PostgREST over your tables | Hanzo Base — a hosted backend for your app: collections, records, access rules and sign-in |
| RLS policy on a table | `POST /v1/authz/check` — a subject, an object, an action, one allow or deny |
| `pg_cron` | `/v1/tasks/` — a durable engine that outlives any one database; bare `/v1/tasks` only redirects |
| `pgmq`, Queues | `/v1/mq` (15) — `POST /v1/mq/stream`, consumers pull at `.../consumer/{name}/next` |
| Database webhooks | `/v1/webhook` (8), deliveries at `GET /v1/webhook/{id}/deliveries` |
| The other backing stores | `/v1/provisioning` also mints datastore, docdb, kv, search, s3 and vector |
### Auth
| Supabase | Hanzo |
|---|---|
| GoTrue, the `auth.users` table | `/v1/iam` (159) — users at `GET /v1/iam/users` |
| `signUp()`, `signInWithPassword()` | `POST /v1/iam/signup`, `POST /v1/iam/signin` |
| `signInWithOtp()`, magic link | `POST /v1/iam/send-verification-code`, then `POST /v1/iam/verification-codes` |
| `signInWithOAuth()` | `GET /v1/iam/oauth/authorize`; providers at `/v1/iam/providers` |
| `signInWithWeb3()` | `GET /v1/iam/web3/nonce`, then `POST /v1/iam/web3/verify` |
| MFA enroll and challenge | `POST /v1/iam/mfa/setup/initiate`, then `POST /v1/iam/mfa/setup/enable` |
| Passkeys | `GET /v1/iam/webauthn/signin/begin`, `POST /v1/iam/webauthn/signin/finish` |
| JWT secret, the JWKS endpoint | `GET /v1/iam/.well-known/jwks` |
| `service_role` key | `POST /v1/iam/service-accounts`, then `POST /v1/iam/service-accounts/{name}/keys` |
| Sessions and refresh tokens | `/v1/iam/sessions`, `POST /v1/iam/oauth/token` |
| SAML SSO and SCIM provisioning | `/v1/iam/scim/v2/Users`, certificates at `/v1/iam/certs` |
| Audit log | `GET /v1/iam/audit-logs` |
### Storage, functions, realtime, vector
| Supabase | Hanzo |
|---|---|
| Storage bucket | `POST /v1/s3/buckets`; `GET /v1/s3/buckets/{bucket}/objects` — one folder level, or `?recursive=true` |
| `storage.upload()` | `POST /v1/s3/buckets/{bucket}/objects` — mints a presigned PUT |
| Edge Function | `POST /v1/functions` — runtime node, python or deno |
| `functions.invoke()` | `POST /v1/functions/{name}/invoke`; logs at `GET /v1/functions/{name}/logs` |
| Function secrets | `envNames` on the definition — names only; values stay in the store |
| Realtime Broadcast | `POST /v1/pubsub/publish` — durable when a stream captures the subject |
| Realtime Postgres Changes | Base collection subscriptions. On a provisioned instance, you publish the change |
| `pgvector` column | `POST /v1/provisioning/vector` — a collection — plus `POST /v1/embeddings` |
| A `match_documents()` RPC | `POST /v1/ai/rag/query` — top-K chunks for a query; `POST /v1/ai/rag/embed` indexes the file first |
| `tsvector` full-text search | `/v1/index` (17) — `POST /v1/index/indexes/{uid}/search`, typos forgiven |
| Studio | console.hanzo.ai |
| Logs and Reports | `/v1/o11y` (381); product analytics at `/v1/event` (12) |
| Supabase AI Assistant | `/v1/ai` (272) — Zen models and the rest of the AI surface |
Two notes on that last table. The presigned PUT means the bytes go straight to
the bucket and the admin credential never leaves the server, so an upload is not
bounded by what the API will accept in a body. Nothing rewrites an object on the
way out, though, so size an avatar before you store it rather than at the
request that serves it.
## The call
Supabase, standing up a project:
```bash
curl -sS -X POST https://api.supabase.com/v1/projects \
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"organization_id": "yrxkpmdgbwvhqnjs",
"name": "acme",
"region": "us-east-1",
"db_pass": "a-password-you-invent"
}'
```
Every client call afterwards carries the project ref in the host and two headers
that must agree with each other:
```bash
curl -sS "https://yrxkpmdgbwvhqnjs.supabase.co/rest/v1/tasks?select=*&status=eq.active" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Authorization: Bearer $USER_JWT"
```
Hanzo:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/provisioning/sql \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"acme"}'
```
The 201 carries `connectionString`, `host`, `port` 5432, `username`, `password`
and `status`. Authorization is a separate call, one question at a time:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/authz/check \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"sub":"u_314","obj":"task:9f2","act":"write"}'
```
The body has one field because the org comes from the validated key: there is no
`organization_id` to get wrong, no anon key to keep in step with a service key,
and no ref in the host to keep in step with either. The logical database is
derived from `name` under an org-namespacing hash, so two orgs that both ask for
`acme` cannot land on one. `status` is `ready` for a shared backend, and a
dedicated instance answers 201 while still launching and reaches `ready` when a
later read reconciles it against the operator's live record — never on a timer
and never fabricated. `POST /v1/authz/check` echoes `sub`, `obj` and `act` beside
`allow`, so a decision you cached or logged carries the question it answered, and
a request that carries no org is refused rather than answered from a default set.
## What does not carry
**Row-level security does not travel into the database.** Supabase compiles
`auth.uid() = user_id` into the query plan, so a client that forgets a filter
still cannot read another user's rows. `POST /v1/authz/check` is a question your
service asks on the path that reads the row. Port every policy to a call, and
treat a missed call as a bug the database will not catch for you.
**PostgREST's URL grammar is not a route here.**
`?select=*&status=eq.active&order=created.desc` is most of Supabase's read API
and it has no equivalent over a provisioned instance — once you hold the DSN you
talk SQL with your own driver. The nearest shape is
`POST /v1/index/indexes/{uid}/search` with `q`, `filter`, `limit` and `offset`,
and it ranks documents in an index rather than rows in a table. If a generated
API over your data is what you actually wanted, that is Base.
**The key you publish cannot read.** Supabase's anon key ships in a browser
bundle and reads tables, with RLS as the backstop. There is a publishable key
here — `POST /v1/account/keys` with `type: publishable`, or an org key at scope
`publish` — but it is write-only: a `pk-` resolves an org at the ingest door and
never a principal, so it writes an event and reads nothing back. A browser that
reads gets a user token through `GET /v1/iam/oauth/authorize` and
`POST /v1/iam/oauth/token`. The tenant still comes from the credential rather
than from the hostname, which is why there is no per-project API host to
provision alongside it.
**Nothing tails a provisioned instance's write-ahead log.**
`POST /v1/pubsub/publish` publishes what you hand it, durably when a stream
captures the subject. Base is the piece that ships change subscriptions over its
own collections, so if the change feed is the reason you chose Supabase, put that
data in Base rather than in a raw SQL instance and publish nothing by hand.
**The database credential comes back once.** `connectionString` and `password`
are on the 201 and on no read beside it; where KMS is configured the password is
sealed there and only a reference is kept. A runbook that re-reads the database
password from the Supabase dashboard has nowhere to go. Store it at creation, or
provision again.
**A branch is a second database, not a fork of the first.** Supabase preview
branches give you an instance seeded from your migrations and tied to the
original. `POST /v1/provisioning/sql` with a different `name` gives you a second
instance and nothing links the two: you run your own migrations into it and
remove it with `DELETE /v1/provisioning/sql/{name}`.
+45 -9
View File
@@ -5,8 +5,44 @@ description: Tavily searches the live web and pulls pages back as text for agent
Tavily gives an agent two primitives: search the live web, and read a page.
`/v1/websearch` (6 operations) answers the first and `/v1/crawl` (1) answers the
second, with `/v1/scrape` (1) beside it for callers that already speak the
firecrawl envelope.
second, with `/v1/websearch/scrape` (1) beside it for callers that already
speak the firecrawl envelope.
## Start here
Mint a key, search the live web, then read one of the URLs that came back — Tavily's single `search()` is two calls here.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. search — answers {query, number_of_results, results:[{url, title, content, engine}]}
curl -sS -X POST https://api.hanzo.ai/v1/websearch \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"q": "Who is Leo Messi?"}'
# 3. read one of those URLs — step 2 gave snippets, this gives the page
curl -sS -X POST https://api.hanzo.ai/v1/crawl \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://en.wikipedia.org/wiki/Lionel_Messi"}'
```
Step 2 returned engine snippets with the engine named on each hit; step 3 turned
one of those URLs into `data.markdown`. That is the whole shape of the move —
Tavily's one round trip is two, and both calls fail soft, so an empty `results`
array and a `success: false` are answers at 200 rather than outages.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/websearch` | Searches the live web. Keyless public engines run concurrently, hits are merged and deduplicated by normalised URL, capped at 30. | 7 |
| `/v1/crawl` | Fetches one URL from inside the cluster and answers with the page as markdown, plus what the page said about itself. | 1 |
## Nouns
@@ -19,7 +55,7 @@ firecrawl envelope.
| `client.extract(urls)` | `POST /v1/crawl` — one URL, answered as markdown |
| `raw_content` | `data.markdown` |
| `failed_results` | `success: false` with the reason in `error`, at 200 |
| A firecrawl-shaped client | `POST /v1/scrape` — `{success, data: {markdown, metadata}}` |
| A firecrawl-shaped client | `POST /v1/websearch/scrape` — `{success, data: {markdown, metadata}}` |
Search takes a validated principal and nothing else. The results are public web
pages, identical for every caller, so there is no tenant to scope and nothing
@@ -84,15 +120,15 @@ to unpack. Walking a site is a loop you write.
**No research task.** `client.research()` is an agent: it plans, searches, reads
and writes a cited report, and you poll a `request_id` for it. There is no
equivalent single call. `/v1/research` is a different thing under a similar name
— an append-only record of experiments and artifacts an org has run, useful for
*keeping* what a research loop produced, not for producing it.
equivalent single call. Plan the loop yourself: search, read the pages you
picked, then compose the report.
**No search options.** There is no `search_depth`, `topic`, `max_results`,
`include_domains` or `exclude_domains`. Engines run concurrently, hits are merged
and deduplicated by normalised URL, and the list is capped at 30. Ranking is
deterministic rather than scored: the first configured engine's hits lead.
**`/v1/scrape` is not the one to reach for.** It is the service-to-service door
and takes the shared service key as its `Authorization` Bearer — a validated
principal does not substitute for it. Use `/v1/crawl`, which takes either.
**`/v1/websearch/scrape` is not the one to reach for.** It is the
service-to-service door and takes the shared service key as its `Authorization`
Bearer — a validated principal does not substitute for it. Use `/v1/crawl`,
which takes either.
+198
View File
@@ -0,0 +1,198 @@
---
title: Temporal
description: Temporal runs durable workflows as your own code against workers you operate. Here that is /v1/auto — 17 operations covering flows, versions, durable runs, schedules and signals — and there is no worker to deploy.
---
Temporal is a coordinator. Nothing of yours runs on it: you deploy worker
processes that poll a task queue, and the server replays an event history
against them so a long-running function survives a crash. `/v1/auto`
(17 operations) is the same job with that half removed — the engine holds the
step tree and executes it, so there is no worker fleet, no task queue to name,
and no workflow type to keep in step between the process that starts a run and
the process that serves it.
Do not confuse it with `/v1/flow` (8), which is a different plane: a graph run
synchronously under a five-minute ceiling, which is precisely what a Temporal
workflow is not.
## Start here
Two calls stand between an empty account and a durable run: create the flow, start it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. create the flow — a step tree the engine holds, not a type a worker registers
curl -sS -X POST https://api.hanzo.ai/v1/auto/flows \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"displayName":"Process order",
"trigger":{"name":"trigger","displayName":"Start","type":"PIECE_TRIGGER",
"strategy":"MANUAL","settings":{"pieceName":"core","triggerName":"manual"}}}'
# 201 {"id":"flow_1","status":"DISABLED","publishedVersionId":""}
# 3. start one durable run of it
curl -sS -X POST https://api.hanzo.ai/v1/auto/flows/flow_1/run \
-H "Authorization: Bearer $HANZO_API_KEY"
# 201 {"id":"run_1","flowId":"flow_1","flowVersionId":"ver_1","status":"RUNNING"}
```
Nothing was deployed between those two calls — no worker process, no task queue,
no workflow type held in agreement between the side that starts a run and the
side that serves it — and the org came from the key, so `flow_1` is the whole
address. Creating does not arm the flow's trigger, which is what
`POST /v1/auto/flows/{id}/enable` is for; step 3 starts a run by hand, and
`GET /v1/auto/runs/run_1` reads it back from the engine while it is still live.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/auto` | Flows, versions, durable runs, schedules and signals. The engine holds the step tree and executes it | 17 |
| `/v1/functions` | Code a step needs, published once and called at `POST /v1/functions/{name}/invoke` | 11 |
| `/v1/sandbox` | A leased machine for a step that must run a real command, at `POST /v1/sandbox/run` | 19 |
## Nouns
### Workflows, runs and signals
| Temporal | Hanzo |
|---|---|
| Namespace, on every call and every worker connection | Your org, taken from the validated key. Never a field in the request |
| Workflow definition registered on a worker | Flow — `POST /v1/auto/flows`, a step tree the engine executes |
| Workflow type, and the task queue it is polled from | Nothing polls, so neither exists. The flow id is the whole address |
| `StartWorkflowExecution` | `POST /v1/auto/flows/{id}/run` — runs the published version, else the latest |
| `SignalWithStartWorkflowExecution` | `POST /v1/auto/hooks/{source}/{event}` — starts every enabled flow subscribed to that key |
| `SignalWorkflowExecution` | `POST /v1/auto/runs/{id}/resume` — releases a run parked at an approval step; the body lands verbatim as that waitpoint's output |
| `DescribeWorkflowExecution` | `GET /v1/auto/runs/{id}` — refreshed from the engine while the run is not terminal |
| `ListWorkflowExecutions` | `GET /v1/auto/runs`, narrowed by `flowId` |
| Activity, and the activity task it becomes | A step. The catalogue is `GET /v1/auto/connectors`; one action alone is `POST /v1/auto/connectors/{id}/run` |
| Schedule — create, pause, unpause | `POST /v1/auto/flows/{id}/enable` and `.../disable`. Arming a polling trigger writes its cron on the durable engine |
| Worker build ids and `GetVersion` patches | `POST /v1/auto/flows/{id}/versions`, and `publishedVersionId` on `PATCH /v1/auto/flows/{id}` pins which one runs |
| A workflow started by a workflow | A step fires `POST /v1/auto/hooks/{source}/{event}`; the `X-Causation-Depth` header bounds the chain |
### The rest of the platform
| Temporal | Hanzo |
|---|---|
| The worker fleet you build, deploy and scale | Nothing to run. Code a step needs is published to `/v1/functions` (11) and called at `POST /v1/functions/{name}/invoke` |
| An activity that must run a command on a real machine | `/v1/sandbox` (19) — lease one, then `POST /v1/sandbox/run` |
| A local activity — one short snippet | `POST /v1/exec` — a snippet in a sandboxed interpreter |
| Task queue used as a durable buffer with acks | `/v1/mq` (15) — streams and pull consumers, drained at `.../consumer/{name}/next` |
| A cron schedule standing alone from any workflow | `/v1/tasks` (5) — the durable engine's own root |
| Memo attached to an execution | `metadata` on `PATCH /v1/auto/flows/{id}` — opaque JSON, stored and returned verbatim |
| Cloud metrics, and the history you read as telemetry | `/v1/o11y` (381) |
| Audit log | `GET /v1/audit` — your own org's trail; a resume is recorded as `automations.run.resume` |
| Web UI | console.hanzo.ai — where the step tree is built |
| Cloud users, service accounts, API keys | `/v1/iam` (159) — `/v1/iam/service-accounts`, `/v1/iam/keys` |
| Codec server, payloads sealed before they are stored | `/v1/kms` (5) |
| An activity whose whole job is telling another system | `/v1/webhook` (8), signed, with a per-attempt log at `.../deliveries`; `/v1/notify` (4) for email or SMS |
## The call
Temporal, starting a run and signalling it:
```bash
# only works if a worker is already polling `orders`
# with ProcessOrder registered on it
temporal workflow start \
--namespace acme-prod \
--task-queue orders \
--type ProcessOrder \
--workflow-id order-8412 \
--input '{"orderId":"8412"}'
temporal workflow signal \
--namespace acme-prod \
--workflow-id order-8412 \
--name approve \
--input '{"by":"ops"}'
```
Hanzo:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/auto/flows/flow_1/run \
-H "Authorization: Bearer $HANZO_API_KEY"
# 201 {"id":"run_1","flowId":"flow_1","flowVersionId":"ver_3","status":"RUNNING"}
curl -sS -X POST https://api.hanzo.ai/v1/auto/runs/run_1/resume \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"by":"ops","approved":true}'
# {"resumed":true}
```
Starting from an event rather than by hand:
```bash
curl -sS -X POST https://api.hanzo.ai/v1/auto/hooks/stripe/invoice.paid \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'X-Idempotency-Key: evt_8412' \
-H 'Content-Type: application/json' \
-d '{"invoice":"in_8412","amount":4200}'
# {"matched":2}
```
Count the identifiers. The start on the left names four — namespace, task queue,
workflow type, workflow id — and each has to match what some worker process
registered, or the call is accepted and nothing ever picks it up. The start on
the right names one, the flow id: the org is the key's, and the version is the
one the flow publishes, so there is no fifth thing to hold in agreement.
The answers are decisions, not receipts. `resume` returns `{"resumed":true}`
once the engine has taken the signal, where `SignalWorkflowExecution` returns
when the signal is written to history and the workflow observes it on some later
task. The hook returns how many flows it started, so zero is a real answer
meaning nothing was subscribed rather than a success you have to go and check. A
second identical post collapses onto the first — `X-Idempotency-Key` dedupes,
and with no header the body is content-hashed instead — so a retrying producer
gets one run, not one per attempt. Over the org's per-minute start budget or its
in-flight ceiling, no run starts and no run id is burned: the failure is the
absence of a run rather than a run in a state you have to reconcile.
## What does not carry
**Workflow code in your own language, replayed.** Temporal's bargain is that
your Go or TypeScript function *is* the workflow, and durability comes from
replaying its event history against it deterministically. A flow here is a step
tree — `PIECE`, `CODE`, `ROUTER`, `LOOP_ON_ITEMS` — that the engine executes. A
`CODE` step runs code, so the logic ports; the worker process, the determinism
rules and `workflow.GetVersion` patching do not.
**No retry policy on the wire.** Temporal takes an initial interval, a backoff
coefficient, a maximum attempt count and a list of non-retryable error types,
per activity. No `/v1/auto` route accepts those fields — a step's settings are
its connector, its action and its input. The engine retries; you do not
shape it step by step.
**No event history, and no query.** `GetWorkflowExecutionHistory` is what makes
replay and `temporal workflow reset` possible, and `QueryWorkflow` reads live
in-workflow state without disturbing it. `GET /v1/auto/runs/{id}` answers a
status, the version that ran and its timestamps. Step-level detail lives in
`/v1/o11y` (381) as spans and logs — readable, but not a thing you can replay.
**No terminate on a run in flight.** `POST /v1/auto/flows/{id}/disable` states
plainly that runs already started are unaffected, and
`DELETE /v1/auto/flows/{id}` removes the flow, its versions and its history
rather than stopping work. That control does exist for a supervised agent —
`/v1/agents/sessions/{id}/pause`, `/resume` and `/stop` on the agents plane (37)
— but it is a different plane, so porting a terminate means moving the work onto
it.
**No search attributes, no visibility query, and no batch over one.** Temporal
indexes custom fields per execution, lists with a SQL-ish `WHERE`, and then
signals or terminates thousands of matches as one batch job. `GET /v1/auto/runs`
takes `flowId` and `limit`, newest first. A flow's `metadata` is a place to keep
your own key, not an index to filter on, and each start and each resume is one
call against one run.
**Continue-as-new has no equivalent.** Temporal rolls a long-lived execution
into a fresh history so it never grows without bound, and the caller keeps
treating it as one thing. A recurring flow here is many runs, each with its own
run id, minted by the trigger its schedule arms. Anything you key on a run id
has to be rewritten around the flow id instead.
+37 -2
View File
@@ -8,6 +8,41 @@ over them, transactional delivery through a provider you configure, and chat on
somebody else's transport. Each has its own address here — `/v1/tel` (10
operations), `/v1/notify` (4), `/v1/channels` (7).
## Start here
Two calls after the key: read the number the org holds, then send from it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. what the org holds — this is where a legal `from` comes from
curl -sS https://api.hanzo.ai/v1/tel/numbers \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. send from one of them
curl -sS -X POST https://api.hanzo.ai/v1/tel/messages \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"from": "+14155550142", "to": "+15558675309", "text": "Hello from Hanzo!"}'
```
Step 3 is refused unless `from` is a number step 2 returned, checked against our
own store rather than the carrier's word — so a 201 is evidence the org really
holds the sender. If step 2 is empty, search with `GET /v1/tel/numbers/available`
and buy with `POST /v1/tel/numbers` before sending.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/tel` | Numbers the org holds, and the calls and messages over them | 10 |
| `/v1/channels` | Chat on Discord, Slack, Teams and Telegram | 7 |
| `/v1/notify` | Transactional email and SMS through the org's own provider credential | 4 |
## Nouns
| Twilio | Hanzo |
@@ -100,8 +135,8 @@ org's own numbers and nothing else.
the row, read back with `GET /v1/tel/messages`.
**No WhatsApp and no RCS.** `/v1/channels` covers Discord, Slack, Teams and
Telegram. All four render text today, so attachments and actions in the send
envelope may not appear as you expect.
Telegram. All four render text today, so attachments and actions are flattened
to one line each after the text — never dropped.
**No subaccounts, no Messaging Services, no regions or edges.** Tenancy is the
org in the key. There is no account tree to mirror and no `region`/`edge` to
+225
View File
@@ -0,0 +1,225 @@
---
title: Upstash
description: Upstash sells Redis, Kafka, QStash and Vector as four products behind four hostnames and ten credential values; here that is /v1/kv (6), /v1/mq (15), /v1/webhook (8) and /v1/index (17) on one hostname with one bearer.
---
Upstash sells four serverless data products, and each one is its own service:
`UPSTASH_REDIS_REST_URL` and its token, `UPSTASH_KAFKA_REST_URL` with a username
and a password, `QSTASH_TOKEN` plus a current and a next signing key,
`UPSTASH_VECTOR_REST_URL` and its token. That is ten values across four
hostnames, and every one of them has to stay in step through every rotation.
Four capabilities answer them on one hostname with one bearer. The structural
difference is where the tenant comes from: there is no per-database endpoint to
address, because the org is minted from the validated key's owner claim and no
request body can name another one.
## Start here
Three calls stand in for `UPSTASH_REDIS_REST_URL` and its token: mint a key,
create a bucket, write to it.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. create the bucket — this is the whole of "provision a database"
curl -sS -X POST https://api.hanzo.ai/v1/kv/sessions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"history":5,"ttl":3600}'
# 3. write a key; the answer is the revision the write created
curl -sS -X PUT https://api.hanzo.ai/v1/kv/sessions/session_1 \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"value":"{\"theme\":\"dark\"}"}'
```
Step 2 answers 201 with the bucket; step 3 answers `{"revision":1}`, and the
fifth write to that key answers 5 with the four before it still readable at
`GET /v1/kv/sessions/session_1/history`. Neither body carries a database URL or
an org, because the bucket is a name under the key's owner claim — another
customer's `sessions` is a different bucket, and neither can address the other's.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/kv` | Buckets of versioned keys: put, get, delete, and up to 64 retained revisions per key | 6 |
| `/v1/mq` | Durable streams over subjects: named pull consumers that acknowledge what they hand back, retention by age, bytes or count | 15 |
| `/v1/webhook` | Registered URLs that receive matching events, signed HMAC-SHA256, one row per delivery attempt | 8 |
| `/v1/index` | Full-text indexes: write documents, search with typo tolerance, declare the filterable attributes | 17 |
## Nouns
### Redis → `/v1/kv` (6) and `/v1/provisioning` (28)
| Upstash | Hanzo |
|---|---|
| Database, with its own REST URL and token | Bucket — `POST /v1/kv/{bucket}`, on the same hostname as everything else |
| `SET key value` | `PUT /v1/kv/{bucket}/{key}`, body `value`, carried verbatim as UTF-8 text |
| `GET key` | `GET /v1/kv/{bucket}/{key}` — the value and the revision it is at |
| `DEL key` | `DELETE /v1/kv/{bucket}/{key}` — a delete marker, so watchers see it happen |
| Per-key `EXPIRE` | `ttl` on the bucket, in seconds, fixed at create |
| No history; the previous value is gone | `GET /v1/kv/{bucket}/{key}/history` — up to 64 revisions, oldest first |
| A Redis endpoint you connect a driver to | `POST /v1/provisioning/kv` — your org's own instance on 6379, DSN returned once |
### Kafka → `/v1/mq` (15) and `/v1/pubsub` (2)
| Upstash | Hanzo |
|---|---|
| Cluster | Your org's subject namespace, derived from the key |
| Topic | Stream — `POST /v1/mq/stream`, capturing one or more subjects |
| `/produce/{topic}` | `POST /v1/pubsub/publish` — `subject`, `data`, `headers` |
| Consumer group and instance | Consumer — `POST /v1/mq/stream/{stream}/consumer`, durable and named |
| `/consume/{group}/{instance}/{topic}` | `POST /v1/mq/stream/{stream}/consumer/{name}/next` |
| `/fetch` at an explicit offset | `GET /v1/mq/stream/{name}/message` — by sequence, with no consumer involved |
| `retention.ms` and `retention.bytes` | `max_age`, `max_bytes` and `max_msgs` on the stream |
| Cleanup policy | `retention` — limits, interest or workqueue |
| Deleting a topic's records | `POST /v1/mq/stream/{name}/purge`, consumers left in place |
### QStash → `/v1/webhook` (8) and `/v1/tasks` (5)
| Upstash | Hanzo |
|---|---|
| Publish to a destination URL | `POST /v1/webhook` — register the URL once, and matching events go to it |
| `Upstash-Signature`, verified with two keys | One HMAC-SHA256 secret, returned once on create |
| Rotating that key | `POST /v1/webhook/{id}/secret` |
| URL groups and topics, for fan-out | `events` — subject patterns such as `commerce.order.>`; empty means every event |
| `GET /v2/events` to learn what happened | `GET /v1/webhook/{id}/deliveries` — one row per attempt, narrowed by `ok`, `retrying` or `failed` |
| Publishing a probe, then polling for it | `POST /v1/webhook/{id}/test` — one signed send, outcome answered inline |
| `Upstash-Cron` on a message | `/v1/tasks` — a durable engine, not a header on a send |
### Vector and Search → `/v1/ai` (272), `/v1/index` (17)
| Upstash | Hanzo |
|---|---|
| Vector index, with its own REST URL | `POST /v1/provisioning/vector` — a collection reached through the gateway |
| `/upsert` | `POST /v1/ai/vectors`, read back at `GET /v1/ai/vectors/{owner}/{name}` |
| Embedding model attached to the index | `POST /v1/embeddings` — a model named in the body, on the same key |
| `/upsert-data` over a document | `POST /v1/ai/rag/embed` — parse, chunk and embed one file under its id |
| Upstash Search index | `POST /v1/index/indexes`, written at `.../documents` |
| Query with typo tolerance | `POST /v1/index/indexes/{uid}/search` |
| Metadata filter on a query | `PATCH /v1/index/indexes/{uid}/settings` declares the filterable attributes first |
| Reranking a candidate set | `POST /v1/rerank` |
| Index size in the console | `GET /v1/index/stats` |
## The call
Four products, four hostnames, four credentials:
```bash
# Redis
curl "$UPSTASH_REDIS_REST_URL/set/session:1/dark" \
-H "Authorization: Bearer $UPSTASH_REDIS_REST_TOKEN"
# Kafka
curl "$UPSTASH_KAFKA_REST_URL/produce/orders" \
-u "$UPSTASH_KAFKA_REST_USERNAME:$UPSTASH_KAFKA_REST_PASSWORD" \
-d '{"value":"{\"id\":1}"}'
# QStash
curl -X POST "https://qstash.upstash.io/v2/publish/https://acme.example/hooks" \
-H "Authorization: Bearer $QSTASH_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"id":1}'
# Vector
curl -X POST "$UPSTASH_VECTOR_REST_URL/upsert" \
-H "Authorization: Bearer $UPSTASH_VECTOR_REST_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"id":"doc-1","vector":[0.1,0.2],"metadata":{"user":"alice"}}'
```
The same four acts, one hostname and one credential:
```bash
# Key-value. The bucket is made once; the put answers the revision it created.
curl -X POST https://api.hanzo.ai/v1/kv/sessions \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"history": 5, "ttl": 3600}'
curl -X PUT https://api.hanzo.ai/v1/kv/sessions/session_1 \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"value": "{\"theme\":\"dark\"}"}'
# Publish. The receipt names the stream and sequence once storage has it.
curl -X POST https://api.hanzo.ai/v1/pubsub/publish \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"subject": "orders.created",
"data": "{\"id\":1}",
"headers": {"Nats-Msg-Id": "order-1"}
}'
# HTTP delivery. Create is one of only two answers that ever carry the secret.
curl -X POST https://api.hanzo.ai/v1/webhook \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://acme.example/hooks",
"events": ["commerce.order.>"]
}'
# Search. The index is created by this write, and the document is findable
# when the call answers.
curl -X POST https://api.hanzo.ai/v1/index/indexes/docs/documents \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '[{"id": "doc-1", "user": "alice", "text": "roadmap"}]'
```
One hostname and one bearer, so a rotation is one value rather than ten across
four dashboards. Nothing in those bodies names an org: the tenant is minted from
the key's owner claim, which is why two customers can both hold a bucket called
`sessions` and neither can address the other's. The answers are decided rather
than promised — the put returns the revision it created, the publish receipt
carries `stream` and `seq` only after the broker has the message on storage, and
the index write is applied before its response, so a Meilisearch client polling
`waitForTask` resolves on its first call.
## What does not carry
**No Redis command surface, and no key scan.** Upstash's REST endpoint speaks
every Redis command — `INCR`, `ZADD`, `LPUSH`, `EVAL` — and `/pipeline` batches
them in one round trip. `/v1/kv` is get, put, delete and history over one string
value per key, one key per call, and it has no route that enumerates a bucket.
Sorted sets, counters and `SCAN` live on the instance `POST /v1/provisioning/kv`
gives your org, which you reach with a driver on 6379.
**No delay and no cron on a send.** QStash carries scheduling as headers on the
message: `Upstash-Delay`, `Upstash-Not-Before`, `Upstash-Cron`.
`POST /v1/pubsub/publish` sends now. Scheduling is `/v1/tasks`, a durable engine
that outlives any one message, so porting a header to it is a rewrite rather
than a rename.
**One signing secret, and no overlap window.** QStash hands a receiver a current
and a next key so verification survives a rotation.
`POST /v1/webhook/{id}/secret` mints a new secret and the old one stops working
the instant the call returns. Change the value on the subscriber first, then
rotate.
**The pull acknowledges.** `POST /v1/mq/stream/{stream}/consumer/{name}/next`
acknowledges what it hands back, so there is no offset to commit afterwards and
nothing is redelivered. A Kafka consumer that commits after a successful handler
does not port over HTTP; explicit acknowledgement lives on the NATS port, which
is where a worker that needs at-least-once should sit.
**No region and no read replicas.** An Upstash database is pinned to a region
and can carry global read replicas, with the consistency that implies. A
provision request here is a name and an optional app instance to bind the DSN
into — there is no region field and no replica count, so there is no replica to
read a stale value from.
**Filterable attributes are declared, not inferred.** Upstash Vector filters on
whatever metadata you happened to upsert. Here an attribute has to be listed by
`PATCH /v1/index/indexes/{uid}/settings` before a search `filter` may constrain
it. That is one more call at setup, and it is what makes per-user narrowing
within an index a property of the index rather than of each query.
+215
View File
@@ -0,0 +1,215 @@
---
title: HashiCorp Vault
description: Vault stores secrets, mints credentials and decides who may read them. Here that is three capabilities — /v1/kms (5) holds the secret, /v1/iam (159) and /v1/authz (3) hold the identity and the decision — and none of them has a mount table.
---
Vault is one server doing four jobs: holding static secrets, minting dynamic
ones, encrypting on demand, and deciding whether a token may act. Those come
apart here — `/v1/kms` (5 operations) is custody, `/v1/iam` (159) is identity and
credentials, `/v1/authz` (3) is the decision, and `/v1/provisioning` (28) is the
backend a dynamic credential used to point at. The difference that costs the most
to port is the mount: in Vault a path is at once where a secret lives, which
engine serves it, and what a policy matches, so `secret/data/backend/prd` has to
be spelled the same way in the write, in the policy, and in the role bound to
that policy. Here `path` and `env` are storage coordinates and nothing else —
authority is org membership, and the org is derived from the key rather than
named in the request.
## Start here
Mint a key, seal one secret, list what is held — three calls, no mount and no policy.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. seal one secret — env is required on a write and has no default
curl -sS -X POST https://api.hanzo.ai/v1/kms/secrets \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"path":"backend","env":"prd","name":"SIGNING_SECRET","value":"s3cr3t"}'
# 3. see what your org holds
curl -sS "https://api.hanzo.ai/v1/kms/secrets?path=backend&env=prd" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
The write sealed the value under a fresh per-secret data key wrapped by the
master key; the listing came back with name, path, environment and sealing
scheme, and no value. Neither call named an org — the store root comes from the
validated key — so there is no namespace, mount name or policy path to spell
twice and get out of step.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/kms` | Custody. Seals a secret, enumerates what is held, reports whether the store is open | 5 |
| `/v1/iam` | Identity and credentials — users, service accounts, roles, permissions, tokens | 159 |
| `/v1/authz` | The decision. A subject, an object and an action in, one allow or deny out | 3 |
## Nouns
### Secrets
| Vault | Hanzo |
|---|---|
| Namespace, sent as `X-Vault-Namespace` | Your org, taken from the validated key. Never a header, never a field |
| Mount point, `sys/mounts` | Nothing to mount. `/v1/kms/secrets` is always there |
| KV v2 path, `secret/data/backend/prd` | `path` and `env` — two flat fields on the request |
| `POST /v1/secret/data/{path}` | `POST /v1/kms/secrets`, body `path`, `env`, `name`, `value` — one secret per write |
| `LIST /v1/secret/metadata/{path}` | `GET /v1/kms/secrets` — name, path, environment, sealing scheme. Never a value |
| KV v2 version history, `?version=N` | No history. A write upserts, and the value it replaced is gone |
| Seal status, `sys/health` | `GET /v1/kms/health` — 200 only when the store is open and a master key is present |
| Envelope encryption under the barrier | A fresh per-secret data key wrapped by the master key; plaintext never reaches disk |
### Identity, policy and tokens
| Vault | Hanzo |
|---|---|
| ACL policy in HCL, `sys/policies/acl/{name}` | `POST /v1/iam/permissions` — a grant naming who, which action, which resource |
| Identity group | `POST /v1/iam/roles` |
| Identity entity and entity-alias | A user at `/v1/iam/users`; joined identities at `/v1/iam/linked-accounts` |
| `sys/capabilities-self` | `POST /v1/authz/check` — a subject, an object and an action in, one allow or deny out |
| Auth method mounted at `auth/{type}` | `GET /v1/iam/auth/methods` — which methods an application has switched on |
| AppRole role-id plus secret-id | `POST /v1/iam/service-accounts` — one identity, one key, secret half shown once |
| `POST /v1/auth/approle/login` | `POST /v1/kms/auth/login` for the secret store, `POST /v1/iam/oauth/token` for everything else |
| `POST /v1/auth/token/create` | `POST /v1/iam/tokens/issue` |
| `GET /v1/auth/token/lookup-self` | `GET /v1/iam/whoami`, or `POST /v1/iam/oauth/introspect` for a token you did not mint |
| `POST /v1/auth/token/revoke` | `POST /v1/iam/oauth/revoke` for a token, `POST /v1/iam/revoke-user-keys` for a key |
| Root token, used for break-glass | `POST /v1/iam/assume`, then `POST /v1/iam/release`. The token still names the operator |
| Audit device, a file or syslog sink you attach | `GET /v1/audit` and `GET /v1/iam/audit-logs` — already written, already queryable |
### The engines
| Vault | Hanzo |
|---|---|
| `GET /v1/database/creds/{role}`, a user per request | `POST /v1/provisioning/sql` — your org's own PostgreSQL instance, not a user on a shared one |
| `database/config` and rotate-root | `POST /v1/iam/service-accounts/{name}/keys` — mints the successor and kills the prior key in one call |
| A KV or object engine you mount | `POST /v1/provisioning/kv`, `POST /v1/provisioning/s3` |
| `POST /v1/transit/sign/{name}` | `POST /v1/wallet/{id}/sign` — secp256k1 over a 32-byte digest, key never leaving custody |
| `POST /v1/transit/keys/{name}/rotate` | `POST /v1/wallet/{id}/keys` |
| `POST /v1/pki/issue/{role}` for a public host | ACME, configured at `PUT /v1/ingress/tls` |
| Token signing key, `identity/oidc/key` | `POST /v1/iam/certs` names the key and its JWKS `kid`; the deployment supplies the material |
| A credential scoped to one prefix for one job | The upload grant on `POST /v1/projects/{slug}/deployments` — minted on the 202, never served again |
## The call
Vault, giving CI read access to one production secret:
```bash
# 1. the policy
curl -sS -X PUT "$VAULT_ADDR/v1/sys/policies/acl/ci-read" \
-H "X-Vault-Token: $VAULT_TOKEN" -H "X-Vault-Namespace: acme" \
-d '{"policy":"path \"secret/data/backend/prd\" { capabilities = [\"read\"] }"}'
# 2. a role bound to it
curl -sS -X POST "$VAULT_ADDR/v1/auth/approle/role/ci" \
-H "X-Vault-Token: $VAULT_TOKEN" -H "X-Vault-Namespace: acme" \
-d '{"token_policies":"ci-read","token_ttl":"1h"}'
# 3 and 4. the two halves of the credential
curl -sS "$VAULT_ADDR/v1/auth/approle/role/ci/role-id" \
-H "X-Vault-Token: $VAULT_TOKEN"
curl -sS -X POST "$VAULT_ADDR/v1/auth/approle/role/ci/secret-id" \
-H "X-Vault-Token: $VAULT_TOKEN"
# 5. exchange them for a client token
curl -sS -X POST "$VAULT_ADDR/v1/auth/approle/login" \
-d '{"role_id":"'"$ROLE_ID"'","secret_id":"'"$SECRET_ID"'"}'
# 6. read
curl -sS "$VAULT_ADDR/v1/secret/data/backend/prd" \
-H "X-Vault-Token: $CLIENT_TOKEN" -H "X-Vault-Namespace: acme"
```
Hanzo, the same thing:
```bash
# 1. an identity for the program; the secret half is shown once, here
curl -sS -X POST https://api.hanzo.ai/v1/iam/service-accounts \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"ci"}'
# 2. what it may see
curl -sS "https://api.hanzo.ai/v1/kms/secrets?path=backend&env=prd" \
-H "Authorization: Bearer $CI_KEY"
```
The write, side by side:
```bash
curl -sS -X POST "$VAULT_ADDR/v1/secret/data/backend/prd" \
-H "X-Vault-Token: $VAULT_TOKEN" -H "X-Vault-Namespace: acme" \
-d '{"data":{"SIGNING_SECRET":"s3cr3t"}}'
curl -sS -X POST https://api.hanzo.ai/v1/kms/secrets \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"path":"backend","env":"prd","name":"SIGNING_SECRET","value":"s3cr3t"}'
```
Six requests to two, and five identifiers that must agree to none. Vault's
namespace header, mount name, policy path string, role name and KV path all have
to line up, and the one that most often does not is the path: KV v2 puts `data/`
in the API path and not in the CLI path, so a policy copied from
`vault kv get secret/backend/prd` denies a read of `secret/data/backend/prd`, and
the 403 names the read rather than the policy that refused it.
Nothing here can drift that way, because `path` never appears in a policy to
disagree with itself and the store root is derived from the org claim in the key
— a caller has no way to write down another tenant's namespace. Note which
credential does which half: the CI key reads, and cannot write, because a machine
credential holds no org membership and so is never an org admin.
## What does not carry
**No transit engine.** `transit/encrypt` and `transit/decrypt` take your
plaintext and hand back ciphertext, so an application never holds a key. Sealing
here happens beneath `/v1/kms/secrets` — a per-secret data key wrapped by the
master key — and is not exposed as an operation over bytes you supply. Signing
is: `POST /v1/wallet/{id}/sign` produces a secp256k1 signature over a 32-byte
digest, with the private key never leaving KMS or MPC custody. That is a signing
service, not a general cipher.
**No route returns a secret's value.** `GET /v1/kms/secrets` enumerates what is
held and there is no per-secret read beside it. `vault kv get` ports to a
listing; `vault agent` template rendering and `consul-template` do not, because
both exist to pull values into a file on disk.
**No leases, so nothing to renew.** Every dynamic thing in Vault carries a lease,
a TTL, `sys/leases/renew` and a `max_ttl` that eventually forces a re-auth. Keys
here are valid until replaced, and replacement is one call:
`POST /v1/iam/service-accounts/{name}/keys` mints the successor and invalidates
the prior key in the same operation, so there is no window in which both are
live. Revocation is immediate rather than scheduled — a token retired at
`POST /v1/iam/oauth/revoke` reads as dead at `POST /v1/iam/oauth/introspect` on
the next call, where Vault's expiration manager revokes in the background and
retries on failure. Response wrapping, which returns a single-use token in place
of a payload, goes with the leases.
**No dynamic database users.** `database/creds/{role}` makes a fresh Postgres
user per request. `POST /v1/provisioning/sql` gives your org its own PostgreSQL
instance in its own namespace, with the admin password returned once at create
and otherwise held in KMS. The isolation boundary moves from a short-lived user
to a dedicated instance — a smaller blast radius and a different operational
habit to run. The one per-job scoped credential that does exist is the upload
grant on `POST /v1/projects/{slug}/deployments`, which is minted on the 202 and
cannot be fetched again.
**No private CA.** Public certificates are issued by ACME and configured at
`PUT /v1/ingress/tls`. What has no counterpart is `pki/issue/{role}` — a leaf and
its private key minted from a CA of yours, for service-to-service mTLS.
`POST /v1/iam/certs` registers a signing certificate's identity, its name being
the JWKS `kid`, and key material never travels through the call: the deployment
supplies it under the registered name.
**No `sys/` to operate.** Vault is software you run — init, Shamir shares, an
unseal quorum, raft peers, a mount table, audit devices you attach and tune.
None of that ports because none of it is yours. `GET /v1/kms/health` reports
whether the store is open and a master key is present, and that is the whole
operational surface.
+39 -2
View File
@@ -19,6 +19,43 @@ app: use `/v1/platform`. If it is `output: 'export'` — a directory of HTML —
Sites plane is simpler and cheaper. This documentation site is the first; the
marketing site is the second.
## Start here
Register the app against your repo, then deploy it — two calls, no dashboard.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. register the app against a git repo — it lands in draft, nothing built yet
curl -sS -X POST https://api.hanzo.ai/v1/platform/projects/default/apps \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"web","source":"git","repo":{"url":"https://github.com/acme/web","branch":"main"},"port":3000}'
# 3. build it and run it
curl -sS -X POST https://api.hanzo.ai/v1/platform/projects/default/apps/web/deploy \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"commit":"main"}'
```
Step 2 answers 201 with the app in `draft` and its canonical HTTPS host already
attached; the `default` project needs no creating, because every org has one.
Step 3 answers 202 — an accepted deployment, not a live one — and comes back
`building` while BuildKit runs, which is why the logs call below exists.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/platform` | Builds a repo and runs it: deploy, per-deployment logs, preview, promote, rollback, env, domains | 37 |
| `/v1/projects` | Serves a directory you already built, as content-addressed releases with domains and purge | 27 |
| `/v1/functions` | Publishes one handler with no app around it, and invokes it | 11 |
## Nouns
Vercel is not only deploys, and neither is this. The left column is their
@@ -76,7 +113,7 @@ curl -sS -X POST \
"https://api.hanzo.ai/v1/platform/projects/acme/apps/web/deploy" \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"ref":"main"}'
-d '{"commit":"main"}'
```
Then watch it, and promote when you are happy:
@@ -96,7 +133,7 @@ Static instead, when the output needs no server — enqueue, upload, complete:
curl -sS -X POST https://api.hanzo.ai/v1/projects/my-site/deployments \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"branch":"main","commit":"'"$GIT_SHA"'"}'
-d '{"commit":"'"$GIT_SHA"'"}'
```
The bytes go straight to object storage with the grant that call hands back, so
+249
View File
@@ -0,0 +1,249 @@
---
title: WorkOS
description: WorkOS sells enterprise readiness as four products — SSO, SCIM, audit logs, an admin portal — wired to one organization. Here they are one capability, /v1/iam (159 operations), addressing one user record.
---
WorkOS makes an app enterprise-ready: SAML and OIDC sign-on, SCIM directory
sync, an audit trail, and a hosted portal your customer's IT admin configures it
all in. `/v1/iam` (159 operations) is all four, with `/v1/audit` (1) for the
tamper-evident trail and `/v1/authz` (3) for a fine-grained check.
The difference worth knowing before you port anything: **WorkOS gives one human
three identifiers.** An SSO `prof_…`, a directory `directory_user_…`, an AuthKit
`user_…`, and you reconcile them on email. Here `{owner}/{name}` is the whole
address, and `/v1/iam/scim/v2/Users/{owner}/{name}` and
`/v1/iam/users/{owner}/{name}` are the same person at the same coordinates — the
directory writes what OIDC authenticates.
## Start here
Mint a key, attach a customer's identity provider, then read what their login
screen should draw.
```bash
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. attach the IdP — WorkOS's Connection, metadata XML taken verbatim
curl -sS -X POST https://hanzo.id/v1/iam/providers \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"owner": "acme",
"name": "okta",
"displayName": "Acme Okta",
"issuerUrl": "https://acme.okta.com",
"metadata": "<EntityDescriptor ...>"
}'
# 3. read the sign-in methods one application has switched on
curl -sS "https://hanzo.id/v1/iam/auth/methods?clientId=$CLIENT_ID" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
The provider's address is `acme/okta` — the org plus the name you chose — so step
2 hands back no identifier you have to keep. Step 3 is what a login screen reads
before anyone has signed in, and the provider from step 2 joins that answer when
you switch it on for that application, not when a URL carries `connection=`.
### Core capabilities
| Capability | What it does | Operations |
|---|---|---|
| `/v1/iam` | SAML and OIDC sign-on, SCIM provisioning, sessions, MFA, orgs and applications — WorkOS's four products at one address | 159 |
| `/v1/audit` | The tamper-evident trail, filtered and paged, answered synchronously — no export job, no CSV | 1 |
| `/v1/authz` | The question WorkOS FGA answers: subject, object and action in, allow or deny out | 3 |
## Nouns
### Sign-on
| WorkOS | Hanzo |
|---|---|
| Organization (`org_…`) | Org — `/v1/iam/organizations`, addressed `{owner}/{name}` |
| Connection (`conn_…`, SAML or OIDC) | Provider — `/v1/iam/providers`, switched on per application |
| IdP metadata XML, entity id | `metadata` and `issuerUrl` on the provider, taken verbatim |
| ACS URL, NameID, attribute map | `samlReplyUrl`, `useEmailAsSamlNameId`, `samlAttributes` on the application |
| `GET /sso/authorize` | `GET /v1/iam/oauth/authorize` |
| `POST /sso/token` | `POST /v1/iam/oauth/token` |
| Profile (`prof_…`) | `GET /v1/iam/oauth/userinfo`, mapped through the provider's `userMapping` |
| Client id and secret | `/v1/iam/applications` — one per product your people sign in to |
| Which buttons the login page draws | `GET /v1/iam/auth/methods?clientId=…`, read before anyone signs in |
| Certificate rotation | `/v1/iam/certs` — the name you register is the JWKS `kid` |
| API key, test and live | The same bearer as everything else — `/v1/iam/keys` mints it |
### Directory
| WorkOS | Hanzo |
|---|---|
| Directory (`directory_…`) | Nothing. SCIM writes the org's own users |
| Directory User (`directory_user_…`) | `/v1/iam/scim/v2/Users/{owner}/{name}` — the user's own address |
| `GET /directory_users` | `GET /v1/iam/scim/v2/Users` |
| Provisioning a new hire | `POST /v1/iam/scim/v2/Users` |
| Deprovisioning | `DELETE /v1/iam/scim/v2/Users/{owner}/{name}` — sessions stop at once |
| SCIM discovery | `/v1/iam/scim/v2/ServiceProviderConfig`, `/Schemas`, `/ResourceTypes` |
| Directory Group | Nothing. See below |
| Organization membership | `/v1/iam/memberships` |
| Invitation | `/v1/iam/invitations` |
| Roles and permissions | `/v1/iam/roles`, `/v1/iam/permissions` |
| `POST /fga/v1/check` | `POST /v1/authz/check` — body `{sub, obj, act}`, one allow or deny |
Eleven SCIM operations across seven addresses, not an enterprise tier. A `PUT`
replaces only the attributes SCIM describes; multi-factor enrolment is not one of
them, so a routine sync from an IdP cannot quietly strip somebody's second factor
or bring a deleted account back.
### Audit and events
| WorkOS | Hanzo |
|---|---|
| `POST /audit_logs/events` | `POST /v1/iam/audit-logs` |
| `POST /audit_logs/exports`, then poll | `GET /v1/audit` — one call, filtered, paged, with the matched total |
| Audit log schema, declared first | Nothing to declare. The action is a string on the row |
| Actor, target, context | `sub`, `resource`, `resourceId`, `sourceIp`, `userAgent`, `requestId` |
| Denied attempts | `result` is `success`, `deny` or `error`, all in the one trail |
| `GET /events` polling | `GET /v1/webhook/{id}/deliveries` — every attempt, status and duration |
| Webhook endpoint, dashboard-only | `/v1/webhook` (8) — create, rotate the secret, send a test |
| Log Streams to a SIEM | See below |
### The rest
| WorkOS | Hanzo |
|---|---|
| Admin Portal (`POST /portal/generate_link`) | console.hanzo.ai — their own admin, in their own org |
| AuthKit hosted login | [hanzo.id](https://hanzo.id), branded by domain |
| Magic Auth | `POST /v1/iam/send-verification-code`, then `POST /v1/iam/login` with the code |
| MFA enrolment | `/v1/iam/mfa/setup/initiate`, then `/v1/iam/mfa/setup/enable` |
| Passkeys | `/v1/iam/webauthn/signin/begin` and `/finish` |
| Session revocation | `DELETE /v1/iam/sessions/{owner}/{name}/{application}` |
| Impersonation | `POST /v1/iam/assume` — the token still names the operator |
| M2M credentials | `/v1/iam/service-accounts` — the secret half is shown once |
| Vault | `/v1/kms` (5) — sealed at rest, org taken from the key |
| Radar bot signals | `/v1/risk` (11) — `POST /v1/risk/score`, against the org's own model |
| The SOC 2 posture you show a customer | `/v1/trust` (17) and `/v1/compliance` (17) |
## The call
Connecting a customer's identity provider. WorkOS:
```bash
curl -sS -X POST https://api.workos.com/organizations \
-H "Authorization: Bearer $WORKOS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"Acme","domain_data":[{"domain":"acme.com","state":"verified"}]}'
# -> {"id":"org_01H8..."}
curl -sS -X POST https://api.workos.com/portal/generate_link \
-H "Authorization: Bearer $WORKOS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"organization":"org_01H8...","intent":"sso"}'
# -> a link that expires in five minutes
curl -sS "https://api.workos.com/connections?organization_id=org_01H8..." \
-H "Authorization: Bearer $WORKOS_API_KEY"
# -> {"data":[{"id":"conn_01H8...","state":"active"}]}
```
Three calls, and the third has no answer until somebody you do not employ finishes
the second. You now hold `org_…`, `conn_…` and `client_…`, and every sign-in URL
has to carry the right pair.
Hanzo:
```bash
curl -sS -X POST https://hanzo.id/v1/iam/organizations \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"acme","displayName":"Acme"}'
curl -sS -X POST https://hanzo.id/v1/iam/providers \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"owner": "acme",
"name": "okta",
"displayName": "Acme Okta",
"issuerUrl": "https://acme.okta.com",
"metadata": "<EntityDescriptor ...>"
}'
```
Two calls, both synchronous, and the provider's address is `acme/okta` — the org
you just named plus the name you chose. There is no third identifier to store,
because the address is the identifier. `metadata` takes the IdP's XML as it came,
so nothing is transcribed field by field.
Sign-in then starts the ordinary way, with no `connection=` on the URL:
```bash
curl -sS -i "https://hanzo.id/v1/iam/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=https://app.example.com/callback"
```
Which IdPs a sign-in may use is a property of the application, so a login page
reads `GET /v1/iam/auth/methods?clientId=…` and draws what is switched on.
Onboarding a second customer changes no code and no URL.
Reading the trail. WorkOS starts a job and collects it:
```bash
curl -sS -X POST https://api.workos.com/audit_logs/exports \
-H "Authorization: Bearer $WORKOS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"organization_id":"org_01H8...","range_start":"2026-08-01T00:00:00Z","range_end":"2026-08-22T00:00:00Z"}'
# -> {"id":"audit_log_export_01H8...","state":"pending","url":null}
curl -sS https://api.workos.com/audit_logs/exports/audit_log_export_01H8... \
-H "Authorization: Bearer $WORKOS_API_KEY"
# poll until state is "ready", then fetch the CSV at url
```
Hanzo answers it:
```bash
curl -sS "https://api.hanzo.ai/v1/audit?since=2026-08-01T00:00:00Z&result=deny&pageSize=50&p=1" \
-H "Authorization: Bearer $HANZO_API_KEY"
```
There is no `organization_id` in that request. The org is the validated
principal's, so a filter narrows the trail and has no way to widen it past the
tenant. Every row carries `seq`, `hash` and `prevHash` — a SHA-256 over the row's
own canonical JSON folded with its predecessor's — so a reader recomputes the
chain and a deleted or reordered row breaks it at that point. That is why the
answer is synchronous: there is nothing to assemble.
## What does not carry
**No `generate_link` handoff.** WorkOS mints a single-use URL with an `intent` —
`sso`, `dsync`, `audit_logs` — good for five minutes, and you email it to somebody
who has no account with you. Here the customer's admin has an account: invite them
with `POST /v1/iam/invitations`, they sign in at hanzo.id and administer their own
org in console.hanzo.ai. Better every day after the first, worse on the first.
**No SCIM Groups.** The directory provisions people and only people:
`/v1/iam/scim/v2/Users` with nothing beside it. WorkOS's `directory_group` and its
membership pushes have no counterpart, so mapping an IdP group to access is a
write you make against `/v1/iam/roles` after the sync rather than something the
IdP drives.
**Raw IdP attributes are not passed through.** `POST /sso/token` hands back a
profile carrying `raw_attributes` — everything the assertion held, asked for or
not. Claims here come from `GET /v1/iam/oauth/userinfo` through the provider's
`userMapping`, so an attribute the mapping does not name never arrives. Enumerate
what you actually read before you cut over.
**No export job and no file.** `GET /v1/audit` pages JSON and answers now; nothing
produces a CSV at a signed URL. A yearly archive is your loop over `p`, not a job
you start and come back to.
**No managed Log Streams.** WorkOS ships the trail into Datadog, Splunk or S3 for
you. `/v1/webhook` pushes each event to a URL you own, signed HMAC-SHA256 and
retried at 1s, 5s then 25s, with `GET /v1/webhook/{id}/deliveries` recording every
attempt. You write the last hop.
**Audit is two routes, not one.** `/v1/audit` is the platform trail: hash-chained,
read-only, and the one you query. `/v1/iam/audit-logs` is the identity trail and
takes a `POST` for entries your own systems raise. WorkOS has one `audit_logs`
noun; port your emits to the second address and your reads to the first.