Fullstack vouch Github Prover Tutorial
This guide demonstrates how to use vlayer's vouch service in a fullstack Next.js application to verify GitHub contributions.
Prerequisites
- Node.js 20+
- PostgreSQL database (we recommend Neon for serverless Postgres)
- vouch datasource ID and customer ID
- vouch API key (
VOUCH_API_KEY)
Installation
Clone the repository and install dependencies:
git clone https://github.com/vlayer-xyz/vouch-github-verifier.git
cd vouch-github-verifier
pnpm installEnvironment Variables
Create a .env.local file for Next.js environment variables:
VOUCH_API_KEY=your-vouch-api-key
VOUCH_WEBHOOK_SECRET=your-webhook-secret # from Vouch dashboard → Organization → Webhook Secret
NEXT_PUBLIC_WEBHOOK_URL=https://your-public-url.example.com # optional, defaults to window.location.originPrisma does not read .env.local — create a separate .env file for the database URL:
POSTGRES_URL=postgresql://user:password@host/dbname?sslmode=requireDatabase Setup
Generate the Prisma client and run migrations:
npx prisma generate
npx prisma migrate dev --name initThis creates the web_proofs table in your database. You can inspect it with:
npx prisma studioStart Development Server
pnpm devVisit http://localhost:3000 to see the application.
Implementation
1. Starting verification
The client generates a requestId, then POST to /api/start-verification to get the vouch redirect URL:
const startVerification = async () => {
const requestId = window.crypto.randomUUID();
localStorage.setItem('lastRequestId', requestId);
const webhookBaseUrl = process.env.NEXT_PUBLIC_WEBHOOK_URL || window.location.origin;
const res = await fetch('/api/start-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId,
redirectBackUrl: `${window.location.origin}?requestId=${requestId}`,
webhookUrl: `${webhookBaseUrl}/api/web-proof`,
inputs: {
github_owner: githubOwner,
github_repo: githubRepo,
github_username: githubUsername,
},
}),
});
const { verificationUrl } = await res.json();
window.location.href = verificationUrl;
};Parameter breakdown:
requestId— UUID linking this browser session to the webhook callbackredirectBackUrl— where vouch redirects after verification (includesrequestIdas query param)webhookUrl— your HTTPS endpoint to receive the proofinputs— custom data passed to the datasource (GitHub details in this case)
The webhookUrl must be publicly accessible via HTTPS. For local development, use a service like ngrok or deploy to Vercel.
2. Fetching the proof after redirect
When vouch redirects back, requestId is in the URL. The frontend uses it to retrieve the stored proof:
const searchParams = useSearchParams();
const requestId = searchParams?.get('requestId');
useEffect(() => {
const fetchProof = async () => {
if (!requestId) return;
setLoading(true);
try {
const response = await fetch(`/api/web-proof/${requestId}`);
if (response.ok) {
const data = await response.json();
setProof(data);
} else {
setError('Proof not found yet. It may still be processing.');
}
} catch (err) {
setError('Failed to fetch proof');
} finally {
setLoading(false);
}
};
fetchProof();
}, [requestId]);Server: Start Verification Route
The /api/start-verification route initializes the Vouch SDK with server-side credentials and returns the redirect URL. This keeps VOUCH_API_KEY out of the browser. View the implementation at app/api/start-verification/route.ts.
import { Vouch } from '@getvouch/sdk';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { requestId, redirectBackUrl, webhookUrl, inputs } = await request.json();
const vouch = new Vouch({
customerId: '1be03be8-5014-413c-835a-feddf4020da2',
apiKey: process.env.VOUCH_API_KEY!,
});
const { verificationUrl } = await vouch.getDataSourceUrl({
datasourceId: 'ee72bdf7-cf47-424a-9705-75a96e39153e',
requestId,
redirectBackUrl,
webhookUrl,
inputs,
});
return NextResponse.json({ verificationUrl });
}Backend: Webhook Endpoint
The webhook endpoint receives proofs from vouch and stores them in the database. View the complete implementation at app/api/web-proof/route.ts.
import { timingSafeEqual, createHash } from 'crypto';
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
function verifyPsk(authHeader: string | null): boolean {
const secret = process.env.VOUCH_WEBHOOK_SECRET;
if (!secret || !authHeader) return false;
const expected = `PSK ${secret}`;
// Hash both sides so timingSafeEqual always operates on equal-length buffers
const expectedHash = createHash('sha256').update(expected).digest();
const actualHash = createHash('sha256').update(authHeader).digest();
return timingSafeEqual(expectedHash, actualHash);
}
export async function POST(request: NextRequest) {
// Authenticate before touching the body
if (!verifyPsk(request.headers.get('authorization'))) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = await request.json();
const proofId = payload.proofId?.trim() || null;
const requestId = payload.requestId?.trim() || null;
// Use proofId if present, fall back to requestId (v2.0 payloads omit proofId)
const uniqueKey = proofId ?? requestId;
if (!uniqueKey) {
console.error('Webhook rejected: payload contains neither proofId nor requestId');
return NextResponse.json({ error: 'Missing proof identifier' }, { status: 400 });
}
await prisma.webProof.upsert({
where: { proofId: uniqueKey },
update: { requestId, payload },
create: { proofId: uniqueKey, requestId, payload },
});
return NextResponse.json({ success: true }, { status: 200 });
}Backend: Proof Retrieval Route
The GET /api/web-proof/[requestId] route looks up the stored proof by requestId so the frontend can display it after redirect. View the implementation at app/api/web-proof/[requestId]/route.ts.
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ requestId: string }> }
) {
const { requestId } = await params;
const proof = await prisma.webProof.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
if (!proof) {
return NextResponse.json({ error: 'Proof not found' }, { status: 404 });
}
return NextResponse.json(proof);
}Database Schema
The web_proofs table stores the full webhook payload alongside the requestId used to look it up:
model WebProof {
id String @id @default(uuid())
proofId String @unique @map("proof_id")
requestId String? @map("request_id")
payload Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
@@map("web_proofs")
@@index([proofId])
@@index([requestId])
@@index([createdAt(sort: Desc)])
}Next Steps
Now that you understand the vouch integration, you can:
- Customize the UI - Build your own proof display components
- Add more datasources - vouch supports multiple data sources beyond GitHub
- Verify proofs server-side - Send
presentationJsonto thePOST /api/v2.0/verifyendpoint to cryptographically verify each proof - Build custom workflows - Use proofs for access control, airdrops, or reputation systems
- Analytics and monitoring - Track verification patterns and user behavior