# Webhooks

When a company revises figures it has already filed, Arche records the change
as a restatement alert. Webhooks push each alert to an endpoint you control, so
you learn about a restatement when Arche detects it rather than on your next
poll of `/v1/edgar/restatement-alerts`.

Webhooks are available from the Developer plan. Developer allows up to three
endpoints; Growth and above have no limit.

Every delivery is signed with a secret that belongs to your subscription alone.
Verify the signature before acting on a delivery. An unverified request could
have come from anyone.

## What Arche sends

Arche sends one event type, `restatement.detected`, as an HTTPS `POST` with a
JSON body. Each request carries these headers:

- `X-Arche-Signature` (string): `t=<unix seconds>,v1=<hex>`. Verify it as described below.
- `X-Arche-Event-Type` (string): Always `restatement.detected` today.
- `X-Arche-Delivery-Id` (uuid): Identifies this delivery. It stays the same across retries of the same
  delivery.
- `X-Arche-Dedupe-Key` (string): Stable key for this alert and subscription. Use it to ignore a delivery
  you have already processed.

## Registering an endpoint

Register endpoints on the **Webhooks** page of the
[developer portal](https://app.arche.fi/webhooks). Endpoints belong to an
environment, so register one under each environment you want alerts for. The
URL must use `https://`.

When you add an endpoint, the portal shows its signing secret once. It starts
with `whsec_`. Store it where your receiver can read it, such as a secrets
manager. The portal does not show it again, but you can rotate it at any time.

Adding a URL that is already registered returns the existing endpoint and its
current secret rather than creating a second one.

## Verifying signatures

The signature is an HMAC-SHA256, keyed with your signing secret, over the
timestamp, a period, and the raw request body:

```text
signed_content = "<t>." + <raw request body bytes>
v1             = hex(HMAC_SHA256(signing_secret, signed_content))
```

To verify a delivery:

1. Read the **raw** request body before parsing it. Parsing and re-serializing
   JSON changes the bytes, and the signature will no longer match.
2. Split `X-Arche-Signature` on commas and read `t` and `v1`.
3. Reject the delivery if `t` is more than five minutes from your current time.
   This stops someone replaying a captured delivery later.
4. Compute the HMAC over `"<t>."` followed by the raw body, and compare it with
   `v1` using a constant-time comparison.

**Verify a delivery**

```python
import hashlib
import hmac
import time


def verify_arche_signature(
    secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300
) -> bool:
    parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
    timestamp, received = parts.get("t"), parts.get("v1")
    if not timestamp or not received or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, received)
```

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyArcheSignature(
  secret: string,
  header: string,
  rawBody: Buffer,
  toleranceSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((item) => item.trim().split('=', 2)),
  )
  const timestamp: string | undefined = parts.t
  const received: string | undefined = parts.v1
  if (!timestamp || !received || !/^\d+$/.test(timestamp)) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false
  }
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(received)
  return a.length === b.length && timingSafeEqual(a, b)
}
```

Respond with any `2xx` status once you have verified and stored the delivery.
Do the slower work, such as rerunning a model, after you respond.

> **Warning:** Frameworks that parse JSON automatically often discard the raw body. In
> Express, mount the `express.raw` middleware with type
> `application/json` on the webhook route. In Flask, read
> `request.get_data()`.

## Delivery and retries

Arche checks for undelivered alerts every ten minutes, so a delivery usually
arrives within ten minutes of detection.

A delivery succeeds when your endpoint returns a `2xx` status within ten
seconds. Any other status, a timeout, or a connection failure counts as a
failure. Arche makes **up to three attempts** in total. Retries happen on later
runs, waiting at least 30 seconds after the first failure and at least five
minutes after the second. After the third failure Arche stops trying.

A missed delivery never loses the alert. Every alert remains available from
[`/v1/edgar/restatement-alerts`](https://docs.arche.fi/reference), so you can reconcile after an
outage.

Deliveries can arrive more than once, and not always in the order the alerts
were detected. Use `X-Arche-Dedupe-Key` to skip a delivery you have already
processed, and `detected_at` in the body to order alerts.

## Rotating your secret

Rotate the secret if it may have been exposed. Rotation takes effect
immediately: every delivery sent afterwards is signed with the new secret, and
there is no period where both are valid. Deploy the new secret to your receiver
as soon as you receive it, and expect deliveries to fail verification until it
is in place. Deliveries that fail verification should return a non-`2xx`
status, so Arche retries them.

To rotate a secret, choose **Rotate secret** next to the endpoint on the
[Webhooks](https://app.arche.fi/webhooks) page. The new secret is shown once.

## Removing an endpoint

Choose **Remove** next to the endpoint on the
[Webhooks](https://app.arche.fi/webhooks) page. Arche stops sending to it
immediately, including any retries still pending. Alerts remain available from
`/v1/edgar/restatement-alerts`. Adding the same URL again later creates a new
endpoint with a new signing secret.

## Payload reference

A delivery for a balance-sheet amendment looks like this. Values are
illustrative.

```json
{
  "event_type": "restatement.detected",
  "restatement_alert_event_id": "5f0c9a1e-2b7d-4c1a-9e3f-8d2a6b4c7e10",
  "dedupe_key": "restatement.detected:0001069183:BALANCE_SHEET:2024:FY:0001069183-25-000019:0001069183-25-000075",
  "delivery_dedupe_key": "restatement.detected:0001069183:BALANCE_SHEET:2024:FY:0001069183-25-000019:0001069183-25-000075:9b1e4d2c-6a3f-4f8e-b0c1-2d7e5a9f4b36",
  "detected_at": "2025-05-07T21:14:02Z",
  "company_id": "a3d1c6e2-7f4b-4b9a-8c2e-1f6d5b3a9e70",
  "cik": "0001069183",
  "ticker": "AXON",
  "accession_id": "0001069183-25-000075",
  "statement_version_id": "c7e2a4b9-1d3f-4e6a-9b8c-5f0d2e7a1c34",
  "prior_statement_version_id": "e1b5d8f3-6c2a-4f9e-8d7b-3a0c4e6f2b91",
  "statement_type": "BALANCE_SHEET",
  "statement_date": "2024-12-31",
  "fiscal_year": 2024,
  "fiscal_period": "FY",
  "facts_changed_count": 2,
  "material_facts_count": 1,
  "materiality_score": "HIGH",
  "change_class_counts": { "VALUE_REVISION": 2 },
  "summary_metrics": [
    {
      "metric_code": "total_current_liabilities",
      "change_type": "VALUE_REVISION",
      "old_value": "997586000",
      "new_value": "1677875000",
      "delta_value": "680289000"
    }
  ],
  "materiality_summary": "HIGH severity across 2 fact revisions",
  "model_impacts": []
}
```

- `restatement_alert_event_id` (uuid): The alert. Fetch it from `/v1/edgar/restatement-alerts/{id}` for the full
  record.
- `statement_version_id` (uuid): The new statement version. `prior_statement_version_id` is the version it
  revises. `/v1/edgar/statements/{statement_version_id}/fact-revisions` lists
  every fact that changed between them.
- `materiality_score` (string): `NONE`, `LOW`, `MEDIUM`, `HIGH`, or `CRITICAL`. `materiality_summary`
  explains the rating.
- `summary_metrics` (array): The most significant metric changes, as decimal strings.
- `model_impacts` (array): Effects on derived model inputs, such as margins, when Arche can compute
  them.
