Verify Eventually webhook signatures
Every webhook delivery is an HTTP POST signed with HMAC-SHA256 so you can confirm it came from Eventually and wasn't tampered with. Verify each delivery against your endpoint's signing secret before you process it.
Delivery headers
Each delivery is an HTTP POST with a JSON body and these headers:
Content-Type: application/jsonUser-Agent: Eventually-Webhooks/1.0X-Eventually-Event: The event identifier, for exampleregistration.created.X-Eventually-Delivery-Id: A stable ID for this delivery, unchanged across retries.X-Eventually-Signature: The signature, in the formt=<unix timestamp>,v1=<hex HMAC-SHA256>.
The signature's HMAC is computed over "<timestamp>.<raw request body>" with your endpoint's signing secret.
Verify the signature
Always verify against the raw request body, before any JSON parsing or re-serialization. Re-serializing the JSON produces a different body and a signature that won't match.
Check the timestamp as well as the HMAC. Reject any delivery whose t is more than five minutes (300 seconds) old, which is what the tolerance argument does in both samples below: tolerance in Ruby and toleranceSeconds in Node, each defaulting to 300 seconds. A valid signature replayed hours later is still a replay.
Ruby:
def verify_eventually_signature(request, secret, tolerance: 300)
header = request.headers["X-Eventually-Signature"].to_s
parts = header.split(",").to_h { |kv| kv.split("=", 2) }
timestamp = parts["t"].to_i
return false if (Time.now.to_i - timestamp).abs > tolerance
body = request.body.read
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
ActiveSupport::SecurityUtils.secure_compare(expected, parts["v1"].to_s)
endNode:
const crypto = require("crypto")
function verifyEventuallySignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")))
const timestamp = parseInt(parts.t, 10)
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex")
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""))
}Reject the delivery if the signature doesn't match, and respond with a non-2xx status so Eventually retries it.
Deduplicate on the delivery ID
Use X-Eventually-Delivery-Id to avoid processing the same event twice. The ID stays the same across retries and across redeliveries, so store the IDs you've already handled and skip any you've seen. When a redelivery arrives with an ID you've seen, drop the duplicate only if the first copy was fully processed, because a redelivery usually means the original attempt failed on your side.
Endpoint requirements
Your endpoint must respond with a 2xx status within 10 seconds. Redirects and other non-2xx responses count as failed attempts. Respond first, then process the payload.
Webhook payloads can contain attendee and event information, so secure your receiver and control who can access it.
Retries and automatic disablement
A failed delivery is attempted up to 5 times total (the initial attempt plus up to 4 retries) with increasing backoff. A connection that is refused, times out, or fails its TLS handshake counts as a failed attempt and is retried the same way a non-2xx response is.
After 10 consecutive exhausted deliveries, the endpoint is automatically disabled and the team owner is emailed. Fix your server, then re-enable the endpoint from Settings, which resets the failure counter. See Set up outbound webhooks for how to re-enable an endpoint.