Webhooks
Receive real-time notifications when events happen in your Solidus integration. Webhooks deliver HTTP POST requests to your endpoint whenever a verification completes, a credential is issued, or other events occur.
Base URL
https://verify.solidus.network/v1Authenticate requests with your API key:
Authorization: Bearer YOUR_API_KEY1. Create a Webhook Endpoint
Register a URL to receive webhook events.
const response = await fetch('https://verify.solidus.network/v1/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({
url: 'https://myapp.com/webhooks/solidus',
events: [
'verification.completed',
'verification.failed',
'verification.expired',
'credential.issued',
'credential.revoked',
],
description: 'Production webhook for KYC events',
}),
})
const webhook = await response.json()Response:
{
"id": "wh_3nK8mR2pQ5",
"url": "https://myapp.com/webhooks/solidus",
"events": [
"verification.completed",
"verification.failed",
"verification.expired",
"credential.issued",
"credential.revoked"
],
"description": "Production webhook for KYC events",
"secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"status": "active",
"createdAt": "2026-05-07T12:00:00Z"
}Save the secret value. You will use it to verify webhook signatures. The secret is only returned once at creation time.
2. Supported Events
| Event | Trigger |
|---|---|
verification.completed | KYC verification passed, credential issued |
verification.failed | KYC verification failed (document issues, face mismatch) |
verification.expired | Verification session expired before completion |
credential.issued | A new Verifiable Credential was issued |
credential.revoked | An existing credential was revoked |
3. Webhook Payload Format
Every webhook delivery sends a JSON payload with the following structure:
{
"id": "evt_8nR3kL5mQ2xY",
"type": "verification.completed",
"createdAt": "2026-05-07T12:05:30Z",
"data": {
"verificationId": "ver_2xK9mP4qR7nL",
"status": "completed",
"outcome": "pass",
"subjectDid": "did:solidus:testnet:7Hk3mRtQZv...",
"level": 1,
"credentialId": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Payload by Event Type
verification.completed
{
"id": "evt_8nR3kL5mQ2xY",
"type": "verification.completed",
"createdAt": "2026-05-07T12:05:30Z",
"data": {
"verificationId": "ver_2xK9mP4qR7nL",
"status": "completed",
"outcome": "pass",
"subjectDid": "did:solidus:testnet:7Hk3mRtQZv...",
"level": 1,
"credentialId": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}verification.failed
{
"id": "evt_9pQ4rM6sT3wZ",
"type": "verification.failed",
"createdAt": "2026-05-07T12:05:30Z",
"data": {
"verificationId": "ver_2xK9mP4qR7nL",
"status": "failed",
"outcome": "fail",
"subjectDid": "did:solidus:testnet:7Hk3mRtQZv...",
"reason": "face_mismatch",
"message": "The selfie does not match the photo on the identity document."
}
}credential.issued
{
"id": "evt_5kN2jH8bF7mR",
"type": "credential.issued",
"createdAt": "2026-05-07T12:05:30Z",
"data": {
"credentialId": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": ["VerifiableCredential", "KYCCredential"],
"issuer": "did:solidus:testnet:<verify-service>",
"subjectDid": "did:solidus:testnet:7Hk3mRtQZv...",
"issuanceDate": "2026-05-07T12:05:30Z",
"expirationDate": "2027-05-07T12:05:30Z",
"txHash": "0x7a3b9c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b"
}
}credential.revoked
{
"id": "evt_4mL9gK3cE6nP",
"type": "credential.revoked",
"createdAt": "2026-05-07T14:20:00Z",
"data": {
"credentialId": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"subjectDid": "did:solidus:testnet:7Hk3mRtQZv...",
"revokedBy": "did:solidus:testnet:<verify-service>",
"reason": "Information no longer accurate",
"txHash": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
}
}4. Signature Verification
Every webhook request includes a signature in the Solidus-Signature header. Always verify this
signature. It confirms that the request came from Solidus.
The header carries two fields, separated by a comma:
Solidus-Signature: t=1758579012,v1=a1b2c3d4e5f6...t is the Unix timestamp in seconds. v1 is the HMAC-SHA256 hex digest. There is no separate
timestamp header. Parse t out of this header.
The signed string is the timestamp, a newline, then the raw request body:
t=<timestamp>\n<raw body>Your webhook secret is the HMAC key. Sign the joined string, not the body alone.
import crypto from 'crypto'
function parseSignatureHeader(
header: string
): { timestamp: number; signature: string } | null {
const parts = new Map<string, string>()
for (const field of header.split(',')) {
const [key, value] = field.split('=', 2)
if (key && value) parts.set(key.trim(), value.trim())
}
const t = parts.get('t')
const v1 = parts.get('v1')
if (!t || !v1) return null
const timestamp = Number.parseInt(t, 10)
if (!Number.isFinite(timestamp)) return null
return { timestamp, signature: v1 }
}
function verifyWebhookSignature(
rawBody: string,
header: string,
secret: string
): boolean {
const parsed = parseSignatureHeader(header)
if (!parsed) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`t=${parsed.timestamp}\n${rawBody}`)
.digest('hex')
// timingSafeEqual throws when the two buffers differ in length. Compare the
// lengths first, then compare the bytes.
const given = Buffer.from(parsed.signature, 'utf8')
const want = Buffer.from(expected, 'utf8')
if (given.length !== want.length) return false
return crypto.timingSafeEqual(given, want)
}The timestamp travels inside the same header, as the t field. There is no separate timestamp header. Reject requests where t is more than 5 minutes old. That prevents replay attacks.
5. Complete Express Webhook Handler
A production-ready Express handler that receives, verifies, and processes Solidus webhooks.
import express from 'express'
import crypto from 'crypto'
const app = express()
const WEBHOOK_SECRET = process.env.SOLIDUS_WEBHOOK_SECRET!
// Use raw body for signature verification
app.post(
'/webhooks/solidus',
express.raw({ type: 'application/json' }),
async (req, res) => {
const header = req.headers['solidus-signature'] as string
const rawBody = req.body.toString()
// 1. Parse the header. It carries the timestamp and the digest together.
const parsed = header ? parseSignatureHeader(header) : null
if (!parsed) {
res.status(400).json({ error: 'Missing or malformed signature header' })
return
}
// 2. Verify the timestamp is recent (within 5 minutes)
const eventTime = parsed.timestamp * 1000
const now = Date.now()
if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
res.status(400).json({ error: 'Timestamp too old' })
return
}
// 3. Verify the signature over `t=<timestamp>\n<raw body>`
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`t=${parsed.timestamp}\n${rawBody}`)
.digest('hex')
const given = Buffer.from(parsed.signature, 'utf8')
const want = Buffer.from(expected, 'utf8')
const isValid =
given.length === want.length && crypto.timingSafeEqual(given, want)
if (!isValid) {
res.status(401).json({ error: 'Invalid signature' })
return
}
// 3. Parse the event
const event = JSON.parse(rawBody)
// 4. Respond immediately with 200 to acknowledge receipt
res.status(200).json({ received: true })
// 5. Process the event asynchronously
try {
switch (event.type) {
case 'verification.completed':
await handleVerificationCompleted(event.data)
break
case 'verification.failed':
await handleVerificationFailed(event.data)
break
case 'verification.expired':
await handleVerificationExpired(event.data)
break
case 'credential.issued':
await handleCredentialIssued(event.data)
break
case 'credential.revoked':
await handleCredentialRevoked(event.data)
break
default:
console.log('Unknown event type:', event.type)
}
} catch (err) {
console.error('Error processing webhook:', err)
}
}
)
async function handleVerificationCompleted(data: any) {
// Update user record in your database
await db.users.update({
where: { did: data.subjectDid },
data: {
kycStatus: 'verified',
kycLevel: data.level,
credentialId: data.credentialId,
verifiedAt: new Date(),
},
})
}
async function handleVerificationFailed(data: any) {
await db.users.update({
where: { did: data.subjectDid },
data: {
kycStatus: 'failed',
kycFailureReason: data.reason,
},
})
}
async function handleVerificationExpired(data: any) {
await db.users.update({
where: { did: data.subjectDid },
data: { kycStatus: 'expired' },
})
}
async function handleCredentialIssued(data: any) {
await db.credentials.create({
data: {
credentialId: data.credentialId,
subjectDid: data.subjectDid,
type: data.type,
issuer: data.issuer,
issuedAt: new Date(data.issuanceDate),
expiresAt: new Date(data.expirationDate),
txHash: data.txHash,
},
})
}
async function handleCredentialRevoked(data: any) {
await db.credentials.update({
where: { credentialId: data.credentialId },
data: {
revokedAt: new Date(),
revokedBy: data.revokedBy,
revocationReason: data.reason,
},
})
}
app.listen(3001)6. Handling Delivery
Follow these practices to handle webhook deliveries reliably:
Respond quickly. Return a 200 status code as fast as possible. Process the event asynchronously after responding. If your endpoint takes too long (more than 10 seconds), the delivery will be marked as failed.
Handle duplicates. Webhook deliveries may be retried, so you might receive the same event more than once. Use the id field to deduplicate events.
// Deduplicate using the event ID
const alreadyProcessed = await db.processedEvents.findUnique({
where: { eventId: event.id },
})
if (alreadyProcessed) {
res.status(200).json({ received: true, duplicate: true })
return
}
// Mark as processed before handling
await db.processedEvents.create({
data: { eventId: event.id, processedAt: new Date() },
})Use HTTPS. Webhook endpoints must use HTTPS in production. HTTP endpoints are only allowed for localhost during development.
7. Retry Policy
If your endpoint returns a non-2xx status code, or does not respond within 10 seconds, Solidus retries the delivery with exponential backoff. There are 6 attempts in total, and the backoff starts at 10 seconds:
| Attempt | Fires at, after the first try |
|---|---|
| 1st try | 0s |
| 2nd try | ~10s |
| 3rd try | ~20s |
| 4th try | ~40s |
| 5th try | ~80s |
| 6th try | ~160s |
The whole retry window is about 5 minutes. After the 6th attempt the delivery is marked permanently failed. Plan for this: an endpoint that stays down for 10 minutes loses the event. You can view failed deliveries in the dashboard or via the API, and you can resend one from there.
Corrected 2026-09-23. An earlier version of this page described 5 retries spread over about 14 hours, and a 30-second timeout. Neither matched the implementation. The table above is the shipped behaviour, read from
verify/apps/backend/src/modules/webhooks/service.ts.
8. Delivery Logs
Inspect the delivery history for a webhook endpoint.
const response = await fetch(
'https://verify.solidus.network/v1/webhooks/wh_3nK8mR2pQ5/deliveries',
{
headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
}
)
const deliveries = await response.json()Response:
{
"data": [
{
"id": "del_7rT2nK9mP4",
"eventId": "evt_8nR3kL5mQ2xY",
"eventType": "verification.completed",
"status": "delivered",
"statusCode": 200,
"attemptNumber": 1,
"requestTimestamp": "2026-05-07T12:05:31Z",
"responseTimestamp": "2026-05-07T12:05:31Z",
"duration": 145
},
{
"id": "del_4mL9gK3cE6",
"eventId": "evt_9pQ4rM6sT3wZ",
"eventType": "verification.failed",
"status": "failed",
"statusCode": 500,
"attemptNumber": 1,
"nextRetryAt": "2026-05-07T12:11:30Z",
"requestTimestamp": "2026-05-07T12:05:30Z",
"responseTimestamp": "2026-05-07T12:05:32Z",
"duration": 2045
}
],
"pagination": {
"total": 2,
"page": 1,
"perPage": 25
}
}9. Testing Webhooks
Send a test event to your endpoint to verify it is configured correctly.
const response = await fetch(
'https://verify.solidus.network/v1/webhooks/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({
webhookId: 'wh_3nK8mR2pQ5',
eventType: 'verification.completed',
}),
}
)
const result = await response.json()Response:
{
"id": "del_test_9kM2nR5pQ3",
"status": "delivered",
"statusCode": 200,
"duration": 230,
"request": {
"url": "https://myapp.com/webhooks/solidus",
"headers": {
"Content-Type": "application/json",
"Solidus-Signature": "t=1758579012,v1=a1b2c3d4..."
}
}
}Test events have an id prefixed with evt_test_ so you can distinguish them from real events.
10. Managing Webhooks
List Webhooks
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://verify.solidus.network/v1/webhooksUpdate a Webhook
curl -X PATCH https://verify.solidus.network/v1/webhooks/wh_3nK8mR2pQ5 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": ["verification.completed", "credential.issued"],
"status": "active"
}'Delete a Webhook
curl -X DELETE https://verify.solidus.network/v1/webhooks/wh_3nK8mR2pQ5 \
-H "Authorization: Bearer YOUR_API_KEY"Rotate the Secret
If your webhook secret is compromised, rotate it. The old secret becomes invalid immediately.
curl -X POST \
https://verify.solidus.network/v1/webhooks/wh_3nK8mR2pQ5/rotate-secret \
-H "Authorization: Bearer YOUR_API_KEY"Security Checklist
- Always verify the
Solidus-Signatureheader before processing events - Reject requests with timestamps older than 5 minutes
- Use HTTPS endpoints in production
- Store the webhook secret securely (environment variable, secret manager)
- Deduplicate events using the event
idfield - Process events asynchronously after returning
200 - Monitor delivery logs for persistent failures
Next Steps
- KYC Integration - embed the verification flow
- Credential Flow - full credential lifecycle
- API Reference - complete API documentation