Skip to main content

Overview

Xenia Webhooks provide real-time HTTP notifications when events occur in your workspace. Instead of polling for changes, your application receives instant updates when tasks change status, submissions are completed, or users are activated. Key Capabilities:
  • Real-time event notifications via HTTP POST
  • Configurable event subscriptions (subscribe only to events you need)
  • Secure signature verification using HMAC-SHA256
  • Automatic retries with exponential backoff
  • Dead letter queue for failed deliveries with replay capability
  • Secret rotation with 24-hour grace period

Prerequisites

Before using webhooks:
  1. Feature Flag: WEBHOOKS must be enabled for your workspace
  2. Authentication: Valid API credentials (x-client-key and x-client-secret)
  3. HTTPS Endpoint: Your webhook receiver must use HTTPS (required for production)

Quick Start

Step 1: Create a Webhook Subscription

curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Task Notifications",
    "url": "https://your-app.com/webhooks/xenia",
    "events": ["task.status_changed", "submission.submitted"]
  }'
Response:
{
  "status": true,
  "code": 201,
  "data": {
    "id": "sub_abc123",
    "name": "My Task Notifications",
    "url": "https://your-app.com/webhooks/xenia",
    "events": ["task.status_changed", "submission.submitted"],
    "secret": "a1b2c3d4e5f6...64-character-hex-string",
    "isActive": true,
    "createdAt": "2024-12-23T10:00:00Z"
  }
}
The secret is only returned once when the subscription is created. Store it securely immediately - you’ll need it to verify webhook signatures.

Step 2: Store the Secret Securely

Store the webhook secret in your environment variables or secrets manager:
# .env file
XENIA_WEBHOOK_SECRET=a1b2c3d4e5f6...

Step 3: Implement Signature Verification

See the Signature Verification section below for implementation details.

Step 4: Test Your Endpoint

curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}/test" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET"
Response:
{
  "status": true,
  "code": 200,
  "data": {
    "success": true,
    "responseStatus": 200,
    "responseTimeMs": 145,
    "message": "Webhook endpoint responded successfully"
  }
}

Event Types

Tier 1 - Critical (Active)

These events are currently available:
Event TypeDescription
task.status_changedTask status transitions (e.g., Open → In Progress → Completed)
submission.submittedChecklist submission completed
submission.approvedSubmission approved in approval workflow
submission.rejectedSubmission rejected in approval workflow
user.invitedUser invited to workspace
user.activatedUser completed onboarding and became active
user.deactivatedUser deactivated in workspace

Tier 2 - Standard (Coming Soon)

Event TypeDescription
task.createdNew task created
task.assignedTask assignment changed
recurring_task.instance_createdNew instance of recurring task generated
submission.approval_requiredSubmission requires approval
template.publishedChecklist template published
template.archivedChecklist template archived

Tier 3 - Extended (Future)

Event TypeDescription
bulk_operation.completedBulk operation finished
export.completedData export ready for download
location.member_addedUser added to location
location.member_removedUser removed from location
team.member_addedUser added to team
team.member_removedUser removed from team
To get the current list of available event types, use the List Event Types endpoint.

Webhook Payload Format

Payload Structure

Every webhook delivery includes a JSON payload with this structure:
{
  "eventId": "evt_abc123def456",
  "eventType": "task.status_changed",
  "timestamp": "2024-12-23T10:30:00.000Z",
  "workspaceId": "ws_xyz789",
  "data": {
    // Event-specific data
  },
  "metadata": {
    "triggeredBy": "user_abc123"
  }
}
FieldTypeDescription
eventIdstringUnique identifier for this event (use for idempotency)
eventTypestringThe type of event that occurred
timestampstringISO-8601 timestamp when the event occurred
workspaceIdstringWorkspace where the event occurred
dataobjectEvent-specific payload data
metadataobjectAdditional context (e.g., who triggered the event)

HTTP Headers

