Docs /api-reference/webhooks-api

Webhooks API Reference

Complete reference for webhook configuration, delivery, and security in Izri.

๐Ÿ“‹ Overview

Webhooks allow you to receive real-time HTTP notifications when events occur in your projects.

Status: ๐Ÿšง Partially Implemented (database schema ready, endpoints planned)

Webhook Types:

  • ๐Ÿ“ค Outgoing webhooks - Send events to external URLs
  • ๐Ÿ“ฅ Incoming webhooks - Receive events from GitHub, etc. (planned)

๐Ÿ“ค Outgoing Webhooks

Send HTTP POST requests to your endpoints when events occur.

Event Types

Event Description Payload
test_run.started Test execution started TestRun object
test_run.completed Test execution completed TestRun with results
test_run.failed Test execution failed TestRun with error
test.generated AI generated new test GeneratedTest object
analysis.completed Repository analysis done Analysis object
project.created New project created Project object
project.updated Project settings changed Project object
project.deleted Project deleted Project ID

๐Ÿ—„๏ธ Database Schema

webhooks Table

{
  id: string
  organizationId?: string  // Org-wide or null
  projectId?: string       // Project-specific or null
  url: string             // Destination URL
  events: string[]        // ['test_run.completed', 'analysis.completed']
  secret: string          // For signature verification
  active: boolean
  createdAt: Date
  updatedAt: Date
}

Scope Options:

  • Organization-wide: Set organizationId, leave projectId null
  • Project-specific: Set both organizationId and projectId

webhookDeliveries Table

{
  id: string
  webhookId: string
  event: string
  payload: object         // JSONB - full event payload
  responseStatus?: number
  responseBody?: string
  error?: string
  attemptCount: number
  nextRetryAt?: Date
  deliveredAt?: Date
  createdAt: Date
}

Delivery Tracking:

  • Records every webhook attempt
  • Stores response status and body
  • Tracks retry attempts
  • Maintains delivery history

๐Ÿ”ง Webhook Management API

Create Webhook

tRPC (planned):

const { mutate } = trpc.webhooks.create.useMutation()

mutate({
  url: 'https://api.example.com/webhooks',
  events: ['test_run.completed', 'analysis.completed'],
  projectId: 'proj_123',  // Optional
  organizationId: 'org_456'  // Optional
})

Request:

{
  url: string              // Must be HTTPS in production
  events: string[]         // Event types to subscribe to
  projectId?: string       // Project-specific webhook
  organizationId?: string  // Organization webhook
  secret?: string          // Custom secret (auto-generated if not provided)
}

Response:

{
  webhook: {
    id: 'webhook_abc123',
    url: 'https://api.example.com/webhooks',
    events: ['test_run.completed', 'analysis.completed'],
    secret: 'whsec_...',    // Only shown once
    active: true,
    createdAt: Date
  }
}

Important: Save the secret immediately - it won't be shown again!


List Webhooks

tRPC (planned):

const { data } = await trpc.webhooks.list.useQuery({
  projectId: 'proj_123'
})

Response:

{
  webhooks: [
    {
      id: 'webhook_abc',
      url: 'https://api.example.com/webhooks',
      events: ['test_run.completed'],
      active: true,
      createdAt: Date,
      lastDeliveryAt?: Date,
      deliveryCount: 42
    }
  ]
}

Note: Secrets are never returned in list/get operations


Update Webhook

tRPC (planned):

const { mutate } = trpc.webhooks.update.useMutation()

mutate({
  webhookId: 'webhook_abc',
  url: 'https://new-url.example.com/webhooks',
  events: ['test_run.completed', 'test_run.failed'],
  active: true
})

Updatable Fields:

  • url
  • events
  • active (enable/disable)

Non-updatable:

  • id
  • secret (use rotate secret instead)
  • organizationId
  • projectId

Delete Webhook

tRPC (planned):

