OpenClaw Webhook Handling: Receiving External Events
OpenClaw webhook handling lets your AI agent respond to external events from Stripe, GitHub, HubSpot, and other services. Here's how to set it up.
OpenClaw can receive webhook events from external services and trigger agent actions in response. When a Stripe payment succeeds, a GitHub PR is merged, or a form is submitted, your DenchClaw agent can react automatically — updating records, sending notifications, or kicking off a workflow. This guide explains how webhook handling works in OpenClaw and how to set it up.
New to DenchClaw? Start with what DenchClaw is and the setup guide first.
How OpenClaw Handles Webhooks#
OpenClaw's gateway (running on port 19001) can expose HTTP endpoints that external services can POST to. When an event arrives, the gateway can either:
- Log it — write the payload to a file for later processing
- Trigger an agent session — wake up a subagent to process the event immediately
- Write to DuckDB — store the event for batch processing
The design is intentionally simple. Webhooks are just HTTP POST requests with a JSON body. OpenClaw doesn't need a special webhook framework — it can handle them with a small Node.js or shell script that the gateway exposes.
Architecture Overview#
External Service OpenClaw
───────────────── ──────────────────────────────────
Stripe → HTTPS POST → Gateway (port 19001)
GitHub → → Webhook handler script
HubSpot → → Parses payload
→ Writes to DuckDB / triggers agent
For production, you'll want a public URL. For local development, use ngrok or Cloudflare Tunnel to expose your local gateway.
Step 1: Set Up a Local Webhook Receiver#
Create a webhook handler script in your workspace:
mkdir -p ~/.openclaw-dench/workspace/webhooksCreate a simple Node.js webhook server:
// ~/.openclaw-dench/workspace/webhooks/server.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const PORT = 19100; // Different from the main gateway port
const LOG_DIR = path.join(process.env.HOME, '.openclaw-dench/workspace/webhooks/events');
fs.mkdirSync(LOG_DIR, { recursive: true });
const server = http.createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
res.end('Method Not Allowed');
return;
}
let body = '';
req.on('data', chunk => body += chunk.toString());
req.on('end', () => {
const source = req.url.split('/')[1] || 'unknown';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${source}-${timestamp}.json`;
const filepath = path.join(LOG_DIR, filename);
try {
const payload = JSON.parse(body);
// Write to log file
fs.writeFileSync(filepath, JSON.stringify({
source,
received_at: new Date().toISOString(),
headers: req.headers,
payload
}, null, 2));
console.log(`Received ${source} webhook: ${filename}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
// Process the event
processWebhook(source, payload, req.headers);
} catch (err) {
console.error('Failed to parse webhook:', err.message);
res.writeHead(400);
res.end('Bad Request');
}
});
});
function processWebhook(source, payload, headers) {
// Route to appropriate handler
if (source === 'stripe') handleStripe(payload, headers);
else if (source === 'github') handleGitHub(payload, headers);
else console.log(`Unhandled webhook source: ${source}`);
}
function handleStripe(payload, headers) {
const event = payload.type;
console.log(`Stripe event: ${event}`);
// Write to DuckDB via CLI
const safePayload = JSON.stringify(payload).replace(/'/g, "''");
try {
execSync(`duckdb ~/.openclaw-dench/workspace/workspace.duckdb "
INSERT OR IGNORE INTO webhook_events (source, event_type, payload, received_at)
VALUES ('stripe', '${event}', '${safePayload}'::JSON, CURRENT_TIMESTAMP)
"`);
} catch (err) {
console.error('DuckDB insert failed:', err.message);
}
}
function handleGitHub(payload, headers) {
const event = headers['x-github-event'];
console.log(`GitHub event: ${event}`);
// Add GitHub-specific handling
}
server.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
});Create the events table in DuckDB#
CREATE TABLE IF NOT EXISTS webhook_events (
id INTEGER PRIMARY KEY,
source VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
payload JSON,
processed BOOLEAN DEFAULT FALSE,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP
);Start the webhook server#
node ~/.openclaw-dench/workspace/webhooks/server.js &Or add it to your systemd setup (see the enterprise deployment guide for details).
Step 2: Expose Locally with ngrok#
For testing, expose your webhook server to the internet:
# Install ngrok if you haven't
brew install ngrok
# Expose port 19100
ngrok http 19100ngrok gives you a URL like https://abc123.ngrok.io. Use this as your webhook URL in external services.
For production, use a stable domain with your own reverse proxy instead of ngrok.
Step 3: Configure Webhooks in External Services#
Stripe#
Endpoint URL: https://your-domain.com/stripe
Events to listen for:
- payment_intent.succeeded
- payment_intent.payment_failed
- customer.subscription.created
- customer.subscription.deleted
- invoice.paid
- invoice.payment_failed
Always verify Stripe webhook signatures:
const stripe = require('stripe')(process.env.STRIPE_API_KEY);
function verifyStripe(rawBody, signature) {
return stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
}GitHub#
Payload URL: https://your-domain.com/github
Content type: application/json
Events: Pull requests, Push, Issues
Verify with the shared secret:
const crypto = require('crypto');
function verifyGitHub(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}Generic webhook (any service)#
Most services follow the same pattern: POST to your URL with a JSON body and optionally a signature header. The webhook server above handles all of them — just add a new else if branch in processWebhook().
Step 4: Create a Webhook Processing Skill#
Add a skill that tells your agent how to handle webhook events:
# Webhook Processing Skill
Use this skill when asked to process webhook events, check incoming events,
or respond to triggers from external services.
## Event Storage
Unprocessed webhook events are in the `webhook_events` table in DuckDB:
```sql
SELECT * FROM webhook_events WHERE processed = FALSE ORDER BY received_at DESC;Processing Events#
Stripe payment succeeded#
When a payment_intent.succeeded event arrives:
- Extract customer ID and amount from payload
- Find the matching contact in the CRM
- Update their payment_status to "paid"
- Create a note: "Payment received: $X on DATE"
- Mark the webhook event as processed
Stripe subscription cancelled#
When customer.subscription.deleted arrives:
- Find the customer in the CRM by stripe_customer_id
- Update their subscription_status to "cancelled"
- Create a follow-up task: "Check in with churned customer"
- Mark as processed
GitHub PR merged#
When a pull_request.closed + merged=true event arrives:
- Find the associated project in the CRM
- Update the project status if this was a release PR
- Add a note with the PR title and URL
Marking Events Processed#
After handling an event:
UPDATE webhook_events
SET processed = TRUE, processed_at = CURRENT_TIMESTAMP
WHERE id = EVENT_ID;
## Step 5: Trigger Agent Sessions from Webhooks
The most powerful pattern: incoming webhooks that automatically spawn an agent session to process them.
Modify your webhook server to call the OpenClaw API:
```javascript
const https = require('https');
function triggerAgent(prompt) {
const data = JSON.stringify({
message: prompt,
channel: 'webhook-processor'
});
const options = {
hostname: 'localhost',
port: 19001,
path: '/api/sessions/spawn',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENCLAW_API_KEY}`
}
};
const req = https.request(options);
req.write(data);
req.end();
}
// In handleStripe:
function handleStripe(payload, headers) {
if (payload.type === 'payment_intent.succeeded') {
const amount = (payload.data.object.amount / 100).toFixed(2);
const customer = payload.data.object.customer;
triggerAgent(
`A Stripe payment of $${amount} was received from customer ${customer}. ` +
`Load the CRM skill, find this customer, update their payment_status to "paid", ` +
`and create a note recording this payment.`
);
}
}
This creates a feedback loop: external event → webhook → agent session → CRM update. Fully automated.
Step 6: Queue-Based Processing (for High Volume)#
For high-volume webhooks, don't process synchronously. Write to DuckDB immediately (fast) and process in batches:
// Webhook handler: just write to DB
function handleStripe(payload) {
db.run(
'INSERT INTO webhook_events (source, event_type, payload) VALUES (?, ?, ?)',
['stripe', payload.type, JSON.stringify(payload)]
);
// Return 200 immediately
}Then use a scheduled task or heartbeat to process the queue:
Check for unprocessed webhook events in the webhook_events table.
For each unprocessed Stripe payment_intent.succeeded event:
1. Update the matching CRM contact's payment_status
2. Create a payment note
3. Mark the event as processed
Process up to 50 events per run.
Security Best Practices#
Always verify signatures. Every major webhook provider signs their payloads. Without verification, anyone can POST to your webhook URL and trigger agent actions.
Use HTTPS only. Never accept webhooks over plain HTTP in production.
Validate event types before acting. Check that the event type matches what you expect before taking action. An unexpected event type should be logged, not processed.
Rate limit your webhook endpoint. Add rate limiting to prevent flood attacks:
# In nginx config
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=100r/s;
limit_req zone=webhooks burst=50 nodelay;Respond quickly, process asynchronously. Always return HTTP 200 within 5 seconds or the sending service will retry. Write to DuckDB synchronously, process the event asynchronously.
Debugging Webhooks#
When something's not working:
-
Check the events directory. Every webhook is logged to
~/.openclaw-dench/workspace/webhooks/events/. The raw payload is there. -
Check DuckDB. Verify events are being written:
SELECT * FROM webhook_events ORDER BY received_at DESC LIMIT 10; -
Check the webhook server logs. Run the server in foreground (
node server.jswithout&) to see real-time output. -
Use Stripe CLI for local testing:
stripe listen --forward-to localhost:19100/stripe stripe trigger payment_intent.succeeded -
Use GitHub's webhook delivery logs. GitHub shows every delivery attempt and the response in Settings → Webhooks → Recent Deliveries.
FAQ#
Q: Can webhooks trigger while I'm not using DenchClaw? A: Yes, if the webhook server is running as a background service. Pair it with the queue-based approach so events are stored even if the agent isn't available to process them immediately.
Q: How do I handle webhook retries?
A: Most services retry on non-200 responses. Use the event ID from the payload to deduplicate: INSERT OR IGNORE INTO webhook_events (event_id, ...) with a unique constraint on event_id.
Q: What if my server is behind a NAT? A: Use ngrok for development, or a VPS with a public IP for production. Cloudflare Tunnel is another option that works without port-forwarding.
Q: Can I test webhooks without a public URL?
A: Yes. Stripe CLI (stripe listen), GitHub Smee.io, and similar tools forward webhooks to localhost.
Q: How many webhooks per second can OpenClaw handle? A: The limiting factor is DuckDB write speed, which is very fast for sequential inserts (~10,000/sec). At high volume, the async queue pattern handles this without issue.
Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →
