One prompt. A finished book.

Open Nellie Sign in

Webhooks

Receive real-time notifications when your book generation jobs complete.

Overview

Instead of polling the status endpoint, you can provide a webhook_url when creating a book. Nellie will send a POST request to your URL when the job completes (successfully or with an error).

Benefits:

  • No polling required
  • Instant notification on completion
  • Reduced API calls
  • Event-driven architecture support

Quick Start

1. Set Up Your Endpoint

Create a publicly accessible HTTPS endpoint to receive webhooks:

# Flask example
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks/nellie', methods=['POST'])
def handle_webhook():
    data = request.json
    
    if data['status'] == 'completed':
        print(f"Book ready: {data['resultUrl']}")
        # Download and process the book
    else:
        print(f"Book failed: {data.get('error')}")
    
    return jsonify({'received': True}), 200

2. Include webhook_url in Your Request

from nellie_api import Nellie

client = Nellie(api_key="nel_...")

book = client.books.create(
    prompt="A mystery novel",
    webhook_url="https://your-site.com/webhooks/nellie"
)

# No need to poll - you'll receive a webhook when done
print(f"Job started: {book.request_id}")

3. Handle the Webhook

When the job completes, Nellie POSTs to your URL with the result.

Webhook Payload

Success Payload

{
  "requestId": "abcd1234-ef56-7890-abcd-12ef34567890",
  "status": "completed",
  "completedAt": "2025-12-03T10:55:00Z",
  "creditsUsed": 250,
  "resultUrl": "https://api.nelliewriter.com/v1/download/abcd1234-ef56-7890-abcd-12ef34567890"
}

Failure Payload

{
  "requestId": "abcd1234-ef56-7890-abcd-12ef34567890",
  "status": "failed",
  "completedAt": "2025-12-03T10:35:00Z",
  "creditsUsed": 0,
  "resultUrl": null,
  "error": "Insufficient credits"
}

Payload Fields

FieldTypeDescription
requestIdstringThe job identifier
statusstring"completed" or "failed"
completedAtstringISO 8601 completion timestamp
creditsUsedintegerCredits consumed (0 if failed)
resultUrlstring | nullDownload URL (null if failed)
errorstringError description (only if failed)

Signature Verification

Nellie signs all webhook payloads so you can verify they’re authentic. The signature is in the X-Nellie-Signature header.

Getting Your Webhook Secret

  1. Open the Nellie app
  2. Go to Settings → API Management
  3. Select the Webhooks tab
  4. Copy your Webhook Signing Secret

Your secret looks like: whsec_abc123def456...

Signature Format

The X-Nellie-Signature header contains:

t=1701234567,v1=abc123def456...
  • t: Unix timestamp when the signature was created
  • v1: HMAC-SHA256 signature

Verification Algorithm

  1. Parse the timestamp (t) and signature (v1) from the header
  2. Reject if timestamp is too old (>5 minutes)
  3. Concatenate: {timestamp}.{raw_payload}
  4. Compute HMAC-SHA256 using your webhook secret
  5. Compare signatures using constant-time comparison

Python Verification (Manual)

import hmac
import hashlib
import time

def verify_webhook(request, secret):
    # 1. Get the signature header
    sig_header = request.headers.get('X-Nellie-Signature')
    if not sig_header:
        return False
    
    # 2. Parse timestamp and signature
    parts = dict(x.split('=', 1) for x in sig_header.split(','))
    timestamp = parts.get('t')
    signature = parts.get('v1')
    
    if not timestamp or not signature:
        return False
    
    # 3. Reject old signatures (replay protection)
    if abs(time.time() - int(timestamp)) > 300:  # 5 minutes
        return False
    
    # 4. Get the raw payload
    payload = request.get_data(as_text=True)
    
    # 5. Compute expected signature
    signed_content = f"{timestamp}.{payload}"
    expected = hmac.new(
        key=secret.encode(),
        msg=signed_content.encode(),
        digestmod=hashlib.sha256
    ).hexdigest()
    
    # 6. Compare securely
    return hmac.compare_digest(expected, signature)

Python Verification (SDK)

The SDK provides a convenient helper:

from nellie_api import Webhook, WebhookSignatureError
import os

WEBHOOK_SECRET = os.environ.get('NELLIE_WEBHOOK_SECRET')

@app.route('/webhooks/nellie', methods=['POST'])
def handle_webhook():
    payload = request.data
    sig_header = request.headers.get('X-Nellie-Signature')
    
    try:
        event = Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)
    except WebhookSignatureError:
        return 'Invalid signature', 400
    
    # event is a Book object
    if event.status == 'completed':
        print(f"Book ready: {event.result_url}")
    else:
        print(f"Book failed: {event.error}")
    
    return 'OK', 200

Node.js Verification

const crypto = require('crypto');

function verifyWebhook(payload, sigHeader, secret) {
  if (!sigHeader) return false;
  
  // Parse header
  const parts = Object.fromEntries(
    sigHeader.split(',').map(x => x.split('='))
  );
  const timestamp = parts.t;
  const signature = parts.v1;
  
  if (!timestamp || !signature) return false;
  
  // Check timestamp (5 minute tolerance)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false;
  
  // Compute expected signature
  const signedContent = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedContent)
    .digest('hex');
  
  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// Express example
app.post('/webhooks/nellie', express.raw({type: 'application/json'}), (req, res) => {
  const payload = req.body.toString();
  const sigHeader = req.headers['x-nellie-signature'];
  
  if (!verifyWebhook(payload, sigHeader, process.env.WEBHOOK_SECRET)) {
    return res.status(400).send('Invalid signature');
  }
  
  const event = JSON.parse(payload);
  console.log(`Received webhook for ${event.requestId}: ${event.status}`);
  
  res.status(200).send('OK');
});

