Webhooks
Signed tenant webhooks
Attesto sends signed HTTP POST deliveries for important lifecycle events. Your endpoint must verify the signature, dedupe delivery IDs, and return a 2xx response within 10 seconds.
Supported events
| Event | When it fires |
|---|---|
batch.confirmed | A Merkle batch of tenant events was confirmed on-chain. |
batch.failed | A batch permanently failed or exceeded the orphan window. |
event.anchored | Per-event notification after the related batch is confirmed. |
proofstream.checkpointed | A Proofstream checkpoint closed and is available for verification. |
proofstream.fork_detected | Fork evidence was created for a stream history conflict. |
proofstream.bundle_ready | A verifier bundle is ready for the configured range. |
Create a subscription
: "${ATTESTO_WEBHOOK_URL:?set this to an HTTPS endpoint controlled by your organization}"
curl -X POST https://dashboard.attesto.eu/v1/webhooks \
-H "Authorization: Bearer $ATTESTO_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- <<JSON
{
"name": "audit-notifications",
"url": "$ATTESTO_WEBHOOK_URL",
"eventTypes": ["batch.confirmed", "batch.failed"],
"description": "Posts lifecycle notifications to the configured endpoint"
}
JSON
Verify delivery signatures
Each delivery includes these headers:
X-Attesto-Event: event type.X-Attesto-Delivery-Id: unique delivery attempt ID.X-Attesto-Timestamp: Unix seconds used in the signature.X-Attesto-Signature: HMAC-SHA256 overtimestamp + "." + raw_body.
Delivery body shape:
{
"id": "delivery_...",
"event": "proofstream.bundle_ready",
"tenant_id": "tenant_...",
"created_at": "2026-06-07T12:00:00Z",
"data": {
"stream_id": "str_...",
"bundle_id": "bundle_...",
"from_seq_no": 1,
"to_seq_no": 250,
"verification_url": "https://verify.attesto.eu/v2/verify"
}
}
import hashlib
import hmac
import time
def verify(signing_value: str, body: bytes, timestamp: str, signature: str) -> bool:
ts = int(timestamp)
if abs(int(time.time()) - ts) > 300:
return False
expected = hmac.new(
signing_value.encode("ascii"),
f"{ts}.".encode("ascii") + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
Minimal Node handler
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
function verifyDelivery(signingValue, body, timestamp, signature) {
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
return false;
}
const expected = crypto
.createHmac("sha256", signingValue)
.update(`${timestamp}.`)
.update(body)
.digest("hex");
const expectedBytes = Buffer.from(expected, "hex");
const receivedBytes = Buffer.from(signature || "", "hex");
return expectedBytes.length === receivedBytes.length
&& crypto.timingSafeEqual(expectedBytes, receivedBytes);
}
app.post("/attesto/webhook", async (req, res) => {
const ok = verifyDelivery(
process.env.ATTESTO_WEBHOOK_SIGNING_VALUE,
req.body,
req.header("X-Attesto-Timestamp"),
req.header("X-Attesto-Signature"),
);
if (!ok) return res.status(401).end();
const deliveryId = req.header("X-Attesto-Delivery-Id");
const payload = JSON.parse(req.body.toString("utf8"));
await recordDeliveryOnce(deliveryId, payload);
res.status(204).end();
});
Retry and dedupe
Delivery is at least once. Any non-2xx response, timeout, or refused
connection is retried. Dedupe by X-Attesto-Delivery-Id
before side effects.
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | Final attempt |
Endpoint requirements
Use HTTPS, verify the raw body before parsing, store delivery IDs for dedupe, return quickly, and move expensive processing to your own queue after signature verification succeeds.
