TL;DRFor batches over 100 items, submit to /v1/humanize/batch/async with a callbackUrl. The API returns a job ID immediately and POSTs full results to your webhook when processing completes. Always verify the X-Aih-Signature HMAC header before processing the payload.
ASYNC BATCH + WEBHOOK FLOWYour clientHumanizer API1. POST /v1/humanize/batch/async (items + callbackUrl)2. 202 Accepted { job_id, status: queued }processing~1-15 min3. POST callbackUrl (results + HMAC signature)Retries with exponential backoff if your endpoint returns non-2xx

Webhooks and Callbacks: Async Humanization for High-Volume Pipelines

When you’re processing hundreds or thousands of documents daily, waiting for synchronous API responses becomes a bottleneck. Webhooks enable asynchronous humanization, allowing your application to fire a request and continue with other work while results are delivered to your callback URL. This guide shows you how to build a robust webhook-based humanization pipeline.

Synchronous vs Asynchronous API Patterns

The synchronous pattern works fine for single documents or small batches. Your application sends a request and waits for the response. For high-volume processing, this ties up resources and slows your pipeline.

Asynchronous webhooks flip the model. You send a humanization request with a callback URL. The API processes the text in the background and posts the result to your webhook endpoint when ready. Your application handles other tasks in between.

The performance difference is significant. A synchronous request that takes 3 seconds blocks your application for that duration. With webhooks, you queue 100 requests in seconds and handle responses as they arrive. This pattern scales from dozens to thousands of concurrent requests.

Webhook Fundamentals and Setup

A webhook is simply an HTTP POST request to your application. The AI Humanizer API calls your endpoint to deliver results. Before you can receive webhooks, you need a public URL that accepts POST requests.

For local development, use a tunneling service like ngrok to expose your local server to the internet. For production, deploy to a cloud platform like AWS, Hercel, or Vercel.

// Using ngrok to tunnel local server
// In terminal:
// ngrok http 3000

// This gives you a public URL like https://abc123.ngrok.io
// Use https://abc123.ngrok.io/webhooks/humanize as your callback URL

Create a webhook endpoint that receives POST requests from the API. This endpoint must be publicly accessible and return a 200 status code to confirm receipt.

// Express webhook endpoint
import express from 'express';

app.post('/webhooks/humanize', express.json(), async (req, res) => {
  try {
    const { request_id, humanized_text, original_text, status, error } = req.body;

    // Acknowledge receipt immediately
    res.status(200).json({ received: true });

    // Process the result asynchronously
    if (status === 'completed') {
      await saveHumanizedText(request_id, humanized_text);
      await notifyUser(request_id);
    } else if (status === 'failed') {
      await logError(request_id, error);
      await notifyUserOfFailure(request_id, error);
    }
  } catch (error) {
    console.error('Webhook processing error:', error);
    res.status(500).json({ error: 'Processing failed' });
  }
});

Always return a 200 status code immediately. Process the actual work after acknowledging receipt. This prevents the API from retrying the webhook delivery.

Initiating Asynchronous Humanization Requests

Send a humanization request with your webhook URL. The API will call your endpoint when processing is complete.

interface AsyncHumanizeRequest {
  text: string;
  callback_url: string;
  request_id?: string; // Optional ID to track the request
  tone?: 'professional' | 'casual' | 'academic';
  preserve_formatting?: boolean;
}

async function initiateAsyncHumanization(
  text: string,
  callbackUrl: string
): Promise {
  const requestId = generateUniqueId();

  const response = await fetch(
    `${process.env.HUMANIZER_API_URL}/humanize/async`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.HUMANIZER_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        text,
        callback_url: callbackUrl,
        request_id: requestId,
        tone: 'professional',
        preserve_formatting: true,
      }),
    }
  );

  if (!response.ok) {
    throw new Error(`Async request failed: ${response.statusText}`);
  }

  return requestId;
}

// Track request in your database
async function sendHumanizationRequest(documentId: string, text: string) {
  const callbackUrl = `${process.env.APP_URL}/webhooks/humanize`;
  const requestId = await initiateAsyncHumanization(text, callbackUrl);

  // Store mapping for webhook callback
  await db.insert('humanization_requests', {
    request_id: requestId,
    document_id: documentId,
    original_text: text,
    status: 'pending',
    created_at: new Date(),
  });
}