const { mutate } = trpc.webhooks.delete.useMutation()
mutate({ webhookId: 'webhook_abc' })

Side Effects:

  • Webhook immediately deactivated
  • Pending deliveries cancelled
  • Delivery history preserved for 30 days

Rotate Secret

tRPC (planned):

const { mutate } = trpc.webhooks.rotateSecret.useMutation()
mutate({ webhookId: 'webhook_abc' })

Response:

{
  secret: 'whsec_new_secret_here'  // Only shown once
}

Use Case: Secret compromised or routine rotation


๐Ÿ“จ Webhook Payload Format

Standard Payload Structure

{
  id: string              // Delivery ID
  event: string           // Event type
  timestamp: string       // ISO 8601
  data: object            // Event-specific data
  project?: {
    id: string
    name: string
    repository: string
  }
  organization?: {
    id: string
    name: string
  }
}

test_run.completed

{
  "id": "delivery_abc123",
  "event": "test_run.completed",
  "timestamp": "2024-01-01T00:00:00.000Z",
  "data": {
    "id": "run_xyz789",
    "projectId": "proj_123",
    "status": "completed",
    "totalTests": 50,
    "passedTests": 45,
    "failedTests": 5,
    "skippedTests": 0,
    "duration": 12500,
    "coverage": 85.5,
    "createdAt": "2024-01-01T00:00:00.000Z"
  },
  "project": {
    "id": "proj_123",
    "name": "My App",
    "repository": "https://github.com/user/repo"
  }
}

test_run.failed

{
  "id": "delivery_def456",
  "event": "test_run.failed",
  "timestamp": "2024-01-01T00:00:00.000Z",
  "data": {
    "id": "run_xyz789",
    "projectId": "proj_123",
    "status": "failed",
    "error": "Failed to execute tests: timeout",
    "log": "Test execution logs...",
    "createdAt": "2024-01-01T00:00:00.000Z"
  },
  "project": {
    "id": "proj_123",
    "name": "My App",
    "repository": "https://github.com/user/repo"
  }
}

analysis.completed

{
  "id": "delivery_ghi789",
  "event": "analysis.completed",
  "timestamp": "2024-01-01T00:00:00.000Z",
  "data": {
    "id": "analysis_abc",
    "projectId": "proj_123",
    "commitSha": "abc123def456",
    "filesAnalyzed": 156,
    "testCandidates": 23,
    "languages": {
      "TypeScript": 120,
      "JavaScript": 36
    },
    "frameworks": ["react", "vite"],
    "createdAt": "2024-01-01T00:00:00.000Z"
  },
  "project": {
    "id": "proj_123",
    "name": "My App",
    "repository": "https://github.com/user/repo"
  }
}

project.created

{
  "id": "delivery_jkl012",
  "event": "project.created",
  "timestamp": "2024-01-01T00:00:00.000Z",
  "data": {
    "id": "proj_123",
    "name": "My App",
    "repository": "https://github.com/user/repo",
    "branch": "main",
    "language": "typescript",
    "framework": "react",
    "createdAt": "2024-01-01T00:00:00.000Z"
  }
}

๐Ÿ”’ Security & Verification

Request Headers

Every webhook request includes these headers:

POST /your-webhook-endpoint HTTP/1.1
Host: api.example.com
Content-Type: application/json
User-Agent: Izri-Webhook/1.0
X-Izri-Signature: sha256=abc123...
X-Izri-Event: test_run.completed
X-Izri-Delivery: delivery_abc123
X-Izri-Timestamp: 2024-01-01T00:00:00.000Z

Header Descriptions:

  • X-Izri-Signature - HMAC signature for verification
  • X-Izri-Event - Event type
  • X-Izri-Delivery - Unique delivery ID
  • X-Izri-Timestamp - Delivery timestamp

Signature Verification

Signature Format: sha256=<hex_digest>

Algorithm: HMAC-SHA256

Node.js Example:

