Skip to main content
kycert sends real-time HTTP POST notifications to your configured endpoint when key events occur — most importantly when a bureau run finishes and a decision is ready. You receive the full result payload in the webhook body, so you don’t need to poll the API for run status. Every webhook payload is signed with HMAC-SHA256 using your webhook secret, so you can verify each delivery came from kycert and was not tampered with in transit. Typical delivery time from run creation to webhook receipt is 5 to 30 seconds.

Configuring a webhook endpoint

1

Go to the Webhooks page

In the kycert dashboard, navigate to Settings → Developer → Webhooks.
2

Click Add Endpoint

Click the Add Endpoint button to open the endpoint configuration form.
3

Enter your HTTPS URL

Provide the full HTTPS URL of your receiving endpoint, for example https://api.yourapp.com/webhooks/kycert. HTTP is accepted in sandbox only — production endpoints must use HTTPS.
4

Select events

Choose the events you want your endpoint to receive. You can subscribe to all events or only specific ones.
5

Copy the signing secret

After saving, copy the Webhook Secret (prefix whsec_). You need this to verify payload signatures. Store it as an environment variable — never in source code.
You can also override the dashboard-configured endpoint per request by passing webhook_url directly in the POST /bureau/runs body. This is useful for routing results from different run types to different services.

Events

EventFired when
run.completedA bureau run finishes with a final decision — status is completed, blocked, pending_review, or partial
run.failedA bureau run encountered an unrecoverable technical failure and produced no decision

Payload structure

Every event follows the same envelope structure. The data.object field contains the run result:
{
  "id": "evt_01J4ZR...",
  "object": "event",
  "event": "run.completed",
  "created": 1718200818,
  "livemode": true,
  "data": {
    "object": "run",
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "completed",
    "decision": "APPROVED",
    "operative_decision": null,
    "risk_band": "LOW",
    "subject_type": "pf",
    "template_id": "661e9511-f3ac-52e5-b827-557766551111",
    "external_id": "cust_abc123",
    "metadata": { "channel": "app_mobile" },
    "created_at": "2026-06-12T14:00:00Z",
    "completed_at": "2026-06-12T14:00:18Z",
    "livemode": true
  }
}
For run.failed events, the data object contains status: "failed", decision: null, and a failed_at timestamp instead of completed_at. The livemode field is true for production events and false for sandbox events. Use this to distinguish environments in a shared endpoint.

Acting on the decision

decisionrisk_bandWhat to do
APPROVEDLOW / MEDIUMProceed with onboarding or operation
BLOCKEDHIGH / CRITICALReject — do not reverse without a manual compliance review
PENDING_REVIEWMEDIUM / HIGHHold the customer pending a decision by your compliance analyst in the kycert dashboard
PARTIALAnyCall GET /bureau/runs/:id/analysis to see which sources failed, then route to compliance

Verifying signatures

The kycert-signature header is included on every webhook request:
kycert-signature: t=1718200818,v1=3f5e8a2b1c...
The header contains two parts: t (Unix timestamp of the request) and v1 (the HMAC-SHA256 signature). To verify:
  1. Extract t and v1 from the header.
  2. Compute HMAC-SHA256(webhook_secret, "${t}.${raw_request_body}").
  3. Compare your computed signature to v1 using a constant-time comparison.
  4. Reject the event if the timestamp is more than 5 minutes from your server’s current time (replay attack protection).
Always verify the signature before processing any webhook event. Any server on the internet can POST to your endpoint. An unverified event could trigger compliance decisions based on forged data.Use the raw request body for verification — do not pass a parsed and re-serialized JSON object. Middleware like express.json() transforms the body before you can read it as a string, which will cause signature verification to fail. Use express.raw() or the equivalent in your framework.
import crypto from 'crypto'

function verifyKycertSignature(
  rawBody: Buffer,
  signatureHeader: string,
  secret: string,
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('=') as [string, string])
  )
  const timestamp = parts['t']
  const received  = parts['v1']

  if (!timestamp || !received) return false

  // Reject events older than 5 minutes
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
    return false
  }

  const computed = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody.toString()}`)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(computed, 'hex'),
    Buffer.from(received, 'hex'),
  )
}

Framework examples

import express from 'express'

const app = express()

app.post(
  '/webhooks/kycert',
  express.raw({ type: 'application/json' }), // raw body — required for signature verification
  (req, res) => {
    const signature = req.headers['kycert-signature'] as string
    const valid = verifyKycertSignature(
      req.body,
      signature,
      process.env.KYCERT_WEBHOOK_SECRET!,
    )

    if (!valid) return res.status(401).json({ error: 'invalid signature' })

    res.sendStatus(200) // respond immediately
    const event = JSON.parse(req.body.toString())
    setImmediate(() => processEvent(event)) // process asynchronously
  }
)

Retry policy

If your endpoint does not return a 2xx status within 30 seconds, kycert retries the delivery with exponential backoff:
AttemptDelay after previous attempt
15 minutes
215 minutes
330 minutes
41 hour
52 hours
66 hours
712 hours
After 7 failed attempts (approximately 24 hours total), the event is marked as failed and no further retries occur. The full attempt history is visible in the dashboard under Settings → Webhooks.
Never return a 4xx status for internal processing errors. A 4xx tells kycert the event was rejected — retries stop immediately. If your handler throws an error after receiving a valid event, return 200 and handle the failure internally (a dead-letter queue, an alert, etc.).

Deduplication

Because kycert retries failed deliveries, your endpoint may receive the same event more than once. Make your handler idempotent by storing processed event IDs:
// Example: Redis-based deduplication
const wasProcessed = await redis.get(`webhook:${event.id}`)
if (wasProcessed) return // already handled — skip

await redis.setex(`webhook:${event.id}`, 86400, '1')
// proceed with processing

Testing webhooks in sandbox

Webhook events are delivered in sandbox exactly as in production. Use a tunneling tool like ngrok or an inspection service like webhook.site during local development to receive events on your machine. Sandbox events include livemode: false in the payload so you can distinguish them in a shared endpoint.