Skip to main content

Overview

Webhooks allow you to receive real-time HTTP notifications when events occur in your Percify account. Instead of polling the API for status updates, webhooks push updates to your server automatically.

Benefits

Real-Time Updates

Get notified instantly when events occur

Reduced API Calls

No need to poll for status updates

Efficient

Save credits and reduce latency

Setting Up Webhooks

1. Create an Endpoint

Create an HTTPS endpoint on your server to receive webhook events:
const express = require('express');
const app = express();

app.use(express.json());

app.post('/webhooks/percify', async (req, res) => {
  const event = req.body;
  
  console.log('Received event:', event.type);
  
  // Process event
  switch (event.type) {
    case 'avatar.completed':
      await handleAvatarCompleted(event.data);
      break;
    case 'video.completed':
      await handleVideoCompleted(event.data);
      break;
    // Handle other events...
  }
  
  // Acknowledge receipt
  res.sendStatus(200);
});

app.listen(3000);

2. Register Webhook URL

Register your endpoint in the Percify dashboard:
1

Navigate to Settings

Go to Dashboard → Settings → Webhooks
2

Add Endpoint

Click “Add Endpoint” and enter your webhook URLRequirements:
  • Must be HTTPS (required for production)
  • Must respond within 10 seconds
  • Should return 2xx status code
3

Select Events

Choose which events to receive:
  • All events (recommended for development)
  • Specific event types only
4

Save and Test

Save your webhook and click “Send Test Event” to verify

3. Verify Webhook Signatures

Always verify webhook signatures to ensure requests are from Percify:
const crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
    
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

