Publisher API
Web and native sign-in, personal ownership, publication and registry reads.
This reference describes the account and publishing server contract. It is not part of window.Hay: plugins cannot manage accounts or obtain the desktop host’s bearer session.
The default authenticated API origin is https://app.hay.so; public catalog reads use https://plugins.hay.so. The legacy account origin remains compatible as described below. Earlier live inbox-code, publication, history and revocation checks used the legacy host and existing D1 storage. The new origin’s deployed routing/HTTPS checks passed using its published address and normal TLS validation. Live installed-app credential retention/scoped fallback and Chrome’s authenticated legacy-to-new browser carry are also verified; canonical native fallback retirement remains regression-covered only. See the operator verification record for the release and integration scope.
Legacy account compatibility
https://accounts.hay.so remains a served account/API origin for older clients. Bearer API requests are handled directly, without redirecting credentials to app.hay.so. New clients use the new origin; public/custom registry credential rules are unchanged.
Native encrypted-session migration accepts only the exact old production origin to the exact new production origin. The host tries the canonical account API first. Before its first canonical response, only requests carrying the retained legacy credential may retry the original HTTPS server on ENOTFOUND or EAI_AGAIN. Fresh email-code requests, custom account servers, HTTP errors, certificate failures and other transport failures never qualify. The encrypted saved origin remains legacy while fallback is used and is rewritten after successful canonical validation; a canonical response retires fallback for the running client. Manage on web may accept a validated legacy URL for these migrated logins. This is not a general cross-origin credential migration.
Browser cookies stay host-only, with no Domain attribute. A valid legacy web session moves through a short-lived one-use handoff bound to the receiving browser and explicit identity confirmation. The new host sets its own HTTP-only cookie without exposing either session to JavaScript or asking for another email code solely for the move. Handoff IDs are not bearer sessions; expiry, replay and approving-session revocation still apply. The migrated cookies share the legacy browser session’s revocation boundary rather than minting an independently surviving login. A validated canonical /sign-in destination can preserve a pending desktop request through migration. See Hay accounts for the user flow and the operator record for deployment evidence.
Native email-code fallback
These routes support the trusted native host’s fallback. Web pages use the cookie-backed /web/ routes below rather than obtaining a bearer session in JavaScript. Email-and-password signup and password login are not enabled.
| Method and path | Request or result |
|---|---|
POST /api/auth/email-otp/send-verification-otp | JSON {email, type: 'sign-in'} requests a code. |
POST /api/auth/sign-in/email-otp | JSON {email, otp} verifies the code and returns the signed-in user and session token. |
GET /api/auth/get-session | Validate the current account session. |
POST /api/auth/sign-out | Revoke the presented account session. |
Email verification signs in an existing account or creates a new one only after a correct code. Sending a code alone does not create an account. Codes have six digits, expire after five minutes, and allow three wrong attempts. A used code cannot be replayed. The server also limits requests per address and IP; a throttled caller must wait rather than retry rapidly.
Keep codes and session tokens out of agent context, plugin data, logs and distributed code. A user should enter a verification code directly in the trusted sign-in surface.
Browser sessions
All web account actions live on the configured account origin, normally https://app.hay.so. On HTTPS deployments, the browser uses a host-only __Host-hay_web_session cookie with Secure, HttpOnly, SameSite=Lax and Path=/; there is no Domain attribute. Account pages and JSON session responses are private/no-store and vary by Cookie. Account HTML uses Referrer-Policy: same-origin; account API JSON uses Referrer-Policy: no-referrer.
| Method and path | Request or result |
|---|---|
GET /web/session | {connected:false} or a sanitized connected profile containing user, publisher if present, expiry and registry addresses. No bearer token. |
POST /web/send-code | {email} requests the inbox code; returns {sent:true}. |
POST /web/verify-code | {email,code} signs in or creates the verified account, sets its HTTP-only cookie and returns sanitized profile status. |
GET /web/handles?handle=<handle> | Authenticated, read-only handle availability; {handle,available}. |
POST /web/profile | {handle,name,expectedUserId} saves the current account’s profile and returns sanitized session status with its publisher, HTTP 201. |
POST /web/sign-out | Form or JSON returnTo; revokes this browser session, clears its cookie and returns a validated HTTP 303 redirect. |
Browser POSTs require the exact account origin, except sign-out also accepts the configured marketplace origin. Return destinations are confined to the configured registry origin’s root, plugin or docs pages, or the account origin’s /account. A narrowly validated, live /sign-in?request=<id> return is allowed for explicit account switching. There is no arbitrary-origin CORS or cookie-backed publication API.
Optional profile setup
Web Choose your handle and native profile setup use the same personal publisher principal and namespace as publication. They do not create a second identity, release or capability grant. The offer appears after verification when a profile is missing; Do this later continues the original request or validated return destination. Complete profiles skip it, existing profileless app sessions stay usable, and a profile already offered on the browser flow is not immediately offered again natively.
Both availability routes require an authenticated verified session: /web/handles uses the host cookie and /v1/handles the native bearer. They return {handle,available}. Invalid or reserved handles return 400; a taken valid handle returns available:false. Lookup is read-only and advisory, not a reservation. Final profile creation can still return 409 if another account claimed the handle. Handle lookup is limited to 60 requests per account per minute; profile saves to 30 per hour.
POST /web/profile requires the exact account Origin, a valid verified cookie session and an expectedUserId equal to that current session’s user. An account switch or ownership mismatch returns 409. The response is sanitized account status, never a bearer token. Native setup retains POST /v1/publisher with {handle,name} and checks the expected user in the host before saving; this expected identity is not delegated to a plugin.
Both fields are required when saving: a 1–80-character display name and the validated public handle below. The handle cannot be renamed yet. An already-profiled account can return 409 on another creation attempt; the client refreshes and reuses its existing publisher instead of creating or renaming one. Saved plugin IDs, local records and publication histories keep their identities. Profile setup never publishes code or bypasses release review. Account operations remain outside the plugin SDK.
Browser approval for native sign-in
| Method and path | Request or result |
|---|---|
POST /v1/login-requests | Native host sends {challenge}; returns 201 with {id,url,expiresAt}. |
GET /web/login-requests/<id> | {kind:'desktop',expiresAt,state} where state is pending or approved; never returns a callback code. |
POST /web/login-requests/<id>/approve | Explicit browser action with its valid cookie and {}; returns {approved:true,appURL}. |
POST /v1/login-requests/<id>/exchange | Native host sends {verifier,code} to exchange once for its own session. No-code requests return 202 pending without a session. |
POST /v1/login-requests/<id>/cancel | Native host sends {verifier} to consume and cancel the pending request. |
The native host generates the PKCE verifier and its SHA-256 base64url challenge. The verifier remains in the host process. Requests expire after five minutes; their 43-character base64url IDs are not login grants.
Approval creates a separate, single-use 43-character callback code. Only the explicit approval POST returns hay://sign-in?request=<id>&code=<code>; a page visit or status GET never supplies it. The user then chooses Open Hay. Native completion requires both the matching verifier and callback code, the current pending request and a still-valid approving session. Polling with the verifier alone cannot sign in. The exchange consumes the request atomically before issuing a native session; cancellation, expiry and replay fail closed.
Reapproving explicitly from the same browser session can rotate the callback code while the request remains pending. Keep callback values only for that user-initiated handoff; do not persist, log, forward or put them into agent context. Callback links accept exactly one request and one code, with no extra parameters, credentials or fragments.
App-to-web account handoff
POST /v1/web-handoff requires the native bearer session and accepts {returnTo?}. It returns a single-use account-origin /continue?handoff=<id> URL, expiring after two minutes. It contains no session token.
The initial /continue visit binds the handoff to that browser using a separate host-only HTTP-only flow cookie. GET /web/handoffs/<id> requires that binding and returns only the identity and expiry. Explicit POST /web/handoffs/<id>/accept with {} consumes the handoff, validates the approving native session and sets or reuses the correct browser session. A normal app-to-web handoff returns a validated account-origin /account?returnTo=... destination. Legacy browser carry can instead return a narrowly validated canonical /sign-in request destination. The client replaces the URL to remove the handoff ID.
Opening the page does not accept it or switch an existing browser identity. Another browser, an expired link, replay or a revoked approving session cannot complete it. Web sign-out and native sign-out revoke separate sessions; there is no sign-out-everywhere control in this version.
Marketplace profile frame
GET /session-frame?parent=<registry-origin> is served only for the configured registry parent, with matching frame-ancestors. Other account pages cannot be framed. The frame fetches /web/session using same-origin cookies and posts {type:'hay.session/1',session} with sanitized profile status to that exact parent.
The marketplace validates both event.origin and event.source === iframe.contentWindow. The frame accepts refresh messages only from its exact parent and origin. Public browsing requests still omit credentials. Neither the frame message nor web profile APIs expose a bearer session, and JavaScript never reads the HTTP-only cookie or uses local/session storage for credentials.
Authenticated account routes
The account and publication routes listed below require Authorization: Bearer <session-token> for an email-verified account. Public /v1/registry/ reads do not require it. Browser cookies alone cannot mutate publications. The host sends tokens only to the configured account/API origin or its own authenticated personal-registry base, never to a public or custom registry.
| Method and path | Result |
|---|---|
GET /v1/account | {user, expiresAt, publisher?, registryURL, personalRegistryURL}. User fields are id, email, and name. |
POST /v1/web-handoff | Create the explicit app-to-web handoff described above; requires the native session. |
GET /v1/handles?handle=<handle> | Authenticated native availability lookup; {handle,available}, with no reservation. |
POST /v1/publisher | Create the account’s personal profile with JSON {handle, name}. Returns {id, handle, name} with HTTP 201. |
GET /v1/publications | {publications} for the account’s publisher, including plugin ID, version, digest and publication time. |
POST /v1/releases | Publish JSON {bundle, sha256} to the public marketplace; returns a release receipt. |
POST /v1/personal/releases | Publish the same reviewed format to the caller’s private registry. |
GET /v1/personal/publications | {publications} for the caller’s private release history. |
A handle is 3–32 lower-case letters or digits with single internal hyphens, starting with a letter. Repeated or trailing hyphens are refused. Reserved handles are unavailable. The display name is 1–80 characters after validation. Version 1 allows one personal publisher per account and no handle rename, organization creation or membership-management endpoint.
Release body
Send only bundle and sha256. The bundle must pass the canonical SDK validator and contain schemaVersion, a complete manifest, and self-contained html. initialData is refused for direct publishing.
The manifest ID must be <owned-handle>.<slug>. Slugs are 1–48 lower-case letters or digits with single internal hyphens and start with a letter. The complete ID must fit within 80 characters. manifest.author must equal the publisher profile’s display name.
Release versions use canonical major.minor.patch numeric components without leading zeroes, with each component at most nine digits. This publishing rule is narrower than the general package format. Use a newer version for changed code.
The digest hashes the exact service serialization:
const bytes = Buffer.from(JSON.stringify(bundle) + '\n');
const sha256 = createHash('sha256').update(bytes).digest('hex');createHash is the Node.js crypto hash function. Property order and the trailing newline affect the hash; do not substitute the bytes of a separately prettified file. The complete serialized bundle must fit within 2,000,000 bytes.
Ownership and immutability
The service checks the authenticated account’s publisher membership and owned handle. A caller cannot claim another publisher’s plugin by submitting a different author or ID. Ownership is enforced server-side rather than trusted from the request.
A new ID/version is stored atomically with its manifest, digest and artifact. Repeating an identical publication returns the same receipt. Reusing an ID/version with different bytes returns a conflict. Successful release requests return status 201.
The receipt contains pluginId, version, sha256, size, registryURL, and artifactURL. It identifies the accepted release. It does not attest to code safety or the publisher’s legal identity.
Public reads
Connect plugins.hay.so as the registry base. These read-only routes need no account credentials:
| Method and path | Result |
|---|---|
GET /.well-known/hay-registry.json | hay.registry.discovery/1 descriptor with catalog and release paths. |
GET /v1/registry/plugins | hay.registry.page/1 catalog page, up to 50 entries within 1,000,000 bytes. |
GET /v1/registry/plugins?after=<cursor> | Next page; follow the response’s base-relative next path. |
GET /v1/registry/releases/<id>/<version> | Exact hay.registry.release/1 metadata; no catalog walk required. |
GET /artifacts/<sha256>.hay-plugin.json | Immutable artifact bytes. |
GET /index.json | Legacy hay.registry/1 compatibility index. |
Hosted publishing retains releases without the old 100-release catalog ceiling. The legacy index alone remains bounded at 100 entries / 1,000,000 bytes and returns an upgrade error beyond that bound; it never silently drops old releases. Artifact responses use immutable cache headers; discovery and catalog metadata can be refreshed. Read the registry contract for bounded client browsing and same-origin verification.
Marketplace reads
The registry host serves the marketplace shell at / and /plugins/<id>. It keeps /.well-known/hay-registry.json for protocol discovery. Public pages need no token or browser sign-in.
| Method and path | Result |
|---|---|
GET /v1/marketplace/plugins?q=<query>&after=<cursor> | {plugins, next?}; up to 24 latest releases, one per public plugin. |
GET /v1/marketplace/plugins/<id>?after=<cursor> | {plugin, releases, next?}; latest plugin plus up to 50 releases in numeric-newest order. |
Entries contain {manifest, bundle} with the same manifest, path, exact size and digest as registry release metadata. Search matches the current ID, name, description and author literally and case-insensitively, up to 120 characters. It is not a regex or fuzzy-search API. Follow next relative to the registry base rather than constructing cursors. Latest metadata is updated atomically, including when an older version is published later.
Use an explicit install link to hand an exact release to Hay. It carries only registry, ID, version and digest, never an account session.
Private registry reads
GET /v1/account returns the caller’s personalRegistryURL, shaped as https://plugins.hay.so/private/<owner-id>/. The path identifies an owner; it is not a secret or access token.
Under that base, discovery, v1/registry/plugins, v1/registry/releases/<id>/<version> and artifacts/<sha256>.hay-plugin.json require the same owner’s valid bearer session. Responses use Cache-Control: private, no-store and Vary: Authorization, including errors. Missing/revoked sessions receive 401; a different authenticated owner receives 404. Private bytes and metadata use separate storage and never appear in public catalog, marketplace or artifact reads.
Private publication uses the same code-only validation, owned handle and digest rules. Its receipt additionally identifies private visibility and the private registry/artifact addresses. Public and private immutable histories are independent. There is no sharing, organization access, anonymous private download, promotion or deletion API.
The desktop confines authenticated requests to its own exact private base and validates the session online. It refuses redirects and escaping endpoints; it never delegates the credential to a renderer, plugin, model or custom registry.
Failures and limits
API errors use appropriate HTTP status codes: 400 for invalid data or a digest mismatch, 401 for a missing or invalid session, 403 for missing publisher ownership, 409 for handle or immutable-release conflicts and legacy-index capacity errors, and 429 for throttling. Authentication routes also return their authentication-provider error structure. Failed verification-email dispatch returns HTTP 503 rather than a false success. A provider acceptance receipt is not proof of inbox delivery; account verification still requires the actual code received by the user.
The service allows at most three code requests per email address in a 15-minute server bucket, with additional IP-based authentication limits. Each publication destination allows up to 30 requests per account per hour. An unavailable email transport or database is an error, never a successful login or publication.