Apier
Apier.no
Guides

Webhook subscriptions

Subscribe to change-archive deltas via signed HTTPS webhooks. SSRF-resistant, exponential-backoff retries, HMAC-SHA256 verification.

[Cite this as: Apier.no Docs v0.1.0 — last updated 2026-04-28]

Apier ships a Pro-tier webhook surface for consumers who would rather react to upstream changes than poll /v1/changes on a timer. Every detected change-archive event that matches your filter is POSTed to your webhook URL with an HMAC-SHA256 signature and a 7-attempt exponential-backoff retry schedule (immediate try plus six retries at 1m / 5m / 15m / 1h / 6h / 24h).

Quickstart

Create a subscription with a filter and your receiving URL:

curl -X POST -H 'Authorization: Bearer <API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
        "webhook_url": "https://hooks.yourapp.com/apier",
        "filter": { "source": "brreg", "change_type": "updated" }
      }' \
  https://apier.no/api/v1/subscriptions

The response carries the plaintext webhook secret — store it client-side immediately. It is returned ONCE; the server holds only a SHA-256 hash + an AES-256-GCM ciphertext.

Verifying a delivery

Every POST to your URL carries an X-Apier-Signature header in Stripe-compatible format: t=<unix_seconds>,v1=<hex_hmac>. The HMAC input is <timestamp>.<exact_raw_body>. Reject deliveries with a stale timestamp (default tolerance: 300 seconds) or a mismatched HMAC.

Node.js

import crypto from "node:crypto";

function verify(req, body, secret) {
  const header = req.headers["x-apier-signature"] ?? "";
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const ts = Number(parts.t);
  const sig = parts.v1;
  if (!Number.isInteger(ts) || !sig) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${body}`, "utf8")
    .digest("hex");
  if (expected.length !== sig.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(expected, "utf8"),
    Buffer.from(sig, "utf8"),
  );
}

Python

import hmac, hashlib, time

def verify(headers, body: str, secret: str) -> bool:
    header = headers.get("x-apier-signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        ts = int(parts["t"])
        sig = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(int(time.time()) - ts) > 300:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{ts}.{body}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, sig)

openssl (manual debug)

TS=1714234567
BODY='{"id":"..."}'
echo -n "$TS.$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"

Retry behavior

AttemptDelay since previous
10 (immediate)
2~1 minute
3~5 minutes
4~15 minutes
5~1 hour
6~6 hours
7~24 hours

Each delay is jittered ±10% to spread retries across many subscriptions hitting the same upstream after a brief outage. After 7 attempts the delivery is abandoned. After 6 consecutive 4xx responses (excluding 408 / 429) the subscription auto-disables with deactivation_reason='consecutive_4xx'.

URL hardening

Apier validates webhook URLs at create time AND immediately before every delivery attempt. Rejected URLs include:

  • Private CIDR ranges (RFC 1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) — including IPv4-mapped IPv6 forms like ::ffff:127.0.0.1.
  • Cloud metadata endpoints (169.254.169.254, metadata.google.internal).
  • .local, .internal, .localhost, .test, .example, .invalid suffixes.
  • HTTP scheme in production (HTTPS-only by default).
  • URLs containing userinfo (https://user:pass@...) or fragments.
  • 3xx redirects from your endpoint are NOT followed (error_class=redirect_blocked).

Quotas and limits

  • Maximum 10 active subscriptions per consumer (atomic enforcement).
  • Webhook response bodies are read up to 64 KiB; longer bodies are truncated and logged with error_class=body_too_large.
  • Per-tier rate limits apply to the management API (POST/GET/DELETE); the delivery cron itself is internal and not consumer-rate-limited.

On this page