import crypto from 'node:crypto'

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const hmac = crypto.createHmac('sha256', secret)
  const digest = `sha256=${hmac.update(payload).digest('hex')}`
  
  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(digest)
  )
}

// Usage in Express/Hono
app.post('/webhooks', async (c) => {
  const rawBody = await c.req.text()
  const signature = c.req.header('X-Izri-Signature')
  const secret = process.env.WEBHOOK_SECRET
  
  if (!signature || !verifyWebhookSignature(rawBody, signature, secret)) {
    return c.json({ error: 'Invalid signature' }, 401)
  }
  
  const payload = JSON.parse(rawBody)
  // Process webhook...
  
  return c.json({ received: true })
})

Python Example:

import hmac
import hashlib

def verify_webhook_signature(payload: str, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        payload.encode(),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected)

Timestamp Validation

Prevent replay attacks by checking timestamp:

function isTimestampValid(timestamp: string, toleranceSeconds = 300): boolean {
  const now = Date.now()
  const webhookTime = new Date(timestamp).getTime()
  const diff = Math.abs(now - webhookTime) / 1000
  
  return diff <= toleranceSeconds
}

// Usage
const timestamp = c.req.header('X-Izri-Timestamp')
if (!isTimestampValid(timestamp, 300)) {
  return c.json({ error: 'Timestamp too old' }, 401)
}

Recommended tolerance: 5 minutes (300 seconds)


๐Ÿ”„ Delivery & Retries

Delivery Guarantees

At-least-once delivery: Webhooks may be delivered multiple times

Expected Response:

  • Success: HTTP 200-299
  • Retry: HTTP 408, 429, 500-599
  • Fail: HTTP 400-499 (except 408, 429)

Idempotency: Use X-Izri-Delivery header to detect duplicates

const deliveryId = req.headers['X-Izri-Delivery']

// Check if already processed
const exists = await redis.get(`webhook:${deliveryId}`)
if (exists) {
  return res.status(200).json({ received: true })
}

// Process webhook
await processWebhook(payload)

// Mark as processed (24 hour TTL)
await redis.set(`webhook:${deliveryId}`, '1', 'EX', 86400)

Retry Strategy

Retry Schedule:

  • Attempt 1: Immediate
  • Attempt 2: 1 minute later
  • Attempt 3: 5 minutes later
  • Attempt 4: 30 minutes later
  • Attempt 5: 2 hours later
  • Attempt 6: 12 hours later

Max Attempts: 6

Exponential Backoff: Yes

Headers on Retry:

X-Izri-Delivery-Attempt: 3

Timeout

Request Timeout: 30 seconds

Connection Timeout: 10 seconds

Keep-Alive: Disabled (fresh connection each time)


๐Ÿ“Š Webhook Deliveries

View Delivery History

tRPC (planned):

const { data } = await trpc.webhooks.deliveries.useQuery({
  webhookId: 'webhook_abc',
  limit: 50,
  offset: 0
})

Response:

{
  deliveries: [
    {
      id: 'delivery_abc',
      event: 'test_run.completed',
      responseStatus: 200,
      attemptCount: 1,
      deliveredAt: Date,
      createdAt: Date
    },
    {
      id: 'delivery_def',
      event: 'test_run.failed',
      responseStatus: 500,
      error: 'Connection timeout',
      attemptCount: 3,
      nextRetryAt: Date,
      createdAt: Date
    }
  ],
  pagination: {
    total: 125,
    limit: 50,
    offset: 0
  }
}

Retry Failed Delivery

tRPC (planned):

const { mutate } = trpc.webhooks.retryDelivery.useMutation()
mutate({ deliveryId: 'delivery_abc' })