Every webhook request includes these headers:
HeaderDescription
Content-TypeAlways application/json
X-Xenia-SignatureHMAC-SHA256 signature of the payload
X-Xenia-TimestampUnix timestamp (seconds) when the webhook was sent
X-Xenia-Event-IdUnique event ID (same as eventId in payload)
X-Xenia-Event-TypeEvent type (same as eventType in payload)
X-Xenia-Previous-SignaturePrevious signature (only during secret rotation)
User-AgentXenia-Webhooks/1.0

Receiving Webhooks

Event Payload Examples

{
  "eventId": "evt_task_001",
  "eventType": "task.status_changed",
  "timestamp": "2024-12-23T10:30:00.000Z",
  "workspaceId": "ws_xyz789",
  "data": {
    "taskId": "task_abc123",
    "title": "Daily Safety Inspection",
    "previousStatus": "Open",
    "newStatus": "Completed",
    "locationId": "loc_downtown",
    "locationName": "Downtown Branch",
    "assignees": [
      {
        "userId": "user_123",
        "name": "John Doe",
        "email": "[email protected]"
      }
    ],
    "completedBy": {
      "userId": "user_123",
      "name": "John Doe"
    },
    "completedAt": "2024-12-23T10:30:00.000Z"
  },
  "metadata": {
    "triggeredBy": "user_123"
  }
}
{
  "eventId": "evt_sub_001",
  "eventType": "submission.submitted",
  "timestamp": "2024-12-23T11:00:00.000Z",
  "workspaceId": "ws_xyz789",
  "data": {
    "submissionId": "sub_def456",
    "checklistId": "chk_template_001",
    "checklistName": "Opening Checklist",
    "taskId": "task_abc123",
    "taskTitle": "Morning Opening Procedure",
    "locationId": "loc_downtown",
    "locationName": "Downtown Branch",
    "submittedBy": {
      "userId": "user_456",
      "name": "Jane Smith",
      "email": "[email protected]"
    },
    "submittedAt": "2024-12-23T11:00:00.000Z",
    "score": {
      "total": 100,
      "earned": 95,
      "percentage": 95.0
    }
  },
  "metadata": {
    "triggeredBy": "user_456"
  }
}
{
  "eventId": "evt_user_001",
  "eventType": "user.invited",
  "timestamp": "2024-12-23T09:00:00.000Z",
  "workspaceId": "ws_xyz789",
  "data": {
    "userId": "user_new789",
    "email": "[email protected]",
    "firstName": "New",
    "lastName": "User",
    "roleId": "role_staff",
    "roleName": "Staff Member",
    "invitedBy": {
      "userId": "user_admin",
      "name": "Admin User"
    },
    "invitedAt": "2024-12-23T09:00:00.000Z"
  },
  "metadata": {
    "triggeredBy": "user_admin"
  }
}
{
  "eventId": "evt_approval_001",
  "eventType": "submission.approved",
  "timestamp": "2024-12-23T14:00:00.000Z",
  "workspaceId": "ws_xyz789",
  "data": {
    "submissionId": "sub_def456",
    "checklistId": "chk_template_001",
    "checklistName": "Safety Audit",
    "approvedBy": {
      "userId": "user_manager",
      "name": "Manager Name"
    },
    "approvedAt": "2024-12-23T14:00:00.000Z",
    "approvalStep": 1,
    "totalSteps": 2,
    "comments": "Looks good, approved."
  },
  "metadata": {
    "triggeredBy": "user_manager"
  }
}
{
  "eventId": "evt_user_002",
  "eventType": "user.activated",
  "timestamp": "2024-12-23T10:00:00.000Z",
  "workspaceId": "ws_xyz789",
  "data": {
    "userId": "user_new789",
    "email": "[email protected]",
    "firstName": "New",
    "lastName": "User",
    "roleId": "role_staff",
    "roleName": "Staff Member",
    "activatedAt": "2024-12-23T10:00:00.000Z"
  },
  "metadata": {
    "triggeredBy": "user_new789"
  }
}

Signature Verification

Always verify webhook signatures to ensure requests are genuinely from Xenia and haven’t been tampered with.

