SIGIL · INTEGRATION DOCS← Back to site
[ DOCS · v1 · 2026 ]

Integrate SIGIL
in your app.

Sigil is a desktop wallet. Your frontend asks it to sign things over a sigil:// URI. Sigil pops up, the user reviews and approves, then Sigil POSTs the signed result to a callback URL you control. That's the whole interface.

The quickest path is the official @sigil-oss/connect SDK — it builds envelopes, signs them, and parses callback responses. Or build the URI yourself: the protocol is small enough to drop into any JS, Python, or shell context.

[ SOURCE OF TRUTH ]

Everything below is taken from the Rust validator at src-tauri/src/deep_link.rs. If the code and these docs disagree, the code wins.

The protocol

One URI, one callback. Sigil registers the sigil:// scheme with the OS; opening such a URI brings Sigil to the foreground with your request.

URI SHAPEsigil://v1/request?d=<base64url-encoded JSON envelope>
 [&cb=<optional HTTPS callback URL>]
  • Scheme must be sigil
  • Host must be v1, path must be /request
  • d = base64url (no padding) of a JSON envelope: { request, callback?, proof? }
  • Callback can be set in the envelope's callback field or via the &cb= query param — if both are present they must match
  • Envelope max size: 8 192 bytes (base64)

End-to-end flow

  1. Build your JSON envelope: { request: { type, nonce, dapp, ...fields }, callback? }
  2. Base64url-encode the envelope (no padding) and put it in ?d=.
  3. Spin up a callback endpoint if you want a structured response — put its HTTPS URL in the envelope's callback field.
  4. Open the URI window.location.href = uri in the browser, open/xdg-open/start from a native app.
  5. Sigil validates the URI in Rust, focuses its window, queues the request.
  6. User approves or rejects. Sigil POSTs the result to your callback from the Rust layer.

Payload format

The d parameter encodes a JSON envelope. The request object inside it contains these required fields on every request type:

FieldTypeNotes
type *stringOne of connect, transfer, sc_call, sign_message, verify_message
nonce *string16–128 chars, alphanumeric or -_=+. Must be unique — Sigil tracks seen nonces for 1 hour and rejects replays
dapp.origin *stringMust be a valid HTTPS URL, e.g. https://yourapp.example
dapp.namestringDisplay name shown to the user. Strongly recommended
dapp.iconstringURL to the dApp's icon. Optional
expintegerUnix seconds. Defaults to 5 minutes from receipt if omitted. Max 1 hour from now — requests further out are rejected

Encoding the URI

Encode the envelope (not just the request) with URL-safe base64 without padding.