Effect: Immediately retries delivery (doesn't wait for scheduled retry)


๐Ÿ“ฅ Incoming Webhooks

Receive webhooks from external services.

GitHub Webhooks

Endpoint: POST /webhooks/github

Events Supported:

  • push - Trigger analysis/tests on new commits
  • pull_request - Run tests on PRs
  • release - Trigger on releases

Setup:

  1. Go to GitHub repo โ†’ Settings โ†’ Webhooks
  2. Add webhook URL: https://yourdomain.com/webhooks/github
  3. Set content type: application/json
  4. Set secret: Generate random secret
  5. Select events: push, pull_request
  6. Save webhook

Signature Verification (GitHub):

import crypto from 'node:crypto'

function verifyGitHubSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const hmac = crypto.createHmac('sha1', secret)
  const digest = `sha1=${hmac.update(payload).digest('hex')}`
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(digest)
  )
}

// Usage
const signature = req.headers['x-hub-signature']
const event = req.headers['x-github-event']

if (!verifyGitHubSignature(rawBody, signature, GITHUB_SECRET)) {
  return res.status(401).json({ error: 'Invalid signature' })
}

// Handle event
if (event === 'push') {
  await triggerAnalysis(payload.repository.url, payload.after)
}

๐Ÿงช Testing Webhooks

Webhook Test Endpoint

tRPC (planned):

const { mutate } = trpc.webhooks.test.useMutation()
mutate({ webhookId: 'webhook_abc' })

Test Payload:

{
  "id": "delivery_test",
  "event": "webhook.test",
  "timestamp": "2024-01-01T00:00:00.000Z",
  "data": {
    "message": "This is a test webhook"
  }
}

Expected Response: HTTP 200


Local Testing with ngrok

# Install ngrok
brew install ngrok

# Start ngrok tunnel
ngrok http 4000

# Use ngrok URL as webhook URL
# Example: https://abc123.ngrok.io/webhooks

Webhook URL: https://abc123.ngrok.io/webhooks


Mock Webhook Server

// test-webhook-server.ts
import { Hono } from 'hono'

const app = new Hono()

app.post('/webhooks', async (c) => {
  const signature = c.req.header('X-Izri-Signature')
  const event = c.req.header('X-Izri-Event')
  const delivery = c.req.header('X-Izri-Delivery')
  const payload = await c.req.json()
  
  console.log('Received webhook:')
  console.log('Event:', event)
  console.log('Delivery:', delivery)
  console.log('Payload:', JSON.stringify(payload, null, 2))
  
  return c.json({ received: true })
})

export default app

๐Ÿ”— Integration Examples

Slack Integration

async function sendToSlack(webhook: WebhookPayload) {
  const slackWebhookUrl = 'https://hooks.slack.com/services/...'
  
  let message = ''
  if (webhook.event === 'test_run.completed') {
    const { passedTests, totalTests } = webhook.data
    message = `โœ… Tests completed: ${passedTests}/${totalTests} passed`
  } else if (webhook.event === 'test_run.failed') {
    message = `โŒ Tests failed: ${webhook.data.error}`
  }
  
  await fetch(slackWebhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: message,
      blocks: [
        {
          type: 'section',
          text: { type: 'mrkdwn', text: message }
        },
        {
          type: 'context',
          elements: [
            { type: 'mrkdwn', text: `Project: ${webhook.project.name}` }
          ]
        }
      ]
    })
  })
}

Discord Integration

async function sendToDiscord(webhook: WebhookPayload) {
  const discordWebhookUrl = 'https://discord.com/api/webhooks/...'
  
  const embed = {
    title: `${webhook.event} Event`,
    description: formatWebhookData(webhook.data),
    color: webhook.event.includes('failed') ? 0xff0000 : 0x00ff00,
    timestamp: webhook.timestamp,
    footer: { text: `Project: ${webhook.project.name}` }
  }
  
  await fetch(discordWebhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ embeds: [embed] })
  })
}

๐Ÿ”— Related Documentation

Reading this with an agent? /docs/api-reference/webhooks-api.md serves the raw markdown.

All docs