Webhooks allow you to receive real-time notifications when workflow sessions complete. When a session finishes, Simplex sends a POST request to your configured webhook URL with the complete session results.
Setting up webhooks
You can configure webhooks in two ways:
Global webhook (Dashboard)
Set a global webhook URL for your organization on the Dashboard Settings page . This webhook will receive notifications for all workflow sessions in your organization.
Per-request webhook (API)
When running a workflow via the API, you can specify a custom webhook URL in the Run Workflow request. This is useful for testing with ngrok or routing different workflows to different endpoints.
const result = await client . workflows . run ( workflowId , {
webhookUrl: 'https://your-domain.com/webhook'
});
Webhook payload structure
When a session completes, Simplex will send a POST request to your webhook URL with the following payload:
Workflow ID of the session
Session ID of the session
Metadata set in the workflow definition
Custom metadata you provided when starting the session
Indicates if the session completed successfully
Text description of what the agent accomplished
Structured output fields set during workflow execution
Presigned URL to download a screenshot of the final browser state
Outputs of any scrapers ran during workflow execution
Metadata for files downloaded during the session Name of the downloaded file
Presigned URL to download the file
Size of the file in bytes
ISO 8601 timestamp when the file was downloaded
Structured Output
The structured_output field contains custom data fields that can be defined in your workflow configuration . When you define structured output fields in your workflow, the agent will extract and return these specific pieces of information in addition to the standard response.
For example, if your workflow defines structured output fields for extracting product information:
"structured_output" : {
"product_name" : "MacBook Pro" ,
"price" : "$2499" ,
"availability" : "In Stock" ,
"sku" : "MBP-14-M3-2024"
}
This allows you to programmatically access specific data extracted during the workflow execution without having to parse the agent_response text. The fields available in structured_output depend on what you’ve defined in your workflow configuration.
Testing Locally
Testing with ngrok
When developing locally, you can use ngrok to create a public URL that forwards to your local development server:
Install ngrok if you haven’t already
Start your local webhook server (e.g., on port 3000)
Run ngrok to expose your local server:
Use the generated ngrok URL in your webhook configuration:
const result = await client . workflows . run ( "your-workflow-id" , {
webhookUrl: "https://abc123.ngrok.io/api/webhook"
});
Your local server will now receive webhook notifications when the workflow completes.
Webhook security
Simplex signs all webhook requests using HMAC-SHA256 to ensure authenticity. You should always verify the signature before processing webhook data.
Finding your webhook secret
Your webhook secret is available on the Dashboard Settings page . Keep this secret secure and never commit it to version control.
How signing works
Each webhook request includes an X-Simplex-Signature header containing an HMAC-SHA256 signature of the request body:
X-Simplex-Signature: 5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The signature is computed as:
HMAC-SHA256(webhook_secret, request_body)
Verifying webhooks
TypeScript SDK
Manual verification
The Simplex TypeScript SDK includes a built-in verifySimplexWebhook() function that handles signature verification for you. Installation Next.js App Router
Next.js Pages Router
// app/api/webhook/route.ts
import { NextRequest , NextResponse } from 'next/server' ;
import { verifySimplexWebhook , WebhookVerificationError , WebhookPayload } from 'simplex-ts' ;
export async function POST ( request : NextRequest ) {
const webhookSecret = process . env . SIMPLEX_WEBHOOK_SECRET ! ;
try {
// Get raw body as text
const body = await request . text ();
// Convert headers to plain object
const headers : Record < string , string > = {};
request . headers . forEach (( value , key ) => {
headers [ key ] = value ;
});
// Verify signature
verifySimplexWebhook ( body , headers , webhookSecret );
// ✅ Verified! Parse and process
const payload : WebhookPayload = JSON . parse ( body );
console . log ( 'Session completed:' , payload . session_id );
// Access structured output if present
if ( payload . structured_output ) {
console . log ( 'Structured data:' , payload . structured_output );
// Process custom fields from workflow
}
return NextResponse . json ({ received: true });
} catch ( error ) {
if ( error instanceof WebhookVerificationError ) {
return NextResponse . json (
{ error: 'Invalid signature' },
{ status: 401 }
);
}
return NextResponse . json (
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// pages/api/webhook.ts
import type { NextApiRequest , NextApiResponse } from 'next' ;
import { verifySimplexWebhook , WebhookVerificationError , WebhookPayload } from 'simplex-ts' ;
// Disable Next.js body parsing
export const config = {
api: {
bodyParser: false ,
},
};
// Helper to read raw body
async function getRawBody ( req : NextApiRequest ) : Promise < string > {
const chunks : Buffer [] = [];
return new Promise (( resolve , reject ) => {
req . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ));
req . on ( 'end' , () => resolve ( Buffer . concat ( chunks ). toString ( 'utf8' )));
req . on ( 'error' , reject );
});
}
export default async function handler (
req : NextApiRequest ,
res : NextApiResponse
) {
if ( req . method !== 'POST' ) {
return res . status ( 405 ). json ({ error: 'Method not allowed' });
}
const webhookSecret = process . env . SIMPLEX_WEBHOOK_SECRET ! ;
try {
const body = await getRawBody ( req );
verifySimplexWebhook ( body , req . headers , webhookSecret );
const payload : WebhookPayload = JSON . parse ( body );
console . log ( 'Session completed:' , payload . session_id );
// Access structured output if present
if ( payload . structured_output ) {
console . log ( 'Structured data:' , payload . structured_output );
// Process custom fields from workflow
}
res . json ({ received: true });
} catch ( error ) {
if ( error instanceof WebhookVerificationError ) {
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
res . status ( 500 ). json ({ error: 'Internal server error' });
}
}
If you’re not using our SDKs, you can manually verify webhook signatures: TypeScript/Node.js import crypto from 'crypto' ;
function verifyWebhook ( body : string , signature : string , secret : string ) : boolean {
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( body , 'utf8' )
. digest ( 'hex' );
// Use constant-time comparison to prevent timing attacks
return crypto . timingSafeEqual (
Buffer . from ( expectedSignature ),
Buffer . from ( signature )
);
}
// Usage
app . post ( '/webhook' , express . raw ({ type: 'application/json' }), ( req , res ) => {
const body = req . body . toString ( 'utf8' );
const signature = req . headers [ 'x-simplex-signature' ];
const secret = process . env . SIMPLEX_WEBHOOK_SECRET ;
if ( ! verifyWebhook ( body , signature , secret )) {
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
// Process webhook...
const payload = JSON . parse ( body );
res . json ({ received: true });
});
Important security notes
Always use the raw request body for signature verification (don’t parse it as JSON first)
Use constant-time comparison functions to prevent timing attacks (crypto.timingSafeEqual in Node.js)
Never log or expose your webhook secret
Always use HTTPS for your webhook endpoints
Respond quickly to acknowledge receipt
Best practices
1. Always verify signatures
Never process webhook data without first verifying the signature. This ensures the request actually came from Simplex and hasn’t been tampered with.
// ✅ Good - verify first
verifySimplexWebhook ( body , headers , secret );
const payload = JSON . parse ( body );
// ❌ Bad - processing without verification
const payload = JSON . parse ( body );
processWebhook ( payload );
2. Respond quickly
Your webhook endpoint should acknowledge receipt within 30 seconds to prevent retries from the Simplex server. If you need to perform long-running operations (like processing large files or making external API calls), respond with a 200 OK immediately and process the data asynchronously.
// ✅ Good - respond quickly, process later
app . post ( '/webhook' , async ( req , res ) => {
verifyWebhook ( /* ... */ );
// Respond immediately
res . json ({ received: true });
// Process asynchronously (e.g., using a job queue)
processWebhookAsync ( payload );
});
// ❌ Bad - long processing blocks response
app . post ( '/webhook' , async ( req , res ) => {
verifyWebhook ( /* ... */ );
await longRunningTask ( payload ); // Don't make Simplex wait!
res . json ({ received: true });
});
3. Handle errors gracefully
Return appropriate HTTP status codes:
200 - Webhook received and verified successfully
401 - Signature verification failed
500 - Server error
4. Use environment variables
Store your webhook secret in environment variables, never in code:
# .env
SIMPLEX_WEBHOOK_SECRET = your-webhook-secret-here
5. Test with ngrok
Use ngrok to test webhooks locally before deploying to production:
Then update your webhook URL in the Simplex dashboard to the ngrok URL.
Troubleshooting
”Invalid signature” errors
Cause : The most common cause is parsing the request body as JSON before verification.
Solution : Always use the raw request body:
Express: Use express.raw({ type: 'application/json' })
Next.js: Disable bodyParser in API route config
Webhook not receiving requests
Check that your webhook URL is correct in the Dashboard Settings
Ensure your endpoint is publicly accessible (use ngrok for local testing)
Verify your server is running and responding to POST requests
Ensure you’re reading headers correctly. Header names are case-insensitive in HTTP, but some frameworks may normalize them:
X-Simplex-Signature
x-simplex-signature
Need help?
If you have questions about webhooks or need help with integration, reach out to us on Slack or at [email protected] .