Skip to main content

Overview

Webhooks allow you to receive real-time notifications when a KYC verification status changes. Gu1 automatically sends HTTP POST requests to your configured webhook endpoint whenever a validation status updates.

Why Use Webhooks?

Real-Time Updates

Get instant notifications when verification status changes

Efficient

No need to poll the API repeatedly

Automated Workflows

Automatically update user accounts based on verification results

Better UX

Notify customers immediately after verification

Webhook Events

Gu1 sends webhooks for the following KYC validation events:
Event TypeDescriptionWhen Triggered
kyc.validation_createdValidation session createdWhen you create a new KYC validation
kyc.validation_in_progressCustomer started verificationCustomer begins the verification process
kyc.validation_approvedVerification approvedIdentity successfully verified
kyc.validation_rejectedVerification rejectedIdentity verification failed
kyc.validation_abandonedCustomer abandoned processCustomer left without completing
kyc.validation_expiredValidation session expiredSession expired (typically after 7 days)

Setting Up Webhooks

Step 1: Configure Webhook in Dashboard

Configure your webhook URL in the Gu1 dashboard:
  1. Navigate to Webhooks Settings
    • Log in to your Gu1 dashboard
    • Go to SettingsWebhooks
  2. Create New Webhook
    • Click Add Webhook or Create Webhook
    • Enter a descriptive name (e.g., “Production KYC Webhook”)
    • Enter your webhook URL (must be HTTPS): https://yourapp.com/webhooks/kyc
  3. Select Environment
    • Choose Sandbox for testing
    • Choose Production for live events
  4. Subscribe to Events
    • Select all KYC events or specific ones:
      • kyc.validation_created
      • kyc.validation_in_progress
      • kyc.validation_approved
      • kyc.validation_rejected
      • kyc.validation_abandoned
      • kyc.validation_expired
  5. Save Your Secret
    • A webhook secret will be automatically generated
    • Copy and save this secret - you’ll need it to verify webhook signatures
    • You won’t be able to see it again (but you can regenerate it later)
  6. Activate the Webhook
    • Toggle the webhook to Enabled
    • Click Save
You can create separate webhooks for sandbox and production environments with different URLs.

Step 2: Create a Webhook Endpoint

Create an endpoint in your application to receive webhook POST requests:
const express = require('express');
const crypto = require('crypto');

const app = express();

// IMPORTANT: Use raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.post('/webhooks/kyc', async (req, res) => {
  try {
    // Verify webhook signature
    const signature = req.headers['x-webhook-signature'];
    const webhookSecret = process.env.GUENO_WEBHOOK_SECRET;

    if (!verifySignature(req.rawBody, signature, webhookSecret)) {
      console.error('Invalid webhook signature');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Extract webhook data
    const { event, timestamp, organizationId, payload } = req.body;

    console.log('Received KYC webhook:', {
      event,
      validationId: payload.validationId,
      status: payload.status
    });

    // Process the webhook based on event type
    await handleKycWebhook(event, payload);

    // Return 200 to acknowledge receipt
    res.status(200).json({
      success: true,
      message: 'Webhook received'
    });
  } catch (error) {
    console.error('Webhook error:', error);
    // Still return 200 to prevent retries
    res.status(200).json({
      success: false,
      error: error.message
    });
  }
});

// Verify HMAC signature
function verifySignature(rawBody, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  return signature === expectedSignature;
}

