{"openapi":"3.1.0","info":{"title":"Login with VisitKorat","version":"1.0.0","summary":"Sign in to a partner application with a Visit Korat account.","description":"Visit Korat is an OpenID Connect provider. A partner adds a\n\"Login with VisitKorat\" button, and receives a verified email address and\ndisplay name for the person who pressed it — with their consent, recorded\nagainst a named version of the partner's own terms and privacy policy.\n\n**Standards.** OAuth 2.0 authorization code flow (RFC 6749) with mandatory\nPKCE S256 (RFC 7636), OpenID Connect Core 1.0, Discovery 1.0, and token\nrevocation (RFC 7009). Any conformant client library works; there is\nnothing bespoke to implement.\n\n**Start at the discovery document.** `/.well-known/openid-configuration`\nlists every endpoint below, so a client library only needs the issuer URL\nand your `client_id`.\n\n**Two environments, two registries.** `https://dev.visitkorat.com` and\n`https://visitkorat.com` are separate: separate clients, separate secrets,\nseparate consents. A code minted on one is not redeemable on the other.\nRegister and build on dev; ask the Visit Korat team when you are ready for\nproduction.\n","contact":{"name":"Visit Korat","url":"https://visitkorat.com/developers"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://visitkorat.com","description":"Production — clients are created by the Visit Korat team"},{"url":"https://dev.visitkorat.com","description":"Development — register and integrate here"}],"tags":[{"name":"Discovery","description":"Machine-readable metadata. Fetch these first; hardcode nothing else."},{"name":"Authorization","description":"The browser half of the flow — where the person signs in and consents."},{"name":"Token","description":"The server half — exchanging a code, refreshing, and revoking."},{"name":"Identity","description":"Reading the profile a token stands for."}],"paths":{"/.well-known/openid-configuration":{"get":{"tags":["Discovery"],"operationId":"getOpenIdConfiguration","summary":"OpenID Provider Metadata","description":"Every endpoint, algorithm and capability this provider supports.\nPoint your client library here rather than hardcoding URLs — it is how\nan endpoint can move without breaking your integration.\n","security":[],"responses":{"200":{"description":"The provider metadata document.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenIdConfiguration"},"example":{"issuer":"https://dev.visitkorat.com","authorization_endpoint":"https://dev.visitkorat.com/oauth/authorize","token_endpoint":"https://dev.visitkorat.com/oauth/token","userinfo_endpoint":"https://dev.visitkorat.com/oauth/userinfo","revocation_endpoint":"https://dev.visitkorat.com/oauth/revoke","end_session_endpoint":"https://dev.visitkorat.com/oauth/logout","jwks_uri":"https://dev.visitkorat.com/.well-known/jwks.json","scopes_supported":["openid","profile","email","offline_access"],"response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"id_token_signing_alg_values_supported":["ES256"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","none"]}}}}}}},"/.well-known/jwks.json":{"get":{"tags":["Discovery"],"operationId":"getJwks","summary":"Public keys for ID token verification","description":"The public half of every key that may have signed a live ID token —\nnot only the current one. Match on the `kid` in the token header.\n\nCache this, but re-fetch when you meet an unknown `kid`: that is what a\nkey rotation looks like from your side, and re-fetching is the whole of\nyour part in it.\n","security":[],"responses":{"200":{"description":"A JSON Web Key Set.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Jwks"},"example":{"keys":[{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","kid":"NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg","alg":"ES256","use":"sig"}]}}}}}}},"/oauth/authorize":{"get":{"tags":["Authorization"],"operationId":"authorize","summary":"Send the person here to sign in and consent","description":"A **browser redirect**, not an API call — never fetch this with XHR.\n\nThe person signs in with a Visit Korat magic link, sees exactly what\nyour app is asking for, and agrees or declines. On success they come\nback to your `redirect_uri` with `code` and `state`.\n\n**PKCE is mandatory.** Generate a random `code_verifier` (43–128\ncharacters from `A-Za-z0-9-._~`), send its base64url SHA-256 as\n`code_challenge`, and keep the verifier for the token call. `plain` is\nrefused.\n\n**Errors do not always come back to you.** If `client_id` or\n`redirect_uri` cannot be verified, the person sees an error page and is\nredirected nowhere — forwarding a browser to an unverified URI is how\nan authorize endpoint becomes an open redirect. Everything else is\nreported to your `redirect_uri` as `?error=`.\n","security":[],"parameters":[{"name":"client_id","in":"query","required":true,"schema":{"type":"string"},"example":"my-app-3f2a91c4"},{"name":"redirect_uri","in":"query","required":true,"description":"Must match one of your registered URIs **exactly**. No wildcards.","schema":{"type":"string","format":"uri"},"example":"https://example.com/auth/callback"},{"name":"response_type","in":"query","required":true,"schema":{"type":"string","enum":["code"]}},{"name":"scope","in":"query","required":true,"description":"Space-delimited. Must include `openid`.","schema":{"type":"string"},"example":"openid email profile"},{"name":"state","in":"query","required":true,"description":"Your CSRF token, returned untouched. Required, not merely\nrecommended: we cannot verify it for you, and an integration\nwithout it is one login-CSRF away from attaching a victim's session\nto an attacker's account.\n","schema":{"type":"string","maxLength":512}},{"name":"code_challenge","in":"query","required":true,"description":"base64url SHA-256 of your `code_verifier` — 43 characters.","schema":{"type":"string","minLength":43,"maxLength":43},"example":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},{"name":"code_challenge_method","in":"query","required":true,"schema":{"type":"string","enum":["S256"]}},{"name":"nonce","in":"query","required":false,"description":"Returned in the ID token. Send one and check it to bind the token to this request.","schema":{"type":"string","maxLength":512}},{"name":"terms_version","in":"query","required":true,"description":"The version of **your** terms the person is agreeing to. Stored with\nthe consent record, and a change forces a fresh prompt — which is\nwhat makes the record mean something under PDPA.\n","schema":{"type":"string","maxLength":32},"example":"2026-08-01"},{"name":"privacy_version","in":"query","required":true,"description":"The version of **your** privacy policy. Same rules.","schema":{"type":"string","maxLength":32},"example":"2026-08-01"}],"responses":{"200":{"description":"An HTML page — the sign-in form, the consent screen, or an error\nthat must not be redirected.\n","content":{"text/html":{"schema":{"type":"string"}}}},"302":{"description":"Back to your `redirect_uri`, with either `code` + `state` or\n`error` + `error_description` + `state`.\n","headers":{"Location":{"schema":{"type":"string","format":"uri"},"examples":{"success":{"value":"https://example.com/auth/callback?code=8f14e45f...&state=xyz"},"declined":{"value":"https://example.com/auth/callback?error=access_denied&error_description=The%20user%20declined%20the%20request.&state=xyz"}}}}}}}},"/oauth/token":{"post":{"tags":["Token"],"operationId":"token","summary":"Exchange a code, or refresh","description":"**From your server.** A confidential client that calls this from a\nbrowser is refused outright, because a secret that reaches this endpoint\nfrom a browser is a secret that shipped in a browser bundle.\n\nPublic clients (SPA, native) have no secret and may call it from the\nbrowser — but only from an origin registered on the app, which is\nechoed back one at a time. There is no wildcard CORS here.\n\nBody is `application/x-www-form-urlencoded`. JSON is refused.\n\nAccess tokens are opaque, not JWTs: do not try to decode one. Verify the\n`id_token` instead, or call `/oauth/userinfo`.\n","security":[{"clientSecretBasic":[]},{}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"oneOf":[{"$ref":"#/components/schemas/AuthorizationCodeGrant"},{"$ref":"#/components/schemas/RefreshTokenGrant"}]},"examples":{"authorizationCode":{"summary":"Exchange the code you were redirected back with","value":{"grant_type":"authorization_code","code":"8f14e45fceea167a5a36dedd4bea2543...","redirect_uri":"https://example.com/auth/callback","code_verifier":"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk","client_id":"my-app-3f2a91c4","client_secret":"vkoa_dev_..."}},"refresh":{"summary":"Trade a refresh token for a new pair","value":{"grant_type":"refresh_token","refresh_token":"vkrt_dev_...","client_id":"my-app-3f2a91c4","client_secret":"vkoa_dev_..."}},"publicClient":{"summary":"A public client — no secret, PKCE only","value":{"grant_type":"authorization_code","code":"8f14e45fceea167a5a36dedd4bea2543...","redirect_uri":"https://app.example.com/callback","code_verifier":"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk","client_id":"my-spa-91c4a3f2"}}}}}},"responses":{"200":{"description":"Tokens.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"},"example":{"access_token":"vkat_dev_9f2b1c...","token_type":"Bearer","expires_in":3600,"scope":"openid email","id_token":"eyJhbGciOiJFUzI1NiIsImtpZCI6Ik5qVkJ..."}}}},"400":{"description":"The request or the grant was bad. A single `invalid_grant` covers\nunknown, expired and already-used codes on purpose — telling them\napart is an oracle for anyone holding a stolen code.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"examples":{"badCode":{"value":{"error":"invalid_grant","error_description":"Code is unknown, expired or already used."}},"pkceMismatch":{"value":{"error":"invalid_grant","error_description":"code_verifier does not match the code_challenge."}},"refreshReuse":{"value":{"error":"invalid_grant","error_description":"This refresh token was already used. Every token from that authorization has been revoked; the user must sign in again."}},"wrongContentType":{"value":{"error":"invalid_request","error_description":"Send application/x-www-form-urlencoded, as RFC 6749 §4.1.3 requires. JSON is not accepted."}}}}}},"401":{"description":"We could not tell who you are.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"examples":{"badSecret":{"value":{"error":"invalid_client","error_description":"Client authentication failed."}},"browserOriginOnConfidential":{"value":{"error":"invalid_client","error_description":"A confidential client must call this endpoint from its server, never from a browser. Register a public client if you need a browser-based flow."}}}}}},"429":{"description":"Rate limited — 60 requests per minute per client and address.","headers":{"Retry-After":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}}}}},"/oauth/userinfo":{"get":{"tags":["Identity"],"operationId":"userinfo","summary":"The profile behind an access token","description":"Claims follow the scopes the token carries, which may be narrower than\nwhat your app is registered for. `sub` is always present.\n\nConsent is rechecked on every call. If the person disconnects your app\nat `/account/connections`, this starts answering `401` in the same\nsecond — not when the token expires.\n","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The claims this token is allowed to see.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInfo"},"examples":{"full":{"summary":"openid email profile","value":{"sub":"6f3f0a5e-3f7a-4a6b-9c1e-2b2f9e4a1d77","email":"somebody@example.com","email_verified":true,"name":"สมชาย ใจดี"}},"minimal":{"summary":"openid only","value":{"sub":"6f3f0a5e-3f7a-4a6b-9c1e-2b2f9e4a1d77"}}}}}},"401":{"description":"No token, or the token is expired, revoked or disowned.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"visitkorat\", error=\"invalid_token\", error_description=\"The access token is expired, revoked or unknown.\""}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"},"examples":{"expired":{"value":{"error":"invalid_token","error_description":"The access token is expired, revoked or unknown."}},"withdrawn":{"value":{"error":"invalid_token","error_description":"The user has withdrawn consent for this application."}}}}}},"429":{"description":"Rate limited — 120 requests per minute per token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}}}},"post":{"tags":["Identity"],"operationId":"userinfoPost","summary":"The profile behind an access token (POST form)","description":"Identical to `GET`. OIDC Core §5.3.1 allows POST for clients that dislike long headers.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"As for `GET /oauth/userinfo`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInfo"}}}},"401":{"description":"As for `GET /oauth/userinfo`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}}}}},"/oauth/revoke":{"post":{"tags":["Token"],"operationId":"revoke","summary":"Revoke a token (RFC 7009)","description":"Call this when someone signs out of your app. Revoking a refresh token\ntakes its access tokens with it — otherwise \"log me out\" leaves an hour\nof working credential behind.\n\nAnswers `200` whether or not the token existed. A distinguishable \"no\nsuch token\" would be an oracle for testing guessed tokens.\n","security":[{"clientSecretBasic":[]},{}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"},"token_type_hint":{"type":"string","enum":["access_token","refresh_token"],"description":"Optional and ignored — both tables are checked."},"client_id":{"type":"string"},"client_secret":{"type":"string"}}},"example":{"token":"vkrt_dev_...","client_id":"my-app-3f2a91c4","client_secret":"vkoa_dev_..."}}}},"responses":{"200":{"description":"Done — or the token was never ours. You cannot tell, by design."},"400":{"description":"The request was malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"401":{"description":"Client authentication failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}}}}},"/oauth/logout":{"get":{"tags":["Authorization"],"operationId":"logout","summary":"End the Visit Korat session","description":"A **browser redirect**. Ends the person's session with Visit Korat and\nreturns them to a registered `post_logout_redirect_uri`.\n\n⚠️ This does **not** sign them out of your app, or of any other\nrelying party. There is no front- or back-channel logout, and the\ndiscovery document says so. Call `/oauth/revoke` and clear your own\nsession as well — and do not tell the person they have been signed out\neverywhere, because they have not.\n\nAn unregistered `post_logout_redirect_uri` is ignored, not echoed: this\nis a page people reach while signed in, which makes it the best possible\nplace for an open redirect.\n","security":[],"parameters":[{"name":"id_token_hint","in":"query","required":false,"description":"An ID token you were issued. Tells us which app is asking.","schema":{"type":"string"}},{"name":"client_id","in":"query","required":false,"description":"Alternative to `id_token_hint`.","schema":{"type":"string"}},{"name":"post_logout_redirect_uri","in":"query","required":false,"description":"Must be registered on the app, matched exactly.","schema":{"type":"string","format":"uri"}},{"name":"state","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"To your registered URI, or to a Visit Korat page if none matched.","headers":{"Location":{"schema":{"type":"string","format":"uri"}}}}}}},"/oauth/openapi.json":{"get":{"tags":["Discovery"],"operationId":"getOpenApiJson","summary":"This document, as JSON","security":[],"responses":{"200":{"description":"The OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/oauth/openapi.yaml":{"get":{"tags":["Discovery"],"operationId":"getOpenApiYaml","summary":"This document, as YAML","security":[],"responses":{"200":{"description":"The OpenAPI 3.1 document.","content":{"application/yaml":{"schema":{"type":"string"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"`Authorization: Bearer <access_token>`. The token is opaque — it is a\nrandom string, not a JWT, and decoding it will get you nothing.\n"},"clientSecretBasic":{"type":"http","scheme":"basic","description":"`Authorization: Basic base64(client_id:client_secret)`, both halves\nform-urlencoded first (RFC 6749 §2.3.1). Confidential clients only.\n"}},"schemas":{"OAuthError":{"type":"object","description":"RFC 6749 §5.2. Flat and snake_case — note this differs from the Visit\nKorat Partner API at `/v1/*`, which nests under `error.code`. Two\ndifferent APIs; OAuth's shape is what client libraries parse.\n","required":["error"],"properties":{"error":{"type":"string","enum":["invalid_request","invalid_client","invalid_grant","unauthorized_client","unsupported_grant_type","unsupported_response_type","invalid_scope","access_denied","server_error","temporarily_unavailable","invalid_token","insufficient_scope"]},"error_description":{"type":"string","description":"Written for a human debugging an integration, not for a switch statement."}}},"AuthorizationCodeGrant":{"type":"object","required":["grant_type","code","redirect_uri","code_verifier","client_id"],"properties":{"grant_type":{"type":"string","enum":["authorization_code"]},"code":{"type":"string"},"redirect_uri":{"type":"string","format":"uri","description":"The same value you sent to `/oauth/authorize`. Compared exactly."},"code_verifier":{"type":"string","minLength":43,"maxLength":128,"pattern":"^[A-Za-z0-9\\-._~]{43,128}$"},"client_id":{"type":"string"},"client_secret":{"type":"string","description":"Confidential clients only. Omit entirely for a public client."}}},"RefreshTokenGrant":{"type":"object","required":["grant_type","refresh_token","client_id"],"properties":{"grant_type":{"type":"string","enum":["refresh_token"]},"refresh_token":{"type":"string"},"client_id":{"type":"string"},"client_secret":{"type":"string"}}},"TokenResponse":{"type":"object","required":["access_token","token_type","expires_in"],"properties":{"access_token":{"type":"string","description":"Opaque. One hour. Prefixed `vkat_<env>_` so a dev token in a prod config is obvious."},"token_type":{"type":"string","enum":["Bearer"]},"expires_in":{"type":"integer","example":3600},"scope":{"type":"string","example":"openid email"},"id_token":{"type":"string","description":"A signed JWT (ES256). Present whenever `openid` was granted."},"refresh_token":{"type":"string","description":"Opaque, thirty days, **single use**. Only issued when the Visit\nKorat team has enabled `offline_access` for your app and the person\nconsented to it.\n\nEvery refresh returns a new one and burns the old. Presenting a\nburned token revokes every token from that authorization — we\ncannot tell you from a thief, so both are signed out.\n"}}},"UserInfo":{"type":"object","required":["sub"],"properties":{"sub":{"type":"string","format":"uuid","description":"Stable, opaque, and the same for this person and your app forever.\nKey your records on this, never on the email address — people change\nthose.\n"},"email":{"type":"string","format":"email"},"email_verified":{"type":"boolean","description":"Always true. Every Visit Korat account is created by clicking a link in the inbox."},"name":{"type":"string","nullable":true,"description":"Display name. May be null — people are not required to set one."}}},"OpenIdConfiguration":{"type":"object","description":"OpenID Connect Discovery 1.0 §3. Only the fields we actually implement appear.","properties":{"issuer":{"type":"string","format":"uri"},"authorization_endpoint":{"type":"string","format":"uri"},"token_endpoint":{"type":"string","format":"uri"},"userinfo_endpoint":{"type":"string","format":"uri"},"revocation_endpoint":{"type":"string","format":"uri"},"end_session_endpoint":{"type":"string","format":"uri"},"jwks_uri":{"type":"string","format":"uri"},"service_documentation":{"type":"string","format":"uri"},"scopes_supported":{"type":"array","items":{"type":"string"}},"response_types_supported":{"type":"array","items":{"type":"string"}},"response_modes_supported":{"type":"array","items":{"type":"string"}},"grant_types_supported":{"type":"array","items":{"type":"string"}},"subject_types_supported":{"type":"array","items":{"type":"string"}},"id_token_signing_alg_values_supported":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_methods_supported":{"type":"array","items":{"type":"string"}},"code_challenge_methods_supported":{"type":"array","items":{"type":"string"}},"claims_supported":{"type":"array","items":{"type":"string"}},"frontchannel_logout_supported":{"type":"boolean"},"backchannel_logout_supported":{"type":"boolean"},"ui_locales_supported":{"type":"array","items":{"type":"string"}}}},"Jwks":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"object","properties":{"kty":{"type":"string","example":"EC"},"crv":{"type":"string","example":"P-256"},"x":{"type":"string"},"y":{"type":"string"},"kid":{"type":"string","description":"RFC 7638 thumbprint. Derived from the key","never chosen.":null},"alg":{"type":"string","example":"ES256"},"use":{"type":"string","example":"sig"}}}}}}}}}