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 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:

AttemptFires at, after the first try
1st try0s
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/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 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