Webhooks
&Open can send webhook events to notify your application when something happens in your account. Each webhook delivery is signed so you can verify it came from &Open and has not been tampered with.
Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
AndOpen-Webhook-Dispatch-Timestamp | Unix epoch seconds when the request was dispatched |
AndOpen-Webhook-Signature | HMAC-SHA256 signature of the request |
Content-Type | Always application/vnd.api+json |
Verifying signatures
To verify a webhook request:
- Extract the timestamp from
AndOpen-Webhook-Dispatch-Timestamp - Concatenate the timestamp and the raw request body with a dot:
"{timestamp}.{body}" - Compute the HMAC-SHA256 of that string using your webhook secret
- Compare the result to
AndOpen-Webhook-Signatureusing a constant-time comparison
Replay protection
The dispatch timestamp enables stateless replay protection. After verifying the signature, check that the timestamp is within an acceptable window of the current time (e.g. 5 minutes). A captured request becomes useless after the tolerance window expires.
The dispatch timestamp is set fresh on each delivery attempt, so legitimate retries will not be rejected as stale.
Each webhook payload also includes the event ID, which you can use for stateful deduplication if needed.
Code Examples
- Ruby
- Python
- JavaScript
- Java
- PHP
- cURL
require 'openssl'
timestamp = request.headers["AndOpen-Webhook-Dispatch-Timestamp"]
signature = request.headers["AndOpen-Webhook-Signature"]
body = request.body.read
# Reject stale requests (replay protection)
if (Time.now.to_i - timestamp.to_i).abs > 300
head :forbidden
return
end
# Verify signature
signed_content = "#{timestamp}.#{body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_content)
unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
head :forbidden
end
import hmac, hashlib, time
timestamp = request.headers["AndOpen-Webhook-Dispatch-Timestamp"]
signature = request.headers["AndOpen-Webhook-Signature"]
body = request.body # must be a string, not bytes
# Reject stale requests (replay protection)
if abs(time.time() - int(timestamp)) > 300:
return 403, "Timestamp too old"
# Verify signature
signed_content = f"{timestamp}.{body}"
expected = hmac.new(
secret.encode(),
signed_content.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return 403, "Invalid signature"
const crypto = require('crypto');
const timestamp = request.headers['andopen-webhook-dispatch-timestamp'];
const signature = request.headers['andopen-webhook-signature'];
const body = request.body;
// Reject stale requests (replay protection)
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(403).send('Timestamp too old');
}
// Verify signature
const signedContent = `${timestamp}.${body}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
if (expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)) {
return res.status(403).send('Invalid signature');
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.stream.Collectors;
String timestamp = request.getHeader("AndOpen-Webhook-Dispatch-Timestamp");
String signature = request.getHeader("AndOpen-Webhook-Signature");
String body = request.getReader().lines().collect(Collectors.joining());
// Reject stale requests (replay protection)
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - Long.parseLong(timestamp)) > 300) {
response.sendError(403, "Timestamp too old");
return;
}
// Verify signature
String signedContent = timestamp + "." + body;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
byte[] hash = mac.doFinal(signedContent.getBytes());
String expected = HexFormat.of().formatHex(hash);
if (!MessageDigest.isEqual(expected.getBytes(), signature.getBytes())) {
response.sendError(403, "Invalid signature");
}
<?php
$timestamp = $_SERVER['HTTP_ANDOPEN_WEBHOOK_DISPATCH_TIMESTAMP'];
$signature = $_SERVER['HTTP_ANDOPEN_WEBHOOK_SIGNATURE'];
$body = file_get_contents('php://input');
// Reject stale requests (replay protection)
if (abs(time() - intval($timestamp)) > 300) {
http_response_code(403);
exit('Timestamp too old');
}
// Verify signature
$signedContent = "{$timestamp}.{$body}";
$expected = hash_hmac('sha256', $signedContent, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit('Invalid signature');
}
?>
# Verify a webhook manually using the signature and timestamp
TIMESTAMP=$(date +%s) # or copy from the AndOpen-Webhook-Dispatch-Timestamp header
BODY='{"data":{"type":"webhook_events","id":"..."}}'
SECRET="your_webhook_secret"
SIGNED_CONTENT="${TIMESTAMP}.${BODY}"
EXPECTED=$(echo -n "$SIGNED_CONTENT" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
echo "Expected signature: $EXPECTED"