Webhook Payload Format and Structure

The API delivers results in a consistent JSON format. Understanding the payload structure helps you process results reliably.

// Successful humanization webhook payload
{
  "request_id": "req_abc123xyz",
  "status": "completed",
  "humanized_text": "Here is the naturally written version of your content...",
  "original_text": "Here is the AI generated text that was submitted...",
  "original_length": 150,
  "humanized_length": 165,
  "processing_time_ms": 2847,
  "tone_applied": "professional",
  "metadata": {
    "model_version": "v2.1",
    "timestamp": "2026-04-23T14:30:45.123Z"
  }
}

// Failed humanization webhook payload
{
  "request_id": "req_abc123xyz",
  "status": "failed",
  "error": "Text exceeds maximum length",
  "error_code": "TEXT_TOO_LONG",
  "original_text": "...",
  "metadata": {
    "timestamp": "2026-04-23T14:30:45.123Z"
  }
}

Use the request_id field to correlate the webhook response with your original request. This ties the humanized text back to the correct document in your system.

HMAC Signature Verification for Security

Verify that webhook deliveries genuinely come from the API by checking HMAC signatures. This prevents malicious actors from spoofing webhook calls.

import crypto from 'crypto';

// Verify webhook signature
function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Use in your webhook endpoint
app.post('/webhooks/humanize', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-humanizer-signature'] as string;
  const payload = req.body.toString();

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

  const data = JSON.parse(payload);
  // Process webhook...
  res.status(200).json({ received: true });
});

Request your webhook secret from the API dashboard. Store it securely in your environment variables, never in version control.

Retry Logic and Failure Handling

Network issues happen. The API retries webhook deliveries multiple times before giving up. Design your webhook handler to be idempotent so it safely handles duplicate deliveries.

// Idempotent webhook handler using request_id as unique key
async function handleHumanizationWebhook(data: WebhookPayload) {
  // Check if we've already processed this request
  const existingRecord = await db.query(
    'SELECT * FROM humanization_responses WHERE request_id = ?',
    [data.request_id]
  );

  if (existingRecord) {
    console.log(`Webhook already processed: ${data.request_id}`);
    return; // Safe to return even if called multiple times
  }

  // Process the result
  await db.insert('humanization_responses', {
    request_id: data.request_id,
    humanized_text: data.humanized_text,
    status: data.status,
    processing_time_ms: data.processing_time_ms,
    received_at: new Date(),
  });

  // Update the original document
  const humanizationRequest = await db.query(
    'SELECT document_id FROM humanization_requests WHERE request_id = ?',
    [data.request_id]
  );

  if (humanizationRequest) {
    await db.update('documents', {
      humanized_content: data.humanized_text,
      humanization_status: 'completed',
      humanization_completed_at: new Date(),
    }, {
      id: humanizationRequest.document_id,
    });
  }
}

If your webhook handler fails, return a non-200 status code. The API will retry the delivery. For transient failures like database connection issues, use exponential backoff in your retry logic.

// Retry mechanism for failed webhook processing
async function processWebhookWithRetry(
  data: WebhookPayload,
  maxRetries: number = 3
) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      await handleHumanizationWebhook(data);
      return; // Success
    } catch (error) {
      if (attempt < maxRetries) {
        const delayMs = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
        await new Promise(resolve => setTimeout(resolve, delayMs));
      } else {
        // Final attempt failed, log and alert
        console.error('Webhook processing failed after retries:', error);
        await alertOpsTeam(`Webhook processing failed for request ${data.request_id}`);
        throw error;
      }
    }
  }
}

Webhook Event Patterns and Event Types

The API sends different event types as your humanization request progresses through its lifecycle.

// humanization.started event
{
  "event": "humanization.started",
  "request_id": "req_abc123",
  "timestamp": "2026-04-23T14:30:00Z",
  "metadata": {
    "text_length": 500,
    "tone": "professional"
  }
}

