Advanced45 minModule 2 of 4

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)

POST prompt POST /v1/predictions + webhook URL 202 Accepted prediction_id processing complete succeeded failed notify client Client Your API Skytells Status POST to your webhook URL
ApproachConnections held openServer costRecommended for
Polling every 1sMany long-livedHighQuick prototypes
Polling every 5sModerateMediumDev/testing only
WebhooksNoneNear zeroProduction

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

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);
  }
}

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/skytells

You 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:

AttemptDelay
1Immediate
25 seconds
330 seconds
45 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

The four rules of production webhooks:

  1. Verify the signaturetimingSafeEqual on every request, no exceptions
  2. Respond 200 immediately — heavy work goes in a background task
  3. Make it idempotent — check a processedAt flag before acting
  4. Download CDN outputs — Skytells URLs expire in 24 hours

On this page