Webhooks
Verifying signatures
Every delivery is signed with HMAC-SHA256 over the raw body. Verify it before acting on a payload — an endpoint that skips this accepts anything that learns its URL.
Delivery headers
Each delivery carries these headers:
| Header | Description |
|---|---|
X-Inkfree-Event | Event type, e.g. envelope.completed |
X-Inkfree-Delivery | Unique delivery id — use as your idempotency key |
X-Inkfree-Timestamp | Unix epoch seconds at signing time |
X-Inkfree-Signature | sha256=<hex hmac> |
X-Inkfree-Truncated | true when the payload was capped at 256 KiB |
X-Inkfree-Truncated
X-Inkfree-Truncated: true, read the full body from the delivery history endpoint rather than acting on the truncated copy.The signature
The signature is sha256_hex(hmac(secret, timestamp + "." + raw_body)), with a 300-second timestamp tolerance. Deliveries are retried on failure, so the same X-Inkfree-Delivery may arrive more than once — deduplicate on it. Runnable Node and Python verification samples are served from GET /public/inkfree/webhooks/docs/verification, and the full event catalog from GET /public/inkfree/webhooks/events; neither requires authentication.
sha256_hex( hmac( secret, timestamp + "." + raw_body ) )The value arrives as sha256=<hex>. Sign the raw request body, not a re-serialized object — parsing and re-encoding JSON changes key order and whitespace, and the digest will not match.
Three things to get right
Compare in constant time. A byte-by-byte == is a timing oracle for the signature.
Enforce the 300-second timestamp tolerance, or a captured delivery can be replayed against you indefinitely.
Reject, do not ignore, a bad signature — respond 401 and log it.
No signature header at all?
secret stored nothing and left deliveries unsigned. The samples below correctly reject those. Set a secret on it with PATCH /webhooks/{id}, then re-test with the test-event endpoint.Node.js
const crypto = require("crypto");
// Inkfree signs the RAW body. Mount a raw body parser — verifying a
// re-serialized JSON object will not match, because key order and
// whitespace change.
app.post("/inkfree", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("X-Inkfree-Signature") ?? "";
const timestamp = req.header("X-Inkfree-Timestamp") ?? "";
const deliveryId = req.header("X-Inkfree-Delivery");
// Reject replays outside the 300-second tolerance window.
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("stale timestamp");
}
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.INKFREE_WEBHOOK_SECRET)
.update(timestamp + "." + req.body.toString("utf8"))
.digest("hex");
// Constant-time compare — a plain === leaks the signature byte by byte.
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).send("bad signature");
// Retries reuse the delivery id, so deduplicate before doing any work.
if (alreadyProcessed(deliveryId)) return res.sendStatus(200);
handleEvent(JSON.parse(req.body.toString("utf8")));
res.sendStatus(200);
});Python
import hashlib
import hmac
import os
import time
from flask import request, abort
SECRET = os.environ["INKFREE_WEBHOOK_SECRET"].encode()
@app.post("/inkfree")
def inkfree_webhook():
signature = request.headers.get("X-Inkfree-Signature", "")
timestamp = request.headers.get("X-Inkfree-Timestamp", "")
delivery_id = request.headers.get("X-Inkfree-Delivery")
# Reject replays outside the 300-second tolerance window.
if not timestamp or abs(time.time() - int(timestamp)) > 300:
abort(400, "stale timestamp")
# Read the raw bytes before any JSON parsing — that is what was signed.
raw = request.get_data()
expected = "sha256=" + hmac.new(
SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401, "bad signature")
# Retries reuse the delivery id, so deduplicate before doing any work.
if already_processed(delivery_id):
return "", 200
handle_event(request.get_json())
return "", 200Deduplication
Deliveries are retried on failure, so the same X-Inkfree-Delivery can arrive more than once — treat it as an idempotency key and record the ones you have handled. Note that a manual retry creates a newdelivery id rather than reusing the original; to suppress those too, deduplicate on the event's own id, which is stable for a given state change across both webhook and change-feed channels.
Machine-readable contract
The same contract — header list, algorithm, formula, tolerance, dedup guidance, and runnable Node and Python samples — is served as JSON, unauthenticated:
curl "https://api-uat.softsages.com/core/public/inkfree/webhooks/docs/verification"See the Reference endpoints for the full response schema.