Skip to content

Webhooks

XipFax doesn’t offer a way to register an endpoint yet. Until it does, no event is posted. Each event goes to every webhook endpoint of your account when a fax you sent is delivered or fails for good, and when a fax sent to your number arrives, as webhooks describes each: {"id", "type", "api_version", "created_at", "data"}, the fax in data as GET /faxes gives it. Each delivery is signed per Standard Webhooks with the endpoint’s own secret, shown once, when the endpoint is registered: verify webhook-signature before you trust the body. XipFax posts only to https at a public address, and follows no redirect. Answer a 2xx within 15 seconds. After a 408, a 429, a 5xx or no answer, XipFax tries again over about three days, later where Retry-After asks for longer, up to a day; after a redirect or another 4xx, it stops. An event may come more than once, or after a later one: take each id once.

The events

Check a delivery

Before you read a delivery, check its webhook-signature against its body exactly as it came, before anything parses it. Each sample below also refuses a delivery more than five minutes from your own clock, and takes any of the signatures a delivery holds.

Node
import { createHmac, timingSafeEqual } from 'node:crypto';

/** Whether a delivery is XipFax's: its body as it came, its headers and your endpoint's secret. */
function isFromXipFax(body, headers, secret) {
  const timestamp = headers['webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace('whsec_', ''), 'base64');
  const signed = `${headers['webhook-id']}.${timestamp}.${body}`;
  const expected = createHmac('sha256', key).update(signed).digest();
  return headers['webhook-signature'].split(' ').some((signature) => {
    const given = Buffer.from(signature.replace('v1,', ''), 'base64');
    return (
      signature.startsWith('v1,') &&
      given.length === expected.length &&
      timingSafeEqual(given, expected)
    );
  });
}
Python
import base64, hashlib, hmac, time

def is_from_xipfax(body: bytes, headers, secret: str) -> bool:
    """Whether a delivery is XipFax's: its body as it came, its headers and your endpoint's secret."""
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f'{headers["webhook-id"]}.{timestamp}.'.encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        signature.startswith("v1,") and hmac.compare_digest(signature[3:], expected)
        for signature in headers["webhook-signature"].split(" ")
    )