Signatures
Register a request on your server and send the user back to a confirmed https URL. Your server reads the completion.
The base URL is https://api.clavis.nexus and the path is /app/intent-requests. The steps are in the Quickstart.
Before you start
2. Open the Org and subscribe to Signature (the plan view is
/developers/{orgId}).3. Register an app and confirm its host (see the Quickstart).
4. Put the operate private key on your server. Storing the master public JWK in your environment is covered in Authentication.
SDK
On the server, install npm install @clavis.nexus/app. For the wait in the browser, install npm install @clavis.nexus/app-shell.
Steps
1. Register on the server and return JSON to the browser.
TypeScript (server)import { createAppClient } from "@clavis.nexus/app"; function requiredEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`missing env: ${name}`); return value; } const app = createAppClient({ apiOrigin: "https://api.clavis.nexus", appId: requiredEnv("CLAVIS_APP_ID"), operatePrivateJwk: requiredEnv("OPERATE_PRIVATE_JWK"), masterPublicJwkJson: requiredEnv("MASTER_PUBLIC_JWK_JSON"), }); async function start(messageDigest: string): Promise<Response> { const { requestId, pollSecret, consumeUrl } = await app.registerGenericSign({ messageDigest, purpose: "generic.sign", displayHint: "Change billing account", returnUrl: "https://console.example/done", }); await storePollSecret(requestId, pollSecret, messageDigest); return Response.json({ consume_url: consumeUrl, request_id: requestId }); }CLAVIS_APP_IDThe app's id.OPERATE_PRIVATE_JWKThe Ed25519 private JWK (OKP, crv Ed25519, with d) as JSON. Only your server holds it.MASTER_PUBLIC_JWK_JSONThe master object from the aggregate JWKS, stored as a JSON string.returnUrlAn https URL under your confirmed host.storePollSecretWhere poll_secret is kept. Never put it in a query, a cookie or the JSON sent to the browser.
JSON your server returns to the browser.
{ "consume_url": "https://api.clavis.nexus/app/intent-requests/consume/550e8400-e29b-41d4-a716-446655440000", "request_id": "550e8400-e29b-41d4-a716-446655440000" }2. The browser loads
@clavis.nexus/app-shell, then moves toconsume_url. The CSS isimport "@clavis.nexus/app-shell/styles.css". The steps are in the Quickstart.3. Read the
poll_secretyou stored under therequest_idin the return query, and check thatstatefromwaitForCompletioniscompleted. Keepsub,messageDigest,purposeandsignedAtin your product.TypeScript (completion)const stored = await loadPollSecret(requestId); const status = await app.waitForCompletion(requestId, stored.pollSecret, { expectedMessageDigest: stored.messageDigest, }); // status.state === "completed" // Keep status.sub / status.messageDigest / status.purpose / status.signedAt in your productExample return URL.
https://console.example/done?request_id=550e8400-e29b-41d4-a716-446655440000requestIdThe request_id in the return query; the key for reading the store.pollSecretThe value your server stored at registration.stateThe signature is complete when this is completed.sub / messageDigest / purpose / signedAtThe result of a completed signature, which your product keeps and shows. The SDK flattens sign_proof from the HTTP response.
Example JSON to keep in your product.
{ "request_id": "550e8400-e29b-41d4-a716-446655440000", "intent_id": "<intent_id>", "sub": "<passkey sub>", "message_digest": "<base64url SHA-256>", "purpose": "generic.sign", "signed_at": 1710000000 }
digest
The JSON that message_digest is made from, and where a reference to an identity check goes.
Example JSON that the digest is made from.
{
"v": 1,
"operation": {
"action": "billing_account.change",
"account_id": "acct_8f2c",
"bank_account_last4": "4821"
},
"identity_binding": {
"provider": "jpki",
"ref": "<a reference the checker can look the result up by>",
"assurance": "<a level your product defines>",
"verified_at": "2026-10-09T00:00:00Z"
}
}vnumberThe version of the JSON shape. Raise it when the shape changes.operationobjectThe operation itself. Use the same operation you show withdisplay_hint.identity_bindingobjectoptionalInclude it only when your product has checked the person's identity.identity_binding.providerstringA label for the checking method or provider. Your product chooses the value.identity_binding.refstringA reference the checker can look the result up by. Do not put names, numbers or images in it.identity_binding.assurancestringThe level of the check. Your product chooses the value.identity_binding.verified_atstring (RFC 3339)When the identity check took place.
const preimage = await loadOperation(operationId); // the JSON above
const messageDigest = await sha256Base64Url(canonicalStringify(preimage));
await storePreimage(operationId, preimage);canonicalStringifyandsha256Base64Urlare the samples in the Quickstart.- Only
message_digestreaches Clavis. Your product keeps the original JSON. identity_bindingis part of the digest, so it is fixed together with the signature. Your product and the checker keep what the reference points to.- To show a signature to someone else, recompute the digest from the JSON you kept and compare it with
message_digest.
POST /register
The HTTP the SDK sends. registration is a Compact JWS signed with the operate private key.
JWT claims
{
"iss": "app:<app_id>",
"aud": "app-intent-registration",
"app_id": "<app_id>",
"intent_type": "generic_sign",
"iat": 1710000000,
"exp": 1710000120,
"jti": "<unique-id>",
"return_url": "https://console.example/done",
"ui_locales": "en ja",
"payload": {
"purpose": "generic.sign",
"message_digest": "<base64url SHA-256>",
"display_hint": "Change billing account"
}
}Request
POST https://api.clavis.nexus/app/intent-requests/register
Content-Type: application/json
{
"registration": "<Compact JWT signed with the operate key>"
}Response
{
"payload": {
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"display_code": "AB12CD34EF",
"consume_url": "https://api.clavis.nexus/app/intent-requests/consume/550e8400-e29b-41d4-a716-446655440000",
"link_consume_url": "https://api.clavis.nexus/app/intent-requests/consume-link/550e8400-e29b-41d4-a716-446655440000",
"poll_secret": "<48 hex>"
}
}payload.request_idstring (UUID)requiredThe identifier for reading the completion.payload.display_codestringrequiredA short code for matching screens.payload.consume_urlstring (URL)requiredThe URL where the end user enters Connect.payload.link_consume_urlstring (URL)requiredThe URL for federation.link.payload.poll_secretstring (48 hex)requiredIssued on every registration and held only by your server. Never put it in a query, a cookie or the browser.registration.payload.display_hintstring (up to 500 characters)optionalThe operation name shown to the end user on the Connect confirmation (displayHintin the SDK). The line shown is part of the signature and is copied intoshownin the SignProof. Your product keeps the operation name consistent with what the digest covers.registration.ui_localesstring (BCP 47, space-separated)optionalThe languages of your product's screen, in order of preference (for example"en ja"). Connect shows its confirmation in the first one it offers, so it matches the operation name you wrote. Languages Connect does not offer are skipped rather than rejected; when none match, Connect uses the user's own choice, then the browser's languages. The end user can still switch on Connect. In the SDK this isuiLocales(an array).registration.expUnix secondsrequiredregisterGenericSignin the SDK usesiat + 120seconds. The user must reach the passkey on Connect within this window. Once it lapses, register again with a newjti.
GET /status
The HTTP that getStatus in the SDK reads. The SDK verifies signature.
Request
GET https://api.clavis.nexus/app/intent-requests/status/550e8400-e29b-41d4-a716-446655440000
X-Intent-Poll-Secret: <poll_secret>Response (pending)
{
"payload": {
"state": "pending"
},
"signature": "<response signature>"
}Response (intent_issued)
{
"payload": {
"state": "intent_issued",
"intent_id": "<intent_id>",
"intent_type": "generic_sign"
},
"signature": "<response signature>"
}Response (completed)
{
"payload": {
"state": "completed",
"intent_id": "<intent_id>",
"intent_type": "generic_sign",
"sign_proof": {
"v": 1,
"intent_type": "generic_sign",
"intent_id": "<intent_id>",
"message_digest": "<base64url SHA-256>",
"purpose": "generic.sign",
"sub": "<passkey sub>",
"shown": {
"app": "Example Console",
"host": "console.example",
"action": "Change billing account"
},
"signed_at": 1710000000
},
"sub": "<passkey sub>",
"message_digest": "<base64url SHA-256>",
"signed_at": 1710000000,
"purpose": "generic.sign"
},
"signature": "<response signature>"
}payload.statepending | intent_issued | completedrequiredpending is before Connect, intent_issued is after the passkey and before it settles, and completed means done. Keep making the same GET during the first two.payload.sign_proofobjectoptionalPresent when completed. Its shape is the SignProof in Authentication. It is not a verification key.waitForCompletionin the SDK flattens it intosuband the other fields and does not return the object; the flattened fields are enough for your product to keep.payload.substringoptionalFlattened from sign_proof when completed.payload.message_digeststringoptionalFlattened when completed. Compare it with the digest you registered.payload.signed_atnumber (Unix seconds)optionalFlattened when completed.signaturestringrequiredThe response signature, verified with the master public JWK.
Example JSON to keep in your product.
{
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"intent_id": "<intent_id>",
"sub": "<passkey sub>",
"message_digest": "<base64url SHA-256>",
"purpose": "generic.sign",
"signed_at": 1710000000
}Waiting for completion
The defaults of waitForCompletion, and what to read after a timeout.
timeoutMsnumberoptional120000 (120 seconds) by default, matching the lifetime of a registration.intervalMsnumberoptional1500 by default: the interval between GET /status calls.AppClientError.codestringoptionalWhile waiting, look fortimeoutanddigest_mismatch. A failed registration request isregister_failed; see Errors.
import { AppClientError } from "@clavis.nexus/app";
try {
const status = await app.waitForCompletion(requestId, stored.pollSecret, {
expectedMessageDigest: stored.messageDigest,
});
await storeReceipt({
request_id: requestId,
intent_id: status.intentId,
sub: status.sub,
message_digest: status.messageDigest,
purpose: status.purpose,
signed_at: status.signedAt,
});
} catch (e) {
if (e instanceof AppClientError && e.code === "timeout") {
const again = await app.getStatus(requestId, stored.pollSecret, stored.messageDigest);
if (again.state === "completed") {
await storeReceipt(/* the flattened fields of again */);
return;
}
// When it is pending / intent_issued, move on to a new registration
return;
}
throw e;
}- After a
timeout, readgetStatusonce with the samerequest_id. - If it is
completed, keep it. If it ispendingorintent_issued, move on to a new registration. - Read it the same way when the end user closes Connect.
Counts
What "remaining" and "this month" in the plan view refer to.
- Only successful completions in which an intent's SignProof is formed for the first time are counted.
- Failures, retries and registration alone are not counted.
- OIDC (sign-in) is included in the Signature subscription and is counted neither per person nor against this month.
- This month is the calendar month in UTC.
- Once this month's limit is reached, new intent registrations do not go through.
- Even with a subscription, registrations do not go through without allowance left this month.
- After reaching the limit, raise this month's limit in the plan view of the Org, or wait until the next month (UTC).
Errors
Every error has error, message, requestId and trace_id.
Error
{
"error": "RETURN_URL_NOT_ALLOWED",
"message": "registration rejected",
"requestId": "<id>",
"trace_id": "<id>"
}404 UNKNOWN_APPTheapp_idin registration is unknown, or on the plan route the app cannot open intents. In the developer console, check that the app is active, that its declared use includes signatures, that the plan subscribes to Signature, and that there is allowance left this month.403 RETURN_URL_NOT_ALLOWEDPutreturn_urlon https under a parent whose host is confirmed.400 INVALID_RETURN_URLreturn_url must be https.409 DUPLICATE_JTIThe same jti was registered again. Send a new jti.400 REGISTRATION_EXPIREDThe registration JWT is past its exp. Build a new one.400 REGISTRATION_JWT_INVALIDThe operate private key does not match the public key in the console.401 UNAUTHORIZEDThe status call has no X-Intent-Poll-Secret.403 FORBIDDENpoll_secret does not match.404 NOT_FOUNDThe request_id is unknown, or polling has expired.
Error (when the start conditions are not met)
{
"error": "UNKNOWN_APP",
"message": "registration rejected",
"requestId": "<id>",
"trace_id": "<id>"
}A closed plan often shows up as 404 UNKNOWN_APP on intent registration. Check the plan and the app's declared use in the developer console first.
Troubleshooting
From a symptom to where to look next.
Registration returns 404 UNKNOWN_APPCheck the status on the app page (is it Active?), signatures in its declared use, and the signature blockers (no plan, payment, limit) in the plan view of the Org. The steps are in the Quickstart.Registration stops at the limitLook at "remaining" and "this month's limit" in the plan view of the Org. Raise the limit, or wait until the next month (UTC).403 RETURN_URL_NOT_ALLOWEDMake the host on the app page and the parent of returnUrl the same. If the host is not confirmed yet, add the TXT record to DNS and confirm it.Expired before ConnectMore than 120 seconds have passed since registration. Register again with a newjtiand go to Connect straight away.Back, but not completedKeep callinggetStatuswith the samerequest_id. If it times out while stillpendingorintent_issued, move on to a new registration. The steps are in Waiting for completion.No poll_secret after the returnThe store is keyed byrequest_id. If it cannot be read because of another server or a lost session, move on to a new registration.The plan says payment has not been confirmedCheck the payment in Polar's payment management page (Customer Portal).The Org is stoppedWhen the Org is stopped, buying Signature does not lift the stop. Contact [email protected]."Subscribe is closed"When public checkout is closed, wait until it opens, then subscribe to Signature. Invoicing and onboarding are handled under the Enterprise plan.Not sure where to keep the resultKeepsub,messageDigest,purposeandsignedAtfromcompletedin your product. A sample is in GET /status.
Reference
- Quickstart — Setting up the Org, the app and the host check.
- Authentication — Credentials for registering intents and calling the Org API.