Webhooks

Receive real-time notifications when conversions complete or fail. Webhooks eliminate the need for polling and enable event-driven workflows.

Overview

LogTalk supports two types of webhooks. The same delivery mechanism and the same event names are used for both changelog conversions and document conversions — the source_type field in the payload (changelog vs document) tells you which conversion produced the event.

  • Per-request webhooks — Pass a webhook_url in your conversion request. The callback is sent only for that specific conversion. Ideal for CI/CD pipelines.
  • Persistent webhooks — Configure webhooks in your dashboard to receive notifications for all conversions. Useful for Slack notifications, internal dashboards, and monitoring.

Per-Request Webhooks

Include a webhook_url in your conversion request to receive a callback when that specific conversion completes:

curl -X POST https://logtalk.io/api/v1/conversions \
-H "Authorization: Bearer lt_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"source_type": "changelog",
"content": "## v2.0.0\n- New feature",
"webhook_url": "https://your-app.com/webhooks/logtalk"
}'

Note: Webhook URLs must use HTTPS. HTTP URLs will be rejected.

The same webhook_url parameter works for document conversions — pass "source_type": "document" instead of "changelog", and you'll receive the same conversion.completed / conversion.failed events, with the document payload shape shown below.

Event Types

EventDescription
conversion.completedA conversion finished successfully with audio (or video) ready
conversion.failedA conversion failed with error details

Both events are emitted for changelog and document conversions. Branch on data.source_type to tell them apart. | quota.warning | Approaching quota limit (80%) | | quota.exceeded | Monthly quota exceeded |

Webhook Payload

Webhooks are sent as POST requests with a JSON body. Every payload shares the same envelope — id, type, created_at, api_version, and data — but the shape of data depends on which conversion produced it.

Changelog conversions (conversion.*)

For conversions created with "source_type": "changelog", the payload carries the changelog shape below.

conversion.completed

{
"id": "evt_abc123def456",
"type": "conversion.completed",
"created_at": "2026-01-18T14:32:15.000Z",
"api_version": "2026-01-18",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"source_type": "changelog",
"mode": "changelog",
"product_name": "Acme Widget",
"version": "2.0.0",
"tone": "professional",
"duration": "short",
"output_format": "audio",
"source": "api",
"audio_url": "https://logtalk.io/api/v1/conversions/550e8400-e29b-41d4-a716-446655440000/audio",
"video_url": null,
"audio_duration_seconds": 180,
"script_text": "Here's what's new in Acme Widget 2.0.0...",
"listen_url": "https://logtalk.io/listen/550e8400-e29b-41d4-a716-446655440000",
"embed_url": "https://logtalk.io/embed/550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-01-18T14:30:00.000Z",
"completed_at": "2026-01-18T14:32:15.000Z"
}
}

listen_url and embed_url are null unless the conversion is public. video_url is null for audio-only conversions.

conversion.failed

{
"id": "evt_abc123def456",
"type": "conversion.failed",
"created_at": "2026-01-18T14:31:00.000Z",
"api_version": "2026-01-18",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"source_type": "changelog",
"mode": "changelog",
"product_name": "Acme Widget",
"version": "2.0.0",
"tone": "professional",
"duration": "short",
"output_format": "audio",
"source": "api",
"created_at": "2026-01-18T14:30:00.000Z",
"failed_at": "2026-01-18T14:31:00.000Z",
"error": {
"code": "GENERATION_FAILED",
"message": "Audio generation failed"
}
}
}

Document conversions

Conversions created with "source_type": "document" emit the same conversion.completed / conversion.failed events, but the data shape differs — it has no mode or version field, and instead carries source_type, verbosity, and speakers.

conversion.completed (document)

{
"id": "evt_def789ghi012",
"type": "conversion.completed",
"created_at": "2026-01-18T14:32:15.000Z",
"api_version": "2026-01-18",
"data": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "completed",
"source_type": "document",
"content_description": "Q3 product roadmap.pdf",
"tone": "casual",
"verbosity": "normal",
"speakers": 2,
"source": "api",
"audio_url": "https://logtalk.io/api/v1/conversions/7c9e6679-7425-40de-944b-e07fc1f90ae7/audio",
"audio_duration_seconds": 240,
"script_text": "Speaker 1: Let's dig into the Q3 roadmap...",
"listen_url": "https://logtalk.io/listen/7c9e6679-7425-40de-944b-e07fc1f90ae7",
"embed_url": "https://logtalk.io/embed/7c9e6679-7425-40de-944b-e07fc1f90ae7",
"created_at": "2026-01-18T14:29:00.000Z",
"completed_at": "2026-01-18T14:32:15.000Z"
}
}

