mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
@@ -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}`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user