// humanization.completed event
{
  "event": "humanization.completed",
  "request_id": "req_abc123",
  "status": "completed",
  "humanized_text": "...",
  "processing_time_ms": 2500,
  "timestamp": "2026-04-23T14:30:02.500Z"
}

// humanization.failed event
{
  "event": "humanization.failed",
  "request_id": "req_abc123",
  "error": "Text exceeds maximum length",
  "error_code": "TEXT_TOO_LONG",
  "timestamp": "2026-04-23T14:30:00.150Z"
}

Use the event field to handle different stages of the humanization process. Track started events to monitor which requests are in progress. Listen for completed and failed events to trigger downstream actions.

Building a Queue-Based Processing Architecture

For truly high-volume pipelines, combine webhooks with a message queue. Submit humanization requests in bulk and process results as they arrive.

// Queue-based architecture using Bull (Redis-backed queue)
import Queue from 'bull';

const humanizationQueue = new Queue('humanization', {
  redis: { host: process.env.REDIS_HOST, port: 6379 },
});

// Submit documents to the queue
async function queueDocumentsForHumanization(documents: Document[]) {
  const jobs = documents.map(doc => ({
    data: {
      document_id: doc.id,
      text: doc.content,
      callback_url: `${process.env.APP_URL}/webhooks/humanize`,
    },
  }));

  await humanizationQueue.addBulk(jobs);
}

// Process queue items and send API requests
humanizationQueue.process(5, async (job) => {
  const { document_id, text, callback_url } = job.data;

  try {
    const response = await fetch(
      `${process.env.HUMANIZER_API_URL}/humanize/async`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.HUMANIZER_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          text,
          callback_url,
          request_id: `${document_id}-${Date.now()}`,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`API request failed: ${response.status}`);
    }

    const result = await response.json();
    job.progress(50); // Indicate that the request was sent

    return { request_id: result.request_id };
  } catch (error) {
    // Retry up to 5 times with exponential backoff
    throw error;
  }
});

// Monitor queue events
humanizationQueue.on('completed', (job, result) => {
  console.log(`Job ${job.id} completed. Request ID: ${result.request_id}`);
});

humanizationQueue.on('failed', (job, error) => {
  console.error(`Job ${job.id} failed: ${error.message}`);
});

Monitoring and Alerting for Webhook Delivery

Track webhook delivery success rates and performance to identify issues early.

// Track webhook metrics
interface WebhookMetric {
  request_id: string;
  delivery_status: 'delivered' | 'failed' | 'retrying';
  delivery_attempts: number;
  last_attempt_at: Date;
  processing_time_ms?: number;
  humanization_status: 'pending' | 'completed' | 'failed';
}

// Log metrics to monitoring service
async function recordWebhookMetric(metric: WebhookMetric) {
  await fetch('https://monitoring.example.com/metrics', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      timestamp: new Date().toISOString(),
      service: 'humanizer-webhooks',
      ...metric,
    }),
  });
}

// Alert when delivery fails after all retries
function shouldAlertDeliveryFailure(metric: WebhookMetric): boolean {
  return metric.delivery_status === 'failed' && metric.delivery_attempts > 3;
}

// Query webhook metrics for monitoring
async function getWebhookDeliveryStats(timeRange: number = 3600000) {
  const since = new Date(Date.now() - timeRange);

  const stats = await db.query(`
    SELECT
      COUNT(*) as total_webhooks,
      SUM(CASE WHEN delivery_status = 'delivered' THEN 1 ELSE 0 END) as successful,
      SUM(CASE WHEN delivery_status = 'failed' THEN 1 ELSE 0 END) as failed,
      AVG(processing_time_ms) as avg_processing_time
    FROM webhook_deliveries
    WHERE created_at > ?
  `, [since]);

  return stats;
}

Cost Optimization with Async Processing

Webhooks naturally reduce costs by eliminating idle time waiting for responses. Further optimize by batching requests and caching results.

Group similar humanization requests together. If multiple documents need the same tone applied, process them in sequence and reuse any cached results when possible.

Archive old requests and responses to keep your database lean. Set up a cleanup job that runs weekly to move completed requests older than 30 days to cold storage.

