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.
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
callbackfield or via the&cb=query param — if both are present they must match - Envelope max size: 8 192 bytes (base64)
End-to-end flow
- Build your JSON envelope:
{ request: { type, nonce, dapp, ...fields }, callback? } - Base64url-encode the envelope (no padding) and put it in
?d=. - Spin up a callback endpoint if you want a structured response — put its HTTPS URL in the envelope's
callbackfield. - Open the URI —
window.location.href = uriin the browser,open/xdg-open/startfrom a native app. - Sigil validates the URI in Rust, focuses its window, queues the request.
- 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:
| Field | Type | Notes |
|---|---|---|
| type * | string | One of connect, transfer, sc_call, sign_message, verify_message |
| nonce * | string | 16–128 chars, alphanumeric or -_=+. Must be unique — Sigil tracks seen nonces for 1 hour and rejects replays |
| dapp.origin * | string | Must be a valid HTTPS URL, e.g. https://yourapp.example |
| dapp.name | string | Display name shown to the user. Strongly recommended |
| dapp.icon | string | URL to the dApp's icon. Optional |
| exp | integer | Unix 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}`;}
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://localhostandhttp://127.0.0.1are 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" }
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.
| Field | Type | Notes |
|---|---|---|
| type * | string | "connect" |
| permissions | string[] | 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.
| Field | Type | Notes |
|---|---|---|
| type * | string | "transfer" |
| to * | string | Exactly 60 uppercase A–Z letters (Qubic identity format) |
| amount * | integer | Positive. 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.
| Field | Type | Notes |
|---|---|---|
| type * | string | "sc_call" |
| contract_index * | integer | 0–63 |
| input_type * | integer | Non-negative. The procedure number on the contract |
| payload | string | Base64-encoded input bytes for the call (if the procedure takes input) |
| amount | integer | QU attached to the call, if any |
| from | string | Prefer 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.
| Field | Type | Notes |
|---|---|---|
| type * | string | "sign_message" |
| message * | string | Non-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.
| Field | Type | Notes |
|---|---|---|
| type * | string | "verify_message" |
| message * | string | The original message that was signed |
| signature * | string | The signature to verify |
| public_key * | string | The 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/connectSource 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 = uriSIGN 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:
| Level | Meaning | Blocks approval? |
|---|---|---|
legacy_unverified | No proof present — dApp name/origin are self-reported | No |
signed_untrusted | Valid ES256 signature, but issuer not in the user's local registry | No |
verified_registry | Valid signature, issuer in registry, origin matches | No |
signature_invalid | Proof present but signature verification failed, or payload hash mismatch | Yes |
registry_revoked | Issuer is in the registry but marked revoked | Yes |
registry_origin_mismatch | Issuer is registered but dapp.origin doesn't match its trusted origins | Yes |
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 (
SubtleCryptoES256) over the canonical payload, base64url-encoded - Include
public_jwkfor 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)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"
}@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
sigilor host isn'tv1 - Missing
dparameter, payload over 8 192 bytes, or invalid base64url - Missing
type,nonce, ordapp.origin - Unknown
type, or nonce outside 16–128 chars or invalid charset dapp.originscheme isn'thttpsexpis in the past, or more than 1 hour from now- Callback isn't HTTPS (except
http://localhost/http://127.0.0.1for 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 SigilSERVER — 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)
})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.