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/subscriptionsThe 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
| Attempt | Delay since previous |
|---|---|
| 1 | 0 (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,.invalidsuffixes.- 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.