# Skill: Webhook Implementation

## Identity
- **Skill ID**: `webhook-implementation`
- **Domain**: Webhook Sending/Receiving, HTTP Callbacks
- **Technologies**: Axios, HMAC Signatures, Retry Logic
- **Source Agent**: `backend-app-expert.md`

## When to Load This Skill

Load this skill when working on:
- Sending webhooks to WordPress plugin
- Receiving webhooks from PayPal
- HMAC signature generation and verification
- Webhook retry logic with exponential backoff
- Webhook delivery tracking
- Webhook endpoint validation

**File patterns:**
- `api/src/services/webhook*.ts`
- `api/src/routes/webhooks/**/*.ts`
- `api/src/middleware/webhook-verify*.ts`

## Core Patterns

### 1. Outgoing Webhooks (to WordPress Plugin)

```typescript
import axios, { AxiosError } from 'axios';
import crypto from 'crypto';
import { prisma } from '../lib/prisma';

interface WebhookPayload {
  job_id: string;
  client_job_id: string | null;
  status: 'completed' | 'failed';
  translation?: string;
  tokens_used?: number;
  cost_usd?: number;
  error?: string;
}

// Generate HMAC-SHA256 signature
function generateSignature(payload: string, secret: string): string {
  return crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
}

// Send webhook with signature
async function sendWebhook(
  url: string,
  payload: WebhookPayload,
  secret?: string
): Promise<{ success: boolean; httpStatus?: number; error?: string }> {
  try {
    const timestamp = Date.now().toString();
    const payloadString = JSON.stringify(payload);
    
    // Generate signature if secret provided
    const signature = secret 
      ? generateSignature(payloadString, secret)
      : undefined;
    
    const response = await axios.post(url, payload, {
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': signature || '',
        'X-Webhook-Timestamp': timestamp,
        'User-Agent': 'TranslatePressZone/1.0'
      },
      timeout: 30000, // 30 second timeout
      validateStatus: (status) => status >= 200 && status < 300
    });
    
    return {
      success: true,
      httpStatus: response.status
    };
    
  } catch (error) {
    const axiosError = error as AxiosError;
    return {
      success: false,
      httpStatus: axiosError.response?.status,
      error: axiosError.message
    };
  }
}

// Webhook delivery with retry (use this from queue worker)
export async function deliverWebhook(
  jobId: string,
  url: string,
  payload: WebhookPayload,
  secret?: string,
  attemptNumber: number = 1
): Promise<void> {
  const result = await sendWebhook(url, payload, secret);
  
  // Log delivery attempt
  await prisma.webhookDelivery.create({
    data: {
      job_id: jobId,
      attempt_number: attemptNumber,
      success: result.success,
      http_status: result.httpStatus || null,
      error_message: result.error || null
    }
  });
  
  if (!result.success) {
    // Don't retry on client errors (4xx)
    if (result.httpStatus && result.httpStatus >= 400 && result.httpStatus < 500) {
      throw new Error(`Permanent failure: ${result.error}`);
    }
    
    // Retry on server errors (5xx) and network issues
    throw new Error(result.error);
  }
}
```

### 2. Incoming Webhooks (from PayPal)