JAVASCRIPTfunction b64url(str) {return btoa(str) .replaceAll('+', '-') .replaceAll('/', '_') .replaceAll('=', '');}function buildSigilUri(request, callback) {// Wrap request in the envelope — callback lives here, not as a query paramconst envelope = { request, callback: callback ?? null };const d = b64url(JSON.stringify(envelope));return `sigil://v1/request?d=${d}`;}
[ TRUST LEVELS ]

Requests can be unsigned (legacy_unverified — metadata is self-reported) or carry an ES256 proof signed by a registered dApp issuer. If a proof is present but the signature is invalid, or the issuer is in the registry but the origin doesn't match, Sigil blocks approval. Unsigned requests can still be reviewed and approved — the UI shows the trust level clearly. Use @sigil-oss/connect to sign envelopes.

Receiving the result

Sigil makes an HTTP POST with a JSON body to your callback URL from the Rust layer. If delivery fails, the result stays recoverable in request history for retry.

Constraints on cb

  • Must be https:// in production
  • http://localhost and http://127.0.0.1 are allowed for local dev

If the user rejects

POST <your cb URL>{
 "status": "rejected",
 "nonce": "<the nonce you sent>",
 "type": "<original request type>",
 "reason": "user_rejected"
}
[ ALWAYS VERIFY THE NONCE ]

Match the nonce on the callback against the one you sent. Don't trust the body until that matches.

connect

Ask the user to pair their wallet with your app and optionally pre-grant permissions.

FieldTypeNotes
type *string"connect"
permissionsstring[]Any subset of "transfer", "sc_call", "sign_message"
REQUEST PAYLOAD{
 "type": "connect",
 "nonce": "a1b2c3d4e5f6g7h8",
 "dapp": { "name": "Acme", "origin": "https://acme.example" },
 "permissions": ["transfer", "sign_message"]
}

transfer

Send an amount of QU from the user's selected account to a recipient.

FieldTypeNotes
type *string"transfer"
to *stringExactly 60 uppercase A–Z letters (Qubic identity format)
amount *integerPositive. Whole QU units
REQUEST PAYLOAD{
 "type": "transfer",
 "nonce": "f3a8b2c1...",
 "dapp": { "name": "Acme", "origin": "https://acme.example" },
 "to": "NQZBXKZP4MTLD...UVWXYZK8MF",
 "amount": 1500000
}

sc_call

Call a Qubic smart contract function. Sigil constructs the SC invocation transaction and signs it.

FieldTypeNotes
type *string"sc_call"
contract_index *integer0–63
input_type *integerNon-negative. The procedure number on the contract
payloadstringBase64-encoded input bytes for the call (if the procedure takes input)
amountintegerQU attached to the call, if any
fromstringPrefer a specific account identity. Optional — user can override

sign_message

Ask the user to sign a message without sending a transaction. Useful for proving address ownership.

FieldTypeNotes
type *string"sign_message"
message *stringNon-empty, max 2 048 characters. Shown verbatim to the user
APPROVE CALLBACK{
 "status": "signed",
 "identity": "<signing identity>",
 "signature": "<signature bytes, base64>",
 "public_key": "<public key, base64>"
}

verify_message

Hand Sigil a message + signature + public key, get back whether the signature is valid.

FieldTypeNotes
type *string"verify_message"
message *stringThe original message that was signed
signature *stringThe signature to verify
public_key *stringThe public key to verify against

@sigil-oss/connect

The official SDK handles envelope construction, ES256 signing, and callback parsing so you don't wire up base64url encoding and canonicalization yourself.

INSTALLnpm install @sigil-oss/connect
# or
bun add @sigil-oss/connect

Source and changelog: github.com/sigil-oss/sigil.connect. The package re-exports the same Zod schema that Sigil's renderer uses, so a request that parses in the SDK is guaranteed to parse inside the wallet.

Unsigned requests

Unsigned requests show a trust level of "unverified" in the wallet UI — the dApp name and origin are self-reported and can't be verified. Use them for prototyping or internal tooling where trust is established by other means.

TRANSFERimport { buildSigilUri } from '@sigil-oss/connect'

const uri = buildSigilUri({
  request: {
    type: 'transfer',
    nonce: crypto.randomUUID(),
    dapp: { name: 'Acme', origin: 'https://acme.example' },
    to: 'RECIPIENT60CHARIDENTITYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
    amount: 1_500_000,
    // optional: from, tick_offset
  },
  callback: 'https://acme.example/sigil/callback',
})
window.location.href = uri
SIGN MESSAGEconst uri = buildSigilUri({
  request: {
    type: 'sign_message',
    nonce: crypto.randomUUID(),
    dapp: { name: 'Acme', origin: 'https://acme.example', icon: 'https://acme.example/logo.png' },
    message: `Sign in to Acme · ${new Date().toISOString()}`,
    exp: Math.floor(Date.now() / 1000) + 300,  // 5 min, max 1 hour
    // optional: from, data
  },
  callback: 'https://acme.example/sigil/callback',
})
CONNECT (REQUEST PERMISSIONS)const uri = buildSigilUri({
  request: {
    type: 'connect',
    nonce: crypto.randomUUID(),
    dapp: { name: 'Acme', origin: 'https://acme.example' },
    permissions: ['transfer', 'sign_message'],  // or omit to ask for nothing pre-approved
  },
  callback: 'https://acme.example/sigil/callback',
})
SC CALL (QEARN LOCK EXAMPLE)const uri = buildSigilUri({
  request: {
    type: 'sc_call',
    nonce: crypto.randomUUID(),
    dapp: { name: 'Acme', origin: 'https://acme.example' },
    contract_index: 6,     // Qearn
    input_type: 1,         // lock procedure
    amount: 10_000_000,
    // optional: payload (base64 encoded input bytes), from, tick_offset
  },
  callback: 'https://acme.example/sigil/callback',
})

Signed requests

Signed requests include an ES256 proof that lets Sigil verify the request came from your registered dApp identity. The wallet evaluates each request against a local trusted issuer registry and surfaces one of these trust levels:

LevelMeaningBlocks approval?
legacy_unverifiedNo proof present — dApp name/origin are self-reportedNo
signed_untrustedValid ES256 signature, but issuer not in the user's local registryNo
verified_registryValid signature, issuer in registry, origin matchesNo
signature_invalidProof present but signature verification failed, or payload hash mismatchYes
registry_revokedIssuer is in the registry but marked revokedYes
registry_origin_mismatchIssuer is registered but dapp.origin doesn't match its trusted originsYes

How the proof is computed

The SDK handles this, but here's what happens under the hood so you can audit it or implement it without the SDK:

  • Canonical payload = deterministic JSON of { request, callback } with keys sorted recursively
  • payload_hash = SHA-256 of the canonical payload, base64url-encoded (no padding)
  • signature = ECDSA P-256 + SHA-256 (SubtleCrypto ES256) over the canonical payload, base64url-encoded
  • Include public_jwk for self-verifying proofs; omit it to rely on registry lookup only
SIGNED REQUEST (SDK)import { buildSigilUri, signEnvelope } from '@sigil-oss/connect'

// Generate once and register the public key with your issuer entry.
// Keep privateKey secure — never expose it to the browser.
const { privateKey, publicKey } = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  true,   // extractable — needed to export the JWK for registration
  ['sign', 'verify'],
)