Framework Examples

Flask

from flask import Flask, request, jsonify
from nellie_api import Webhook, WebhookSignatureError
import os

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['NELLIE_WEBHOOK_SECRET']

@app.route('/webhooks/nellie', methods=['POST'])
def nellie_webhook():
    try:
        event = Webhook.construct_event(
            request.data,
            request.headers.get('X-Nellie-Signature'),
            WEBHOOK_SECRET
        )
    except WebhookSignatureError:
        return jsonify({'error': 'Invalid signature'}), 400
    except ValueError:
        return jsonify({'error': 'Invalid payload'}), 400
    
    # Process the event
    if event.is_successful():
        # Download the result
        download_book(event.result_url, event.request_id)
    else:
        # Handle failure
        log_failure(event.request_id, event.error)
    
    return jsonify({'received': True}), 200

FastAPI

from fastapi import FastAPI, Request, HTTPException
from nellie_api import Webhook, WebhookSignatureError
import os

app = FastAPI()
WEBHOOK_SECRET = os.environ['NELLIE_WEBHOOK_SECRET']

@app.post('/webhooks/nellie')
async def nellie_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get('x-nellie-signature')
    
    try:
        event = Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)
    except WebhookSignatureError:
        raise HTTPException(status_code=400, detail='Invalid signature')
    except ValueError:
        raise HTTPException(status_code=400, detail='Invalid payload')
    
    if event.is_successful():
        # Process completed book
        await process_book(event)
    else:
        # Handle failure
        await handle_failure(event)
    
    return {'received': True}

Django

# views.py
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from nellie_api import Webhook, WebhookSignatureError
import os

WEBHOOK_SECRET = os.environ['NELLIE_WEBHOOK_SECRET']

@csrf_exempt
@require_POST
def nellie_webhook(request):
    try:
        event = Webhook.construct_event(
            request.body,
            request.headers.get('X-Nellie-Signature'),
            WEBHOOK_SECRET
        )
    except WebhookSignatureError:
        return HttpResponseBadRequest('Invalid signature')
    except ValueError:
        return HttpResponseBadRequest('Invalid payload')
    
    if event.is_successful():
        # Queue background task to download book
        from .tasks import process_completed_book
        process_completed_book.delay(event.request_id, event.result_url)
    
    return JsonResponse({'received': True})

Express.js

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.NELLIE_WEBHOOK_SECRET;

// Use raw body for signature verification
app.post('/webhooks/nellie', 
  express.raw({ type: 'application/json' }), 
  (req, res) => {
    const payload = req.body.toString();
    const sigHeader = req.headers['x-nellie-signature'];
    
    if (!verifySignature(payload, sigHeader, WEBHOOK_SECRET)) {
      return res.status(400).json({ error: 'Invalid signature' });
    }
    
    const event = JSON.parse(payload);
    
    if (event.status === 'completed') {
      // Process completed book
      processBook(event.requestId, event.resultUrl);
    } else {
      // Handle failure
      console.error(`Book failed: ${event.error}`);
    }
    
    res.json({ received: true });
  }
);

Webhook URL Requirements

Your webhook URL must:

RequirementDetails
HTTPSMust use HTTPS (not HTTP)
PublicMust be publicly accessible
Valid certificateMust have a valid SSL certificate
Respond quicklyShould return 2xx within 30 seconds

Not allowed:

  • localhost or 127.0.0.1
  • Internal IP ranges (10.x.x.x, 192.168.x.x, etc.)
  • Cloud metadata endpoints
  • Non-HTTPS URLs

Retry Behavior

If your endpoint doesn’t return a 2xx status code, Nellie will retry:

AttemptDelay
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes

After 3 failed retries, the webhook is abandoned. You can still poll the status endpoint to get results.

Testing Webhooks

Local Development

Use a tunnel service to expose your local server:

# Using ngrok
ngrok http 5000
# Gives you: https://abc123.ngrok.io

# Then use that URL
book = client.books.create(
    prompt="Test book",
    webhook_url="https://abc123.ngrok.io/webhooks/nellie"
)

Generate Test Signatures

The SDK can generate test signatures:

from nellie_api import Webhook
import json

# Create test payload
payload = json.dumps({
    "requestId": "test-123",
    "status": "completed",
    "completedAt": "2025-12-03T10:55:00Z",
    "creditsUsed": 250,
    "resultUrl": "https://example.com/test.pdf"
})

# Generate signature
signature = Webhook.generate_signature(payload, "whsec_your_test_secret")

# Use in tests
response = test_client.post(
    '/webhooks/nellie',
    data=payload,
    headers={
        'Content-Type': 'application/json',
        'X-Nellie-Signature': signature
    }
)

Best Practices

✅ DO

  1. Always verify signatures: Never process unverified webhooks
  2. Respond quickly: Return 200 immediately, process async
  3. Be idempotent: Handle duplicate deliveries gracefully
  4. Log everything: Keep records for debugging
  5. Use queues: Process heavy work in background jobs

❌ DON’T

  1. Skip verification: Anyone could POST to your endpoint
  2. Do heavy processing sync: Can cause timeouts
  3. Assume order: Webhooks may arrive out of order
  4. Ignore failures: Set up alerts for webhook errors

Troubleshooting

Webhook Not Received

  1. Check URL is publicly accessible
  2. Verify HTTPS certificate is valid
  3. Check server logs for incoming requests
  4. Ensure firewall allows incoming connections

Signature Verification Fails

  1. Use the raw request body (not parsed JSON)
  2. Check webhook secret matches dashboard
  3. Verify timestamp is within 5 minutes
  4. Ensure correct header name: X-Nellie-Signature

Timeouts

  1. Return 200 immediately
  2. Process in background worker
  3. Don’t download files synchronously