async function handleKycWebhook(event, data) {
  const { validationId, entityId, entity, status } = data;

  // Update your database with validation ID from Gu1
  await db.updateEntity(entity.externalId, {
    kycValidationId: validationId,
    kycStatus: status,
    lastUpdated: new Date()
  });

  // Perform actions based on event type
  switch (event) {
    case 'kyc.validation_created':
      console.log('KYC validation created for:', entity.name);
      break;

    case 'kyc.validation_in_progress':
      await notifyCustomer(entity.externalId, 'verification-started');
      break;

    case 'kyc.validation_approved':
      // Extract verified data
      const { extractedData, verifiedFields } = data;

      await db.updateEntity(entity.externalId, {
        verifiedData: extractedData,
        verifiedFields: verifiedFields,
        verifiedAt: data.verifiedAt,
        isVerified: true
      });

      await activateCustomerAccount(entity.externalId);
      await notifyCustomer(entity.externalId, 'verification-approved');
      break;

    case 'kyc.validation_rejected':
      await db.updateEntity(entity.externalId, {
        isVerified: false,
        rejectionReasons: data.warnings
      });

      await notifyCustomer(entity.externalId, 'verification-rejected');
      break;

    case 'kyc.validation_abandoned':
      await notifyCustomer(entity.externalId, 'verification-incomplete');
      break;

    case 'kyc.validation_expired':
      await notifyCustomer(entity.externalId, 'verification-expired');
      break;
  }
}

Step 3: Make Your Endpoint Publicly Accessible

Your webhook endpoint must be:
  • Publicly accessible via HTTPS
  • Able to receive POST requests
  • Return a 200 status code quickly (within 30 seconds)
For local development, use tools like ngrok to create a public URL that tunnels to your local server.

Security: Verifying Webhook Signatures

Always verify webhook signatures to ensure requests are coming from Gu1.

How Signature Verification Works

  1. Gu1 generates an HMAC SHA-256 signature of the webhook payload using your secret
  2. The signature is sent in the X-Webhook-Signature header
  3. Your server recalculates the signature using the same secret
  4. Compare the signatures - if they match, the webhook is authentic

Signature Verification Examples

const crypto = require('crypto');

function verifySignature(rawBody, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  return signature === expectedSignature;
}

// In your webhook endpoint:
app.post('/webhooks/kyc', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const secret = process.env.GUENO_WEBHOOK_SECRET;

  if (!verifySignature(req.rawBody, signature, secret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process webhook...
});
Never skip signature verification in production. Without it, anyone can send fake webhooks to your endpoint.

HTTP Headers

Every webhook request includes these headers:
HeaderDescriptionExample
Content-TypeAlways application/jsonapplication/json
X-Webhook-EventEvent typekyc.validation_approved
X-Webhook-IDWebhook configuration ID550e8400-e29b-41d4-a716-446655440000
X-Webhook-TimestampISO 8601 timestamp2025-01-15T10:30:00.000Z
X-Webhook-SignatureHMAC SHA-256 signatureabc123...

Webhook Payload Structure

All webhooks follow this standard structure:
{
  "event": "kyc.validation_approved",
  "timestamp": "2025-01-15T11:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "externalId": "customer_xyz789",
      "name": "John Doe",
      "type": "person"
    },
    "status": "approved"
    // ... event-specific fields
  }
}

Common Payload Fields

event
string
The event type (e.g., kyc.validation_approved)
timestamp
string
ISO 8601 timestamp when the event occurred
organizationId
string
Your organization ID
payload.validationId
string
The KYC validation ID in Gu1
payload.entityId
string
The entity (person) ID being verified
payload.entity
object
Entity information including your externalId for easy lookup
payload.status
string
Current validation status: pending, in_progress, approved, rejected, abandoned, expired

Event-Specific Payloads

kyc.validation_created

Sent when a new KYC validation is created.
{
  "event": "kyc.validation_created",
  "timestamp": "2025-01-15T10:30:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "externalId": "customer_xyz789",
      "name": "John Doe",
      "type": "person"
    },
    "status": "pending"
  }
}

kyc.validation_in_progress

Sent when a customer starts the verification process.
{
  "event": "kyc.validation_in_progress",
  "timestamp": "2025-01-15T10:35:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "in_progress"
  }
}

kyc.validation_approved

