Webhooks
Receive review results automatically via secure webhook notifications.
How Webhooks Work
When a review is completed, we'll POST the results to the webhook_url you provided when submitting the content.
Webhook Payload
The payload structure depends on the reviewer's decision:
Accepted (No Changes)
When the reviewer accepts the AI response without modifications:
{
"review_id": 42,
"external_id": "TICKET-1234",
"decision": "accepted",
"edited_output": null,
"rejection_reason": null,
"metadata": {
"ticket_id": "1234",
"customer_id": "C789"
}
}
Use your original AI output - the reviewer approved it as-is!
Edited (Improved)
When the reviewer improves the AI response:
{
"review_id": 43,
"external_id": "TICKET-1235",
"decision": "edited",
"edited_output": "To return your order, please visit our returns portal...",
"rejection_reason": null,
"metadata": {
"ticket_id": "1235",
"customer_id": "C790"
}
}
Use the edited_output instead of your original - it's been improved by the reviewer!
Rejected
When the reviewer rejects the AI response:
{
"review_id": 44,
"external_id": "TICKET-1236",
"decision": "rejected",
"edited_output": null,
"rejection_reason": "Response contains inaccurate information",
"metadata": {
"ticket_id": "1236",
"customer_id": "C791"
}
}
Don't use the original output. Check rejection_reason and regenerate content or escalate to human support.
Why isn't the original AI output included?
The webhook focuses on review results only. Use the external_id to look up the original content in your system. This keeps payloads small and forces proper data architecture.
Webhook Delivery
| Method | POST |
|---|---|
| Content-Type | application/json |
| Timeout | 10 seconds |
| Retries | Up to 3 attempts with exponential backoff (20s, 40s, 60s) |
| Success | Any 2xx status code |
| Failure | After 3 failed attempts, status changes to webhook_failed |
Respond quickly!
Your webhook endpoint must respond with a 2xx status code within 10 seconds. Process heavy workloads asynchronously.
Webhook Security
All webhooks include HMAC-SHA256 signatures to verify authenticity and prevent tampering.
You MUST verify webhook signatures!
Without verification, attackers could send fake review results to your endpoint. Always validate the signature before processing.
Security Headers
Every webhook request includes these headers:
| Header | Format | Description |
|---|---|---|
X-Signature-256
|
sha256=<hex>
|
HMAC-SHA256 signature of request body |
X-Webhook-Timestamp
|
<unix_timestamp>
|
When webhook was sent (seconds since epoch) |
X-Webhook-ID
|
<uuid>
|
Unique identifier for this delivery |
X-Tenant-ID
|
<tenant_id>
|
Your tenant/organization identifier |
Signature Verification
Step 1: Get Your Webhook Secret
Sign in and navigate to Settings to generate a webhook secret.
Webhook secrets start with
cya_whs_
Step 2: Verify Each Webhook
- Extract the raw request body (before parsing JSON)
-
Extract signature from
X-Signature-256header (remove "sha256=" prefix) - Compute HMAC-SHA256 hash of raw body using your webhook secret
- Compare computed signature with received signature (constant-time comparison)
- Verify timestamp is recent (within 5 minutes) to prevent replay attacks
Verification Code Example
Here's a language-agnostic example showing the verification flow:
// 1. Extract signature and timestamp from webhook headers
signature = request.headers['X-CheckYourAI-Signature']
timestamp = request.headers['X-CheckYourAI-Timestamp']
// 2. Verify timestamp is recent (within 5 minutes)
current_time = current_unix_timestamp()
if (abs(current_time - timestamp) > 300) {
return error("Webhook timestamp too old")
}
// 3. Construct the signed payload
signed_payload = timestamp + "." + request.body
// 4. Compute HMAC-SHA256 signature
expected_signature = hmac_sha256(
key: YOUR_WEBHOOK_SECRET,
message: signed_payload
)
// 5. Compare signatures (use constant-time comparison!)
if (!constant_time_compare(signature, expected_signature)) {
return error("Invalid webhook signature")
}
// 6. Signature is valid - process webhook safely
process_webhook(request.body)
Implementation Notes:
- Timestamp validation: Prevents replay attacks by rejecting old webhooks
- Constant-time comparison: Prevents timing attacks when comparing signatures
- HMAC-SHA256: Use your language's crypto library (crypto in Node.js, hashlib in Python, etc.)
Complete Webhook Handler Example
Here's a complete example handling all decision types:
app.post('/webhooks/review-completed', async (req, res) => {
const params = req.body;
res.status(200).json({received: true});
try {
const original = await db.getSubmissionByExternalId(params.external_id);
if (params.decision === 'accepted') {
await processApprovedContent(original.ai_output, params.metadata);
} else if (params.decision === 'edited') {
await processApprovedContent(params.edited_output, params.metadata);
} else if (params.decision === 'rejected') {
await handleRejection(original, params.rejection_reason, params.metadata);
}
} catch (error) {
console.error('Error:', error);
}
});
Best Practices
Respond Immediately
Return 200 OK as quickly as possible, then process the payload asynchronously. Don't wait for database writes or external API calls.
Always Verify Signatures
NEVER skip signature verification, even in development. Use constant-time comparison to prevent timing attacks.
Check Timestamps
Reject webhooks with old timestamps (>5 minutes) to prevent replay attacks.
Handle All Decision Types
Your code should handle "accepted", "edited", and "rejected" decisions appropriately.
Make Webhook Processing Idempotent
Use X-Webhook-ID to track
processed webhooks and avoid duplicate processing if we retry.
Testing Webhooks Locally
Use a Tunneling Service
Expose your local development server to receive webhooks:
# Using ngrok
ngrok http 4000
# Or using localtunnel
npx localtunnel --port 4000
Then use the provided public URL as your webhook_url when testing.