Webhook Configuration & Events
Webhooks let you build real-time integrations with BlackOps Center. Get notified when content is published, users subscribe, payments process, or RSS items are scored—and trigger custom workflows in response.
What Are Webhooks?
A webhook is a server-side HTTP callback. When something happens in BlackOps Center (an "event"), we send a POST request to your specified URL with event data. Your server receives it and does whatever you want—update a CRM, trigger a Slack notification, sync to another system, etc.
Webhooks vs. Polling
❌ Polling (Inefficient)
Every 5 minutes: "Any new posts?" "No" "Any new posts?" "No" "Any new posts?" "Yes! Here's data"
✅ Webhooks (Efficient)
[silence] [silence] [POST] "New post published" [silence]
Available Webhooks
1. Stripe Payment Webhooks
Endpoint: /api/webhooks/stripe
Events:
customer.subscription.created
Fired when a new subscription starts (including trials).
{
"type": "customer.subscription.created",
"data": {
"object": {
"id": "sub_xxx",
"customer": "cus_xxx",
"status": "active",
"items": {
"data": [
{
"price": {
"id": "price_base",
"unit_amount": 29700
},
"quantity": 1
}
]
},
"trial_end": 1738368000,
"current_period_start": 1735776000,
"current_period_end": 1738368000
}
}
}What We Do:
- Create subscription record in database
- Calculate site count (base + additional sites)
- Initialize monthly credits for user
- Send welcome notification (if configured)
customer.subscription.updated
Fired when subscription changes (upgrade, add sites, billing renewal).
What We Do:
- Update subscription status and site count
- Reset monthly credits on billing period renewal
- Handle tier upgrades/downgrades
- Deactivate trial if converted to paid
customer.subscription.deleted
Fired when subscription is canceled.
What We Do:
- Mark subscription as canceled
- Send cancellation notification to platform admin
- Retain data (sites go read-only, not deleted)
invoice.payment_succeeded
Fired when payment processes successfully.
What We Do:
- Log successful payment
- Update subscription status if needed
- Optional: Send receipt email
invoice.payment_failed
Fired when payment fails.
What We Do:
- Send payment failure notification to user
- Alert platform admin (for follow-up)
- Update subscription status
checkout.session.completed
Fired when one-time purchase completes (credit packs).
What We Do:
- Add purchased credits to user account
- Record transaction in credit history
- Send purchase confirmation
2. Resend Email Webhooks
Endpoint: /api/webhooks/resend/inbound
email.received
Fired when email is sent to a reservoir's unique address (e.g., react-tips@blackopscenter.com).
{
"type": "email.received",
"data": {
"email_id": "email_xxx",
"from": "Ben Newton <ben@example.com>",
"to": ["react-tips@blackopscenter.com"],
"subject": "Great article on useMemo",
"text": "Check this out: https://...",
"html": "<p>Check this out...</p>",
"received_at": "2025-02-01T10:30:00Z"
}
}What We Do:
- Validate reservoir email address
- Verify webhook signature (Svix)
- Fetch full email content from Resend API
- Extract text/HTML and metadata
- Create reservoir item from email content
- Track in
reservoir_inbound_emailstable - Prevent duplicate imports via
deleted_contenttracking
Endpoint: /api/webhooks/resend/delivery
email.delivered
Fired when newsletter email is successfully delivered.
email.bounced
Fired when email bounces (invalid address, full inbox, etc.).
What We Do:
- Update subscriber status (bounced)
- Track bounce reasons
- Auto-unsubscribe after hard bounce
email.opened
Fired when recipient opens newsletter email.
What We Do:
- Track open event
- Update subscriber engagement score
- Record timestamp for analytics
email.clicked
Fired when recipient clicks link in newsletter.
What We Do:
- Track click event
- Record which link was clicked
- Update engagement metrics
Setting Up Webhooks
Stripe Webhooks (Required for Billing)
Local Development with Stripe CLI
- Install Stripe CLI:
# macOS brew install stripe/stripe-cli/stripe # Or download from stripe.com/docs/stripe-cli
- Login to Stripe:
stripe login
- Forward webhooks to local dev:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
- Copy webhook signing secret from CLI output:
Ready! Your webhook signing secret is whsec_xxx
- Add to
.env.local:STRIPE_WEBHOOK_SECRET=whsec_xxx
- Restart dev server
- Test:
stripe trigger customer.subscription.created
Production Setup
- Go to Stripe Dashboard → Webhooks
- Click "Add endpoint"
- Enter endpoint URL:
https://blackopscenter.com/api/webhooks/stripe
- Select events:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failedcheckout.session.completed
- Click "Add endpoint"
- Reveal signing secret and copy it
- Add to production environment variables:
STRIPE_WEBHOOK_SECRET=whsec_production_xxx
Resend Inbound Email Webhooks
Setup Steps
- Go to Resend Dashboard → Webhooks
- Click "Create Webhook"
- Enter endpoint URL:
https://blackopscenter.com/api/webhooks/resend/inbound
- Select event:
email.received - Save webhook
- Copy signing secret (Resend uses Svix)
- Add to environment:
RESEND_WEBHOOK_SECRET=whsec_xxx
Configure Inbound Email Domain
- In Resend, go to Domains → Inbound
- Add MX records to DNS:
Priority: 10 Host: @ Value: inbound.resend.com
- Verify domain
- Create catch-all forwarding rule in Resend (optional)
Webhook Security
Signature Verification
Always verify webhook signatures to prevent spoofing:
// Stripe signature verification
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
)
// Process event...
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
}Rate Limiting
Resend inbound webhook includes rate limiting:
// Rate limiting (in-memory for serverless)
const rateLimitMap = new Map()
const MAX_REQUESTS = 10 // per minute per reservoir
function checkRateLimit(identifier: string): boolean {
const now = Date.now()
const record = rateLimitMap.get(identifier)
if (!record || now > record.resetTime) {
rateLimitMap.set(identifier, {
count: 1,
resetTime: now + 60000
})
return true
}
if (record.count >= MAX_REQUESTS) {
return false // Rate limit exceeded
}
record.count++
return true
}Payload Size Limits
Protect against large payloads:
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
const contentLength = request.headers.get('content-length')
if (contentLength && parseInt(contentLength) > MAX_SIZE) {
return new Response('Payload too large', { status: 413 })
}Webhook Best Practices
1. Respond Quickly
Webhook providers expect 2xx response within 10-30 seconds:
// ✅ Good: Acknowledge immediately, process async
export async function POST(request: Request) {
const event = await parseWebhook(request)
// Queue for background processing
await queueJob('process-webhook', { event })
return new Response('Accepted', { status: 202 })
}
// ❌ Bad: Process inline (timeout risk)
export async function POST(request: Request) {
const event = await parseWebhook(request)
await processEvent(event) // Takes 45 seconds
return new Response('OK', { status: 200 })
}2. Handle Duplicates
Webhooks may be sent multiple times. Use idempotency:
// Check for duplicate by event ID
const { data: existing } = await supabase
.from('processed_webhooks')
.select('id')
.eq('event_id', event.id)
.single()
if (existing) {
return new Response('Already processed', { status: 200 })
}
// Process event...
// Mark as processed
await supabase
.from('processed_webhooks')
.insert({ event_id: event.id, processed_at: new Date() })3. Log Everything
Comprehensive logging helps debugging:
console.log('[Webhook]', {
type: event.type,
id: event.id,
timestamp: new Date().toISOString(),
source: 'stripe',
customer: event.data.object.customer
})
// On error
console.error('[Webhook Error]', {
type: event.type,
error: error.message,
stack: error.stack
})4. Graceful Degradation
Don't fail the entire app if webhook processing fails:
try {
await processWebhook(event)
} catch (error) {
// Log error
console.error('Webhook processing failed:', error)
// Store for manual review
await supabase
.from('failed_webhooks')
.insert({
event_id: event.id,
event_type: event.type,
error_message: error.message,
payload: event
})
// Still return 200 to prevent retries
return new Response('Logged for review', { status: 200 })
}Testing Webhooks
Local Testing
Use tools like ngrok or Stripe CLI to test locally:
# Option 1: Stripe CLI (recommended) stripe listen --forward-to localhost:3000/api/webhooks/stripe # Option 2: ngrok ngrok http 3000 # Use ngrok URL in webhook configuration
Test Events
Trigger test events without real transactions:
stripe trigger customer.subscription.created stripe trigger invoice.payment_failed stripe trigger checkout.session.completed
Manual Testing with cURL
curl -X POST http://localhost:3000/api/webhooks/stripe \
-H "Content-Type: application/json" \
-H "stripe-signature: t=xxx,v1=xxx" \
-d '{
"type": "customer.subscription.created",
"data": {
"object": {
"id": "sub_test123",
"customer": "cus_test123",
"status": "active"
}
}
}'Monitoring Webhooks
Stripe Dashboard
- Go to Stripe → Webhooks
- Click on your endpoint
- View:
- Recent Events: Success/failure status
- Response Times: How fast you respond
- Retry Attempts: Failed deliveries
Application Logs
Monitor webhook processing in your application logs:
// Example log output [Webhook] stripe/customer.subscription.created | sub_xxx | 152ms | success [Webhook] stripe/invoice.payment_succeeded | in_xxx | 87ms | success [Webhook] resend/email.received | email_xxx | 1234ms | success [Webhook Error] stripe/checkout.session.completed | Timeout | retry_queued
Alerting
Set up alerts for webhook failures:
- High Failure Rate: >5% of webhooks fail
- Slow Response: Response time >10 seconds
- Signature Errors: Invalid signatures detected
Troubleshooting
Webhook Not Receiving Events
Causes:
- Wrong endpoint URL
- Firewall blocking requests
- Endpoint not publicly accessible
Fixes:
- Verify URL is correct and publicly accessible
- Check server logs for incoming requests
- Test with
curlfrom external server - Check firewall/security group rules
"Invalid Signature" Errors
Causes:
- Wrong webhook secret in environment
- Body modified before verification
- Using wrong secret (test vs. prod)
Fixes:
- Verify
STRIPE_WEBHOOK_SECRETmatches dashboard - Don't parse body as JSON before verification (use raw body)
- Check you're using the correct secret for environment
Webhooks Timing Out
Causes:
- Processing takes too long
- External API calls block response
- Database queries slow
Fixes:
- Return 202 immediately, process async
- Use background job queue
- Optimize database queries
- Set reasonable timeout on external calls
Custom Webhooks (Coming Soon)
In future releases, you'll be able to configure custom webhooks for BlackOps Center events:
post.published: New blog post goes livepost.updated: Existing post editedsubscriber.added: New newsletter signupreservoir.item_added: New content saved to reservoircampaign.completed: Content campaign finishes
Configuration will be available in Settings → Webhooks.
Next Steps
- Building Integrations: Use webhooks in custom integrations
- API Documentation: Combine webhooks with API calls
- Architecture Overview: Understand the system
Webhooks turn BlackOps Center from a standalone tool into the central hub of your content ecosystem.