Lymnus Logo
Docs Developer

Webhooks

Updated 15 hours ago 6 min read 10 views

What are webhooks?

Webhooks let your servers receive real-time notifications when events happen in Lymnus. Instead of polling our API, Lymnus sends an HTTP POST to your URL the moment an event occurs.

Enterprise teams can configure webhook endpoints through their platform administrator.

Supported events:

  • extraction.completed: A document extraction finishes successfully

  • extraction.failed: An extraction fails permanently

  • processing.completed: Data processing finishes successfully

  • generation.completed: Synthetic data generation finishes

  • report.generated: A report, financial model, or an audit is ready

  • agent.run_completed: A workflow agent run finishes

  • agent.run_failed: A workflow agent run fails

  • team.member_added: A new member joins your team

  • team.member_removed: A member is removed from your team

  • subscription.created: A subscription is created

  • subscription.canceled: A subscription is canceled

Payload format:

Every webhook delivery uses this envelope:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "extraction.completed",
  "created": "2025-04-06T14:32:00Z",
  "livemode": true,
  "api_version": "v1",
  "data": {
    "project_id": 42,
    "project_slug": "my-project-abc123",
    "project_name": "Q1 Invoices",
    "format": "json",
    "total_records": 847
  }
}

Verifying webhook signatures:

Every delivery includes an X-Lymnus-Signature header. Always verify this signature before processing the payload — it proves the request came from Lymnus and not a third party.

Signature format:

X-Lymnus-Signature: t=1712413920,v1=a4b3c2d1e5f6...

Verification code — PHP:

function verifyLymnusWebhook(
    string $payload,
    string $signature,
    string $secret
): bool {
    $parts = [];
    foreach (explode(',', $signature) as $part) {
        [$k, $v] = explode('=', $part, 2);
        $parts[$k] = $v;
    }

    $timestamp = $parts['t'] ?? null;
    $v1        = $parts['v1'] ?? null;

    if (!$timestamp || !$v1) return false;

    // Reject replays older than 5 minutes
    if (abs(time() - (int) $timestamp) > 300) return false;

    $expected = hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $v1);
}

// Usage in your webhook handler:
$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_LYMNUS_SIGNATURE'] ?? '';
$secret    = 'whsec_your_signing_secret_here';

if (!verifyLymnusWebhook($payload, $signature, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);
// Handle $event['event'] ...

Verification code — Node.js:

const crypto = require('crypto');

function verifyLymnusWebhook(payload, signature, secret) {
    const parts = Object.fromEntries(
        signature.split(',').map(p => p.split('='))
    );

    const timestamp = parts['t'];
    const v1 = parts['v1'];

    if (!timestamp || !v1) return false;

    // Reject replays older than 5 minutes
    if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;

    const expected = crypto
        .createHmac('sha256', secret)
        .update(payload)
        .digest('hex');

    return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(v1)
    );
}

Verification code — Python:

import hmac
import hashlib
import time

def verify_lymnus_webhook(payload: bytes, signature: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in signature.split(','))
    timestamp = parts.get('t')
    v1 = parts.get('v1')

    if not timestamp or not v1:
        return False

    # Reject replays older than 5 minutes
    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(
        secret.encode(), payload, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, v1)

Retry behaviour:

If your endpoint does not return a 2xx status code within 10 seconds, Lymnus retries the delivery with exponential backoff:

  • 1st retry: 1 minute

  • 2nd retry: 5 minutes

  • 3rd retry: 30 minutes

  • 4th retry: 2 hours

  • 5th retry (final): 8 hours

After 5 failed attempts the delivery is marked exhausted and no further retries occur. You can manually trigger a retry from your webhook delivery log.

Best practices:

  • Respond quickly — return HTTP 200 immediately and process the event asynchronously in a background job.

  • Be idempotent — the same event may be delivered more than once in rare cases. Use id (the delivery UUID) to deduplicate.

  • Always verify signatures — never process a payload without verifying the X-Lymnus-Signature header.

  • Handle all events — your handler should return 200 even for events you do not care about. Returning 4xx causes unnecessary retries.

Was this page helpful?