```typescript
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';

// Verify PayPal webhook signature
function verifyPayPalSignature(
  transmissionId: string,
  timestamp: string,
  webhookId: string,
  eventBody: string,
  certUrl: string,
  actualSignature: string,
  algorithm: string
): boolean {
  // In production, verify cert_url is from PayPal domain
  if (!certUrl.startsWith('https://api.paypal.com/')) {
    return false;
  }
  
  // Construct expected signature string
  const expectedSigString = `${transmissionId}|${timestamp}|${webhookId}|${crypto.createHash('sha256').update(eventBody).digest('base64')}`;
  
  // For simplicity, using HMAC with webhook secret
  // In production, use PayPal's SDK for proper verification
  const secret = process.env.PAYPAL_WEBHOOK_SECRET!;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(expectedSigString)
    .digest('base64');
  
  return crypto.timingSafeEqual(
    Buffer.from(actualSignature),
    Buffer.from(expectedSignature)
  );
}

// Middleware to verify PayPal webhooks
export function verifyPayPalWebhook(req: Request, res: Response, next: NextFunction) {
  const transmissionId = req.headers['paypal-transmission-id'] as string;
  const timestamp = req.headers['paypal-transmission-time'] as string;
  const webhookId = process.env.PAYPAL_WEBHOOK_ID!;
  const certUrl = req.headers['paypal-cert-url'] as string;
  const actualSignature = req.headers['paypal-transmission-sig'] as string;
  const algorithm = req.headers['paypal-auth-algo'] as string;
  
  if (!transmissionId || !timestamp || !actualSignature) {
    return res.status(401).json({ error: 'Missing PayPal signature headers' });
  }
  
  // Verify timestamp (prevent replay attacks)
  const webhookTime = new Date(timestamp).getTime();
  const currentTime = Date.now();
  const timeDiff = Math.abs(currentTime - webhookTime);
  
  if (timeDiff > 5 * 60 * 1000) { // 5 minute window
    return res.status(401).json({ error: 'Webhook timestamp too old' });
  }
  
  // Verify signature
  const eventBody = JSON.stringify(req.body);
  const isValid = verifyPayPalSignature(
    transmissionId,
    timestamp,
    webhookId,
    eventBody,
    certUrl,
    actualSignature,
    algorithm
  );
  
  if (!isValid) {
    return res.status(401).json({ error: 'Invalid PayPal signature' });
  }
  
  next();
}

// PayPal webhook handler
router.post('/webhooks/paypal', verifyPayPalWebhook, async (req, res) => {
  const event = req.body;
  
  try {
    switch (event.event_type) {
      case 'BILLING.SUBSCRIPTION.CREATED':
        await handleSubscriptionCreated(event);
        break;
        
      case 'BILLING.SUBSCRIPTION.ACTIVATED':
        await handleSubscriptionActivated(event);
        break;
        
      case 'PAYMENT.SALE.COMPLETED':
        await handlePaymentCompleted(event);
        break;
        
      case 'BILLING.SUBSCRIPTION.CANCELLED':
        await handleSubscriptionCancelled(event);
        break;
        
      case 'BILLING.SUBSCRIPTION.SUSPENDED':
        await handleSubscriptionSuspended(event);
        break;
        
      default:
        console.log(`Unhandled PayPal event: ${event.event_type}`);
    }
    
    res.sendStatus(200);
  } catch (error) {
    console.error('PayPal webhook error:', error);
    res.sendStatus(500);
  }
});

// Event handlers
async function handleSubscriptionCreated(event: any) {
  const subscriptionId = event.resource.id;
  const customId = event.resource.custom_id; // User ID
  
  // Store subscription ID for later activation
  await prisma.subscription.update({
    where: { user_id: customId },
    data: {
      paypal_subscription_id: subscriptionId,
      status: 'pending'
    }
  });
}

async function handleSubscriptionActivated(event: any) {
  const subscriptionId = event.resource.id;
  
  await prisma.subscription.update({
    where: { paypal_subscription_id: subscriptionId },
    data: {
      status: 'active',
      current_period_start: new Date(event.resource.billing_info.last_payment.time),
      current_period_end: new Date(event.resource.billing_info.next_billing_time)
    }
  });
  
  // Allocate initial credits (handled by queue in production)
}

async function handlePaymentCompleted(event: any) {
  const paymentId = event.resource.id;
  const amount = parseFloat(event.resource.amount.total);
  const subscriptionId = event.resource.billing_agreement_id;
  
  // Find user by subscription
  const subscription = await prisma.subscription.findUnique({
    where: { paypal_subscription_id: subscriptionId }
  });
  
  if (!subscription) {
    console.error(`Subscription not found: ${subscriptionId}`);
    return;
  }
  
  // Record payment
  await prisma.payment.create({
    data: {
      user_id: subscription.user_id,
      paypal_payment_id: paymentId,
      amount,
      currency: 'USD',
      status: 'completed',
      type: 'subscription_payment',
      subscription_id: subscription.id
    }
  });
}

async function handleSubscriptionCancelled(event: any) {
  const subscriptionId = event.resource.id;
  
  await prisma.subscription.update({
    where: { paypal_subscription_id: subscriptionId },
    data: {
      status: 'cancelled',
      cancel_at_period_end: true
    }
  });
}

async function handleSubscriptionSuspended(event: any) {
  const subscriptionId = event.resource.id;
  
  await prisma.subscription.update({
    where: { paypal_subscription_id: subscriptionId },
    data: { status: 'suspended' }
  });
  
  // Optionally suspend user account
  const subscription = await prisma.subscription.findUnique({
    where: { paypal_subscription_id: subscriptionId }
  });
  
  if (subscription) {
    await prisma.user.update({
      where: { id: subscription.user_id },
      data: { status: 'suspended' }
    });
  }
}
```

### 3. Webhook Endpoint Validation