speakers is 1 or 2 depending on whether the conversion was generated as a monologue or two-speaker dialogue. source is always "api" — document conversions are API-only.

conversion.failed (document)

{
"id": "evt_def789ghi012",
"type": "conversion.failed",
"created_at": "2026-01-18T14:31:00.000Z",
"api_version": "2026-01-18",
"data": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "failed",
"source_type": "document",
"content_description": "Q3 product roadmap.pdf",
"tone": "casual",
"verbosity": "normal",
"speakers": 2,
"source": "api",
"audio_url": null,
"audio_duration_seconds": null,
"script_text": null,
"listen_url": null,
"embed_url": null,
"created_at": "2026-01-18T14:29:00.000Z",
"failed_at": "2026-01-18T14:31:00.000Z",
"error": {
"code": "GENERATION_FAILED",
"message": "Could not extract readable text from the uploaded document"
}
}
}

Webhook Headers

Each webhook request includes these headers:

HeaderDescription
Content-TypeAlways application/json
X-LogTalk-EventEvent type (e.g., conversion.completed)
X-LogTalk-DeliveryUnique delivery ID for debugging
X-LogTalk-SignatureHMAC-SHA256 signature (format: t=timestamp,v1=signature)
X-LogTalk-TimestampUnix timestamp when the webhook was sent

Signature Verification

Always verify webhook signatures to ensure requests are authentic. Signatures use HMAC-SHA256 with the format t=timestamp,v1=signature. The signed payload is ${timestamp}.${JSON.stringify(payload)}, which binds the signature to the timestamp and prevents replay attacks. Reject webhooks with timestamps older than 5 minutes.

const crypto = require("crypto");
function verifyWebhookSignature(payload, signatureHeader, secret) {
// Parse signature header: t=timestamp,v1=signature
const parts = signatureHeader.split(",");
const timestamp = parseInt(
parts.find((p) => p.startsWith("t=")).split("=")[1],
);
const signature = parts.find((p) => p.startsWith("v1=")).split("=")[1];
// Check timestamp (reject if > 5 minutes old)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error("Webhook timestamp too old");
}
// Compute expected signature
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Use timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expectedSignature, "hex"),
);
}
// Express.js example
app.post("/webhooks/logtalk", express.json(), (req, res) => {
const signature = req.headers["x-logtalk-signature"];
const secret = process.env.LOGTALK_WEBHOOK_SECRET;
if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).send("Invalid signature");
}
// Process the webhook
const { type, data } = req.body;
console.log(`Received ${type}: ${data.id}`);
res.status(200).send("OK");
});
import hmac
import hashlib
import json
import time
from flask import Flask, request, abort
app = Flask(__name__)
def verify_webhook_signature(payload, signature_header, secret):
"""Verify webhook signature using HMAC-SHA256."""
# Parse signature header
parts = dict(p.split('=') for p in signature_header.split(','))
timestamp = int(parts['t'])
signature = parts['v1']
# Check timestamp (reject if > 5 minutes old)
now = int(time.time())
if abs(now - timestamp) > 300:
raise ValueError('Webhook timestamp too old')
# Compute expected signature
signed_payload = f"{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
expected_signature = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Use constant-time comparison
return hmac.compare_digest(signature, expected_signature)
@app.route('/webhooks/logtalk', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-LogTalk-Signature')
secret = os.environ['LOGTALK_WEBHOOK_SECRET']
if not verify_webhook_signature(request.json, signature, secret):
abort(401, 'Invalid signature')
# Process the webhook
event_type = request.json['type']
data = request.json['data']
print(f"Received {event_type}: {data['id']}")
return 'OK', 200

Retry Policy

If your endpoint returns a non-2xx status code or times out, delivery is retried with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours
68 hours

After 6 failed attempts, the webhook is marked as failing and you'll be notified via email. Webhook timeout is 30 seconds.

Best Practices

  • Respond quickly — Return a 2xx response within 30 seconds. Process webhooks asynchronously if needed: acknowledge receipt first, then handle the event.
  • Handle duplicates — Webhooks may be delivered more than once. Use the event id to deduplicate events and ensure idempotent processing.
  • Verify signatures — Always verify the X-LogTalk-Signature header to ensure webhooks are authentic and haven't been tampered with.
  • Use queue processing — For high-volume integrations, queue webhook events for processing rather than handling them synchronously in the HTTP handler.