Sent when verification is successfully completed.
{
  "event": "kyc.validation_approved",
  "timestamp": "2025-01-15T11:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "approved",
    "verifiedAt": "2025-01-15T11:00:00Z",
    "extractedData": {
      "firstName": "John",
      "lastName": "Doe",
      "dateOfBirth": "1990-05-20",
      "nationality": "US",
      "documentNumber": "AB123456",
      "documentType": "passport"
    },
    "verifiedFields": [
      "firstName",
      "lastName",
      "dateOfBirth",
      "nationality",
      "documentNumber"
    ],
    "warnings": [],
    "decision": {
      "id_verification": "pass",
      "face_match": "pass",
      "liveness": "pass"
    }
  }
}
Additional Fields:
  • verifiedAt: Timestamp when verification was approved
  • extractedData: Personal information extracted from the document
  • verifiedFields: Array of fields that were successfully verified
  • warnings: Array of any warnings detected during verification
  • decision: Verification results (images/URLs removed for security)

kyc.validation_rejected

Sent when verification fails.
{
  "event": "kyc.validation_rejected",
  "timestamp": "2025-01-15T11:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "rejected",
    "verifiedAt": "2025-01-15T11:00:00Z",
    "extractedData": {},
    "verifiedFields": [],
    "warnings": [
      "Document authenticity check failed",
      "Face match confidence low"
    ],
    "decision": {
      "id_verification": "fail",
      "face_match": "fail",
      "liveness": "pass"
    }
  }
}

kyc.validation_abandoned

Sent when a customer starts but doesn’t complete the verification.
{
  "event": "kyc.validation_abandoned",
  "timestamp": "2025-01-15T10:45:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "abandoned"
  }
}

kyc.validation_expired

Sent when a validation session expires without completion.
{
  "event": "kyc.validation_expired",
  "timestamp": "2025-01-15T12:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "expired",
    "expiresAt": "2025-01-22T10:30:00Z"
  }
}

Retry Policy

If your webhook endpoint fails to respond with a 2xx status code, Gu1 will automatically retry delivery. Default Retry Policy:
  • Max Retries: 3 attempts
  • Initial Delay: 1000ms (1 second)
  • Backoff Multiplier: 2x
  • Retry Sequence: 1s → 2s → 4s
Example Timeline:
  1. Initial attempt at T+0s
  2. First retry at T+1s
  3. Second retry at T+3s (1s + 2s)
  4. Third retry at T+7s (1s + 2s + 4s)
Success Criteria:
  • HTTP status codes 200-299 are considered successful
  • Any other status code or network error triggers a retry
Timeout:
  • Each attempt has a 30-second timeout
  • If your endpoint doesn’t respond within 30 seconds, the attempt is marked as failed
You can view all webhook delivery attempts (including retries) in the Webhooks → History section of your dashboard.

Best Practices

Always return a 200 status code as quickly as possible to acknowledge receipt. Process the webhook asynchronously if needed.
app.post('/webhooks/kyc', async (req, res) => {
  // Acknowledge immediately
  res.status(200).send('OK');

  // Process asynchronously
  processWebhook(req.body).catch(console.error);
});
Always verify the X-Webhook-Signature header to ensure the webhook is authentic.
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.rawBody, signature, secret)) {
  return res.status(401).json({ error: 'Invalid signature' });
}
You might receive the same webhook multiple times. Use the validationId to ensure you process each event only once.
async function handleWebhook(webhook) {
  const alreadyProcessed = await db.checkWebhookProcessed(
    webhook.payload.validationId,
    webhook.event
  );

  if (alreadyProcessed) {
    return; // Skip duplicate
  }

  // Process webhook
  await processValidation(webhook.payload);

  // Mark as processed
  await db.markWebhookProcessed(
    webhook.payload.validationId,
    webhook.event
  );
}
The webhook includes entity.externalId which is the ID you provided when creating the entity. Use this to look up the customer in your database.
const customer = await db.findCustomer({
  externalId: data.entity.externalId
});
Store the validationId from Gu1 in your database. This allows you to query validation details later if needed.
await db.updateCustomer(customer.id, {
  kycValidationId: data.validationId,
  kycStatus: data.status
});
If processing fails, log the error but still return 200 to prevent retries. Store failed webhooks for manual review.
try {
  await processWebhook(payload);
} catch (error) {
  await db.saveFailedWebhook({
    payload,
    error: error.message,
    receivedAt: new Date()
  });

  // Still return 200
  res.status(200).json({ success: false });
}
Create separate webhook configurations for sandbox and production environments.
  • Sandbox: Use for testing with test data
  • Production: Use for live customer verifications
