Use callback when your app has a server. Sigil POSTs the result from its Rust layer directly to your endpoint — the result never touches the browser URL bar, and your server can validate, store, and act on it atomically.
callback (this guide) is right for anything involving real money or server-managed sessions. redirect_uri is simpler but puts the result in the browser URL — see the React or Vanilla JS guides for that pattern.
import express from 'express';
import { parseCallbackResponse } from '@sigil-oss/connect';
const app = express();
app.use(express.json({ limit: '16kb' })); // Sigil result bodies are smallSigil POSTs to this URL after the user approves or rejects. Always respond 200 once you've accepted the payload — Sigil will retry on non-2xx responses.
// POST /api/sigil/callback — receives Sigil's HTTP POST after user acts
app.post('/api/sigil/callback', async (req, res) => {
// 1. Parse and validate the shape
let result;
try {
result = parseCallbackResponse(req.body);
} catch {
return res.status(400).json({ error: 'invalid_payload' });
}
// 2. Verify the nonce — match it against what you stored when building the request
const pending = await db.pendingRequest.findUnique({ where: { nonce: result.nonce } });
if (!pending) return res.status(400).json({ error: 'unknown_nonce' });
await db.pendingRequest.delete({ where: { nonce: result.nonce } });
// 3. Handle each outcome
switch (result.status) {
case 'connected':
await db.session.upsert({
where: { identity: result.identity },
create: { identity: result.identity, permissions: result.permissions },
update: { permissions: result.permissions },
});
break;
case 'signed':
if (result.type === 'transfer' || result.type === 'sc_call') {
await db.transaction.create({
data: {
txHash: result.tx_hash,
identity: result.identity,
targetTick: result.target_tick,
},
});
}
break;
case 'rejected':
// result.reason === 'user_rejected'
break;
}
res.sendStatus(200); // Sigil retries on non-2xx
});Build the sigil:// URI server-side and persist the nonce so the callback handler can verify it. Never build the URI directly in the browser when using server callbacks — the client can't be trusted to generate nonces that your server hasn't seen.
// Before building the sigil:// URI, store the nonce server-side
// so the callback handler can verify it belongs to a real request.
import { buildSigilUrl, createEnvelope, createConnectRequest } from '@sigil-oss/connect';
app.get('/api/sigil/connect', async (req, res) => {
const req_ = createConnectRequest({
type: 'connect',
dapp: { name: 'My App', origin: 'https://myapp.example' },
permissions: ['transfer', 'sign_message'],
});
// Store nonce with TTL matching the request expiry
await db.pendingRequest.create({
data: { nonce: req_.nonce, expiresAt: new Date(req_.exp! * 1000) },
});
// Return the URI to the client — client opens it
const url = buildSigilUrl(
createEnvelope(req_, { callback: 'https://myapp.example/api/sigil/callback' })
);
res.json({ url });
});A callback with an unknown nonce is either a replay or a request you didn't originate. Reject it before touching any application state. Delete the nonce from the store on first use so it can't be replayed.
For the sign-in pattern — user signs a message, your server verifies the signature against their Qubic public key. Use the @qubic-lib/crypto package or equivalent to verify the Qubic ECDSA signature.
// routes/auth.ts
import { verifyQubicSignature } from '@qubic-lib/crypto';
import type { Express } from 'express';
export function registerAuthRoutes(app: Express) {
// POST /api/auth/qubic — verify a sign_message result sent from the browser
app.post('/api/auth/qubic', async (req, res) => {
const { identity, signature, public_key, nonce, issuedAt } = req.body;
// 1. Deduplicate nonce (ioredis example — adapt to your Redis client)
const key = `sigil:nonce:${nonce}`;
const used = await redis.get(key);
if (used) return res.status(400).json({ error: 'nonce_reused' });
await redis.set(key, '1', 'EX', 300);
// 2. Reconstruct the exact message that was signed
const message = [
'Sign in to My App',
`nonce: ${nonce}`,
`issuedAt: ${issuedAt}`,
].join('\n');
// 3. Verify the Qubic ECDSA signature
const valid = await verifyQubicSignature({ message, signature, publicKey: public_key });
if (!valid) return res.status(401).json({ error: 'invalid_signature' });
// 4. Identity verified — issue a session token
const token = await createSession(identity);
res.json({ token });
});
}// app/api/sigil/callback/route.ts — Next.js App Router
import { parseCallbackResponse } from '@sigil-oss/connect';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const body = await request.json();
let result;
try {
result = parseCallbackResponse(body);
} catch {
return NextResponse.json({ error: 'invalid_payload' }, { status: 400 });
}
// Verify nonce, handle result…
switch (result.status) {
case 'connected':
// store session
break;
case 'signed':
// record tx
break;
case 'rejected':
break;
}
return new NextResponse(null, { status: 200 });
}// app/api/auth/qubic/route.ts — Next.js App Router
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { identity, signature, public_key, nonce, issuedAt } = await request.json();
// Deduplicate nonce
const key = `sigil:nonce:${nonce}`;
if (await redis.get(key)) {
return NextResponse.json({ error: 'nonce_reused' }, { status: 400 });
}
await redis.set(key, '1', { ex: 300 });
// Reconstruct and verify
const message = ['Sign in to My App', `nonce: ${nonce}`, `issuedAt: ${issuedAt}`].join('\n');
const valid = await verifyQubicSignature({ message, signature, publicKey: public_key });
if (!valid) return NextResponse.json({ error: 'invalid_signature' }, { status: 401 });
const token = await createSession(identity);
return NextResponse.json({ token });
}