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-06-15]
Apier ships a Pro-tier webhook surface for consumers who would
rather react to upstream changes than poll /api/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://<your-webhook-endpoint>",
"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.
Event types
Apier v1 emits a single webhook event type: accounts.updated — a
Norwegian company's annual-accounts filing status changed in the open
Brønnøysund Regnskapsregisteret tier (a new annual-accounts filing
appeared, or the most-recent accounting year advanced).
Events ride the change archive, so you select them with a subscription
filter on the underlying change row's source + entity_type. To
receive accounts.updated:
curl -X POST -H 'Authorization: Bearer <API_KEY>' \
-H 'Content-Type: application/json' \
-d '{
"webhook_url": "https://<your-webhook-endpoint>",
"filter": { "source": "brreg", "entity_type": "annual_accounts" }
}' \
https://apier.no/api/v1/subscriptionsA scheduled diff job re-checks the cached company working set daily and
compares each company's current filing status against a baseline private
to the monitor — so an accounts.updated event fires once per real
change. The first time Apier observes a company's accounts it records a
silent baseline (no event): there is no prior state to have changed
from, and you can always read the current snapshot from
GET /api/v1/company/{org}/accounts.
Delivered payload
Every delivery is a POST whose JSON body wraps one change-archive row in
the change field. For accounts.updated, change_type is always
updated and field_path points at the field that moved
(/last_accounts_year or /has_filed_annual_accounts):
{
"subscription_id": "3f1a0c4e-0000-4000-8000-000000000001",
"delivered_at": "2026-06-15T10:00:03Z",
"attempt_number": 1,
"correlation_id": "8c2d6b7a-0000-4000-8000-000000000002",
"change": {
"id": "a17b9d3c-0000-4000-8000-000000000003",
"source": "brreg",
"entity_type": "annual_accounts",
"entity_id": "999999999",
"change_type": "updated",
"field_path": "/last_accounts_year",
"before_value": { "last_accounts_year": 2023 },
"after_value": { "last_accounts_year": 2024 },
"diff": [{ "op": "replace", "path": "/last_accounts_year", "value": 2024 }],
"detected_at": "2026-06-15T10:00:02Z",
"source_snapshot_id": "accounts-2026-06-15T10:00:00Z"
}
}Deliveries are at-least-once: a lost acknowledgement re-fires the
same change with a higher attempt_number, and the rare case of a
baseline-write hiccup after a successful publish can re-emit a change.
Make your receiver idempotent — key side effects on change.id.
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 (a
user:pass@prefix) 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.
How to verify an apier certificate
Request a signed verification certificate with ?certificate=true, then verify its detached JWS offline against the public JWKS — what's signed, what each field means, and a copy-paste verification script.
Norwegian Company Register Search: How to Look Up and Verify a Company
Look up and verify a Norwegian company via the free Brønnøysund search at brreg.no — check registration status, bankruptcy, filed accounts, and VAT.