Seals
Concept: Seals & integrity.
Personal seal — /api/v1/seals (authenticated)
Section titled “Personal seal — /api/v1/seals (authenticated)”| Method · Route | Purpose | Source |
|---|---|---|
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 /check | Pre-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.OrgSealKeyIdis set ("{org} Master Seal", scopeALL (ROOT)). - Personal seals — each active member’s
UserCredential(s) surfaced as personal seals (one row per passkey; the first is the semantic personal seal).
Passkey change history and alerts
Section titled “Passkey change history and alerts”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.
| Where | Who sees it | What it shows |
|---|---|---|
| Security email to the account address | The member | Time, 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 member | Rows 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 higher | The 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).
Registering a personal seal (passkey)
Section titled “Registering a personal seal (passkey)”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).
Visual seals (the designed stamp)
Section titled “Visual seals (the designed stamp)”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 · Route | Purpose | Source |
|---|---|---|
POST /api/org/{slug}/settings/seal | Upload/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/seal | Remove the company seal. | OrgLogoEndpoints.cs |
PUT /api/org/{slug}/members/{connectedId}/signature | Set 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/image | Upload 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.