Webhooks
Implement reliable webhook handling — signature verification, idempotency, async processing, and a complete production webhook receiver.
What you'll be able to do after this module
Build a production-grade webhook handler that verifies signatures, handles duplicate deliveries, and processes events reliably — even when things go wrong.
Why webhooks (not polling)
| Approach | Connections held open | Server cost | Recommended for |
|---|---|---|---|
| Polling every 1s | Many long-lived | High | Quick prototypes |
| Polling every 5s | Moderate | Medium | Dev/testing only |
| Webhooks | None | Near zero | Production |
For any prediction taking more than ~5 seconds — video, audio, high-quality images — webhooks are the production-correct choice.
Registering a webhook
Add a webhook field when creating the prediction:
curl -X POST https://api.skytells.ai/v1/predictions \
-H "x-api-key: $SKYTELLS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "truefusion-video-pro",
"input": {
"prompt": "Ocean waves at sunset, cinematic, 10 seconds",
"duration_seconds": 10
},
"webhook": "https://yourapp.com/api/webhooks/skytells",
"webhook_events_filter": ["completed"]
}'webhook_events_filter options: start, output, logs, completed
For most use cases, ["completed"] is all you need.
Webhook payload
When the prediction finishes, Skytells sends a POST to your URL with the full prediction object:
{
"id": "pred_abc123",
"status": "succeeded",
"model": "truefusion-video-pro",
"output": ["https://cdn.skytells.ai/outputs/pred_abc123/video.mp4"],
"created_at": "2025-01-01T00:00:00Z",
"completed_at": "2025-01-01T00:02:45Z",
"error": null
}For failed predictions, status is "failed" and error contains a description.
Step 1: Verify the signature
Never skip signature verification. Without it, anyone on the internet can send fake webhook events to your endpoint and trigger actions in your app.
Skytells signs every webhook with HMAC-SHA256 using your webhook secret. The signature is in the X-Skytells-Signature header:
X-Skytells-Signature: sha256=<hex-digest>Get your webhook secret from the Skytells Dashboard → API Keys → Webhooks.
import crypto from 'crypto';
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const received = signatureHeader.replace('sha256=', '');
// Constant-time comparison — prevents timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(received, 'hex'),
);
} catch {
return false; // length mismatch
}
}Step 2: Build the webhook handler
Read the raw body (before parsing)
You must compute the signature on the raw bytes — before JSON parsing. If you parse first and re-serialize, the signature won't match.
Verify the signature
Return 401 immediately if verification fails.
Respond with 200 immediately
Return 200 OK before doing any heavy processing. Skytells expects a response within 10 seconds.
Process asynchronously
Handle the actual business logic (database writes, notifications, storage) after responding.
// app/api/webhooks/skytells/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
function verifySignature(rawBody: string, header: string): boolean {
const expected = crypto
.createHmac('sha256', process.env.SKYTELLS_WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
const received = header.replace('sha256=', '');
try {
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
} catch { return false; }
}
export async function POST(req: NextRequest) {
// 1. Read raw body
const rawBody = await req.text();
// 2. Verify signature
const sig = req.headers.get('x-skytells-signature') ?? '';
if (!verifySignature(rawBody, sig)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// 3. Respond immediately
const prediction = JSON.parse(rawBody);
const response = NextResponse.json({ received: true });
// 4. Process async
void processPrediction(prediction);
return response;
}
async function processPrediction(prediction: any) {
if (prediction.status === 'succeeded') {
// Download and persist outputs, notify users, update DB...
console.log('Prediction succeeded:', prediction.id, prediction.output);
} else if (prediction.status === 'failed') {
console.error('Prediction failed:', prediction.id, prediction.error);
}
}Step 3: Make your handler idempotent
Skytells retries failed webhook deliveries. Your handler will receive the same event more than once. Design for it:
async function processPrediction(prediction: Prediction) {
// Check if already processed
const existing = await db.generations.findUnique({
where: { predictionId: prediction.id },
});
if (existing?.processedAt) {
console.log(`Skipping duplicate: ${prediction.id}`);
return; // Idempotent — safe to ignore
}
if (prediction.status === 'succeeded') {
// 1. Download from CDN (expires in 24h)
const imageBuffer = await fetch(prediction.output![0]).then(r => r.arrayBuffer());
// 2. Upload to your permanent storage
const permanentUrl = await uploadToStorage(imageBuffer, `${prediction.id}.png`);
// 3. Update DB with permanent URL
await db.generations.update({
where: { predictionId: prediction.id },
data: {
status: 'succeeded',
outputUrl: permanentUrl,
processedAt: new Date(),
},
});
// 4. Notify user
await notifyUser(prediction.id);
}
}CDN URLs expire after 24 hours. Always download the output in your webhook handler and upload it to your own persistent storage. Do not save only the Skytells CDN URL in your database.
Testing webhooks locally
Use a tunneling tool so Skytells can reach your local server:
ngrok http 3000
# Use the https URL as your webhook endpoint
# e.g., https://abc123.ngrok.io/api/webhooks/skytellsYou can also test the handler directly with a crafted curl:
# Generate a valid test signature
SECRET="your-webhook-secret"
PAYLOAD='{"id":"pred_test","status":"succeeded","output":["https://cdn.skytells.ai/test.png"]}'
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/api/webhooks/skytells \
-H "Content-Type: application/json" \
-H "x-skytells-signature: sha256=$SIG" \
-d "$PAYLOAD"Retry behavior
Skytells retries failed deliveries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 5 minutes |
| 5+ | Up to 24 hours, then abandoned |
A delivery is considered failed if your server returns a non-2xx status or doesn't respond within 10 seconds. Your handler must return 200 quickly — do the heavy work async.
Summary
Your webhook handler is production-ready. It verifies signatures, handles duplicates, and processes events reliably.
The four rules of production webhooks:
- Verify the signature —
timingSafeEqualon every request, no exceptions - Respond 200 immediately — heavy work goes in a background task
- Make it idempotent — check a
processedAtflag before acting - Download CDN outputs — Skytells URLs expire in 24 hours
Authentication & API Keys
Manage Skytells API keys securely across dev, staging, and production — environment separation, rotation strategy, and server-side-only patterns.
Rate Limits & Error Handling
Handle 429s gracefully, build a prediction queue, write user-friendly error messages, and set budget guardrails — for a resilient production app.