app.post('/webhooks/percify', (req, res) => {
  const signature = req.headers['x-percify-signature'];
  const secret = process.env.PERCIFY_WEBHOOK_SECRET;
  
  if (!verifyWebhook(JSON.stringify(req.body), signature, secret)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process event...
  res.sendStatus(200);
});

Event Types

Avatar Events

avatar.completed

Fired when avatar generation completes successfully.
{
  "type": "avatar.completed",
  "id": "evt_abc123",
  "created": "2025-11-25T06:40:00Z",
  "data": {
    "avatarId": "avatar_xyz789",
    "userId": "user_abc123",
    "status": "completed",
    "imageUrl": "https://cdn.percify.io/avatars/avatar_xyz789.png",
    "thumbnailUrl": "https://cdn.percify.io/avatars/avatar_xyz789_thumb.png",
    "creditCost": 5,
    "model": "imagen3",
    "prompt": "mystical forest guardian"
  }
}

avatar.failed

Fired when avatar generation fails.
{
  "type": "avatar.failed",
  "id": "evt_def456",
  "created": "2025-11-25T06:41:00Z",
  "data": {
    "avatarId": "avatar_xyz789",
    "userId": "user_abc123",
    "status": "failed",
    "error": {
      "code": "content_policy_violation",
      "message": "Prompt violates content policy"
    }
  }
}

avatar.published

Fired when an avatar is published to the community feed.
{
  "type": "avatar.published",
  "id": "evt_ghi789",
  "created": "2025-11-25T06:42:00Z",
  "data": {
    "avatarId": "avatar_xyz789",
    "userId": "user_abc123",
    "visibility": "public",
    "publishedAt": "2025-11-25T06:42:00Z"
  }
}

Video Events

video.completed

Fired when video generation completes.
{
  "type": "video.completed",
  "id": "evt_jkl012",
  "created": "2025-11-25T06:45:00Z",
  "data": {
    "videoId": "video_abc123",
    "userId": "user_xyz789",
    "avatarId": "avatar_def456",
    "status": "completed",
    "videoUrl": "https://cdn.percify.io/videos/video_abc123.mp4",
    "thumbnailUrl": "https://cdn.percify.io/videos/video_abc123_thumb.jpg",
    "durationSeconds": 8,
    "creditCost": 48,
    "resolution": "720p"
  }
}

video.failed

Fired when video generation fails.
{
  "type": "video.failed",
  "id": "evt_mno345",
  "created": "2025-11-25T06:46:00Z",
  "data": {
    "videoId": "video_abc123",
    "userId": "user_xyz789",
    "status": "failed",
    "error": {
      "code": "processing_error",
      "message": "Video generation failed due to server error"
    }
  }
}

Audio Events

audio.completed

Fired when audio generation completes.
{
  "type": "audio.completed",
  "id": "evt_pqr678",
  "created": "2025-11-25T06:50:00Z",
  "data": {
    "audioId": "audio_xyz123",
    "userId": "user_abc456",
    "voiceId": "voice_def789",
    "status": "completed",
    "audioUrl": "https://cdn.percify.io/audio/audio_xyz123.mp3",
    "durationSeconds": 5.2,
    "creditCost": 10
  }
}

voice.cloned

Fired when voice cloning completes.
{
  "type": "voice.cloned",
  "id": "evt_stu901",
  "created": "2025-11-25T06:52:00Z",
  "data": {
    "voiceId": "voice_abc123",
    "userId": "user_xyz789",
    "name": "My Custom Voice",
    "language": "en-US",
    "status": "completed",
    "creditCost": 5
  }
}

Credit Events

credits.low_balance

Fired when credit balance drops below threshold.
{
  "type": "credits.low_balance",
  "id": "evt_vwx234",
  "created": "2025-11-25T06:55:00Z",
  "data": {
    "userId": "user_abc123",
    "balance": 25,
    "threshold": 50,
    "recommendation": "purchase_pack_500"
  }
}

credits.purchased

Fired when credits are purchased.
{
  "type": "credits.purchased",
  "id": "evt_yz567",
  "created": "2025-11-25T06:58:00Z",
  "data": {
    "userId": "user_abc123",
    "purchaseId": "purchase_xyz789",
    "amount": 500,
    "bonusAmount": 50,
    "newBalance": 575,
    "price": 49.99,
    "currency": "USD"
  }
}

User Events

tier.upgraded

Fired when user upgrades subscription tier.
{
  "type": "tier.upgraded",
  "id": "evt_abc890",
  "created": "2025-11-25T07:00:00Z",
  "data": {
    "userId": "user_abc123",
    "previousTier": "free",
    "newTier": "pro",
    "effectiveDate": "2025-11-25T07:00:00Z"
  }
}

Implementing Event Handlers

Complete Example

const express = require('express');
const { verifyWebhook } = require('./utils/webhook');
const { processAvatar, processVideo, notifyUser } = require('./handlers');

const app = express();
app.use(express.json());

const eventHandlers = {
  'avatar.completed': async (data) => {
    console.log(`Avatar ${data.avatarId} completed`);
    
    // Save to database
    await db.avatars.update(data.avatarId, {
      status: 'completed',
      imageUrl: data.imageUrl
    });
    
    // Notify user
    await notifyUser(data.userId, {
      type: 'avatar_ready',
      avatarId: data.avatarId
    });
    
    // Start video generation if configured
    if (shouldGenerateVideo(data.userId)) {
      await startVideoGeneration(data.avatarId);
    }
  },
  
  'video.completed': async (data) => {
    console.log(`Video ${data.videoId} completed`);
    
    // Update database
    await db.videos.update(data.videoId, {
      status: 'completed',
      videoUrl: data.videoUrl
    });
    
    // Send email notification
    await sendEmail(data.userId, {
      subject: 'Your video is ready!',
      videoUrl: data.videoUrl
    });
  },
  
  'credits.low_balance': async (data) => {
    console.log(`User ${data.userId} has low credits: ${data.balance}`);
    
    // Send push notification
    await sendPushNotification(data.userId, {
      title: 'Running low on credits',
      body: `You have ${data.balance} credits remaining.`,
      action: 'purchase_credits'
    });
  },
  
  'avatar.failed': async (data) => {
    console.error(`Avatar ${data.avatarId} failed:`, data.error);
    
    // Log error
    await logError(data.avatarId, data.error);
    
    // Refund credits if appropriate
    if (shouldRefund(data.error.code)) {
      await refundCredits(data.userId, data.creditCost);
    }
    
    // Notify user
    await notifyUser(data.userId, {
      type: 'avatar_failed',
      avatarId: data.avatarId,
      error: data.error.message
    });
  }
};

app.post('/webhooks/percify', async (req, res) => {
  const signature = req.headers['x-percify-signature'];
  const secret = process.env.PERCIFY_WEBHOOK_SECRET;
  
  // Verify signature
  if (!verifyWebhook(JSON.stringify(req.body), signature, secret)) {
    return res.status(401).send('Invalid signature');
  }
  
  const event = req.body;
  
  // Handle event
  const handler = eventHandlers[event.type];
  if (handler) {
    try {
      await handler(event.data);
    } catch (error) {
      console.error(`Error handling ${event.type}:`, error);
      // Still return 200 to avoid retries for application errors
    }
  } else {
    console.log(`Unhandled event type: ${event.type}`);
  }
  
  res.sendStatus(200);
});

app.listen(3000);

Best Practices

Never process webhooks without signature verification. This prevents replay attacks and ensures authenticity.
if (!verifySignature(payload, signature, secret)) {
  return res.status(401).send('Invalid signature');
}
Acknowledge receipt within 10 seconds. Process heavy tasks asynchronously.
app.post('/webhooks/percify', async (req, res) => {
  // Acknowledge immediately
  res.sendStatus(200);
  
  // Process asynchronously
  processWebhookAsync(req.body).catch(console.error);
});
Percify retries failed webhooks with exponential backoff. Make your handlers idempotent.
async function handleAvatarCompleted(data) {
  // Check if already processed
  const existing = await db.find(data.avatarId);
  if (existing?.processed) {
    return; // Skip duplicate
  }
  
  // Process and mark as complete
  await processAvatar(data);
  await db.markProcessed(data.avatarId);
}
const queue = new Queue('webhooks');

app.post('/webhooks/percify', async (req, res) => {
  // Add to queue
  await queue.add(req.body);
  
  // Respond immediately
  res.sendStatus(200);
});

// Process queue asynchronously
queue.process(async (job) => {
  await processEvent(job.data);
});
Keep detailed logs for debugging:
await logWebhook({
  eventId: event.id,
  eventType: event.type,
  receivedAt: new Date(),
  payload: event.data,
  processed: true
});

Testing Webhooks

Local Development with ngrok

# Install ngrok
npm install -g ngrok

# Start your server
node server.js

# Create tunnel in another terminal
ngrok http 3000

# Use the HTTPS URL in Percify dashboard
# https://abc123.ngrok.io/webhooks/percify

Test Events

Send test events from the dashboard or via CLI:
curl -X POST https://your-domain.com/webhooks/percify \
  -H "Content-Type: application/json" \
  -H "X-Percify-Signature: test_signature" \
  -d '{
    "type": "avatar.completed",
    "id": "evt_test123",
    "created": "2025-11-25T07:00:00Z",
    "data": {
      "avatarId": "avatar_test",
      "status": "completed"
    }
  }'

Troubleshooting

IssueCauseSolution
Webhooks not receivedURL not HTTPSUse HTTPS for production
401 Invalid signatureWrong secret or verification logicCheck webhook secret in dashboard
Timeout errorsHandler takes too longRespond within 10s, process async
Duplicate eventsRetry logicImplement idempotent handlers
Events out of orderNetwork delaysDon’t rely on event order

Security Checklist

  • Always verify webhook signatures
  • Use HTTPS endpoints only
  • Respond within 10 seconds
  • Store webhook secrets securely
  • Implement idempotent handlers
  • Log all webhook events
  • Monitor for suspicious activity
  • Rate limit webhook endpoint
  • Validate event data structure
  • Handle errors gracefully

Next Steps