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 X-Solidus-Signature header. Always verify this signature to confirm the request came from Solidus.
The signature is an HMAC-SHA256 hex digest of the raw request body, using your webhook secret as the key.
import crypto from 'crypto'
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}The request also includes an X-Solidus-Timestamp header with a Unix timestamp. Reject requests where the timestamp is more than 5 minutes old to prevent 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 signature = req.headers['x-solidus-signature'] as string
const timestamp = req.headers['x-solidus-timestamp'] as string
const rawBody = req.body.toString()
// 1. Verify the timestamp is recent (within 5 minutes)
const eventTime = parseInt(timestamp, 10) * 1000
const now = Date.now()
if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
res.status(400).json({ error: 'Timestamp too old' })
return
}
// 2. Verify the signature
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
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 30 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 fails to respond within 30 seconds, Solidus retries the delivery with exponential backoff:
| Attempt | Delay after failure |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 12 hours |
After 5 failed retries, the delivery is marked as permanently failed. You can view failed deliveries in the dashboard or via the API.
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",
"X-Solidus-Signature": "a1b2c3d4...",
"X-Solidus-Timestamp": "1746619530"
}
}
}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
X-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