# ZOREAL developer documentation > https://zoreal.com/documentation as one Markdown file, for coding agents and answer engines: Login with ZOREAL, the Meet API and the ZOREAL Book embed. Generated from the page at build time, so it says exactly what the page says. Sign people in as verified humans with Login with ZOREAL, create and run meetings with the Meet API, and put ZOREAL Book scheduling on your own site. ## Make your first request Create an API key in the dashboard, send it as a Bearer header, and a meeting is one request away. This call returns the room code people join at and the host token that runs the room. [Create an API key](https://zoreal.com/settings/api-keys) [Meetings API](https://zoreal.com/documentation#create-instant-meeting) CREATE A MEETING · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant \ -H "Authorization: Bearer private_XXXXXXXXXXXXXXXXXXXXXXXX" \ -H "Content-Type: application/json" \ -d '{ "title": "Weekly sync" }' ``` ## Introduction Three surfaces, each with its own credentials: - [Login with ZOREAL](https://zoreal.com/documentation#login-with-zoreal): Sign people in on standard OpenID Connect, with a pairwise identifier for a verified human, the assurance of each sign-in, and personal claims only on consent. - [ZOREAL Meet API](https://zoreal.com/documentation#meetings): Create, schedule, list and end meetings from your server, read meeting notes, and run a live room: host controls, the door and room policy. - [ZOREAL Book embed](https://zoreal.com/documentation#book-embed): Put ZOREAL Book scheduling on your own site with one script tag or the JavaScript and React packages, with prefill and booking events. Login with ZOREAL is OpenID Connect from `https://id.zoreal.com`. The Meet API speaks JSON over HTTPS from one base address. Every path in this reference is shown in full, so a sample can be pasted as it stands, and every request sample carries its credential header. Requests to the meetings surface are limited to 30 a minute per address; the presenter endpoints allow 12. `https://api.bynn.com` ## With a coding agent Paste this line into your coding agent. It follows Bynn's published setup instructions, which install the Bynn, ZOREAL, DecisionNode, Agemin and Detector24 skills and the Bynn and DecisionNode MCP servers. PROMPT ```text Set up Bynn, ZOREAL, DecisionNode, Agemin and Detector24 for me: fetch https://bynn.com/agent-setup.md and follow it. ``` This whole page is also one Markdown file at [zoreal.com/llms-full.txt](https://zoreal.com/llms-full.txt), and the site's index for agents is [zoreal.com/llms.txt](https://zoreal.com/llms.txt). ## API keys The Meet API authenticates your organization with an API key. Login with ZOREAL uses its own client credentials, described under [Register a client](https://zoreal.com/documentation#login-register), and the Book embed needs none. API keys are created and managed in [the dashboard](https://zoreal.com/settings/api-keys). A key begins with `private_` or `public_`, with `sandbox_` added on test keys, and authenticates your organization. Send it as a Bearer header on every request. Rotating a key issues a new one and keeps the old one working for 12 hours, so a deploy can catch up; rotation can also expire the old key immediately. A key is readable back in the dashboard after creation. Two credential kinds appear in this reference: your API key, for account operations such as creating meetings, listing them and reading notes, and the per-meeting tokens returned when a meeting is created or joined, which authorize actions inside that one room. [Host controls](https://zoreal.com/documentation#host-controls) explains them once, at the top of the group. AUTHENTICATED REQUEST · CURL ```bash curl "https://api.bynn.com/v1/zoreal/meet/instant/meetings" \ -H "Authorization: Bearer private_XXXXXXXXXXXXXXXXXXXXXXXX" ``` Keys belong on your server. Keep them out of browser code, mobile apps and public repositories, and if one may have been exposed, rotate it from the dashboard: the replacement is immediate and the old key can be expired on the spot. ## Login with ZOREAL A sign-in on standard OpenID Connect where the person proves, with the ZOREAL ID app on their phone, that they are a verified, unique and live human. Your site receives a stable pseudonymous identifier for them, how strongly this sign-in was authenticated, and personal details only when the person consents to share them. ### How it works The provider is `https://id.zoreal.com`, and its discovery document is at `https://id.zoreal.com/.well-known/openid-configuration`. A sign-in has four steps: 1. Your frontend starts a pairing with a ZOREAL client library. On a computer the person scans a QR code with ZOREAL ID; on a phone the library opens the app. 2. The person approves in the app: a signature from the phone's hardware key, released by a biometric or passcode unlock, plus a face capture when you ask for one. 3. The library hands your frontend an authorization `code`, with the PKCE `code_verifier` and the `nonce` it generated. 4. Your backend exchanges the code at `/token`, verifies the ID token's ES256 signature against `/jwks`, and reads personal claims from `/userinfo` when the scopes cover them. The provider publishes no `authorization_endpoint`. A sign-in starts at the pairing step the frontend libraries run, so a generic OpenID Connect client that builds an authorize URL has nothing to point at: start the sign-in with a ZOREAL frontend library. On the server, the ZOREAL backend libraries exchange the code and run every check for you; the ID token itself is a JWT that any library verifying ES256 against a JWKS can check. PKCE is mandatory, S256 only, for every client including confidential ones. The `sub` is pairwise per verified domain: two sites comparing notes see different identifiers for the same person. ZOREAL, as the provider, sees every sign-in: which person, at which site, and when. DISCOVERY · CURL ```bash curl https://id.zoreal.com/.well-known/openid-configuration ``` RESPONSE ```json { "issuer": "https://id.zoreal.com", "token_endpoint": "https://id.zoreal.com/token", "userinfo_endpoint": "https://id.zoreal.com/userinfo", "jwks_uri": "https://id.zoreal.com/jwks", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], "id_token_signing_alg_values_supported": ["ES256"], "token_endpoint_auth_methods_supported": [ "none", "client_secret_basic", "private_key_jwt", "tls_client_auth" ], "scopes_supported": [ "openid", "zoreal.age", "zoreal.nationality", "profile.name", "profile.birthdate", "profile.document", "email", "profile.portrait" ], "acr_values_supported": ["zoreal.live", "zoreal.device", "zoreal.session"], "subject_types_supported": ["pairwise"] } ``` ### Register a client Everything a client library needs comes from a ZOREAL asset. 1. Create an account at zoreal.com and open Identify, then [Assets](https://zoreal.com/identify/assets). 2. Create an asset: a website, for a domain you own, or an app, for a reverse-DNS bundle id. The asset's token is your client id. It looks like `ast_...`, it is public, and it ships in your frontend. 3. On the asset's OAuth2 tab, register the redirect URIs and JavaScript origins your app uses (requests from anything not registered are refused), the scopes the client may request, and its client authentication: a client secret for `client_secret_basic`, or a JWKS for `private_key_jwt`. A public client, such as the no-backend button, authenticates with PKCE alone. 4. A website asset verifies its domain, with a DNS or meta-tag proof shown in the dashboard, before it can sign people in or request personal-data scopes. The verified domain is what each person's `sub` is pairwise against. The client id is public. The client secret belongs in your server's secret store and never in the browser. There are no test identities. ZOREAL never issues sandbox humans, because a pool of them would be a fraud vector against the thing the product proves. Enrol your own ZOREAL ID and sign in with it. While you build, set the asset's environment to sandbox, which allows `http://localhost` origins and redirect URIs, and switch it to production when you ship. ### Client libraries One wire protocol, eleven libraries. The frontend libraries run the pairing; the backend libraries exchange the code, verify the ID token and read `/userinfo`, with all four client authentication methods. Each repository's README is the full reference for its platform, and the registry has the current version. | Platform | Install | Role | | --- | --- | --- | | [React](https://github.com/Bynn-Intelligence/zoreal-oauth2-react) | `npm install @zoreal/oauth2-react` | Frontend: the button, the pairing modal, the polling | | [Browser JavaScript](https://github.com/Bynn-Intelligence/zoreal-oauth2-js) | `npm install @zoreal/oauth2-js` | Framework-free frontend core, for Vue, Svelte, Angular or plain pages | | [React Native](https://github.com/Bynn-Intelligence/zoreal-oauth2-react-native) | `npm install @zoreal/oauth2-react-native react-native-svg` | Mobile frontend; opens ZOREAL ID on the same device | | [Node.js](https://github.com/Bynn-Intelligence/zoreal-oauth2-node) | `npm install @zoreal/oauth2-node` | Backend: exchange, verify, userinfo | | [Python](https://github.com/Bynn-Intelligence/zoreal-oauth2-python) | `pip install zoreal-oauth2` | Backend, imported as zoreal\_oauth2 | | [Ruby](https://github.com/Bynn-Intelligence/zoreal-oauth2-ruby) | `gem install zoreal-oauth2` | Backend | | [PHP](https://github.com/Bynn-Intelligence/zoreal-oauth2-php) | `composer require zoreal/oauth2` | Backend | | [Go](https://github.com/Bynn-Intelligence/zoreal-oauth2-go) | `go get github.com/Bynn-Intelligence/zoreal-oauth2-go` | Backend | | [JVM](https://github.com/Bynn-Intelligence/zoreal-oauth2-java) | `com.zoreal:oauth2` | Backend, from Maven Central | | [.NET](https://github.com/Bynn-Intelligence/zoreal-oauth2-dotnet) | `dotnet add package Zoreal.OAuth2` | Backend | | [Rust](https://github.com/Bynn-Intelligence/zoreal-oauth2-rust) | `cargo add zoreal-oauth2` | Backend, used as zoreal\_oauth2 | ### Start the sign-in Two flows; pick by whether you need the person's details. | Flow | Returns | Client | Reaches | | --- | --- | --- | --- | | `auth-code` | A `code`, exchanged on your backend at `/token` with PKCE and client authentication | Confidential | Everything the client is registered for, personal claims included | | `browser-direct` | An ID token, straight to `onSuccess` | Public | `sub` and the assurance block only | The ID token never carries personal data, so the browser-direct flow cannot reach it: name, birthdate, document fields and email are served only from `/userinfo`, against an access token the browser never receives. With React, mount the provider once and call the hook from your own button. On a computer, `login()` puts the pairing modal on screen. On a phone the tap opens ZOREAL ID, and once the person approves, the app brings them back to your page, where the same hook calls `onSuccess` on a fresh page load. PROVIDER · TSX ```tsx // app.tsx, once, around the part of your app that signs in. import { ZorealOAuthProvider } from '@zoreal/oauth2-react'; ; ``` NO BACKEND · TSX ```tsx import { ZorealOAuthProvider, ZorealLogin } from '@zoreal/oauth2-react'; { // An ID token: a stable per-user sub and the assurance block, no email // or name. Verify it on your server against the JWKS before trusting it. }} onError={(e) => console.warn(e.type, e.description)} /> ``` AUTH-CODE FLOW · TSX ```tsx import { useZorealLogin, ZorealMark } from '@zoreal/oauth2-react'; function ZorealSignIn({ onSignedIn }: { onSignedIn: () => void }) { const login = useZorealLogin({ flow: 'auth-code', scope: 'openid email profile.name', // acr_values: 'zoreal.live', // ask for a face capture on this sign-in onSuccess: async ({ code, code_verifier, nonce }) => { // Send all three to your backend, which exchanges and verifies them. const res = await fetch('/api/auth/zoreal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, code_verifier, nonce }), }); if (res.ok) onSignedIn(); }, onError: (e) => console.error(e.description ?? e.error), onNonOAuthError: (e) => { // Declining, letting the request expire or closing the dialog is not an // error: the button is simply ready again. if (e.type === 'request_denied' || e.type === 'request_expired' || e.type === 'popup_closed') return; console.error(e.description ?? e.type); }, }); return ( ); } ``` ### Verify on your server Your backend receives the `code`, `code_verifier` and `nonce` from the frontend and makes one call. The library exchanges the code at `/token` with your client authentication, verifies the ID token with ES256 against `https://id.zoreal.com/jwks`, and checks that `iss` is exactly `https://id.zoreal.com`, `aud` is your client id, `exp` has not passed, `nonce` is the one this login generated, and, when you pass one, that `acr` meets your floor. Requesting a level on the wire only shapes what the person is asked to do; a browser is attacker-controlled. The signed `acr` in the ID token is the proof, so a site that asks for `zoreal.live` also passes `acr` here. NODE.JS · TS ```ts import { ZorealOAuth2Client } from '@zoreal/oauth2-node'; export const zoreal = new ZorealOAuth2Client({ clientId: process.env.ZOREAL_CLIENT_ID!, // ast_... issuer: process.env.ZOREAL_ISSUER ?? 'https://id.zoreal.com', auth: { method: 'client_secret_basic', clientSecret: process.env.ZOREAL_CLIENT_SECRET!, }, }); app.post('/api/auth/zoreal', async (req, res) => { const { code, code_verifier, nonce } = req.body; const login = await zoreal.authenticate(code, code_verifier, { nonce, acr: 'zoreal.live', // refuses any token below this level }); login.sub; // "TC5X-JN7G-YTSE-6E63": pairwise, stable for your domain login.acr; // "zoreal.live" | "zoreal.device" | "zoreal.session" login.assurance; // the assurance block await login.email(); // from /userinfo, with the email scope await login.emailVerified(); }); ``` PYTHON ```python import os from zoreal_oauth2 import ClientSecretBasic, ZorealOAuth2Client ZOREAL_OAUTH = ZorealOAuth2Client( client_id=os.environ["ZOREAL_CLIENT_ID"], # ast_... auth=ClientSecretBasic(os.environ["ZOREAL_CLIENT_SECRET"]), issuer=os.environ.get("ZOREAL_ISSUER", "https://id.zoreal.com"), ) login = ZOREAL_OAUTH.authenticate( code=payload["code"], code_verifier=payload["code_verifier"], nonce=payload["nonce"], acr="zoreal.live", # raises VerificationError unless the signed token says so ) login.sub, login.acr, login.assurance, login.email ``` Match a returning person on `sub`. Claim an existing account by email only when the email is verified, and never overwrite a link to another provider. ACCOUNT MATCHING · TS ```ts let user = await users.findBy({ provider: 'zoreal', uid: login.sub }); if (!user) { if (await login.emailVerified()) { user = await users.findBy({ email: await login.email() }); // claim, don't collide } user ??= await users.create({ email: await login.email() }); await users.update(user, { provider: 'zoreal', uid: login.sub }); } ``` ### Exchange the code `POST https://id.zoreal.com/token` Auth [Client authentication](https://zoreal.com/documentation#login-register) Exchanges an authorization code for the ID token and an access token for `/userinfo`. The backend libraries make this call; it is documented here for platforms without one. The body is form-encoded. #### Request body - `grant_type` (string, required, one of `authorization_code`): The only grant the provider supports. - `code` (string, required): From the frontend library. Single use, and valid for 60 seconds. - `code_verifier` (string, required): The PKCE verifier the frontend library generated with the code. - `client_id` (string, required): Your asset id (it begins ast\_), sent whatever the authentication method: the provider matches the code against it. - `client_assertion_type` (string, optional, one of `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`): private\_key\_jwt only. - `client_assertion` (string, optional): private\_key\_jwt only: a JWT signed with your registered key (ES256 on P-256, or RS256), with iss and sub equal to your client id, aud the token endpoint, a lifetime of at most 60 seconds and a fresh jti, which is accepted once. #### Returns `id_token`, `access_token`, `token_type`, `expires_in` and the granted `scope`. An access token carrying personal-data scopes lives ten minutes and there is no refresh token: a site that needs the data again asks the person again. REQUEST · CURL ```bash curl -X POST https://id.zoreal.com/token \ -u "$ZOREAL_CLIENT_ID:$ZOREAL_CLIENT_SECRET" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d code= \ -d code_verifier= \ -d client_id=$ZOREAL_CLIENT_ID ``` RESPONSE ```json { "id_token": "", "access_token": "", "token_type": "Bearer", "expires_in": 600, "scope": "openid email profile.name" } ``` ERROR · 400 ```json { "error": "invalid_grant", "error_description": "the code is not valid" } ``` With `client_secret_basic` the secret travels as HTTP Basic, the client id as the user and the secret as the password, never as a form field. A public client sends `client_id` alone and PKCE is its only proof. `tls_client_auth` is registrable but not accepted at this endpoint yet. ### Read personal claims `GET https://id.zoreal.com/userinfo` Auth [Access token (Bearer)](https://zoreal.com/documentation#login-token) Returns `sub` and the personal claims the access token's scopes cover. Standard OpenID Connect UserInfo; personal claims reach only a confidential client on a verified domain. #### Returns A claim appears only when the person's document carried it. An absent claim is left out, never sent as a null, an empty string or a guess. REQUEST · CURL ```bash curl https://id.zoreal.com/userinfo \ -H "Authorization: Bearer " ``` RESPONSE ```json { "sub": "7QK3-9F2M-XR84-B5NP", "given_name": "Anna", "family_name": "Lindqvist", "name": "Anna Lindqvist", "birthdate": "1991-04-17", "document_type": "passport", "document_number": "88123456", "issuing_country": "SWE", "document_expires_on": "2031-02-28" } ``` The `sub` here is the pairwise identifier for your client's verified domain, derived from the authenticated client and never from a request parameter. ### Signing keys `GET https://id.zoreal.com/jwks` Auth None The provider's public keys, for verifying ID token signatures. ID tokens are signed with ES256 and nothing else; check `iss` against `https://id.zoreal.com` as an exact string. REQUEST · CURL ```bash curl https://id.zoreal.com/jwks ``` ### Scopes and claims Scopes are requested in the frontend, always starting with `openid`, consented to by the person, and must be registered on the asset first. | Scope | Claims | Delivered in | Requires | | --- | --- | --- | --- | | `openid` | `sub`, `iss`, `aud`, `exp`, `iat`, `nonce`, `auth_time`, `acr`, `amr` and the assurance block | ID token | Any client | | `zoreal.age` | `age_over_13`, `_16`, `_18`, `_21`, `_65` booleans, only for the thresholds you registered; never an age or a birthdate | ID token | Any client | | `zoreal.nationality` | `nationality`, ISO 3166-1 alpha-3, read from the chip | ID token | Any client | | `email` | `email`, `email_verified` | /userinfo | Confidential client and a verified domain | | `profile.name` | `name`, `given_name`, `family_name` | /userinfo | Confidential client and a verified domain | | `profile.birthdate` | `birthdate`, a full ISO 8601 date | /userinfo | Confidential client and a verified domain | | `profile.document` | `document_type`, `document_number`, `issuing_country`, `document_expires_on` | /userinfo | Confidential client and a verified domain | | `profile.portrait` | `portrait`, the chip's facial image: GDPR Article 9 biometric data. Registrable, not served yet | /userinfo | Confidential client and a verified domain | A personal-data scope requested by a public client is refused at the start of the sign-in with `invalid_scope`, before the person sees a consent screen for something they cannot give. Requesting `email` is a choice with a cost. An email address is the same at every service a person uses, so two sites that both hold it can link the person at once, which undoes, at your site, the unlinkability the pairwise `sub` provides. Claims follow the document. `uniqueness: personal_number` is absent when the chip carries no personal number, `nationality` when a travel document names an issuing state and no nationality, `document_expires_on` for national ID cards that do not expire, and `email` when the person enrolled without one. ### Assurance levels `acr` says how strongly this sign-in was authenticated, and it always describes what happened, never what was requested. `amr` lists the factors: `hwk` a hardware key, `user` an unlock gesture, `face` a face biometric. | acr | What the person did | amr | How to get it | | --- | --- | --- | --- | | `zoreal.session` | Nothing: a returning person resumed silently from an existing session, with consent remembered | `[]` | `prompt=none`, only for a person who has signed in to your site before | | `zoreal.device` | Approved on their enrolled phone: a signature from the phone's secure element, released by a biometric or passcode unlock | `["hwk","user"]` | The default, when the request asks for nothing | | `zoreal.live` | All of the above plus a fresh face capture, scored for presentation attacks and matched one to one against the document read at enrolment | `["hwk","face","user"]` | `acr_values=zoreal.live` or `prompt=login` | The order is `zoreal.session`, then `zoreal.device`, then `zoreal.live`, and a requirement is satisfied upward: a floor of `zoreal.device` accepts a `zoreal.live` token. A requested level is a floor, never a hint: a sign-in that cannot meet it is denied with `unmet_authentication_requirements` rather than downgraded. `max_age=N` forces a new authentication when `auth_time` is older than N seconds. Most sites want the default and pass nothing. Ask for `zoreal.live` where a fresh proof of the right, live person is worth a few seconds: onboarding, a high-value transaction, an age-gated purchase, a step-up before a sensitive action. ### The assurance block `acr` grades the sign-in. The `zoreal` claim, the assurance block, grades the identity behind it: how the person was verified at enrolment. A high-value flow usually checks both. It comes with `openid` on every ID token. | Key | Values | Meaning | | --- | --- | --- | | `uniqueness` | `personal_number`, `document`, `none` | What the person is deduplicated on. Gate on personal\_number for one account per human; on document, someone holding a passport and a national ID card can be two people | | `verified_on` | `"YYYY-MM"` | The month the document was verified, to the month on purpose: a full date is a cross-site correlator | | `chip_liveness_proven` | `true`, `false` | Whether the chip's active authentication proved a genuine chip, not a clone | | `trust_tier` | `high`, `standard` | high when chip\_liveness\_proven, otherwise standard | | `key_protection` | `secure_enclave`, `strongbox`, `tee`, `software` | How the person's device key is protected; software means no hardware attestation | ID TOKEN CLAIMS ```json { "iss": "https://id.zoreal.com", "sub": "7QK3-9F2M-XR84-B5NP", "aud": "", "exp": 1786313111, "iat": 1786312991, "nonce": "", "auth_time": 1786312985, "acr": "zoreal.live", "amr": ["hwk", "face", "user"], "age_over_18": true, "nationality": "SWE", "zoreal": { "uniqueness": "personal_number", "verified_on": "2026-08", "chip_liveness_proven": true, "trust_tier": "high", "key_protection": "strongbox" } } ``` `sub` is 80 bits, four groups of four base32-crockford characters. `age_over_18` and `nationality` appear only with their scopes; everything else is always present. ### Errors The provider answers an error as `{ "error", "error_description" }`. At the token exchange: | error | Cause | What to do | | --- | --- | --- | | `invalid_grant` | The code is unknown, expired (60 seconds), already used, failed PKCE, or the asset's domain verification lapsed mid-flow | Start a new sign-in; a code is never reused | | `invalid_request` | Client authentication failed: a wrong secret or a bad private\_key\_jwt assertion | Fix the client configuration | | `unsupported_grant_type` | Anything but authorization\_code reached /token | A bug | Before your backend is involved, the frontend library reports: | Code | Meaning | | --- | --- | | `invalid_scope` | A scope not on the asset, or a personal-data scope from a public client | | `invalid_request` | Missing PKCE or nonce, an unverified domain, an unregistered redirect URI, or an unknown acr\_values | | `login_required` | prompt=none with no session to resume: the expected quiet outcome, not a failure | | `request_denied` | The person declined in ZOREAL ID, or the sign-in could not meet the level you required, with the reason in the description. Treat a decline as a cancel and leave the button ready | | `request_expired` | The pairing window passed. Offer to try again | ## Meet API ZOREAL Meet from your server. The lifecycle of a meeting: create it, schedule it, change it, end it, and read what happened. ### Create an instant meeting `POST https://api.bynn.com/v1/zoreal/meet/instant` Auth [API key (Bearer)](https://zoreal.com/documentation#authentication) Creates a meeting and returns its code, the address people join at, together with a host token that authorizes the host controls for this room. With no schedule fields the meeting is ready as soon as the host joins. To create a scheduled meeting, add the schedule fields described in [Schedule a meeting](https://zoreal.com/documentation#schedule-meeting). #### Request body - `title` (string, optional): Shown to everyone who opens the link. Up to 80 characters. - `e2ee` (boolean, optional, default `false`): End-to-end encryption. It can be turned on later from inside the room, but only while the host is alone, and never off. #### Returns The room and its `host_token`. The token appears in this response and nowhere else - only a digest is stored - so keep it if the meeting will be managed later. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant \ -H "Authorization: Bearer private_XXXXXXXXXXXXXXXXXXXXXXXX" \ -H "Content-Type: application/json" \ -d '{ "title": "Weekly sync" }' ``` RESPONSE ```json { "room": { "code": "kfm-qhtz-pgd", "created_at": "2026-09-01T10:14:22Z", "began_at": null, "title": "Weekly sync", "e2ee": false }, "host_token": "" } ``` The code is ten letters in `xxx-xxxx-xxx`, drawn from a to z without the letter l, which is misread when a code is spoken aloud. A meeting holds up to 25 people, and a call runs up to 90 minutes, measured from when the host first joins; the link itself survives the cap and can carry a fresh call. When no media node can take a new room, the request answers 503 `no_capacity`. ### Schedule a meeting `POST https://api.bynn.com/v1/zoreal/meet/instant` Auth [API key (Bearer)](https://zoreal.com/documentation#authentication) A scheduled meeting is the same call as [create](https://zoreal.com/documentation#create-instant-meeting) with the schedule fields; there is no separate endpoint. Set `scheduled_at` and the link becomes a calendar object that opens 30 minutes before its start, for the host too. #### Request body (in addition to title and e2ee) - `scheduled_at` (datetime, optional): When the meeting starts, ISO 8601. - `scheduled_minutes` (integer, optional): Planned length, 1 to 600. - `scheduled_tz` (string, optional, one of `Europe/Stockholm`, `America/New_York`): An IANA zone name. An unknown zone is refused with 422 rather than silently dropped. A zone rather than an offset, so a series set for 09:00 in Stockholm keeps 09:00 across the daylight-saving change. - `recurrence` (string, optional, one of `FREQ=DAILY`, `FREQ=WEEKLY;INTERVAL=2`, `FREQ=MONTHLY;UNTIL=20261231`): An RRULE body. Three components are honored: FREQ (DAILY, WEEKLY or MONTHLY), INTERVAL (a positive integer, 1 when absent) and UNTIL (a date, inclusive). Anything else is accepted syntactically and silently ignored: `FREQ=WEEKLY;BYDAY=MO,WE` means every week from the anchor, not Mondays and Wednesdays. #### Returns The same room and `host_token` shape as create; the schedule is not echoed back. Read it with [Get a meeting](https://zoreal.com/documentation#get-meeting), which returns `scheduled_at`, `scheduled_minutes`, `scheduled_tz` and `opens_at`. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant \ -H "Authorization: Bearer private_XXXXXXXXXXXXXXXXXXXXXXXX" \ -H "Content-Type: application/json" \ -d '{ "title": "Design review", "scheduled_at": "2026-09-08T09:00:00Z", "scheduled_minutes": 45, "scheduled_tz": "Europe/Stockholm", "recurrence": "FREQ=WEEKLY;INTERVAL=1" }' ``` RESPONSE ```json { "room": { "code": "npg-wkfd-tmc", "created_at": "2026-09-01T10:21:07Z", "began_at": null, "title": "Design review", "e2ee": false }, "host_token": "" } ``` Once created, `recurrence` and `scheduled_tz` cannot be changed. The time can be moved with [Reschedule a meeting](https://zoreal.com/documentation#reschedule-meeting); a single occurrence can be closed but not moved. An occurrence admits late arrivals within its planned length, and at least 30 minutes. A series with no dates left answers 409 `series_over`. ### Rename a meeting `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/title` Auth [Host token](https://zoreal.com/documentation#host-controls) Sets or clears the meeting's title. This is the one host control that also works on an ended meeting: a past meeting's name stays editable, its time does not. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. - `title` (string, optional): Up to 80 characters. Sending an empty string clears the title, which is why the field is optional. - `session_id` (integer, optional): Renames one past session instead of the room, so a recurring series keeps its name. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/title \ -H "Content-Type: application/json" \ -d '{ "host_token": "", "title": "Budget review" }' ``` RESPONSE ```json { "title": "Budget review" } ``` A rename reaches people who join after it; participants already connected keep the name they joined under. A rejected title answers 400 with the reason. Anything else - an unknown code, a wrong credential - answers 404, so the endpoint confirms nothing about which codes exist. ### Reschedule a meeting `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/schedule` Auth [Host token](https://zoreal.com/documentation#host-controls) Moves a scheduled meeting's time. A recurring meeting moves as a whole series; a single occurrence cannot be moved, only closed. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. - `scheduled_at` (string, required): The new start, ISO 8601. A string that does not parse answers 422. - `scheduled_minutes` (integer, optional): The new planned length. Must be positive. - `notify` (boolean, optional, default `false`): Re-sends the calendar invitation to everyone already invited. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/npg-wkfd-tmc/host/schedule \ -H "Content-Type: application/json" \ -d '{ "host_token": "", "scheduled_at": "2026-09-15T09:00:00Z", "notify": true }' ``` RESPONSE ```json { "scheduled_at": "2026-09-15T09:00:00Z", "scheduled_minutes": 45, "notified": 4 } ``` A meeting that was never scheduled answers 409: only a scheduled meeting has a time to move. Calendar clients move the event because its sequence number grows with each reschedule. ### Cancel or end a meeting `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/end` Auth [Host token](https://zoreal.com/documentation#host-controls) Ends the current call, and with `scope: "series"` retires the link itself. A meeting is ended, never deleted: the record and its history survive. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. - `scope` (string, optional, default `occurrence`, one of `occurrence`, `series`): occurrence closes this date and the next occurrence opens on schedule; series ends the call and retires the link. A one-off meeting is always treated as series, because it has no other date to protect. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/end \ -H "Content-Type: application/json" \ -d '{ "host_token": "", "scope": "series" }' ``` RESPONSE ```json { "ended": true, "scope": "series" } ``` The response's `scope` reports what the server resolved, not what was asked: a one-off answers `"series"` even when the caller sent `occurrence`. Everyone is disconnected at the media node first. If the node refuses, the request answers 502 and the meeting keeps running; nothing is left half-ended. Ending a scheduled series emails a calendar cancellation to everyone invited; closing one occurrence emails nothing. A closed occurrence reopens if the creator walks back in; guests stay gated. The 90-minute cap ends a call the same way this endpoint does, measured from the host's first join. ### List meetings `GET https://api.bynn.com/v1/zoreal/meet/instant/meetings` Auth [API key (Bearer)](https://zoreal.com/documentation#authentication) The meetings your account owns: upcoming, live and past, newest first. This is also where attendee data lives - each row carries who was in the room. #### Query parameters - `limit` (integer, optional, default `50`): Rows per page, 1 to 100. - `before` (string, optional): The opaque cursor from a previous page's last row. An unreadable cursor returns the first page. - `q` (string, optional): Search. Every whitespace-separated term must match the title, the code, an attendee's name or a tag name; up to 6 terms. - `from` (date, optional): Earliest day to include. - `to` (date, optional): Latest day to include. - `tags[]` (array of strings, optional): Tag tokens, repeated once per tag. A meeting must carry all of them; up to 10. - `horizon_days` (integer, optional): How far forward to unroll a recurring series into its upcoming dates, 1 to 90. #### Returns `{ "meetings": [...] }`, at most 500 rows. Past rows additionally carry `session_id`, `actual_minutes` and `capture`, whose `transcription` reports recording, processing, ready or failed. REQUEST · CURL ```bash curl "https://api.bynn.com/v1/zoreal/meet/instant/meetings?limit=2&q=sync" \ -H "Authorization: Bearer private_XXXXXXXXXXXXXXXXXXXXXXXX" ``` RESPONSE ```json { "meetings": [ { "code": "kfm-qhtz-pgd", "cursor": "MjAyNi0wOS0wMVQxMDo0MToxNi4wMDAwMDB8OTQx", "title": "Weekly sync", "status": "ended", "began_at": "2026-09-01T10:14:22Z", "created_at": "2026-09-01T10:02:51Z", "last_used_at": "2026-09-01T10:41:16Z", "scheduled_at": null, "series_start_at": null, "scheduled_minutes": null, "scheduled_tz": null, "recurrence": null, "ended_at": "2026-09-01T10:41:16Z", "e2ee": false, "presence_on_join": null, "unproven_access": "full", "attendees": [ { "name": "Anna Lindqvist", "role": "owner", "email": null }, { "name": "Jonas Vikander", "role": "participant", "email": null } ], "invited": [], "tags": [] } ] } ``` Pagination is a cursor, not an offset: pass the last row's `cursor` back as `before`. While paging, `horizon_days` expansion is skipped. A tag token that resolves to nothing returns an empty list, not an unfiltered one. `status` is computed server-side: live, ended or not\_started. `email` on an attendee is only ever a guest's self-typed address; accounts expose none. ### Get a meeting `GET https://api.bynn.com/v1/zoreal/meet/instant/:code` Auth None (public) Resolves a meeting code to its public state: what a client needs before joining. No credential is required, because an invitation link has to work for someone without an account; a signed-in owner is recognized and gets `host: true`. #### Query parameters - `invite` (string, optional): An invitation token. When it is valid for this room, the response includes the invitee's name and email; an unknown token is silently absent, because it authorizes nothing and there is nothing to refuse loudly. REQUEST · CURL ```bash curl https://api.bynn.com/v1/zoreal/meet/instant/npg-wkfd-tmc ``` RESPONSE ```json { "room": { "code": "npg-wkfd-tmc", "created_at": "2026-09-01T10:21:07Z", "started_at": "2026-09-01T10:21:07Z", "began_at": null, "e2ee": false, "host": false, "title": "Design review", "scheduled_at": "2026-09-08T09:00:00Z", "scheduled_minutes": 45, "scheduled_tz": "Europe/Stockholm", "opens_at": "2026-09-08T08:30:00Z", "policy": { "screen_share": true, "microphone": true, "camera": true, "messages": true, "reactions": true, "knock": true } }, "invite": null } ``` `began_at` is when the current call started, null when none is running; `started_at` is when the link was minted. `scheduled_at` is the next occurrence of a series, never the anchor, and `opens_at` is 30 minutes before it. This endpoint returns no attendees; attendee data lives on [List meetings](https://zoreal.com/documentation#list-meetings) rows. An unknown or ended code answers 404. ### Meeting notes `GET https://api.bynn.com/v1/zoreal/meet/instant/:code/sessions/:session_id/notes` Auth [API key (Bearer), owner only](https://zoreal.com/documentation#authentication) The transcript of one past session, speaker by speaker. Transcription runs on ZOREAL's own deployment, not a third-party service, and only when it was started in the call - see [Transcription](https://zoreal.com/documentation#transcription). #### Path parameters - `:code` (string, required): The meeting code. - `:session_id` (integer, required): From a past row on [List meetings](https://zoreal.com/documentation#list-meetings). #### Returns `status` is one of: processing, still transcribing; ready, segments available; empty, everything settled and nobody spoke; failed, every track failed; none, nothing was captured, and the other fields are omitted. REQUEST · CURL ```bash curl "https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/sessions/941/notes" \ -H "Authorization: Bearer private_XXXXXXXXXXXXXXXXXXXXXXXX" ``` RESPONSE ```json { "notes": { "status": "ready", "turns": [ { "speaker": "Anna Lindqvist", "start": 12.4, "end": 18.9, "text": "Let us start with the budget." }, { "speaker": "Jonas Vikander", "start": 19.2, "end": 24.1, "text": "The numbers are in the shared sheet." } ], "pending_tracks": 0, "began_at": "2026-09-01T10:14:22Z" } } ``` Owner-scoped: only the meeting's owner may read notes. Anyone else gets the same 404 an unknown meeting gets. #### As a PDF `GET https://api.bynn.com/v1/zoreal/meet/instant/:code/sessions/:session_id/notes/pdf` The same transcript as a document. Answers `application/pdf` as an attachment named `zoreal-meet-notes--.pdf`. ## Host controls Actions on a live room. Every control below takes the room's `host_token`, returned once by [create](https://zoreal.com/documentation#create-instant-meeting), in the request body; the meeting's owner may omit it and authenticate with their API key, and a promoted co-host uses their `participant_token` in the same slot. A wrong or missing credential answers 404, never 403: these endpoints do not confirm that a code exists. Controls name their target by `identity`, the per-join identifier every participant carries. Identities are minted per join, so someone who leaves and returns is a new identity. ### Presenter control `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/present` Auth [Participant token](https://zoreal.com/documentation#host-controls) One person presents at a time, and the stage is a server-side lock: no join grant carries the screen-share source, so a client that publishes without claiming the stage is refused by the media node. #### Request body - `identity` (string, required): From the join response. - `participant_token` (string, required): From the join response. - `host_token` (string, optional): Permits taking the stage from the current presenter. #### Returns `displaced` names the identity taken off the stage, and is null when the stage was free. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/present \ -H "Content-Type: application/json" \ -d '{ "identity": "user_ab12cd34", "participant_token": "" }' ``` RESPONSE ```json { "presenting": true, "displaced": null } ``` When somebody else is presenting and the caller may not take over, the answer is 409 carrying the holder's `identity`; the roster turns it into a name. 403 when screen sharing is off in the room policy - hosts are exempt from that one - and 403 when the caller has not satisfied the room's presence rule, from which hosts are not exempt. 404 for a stale credential. #### Stop presenting `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/present/stop` Takes `identity` and `participant_token`. In the response, `released: false` means a host had already stopped the share or taken the stage - the browser sees both as a bare unpublish, and this field is how it tells them apart. RESPONSE ```json { "presenting": false, "released": true } ``` ### Promote a co-host `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/promote` Auth [Host token (creator only)](https://zoreal.com/documentation#host-controls) Hands the host controls to someone in the room. This is the one host power a co-host does not inherit: only the meeting's creator may promote, so a room's authority spreads no further than the creator handed it. #### Request body - `host_token` (string, optional): The creator's token, or the creator's API key session. - `identity` (string, required): The participant to promote. - `revoke` (boolean, optional, default `false`): Takes a promotion back. #### Returns The current co-host identities. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/promote \ -H "Content-Type: application/json" \ -d '{ "host_token": "", "identity": "user_3f9a1c02" }' ``` RESPONSE ```json { "hosts": ["user_3f9a1c02"] } ``` Hostship is scoped to the live connection: leave and rejoin, and it is gone. The creator cannot be demoted; their identity carries the `host_` prefix and is never in this list. 409 when the target is already a host. A co-host calling this gets the same 404 an unknown meeting gets. The roster's host marker renders from room metadata only the server can write. ### Mute or remove `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/mute` Auth [Host token](https://zoreal.com/documentation#host-controls) The moderation pair. Muting is a request the participant can undo; removal is executed by the media node itself. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. - `identity` (string, required): The participant to act on. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/mute \ -H "Content-Type: application/json" \ -d '{ "host_token": "", "identity": "user_ab12cd34" }' ``` RESPONSE ```json { "muted": true } ``` A muted participant may unmute again; a hard mute is a remove. 404 when the identity names nobody in the room; 502 when the media node refuses. #### Remove `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/remove` The same body: `host_token` and the `identity` to disconnect. RESPONSE ```json { "removed": true } ``` ### Transcription `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/transcription` Auth [Host token](https://zoreal.com/documentation#host-controls) Starts capturing the call's audio for [Meeting notes](https://zoreal.com/documentation#meeting-notes). Starting is idempotent: asking twice is one recording. Everyone in the room can see that transcription is on; it cannot be switched on quietly. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/transcription \ -H "Content-Type: application/json" \ -d '{ "host_token": "" }' ``` RESPONSE ```json { "recording": { "id": 118, "status": "recording", "started_at": "2026-09-01T10:15:03Z" } } ``` 409 in an end-to-end encrypted meeting: the servers cannot read the call, so there is nothing to transcribe. 409 when no call is running. #### Stop `DELETE https://api.bynn.com/v1/zoreal/meet/instant/:code/host/transcription` Stops the capture; transcription of what was recorded continues and lands in [Meeting notes](https://zoreal.com/documentation#meeting-notes). 404 when nothing is being transcribed. RESPONSE ```json { "recording": { "id": 118, "status": "processing" } } ``` ## The door When a room's `knock` policy is on, which it is by default, someone without a host credential asks to come in and a host answers. Until a host says yes there is nothing for the guest's browser to connect to: no lobby connection, no muted seat in the call. ### Knock `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/knock` Auth None (public) Asks to join a knocking room. The answer is a `knock_token`, the handle the guest polls with and a host answers; it is single use and belongs to this one request to enter. #### Request body - `name` (string, required): The name shown at the door, 1 to 40 characters; control characters are refused. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/knock \ -H "Content-Type: application/json" \ -d '{ "name": "Maja Ekberg" }' ``` RESPONSE ```json { "knock_token": "", "status": "pending" } ``` 422 when the room admits anyone with the link: there is no door to knock on. 429 means the door queue is full, not the room; the meeting itself may have space. ### Poll the knock `GET https://api.bynn.com/v1/zoreal/meet/instant/:code/knock/:knock_token` Auth None (the token is the credential) Reads the state of a knock. The poll is also the heartbeat: a waiting guest holds no connection, so polling is what keeps them visible at the door, and a knock unseen for ten seconds drops off the host's list. Poll every couple of seconds; the allowance is 120 a minute. #### Returns `status` is one of: pending, still waiting; admitted, join with this token; denied, a host said no; ended, the meeting is over. There is no waiting deadline - a knock ends when a host answers or the meeting does, never because a clock ran out. REQUEST · CURL ```bash curl https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/knock/ ``` RESPONSE ```json { "status": "admitted" } ``` An unknown room answers `ended` with HTTP 200, not denied: saying a host refused someone when the meeting simply finished would be a lie about a person. An unknown token reads as `denied`, so the guest gets an instruction rather than a fault. ### Withdraw a knock `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/knock/:knock_token/withdraw` Auth None (the token is the credential) Takes a knock back, so the guest leaves the host's door instead of lingering on it until the heartbeat lapses. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/knock//withdraw ``` RESPONSE ```json { "withdrawn": true } ``` ### Answer the door `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/knocks` Auth [Host token](https://zoreal.com/documentation#host-controls) Who is waiting right now. This read is a POST deliberately: a GET would put the host credential in a query string, and from there into access logs, proxies and Referer headers. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/knocks \ -H "Content-Type: application/json" \ -d '{ "host_token": "" }' ``` RESPONSE ```json { "knocks": [ { "token": "", "name": "Maja Ekberg", "at": "2026-09-02T09:12:40Z" } ] } ``` #### Admit `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/admit` Takes `host_token` and the `knock_token` to let in. Admission grants the permissions the room's policy would have given them at the door, in one call; the guest's next poll reads `admitted` and they join with their token. #### Deny `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/deny` The same body. Denying removes the guest from the door; there is no third state between the corridor and the room. 404 when nobody is waiting under that handle. RESPONSE ```json { "denied": true } ``` ## Room settings What the room permits, set by a host while the meeting runs. The same host credential as [Host controls](https://zoreal.com/documentation#host-controls). ### Room policy `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/policy` Auth [Host token](https://zoreal.com/documentation#host-controls) Turns the room's capabilities on and off. Send any subset; what you do not send is left as it is, and the response returns the whole policy. A change binds people already in the room, not only future joiners. #### Request body (any subset, all boolean) - `screen_share` (boolean, optional, default `true`): Whether participants may present. Turning it off also frees the stage if a participant holds it; hosts keep presenting. - `microphone` (boolean, optional, default `true`): Whether participants may unmute. - `camera` (boolean, optional, default `true`): Whether participants may send video. - `messages` (boolean, optional, default `true`): Whether participants may write in the chat. - `reactions` (boolean, optional, default `true`): Whether participants may send reactions. - `knock` (boolean, optional, default `true`): Whether joining requires a host to admit. On by default: a new room has a door. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/policy \ -H "Content-Type: application/json" \ -d '{ "host_token": "", "screen_share": false }' ``` RESPONSE ```json { "policy": { "screen_share": false, "microphone": true, "camera": true, "messages": true, "reactions": true, "knock": true } } ``` Sending an empty body answers 422: there is nothing to change. ### Presence rule `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/presence` Auth [Host token](https://zoreal.com/documentation#host-controls) What a participant must prove before the room treats them as verified, and what someone who has not proved it may still do. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. - `level` (string, required, one of `poh`, `poh_kyc`, `off`): The bar: `poh` is proof of a live human, `poh_kyc` adds a verified identity. Turning the rule off is the literal word `off`, never a null or an omitted field: a field dropped by a bug must not read as "turn the rule off". - `unproven_access` (string, optional, one of `full`, `denied`): What happens to someone who cannot prove they are live. `full` (the default) warns only: they stay in with video and audio, marked Unproven. `denied` keeps anyone who joins after the rule was set, and has not proved it, outside the meeting until they do; people already in stay, marked Unproven. Nobody is removed. The former `video_only` is still accepted and stored as `full`. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/presence \ -H "Content-Type: application/json" \ -d '{ "host_token": "", "level": "poh", "unproven_access": "full" }' ``` RESPONSE ```json { "presence_on_join": "poh", "unproven_access": "full" } ``` Raising the bar re-checks everyone: a verification that no longer satisfies the new level is dropped, not grandfathered in. Uniquely among host controls, a media-node hiccup does not fail this request: the rule is already written, and every future join reads it. ### Encryption `POST https://api.bynn.com/v1/zoreal/meet/instant/:code/host/encryption` Auth [Host token](https://zoreal.com/documentation#host-controls) Turns end-to-end encryption on for a room created without it. One-way, and only while the host is alone in the room: re-keying a populated call would drop everyone's media to renegotiate. #### Request body - `host_token` (string, optional): From the create response. The meeting owner may omit it and authenticate with their API key instead. #### Returns `rejoin: true` means it worked and the host must reconnect: the encryption key arrives only in a fresh join grant. REQUEST · CURL ```bash curl -X POST https://api.bynn.com/v1/zoreal/meet/instant/kfm-qhtz-pgd/host/encryption \ -H "Content-Type: application/json" \ -d '{ "host_token": "" }' ``` RESPONSE ```json { "e2ee": true, "rejoin": true } ``` 409 when the room is already encrypted, and 409 when more than one participant is connected; 502 when the node cannot be reached to count. In an encrypted room the servers cannot read the call, so [Transcription](https://zoreal.com/documentation#transcription) is refused. ## ZOREAL Book embed ZOREAL Book inside your own site: the booking screens in an iframe your page cannot read and that cannot read your page, with a small event API so your site knows when a booking happened. One script tag, `@zoreal/book-js`, or `@zoreal/book-react`. ### How it works Book is the scheduling inside ZOREAL Meet: you define the kinds of appointment people can book with you, and a guest picks a free slot from a calendar that never overlaps your real one. A booking that ends in a call ends in a Meet room. 1. In your account, open Meet, then Book. 2. Set your availability: the weekly hours you are bookable, in your time zone, with date overrides for days off. 3. Connect a calendar if your existing busy time should be respected and bookings written back: Google and Microsoft 365 are supported, and a ZOREAL calendar works with no connection. 4. Create an event type: a title, a length, where it happens, and its requirement. 5. Copy its link. It looks like `acme/kwm-drpt`: your handle, then the address the server minted for the event type. A handle alone, `acme`, shows your whole booking page. The link is all the embed needs. There is no API key, client id or secret in the browser, because everything the embed can do, a stranger on your hosted page could already do. The hosted page itself is `https://book.zoreal.com/`. | Requirement | What the guest must do | Use it for | | --- | --- | --- | | `open` | Give a name and an email, verified with a six-digit code | Ordinary demos, most public booking | | `verified_human` | Prove they are a live human with ZOREAL ID, no name disclosed | Anything you do not want automated or spammed | | `verified_identity` | Disclose their verified legal name | Consultations, clinics, regulated work | The requirement belongs to the event type, and the embed can neither raise nor lower it: your page cannot talk a guest past your own rule. Embedding is a paid feature. The packages and the script are free to install and load; on a plan without embedding, the frame shows a button through to the hosted page and fires `linkFailed` with `embed_not_available`, so booking still works, on the hosted page. ### One script tag The tag reads its own attributes and needs no second script. Where it goes depends on the mode. An inline tag renders where it stands, so it belongs in the body, at the spot the calendar should appear, with no fixed height and no scrolling wrapper: the screens size themselves. A popup tag renders nothing until an element carrying `data-zoreal-book-link` is clicked, so it goes once per page, anywhere. INLINE · HTML ```html