const envelope = {
  request: {
    type: 'sign_message',
    nonce: crypto.randomUUID(),
    dapp: { name: 'Acme', origin: 'https://acme.example' },
    message: `Sign in · ${new Date().toISOString()}`,
    exp: Math.floor(Date.now() / 1000) + 300,
  },
  callback: 'https://acme.example/sigil/callback',
}

// signEnvelope adds the proof object and returns a new envelope
const signed = await signEnvelope(envelope, {
  issuer: 'https://acme.example',     // stable identifier for your dApp
  privateKey,                          // CryptoKey — never leaves server
  keyId: 'key-2026-01',               // optional — useful for key rotation
  includePublicJwk: true,             // embed public key for self-verifying proofs
})

window.location.href = buildSigilUri(signed)
[ SIGN ON THE SERVER, NOT IN THE BROWSER ]

The ES256 private key must never be exposed to the browser. Build the signed envelope on your server and return the final sigil:// URI (or just the d= parameter) to the client. The client only opens it.

SERVER-SIDE SIGNING (NODE / EXPRESS)import { buildSigilUri, signEnvelope, loadPrivateKey } from '@sigil-oss/connect/node'
import express from 'express'

const app = express()
// Load your ECDSA P-256 private key from a secret store (env var, Vault, KMS, etc.)
const privateKey = await loadPrivateKey(process.env.SIGIL_SIGNING_KEY)

app.post('/api/sigil/prepare', async (req, res) => {
  const { action, payload } = req.body

  const envelope = {
    request: {
      type: action,
      nonce: crypto.randomUUID(),
      dapp: { name: 'Acme', origin: 'https://acme.example' },
      ...payload,
    },
    callback: 'https://acme.example/sigil/callback',
  }

  const signed = await signEnvelope(envelope, {
    issuer: 'https://acme.example',
    privateKey,
    keyId: 'key-2026-01',
  })

  res.json({ uri: buildSigilUri(signed) })
})

Parsing callbacks

Sigil POSTs a JSON body to your callback URL from its Rust layer. The SDK's parseSigilCallback validates the shape and returns a discriminated union you can switch on.

NODE / EXPRESS CALLBACK HANDLERimport { parseSigilCallback } from '@sigil-oss/connect'
import express from 'express'

const app = express()
app.use(express.json())

const pending = new Map()  // nonce → { userId, expectedAction }

app.post('/sigil/callback', (req, res) => {
  const result = parseSigilCallback(req.body)
  if (!result.ok) return res.status(400).json({ error: result.error })

  const { status, nonce, type } = result.data

  // Always verify the nonce matches what you sent
  const session = pending.get(nonce)
  if (!session) return res.status(404).send('unknown nonce')
  pending.delete(nonce)  // single-use

  if (status === 'rejected') {
    console.log('user rejected:', result.data.reason)
    return res.sendStatus(200)
  }

  switch (type) {
    case 'transfer':
    case 'sc_call': {
      // result.data: { status: 'signed', identity, tx_hash, target_tick }
      console.log('tx broadcast:', result.data.tx_hash, 'tick:', result.data.target_tick)
      break
    }
    case 'sign_message': {
      // result.data: { status: 'signed', identity, signature, public_key }
      // Verify the signature yourself if needed — or send a verify_message request back
      markLoggedIn(session.userId, result.data.identity)
      break
    }
    case 'verify_message': {
      // result.data: { status: 'verified', valid: boolean, identity }
      console.log('signature valid:', result.data.valid)
      break
    }
    case 'connect': {
      // result.data: { status: 'connected', identity, permissions: string[] }
      saveWalletSession(session.userId, result.data.identity, result.data.permissions)
      break
    }
  }

  res.sendStatus(200)
})

