Verifying Webhooks
Validate webhook signatures to ensure events are authentically from AgentPost
Verifying Webhooks
AgentPost signs every webhook delivery with an HMAC-SHA256 signature using your endpoint's secret. Verifying this signature ensures that events are genuinely from AgentPost and haven't been tampered with in transit.
How Signing Works
When AgentPost delivers a webhook event, it includes two headers:
| Header | Description |
|---|---|
x-agentpost-signature | HMAC-SHA256 signature of the payload |
x-agentpost-timestamp | Unix timestamp (seconds) of when the delivery was sent |
The signature is computed as:
HMAC-SHA256(
key: webhook_secret,
message: "{timestamp}.{raw_body}"
)The timestamp and raw request body are concatenated with a period (.) separator, then signed with your webhook secret (the whsec_... value returned when you created the endpoint).
Verification Steps
To verify a webhook signature:
- Extract the
x-agentpost-signatureandx-agentpost-timestampheaders - Concatenate the timestamp and raw body:
"{timestamp}.{body}" - Compute HMAC-SHA256 using your webhook secret
- Compare the computed signature with the received signature using constant-time comparison
- Validate the timestamp is within an acceptable window (recommended: 5 minutes)
Code Examples
import { createHmac, timingSafeEqual } from 'crypto';
const WEBHOOK_SECRET = process.env.AGENTPOST_WEBHOOK_SECRET!;
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string | null,
timestampHeader: string | null,
): boolean {
if (!signatureHeader || !timestampHeader) {
return false;
}
// 1. Validate timestamp to prevent replay attacks
const timestamp = parseInt(timestampHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TIMESTAMP_TOLERANCE_SECONDS) {
console.warn('Webhook timestamp outside tolerance window');
return false;
}
// 2. Compute expected signature
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// 3. Constant-time comparison to prevent timing attacks
const expected = Buffer.from(expectedSignature, 'utf8');
const received = Buffer.from(signatureHeader, 'utf8');
if (expected.length !== received.length) {
return false;
}
return timingSafeEqual(expected, received);
}
// Usage in a Hono route handler
app.post('/webhooks/agentpost', async (c) => {
const rawBody = await c.req.text();
const signature = c.req.header('x-agentpost-signature');
const timestamp = c.req.header('x-agentpost-timestamp');
if (!verifyWebhookSignature(rawBody, signature, timestamp)) {
return c.json({ error: 'Invalid signature' }, 401);
}
const event = JSON.parse(rawBody);
// Process the verified event...
return c.json({ received: true });
});import hmac
import hashlib
import time
import os
WEBHOOK_SECRET = os.environ["AGENTPOST_WEBHOOK_SECRET"]
TIMESTAMP_TOLERANCE_SECONDS = 300 # 5 minutes
def verify_webhook_signature(
raw_body: str,
signature_header: str | None,
timestamp_header: str | None,
) -> bool:
if not signature_header or not timestamp_header:
return False
# 1. Validate timestamp to prevent replay attacks
try:
timestamp = int(timestamp_header)
except ValueError:
return False
now = int(time.time())
if abs(now - timestamp) > TIMESTAMP_TOLERANCE_SECONDS:
print("Webhook timestamp outside tolerance window")
return False
# 2. Compute expected signature
signed_payload = f"{timestamp}.{raw_body}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# 3. Constant-time comparison to prevent timing attacks
return hmac.compare_digest(expected_signature, signature_header)
# Usage in a Flask route handler
@app.route("/webhooks/agentpost", methods=["POST"])
def handle_webhook():
raw_body = request.get_data(as_text=True)
signature = request.headers.get("x-agentpost-signature")
timestamp = request.headers.get("x-agentpost-timestamp")
if not verify_webhook_signature(raw_body, signature, timestamp):
return jsonify({"error": "Invalid signature"}), 401
event = json.loads(raw_body)
# Process the verified event...
return jsonify({"received": True})# Manual signature verification using command-line tools
# This is useful for debugging webhook issues
# Variables
WEBHOOK_SECRET="whsec_your_secret_here"
TIMESTAMP="1709910600"
BODY='{"id":"evt_01JQ8X","type":"message.received","data":{}}'
# Compute the expected signature
SIGNED_PAYLOAD="${TIMESTAMP}.${BODY}"
EXPECTED_SIGNATURE=$(echo -n "$SIGNED_PAYLOAD" | \
openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | \
awk '{print $2}')
echo "Expected signature: $EXPECTED_SIGNATURE"
# Compare with the signature from the webhook header
RECEIVED_SIGNATURE="abc123..." # from x-agentpost-signature header
if [ "$EXPECTED_SIGNATURE" = "$RECEIVED_SIGNATURE" ]; then
echo "Signature valid"
else
echo "Signature INVALID"
fiSecurity Considerations
Constant-Time Comparison
Always use constant-time comparison functions when checking signatures:
- TypeScript:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - Go:
crypto/subtle.ConstantTimeCompare()
Regular string comparison (=== or ==) is vulnerable to timing attacks. An attacker could measure response times to determine how many characters of the signature match, eventually reconstructing the full secret.
Replay Prevention
The timestamp header prevents replay attacks, where an attacker captures a valid webhook delivery and re-sends it later. By checking that the timestamp is within a 5-minute window, you ensure that old deliveries are rejected.
If your application requires stricter replay prevention:
- Store processed event IDs in a cache (Redis, in-memory set)
- Reject events with IDs you've already processed
- This also handles the at-least-once delivery guarantee (duplicate events)
const processedEvents = new Set<string>();
app.post('/webhooks/agentpost', async (c) => {
const event = JSON.parse(await c.req.text());
// Reject duplicate events
if (processedEvents.has(event.id)) {
return c.json({ received: true, duplicate: true });
}
processedEvents.add(event.id);
// Process the event...
return c.json({ received: true });
});Secret Rotation
If you suspect your webhook secret has been compromised:
- Create a new webhook endpoint with a fresh secret
- Update your application to accept the new secret
- Delete the old endpoint
// Rotate by creating a new endpoint and deleting the old
const newEndpoint = await client.webhookEndpoints.create({
url: 'https://your-app.com/webhooks/agentpost',
events: ['message.received', 'message.bounced'],
});
// Update your app's WEBHOOK_SECRET env var with newEndpoint.secret
// Then delete the old endpoint
await client.webhookEndpoints.delete(oldEndpointId);# Rotate by creating a new endpoint and deleting the old
new_endpoint = client.webhook_endpoints.create(
url="https://your-app.com/webhooks/agentpost",
events=["message.received", "message.bounced"],
)
# Update your app's WEBHOOK_SECRET env var with new_endpoint.secret
# Then delete the old endpoint
client.webhook_endpoints.delete(old_endpoint_id)# Create a new endpoint (returns new secret)
curl -X POST https://api.agent-post.dev/api/v1/webhooks/endpoints \
-H "Authorization: Bearer ap_sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/agentpost",
"events": ["message.received", "message.bounced"]
}'
# Delete the old endpoint
curl -X DELETE https://api.agent-post.dev/api/v1/webhooks/endpoints/whe_old_endpoint_id \
-H "Authorization: Bearer ap_sk_live_your_key_here"Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Signature always fails | Using parsed JSON instead of raw body | Use the raw request body string for verification |
| Intermittent verification failures | Clock skew between servers | Increase timestamp tolerance to 10 minutes |
| Signature mismatch after deploy | Wrong secret in environment variables | Verify secret matches the endpoint's whsec_... value |
| Timestamp validation fails | Server time out of sync | Ensure server uses NTP time synchronization |