// Archive old webhook records
async function archiveOldWebhookRecords(daysOld: number = 30) {
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - daysOld);

  const oldRecords = await db.query(
    'SELECT * FROM webhook_deliveries WHERE completed_at < ?',
    [cutoffDate]
  );

  // Move to archive table
  for (const record of oldRecords) {
    await db.insert('webhook_deliveries_archive', record);
  }

  // Delete from main table
  await db.delete('webhook_deliveries', {
    completed_at: { $lt: cutoffDate },
  });
}

Testing Your Webhook Implementation

Test webhook handling thoroughly before going live. Simulate various scenarios including successes, failures, and retry conditions.

// Jest test for webhook signature verification
describe('Webhook signature verification', () => {
  it('should accept valid signatures', () => {
    const payload = JSON.stringify({ request_id: 'test', status: 'completed' });
    const secret = 'test-secret';
    const signature = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('hex');

    const isValid = verifyWebhookSignature(payload, signature, secret);
    expect(isValid).toBe(true);
  });

  it('should reject invalid signatures', () => {
    const payload = JSON.stringify({ request_id: 'test', status: 'completed' });
    const isValid = verifyWebhookSignature(payload, 'invalid-sig', 'secret');
    expect(isValid).toBe(false);
  });
});

// Test idempotency
describe('Webhook idempotency', () => {
  it('should handle duplicate webhook deliveries', async () => {
    const payload = {
      request_id: 'req_123',
      status: 'completed',
      humanized_text: 'Result',
    };

    // First delivery
    await handleHumanizationWebhook(payload);
    let count = await db.count('humanization_responses', { request_id: 'req_123' });
    expect(count).toBe(1);

    // Second delivery (duplicate)
    await handleHumanizationWebhook(payload);
    count = await db.count('humanization_responses', { request_id: 'req_123' });
    expect(count).toBe(1); // Still 1, not duplicated
  });
});

Production Deployment Checklist for Webhooks

Before deploying your webhook system to production, verify these items are complete.

Ensure your webhook endpoint is publicly accessible and returns 200 status codes immediately. Set up HMAC signature verification using your webhook secret from the API dashboard. Implement idempotent request handling using unique request IDs. Deploy a monitoring system that tracks delivery success rates and alerts on failures. Test with various payload sizes and network conditions. Document your webhook endpoint for team reference. Set up log aggregation for all webhook events. Plan your queue infrastructure if using batch processing.

Next Steps

Webhooks unlock truly scalable humanization pipelines. You can now process thousands of documents asynchronously while maintaining low latency for user-facing operations. For advanced webhook management and monitoring, check our API documentation.

Ready to scale your humanization pipeline? Get a free API key and start with 10,000 words per month, no credit card required.

The async batch + webhook architecture

Synchronous APIs work fine for ≤100 items. For larger jobs, async batch + webhooks is the right pattern: submit thousands of items in one request, get a job ID immediately, receive results via callback when processing completes. No polling overhead, no held-open HTTP connections, no rate-limit headache.

The flow:

  1. Client POSTs to /v1/humanize/batch/async with items array + callbackUrl
  2. API returns {job_id, status: "queued"} immediately
  3. Server processes items in parallel (typically 50-200 items per minute depending on load)
  4. When complete, server POSTs full results to your callbackUrl with HMAC signature
  5. Your webhook handler verifies signature, processes results, marks job complete

Webhook payload format

POST https://yourapp.com/webhook/humanize-complete
Headers:
  X-Aih-Signature: sha256=<hmac_hex>
  X-Aih-Job-Id: j_abc123
  Content-Type: application/json

Body:
{
  "job_id": "j_abc123",
  "status": "complete",
  "completed_at": "2026-04-29T03:14:15Z",
  "items_processed": 847,
  "items_failed": 3,
  "results": [
    {"id": "post-1", "success": true, "humanized_text": "...", "confidence_score": 0.94},
    {"id": "post-2", "success": true, "humanized_text": "...", "confidence_score": 0.91},
    {"id": "post-3", "success": false, "error_code": "text_too_short"},
    ...
  ]
}

Verifying the webhook signature

Always verify the HMAC before processing. The shared secret is the value you passed as callbackSecret when submitting the job:

Node.js

