{"openapi":"3.0.0","paths":{"/v1/organizations/{orgId}/audit-events":{"get":{"operationId":"AuditController_list","summary":"Page through the organization’s audit events, newest first","description":"Admin only. Actor labels are resolved at read time — a user’s email, a token’s name, and null for an event nobody performed or an actor whose row is gone. The log itself holds only ids.","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Page size (default 50, max 200)","schema":{"minimum":1,"maximum":200,"type":"number"}},{"name":"cursor","required":false,"in":"query","description":"Opaque `nextCursor` from the previous page","schema":{"type":"string"}},{"name":"siteId","required":false,"in":"query","description":"Only this site’s events; organization-level events are excluded","schema":{"type":"string"}},{"name":"action","required":false,"in":"query","description":"Repeatable; several values behave as OR","schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"Audit events plus nextCursor"},"400":{"description":"limit out of range, invalid_cursor or unknown_action"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required (editors and viewers cannot read)"},"404":{"description":"organization_not_found"}},"tags":["audit"],"security":[{"cookie":[]}]}},"/v1/agent/audit-events":{"get":{"operationId":"AgentAuditController_list","summary":"Page through the token’s organization’s audit events","description":"A token bound to one site sees only that site’s events, never the organization-level ones. The token’s creator must still be an active admin.","parameters":[{"name":"limit","required":false,"in":"query","schema":{"minimum":1,"maximum":200,"type":"number"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"siteId","required":false,"in":"query","schema":{"type":"string"}},{"name":"action","required":false,"in":"query","schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"Audit events plus nextCursor"},"400":{"description":"limit out of range, invalid_cursor or unknown_action"},"401":{"description":"Missing, invalid, expired or revoked token"},"403":{"description":"insufficient_scope, token_not_scoped_to_site, or admin_role_required when the token’s creator is no longer an admin"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/auth/otp/request":{"post":{"operationId":"AuthController_requestOtp","summary":"Request a one-time login code by email","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestOtpDto"}}}},"responses":{"202":{"description":"Always the same generic body, whether or not an account exists for that email and whether or not a rate limit was hit — see specs/authentication-security.md"},"400":{"description":"validation_failed"}},"tags":["auth"]}},"/v1/auth/otp/verify":{"post":{"operationId":"AuthController_verifyOtp","summary":"Exchange a one-time code for a session cookie","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyOtpDto"}}}},"responses":{"200":{"description":"Sets the nastar_session cookie; returns the user id and whether the account was created by this verification"},"400":{"description":"validation_failed"},"401":{"description":"invalid_or_expired_code (also returned on too many attempts)"}},"tags":["auth"]}},"/v1/auth/logout":{"post":{"operationId":"AuthController_logout","summary":"End the current session and clear its cookie","parameters":[],"responses":{"204":{"description":"Session revoked. Not guarded: the same 204 comes back when no session cookie was sent, so logging out is always safe to call"}},"tags":["auth"]}},"/v1/auth/me":{"get":{"operationId":"AuthController_me","summary":"Return the signed-in user","parameters":[],"responses":{"200":{"description":"The user id and email"},"401":{"description":"not_authenticated"}},"tags":["auth"],"security":[{"cookie":[]}]}},"/v1/auth/sessions":{"get":{"operationId":"AuthController_listSessions","summary":"List the signed-in user’s active sessions","parameters":[],"responses":{"200":{"description":"Active sessions, with isCurrent marking the one this request used"},"401":{"description":"not_authenticated"}},"tags":["auth"],"security":[{"cookie":[]}]}},"/v1/auth/sessions/{sessionId}":{"delete":{"operationId":"AuthController_revokeSession","summary":"Revoke one of the signed-in user’s sessions","parameters":[{"name":"sessionId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Session revoked; the cookie is cleared too when it was the current one"},"401":{"description":"not_authenticated"},"404":{"description":"session_not_found — also when the session belongs to another user, so nobody can probe for other people’s session ids"}},"tags":["auth"],"security":[{"cookie":[]}]}},"/v1/organizations":{"post":{"operationId":"OrgsController_create","summary":"Create an organization owned by the caller","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationDto"}}}},"responses":{"201":{"description":"The created organization; the caller becomes its admin"},"400":{"description":"validation_failed"},"401":{"description":"not_authenticated"}},"tags":["organizations"],"security":[{"cookie":[]}]},"get":{"operationId":"OrgsController_list","summary":"List the organizations the caller is an active member of","parameters":[],"responses":{"200":{"description":"The caller’s organizations, each with their role in it"},"401":{"description":"not_authenticated"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/sites":{"post":{"operationId":"OrgsController_createSite","summary":"Add a site to an organization","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSiteDto"}}}},"responses":{"201":{"description":"The created site, with its public site key and the default consent categories"},"400":{"description":"validation_failed, or a url that is not a valid hostname"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot create sites)"},"404":{"description":"organization_not_found"}},"tags":["organizations"],"security":[{"cookie":[]}]},"get":{"operationId":"OrgsController_listSites","summary":"List an organization’s sites","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The organization’s sites"},"401":{"description":"not_authenticated"},"404":{"description":"organization_not_found"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/consent-stats":{"get":{"operationId":"OrgsController_consentStats","summary":"Daily consent activity per site for one month","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"month","required":false,"in":"query","schema":{"pattern":"/^\\d{4}-(0[1-9]|1[0-2])$/","type":"string"}},{"name":"timeZone","required":false,"in":"query","schema":{"maxLength":64,"type":"string"}}],"responses":{"200":{"description":"Per-site daily consent counts"},"400":{"description":"month_out_of_range"},"401":{"description":"not_authenticated"},"404":{"description":"organization_not_found"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/members":{"get":{"operationId":"OrgsController_listMembers","summary":"List an organization’s members","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Members with their email, role and membership status"},"401":{"description":"not_authenticated"},"404":{"description":"organization_not_found"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/members/{userId}":{"patch":{"operationId":"OrgsController_changeMemberRole","summary":"Change a member’s role","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeMemberRoleDto"}}}},"responses":{"200":{"description":"The target’s new role, plus the caller’s own resulting role so a self-demotion re-renders correctly"},"400":{"description":"invalid_role"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"member_not_found, or organization_not_found"},"409":{"description":"last_admin — the change would leave the organization with no active admin"}},"tags":["organizations"],"security":[{"cookie":[]}]},"delete":{"operationId":"OrgsController_removeMember","summary":"Remove a member from the organization","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The removal, with the number of setup tokens and the addresses of the pending invites it revoked as a side effect"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"member_not_found, or organization_not_found"},"409":{"description":"last_admin"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/leave":{"post":{"operationId":"OrgsController_leave","summary":"Leave the organization","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The same body a removal returns, for the caller"},"401":{"description":"not_authenticated"},"404":{"description":"organization_not_found"},"409":{"description":"last_admin_must_delete_organization — a sole admin has to promote a successor or delete the organization; there is nobody to hand over to"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}":{"patch":{"operationId":"OrgsController_rename","summary":"Rename the organization","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameOrganizationDto"}}}},"responses":{"200":{"description":"The updated organization"},"400":{"description":"validation_failed"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"organization_not_found"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/delete":{"post":{"operationId":"OrgsController_scheduleDeletion","summary":"Schedule the organization and all its sites for deletion","description":"Requires the organization’s exact name in `confirmName`, compared server-side. Sets a 30-day deadline, cascades every site into pending_deletion with the same deadline, and emails every active admin. During the grace period everything keeps working — the published configs keep being served and consent keeps being recorded — except creating a site, an invite or a setup token. Deletion destroys the organization’s consent records before their own retention deadlines; the response states the exact count.","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfirmDeletionDto"}}}},"responses":{"202":{"description":"deletionScheduledAt, plus the measured siteCount and consentRecordCount"},"400":{"description":"confirmation_mismatch"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"organization_not_found"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/cancel-deletion":{"post":{"operationId":"OrgsController_cancelDeletion","summary":"Cancel a scheduled organization deletion","description":"Returns the organization to active and every site it cascaded into pending_deletion to disabled — never straight back to published. Sites are re-enabled one at a time, deliberately.","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The restored organization"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"organization_not_found"},"409":{"description":"not_pending_deletion"}},"tags":["organizations"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/invites":{"post":{"operationId":"OrgInvitesController_create","summary":"Invite someone to the organization by email","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInviteDto"}}}},"responses":{"201":{"description":"The pending invite; the link is emailed, never returned here. Any earlier pending invite for the same email is revoked"},"400":{"description":"validation_failed, or already_a_member"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required (only admins may grant org access)"},"404":{"description":"organization_not_found"}},"tags":["invites"],"security":[{"cookie":[]}]},"get":{"operationId":"OrgInvitesController_list","summary":"List the organization’s pending invites","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Invites that are neither accepted nor revoked"},"401":{"description":"not_authenticated"},"404":{"description":"organization_not_found"}},"tags":["invites"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/invites/{inviteId}":{"delete":{"operationId":"OrgInvitesController_revoke","summary":"Revoke a pending invite","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"inviteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The invite link stops working immediately"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"invite_not_found (already revoked, or not this org’s), or organization_not_found"}},"tags":["invites"],"security":[{"cookie":[]}]}},"/v1/invites/{token}":{"get":{"operationId":"InvitesController_preview","summary":"Preview what an invite link is for, before signing in","parameters":[{"name":"token","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The organization name, the invited email and the role on offer — nothing else about the organization"},"404":{"description":"invite_not_found_or_expired — the same answer for an unknown, expired, revoked or already-accepted token"}},"tags":["invites"]}},"/v1/invites/{token}/accept":{"post":{"operationId":"InvitesController_accept","summary":"Accept an invite as the signed-in user","parameters":[{"name":"token","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"Membership granted; returns the organization and role"},"401":{"description":"not_authenticated"},"403":{"description":"invite_email_mismatch — the invite was issued to a different email than the signed-in account"},"404":{"description":"invite_not_found_or_expired"}},"tags":["invites"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/tokens":{"post":{"operationId":"TokensController_create","summary":"Create a scoped setup token — the raw value is returned once","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTokenDto"}}}},"responses":{"201":{"description":"The token id, its raw value (shown only here, never again) and its expiry"},"400":{"description":"invalid_scopes or site_not_in_organization"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"organization_not_found"}},"tags":["tokens"],"security":[{"cookie":[]}]},"get":{"operationId":"TokensController_list","summary":"List the organization’s setup tokens (never their values)","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Token metadata: name, site binding, scopes, expiry, usage"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"organization_not_found"}},"tags":["tokens"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/tokens/{tokenId}":{"delete":{"operationId":"TokensController_revoke","summary":"Revoke a setup token immediately","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"tokenId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The token is revoked"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"token_not_found (including an already-revoked one), or organization_not_found"}},"tags":["tokens"],"security":[{"cookie":[]}]}},"/v1/agent/me":{"get":{"operationId":"AgentController_me","summary":"Describe the calling setup token’s grant","parameters":[],"responses":{"200":{"description":"The token’s org/site binding and scopes"},"401":{"description":"Missing, invalid, expired or revoked token"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/agent/sites/{siteId}":{"get":{"operationId":"AgentController_getSite","summary":"Read a site’s draft config, categories and services","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The draft site, its categories and services"},"401":{"description":"Missing, invalid, expired or revoked token"},"403":{"description":"insufficient_scope or token_not_scoped_to_site"},"404":{"description":"site_not_found (or outside the token’s organization)"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/agent/sites/{siteId}/banner-copy":{"post":{"operationId":"AgentController_updateBannerCopy","summary":"Update the site’s draft banner copy","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBannerCopyDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"Invalid request body"},"401":{"description":"Missing, invalid, expired or revoked token"},"403":{"description":"insufficient_scope or token_not_scoped_to_site"},"404":{"description":"site_not_found (or outside the token’s organization)"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/agent/sites/{siteId}/hostnames":{"post":{"operationId":"AgentController_updateHostnames","summary":"Replace the site’s allowed hostnames and preview patterns","description":"§16.3 names \"update its allowed domains\" as a required automation operation, and this is it. Creating a DNS verification and switching enforcement on are deliberately NOT exposed to tokens: proving control of a domain and enabling a setting that can take a live site’s optional scripts down are both human decisions.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateHostnamesDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"hostname_list_empty, invalid_hostname, invalid_wildcard, wildcard_base_not_verified, wildcard_base_is_public_suffix, preview_pattern_too_broad, preview_suffix_not_allowed, too_many_hostnames, too_many_preview_patterns, or hostname_not_verified while the site is enforcing"},"401":{"description":"Missing, invalid, expired or revoked token"},"403":{"description":"insufficient_scope or token_not_scoped_to_site"},"404":{"description":"site_not_found (or outside the token’s organization)"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/agent/sites/{siteId}/google-consent-mode":{"post":{"operationId":"AgentController_updateGoogleConsentMode","summary":"Map Google Consent Mode signals to consent categories","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGoogleConsentModeDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"unknown_category or category_is_necessary"},"401":{"description":"Missing, invalid, expired or revoked token"},"403":{"description":"insufficient_scope or token_not_scoped_to_site"},"404":{"description":"site_not_found (or outside the token’s organization)"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/agent/sites/{siteId}/google-analytics":{"post":{"operationId":"AgentController_updateGoogleAnalytics","summary":"Configure the consent-gated Google Analytics integration","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGoogleAnalyticsDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"invalid_measurement_id, measurement_id_required, category_required, unknown_category or category_is_necessary"},"401":{"description":"Missing, invalid, expired or revoked token"},"403":{"description":"insufficient_scope or token_not_scoped_to_site"},"404":{"description":"site_not_found (or outside the token’s organization)"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/agent/sites/{siteId}/publish":{"post":{"operationId":"AgentController_publish","summary":"Publish an immutable snapshot of the draft config and mirror it to the CDN","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"The published config version"},"400":{"description":"banner_copy_incomplete or no_categories_configured"},"401":{"description":"Missing, invalid, expired or revoked token"},"403":{"description":"insufficient_scope or token_not_scoped_to_site"},"404":{"description":"site_not_found (or outside the token’s organization)"}},"tags":["agent"],"security":[{"setupToken":[]}]}},"/v1/organizations/{orgId}/domain-verifications":{"get":{"operationId":"DomainVerificationsController_list","summary":"List the organization’s domain verifications","description":"Each row carries the exact TXT record to publish, its status, when it was last checked and why the last check failed. The token is returned in full: it is published in DNS, so it is not a credential and has to stay re-displayable.","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The organization’s verifications"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (admins only)"},"404":{"description":"organization_not_found"}},"tags":["domain-verifications"],"security":[{"cookie":[]}]},"post":{"operationId":"DomainVerificationsController_create","summary":"Start verifying a domain this organization controls","description":"Two organizations may verify the same domain: each gets its own token and both records coexist in the zone, because an agency and its client both legitimately control it. Re-requesting a domain this organization already has returns the existing row with 200 rather than issuing a second token that would invalidate the record already published.","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainVerificationDto"}}}},"responses":{"200":{"description":"The verification that already existed for this domain"},"201":{"description":"A new verification, with the record to publish"},"400":{"description":"invalid_domain or domain_is_public_suffix"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (admins only)"},"404":{"description":"organization_not_found"},"409":{"description":"verification_limit_reached"}},"tags":["domain-verifications"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/domain-verifications/{verificationId}/check":{"post":{"operationId":"DomainVerificationsController_check","summary":"Look up the TXT record now","description":"Rate-limited to 10 per verification and 60 per organization per hour, counted in Postgres. An expired verification is refused: expiry is terminal, and the remedy is to delete it and create a new one — the token is public, so nothing is lost.","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"verificationId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The verification after the check"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (admins only)"},"404":{"description":"domain_verification_not_found, or organization_not_found"},"409":{"description":"verification_expired"},"429":{"description":"verification_check_rate_limited"}},"tags":["domain-verifications"],"security":[{"cookie":[]}]}},"/v1/organizations/{orgId}/domain-verifications/{verificationId}":{"delete":{"operationId":"DomainVerificationsController_remove","summary":"Remove a domain verification","description":"Refused while a site in this organization is enforcing an allowlist that would be left unproven without it.","parameters":[{"name":"orgId","required":true,"in":"path","schema":{"type":"string"}},{"name":"verificationId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The verification is gone"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (admins only)"},"404":{"description":"domain_verification_not_found, or organization_not_found"},"409":{"description":"verification_in_use — details carry the dependent siteIds"}},"tags":["domain-verifications"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/hostnames":{"post":{"operationId":"SiteHostnamesController_updateHostnames","summary":"Replace the site’s allowed hostnames and preview patterns","description":"Replace semantics, like languages. Exact hostnames and `*.base` wildcards; a wildcard needs a base this organization has verified and that is not a public suffix. Preview patterns are `prefix*.suffix` with at least three literal prefix characters — they are admin-declared, never verified, and never count toward \"all hostnames verified\". Subdomains are never implicit: example.com does not authorise www.example.com.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateHostnamesDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"hostname_list_empty, invalid_hostname, invalid_wildcard, wildcard_base_not_verified, wildcard_base_is_public_suffix, preview_pattern_too_broad, preview_suffix_not_allowed, too_many_hostnames, too_many_preview_patterns, and — only while the site is in enforce — hostname_not_verified"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/enforcement":{"get":{"operationId":"SiteHostnamesController_getEnforcement","summary":"What stands between this site and enforce","description":"Computed, never stored: when the warn period ends, which listed hostnames no verified domain covers, and which observed origins still need review. The dashboard renders these as sentences rather than disabling a control with no explanation.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The eligibility state"},"401":{"description":"not_authenticated"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]},"post":{"operationId":"SiteHostnamesController_updateEnforcement","summary":"Change the site’s hostname enforcement mode","description":"off and warn are always allowed — reversibility is not negotiable. enforce requires the full warn period, every listed hostname covered by a verified domain, and no unreviewed mismatching observation. off → enforce is never allowed, and there is no override flag: an escape hatch nobody can resist using is not a safety mechanism.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEnforcementDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"warn_period_incomplete (details: enteredWarnAt, eligibleAt), unverified_hostnames (details: hostnames) or unreviewed_origins (details: hostnames)"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/observed-origins":{"get":{"operationId":"SiteHostnamesController_listObservedOrigins","summary":"Page through the hostnames this site’s public key was seen from","description":"One row per (site, hostname), never one per request: no IP, no anonymousId, no user agent and no timeline. `matches` is computed against the live allowlist on every read rather than stored, because a stored flag goes stale the moment an admin edits the list. `observationCount` counts recorded observations — a matching origin is refreshed at most hourly — and is never analytics.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Page size (default 50, max 200)","schema":{"minimum":1,"maximum":200,"type":"number"}},{"name":"cursor","required":false,"in":"query","description":"Opaque `nextCursor` from the previous page","schema":{"type":"string"}}],"responses":{"200":{"description":"Observed origins with matches/first seen/last seen/times seen, plus nextCursor"},"400":{"description":"limit out of range, or invalid_cursor"},"401":{"description":"not_authenticated"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/observed-origins/ignore":{"post":{"operationId":"SiteHostnamesController_ignoreObservedOrigin","summary":"Mark one observed origin reviewed, or put it back","description":"An unreviewed mismatching origin blocks the move to enforce. Adding the hostname to the allowlist resolves it by making it match; this resolves it explicitly. There is deliberately no bulk \"ignore all\" — this review is the only moment anyone looks at the list.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IgnoreObservedOriginDto"}}}},"responses":{"201":{"description":"The updated observation"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"observation_not_found, or site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}":{"get":{"operationId":"SiteConfigController_getDraft","summary":"Read the site’s draft config, categories and services","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The draft site, its categories (including disabled ones) and services, plus its publication state: `hasUnpublishedChanges` is true when publishing now would change what visitors are served, and `lastPublishedAt` is when the live config was published (null before any publish). `unpublishedChanges` lists each difference as `{ area, action, subject?, fields? }` plus `repromptsVisitors` (the consentRelevantVersion would change); it is null before the first publish or while the draft cannot be published. See product-spec.md §9.3b — these are computed from the config itself, not read from `sites.status`, which is a one-way latch and cannot express “published, then edited since”."},"401":{"description":"not_authenticated"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/consent-records":{"get":{"operationId":"SiteConfigController_listConsentRecords","summary":"Page through the site’s anonymous consent events, newest first, with optional date-range, event-type and outcome filters","description":"Numbered pages, not a cursor. `total` is capped at 10000 — see `totalIsCapped` — and a `page` past the last real page implied by `total`/`limit` is clamped down to that last page (echoed back as the response `page`) rather than executed, so a broad, unfiltered query can never force an unbounded count or an unbounded offset scan. `from`/`to` bound `occurredAt`, inclusive of the whole named day in `timeZone`.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"page","required":false,"in":"query","description":"1-based page number (default 1); a value past the last real page is clamped down to it","schema":{"minimum":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Page size (default 50, max 200)","schema":{"minimum":1,"maximum":200,"type":"number"}},{"name":"from","required":false,"in":"query","description":"Inclusive `YYYY-MM-DD`, the first calendar day to include, read in `timeZone`","schema":{"pattern":"/^\\d{4}-\\d{2}-\\d{2}$/","type":"string"}},{"name":"to","required":false,"in":"query","description":"Inclusive `YYYY-MM-DD`, the last calendar day to include, read in `timeZone`","schema":{"pattern":"/^\\d{4}-\\d{2}-\\d{2}$/","type":"string"}},{"name":"timeZone","required":false,"in":"query","description":"IANA zone name `from`/`to` are read in (default UTC); an unrecognised zone falls back to UTC rather than 400ing","schema":{"maxLength":64,"type":"string"}},{"name":"eventType","required":false,"in":"query","description":"One of given, updated, withdrawn","schema":{"enum":["given","updated","withdrawn"],"type":"string"}},{"name":"outcome","required":false,"in":"query","description":"One of accepted_all, partial, rejected_all, or unclassified (selects records whose outcome could not be classified — outcome IS NULL)","schema":{"enum":["accepted_all","partial","rejected_all","unclassified"],"type":"string"}}],"responses":{"200":{"description":"records (id, anonymousId, decisions, eventType, outcome, configVersion, occurredAt — never any account identity), plus page (clamped to the last real page if the request asked past it), pageSize, total and totalIsCapped"},"400":{"description":"page or limit out of range, or from/to not in YYYY-MM-DD form"},"401":{"description":"not_authenticated"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/banner-copy":{"post":{"operationId":"SiteConfigController_updateBannerCopy","summary":"Update the site’s draft banner copy","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBannerCopyDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"Invalid request body"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/appearance":{"post":{"operationId":"SiteConfigController_updateAppearance","summary":"Update the banner’s design tokens, logo and policy links","description":"Merges the given fields onto the stored appearance and validates the resulting palette against WCAG 2.2 AA. There is deliberately no per-button colour: Accept all, Reject all and Customize keep identical size, padding and weight, and the primary button’s label colour is computed from the accent.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAppearanceDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"invalid_color, invalid_mode, invalid_placement, invalid_radius, contrast_text_on_surface, contrast_muted_on_surface, contrast_border_on_surface, contrast_accent_on_surface, contrast_label_on_accent, invalid_logo_url, logo_not_an_image or invalid_policy_url. Contrast failures carry details { pair, ratio, required, suggestion }."},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/languages":{"post":{"operationId":"SiteConfigController_updateLanguages","summary":"Replace the site’s published languages and default language","description":"The default language’s content lives in bannerCopy and the category/service rows, so it cannot be unpublished, and switching which language is the default is not supported yet — that would mean moving content between bannerCopy and translations in both directions. A language with a built-in translation (currently nl) that is newly added here has its empty strings filled from it, for the stock English banner and category text only; strings already translated are kept.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateLanguagesDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"invalid_locale, default_language_not_published, cannot_remove_default_language or cannot_change_default_language"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/translations/{locale}":{"post":{"operationId":"SiteConfigController_updateTranslations","summary":"Deep-merge one locale’s banner, category and service text","description":"Categories are keyed by their stable key and services by their id. A locale that is not published yet can still be edited — publish decides what ships, and blocks on a published locale that is still incomplete.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"locale","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTranslationsDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"invalid_locale or invalid_locale_content"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/categories":{"post":{"operationId":"SiteConfigController_createCategory","summary":"Add a custom nonessential consent category to the site","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCategoryDto"}}}},"responses":{"201":{"description":"The created category"},"400":{"description":"Invalid key/label/description, or category_key_taken"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/categories/{categoryId}":{"post":{"operationId":"SiteConfigController_updateCategory","summary":"Update a consent category’s label, description, order, or enable/disable it","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"categoryId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCategoryDto"}}}},"responses":{"201":{"description":"The updated category"},"400":{"description":"cannot_disable_necessary, category_in_use_by_google or category_has_services"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"category_not_found, or site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/google-consent-mode":{"post":{"operationId":"SiteConfigController_updateGoogleConsentMode","summary":"Map Google Consent Mode signals to consent categories","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGoogleConsentModeDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"unknown_category or category_is_necessary"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/google-analytics":{"post":{"operationId":"SiteConfigController_updateGoogleAnalytics","summary":"Configure the consent-gated Google Analytics integration","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGoogleAnalyticsDto"}}}},"responses":{"201":{"description":"The updated site"},"400":{"description":"invalid_measurement_id, measurement_id_required, category_required, unknown_category or category_is_necessary"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/services":{"post":{"operationId":"SiteConfigController_createService","summary":"Register a service under a consent category","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceDto"}}}},"responses":{"201":{"description":"The created service definition"},"400":{"description":"unknown_category"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/services/{serviceId}":{"post":{"operationId":"SiteConfigController_updateService","summary":"Update a registered service’s name, purpose, category or order","description":"Metadata only, and editor-allowed for that reason. The script fields have their own admin-only route. Moving a service that has a managed script into a necessary category is refused: a necessary category is force-consented, so the script would run with no gate at all.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"serviceId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateServiceDto"}}}},"responses":{"201":{"description":"The updated service definition"},"400":{"description":"unknown_category or managed_service_in_necessary_category"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"service_not_found, or site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]},"delete":{"operationId":"SiteConfigController_deleteService","summary":"Remove a registered service from the site","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"serviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The service is gone (idempotent)"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot write)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/services/{serviceId}/script":{"post":{"operationId":"SiteConfigController_setServiceScript","summary":"Load this service’s script through Nastar (admin only)","description":"Nastar inserts the script once the service’s category is consented, and never inspects what the script contains — “managed” is not “vetted”. Setting a URL for the first time, or changing its host, requires `confirmHost` to equal that host. Warnings in the response body (script_url_unreachable, script_url_content_type) are reported rather than blocking: a transient vendor outage must not fail an unrelated save, and a JavaScript MIME type is not a reliable answer. Nastar cannot unload a script a visitor has already executed, so a later withdrawal stops further injection and nothing more.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"serviceId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetServiceScriptDto"}}}},"responses":{"201":{"description":"The updated service definition, plus any warnings"},"400":{"description":"invalid_script_url (details.reason: not_absolute | not_https | has_credentials | too_long), script_host_not_allowed (details: { host, reason }), invalid_script_integrity, integrity_requires_cross_origin, managed_script_requires_url, managed_service_in_necessary_category, script_url_confirmation_required (details: { host }), invalid_loading_strategy, invalid_script_load_mode, invalid_script_type or invalid_cross_origin"},"401":{"description":"not_authenticated"},"403":{"description":"managed_script_requires_admin — setting a script URL is arbitrary code execution on the customer’s site, so it is narrower than every other site write"},"404":{"description":"service_not_found, or site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]},"delete":{"operationId":"SiteConfigController_clearServiceScript","summary":"Stop loading this service’s script through Nastar (admin only)","description":"Clears the stored URL and returns the service to documentation only. Turning code off never requires confirmation. Already-executed scripts are not unloaded — that is not something any SDK can do.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}},{"name":"serviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The service, now documentation only (idempotent)"},"401":{"description":"not_authenticated"},"403":{"description":"managed_script_requires_admin"},"404":{"description":"service_not_found, or site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/publish":{"post":{"operationId":"SiteConfigController_publish","summary":"Publish an immutable snapshot of the draft config and mirror it to the CDN","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"The published config version"},"400":{"description":"banner_copy_incomplete or no_categories_configured"},"401":{"description":"not_authenticated"},"403":{"description":"insufficient_role (viewers cannot publish)"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/disable":{"post":{"operationId":"SiteConfigController_disable","summary":"Stop new publishes for this site","description":"Disabling does NOT take the banner down. The last published config keeps being served from this API and the CDN, and consent records keep being accepted, because the SDK fails closed: a config that 404s would make the banner vanish and silently block every consent-gated script on the customer’s website. Draft editing stays open so the site can be prepared for re-enabling.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The disabled site"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"site_not_found or organization_not_found"},"409":{"description":"site_pending_deletion"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/enable":{"post":{"operationId":"SiteConfigController_enable","summary":"Allow publishing again","description":"Returns the site to published if it has ever published a config version, and to draft if it has not.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The re-enabled site"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"site_not_found or organization_not_found"},"409":{"description":"site_pending_deletion"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/delete":{"post":{"operationId":"SiteConfigController_scheduleDeletion","summary":"Schedule this site for permanent deletion","description":"Requires the site’s exact name in `confirmName`, compared server-side. Sets a 30-day deadline and emails every active admin. During the grace period the site behaves exactly as disabled — config still served, consent still recorded, publishing refused. Deletion destroys the site’s consent records before their own retention deadlines; the response states the exact count.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfirmDeletionDto"}}}},"responses":{"202":{"description":"deletionScheduledAt, plus the measured consentRecordCount and publishedVersionCount"},"400":{"description":"confirmation_mismatch"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"site_not_found or organization_not_found"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/sites/{siteId}/cancel-deletion":{"post":{"operationId":"SiteConfigController_cancelDeletion","summary":"Cancel a scheduled site deletion","description":"Returns the site to disabled — not to whatever it was before. Re-enabling is a separate, deliberate act.","parameters":[{"name":"siteId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The site, now disabled"},"401":{"description":"not_authenticated"},"403":{"description":"admin_role_required"},"404":{"description":"site_not_found or organization_not_found"},"409":{"description":"not_pending_deletion"}},"tags":["sites"],"security":[{"cookie":[]}]}},"/v1/public/config/{publicSiteKey}":{"get":{"operationId":"PublicConfigController_getConfig","summary":"Fetch a site’s published consent configuration (no auth)","parameters":[{"name":"publicSiteKey","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The published snapshot: banner copy, enabled categories, services and Google mappings"},"403":{"description":"hostname_not_allowed — the site is enforcing and this Origin is not on its allowlist. Not authentication: Origin is spoofable, and this catches misconfiguration and casual key reuse"},"404":{"description":"no_published_config — unknown site key, or nothing published yet"}},"tags":["public"]}},"/v1/public/consent/{publicSiteKey}":{"post":{"operationId":"PublicConfigController_recordConsent","summary":"Record one anonymous consent event for a site (no auth)","parameters":[{"name":"publicSiteKey","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordConsentDto"}}}},"responses":{"201":{"description":"The event was stored"},"400":{"description":"Invalid request body"},"403":{"description":"hostname_not_allowed — the site is enforcing and this Origin is not on its allowlist"},"404":{"description":"site_not_found"},"429":{"description":"rate_limited — the per-site-key or per-IP hourly cap is reached; the body is identical either way and for an unknown site key"}},"tags":["public"]}}},"info":{"title":"Nastar API","description":"Consent management API — session-cookie endpoints for the dashboard (/v1/auth, /v1/organizations, /v1/sites), scoped-token endpoints for automation (/v1/agent), and the unauthenticated public endpoints the consent SDK calls (/v1/public). See specs/authentication-security.md and specs/product-spec.md §16.3 for the two auth models.","version":"1.0","contact":{}},"tags":[{"name":"auth","description":"Email/OTP authentication and session management"},{"name":"organizations","description":"Organization and site management (session-authenticated)"},{"name":"sites","description":"Site configuration: banner copy, categories, services, publish"},{"name":"invites","description":"Organization invites: admin-managed, plus the token-holder’s preview/accept pair"},{"name":"domain-verifications","description":"DNS proof that an organization controls a domain — the gate in front of hostname enforcement and wildcard entries. Not authentication"},{"name":"tokens","description":"Scoped setup token management (session-authenticated)"},{"name":"audit","description":"The immutable record of privileged changes: who changed what, and when"},{"name":"agent","description":"Automation API for IDEs/CLI clients (setup-token-authenticated)"},{"name":"public","description":"Unauthenticated endpoints the consent SDK calls"}],"servers":[],"components":{"securitySchemes":{"cookie":{"type":"apiKey","in":"cookie","name":"nastar_session","description":"Dashboard session — set by POST /v1/auth/otp/verify."},"setupToken":{"scheme":"bearer","bearerFormat":"JWT","type":"http","description":"Scoped setup token (nst_...), see /v1/organizations/:orgId/tokens"}},"schemas":{"RequestOtpDto":{"type":"object","properties":{"email":{"type":"string"}},"required":["email"]},"VerifyOtpDto":{"type":"object","properties":{"email":{"type":"string"},"code":{"type":"string","pattern":"/^\\d{6}$/"}},"required":["email","code"]},"CreateOrganizationDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200}},"required":["name"]},"CreateSiteDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"url":{"type":"string","minLength":1,"maxLength":2048}},"required":["name","url"]},"ChangeMemberRoleDto":{"type":"object","properties":{"role":{"type":"string","enum":["admin","editor","viewer"]}},"required":["role"]},"RenameOrganizationDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100}},"required":["name"]},"ConfirmDeletionDto":{"type":"object","properties":{"confirmName":{"type":"string","minLength":1,"maxLength":200}},"required":["confirmName"]},"CreateInviteDto":{"type":"object","properties":{"email":{"type":"string"},"role":{"type":"string","enum":["admin","editor","viewer"]}},"required":["email","role"]},"CreateTokenDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"siteId":{"type":"string"},"scopes":{"type":"string","enum":["sites:read","sites:write","sites:publish","audit:read"]},"ttlDays":{"type":"number","minimum":1,"maximum":365}},"required":["name","scopes"]},"UpdateBannerCopyDto":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"body":{"type":"string","minLength":1,"maxLength":2000},"acceptAllLabel":{"type":"string","minLength":1,"maxLength":100},"rejectAllLabel":{"type":"string","minLength":1,"maxLength":100},"customizeLabel":{"type":"string","minLength":1,"maxLength":100},"dialogTitle":{"type":"string","minLength":1,"maxLength":200},"dialogBody":{"type":"string","minLength":1,"maxLength":2000},"savePreferencesLabel":{"type":"string","minLength":1,"maxLength":100},"reopenLabel":{"type":"string","minLength":1,"maxLength":100}}},"UpdateHostnamesDto":{"type":"object","properties":{"allowedHostnames":{"type":"array","items":{"type":"string","maxLength":253,"minLength":1}},"previewHostnamePatterns":{"type":"array","items":{"type":"string","maxLength":253,"minLength":1}}},"required":["allowedHostnames"]},"UpdateGoogleConsentModeDto":{"type":"object","properties":{"enabled":{"type":"boolean"},"adStorageCategoryId":{"type":"string","nullable":true},"analyticsStorageCategoryId":{"type":"string","nullable":true},"adUserDataCategoryId":{"type":"string","nullable":true},"adPersonalizationCategoryId":{"type":"string","nullable":true}}},"UpdateGoogleAnalyticsDto":{"type":"object","properties":{"enabled":{"type":"boolean"},"measurementId":{"type":"string","nullable":true,"pattern":"GA_MEASUREMENT_ID_PATTERN"},"consentMode":{"type":"string","enum":["basic","advanced"]},"categoryId":{"type":"string","nullable":true}}},"CreateDomainVerificationDto":{"type":"object","properties":{"domain":{"type":"string","minLength":1,"maxLength":253}},"required":["domain"]},"UpdateEnforcementDto":{"type":"object","properties":{"mode":{"type":"string","enum":["off","warn","enforce"]}},"required":["mode"]},"IgnoreObservedOriginDto":{"type":"object","properties":{"hostname":{"type":"string","minLength":1,"maxLength":253},"ignored":{"type":"boolean"}},"required":["hostname","ignored"]},"UpdateAppearanceDto":{"type":"object","properties":{"mode":{"type":"string","enum":["light","dark"]},"placement":{"type":"string","enum":["bottom","center","bottom-left","bottom-right"]},"launcherPlacement":{"type":"string","enum":["bottom-left","bottom-right"]},"accent":{"type":"string"},"surface":{"type":"string"},"text":{"type":"string"},"muted":{"type":"string"},"border":{"type":"string"},"radius":{"type":"number","minimum":0,"maximum":16},"logoUrl":{"type":"string","nullable":true,"minLength":1,"maxLength":2048},"logoAlt":{"type":"string","nullable":true,"minLength":0,"maxLength":200},"privacyPolicyUrl":{"type":"string","nullable":true,"minLength":1,"maxLength":2048},"cookiePolicyUrl":{"type":"string","nullable":true,"minLength":1,"maxLength":2048}}},"UpdateLanguagesDto":{"type":"object","properties":{"publishedLanguages":{"type":"array","items":{"type":"string","pattern":"LOCALE_PATTERN"}},"defaultLanguage":{"type":"string","pattern":"LOCALE_PATTERN"}},"required":["publishedLanguages","defaultLanguage"]},"UpdateTranslationsDto":{"type":"object","properties":{"bannerCopy":{"$ref":"#/components/schemas/UpdateBannerCopyDto"},"categories":{"type":"object"},"services":{"type":"object"}}},"CreateCategoryDto":{"type":"object","properties":{"key":{"type":"string","pattern":"CATEGORY_KEY_PATTERN"},"label":{"type":"string","minLength":1,"maxLength":100},"description":{"type":"string","minLength":1,"maxLength":500}},"required":["key","label","description"]},"UpdateCategoryDto":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":100},"description":{"type":"string","minLength":1,"maxLength":500},"enabled":{"type":"boolean"},"sortOrder":{"type":"number","minimum":0}}},"CreateServiceDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"purpose":{"type":"string","minLength":1,"maxLength":500},"categoryId":{"type":"string"}},"required":["name","purpose","categoryId"]},"UpdateServiceDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"purpose":{"type":"string","minLength":1,"maxLength":500},"categoryId":{"type":"string"},"sortOrder":{"type":"number","minimum":0,"maximum":9999}}},"SetServiceScriptDto":{"type":"object","properties":{"loadingStrategy":{"type":"string","enum":["manual","managed"]},"scriptUrl":{"type":"string","nullable":true,"maxLength":2048},"loadMode":{"type":"string","enum":["async","defer"]},"scriptType":{"type":"string","enum":["classic","module"]},"integrity":{"type":"string","nullable":true,"maxLength":256},"crossOrigin":{"type":"string","nullable":true,"enum":["anonymous"]},"confirmHost":{"type":"string","maxLength":255}},"required":["loadingStrategy"]},"RecordConsentDto":{"type":"object","properties":{"anonymousId":{"type":"string","minLength":1,"maxLength":200},"configVersion":{"type":"number","minimum":1},"decisions":{"type":"object"},"eventType":{"type":"string","enum":["given","updated","withdrawn"]}},"required":["anonymousId","configVersion","decisions","eventType"]}}}}