Skip to main content
WEBHOOK
/
workflow
/
callback
{
  "workflow_id": "12345678-1234-1234-1234-123456789abc",
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "processing",
  "expires_at": 1730319600
}
Workflow automations can take several minutes to complete. Webhooks provide asynchronous updates so your application can continue processing while the workflow executes, and you’ll be notified when results are ready.

Setup Your Webhook

Before receiving webhooks, you need to generate a secure secret and share it with NenAI.
1

Generate a webhook secret

Create a secure random string to use as your webhook secret. This secret will be used to verify that webhook requests are genuinely from NenAI.
import { randomBytes } from 'crypto';

const webhookSecret = randomBytes(32).toString('hex');
console.log(webhookSecret);
Store this secret securely (like in environment variables or a secrets manager). Never commit it to version control or expose it in client-side code.
2

Share your secret with NenAI

Provide your generated webhook secret to your NenAI engineering contact. They will configure your workflows to sign webhook requests using this secret.
You’ll use this same secret to verify webhook signatures in your application code (see the Webhook Security section below).
3

Set up your webhook endpoint

Create an HTTPS endpoint that accepts POST requests. This URL will receive workflow status updates.
import Fastify from 'fastify';

const fastify = Fastify({ logger: true });

fastify.post('/webhook', async (request, reply) => {
  // Verify signature (see security section)
  // Process webhook payload
  return { status: 'OK' };
});

const start = async () => {
  try {
    await fastify.listen({ port: 3000 });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();
Provide this endpoint URL to your NenAI engineering contact along with your webhook secret.

Webhook Statuses

Your webhook endpoint will receive three types of status updates:
Sent when the workflow begins execution:
{
  "workflow_id": "12345678-1234-1234-1234-123456789abc",
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "processing",
  "expires_at": 1730319600
}
This confirms your workflow has started executing. The next update will be either success or failed.

Webhook HMAC Validation

NenAI secures all webhook requests using HMAC-SHA256 signatures. This cryptographic validation ensures that incoming webhook events originate from NenAI and have not been tampered with during transmission.
Always validate HMAC signatures in production. Skipping validation exposes your endpoint to spoofed requests and potential security vulnerabilities.

HMAC Validation Process

Each webhook request includes an HMAC signature in the X-Hmac-Signature header. Your application must:
  1. Extract the signature from the request header
  2. Compute the expected signature using your shared secret and the raw request body
  3. Compare signatures using a timing-safe comparison to prevent timing attacks
A request should only be processed if the signatures match exactly.

Required Header

X-Hmac-Signature: sha256=<64-character-hex-digest>
The header value follows the format sha256= followed by the lowercase hexadecimal HMAC-SHA256 digest.

Signature Calculation

The HMAC signature is calculated using:
  • Algorithm: HMAC-SHA256 (RFC 2104 compliant)
  • Secret Key: Your webhook secret (generated during setup)
  • Message: Raw request body (UTF-8 encoded JSON)
  • Output: Lowercase hexadecimal string
NenAI signs the raw request body as received. Ensure you validate the signature before parsing or modifying the request body.

Implementation Examples

First, create a reusable verification function:
import { createHmac, timingSafeEqual } from 'crypto';

/**
 * Validate webhook signature to ensure request authenticity.
 */
function verifyWebhookSignature(
  requestBody: Buffer | string,
  signatureHeader: string,
  secretKey: string
): boolean {
  const receivedSignature = signatureHeader.replace('sha256=', '').toLowerCase();
  const hmac = createHmac('sha256', secretKey);
  hmac.update(requestBody, 'utf8');
  const expectedSignature = hmac.digest('hex').toLowerCase();
  
  try {
    return timingSafeEqual(
      Buffer.from(expectedSignature, 'hex'),
      Buffer.from(receivedSignature, 'hex')
    );
  } catch {
    return false;
  }
}
Then use it in your framework:
import Fastify from 'fastify';

const fastify = Fastify({ logger: true });

// Configure raw body parser to preserve raw buffer
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
  done(null, body);
});