const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const signature = req.headers['x-aih-signature']; // "sha256=..."
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(req.body))
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

app.post('/webhook/humanize', (req, res) => {
  if (!verifyWebhook(req, process.env.AIH_CALLBACK_SECRET)) {
    return res.status(401).end();
  }
  // Process results...
  for (const result of req.body.results) {
    if (result.success) {
      saveHumanizedToCMS(result.id, result.humanized_text);
    } else {
      logFailure(result.id, result.error_code);
    }
  }
  res.status(200).end();
});

Python (Flask)

import hmac, hashlib, os
from flask import request, abort

def verify_webhook(req, secret):
    sig = req.headers.get('X-Aih-Signature', '')
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        req.get_data(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(sig, expected)

@app.post('/webhook/humanize')
def humanize_webhook():
    if not verify_webhook(request, os.environ['AIH_CALLBACK_SECRET']):
        abort(401)
    body = request.get_json()
    for result in body['results']:
        if result['success']:
            save_to_cms(result['id'], result['humanized_text'])
    return '', 200

Retry behavior

If your webhook returns non-2xx (or times out), the API retries with exponential backoff: 1 min, 5 min, 30 min, 2 hours, 6 hours, 24 hours. After 6 retries, the job is marked as delivery_failed and you can fetch results manually via GET /v1/jobs/{job_id}.

To prevent duplicate processing on retry, use the X-Aih-Job-Id header as your idempotency key. If you've already processed this job ID, return 200 immediately without re-processing.

Job status polling (alternative to webhooks)

If you can't expose a webhook endpoint (firewalled, ephemeral worker, etc.), poll the job status:

// Submit
POST /v1/humanize/batch/async
{ "items": [...] } // no callbackUrl
→ { "job_id": "j_xyz789", "status": "queued" }

// Poll
GET /v1/jobs/j_xyz789
→ { "job_id": "j_xyz789", "status": "processing", "progress": 0.34 }

// Poll until status is "complete"
→ { "job_id": "j_xyz789", "status": "complete", "results": [...] }

Recommended polling interval: start at 10 seconds, exponential backoff to 60 seconds max. Don't poll faster than every 5 seconds - counts against your rate limit.

Production patterns

Content pipeline integration

CMS → queue → batch submit → webhook → CMS write-back. Common stack: WordPress + Redis queue + Cloud Function webhook handler. See CMS integration patterns.

Multi-tenant SaaS

Tag each item with tenant_id in the id field. Webhook handler routes results back per tenant. Single API key for the whole product, attribution via item metadata.

Scheduled overnight runs

Cron at midnight queues all of the day's drafts. Webhook fires by 6am with results. Editors review humanized output in the morning. See SEO agency pattern.

Real-time chatbot streaming

For chat-style interfaces, async batch is the wrong tool - use the streaming endpoint /v1/humanize/stream for live token-by-token response. Webhooks are for bulk jobs, not single-message latency.

Frequently asked questions

What happens if my webhook is down when the job completes?

Retries 6 times over 24 hours. After that, results sit in the /v1/jobs/{id} endpoint for 7 days - fetch them whenever your webhook is restored.

Can I get partial results before the whole batch completes?

Not currently - you get the full payload when complete. For larger jobs, split into multiple smaller batches and process as each completes.

How big can a single async batch be?

10,000 items per job. For larger jobs, split client-side and submit multiple batches.

Are webhook secrets per-job or per-account?

Per-job. You provide the callbackSecret with each batch submission. This lets you rotate secrets independently and isolate webhook handlers per job type.

What's the SLA on completion time?

For batches under 1,000 items: typically 5-15 minutes. Larger batches scale roughly linearly. SLA is "best effort" on Free/Starter, with explicit completion-time guarantees on Enterprise plans.

Can I cancel a job in progress?

Yes - DELETE /v1/jobs/{job_id}. Items already processed are still billed; queued items aren't.

Get started

The webhook + async pattern requires a publicly accessible endpoint and HMAC verification. For testing, use a tunnel like ngrok or a cloud function (Vercel, Cloudflare Workers, AWS Lambda) - see our API docs for full schema. Sign up for an API key and start with the sync batch endpoint to validate the flow before scaling to async.