This allows you to safely test webhook handling without affecting production systems.

Testing Webhooks

Testing in Dashboard

  1. Go to SettingsWebhooks
  2. Select your webhook
  3. Click Test Webhook
  4. Gu1 will send a test event to your endpoint
  5. Check the response status and logs

Local Development

Use ngrok to expose your local server:
# Start ngrok
ngrok http 3000

# Use the ngrok URL as your webhook URL
https://abc123.ngrok.io/webhooks/kyc

Testing Flow

  1. Create a sandbox webhook pointing to your development endpoint
  2. Create a test KYC validation
  3. Your webhook endpoint receives kyc.validation_created
  4. Complete the verification (or simulate different outcomes)
  5. Your webhook endpoint receives status updates

Monitoring and Debugging

Webhook Logs

View webhook delivery history in your dashboard:
  1. Go to SettingsWebhooks
  2. Select your webhook
  3. Click View Logs or History
Logs include:
  • Timestamp of each delivery attempt
  • HTTP status code received
  • Response body
  • Response time
  • Error messages (if any)
  • Retry attempts

Webhook Statistics

Each webhook displays:
  • Total Triggers: Total number of times the webhook was triggered
  • Success Count: Successful deliveries
  • Failure Count: Failed deliveries
  • Last Triggered: Timestamp of last attempt
  • Last Success: Timestamp of last successful delivery
  • Last Failure: Timestamp of last failed delivery

Troubleshooting

Check these items:
  • Webhook URL is publicly accessible via HTTPS
  • Firewall allows incoming POST requests from Gu1
  • Endpoint returns 200 status code within 30 seconds
  • Webhook is configured and enabled in dashboard
  • Correct environment selected (sandbox vs production)
  • Check server logs for incoming requests
  • Verify webhook subscribed to correct event types
Common causes:
  • Using wrong secret (check dashboard for current secret)
  • Verifying signature on parsed JSON instead of raw body
  • Secret not properly saved after webhook creation
  • Encoding issues (ensure UTF-8)
Solution:
// WRONG - verifying parsed body
const signature = crypto.createHmac('sha256', secret)
  .update(JSON.stringify(req.body))
  .digest('hex');

// RIGHT - use raw body before parsing
const signature = crypto.createHmac('sha256', secret)
  .update(req.rawBody)
  .digest('hex');
This is normal behavior. Webhooks may be sent multiple times due to:
  • Network issues
  • Timeouts
  • Retries after failures
Always implement idempotency using the webhook validationId and event type.
Your endpoint must respond within 30 seconds. If processing takes longer:
app.post('/webhooks', async (req, res) => {
  // Respond immediately
  res.status(200).json({ received: true });

  // Process in background
  await queueWebhookProcessing(req.body);
});
extractedData and verifiedFields are only included in:
  • kyc.validation_approved
  • kyc.validation_rejected
They are not present in other event types like validation_created or validation_in_progress.
If you lost your webhook secret:
  1. Go to SettingsWebhooks
  2. Select your webhook
  3. Click Regenerate Secret
  4. Save the new secret in your environment variables
  5. Update your application with the new secret
Note: Old secret will stop working immediately after regeneration.

Next Steps