Skip to content
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-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/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.

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/subscriptions

A 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

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 (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.
Building with an LLM? Read llms.txt for agent-oriented integration guidance.