fastify.post('/webhook', async (request, reply) => {
  const signature = request.headers['x-hmac-signature'] as string;
  const rawBody = request.body as Buffer;
  
  if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
    return reply.status(401).send({ error: 'Unauthorized: Invalid signature' });
  }
  
  const payload = JSON.parse(rawBody.toString('utf8'));
  await handleWorkflowUpdate(payload);
  
  return { status: 'OK' };
});

await fastify.listen({ port: 3000 });

HMAC Security Requirements

Always use your platform’s built-in HMAC libraries (e.g., crypto.createHmac() in Node.js, hmac.new() in Python). These implementations are:
  • RFC 2104 compliant: Follow the official HMAC specification
  • Constant-time safe: Protect against timing attack vulnerabilities
  • Battle-tested: Widely audited and maintained by security experts
Never implement your own HMAC algorithm.
Your webhook secret is a sensitive credential. Follow these security practices:
  • Store securely: Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
  • Never commit: Add secrets to .gitignore and .env.example files
  • Rotate regularly: Generate new secrets periodically and update with your NenAI contact
  • Limit access: Only share with team members who need it
  • Use separate secrets: Different secrets for staging and production environments
Example: Secure secret storage
// ✅ Good - Load from environment
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;

// ❌ Bad - Hardcoded secret
const WEBHOOK_SECRET = 'abc123secret';
Standard string comparison (=== or ==) can leak timing information that attackers exploit. Always use timing-safe comparison functions:
  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • Go: crypto/subtle.ConstantTimeCompare()
These functions execute in constant time regardless of where differences occur, preventing timing attacks.
The validation order matters for security:
  1. Extract signature from header
  2. Validate HMAC signature (reject if invalid)
  3. Parse JSON payload (only after signature is verified)
  4. Process webhook data
Never parse or process the request body before validating the HMAC signature.

Handling Webhook Payloads

Once verified, process the webhook based on the status:
interface WebhookPayload {
  workflow_id: string;
  message_id: string;
  status: 'processing' | 'success' | 'failed';
  run_payload_presigned_url?: string;
  run_video_presigned_url?: string;
  payload_metadata?: Record<string, string>;
  expires_at: number;
}

async function handleWorkflowUpdate(payload: WebhookPayload): Promise<void> {
  const { status, message_id } = payload;
  
  switch (status) {
    case 'processing':
      // Update your database to show job is in progress
      await updateJobStatus(message_id, 'processing');
      break;
      
    case 'success':
      // Download the results before they expire
      const payloadUrl = payload.run_payload_presigned_url!;
      const videoUrl = payload.run_video_presigned_url!;
      
      // Access custom metadata about the workflow results
      const metadata = payload.payload_metadata || {};
      const certificateCount = metadata.certificate_count;
      const certificatesList = metadata.certificates_list;
      
      await downloadAndStore(payloadUrl, message_id);
      await downloadAndStore(videoUrl, message_id);
      
      // Store metadata for reporting
      await storeWorkflowMetadata(message_id, metadata);
      
      await updateJobStatus(message_id, 'completed');
      break;
      
    case 'failed':
      // Log the failure and potentially notify administrators
      const errorVideoUrl = payload.run_video_presigned_url!;
      await downloadErrorVideo(errorVideoUrl, message_id);
      
      await updateJobStatus(message_id, 'failed');
      break;
  }
}
Return a 200 OK response quickly (within 5 seconds) to acknowledge receipt. Process the webhook data asynchronously if needed.

Best Practices