```typescript
import axios from 'axios';

// Validate callback URL before accepting job
export async function validateCallbackUrl(url: string): Promise<boolean> {
  try {
    // Parse URL
    const parsedUrl = new URL(url);
    
    // Only allow HTTPS in production
    if (process.env.NODE_ENV === 'production' && parsedUrl.protocol !== 'https:') {
      return false;
    }
    
    // Block localhost/internal IPs in production
    if (process.env.NODE_ENV === 'production') {
      const hostname = parsedUrl.hostname;
      if (
        hostname === 'localhost' ||
        hostname === '127.0.0.1' ||
        hostname.startsWith('192.168.') ||
        hostname.startsWith('10.') ||
        hostname.startsWith('172.')
      ) {
        return false;
      }
    }
    
    // Optional: Send test webhook to verify endpoint
    const testPayload = {
      test: true,
      timestamp: Date.now()
    };
    
    const response = await axios.post(url, testPayload, {
      timeout: 5000,
      validateStatus: (status) => status >= 200 && status < 500
    });
    
    return response.status >= 200 && response.status < 300;
    
  } catch (error) {
    return false;
  }
}

// Use in job submission endpoint
router.post('/jobs', requireApiKey, async (req, res) => {
  const { callback_url } = req.body;
  
  if (callback_url) {
    const isValid = await validateCallbackUrl(callback_url);
    
    if (!isValid) {
      return res.status(400).json({
        error: 'Invalid callback URL',
        details: 'URL must be HTTPS and publicly accessible'
      });
    }
  }
  
  // Continue with job creation...
});
```

### 4. Webhook Retry Logic

```typescript
// Exponential backoff calculation
function calculateBackoffDelay(attemptNumber: number): number {
  const baseDelay = 5000; // 5 seconds
  const maxDelay = 300000; // 5 minutes
  
  const delay = baseDelay * Math.pow(2, attemptNumber - 1);
  return Math.min(delay, maxDelay);
}

// Example retry schedule:
// Attempt 1: Immediate
// Attempt 2: 5 seconds
// Attempt 3: 10 seconds
// Attempt 4: 20 seconds
// Attempt 5: 40 seconds

// Webhook queue configuration (see queue-management skill)
import Queue from 'bull';

export const webhookQueue = new Queue('webhook-deliveries', {
  redis: { host: process.env.REDIS_HOST, port: 6379 },
  defaultJobOptions: {
    attempts: 5,
    backoff: {
      type: 'exponential',
      delay: 5000
    }
  }
});
```

### 5. Webhook Status Endpoint

```typescript
// Check webhook delivery status
router.get('/jobs/:jobId/webhooks', requireApiKey, async (req: AuthRequest, res) => {
  const { jobId } = req.params;
  const userId = req.user!.userId;
  
  // Verify job ownership
  const job = await prisma.translationJob.findFirst({
    where: {
      id: jobId,
      user_id: userId
    },
    include: {
      webhook_deliveries: {
        orderBy: { attempted_at: 'desc' }
      }
    }
  });
  
  if (!job) {
    return res.status(404).json({ error: 'Job not found' });
  }
  
  const deliveries = job.webhook_deliveries.map(delivery => ({
    attempt: delivery.attempt_number,
    success: delivery.success,
    status: delivery.http_status,
    error: delivery.error_message,
    timestamp: delivery.attempted_at
  }));
  
  res.json({
    job_id: jobId,
    callback_url: job.callback_url,
    deliveries
  });
});
```

## Anti-Patterns (Forbidden)

| ❌ Mistake | ✅ Fix |
|-----------|--------|
| Blocking HTTP request while sending webhook | Use queue for async delivery |
| No timeout on webhook requests | Set 30 second timeout |
| Infinite retries | Limit to 5 attempts max |
| Retrying 4xx client errors | Only retry 5xx and network errors |
| No signature verification | Use HMAC-SHA256 signatures |
| Trusting `X-Forwarded-For` header | Verify request origin properly |
| No timestamp validation | Check timestamp within 5 minute window |
| Storing webhook secrets in plain text | Use environment variables |
| No delivery tracking | Log all attempts to database |
| Allowing HTTP callbacks in production | Require HTTPS only |

## Integration with Other Skills

**Often combined with:**
- `queue-management` - Async webhook delivery with retries
- `authentication-security` - HMAC signature generation/verification
- `api-endpoint-creation` - Webhook receiver endpoints
- `error-handling-logging` - Logging failed deliveries

**Depends on:**
- `database-schema-design` - WebhookDelivery, TranslationJob tables

## Environment Variables Required

