> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nouvel.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time notifications when video generation jobs complete

Webhooks let you receive HTTP notifications when video generation jobs reach a terminal state, eliminating the need to poll the [jobs endpoint](/api-reference/jobs). When a job completes, fails, or partially completes, Nouvel sends a `POST` request to your registered URL with the job details.

## How It Works

<Steps>
  <Step title="Register a Webhook URL">
    Include a `webhookUrl` in your [POST /api/v1/generate](/api-reference/generate) request body. The URL is stored with the job and will receive a notification when the job finishes.
  </Step>

  <Step title="Job Runs">
    Video generation proceeds as normal. You can still poll the jobs endpoint if desired — webhooks and polling are not mutually exclusive.
  </Step>

  <Step title="Receive Notification">
    When all projects in the job reach a terminal state (`completed`, `partial`, or `failed`), Nouvel sends a `POST` to your webhook URL with the full job details.
  </Step>
</Steps>

## Registration

Register a webhook by including `webhookUrl` in your generate request:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://app.nouvel.ai/api/v1/generate \
    -H "Authorization: Bearer nvl_xxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "urls": ["https://example.com/products/protein-powder"],
      "variantCount": 2,
      "webhookUrl": "https://your-server.com/webhooks/nouvel"
    }'
  ```

  ```typescript TypeScript theme={null}
  const response = await fetch('https://app.nouvel.ai/api/v1/generate', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.NOUVEL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      urls: ['https://example.com/products/protein-powder'],
      variantCount: 2,
      webhookUrl: 'https://your-server.com/webhooks/nouvel',
    }),
  });
  ```
</CodeGroup>

## URL Requirements

Your webhook URL must meet the following requirements:

| Requirement             | Details                                                 |
| ----------------------- | ------------------------------------------------------- |
| **Protocol**            | HTTPS only (HTTP is rejected)                           |
| **No localhost**        | `localhost`, `127.0.0.1`, `0.0.0.0`, `::1` are rejected |
| **No private IPs**      | `10.*`, `192.168.*`, `172.16-31.*` ranges are rejected  |
| **Publicly accessible** | Your server must be reachable from the public internet  |

<Warning>
  If the webhook URL fails validation, the generate request returns a `400` error and no job is created.
</Warning>

## Webhook Payload

When a job reaches a terminal state, Nouvel sends the following `POST` request to your URL:

```json theme={null}
POST https://your-server.com/webhooks/nouvel
Content-Type: application/json
X-Nouvel-Signature: a1b2c3d4e5f6...