Callback shapes

All callbacks include status, nonce, and type. The remaining fields depend on the outcome.

transfer / sc_call — approved

{
  "status":      "signed",
  "type":        "transfer" | "sc_call",
  "nonce":       "<nonce you sent>",
  "identity":    "<60-char Qubic identity that signed>",
  "tx_hash":     "<transaction hash>",
  "target_tick": 14872123
}

sign_message — approved

{
  "status":     "signed",
  "type":       "sign_message",
  "nonce":      "<nonce you sent>",
  "identity":   "<60-char Qubic identity>",
  "signature":  "<ECDSA signature, base64>",
  "public_key": "<public key, base64>"
}

verify_message — complete

{
  "status":   "verified",
  "type":     "verify_message",
  "nonce":    "<nonce you sent>",
  "valid":    true | false,
  "identity": "<derived identity, or empty string>"
}

connect — approved

{
  "status":      "connected",
  "type":        "connect",
  "nonce":       "<nonce you sent>",
  "identity":    "<60-char Qubic identity>",
  "permissions": ["transfer", "sign_message"]  // whatever the user granted
}

Any type — rejected

{
  "status": "rejected",
  "type":   "<original request type>",
  "nonce":  "<nonce you sent>",
  "reason": "user_rejected"
}
[ TYPESCRIPT TYPES ]

@sigil-oss/connect re-exports SigilCallbackResponse, SigilSignedTransferCallback, SigilSignedMessageCallback, SigilConnectedCallback, SigilVerifiedCallback, and SigilRejectedCallback — all derived from the same Zod schema Sigil uses internally.

Validation & errors

Sigil validates every URI in Rust before the renderer sees it. Failures are logged to stderr and the request is silently dropped — no popup will appear. Check stderr if debugging.

What Sigil rejects up front

  • Scheme isn't sigil or host isn't v1
  • Missing d parameter, payload over 8 192 bytes, or invalid base64url
  • Missing type, nonce, or dapp.origin
  • Unknown type, or nonce outside 16–128 chars or invalid charset
  • dapp.origin scheme isn't https
  • exp is in the past, or more than 1 hour from now
  • Callback isn't HTTPS (except http://localhost / http://127.0.0.1 for local dev)
  • Callback targets a private or loopback address (other than localhost)
  • Nonce was used before (replay protection — 1-hour window, persisted to disk)
  • Type-specific validation fails (invalid identity format, non-positive amount, etc.)

Full working example

Minimal login flow: client builds a signed envelope on the server and opens it; server verifies the nonce on callback.

SERVER — prepare endpoint// POST /api/sigil/prepare → { uri }
app.post('/api/sigil/prepare', async (req, res) => {
  const nonce = crypto.randomUUID()
  pending.set(nonce, { userId: req.session.userId })

  const signed = await signEnvelope({
    request: {
      type: 'sign_message',
      nonce,
      dapp: { name: 'Acme', origin: 'https://acme.example' },
      message: `Sign in to Acme · ${new Date().toISOString()}`,
      exp: Math.floor(Date.now() / 1000) + 300,
    },
    callback: 'https://acme.example/sigil/callback',
  }, { issuer: 'https://acme.example', privateKey, keyId: 'key-2026-01' })

  res.json({ uri: buildSigilUri(signed) })
})
CLIENT — open Sigilconst { uri } = await fetch('/api/sigil/prepare', { method: 'POST' }).then(r => r.json())
window.location.href = uri   // OS hands it to Sigil
SERVER — callback handlerapp.post('/sigil/callback', (req, res) => {
  const result = parseSigilCallback(req.body)
  if (!result.ok) return res.sendStatus(400)

  const { status, nonce, type } = result.data
  const session = pending.get(nonce)
  if (!session) return res.sendStatus(404)
  pending.delete(nonce)

  if (status === 'rejected') return res.sendStatus(200)

  if (status === 'signed' && type === 'sign_message') {
    markLoggedIn(session.userId, result.data.identity)
    // optionally: issue a signed verify_message request to double-check the signature
  }
  res.sendStatus(200)
})
[ SOURCE ]

Rust validator: src-tauri/src/deep_link.rs · Zod schema + callback types: src/lib/request-schema.ts · Trust evaluation: src/lib/request-trust.ts — all browseable at github.com/sigil-oss/sigil.app.