Webhooks
&Open sends webhook events to notify your application when something happens in your account. Each delivery is signed, so you can verify it came from &Open and was not tampered with in transit.
Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
AndOpen-Webhook-Signature | HMAC-SHA256 signature of the request, hex-encoded |
AndOpen-Webhook-Dispatch-Timestamp | Unix time, in seconds, when the request was dispatched |
AndOpen-Webhook-Event-Id | UUID of the event — a stable handle for deduplication and log correlation |
AndOpen-Webhook-Event-Type | The type of event being delivered |
AndOpen-Webhook-API-Version | The API version the payload is serialised in |
Content-Type | application/json |
The payload itself is a flat JSON object in the same shape the API returns for that resource (see the API Reference).
Verifying signatures
Verify every delivery before you act on it:
- Read the timestamp from
AndOpen-Webhook-Dispatch-Timestamp. - Concatenate the timestamp and the raw request body with a dot:
"{timestamp}.{body}". Use the bytes you received — re-serialising the parsed JSON can change the body and break the signature. - Compute the HMAC-SHA256 of that string with your webhook secret.
- Compare the result to
AndOpen-Webhook-Signaturewith a constant-time comparison.
Replay protection
The dispatch timestamp gives you stateless replay protection: it stops an attacker who captures a delivery from re-sending it to your endpoint later. After the signature checks out, confirm the timestamp is within an acceptable window of the current time — five minutes is a reasonable default — and reject anything older. Because a valid request stops validating once the window passes, a captured one is useless to a replay attacker.
The timestamp is set fresh on each delivery attempt, so legitimate retries are not rejected as stale.
For stronger guarantees, deduplicate on AndOpen-Webhook-Event-Id: record the IDs you
have processed and ignore any you have already seen. The same ID is sent on every retry
of a given event.
Code examples
These examples verify the signature and reject stale requests. Send back a 403 when
either check fails.
- JavaScript
- Python
- Ruby
- Java
- PHP
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.ANDOPEN_WEBHOOK_SECRET;
// Mount the raw body parser so `req.body` is the exact bytes &Open sent.
// Verifying against a re-serialised JSON parse would break the signature.
app.post('/webhooks', express.raw({ type: '*/*' }), (req, res) => {
const timestamp = req.headers['andopen-webhook-dispatch-timestamp'];
const signature = req.headers['andopen-webhook-signature'];
const body = req.body; // Buffer of the raw 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');
}
// Signature is valid — handle the event.
res.status(200).send();
});
import hmac, hashlib, time
# Flask: request.get_data() returns the exact bytes &Open sent.
# Verifying against a re-serialised JSON parse would break the signature.
timestamp = request.headers["AndOpen-Webhook-Dispatch-Timestamp"]
signature = request.headers["AndOpen-Webhook-Signature"]
body = request.get_data() # raw request body, as bytes
# Reject stale requests (replay protection)
if abs(time.time() - int(timestamp)) > 300:
return "Timestamp too old", 403
# Verify signature
signed_content = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), signed_content, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return "Invalid signature", 403
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 javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
String timestamp = request.getHeader("AndOpen-Webhook-Dispatch-Timestamp");
String signature = request.getHeader("AndOpen-Webhook-Signature");
// Read the exact bytes &Open sent — re-serialising a parsed body would break the signature.
byte[] body = request.getInputStream().readAllBytes();
// 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 over "{timestamp}.{body}"
ByteArrayOutputStream signedContent = new ByteArrayOutputStream();
signedContent.write((timestamp + ".").getBytes(StandardCharsets.UTF_8));
signedContent.write(body);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(signedContent.toByteArray());
String expected = HexFormat.of().formatHex(hash);
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8))) {
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');
}
?>