{
  "event": "job.completed",
  "jobId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "completed",
  "totalProjects": 2,
  "completedProjects": 2,
  "failedProjects": 0,
  "projects": [
    {
      "projectId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "title": "Protein Powder Ad - Variant 1",
      "status": "completed",
      "videoUrl": "https://app.nouvel.ai/api/cdn/videos/final-xxx.mp4"
    },
    {
      "projectId": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
      "title": "Protein Powder Ad - Variant 2",
      "status": "completed",
      "videoUrl": "https://app.nouvel.ai/api/cdn/videos/final-yyy.mp4"
    }
  ]
}
```

### Payload Fields

<ResponseField name="event" type="string" required>
  Event type. Currently always `job.completed` (covers completed, partial, and failed terminal states).
</ResponseField>

<ResponseField name="jobId" type="string" required>
  UUID of the generation job.
</ResponseField>

<ResponseField name="status" type="string" required>
  Terminal job status. One of:

  * `completed` — all projects finished successfully
  * `partial` — some projects completed, some failed
  * `failed` — all projects failed
</ResponseField>

<ResponseField name="totalProjects" type="integer" required>
  Total number of projects in the job.
</ResponseField>

<ResponseField name="completedProjects" type="integer" required>
  Number of projects that completed successfully.
</ResponseField>

<ResponseField name="failedProjects" type="integer" required>
  Number of projects that failed.
</ResponseField>

<ResponseField name="projects" type="array" required>
  Details for each project in the job.

  <Expandable title="Project object">
    <ResponseField name="projectId" type="string" required>
      UUID of the project.
    </ResponseField>

    <ResponseField name="title" type="string" required>
      Generated title for the video.
    </ResponseField>

    <ResponseField name="status" type="string" required>
      Project status (`completed` or `failed`).
    </ResponseField>

    <ResponseField name="videoUrl" type="string">
      URL to the final video file. Only present when `status` is `completed`.
    </ResponseField>
  </Expandable>
</ResponseField>

## Signature Verification

Every webhook request includes an `X-Nouvel-Signature` header containing an HMAC-SHA256 signature of the request body. **Always verify this signature** to ensure the request is genuinely from Nouvel.

<CodeGroup>
  ```typescript TypeScript / Node.js theme={null}
  import crypto from 'crypto';

  function verifyWebhookSignature(
    body: string,
    signature: string,
    secret: string
  ): boolean {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(body)
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }

  // In your webhook handler:
  app.post('/webhooks/nouvel', (req, res) => {
    const signature = req.headers['x-nouvel-signature'] as string;
    const rawBody = JSON.stringify(req.body);

    if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Process the webhook...
    const { event, jobId, status, projects } = req.body;
    console.log(`Job ${jobId}: ${status} (${projects.length} projects)`);

    res.status(200).json({ received: true });
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode(),
          body,
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(signature, expected)

  @app.route('/webhooks/nouvel', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('X-Nouvel-Signature', '')

      if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
          return jsonify({'error': 'Invalid signature'}), 401

      data = request.json
      print(f"Job {data['jobId']}: {data['status']}")

      return jsonify({'received': True}), 200
  ```
</CodeGroup>

<Warning>
  The signing secret is the `WEBHOOK_SIGNING_SECRET` environment variable configured on the Nouvel server. Contact [support@nouvel.ai](mailto:support@nouvel.ai) to obtain your webhook signing secret for signature verification.
</Warning>

## Delivery Behavior

| Property           | Details                                                             |
| ------------------ | ------------------------------------------------------------------- |
| **Method**         | `POST`                                                              |
| **Content-Type**   | `application/json`                                                  |
| **Timeout**        | 10 seconds per attempt — your server must respond within 10 seconds |
| **Retries**        | 3 attempts with exponential backoff (immediate → 10s → 60s)         |
| **Attempt header** | `X-Nouvel-Attempt` — indicates which attempt number (1, 2, or 3)    |
| **Rate limits**    | Webhook deliveries do **not** count toward your API rate limit      |

### Retry Logic

If your endpoint is temporarily unreachable or returns a server error (5xx), Nouvel retries with exponential backoff:

| Attempt | Delay      | Total elapsed |
| ------- | ---------- | ------------- |
| 1       | Immediate  | 0s            |
| 2       | 10 seconds | \~10s         |
| 3       | 60 seconds | \~70s         |

**Non-retryable errors:** HTTP 4xx responses (except 429 Too Many Requests) are treated as permanent failures and are NOT retried. Ensure your endpoint returns `200` on success.

<Note>
  While retries cover most transient failures, we still recommend periodic polling of the [jobs endpoint](/api-reference/jobs) as a safety net for mission-critical workflows.
</Note>

## Timing

The webhook fires when the [job status endpoint](/api-reference/jobs) detects that a job has transitioned to a terminal state. This happens during a status poll — not at the exact moment of completion.

In practice, this means:

* If you're polling the job status endpoint, the webhook fires during one of your polls
* If you're not polling, the webhook fires when any poll (including internal monitoring) checks the job

<Tip>
  For the fastest notification, combine webhooks with periodic polling. The webhook will typically arrive within seconds of job completion if you're actively polling.
</Tip>

## Best Practices

<AccordionGroup>
  <Accordion title="Respond quickly">
    Your webhook endpoint should return a `200` status code within 10 seconds. If you need to do heavy processing, accept the webhook immediately and process asynchronously.

    ```typescript theme={null}
    app.post('/webhooks/nouvel', async (req, res) => {
      // Respond immediately
      res.status(200).json({ received: true });

      // Process asynchronously
      processWebhook(req.body).catch(console.error);
    });
    ```
  </Accordion>

  <Accordion title="Always verify signatures">
    Never trust webhook payloads without verifying the `X-Nouvel-Signature` header. This prevents attackers from sending fake webhook events to your endpoint.
  </Accordion>

  <Accordion title="Handle idempotency">
    Design your webhook handler to be idempotent. Since webhooks retry up to 3 times, your endpoint may receive the same event multiple times. Use the `jobId` as a deduplication key.
  </Accordion>

  <Accordion title="Use polling as fallback">
    While webhooks now retry up to 3 times, implement periodic polling of the jobs endpoint as a safety net for mission-critical workflows. Check for completed jobs that may have been missed if all retry attempts fail.
  </Accordion>
</AccordionGroup>

## Example: Full Integration

Here's a complete example combining webhook registration with a handler:

<CodeGroup>
  ```typescript TypeScript theme={null}
  // 1. Submit generation with webhook
  const job = await fetch('https://app.nouvel.ai/api/v1/generate', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.NOUVEL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      urls: ['https://example.com/products/protein-powder'],
      variantCount: 2,
      webhookUrl: 'https://your-server.com/webhooks/nouvel',
    }),
  }).then(r => r.json());

  console.log(`Job ${job.jobId} started. Waiting for webhook...`);

  // 2. Handle webhook (Express server)
  import express from 'express';
  import crypto from 'crypto';

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

  app.post('/webhooks/nouvel', (req, res) => {
    // Verify signature
    const sig = req.headers['x-nouvel-signature'] as string;
    const expected = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET!)
      .update(JSON.stringify(req.body)).digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).send('Invalid signature');
    }

    const { jobId, status, projects } = req.body;

    // Process completed videos
    for (const project of projects) {
      if (project.status === 'completed') {
        console.log(`Video ready: ${project.videoUrl}`);
        // Download, publish, notify your users, etc.
      }
    }

    res.status(200).json({ received: true });
  });

  app.listen(3000);
  ```
</CodeGroup>