Algorithm

The signature is computed as:
HMAC-SHA256(secret, timestamp + "." + JSON.stringify(payload))

Verification Steps

  1. Extract the X-Xenia-Timestamp and X-Xenia-Signature headers
  2. Check that the timestamp is within 5 minutes of current time (prevents replay attacks)
  3. Compute the expected signature using your webhook secret
  4. Compare signatures using a timing-safe comparison
Always verify webhook signatures in production. Skipping verification exposes your application to spoofed requests, replay attacks, and man-in-the-middle attacks.

Implementation Examples

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, timestamp, secret) {
  // Check timestamp freshness (5-minute tolerance)
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
    throw new Error('Webhook timestamp too old');
  }

  // Compute expected signature
  const signaturePayload = `${timestamp}.${JSON.stringify(payload)}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signaturePayload)
    .digest('hex');

  // Timing-safe comparison
  if (!crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}

// Express middleware example
app.post('/webhooks/xenia', express.json(), (req, res) => {
  try {
    verifyWebhookSignature(
      req.body,
      req.headers['x-xenia-signature'],
      req.headers['x-xenia-timestamp'],
      process.env.XENIA_WEBHOOK_SECRET
    );

    // Process the webhook
    const { eventType, data } = req.body;
    console.log(`Received ${eventType}:`, data);

    // Return 200 immediately
    res.status(200).send('OK');

    // Process asynchronously if needed
    processWebhookAsync(req.body);
  } catch (error) {
    console.error('Webhook verification failed:', error.message);
    res.status(401).send('Unauthorized');
  }
});

Handling Secret Rotation

During secret rotation, webhooks include both the current and previous signature:
function verifyWithRotation(payload, headers, currentSecret, previousSecret) {
  const { 'x-xenia-signature': signature, 'x-xenia-timestamp': timestamp } = headers;
  const previousSignature = headers['x-xenia-previous-signature'];

  // Try current secret first
  try {
    return verifyWebhookSignature(payload, signature, timestamp, currentSecret);
  } catch (e) {
    // If rotation is in progress, try previous secret
    if (previousSignature && previousSecret) {
      return verifyWebhookSignature(payload, previousSignature, timestamp, previousSecret);
    }
    throw e;
  }
}

Retry Logic & Delivery

Delivery Behavior

SettingValue
Timeout30 seconds
Max Attempts5
Backoff Schedule1s, 2s, 4s, 8s, 16s (exponential)
Success Codes200-299

Response Handling

ResponseBehavior
2xxSuccess - no retry
429 (Rate Limited)Retry with backoff
5xx (Server Error)Retry with backoff
TimeoutRetry with backoff
Connection ErrorRetry with backoff
4xx (except 429)Non-retryable - moves to dead letter queue immediately

Auto-Disable

Subscriptions are automatically disabled after too many consecutive failures. To re-enable:
curl -X PATCH "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"isActive": true}'
Return a 200 response immediately and process webhooks asynchronously to avoid timeouts. Most webhook handlers should complete in under 1 second.

Dead Letter Queue

Failed webhooks (after 5 retry attempts) are moved to a dead letter queue with 28-day retention.

Viewing Dead Letters

curl -X GET "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-dead-letter?subscriptionId={subscriptionId}" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET"

Replaying Failed Webhooks

Single Replay:
curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-dead-letter/{id}/replay" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET"
Bulk Replay:
curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-dead-letter/bulk-replay" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "subscriptionId": "sub_abc123",
    "eventType": "task.status_changed"
  }'
Bulk replay requires at least one filter (subscriptionId or eventType) and processes a maximum of 100 items per request.

Secret Rotation

Rotate your webhook secret periodically for security. During rotation, both old and new secrets are valid for 24 hours.

Rotation Workflow

  1. Initiate Rotation:
curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}/rotate-secret" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET"
Response:
{
  "status": true,
  "code": 200,
  "data": {
    "secret": "new_secret_64_char_hex...",
    "previousSecretValidUntil": "2024-12-24T10:00:00Z"
  }
}
  1. Update Your Application: Deploy the new secret to your webhook handler
  2. Verify Both Secrets: During the 24-hour grace period, verify against both signatures
  3. Complete Migration: After 24 hours, only the new secret is valid
During rotation, all webhook deliveries include both X-Xenia-Signature (new secret) and X-Xenia-Previous-Signature (old secret) headers.

Subscription Configuration

Create/Update Options

FieldTypeRequiredDescription
namestringYesSubscription name (max 255 chars)
urlstringYesHTTPS URL to receive webhooks (max 2048 chars)
eventsarrayYesEvent types to subscribe to (1-50 events)
descriptionstringNoDescription (max 2000 chars)
rateLimitintegerNoRequests per minute (1-1000, default: 100)
metadataobjectNoCustom key-value pairs
isActivebooleanNoEnable/disable subscription

Example: Update Subscription

curl -X PATCH "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["task.status_changed", "submission.submitted", "user.activated"],
    "rateLimit": 200,
    "metadata": {
      "environment": "production",
      "team": "operations"
    }
  }'

Monitoring & Analytics

Workspace Statistics

curl -X GET "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-stats?period=7d" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET"
Response:
{
  "status": true,
  "code": 200,
  "data": {
    "period": "7d",
    "totalDeliveries": 1250,
    "successfulDeliveries": 1230,
    "failedDeliveries": 20,
    "successRate": 98.4,
    "averageLatencyMs": 145,
    "byEventType": {
      "task.status_changed": 800,
      "submission.submitted": 350,
      "user.activated": 100
    }
  }
}

Subscription Logs

curl -X GET "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}/logs?status=failed&limit=50" \
  -H "x-client-key: YOUR_CLIENT_KEY" \
  -H "x-client-secret: YOUR_CLIENT_SECRET"

Best Practices

Process webhooks asynchronously to avoid timeouts. Your endpoint should acknowledge receipt within a few seconds, then process the data in the background.
app.post('/webhooks', (req, res) => {
  // Acknowledge immediately
  res.status(200).send('OK');

  // Process asynchronously
  queue.add('process-webhook', req.body);
});
Never skip signature verification in production. It protects against:
  • Spoofed requests from attackers
  • Replay attacks using captured webhooks
  • Man-in-the-middle modifications
Use the eventId to detect and handle duplicate deliveries. Store processed event IDs and skip duplicates:
async function processWebhook(event) {
  const { eventId } = event;

  // Check if already processed
  if (await redis.exists(`webhook:${eventId}`)) {
    return; // Skip duplicate
  }

  // Mark as processing
  await redis.set(`webhook:${eventId}`, 'processing', 'EX', 86400);

  // Process the event
  await handleEvent(event);
}
Rotate your webhook secrets periodically (e.g., every 90 days) to limit exposure if a secret is compromised. The 24-hour grace period allows seamless rotation without downtime.
Regularly check the dead letter queue for failed webhooks. Investigate persistent failures - they often indicate endpoint issues or payload handling problems.
All production webhook URLs must use HTTPS. HTTP URLs are rejected to ensure payload confidentiality and integrity.

Troubleshooting

IssueSolution
WEBHOOKS feature not enabledContact your admin to activate the WEBHOOKS feature flag
Invalid signature errorsVerify you’re using the correct secret and JSON stringification matches
Webhook timeoutsReturn 200 within 30 seconds; process asynchronously
Events not arrivingCheck subscription status (isActive) and URL accessibility
Subscription auto-disabledFix endpoint issues, then set isActive: true
Missing eventsVerify the event types are in your subscription’s events array
Duplicate eventsImplement idempotency using eventId

Common Signature Issues

  1. Wrong secret: Ensure you’re using the secret from subscription creation (or the rotated secret)
  2. JSON serialization mismatch: Use JSON.stringify() without pretty-printing or extra spaces
  3. Encoding issues: Ensure UTF-8 encoding throughout
  4. Timestamp format: Use the raw header value, not parsed date object