Developer console

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

  1. 1. Create an organisation (Org).

  2. 2. Open the Org and subscribe to Signature (the plan view is /developers/{orgId}).

  3. 3. Register an app and confirm its host (see the Quickstart).

  4. 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. 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_ID
      The app's id.
    • OPERATE_PRIVATE_JWK
      The Ed25519 private JWK (OKP, crv Ed25519, with d) as JSON. Only your server holds it.
    • MASTER_PUBLIC_JWK_JSON
      The master object from the aggregate JWKS, stored as a JSON string.
    • returnUrl
      An https URL under your confirmed host.
    • storePollSecret
      Where 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. 2. The browser loads @clavis.nexus/app-shell, then moves to consume_url. The CSS is import "@clavis.nexus/app-shell/styles.css". The steps are in the Quickstart.

  3. 3. Read the poll_secret you stored under the request_id in the return query, and check that state from waitForCompletion is completed. Keep sub, messageDigest, purpose and signedAt in 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 product

    Example return URL.

    https://console.example/done?request_id=550e8400-e29b-41d4-a716-446655440000
    • requestId
      The request_id in the return query; the key for reading the store.
    • pollSecret
      The value your server stored at registration.
    • state
      The signature is complete when this is completed.
    • sub / messageDigest / purpose / signedAt
      The 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"
  }
}
  • vnumber
    The version of the JSON shape. Raise it when the shape changes.
  • operationobject
    The operation itself. Use the same operation you show with display_hint.
  • identity_bindingobjectoptional
    Include it only when your product has checked the person's identity.
  • identity_binding.providerstring
    A label for the checking method or provider. Your product chooses the value.
  • identity_binding.refstring
    A reference the checker can look the result up by. Do not put names, numbers or images in it.
  • identity_binding.assurancestring
    The level of the check. Your product chooses the value.
  • identity_binding.verified_atstring (RFC 3339)
    When the identity check took place.
TypeScript (digest)
const preimage = await loadOperation(operationId); // the JSON above
const messageDigest = await sha256Base64Url(canonicalStringify(preimage));
await storePreimage(operationId, preimage);
  • canonicalStringify and sha256Base64Url are the samples in the Quickstart.
  • Only message_digest reaches Clavis. Your product keeps the original JSON.
  • identity_binding is 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)required
    The identifier for reading the completion.
  • payload.display_codestringrequired
    A short code for matching screens.
  • payload.consume_urlstring (URL)required
    The URL where the end user enters Connect.
  • payload.link_consume_urlstring (URL)required
    The URL for federation.link.
  • payload.poll_secretstring (48 hex)required
    Issued 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)optional
    The operation name shown to the end user on the Connect confirmation (displayHint in the SDK). The line shown is part of the signature and is copied into shown in the SignProof. Your product keeps the operation name consistent with what the digest covers.
  • registration.ui_localesstring (BCP 47, space-separated)optional
    The 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 is uiLocales (an array).
  • registration.expUnix secondsrequired
    registerGenericSign in the SDK uses iat + 120 seconds. The user must reach the passkey on Connect within this window. Once it lapses, register again with a new jti.

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 | completedrequired
    pending 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_proofobjectoptional
    Present when completed. Its shape is the SignProof in Authentication. It is not a verification key. waitForCompletion in the SDK flattens it into sub and the other fields and does not return the object; the flattened fields are enough for your product to keep.
  • payload.substringoptional
    Flattened from sign_proof when completed.
  • payload.message_digeststringoptional
    Flattened when completed. Compare it with the digest you registered.
  • payload.signed_atnumber (Unix seconds)optional
    Flattened when completed.
  • signaturestringrequired
    The 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.

  • timeoutMsnumberoptional
    120000 (120 seconds) by default, matching the lifetime of a registration.
  • intervalMsnumberoptional
    1500 by default: the interval between GET /status calls.
  • AppClientError.codestringoptional
    While waiting, look for timeout and digest_mismatch. A failed registration request is register_failed; see Errors.
TypeScript (after a timeout)
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, read getStatus once with the same request_id.
  • If it is completed, keep it. If it is pending or intent_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_APP
    The app_id in 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_ALLOWED
    Put return_url on https under a parent whose host is confirmed.
  • 400 INVALID_RETURN_URL
    return_url must be https.
  • 409 DUPLICATE_JTI
    The same jti was registered again. Send a new jti.
  • 400 REGISTRATION_EXPIRED
    The registration JWT is past its exp. Build a new one.
  • 400 REGISTRATION_JWT_INVALID
    The operate private key does not match the public key in the console.
  • 401 UNAUTHORIZED
    The status call has no X-Intent-Poll-Secret.
  • 403 FORBIDDEN
    poll_secret does not match.
  • 404 NOT_FOUND
    The 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_APP
    Check 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 limit
    Look 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_ALLOWED
    Make 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 Connect
    More than 120 seconds have passed since registration. Register again with a new jti and go to Connect straight away.
  • Back, but not completed
    Keep calling getStatus with the same request_id. If it times out while still pending or intent_issued, move on to a new registration. The steps are in Waiting for completion.
  • No poll_secret after the return
    The store is keyed by request_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 confirmed
    Check the payment in Polar's payment management page (Customer Portal).
  • The Org is stopped
    When 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 result
    Keep sub, messageDigest, purpose and signedAt from completed in 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.