Track message_id values in your database to prevent processing duplicate webhooks:
async function processWebhook(payload: WebhookPayload): Promise<void> {
  const messageId = payload.message_id;
  
  // Check if already processed
  const existing = await db.messages.findOne({ id: messageId });
  if (existing) {
    return; // Already handled this webhook
  }
  
  // Process and store
  await db.messages.create({ id: messageId, payload });
  await handleWorkflowUpdate(payload);
}
Presigned URLs are temporary and will expire. Download and store important files immediately upon receiving the webhook. If a URL has expired, contact your engineering team to retrieve a fresh presigned URL using the retrieval endpoint.
import { promises as fs } from 'fs';
import https from 'https';
import path from 'path';

async function downloadResults(presignedUrl: string, messageId: string): Promise<string> {
  return new Promise((resolve, reject) => {
    https.get(presignedUrl, (response) => {
      const filePath = path.join('results', messageId, 'output.json');
      
      // Ensure directory exists
      fs.mkdir(path.dirname(filePath), { recursive: true });
      
      const fileStream = fs.createWriteStream(filePath);
      response.pipe(fileStream);
      
      fileStream.on('finish', () => {
        console.log(`Results saved to ${filePath}`);
        resolve(filePath);
      });
      
      fileStream.on('error', reject);
    }).on('error', reject);
  });
}
Set up monitoring to alert you if webhooks stop arriving:
async function checkWebhookHealth(): Promise<void> {
  // Alert if no webhooks received in 1 hour
  const lastWebhook = await getLastWebhookTimestamp();
  const oneHourAgo = Date.now() - (60 * 60 * 1000);
  
  if (lastWebhook < oneHourAgo) {
    await sendAlert('No webhooks received in the last hour');
  }
}

Troubleshooting

Possible causes:
  • Your webhook URL is not publicly accessible
  • Firewall is blocking incoming requests
  • SSL certificate issues on your domain
Solutions:
  • Test your webhook URL with tools like webhook.site
  • Ensure your server accepts POST requests
  • Verify SSL certificates are valid and not self-signed
  • Check firewall rules to allow traffic from NenAI
Possible causes:
  • Using incorrect webhook secret
  • Validating parsed JSON instead of raw request body
  • Character encoding mismatches (non-UTF-8)
  • Request body modified before validation
  • Missing or malformed X-Hmac-Signature header
Solutions:
  • Verify you’re using the webhook secret you generated during setup (not your API key)
  • Validate the raw request body before parsing JSON (request.data in Flask, req.body with express.raw() in Express, request.rawBody in Fastify)
  • Ensure UTF-8 encoding for all string operations
  • Check header extraction: request.headers['X-Hmac-Signature'] or req.headers['x-hmac-signature']
  • Log both expected and received signatures for debugging (securely, without exposing the secret)
Debugging tip:
console.log('Received:', signatureHeader);
console.log('Expected:', `sha256=${expectedSignature}`);
console.log('Body length:', requestBody.length);
The webhook secret is not the same as your API key. The webhook secret is used exclusively for HMAC signature validation.
Possible causes:
  • URLs have expired
  • URLs accessed after expiration timestamp
  • Network issues preventing download
Solutions:
  • Check the expires_at timestamp before downloading
  • Download files immediately upon receiving success webhook
  • Implement retry logic with exponential backoff
  • Store files in your own storage system
Test your webhook integration in a development environment before deploying to production. Use tools like ngrok to expose your local server for testing.

Authorizations

Authorization
string
header
required

Bearer authentication header of the form Bearer <token>, where <token> is your auth token.

Body

application/json

Workflow status update

workflow_id
string<uuid>
required

The workflow ID being executed

Example:

"12345678-1234-1234-1234-123456789abc"

message_id
string<uuid>
required

Unique identifier for this workflow run

Example:

"550e8400-e29b-41d4-a716-446655440000"

status
enum<string>
required

Current status of the workflow

Available options:
processing
Example:

"processing"

expires_at
integer
required

Unix timestamp when presigned URLs expire

Example:

1730319600

Response

200

Return a 200 status to acknowledge receipt of the webhook