Book a demo

``` FLOATING BUTTON · HTML ```html ``` POPUP · HTML ```html Book a demo ``` | Attribute | Purpose | | --- | --- | | `data-zoreal-book` | The link. Required: or / | | `data-zoreal-book-mode` | `inline` (the default), `popup`, `floating`, `redirect` | | `data-zoreal-book-theme` | `light`, `dark` or `auto` | | `data-zoreal-book-config` | JSON prefill, as in Prefill and metadata | | `data-zoreal-book-namespace` | An independent embed on the same page | | `data-zoreal-book-text` | Floating mode: the button label | | `data-zoreal-book-position` | Floating mode: `bottom-right` or `bottom-left` | | `data-zoreal-book-color` | Floating mode: any CSS colour | | `data-zoreal-book-text-color` | Floating mode: any CSS colour | In a page builder, the inline tag goes in an HTML or embed block where the calendar should sit, and the popup tag in the site's header or footer code, with the link attribute added to a button through the builder's custom attributes. The tag also leaves the full API on `window.ZorealBook`. ### The packages [@zoreal/book-js](https://github.com/Bynn-Intelligence/zoreal-book-js) is framework-free with no runtime dependencies, ESM and CJS, and is the core the script tag and the React package are built on. [@zoreal/book-react](https://github.com/Bynn-Intelligence/zoreal-book-react) wraps it as a component and a hook, for React 18 or 19. Each README is the full reference. INSTALL · JAVASCRIPT ```sh npm install @zoreal/book-js ``` INSTALL · REACT ```sh npm install @zoreal/book-react ``` JAVASCRIPT ```ts import ZorealBook from '@zoreal/book-js'; ZorealBook.init(); const embed = ZorealBook.inline({ link: 'acme/kwm-drpt', element: '#book', config: { metadata: { lead_id: 'L-4471' } }, }); ZorealBook.modal({ link: 'acme/kwm-drpt' }); // a layer over the page ZorealBook.redirect({ link: 'acme/kwm-drpt' }); // the hosted page instead ZorealBook.preload({ link: 'acme/kwm-drpt' }); // warm it on hover or focus ZorealBook.on('bookingSuccessful', ({ booking }) => { console.log('booked', booking.id, booking.startsAt); }); // Later embed.destroy(); ``` REACT · TSX ```tsx import { ZorealBook } from '@zoreal/book-react'; export function BookingSection() { return ; } ``` | Call | Returns | | --- | --- | | `ZorealBook.init({ origin? })` | The API, for chaining. Also starts click-to-open | | `ZorealBook.inline({ link, element, config? })` | `{ destroy(), setConfig(config) }` | | `ZorealBook.modal({ link, config? })` | `{ close(), setConfig(config) }` | | `ZorealBook.floatingButton({ link, text?, position?, color?, textColor?, config? })` | `{ destroy() }` | | `ZorealBook.redirect({ link, config?, target? })` | Navigates | | `ZorealBook.hostedUrl(link, config?)` | The hosted page address, as a string | | `ZorealBook.preload({ link })` | Warms a frame | | `ZorealBook.ui(config)` | The API, for chaining | | `ZorealBook.on(event, handler)` | The API, for chaining; off() removes | | `ZorealBook.ns(name)` | That namespace's API, with all of the above | | `ZorealBook.destroy()` | Tears down every namespace | ### Prefill and metadata Everything your site already knows about the guest, so they do not type it twice. Prefill never travels in the URL: the frame opens bare and the config is posted to it once it reports ready, because a name or an email in a URL ends up in logs, history and referrer headers. For the same reason `redirect()` and `hostedUrl()` drop the guest's details and keep only what describes the appointment. `metadata` is how you match a booking back to a lead: up to ten string pairs, shown to the host, returned on every event, never shown to the guest. PREFILL · JAVASCRIPT ```ts ZorealBook.modal({ link: 'acme/kwm-drpt', config: { name: 'Maja Ekberg', email: 'maja@example.com', notes: 'Interested in the enterprise plan', answers: { team_size: '50-200' }, timezone: 'Europe/Stockholm', date: '2026-11-03', metadata: { lead_id: 'L-4471', campaign: 'autumn' }, }, }); ``` | Key | What it does | | --- | --- | | `name`, `email`, `phone` | Prefills the guest's details. Never trusted: an open event type still verifies the address with a code | | `guests` | Additional attendees, by email, when the event type allows them | | `notes` | Prefills the notes field | | `answers` | Prefills your own questions, keyed by question key | | `timezone` | An IANA zone; overrides detection | | `month`, `date`, `slot` | Open on a month, a day, or a chosen slot | | `locale` | A BCP 47 tag; defaults to the browser's language | | `metadata` | Up to ten string pairs carried on the booking and returned on every event | ### Events | Event | Fires when | | --- | --- | | `linkReady` | The link resolved and the screens are usable | | `linkFailed` | The link did not resolve. Fall back to a plain link | | `bookingSuccessful` | A booking was made and confirmed | | `bookingRequested` | A booking was made and is waiting for your approval | | `rescheduleSuccessful` | An existing booking moved to a new slot | | `bookingCancelled` | An existing booking was cancelled | | `identityRequired` | The event type requires proof and the guest has been asked | | `identityVerified` | The guest passed. Carries the grade only | | `paymentStarted` | Checkout opened, on the payment page and never in the frame | Every booking event carries the same shape. Events never carry the guest's name, email or verified identity: a booking a stranger makes on your page does not hand you their details, and an identity check tells you it passed, never who passed it. Collect details on your own form first, pass them as prefill, and match the booking back with `metadata`. `linkFailed` carries a `reason`: `not_found` (no such handle, or the page was deleted), `inactive` (the event type is hidden), `embed_not_available` (the plan does not include embedding) or `protocol_unsupported` (the package is older than the page expects; upgrade it, or use the hosted script, which is always current). BOOKING EVENT ```json { "booking": { "id": "bk_7QK39F2M", "eventType": { "slug": "kwm-drpt", "title": "Product demo", "length": 30 }, "startsAt": "2026-11-03T13:00:00Z", "endsAt": "2026-11-03T13:30:00Z", "timezone": "Europe/Stockholm", "location": "meet_room", "status": "confirmed", "requirement": "verified_human", "metadata": { "lead_id": "L-4471" } } } ``` FALLBACK · JAVASCRIPT ```ts ZorealBook.on('linkFailed', ({ link, reason }) => { if (reason === 'embed_not_available') { showPlainLink(ZorealBook.hostedUrl(link)); } }); ``` ### Content Security Policy Nothing to allow, for most sites: the booking page may be framed by any site, so there is no allowlist to join and no domain to register. A site that already sets a Content Security Policy adds two directives. `script-src` is needed only when loading `embed.js` from the hosted address; `frame-src` is needed either way, because the booking screens are an iframe. CONTENT-SECURITY-POLICY ```text script-src https://book.zoreal.com frame-src https://book.zoreal.com ``` The embed brings its own CSS and takes none of yours. What it paints on your page lives in a shadow root, and the booking screens are painted inside the frame, on ZOREAL's origin. `ZorealBook.ui({ cssVars })` sets only the variables the booking page publishes, such as `--zb-accent` and `--zb-radius`.