Skip to content

Seals

Concept: Seals & integrity.

Personal seal — /api/v1/seals (authenticated)

Section titled “Personal seal — /api/v1/seals (authenticated)”
Method · RoutePurposeSource
POST /Register a seal. OwnerId is always the authenticated user (client input ignored); OwnerType = User or Organization, with a security Tier.SealEndpoints.cs:28
DELETE /{credentialId}Revoke a seal (by owner + ownerType).:65
POST /checkPre-flight: a personal seal needs only email + first passkey (Owner default policy; KYC gating is org-level).:211
GET /check-hybrid-binding?orgSlug=Whether the caller has a pending company-device enrollment in that org, and whether the org requires device-bound passkeys. Answers only for a person in that org (active, invited, or accepted and awaiting their seal); anyone else gets the same answer as for an unknown org.:241

POST / accepts an optional OrgSlug. It is used only when the caller is a person in that org (active, invited, or accepted and awaiting their seal); otherwise the seal is still registered for the caller, with no org attached.

Seal registry — GET /api/org/{slug}/seals

Section titled “Seal registry — GET /api/org/{slug}/seals”

The caller must be an active member of the org (or an Owner/Admin of an ancestor org); anyone else gets 403 with errorCode = "ORG_NOT_MEMBER". The slug is matched case-insensitively. Within the org the route requires the view.seals page scope: the Owner and Admin bypass it, any other member must hold it, and a missing scope returns 403 with errorCode = "PERMISSION_DENIED".

Returns the org’s seals (:90):

  • Master seal — one row if Organization.OrgSealKeyId is set ("{org} Master Seal", scope ALL (ROOT)).
  • Personal seals — each active member’s UserCredential(s) surfaced as personal seals (one row per passkey; the first is the semantic personal seal).

Every time a passkey (a personal seal’s public key) is added to or removed from an account, Axowl records it in three places. The key fingerprint is the first 16 hex characters of the SHA-256 of the stored public key, and it is the same in all three, so a member can compare what they were told with what the records show.

WhereWho sees itWhat it shows
Security email to the account addressThe memberTime, device (on add), key ID, key fingerprint. Sent for a new passkey (api/passkey/add) and for every removal, including a removal caused by deleting a device. Binding a seal key ID (POST /api/v1/seals) does not send one.
GET /api/auth/activity-log (profile Activity Log)The memberRows with eventType seal.lifecycle.registered / seal.lifecycle.revoked, plus keyId and keyFingerprint. Login and logout rows return null for both.
GET /api/v1/admin/audit/query?eventTypes=seal.lifecycle.registered,seal.lifecycle.revoked&scope=all (Seal Registry ▸ Change history)Org Owners and Admins, Pro plan or higherThe org’s members’ passkey changes. Each row’s businessData carries CredentialId and PublicKeyFingerprint. Same plan and role gate as any org-wide audit query.

Adding one passkey writes two seal.lifecycle.registered records: one for the new public key (NewPublicKey: true) and one when the seal key ID is bound to it (NewPublicKey: false). Only the first sends an email or appears in either history. Records written before 2026-10-16 have no NewPublicKey value and are all shown.

The audit record is written inside Axowl; the email lands outside it. If a stored public key were swapped, the member either receives an email for a change they did not make, or the fingerprint in their email no longer matches the record.

Bind personal → master (sole proprietorship)

Section titled “Bind personal → master (sole proprietorship)”

POST /api/org/{slug}/master-seal/bind-personal (:164) — for an independent sole proprietorship whose Owner had no passkey at org-creation time. After the Owner registers a passkey, this binds their first UserCredential as the org master seal. Owner-only; rejected if a master seal is already set (seals are immutable).

RegisterPersonalSealModal drives the member’s cryptographic personal-seal ritual: platform-authenticator check → KYC pre-flight (/api/v1/seals/check, skipped for device-only enrollment) → api/passkey/add/options → navigator.credentials.get → api/passkey/add → POST /api/v1/seals (RegisterSealCommandHandler). The first passkey is the semantic Personal Seal, and registering it also activates a Pending membership → Active (emits MemberActivatedEvent). Hybrid device-trust enrollment tokens ride the same flow. This modal is a required onboarding step and the member entry-gate (see Seals & integrity).

Distinct from the cryptographic seals above — these are the rendered SVG stamps drawn on approval documents. Full model: Seals & integrity → the visual seal designer.

Method · RoutePurposeSource
POST /api/org/{slug}/settings/sealUpload/replace the company seal image; stored on Organization.SealUrl (R2 logos/orgs/{id}/…). Owner/admin. Emits OrganizationSettingsUpdatedEvent (Category Seal).OrgLogoEndpoints.cs
DELETE /api/org/{slug}/settings/sealRemove the company seal.OrgLogoEndpoints.cs
PUT /api/org/{slug}/members/{connectedId}/signatureSet the caller’s own personal signature/seal — {kind, spec} where kind = signature / seal / draw / image / none. Self-only; 8 KB cap; JSON-validated. Stored on User.SignatureSpec. Emits org.member.signature_changed (ORG025).OrgEndpoints.cs
POST /api/org/{slug}/members/{connectedId}/signature/imageUpload a raster/SVG personal seal image (R2 logos/users/{uid}/…). Self-only.OrgEndpoints.cs

The designed spec is exposed on /me (UserInfo.SignatureSpec) and rendered by the shared PersonalSignature component wherever a seal appears.