Skip to Content
GuidesWebhooks

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/v1

Authenticate requests with your API key:

Authorization: Bearer YOUR_API_KEY

1. 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

EventTrigger
verification.completedKYC verification passed, credential issued
verification.failedKYC verification failed (document issues, face mismatch)
verification.expiredVerification session expired before completion
credential.issuedA new Verifiable Credential was issued
credential.revokedAn 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:

AttemptDelay after failure
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry12 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/webhooks

Update 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-Signature header 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 id field
  • Process events asynchronously after returning 200
  • Monitor delivery logs for persistent failures

Next Steps

Last updated on