```bash
# PayPal Configuration
PAYPAL_CLIENT_ID="your-paypal-client-id"
PAYPAL_CLIENT_SECRET="your-paypal-secret"
PAYPAL_WEBHOOK_ID="your-webhook-id"
PAYPAL_WEBHOOK_SECRET="your-webhook-secret"
PAYPAL_MODE="sandbox"  # or "live"

# Webhook Configuration
WEBHOOK_TIMEOUT_MS=30000
WEBHOOK_MAX_RETRIES=5
```

## Quick Reference

### Webhook Headers (Outgoing)

```
Content-Type: application/json
X-Webhook-Signature: {hmac-sha256-hex}
X-Webhook-Timestamp: {unix-timestamp-ms}
User-Agent: TranslatePressZone/1.0
```

### Webhook Payload Format

```json
{
  "job_id": "uuid",
  "client_job_id": "wp_12345",
  "status": "completed",
  "translation": "<p>Translated content</p>",
  "tokens_used": 145,
  "cost_usd": 0.0000725
}
```

### PayPal Event Types

| Event | Description | Action |
|-------|-------------|--------|
| `BILLING.SUBSCRIPTION.CREATED` | Subscription created | Store subscription ID |
| `BILLING.SUBSCRIPTION.ACTIVATED` | Subscription activated | Activate account, allocate credits |
| `PAYMENT.SALE.COMPLETED` | Payment received | Record payment, extend period |
| `BILLING.SUBSCRIPTION.CANCELLED` | Subscription cancelled | Mark for cancellation at period end |
| `BILLING.SUBSCRIPTION.SUSPENDED` | Payment failed | Suspend account |

### HTTP Status Codes

| Code | Meaning | Retry? |
|------|---------|--------|
| 200-299 | Success | No |
| 400-499 | Client error | No (permanent failure) |
| 500-599 | Server error | Yes (temporary failure) |
| Network error | Connection failed | Yes |

## Validation Checklist

Before completing webhook implementation:

- [ ] HMAC-SHA256 signatures generated for outgoing webhooks
- [ ] Signature verification implemented for incoming webhooks
- [ ] Timestamp validation prevents replay attacks (5min window)
- [ ] Webhook timeout set to 30 seconds
- [ ] Exponential backoff configured (5 attempts max)
- [ ] 4xx errors don't trigger retries
- [ ] All delivery attempts logged to database
- [ ] HTTPS required for callback URLs in production
- [ ] Internal IPs blocked for callback URLs
- [ ] PayPal signature verification uses cert validation
- [ ] PayPal webhook secret stored securely
- [ ] Webhook delivery status endpoint implemented
- [ ] Graceful handling of missing callback_url
- [ ] Test mode webhook endpoint available
- [ ] Webhook documentation provided to WordPress plugin

## Security Considerations

### SSRF Prevention
```typescript
// Block internal IPs for callbacks
const blockedHosts = [
  'localhost',
  '127.0.0.1',
  '0.0.0.0',
  '169.254.169.254', // AWS metadata
  '::1',
  'metadata.google.internal' // GCP metadata
];

function isInternalIp(hostname: string): boolean {
  return blockedHosts.includes(hostname) ||
    hostname.startsWith('192.168.') ||
    hostname.startsWith('10.') ||
    hostname.match(/^172\.(1[6-9]|2\d|3[01])\./);
}
```

### Rate Limiting Webhook Endpoints
```typescript
import rateLimit from 'express-rate-limit';

const webhookLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100, // 100 webhooks per minute
  message: 'Too many webhook requests'
});

router.post('/webhooks/paypal', webhookLimiter, verifyPayPalWebhook, handler);
```

## Testing Webhooks

### Development Webhook Testing
```bash
# Use ngrok for local testing
ngrok http 3000

# Test webhook delivery
curl -X POST http://localhost:3000/jobs \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "source_lang": "en",
    "target_lang": "es",
    "content": "Hello",
    "model": "4b",
    "callback_url": "https://your-ngrok-url.ngrok.io/callback"
  }'
```

### Mock PayPal Webhook
```typescript
// Test route (development only)
if (process.env.NODE_ENV === 'development') {
  router.post('/test/paypal-webhook', async (req, res) => {
    const event = {
      event_type: 'PAYMENT.SALE.COMPLETED',
      resource: {
        id: 'test_payment_123',
        amount: { total: '29.00' },
        billing_agreement_id: 'test_subscription_123'
      }
    };
    
    await handlePaymentCompleted(event);
    res.json({ success: true });
  });
}
```
