Receiving AgentLane Webhooks: Verifying X-AgentLane-Signature in Node, Python and PHP
AgentLane signs every outbound webhook the same way Stripe does — HMAC-SHA256 over a timestamp-prefixed body. Here's the exact header format and working verification code in three languages.
By AgentLane Founder · Founder
Every AgentLane webhook delivery is a signed JSON POST. The signature is HMAC-SHA256 over ${timestamp}.${rawBody}, using the secret you were handed once at registration, sent as X-AgentLane-Signature: t=<timestamp>,v1=<signature> alongside an X-AgentLane-Event header naming the event type. This is deliberately modeled on Stripe's webhook convention — if you've verified a Stripe webhook before, the shape here will look familiar. This post is the verification code, in the three languages people actually ask for.
The wire format, exactly
This is copied directly from API Access → Outbound Webhooks, not paraphrased:
body = JSON.stringify({ id, type, createdAt, data })
timestamp = <current Unix time in seconds>
signature = hex(HMAC_SHA256(secret, `${timestamp}.${body}`))
POST <your endpoint URL>
Content-Type: application/json
X-AgentLane-Event: <event type, e.g. "agent_request.resolved">
X-AgentLane-Signature: t=<timestamp>,v1=<signature>
Two details that matter for a correct implementation, both easy to get subtly wrong:
Sign the raw body, not a re-serialized one. The HMAC is computed over the exact bytes AgentLane sent — if your framework parses the JSON before you can get at the raw string and you re-JSON.stringify it to verify, key ordering or whitespace differences will produce a different hash and every signature will fail to match, even though nothing was tampered with. Capture the raw request body before any JSON parsing middleware touches it.
Use constant-time comparison for the signature check. A naive signature === expected string comparison leaks timing information an attacker could theoretically use to guess the correct signature byte by byte. Every example below uses the language's built-in constant-time compare for exactly this reason.
The full event list — agent_request.submitted, agent_request.resolved, agent_request.provisioning_failed, deletion_request.submitted, deletion_request.resolved, account.suspended, client_user.invited, usage.threshold_crossed, seat_purchase.completed, and the synthetic webhook.test — is documented in full at API Access → Outbound Webhooks.
Node.js
const crypto = require("crypto");
/**
* Verifies an AgentLane webhook delivery.
* `rawBody` must be the exact request body bytes, not a re-parsed object —
* capture it before any JSON body-parsing middleware runs.
*/
function verifyAgentLaneSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) {
throw new Error("Malformed X-AgentLane-Signature header");
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
const signatureBuf = Buffer.from(signature, "hex");
const expectedBuf = Buffer.from(expected, "hex");
if (
signatureBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(signatureBuf, expectedBuf)
) {
throw new Error("Signature mismatch");
}
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > toleranceSeconds) {
throw new Error("Timestamp outside tolerance window — possible replay");
}
return true;
}
// Express example — express.raw() is required so req.body stays a Buffer.
app.post(
"/webhooks/agentlane",
express.raw({ type: "application/json" }),
(req, res) => {
try {
verifyAgentLaneSignature(
req.body, // Buffer, coerced to string in the HMAC call
req.header("X-AgentLane-Signature"),
process.env.AGENTLANE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Signature verification failed: ${err.message}`);
}
const event = JSON.parse(req.body.toString("utf8"));
// handle event.type / event.data here
res.sendStatus(200);
}
);
Python
import hashlib
import hmac
import time
def verify_agentlane_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
"""
raw_body must be the exact request bytes — read it before any JSON
deserialization, since re-serializing can change byte-for-byte content.
"""
parts = dict(
item.split("=", 1) for item in signature_header.split(",")
)
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
raise ValueError("Malformed X-AgentLane-Signature header")
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError("Signature mismatch")
age_seconds = abs(time.time() - float(timestamp))
if age_seconds > tolerance_seconds:
raise ValueError("Timestamp outside tolerance window — possible replay")
return True
# Flask example
from flask import Flask, request, abort
import json, os
app = Flask(__name__)
@app.route("/webhooks/agentlane", methods=["POST"])
def agentlane_webhook():
try:
verify_agentlane_signature(
request.get_data(), # raw bytes, before Flask's JSON parsing
request.headers.get("X-AgentLane-Signature", ""),
os.environ["AGENTLANE_WEBHOOK_SECRET"],
)
except ValueError as err:
abort(400, description=f"Signature verification failed: {err}")
event = json.loads(request.get_data())
# handle event["type"] / event["data"] here
return "", 200
PHP
<?php
/**
* $rawBody must be the exact request body — use php://input directly,
* before anything decodes and re-encodes it.
*/
function verifyAgentLaneSignature(string $rawBody, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool
{
$parts = [];
foreach (explode(',', $signatureHeader) as $pair) {
[$key, $value] = array_map('trim', explode('=', $pair, 2));
$parts[$key] = $value;
}
$timestamp = $parts['t'] ?? null;
$signature = $parts['v1'] ?? null;
if ($timestamp === null || $signature === null) {
throw new RuntimeException('Malformed X-AgentLane-Signature header');
}
$signedPayload = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $secret);
if (!hash_equals($expected, $signature)) {
throw new RuntimeException('Signature mismatch');
}
$ageSeconds = abs(time() - (int) $timestamp);
if ($ageSeconds > $toleranceSeconds) {
throw new RuntimeException('Timestamp outside tolerance window — possible replay');
}
return true;
}
// Plain PHP endpoint example
$rawBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_AGENTLANE_SIGNATURE'] ?? '';
$secret = getenv('AGENTLANE_WEBHOOK_SECRET');
try {
verifyAgentLaneSignature($rawBody, $signatureHeader, $secret);
} catch (RuntimeException $e) {
http_response_code(400);
echo 'Signature verification failed: ' . $e->getMessage();
exit;
}
$event = json_decode($rawBody, true);
// handle $event['type'] / $event['data'] here
http_response_code(200);
Testing your endpoint before it matters
Once an endpoint is registered, POST /partners/me/webhooks/:id/test sends a synthetic webhook.test event through the exact same signing and delivery pipeline as a real one — same header format, same HMAC, same retry queue. That's also available from Settings → Webhooks in the dashboard as a Send test action, and it's the fastest way to confirm your verification code is correct before a real agent_request.resolved event depends on it.

If a delivery keeps failing, GET /partners/me/webhooks/:id/deliveries returns the most recent 50 attempts, or check the same log visually from the dashboard:

A few things worth getting right on registration
A partner account may register at most 5 endpoints, and every URL is validated against private/internal IP ranges — both when you register it and again at delivery time — so an endpoint that later resolves to a private address (a misconfigured DNS entry, a tunneling tool pointed somewhere it shouldn't be) simply stops receiving deliveries rather than silently succeeding. The signing secret is returned exactly once, in the registration response — store it immediately in your own secrets manager, because there's no endpoint to retrieve it again later. See API Access for the full endpoint list and event catalog this post pulls from.
If you're building against the wider API rather than just webhooks, the full REST surface is browsable at /api/docs (Swagger UI), documented at API Access. If webhooks are specifically for reacting to agent provisioning outcomes, Deploy your first agent is the dashboard-side flow that actually fires agent_request.resolved and agent_request.provisioning_failed.
Every header name, field, and algorithm in this post is copied from AgentLane's own API reference, not reconstructed from memory — run the test-event action against your own implementation before trusting it with a real delivery.
Frequently asked questions
- What algorithm does AgentLane use to sign webhooks?
- HMAC-SHA256, computed over the string `${timestamp}.${rawBody}` using the endpoint's own signing secret, hex-encoded. It's modeled directly on Stripe's webhook signing convention.
- Where do I get the signing secret?
- It's returned exactly once, in the response body when you register an endpoint via POST /partners/me/webhooks or from Settings → Webhooks in the dashboard. If you lose it, there's no way to retrieve it again — delete the endpoint and register a new one.
- Do I need to check the timestamp, or just the signature?
- Both. A matching signature only proves the payload wasn't tampered with — it doesn't stop a captured request from being replayed later. Reject anything where the timestamp is further in the past than you're willing to tolerate; that replay-window check is the receiver's responsibility, not something AgentLane enforces for you.
- What happens if my endpoint is unreachable when a webhook fires?
- Deliveries run on a BullMQ queue with 5 attempts and exponential backoff starting at 5 seconds, a 10-second timeout per attempt. A delivery's logged status ends as delivered (2xx response), pending (still retrying), or failed (attempts exhausted or the endpoint rejected outright, e.g. by the SSRF check on registration).