Skip to main content

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.

On this page

Delivery headers

Each delivery carries these headers:

HeaderDescription
X-Inkfree-EventEvent type, e.g. envelope.completed
X-Inkfree-DeliveryUnique delivery id — use as your idempotency key
X-Inkfree-TimestampUnix epoch seconds at signing time
X-Inkfree-Signaturesha256=<hex hmac>
X-Inkfree-Truncatedtrue when the payload was capped at 256 KiB

X-Inkfree-Truncated

Payloads are capped at 256 KiB. When a delivery arrives with 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.

HMAC-SHA256
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?

New subscriptions always have a secret, so this should not happen. If it does, the subscription predates August 2026, when omitting 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

Express
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

Flask
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 "", 200

Deduplication

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
curl "https://api-uat.softsages.com/core/public/inkfree/webhooks/docs/verification"

See the Reference endpoints for the full response schema.