# Events to Your Server

Aurea tells your server when the bank ramp changes a user's KYC, a deposit, a transaction or a payout, and when a card onramp session changes status: a signed `POST`, retried until your server takes it — to one webhook per environment, and to up to ten endpoints per environment, each with the event types it asks for.

## Overview

The bank ramp reports every change to Aurea ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). Aurea applies it to its own records and then, when one of your tenant's addresses in that environment asks for that kind of change, posts the updated record to it — normally a few seconds after the bank ramp's event reached Aurea.

- **Two kinds of address, the same deliveries.** The **webhook of each environment** — one per environment — and **webhook endpoints** — up to 10 per environment, each with its own URL, secret, event types, label and status, for example one per service of yours. An event goes to every active address that asks for its type, with the same body and event id, each signed with that address's own secret.
- **Production and sandbox are separate.** Each environment has its own addresses; a sandbox event never reaches a production address.
- **Who sets them up:** your tenant's administrator (role `owner`, `tenantadmin`, `non_signing_admin` or `security_admin`), with the endpoints under **Your Webhooks** and **Webhook Endpoints** in the API Reference; the webhook of each environment also in the Aurea Admin console. A tenant administrator can manage only its own tenant.
- **What you receive is Aurea's record after the change**, with the ids Aurea's reads return — never the bank ramp's own payload.
- **The card onramp uses the same addresses.** Each change of a card-to-crypto session's status is posted as `ramp.card_session.updated` ([Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md)); everything below about the delivery, the signature, retries and order applies to it too.
- You can still read the same state at any time: [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md), [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md), [Get Transactions](https://docs.aureahub.com/docs/payin-transactions.md) and [Get Payout Transaction](https://docs.aureahub.com/docs/payout-get.md).

## Before You Start

- Aurea has connected the bank ramp for your tenant in that environment ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). Aurea can only tell you what the bank ramp told Aurea.
- A server that answers on a public `https://` address, with the raw request body available to your code (to check the signature).
- An access token of your tenant's administrator.

## Register Your Server

Save the webhook of one environment with [Save a Webhook](https://docs.aureahub.com/docs/tenant-webhook-save.md). The first save makes it and answers `201` with its `secret` — **the only time the secret is shown**: store it with your server's other secrets before doing anything else.

```bash
curl -X PUT https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhooks/sandbox \
  -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/aurea/events", "eventTypes": [] }'
```

In the Aurea Admin console, open the tenant's **Manage API Credentials** and use **Webhooks to the tenant's server**: **Add webhook** on the environment saves it and shows the secret once, with **Copy**. The same section changes the webhook, switches deliveries off, sends a test, replaces the secret and removes the webhook.

The address — of the webhook of an environment and of an endpoint alike — must be one Aurea can safely call:

- `https://` on the default port (443), with a full public DNS name such as `hooks.example.com`. An IP address, `localhost`, or a name ending in `.localhost`, `.local`, `.internal`, `.home.arpa` or `.localdomain` is refused.
- No user or password, no fragment (`#`), no whitespace or backslash, and no `.` or `..` path segment, also encoded. At most 2048 characters. A query string is kept.
- Aurea stores the address as the URL parser writes it: host in lowercase (punycode for international names), `:443` dropped.

A refused address answers `400` with `details.field` `url` and `details.reason` — `details.code` `NOAH_WEBHOOK_INVALID` for the webhook of an environment, `WEBHOOK_ENDPOINT_INVALID` for an endpoint — and nothing is saved.

> ℹ️ The host is resolved again on every delivery, and the delivery is refused — and retried later — when any of its addresses is not public: private, loopback, link-local, carrier-grade NAT, benchmarking, documentation, IETF, NAT64, 6to4, Teredo or multicast. Aurea connects to the address it checked, and follows no redirect.

## Endpoints

Add an endpoint with [Add an Endpoint](https://docs.aureahub.com/docs/webhook-endpoint-add.md): its environment, address, the event types it asks for and an optional label. The answer carries its `secret`, shown only then.

```bash
curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhook-endpoints \
  -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "environment": "sandbox", "url": "https://ledger.example.com/aurea/events", "eventTypes": ["ramp.deposit.updated", "ramp.transaction.updated"], "description": "Ledger service" }'
```

- Up to 10 per environment, beside the webhook of the environment, which works as before. Each has its own secret: verify each address's deliveries with that address's secret.
- An endpoint can ask for every published type (Event Types). An empty list receives the five `ramp.*` and the two `earn.*` types only: every other type — `operation.*`, `tokenized_asset.*`, `server_credential.revoked`, `account.*`, `transfer.inbound.*`, `address_book.*` and any type published later — reaches only the endpoints that name it. The webhook of an environment can name only the five `ramp.*` types.
- [Change](https://docs.aureahub.com/docs/webhook-endpoint-change.md) its address, types, label or status; [give it a new secret](https://docs.aureahub.com/docs/webhook-endpoint-rotate.md); [send it a test](https://docs.aureahub.com/docs/webhook-endpoint-test.md); [remove it](https://docs.aureahub.com/docs/webhook-endpoint-remove.md). [List Endpoints](https://docs.aureahub.com/docs/webhook-endpoints-list.md) also lists every type with the version of its body.

## Event Types

`eventTypes` lists the types an address receives. An address with an empty list — an endpoint, or the webhook of an environment — receives the types published with the first endpoints: the five `ramp.*` types and the two `earn.*` types. Every other type, and every type published from now on, reaches only the endpoints that name it, so a new type never changes what an existing address receives: name `operation.*`, `tokenized_asset.*`, `server_credential.revoked`, `account.*`, `transfer.inbound.*` and `address_book.*` to receive them.

| type | Sent when | data |
| --- | --- | --- |
| ramp.kyc.updated | Aurea applied the bank ramp's `Customer` event for a user | `userId`, `kycStatus` and `onboardingStatus` (the values of [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md)), `verificationStatus` (the bank ramp's `Verifications.Status` Aurea keeps, such as `Approved`), `customerType`, `actionsRequired`, `regions`, `agreements` and `nextStep` (as in Onboarding Status's `verification`), `updatedAt` |
| ramp.deposit.updated | Aurea applied the bank ramp's `FiatDeposit` event for a bank transfer to a virtual IBAN | `depositId`, `noahDepositId`, `userId`, `paymentMethodId` (the `id` [Payment Methods](https://docs.aureahub.com/docs/payin-methods.md) returns), `status`, `noahStatus`, `noahSubStatus`, `requestForInformation`, `refunds`, `paymentMethodType`, `paymentSystemId`, `fiatAmount`, `fiatCurrency`, `depositDate`, `updatedAt` |
| ramp.transaction.updated | Aurea applied the bank ramp's `Transaction` event to a transaction: a user's, the one a deposit pays for, a card top-up's or a payout's | `transactionId`, `noahTransactionId`, `userId`, `transactionType`, `status`, `noahStatus`, `noahSubStatus`, `cryptoAmount`, `cryptoCurrency`, `fiatAmount`, `fiatCurrency`, `network`, `destinationAddress`, `transactionHash`, `depositId`, `direction`, `noahDepositId`, `requestForInformation`, `refunds`, `ruleId`, `ruleExecutionId`, `reversesTransactionId`, `adjustment`, `breakdown`, `networkFee`, `fiatFeeAmount`, `fiatRate`, `paymentSystemId`, `updatedAt` |
| ramp.payout.updated | the bank ramp's `Transaction` event changed a payout's status | `payoutId`, `userId`, `status`, `cryptoCurrency`, `cryptoAuthorizedAmount`, `fiatAmount`, `fiatCurrency`, `noahTransactionId`, `updatedAt` |
| ramp.card_session.updated | A card onramp session's status changed — from `initialized` to `failed`, `rejected`, `fulfillment_processing` or `fulfillment_complete` ([Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md)) | The session as [Get a Session](https://docs.aureahub.com/docs/card-onramp-get.md) answers it: `id`, `environment`, `status`, `providerStatus`, `providerSessionId`, `pair`, `destination`, `customer` (your end user's `externalId` when your server opened it for one of [your own wallets](https://docs.aureahub.com/docs/guide-card-onramp-wallets.md), else `null`), `requested`, `amounts`, `transactionId`, `failureCode`, `createdAt`, `updatedAt`. Never the client secret. |
| earn.operation.updated, earn.redemption.updated | **Not sent yet.** Published, and an endpoint may ask for them, but Aurea does not send them today. | An [Earn](https://docs.aureahub.com/docs/guide-earn.md) deposit or withdrawal, and a redemption the product queued. |
| operation.created, operation.state_changed, operation.completed, operation.failed | **Only while Aurea records operations**, which is switched off until Aurea turns it on ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)): an operation was recorded, moved, completed or failed. Only to the endpoints that name them. | The operation as [Get an Operation](https://docs.aureahub.com/docs/operation-get.md) answers it, without `events` — `tokenizedOperationId` included; `operation.state_changed` adds `previousState` and `previousSubState`. |
| tokenized_asset.paused, tokenized_asset.resumed, tokenized_asset.corporate_action | **Not sent yet.** Published, and an endpoint may ask for them, but Aurea does not send them today. | A tokenized asset your tenant offers was paused or trades again on a chain, or has a new dividend or shares multiplier. |
| tokenized_asset.operation.quoted, tokenized_asset.operation.submitted, tokenized_asset.operation.completed, tokenized_asset.operation.failed, tokenized_asset.operation.expired | **Not sent yet.** Published, and an endpoint may ask for them, but Aurea does not send them today. | A tokenized assets operation was created, sent its first transaction, completed on chain, failed (`failureCode` says why) or ran out of time before it was carried out. |
| server_credential.revoked | One of your tenant's [server credentials](https://docs.aureahub.com/docs/guide-server-credentials.md) was revoked: its tokens are refused from the next request. Sent to the endpoints that name it in **both** environments — a credential serves your server in both —, with one event `id`, and only once the revocation is saved. | The credential as [List Credentials](https://docs.aureahub.com/docs/server-credentials-list.md) answers it: `id`, `tenantId`, `name`, `role`, `status` `revoked`, `publicKey`, `thumbprint`, `allowedIps`, `expiresAt`, `createdAt`, `createdBy`, `updatedAt`, `updatedBy`, `lastUsedAt`, `revokedAt`, `revokedBy`, `revokeReason`, `roleNarrows`. Never a secret: the credential holds none. |
| account.created, account.updated, account.wallet_added | **Only while Aurea sends account events**, which is switched off until Aurea turns it on ([Accounts](https://docs.aureahub.com/docs/guide-accounts.md)): an account was created — by a call, or by a bulk creation —, its name, tags, visibility, users or `customerRefId` changed, or a wallet was made for it or put in it. Only to the endpoints that name them: an account's news to your production endpoints, a wallet's to those of its network. | `account.created` and `account.updated`: the account as [Get an Account](https://docs.aureahub.com/docs/account-get.md) answers it. `account.wallet_added`: `accountId`, `customerRefId`, `walletId`, `chain`, `family`, `isTestnet`, `address`, and `how` — `created` (made for the account) or `attached` (one of its owner's wallets put in it). |
| transfer.inbound.recorded, transfer.inbound.removed | **Only while Aurea records the transfers into your wallets and sends their events**, both switched off until Aurea turns them on: a transfer into one of your tenant's wallets was recorded once its block had the platform's confirmations (or came back after a re-organisation of the chain), or a re-organisation took a recorded transfer's block away — it then counts nowhere until a new block carries it again. Only to the endpoints that name them: a testnet wallet's to your sandbox endpoints, a mainnet wallet's to your production ones. | `id`, `walletId`, `accountId`, `userId`, `chain`, `chainId`, `isTestnet`, `txHash`, `logIndex`, `blockNumber`, `blockHash`, `blockTime`, `from`, `to`, `asset` (`native`, a token contract or a Solana mint), `symbol`, `decimals`, `amount` (in the asset's smallest unit, as text), `recordedAt`, `removed` and `removedAt`. |
| address_book.entry.requested, address_book.entry.approved, address_book.entry.activated, address_book.entry.rejected, address_book.entry.removed, address_book.entry.proven | **Only while Aurea sends address book events**, which is switched off until Aurea turns it on ([Address Book](https://docs.aureahub.com/docs/guide-address-book.md)): an entry waits for its approval (added, or its address changed), was approved (it cools until `activeAt`, or is active at once with no cooling period), became active, was rejected, was removed, or its owner proved the address. Only to the endpoints that name them: a testnet entry's to your sandbox endpoints, a mainnet entry's to your production ones. | The entry as [Get an Entry](https://docs.aureahub.com/docs/address-book-entry-get.md) answers it, without `history`. |
| address_book.destination.unlisted | **Only while Aurea sends address book events, and only for a tenant whose signing points read its address book** ([Address Book](https://docs.aureahub.com/docs/guide-address-book.md)): an operation went to an address outside the book, or was refused for it. Only to the endpoints that name it. | `point` (the signing point), `verdict` — `unlisted` (outside the book), `unresolved` (its chain or network could not be told) or `one_time` (outside the book, allowed by your one-time addresses) —, `mode` (`log` or `enforce`), `refused`, `family`, `network`, `chain`, `chainId`, `address`, `sourceWalletId`, `occurredAt`. |

- Each type's body is published with a version in the [OpenAPI document](https://api.aureahub.com/v1/docs/json), as the schema `WebhookEvent.<type>.v<version>` (all at version 1). A change that could break a receiver — a field removed, renamed or retyped — comes as a new version; a field added to `data` does not, so ignore fields you don't know.
- `noahSubStatus` is `AmlScreening`, `UnderReview`, `Submitted`, `Confirming` or `null`. A value the bank ramp does not document is sent as `null`, and the event is still applied.
- `requestForInformation`, `refunds`, `breakdown` and `adjustment` have the shapes of [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md) and [List Customer Transactions](https://docs.aureahub.com/docs/payin-transactions.md); `refunds` and `breakdown` are `[]` when there are none.
- A bank transfer's on-chain delivery names no deposit in the bank ramp's event; its `depositId` is the deposit of the conversion with the same `ruleExecutionId`. When the delivery's event arrives before the conversion's, that delivery is sent with `depositId` `null`, and its next event — and List Customer Transactions — carry it.
- A field Aurea doesn't have yet is `null`. `noahDepositId` and `noahTransactionId` are the bank ramp's ids; every other id is Aurea's, the one its reads return. Times are ISO 8601 in UTC.
- The deposit's and the transaction's amounts, fees and rate, and those in `refunds` and `breakdown`, are decimals written as text without trailing zeros (`"125.5"`). A payout's amounts are as [Get Payout Transaction](https://docs.aureahub.com/docs/payout-get.md) returns them: `cryptoAuthorizedAmount` in the token's smallest unit, `fiatAmount` as a decimal.
- One the bank ramp event about a payout can send two deliveries: `ramp.transaction.updated` and, when the payout's status changed, `ramp.payout.updated`.
- Nothing is sent for an event that is older than what Aurea already applied, for a delivery the bank ramp repeated, or to an address that is switched off. An older `Customer` event that brings a region's verification Aurea didn't have, or a newer one, is still applied and sent: The bank ramp sends one event per currency.
- Never sent: names, identity documents, bank account numbers, transfer references or the bank ramp's payment method ids. `paymentSystemId` is the transfer's id in the payment system (IMAD, UETR, trace number): it names the payment, not a person.

## The Delivery

Each delivery is a `POST` to your address with a JSON body:

```json
{
  "id": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93",
  "type": "ramp.deposit.updated",
  "environment": "sandbox",
  "occurredAt": "2026-09-17T09:14:02.000Z",
  "createdAt": "2026-09-17T09:14:03.418Z",
  "data": {
    "depositId": "0b6c9f2e-3a1d-4e5f-8a7b-9c0d1e2f3a4b",
    "noahDepositId": "8d1f3b5a-7c9e-4f2a-b6d8-0e1a3c5b7d9f",
    "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
    "paymentMethodId": "3f5b7d9a-1c3e-4a5b-8d7f-9b1d3f5a7c9e",
    "status": "completed",
    "noahStatus": "Settled",
    "noahSubStatus": null,
    "requestForInformation": null,
    "refunds": [],
    "paymentMethodType": "BankSepa",
    "paymentSystemId": "SEPA-20260917-000123",
    "fiatAmount": "125.5",
    "fiatCurrency": "EUR",
    "depositDate": "2026-09-17T09:13:58.000Z",
    "updatedAt": "2026-09-17T09:14:03.402Z"
  }
}
```

| Field | Description |
| --- | --- |
| id | `evt_` and 32 hex characters. The same on every retry and every resend of this event, and at every address it goes to; different for each type sent about one the bank ramp event, and for each status of a card onramp session. |
| type | One of the types above, or `webhook.test` for a test. |
| environment | `production` or `sandbox`: the address's environment. |
| occurredAt | When the change happened, as the bank ramp reports it — or, for the card onramp, when the payment provider reported the new status. |
| createdAt | When Aurea applied the change. |
| data | The record after the change (Event Types). |
| Header | Value |
| Content-Type | `application/json` |
| User-Agent | `Aurea-Webhooks/1.0` |
| X-Webhook-Id | The body's `id` |
| X-Webhook-Timestamp | When this attempt was signed, in Unix seconds |
| X-Webhook-Event | The body's `type` |
| X-Webhook-Signature | `v1=` and the base64 HMAC-SHA256 of `<id>.<timestamp>.<body>`, keyed with the address's secret |
| X-Webhook-Jws | Only once Aurea signs its deliveries with its own key — **not yet**: a detached ES256 signature of the body (Verify the JWS) |

## Verify the Signature

Check every delivery before trusting it, with the secret of the address it came to. The key is the **bytes** of the base64 text after `whsec_` in your secret — not the text itself. Compute the HMAC-SHA256 of the `X-Webhook-Id` header, a dot, the `X-Webhook-Timestamp` header, a dot and the **raw body exactly as received**; base64-encode it, put `v1=` in front and compare it with `X-Webhook-Signature` in constant time. Refuse a timestamp that isn't plain digits or is more than five minutes away from your clock: it stops an old delivery from being replayed.

Node.js with Express:

```typescript
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const TOLERANCE_SECONDS = 300;

// True only for a delivery signed with your secret less than five minutes ago.
export function verifyDelivery(
  rawBody: Buffer,
  id: string | undefined,
  timestamp: string | undefined,
  signature: string | undefined,
  secret: string // whsec_…
): boolean {
  if (!id || !timestamp || !signature || !/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const mac = createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest('base64');
  const expected = Buffer.from(`v1=${mac}`);
  const received = Buffer.from(signature);
  return expected.length === received.length && timingSafeEqual(expected, received);
}

export const app = express();

// The raw bytes are needed for the signature: don't let a JSON parser run first.
app.post('/aurea/events', express.raw({ type: 'application/json' }), async (req, res) => {
  const verified = verifyDelivery(
    req.body,
    req.get('x-webhook-id'),
    req.get('x-webhook-timestamp'),
    req.get('x-webhook-signature'),
    process.env.AUREA_WEBHOOK_SECRET!
  );
  if (!verified) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  if (await alreadyHandled(event.id)) return res.sendStatus(200); // a retry you already have
  await saveForLater(event); // answer within 10 seconds, work afterwards
  res.sendStatus(200);
});
```

Python (the `headers` of Flask, Django or FastAPI all work, since they look names up without regard to case):

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def verify_delivery(raw_body: bytes, headers, secret: str) -> bool:
    """True only for a delivery signed with your secret less than five minutes ago."""
    msg_id = headers.get("x-webhook-id")
    timestamp = headers.get("x-webhook-timestamp")
    signature = headers.get("x-webhook-signature")
    if not msg_id or not timestamp or not signature:
        return False
    if not (timestamp.isascii() and timestamp.isdigit()):
        return False
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    mac = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()
    expected = "v1=" + base64.b64encode(mac).decode()
    return hmac.compare_digest(expected.encode(), signature.encode())
```

Both were checked against deliveries signed by the API's own code: they accept a genuine one — also with non-ASCII text in the body, and also when it carries the JWS header — and refuse a body changed by one bit, another id or timestamp, a delivery signed more than five minutes ago or ahead, another secret, and a timestamp that isn't plain digits.

## Verify the JWS

> ℹ️ **Not sent yet.** Aurea adds the `X-Webhook-Jws` header once it turns its signing key on; until then [the key set](https://docs.aureahub.com/docs/webhook-jwks.md) is empty and only the HMAC signature is sent. The HMAC signature stays on every delivery either way, so a server that verifies it needs no change. Verifying the JWS as well proves the delivery came from Aurea, not only from someone holding your secret.

The header is a detached JWS (RFC 7515, appendix F) over the exact body: `<protected header>..<signature>`, base64url, the payload left out. Put the base64url of the raw body between the two dots and verify the result with Aurea's public keys from `GET /v1/webhooks/jwks.json`. Its protected header:

```json
{
  "alg": "ES256",
  "kid": "bzrB8K7EPXdFI4zfAYcUYhyeSrqILkaklrG_kf-IR5I",
  "typ": "aurea-webhook+jws",
  "aud": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
  "iat": 1791297243,
  "jti": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93"
}
```

- `kid`: the key — find it in the key set; when it is new to you, read the set again (a new key is published before it signs anything).
- `aud`: **your tenant's id.** The key is Aurea's and signs every tenant's deliveries, so check that `aud` is yours, or another tenant's delivery would verify too.
- `iat`: the attempt's time, the same as `X-Webhook-Timestamp`; refuse one more than five minutes away. `jti`: the event, the same as `X-Webhook-Id`.

Node.js, with nothing but its built-in `crypto`:

```javascript
import { createPublicKey, verify } from 'node:crypto';

const JWKS_URL = 'https://api.aureahub.com/v1/webhooks/jwks.json';
const TOLERANCE_SECONDS = 300;
let keys = new Map(); // kid → public key
let fetchedAt = 0;

// The key a delivery names. A kid not seen yet reads the set again, at most once a minute.
async function signingKey(kid) {
  if (!keys.has(kid) && Date.now() - fetchedAt > 60_000) {
    const set = await fetch(JWKS_URL).then((res) => res.json());
    keys = new Map(set.keys.map((jwk) => [jwk.kid, createPublicKey({ key: jwk, format: 'jwk' })]));
    fetchedAt = Date.now();
  }
  return keys.get(kid);
}

// True only for a delivery Aurea signed, for your tenant, less than five minutes ago.
// headers: the request's headers with lower-case names (Node's req.headers).
export async function verifyDeliveryJws(rawBody, headers, myTenantId) {
  const parts = String(headers['x-webhook-jws'] ?? '').split('.');
  if (parts.length !== 3 || parts[1] !== '') return false; // detached: <header>..<signature>
  let header;
  try {
    header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
  } catch {
    return false;
  }
  if (typeof header !== 'object' || header === null) return false;
  if (header.alg !== 'ES256' || header.typ !== 'aurea-webhook+jws') return false;
  if (header.aud !== myTenantId) return false; // Aurea's key signs every tenant's deliveries
  if (header.jti !== headers['x-webhook-id'] || String(header.iat) !== headers['x-webhook-timestamp']) return false;
  if (!Number.isInteger(header.iat) || Math.abs(Date.now() / 1000 - header.iat) > TOLERANCE_SECONDS) return false;

  const key = await signingKey(header.kid);
  if (!key) return false;
  const signingInput = `${parts[0]}.${Buffer.from(rawBody).toString('base64url')}`;
  try {
    return verify('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(parts[2], 'base64url'));
  } catch {
    return false;
  }
}
```

Or with any JOSE library — here `jose`:

```javascript
import { compactVerify, createRemoteJWKSet } from 'jose';

const jwks = createRemoteJWKSet(new URL('https://api.aureahub.com/v1/webhooks/jwks.json'));

// Resolves the protected header, or throws, for a delivery Aurea signed for your tenant.
export async function verifyWithJose(rawBody, headers, myTenantId) {
  const [header, , signature] = String(headers['x-webhook-jws'] ?? '').split('.');
  const payload = Buffer.from(rawBody).toString('base64url');
  const { protectedHeader } = await compactVerify(`${header}.${payload}.${signature}`, jwks, {
    algorithms: ['ES256'],
  });
  if (protectedHeader.typ !== 'aurea-webhook+jws') throw new Error('not a webhook signature');
  if (protectedHeader.aud !== myTenantId) throw new Error('not for this tenant');
  if (protectedHeader.jti !== headers['x-webhook-id']) throw new Error('another event');
  if (Math.abs(Date.now() / 1000 - protectedHeader.iat) > 300) throw new Error('stale');
  return protectedHeader;
}
```

Both were checked against deliveries signed by the API's own code and its published key set: they accept a genuine delivery, also after the next key becomes the one in use, and refuse a changed body or protected header, a delivery for another tenant, one signed more than five minutes ago, a `kid` the set doesn't hold, and the other published key's `kid` on a signature it didn't make.

## Answer and Retries

- **Answer `2xx` within 10 seconds** and the delivery is done. Store the event and do the work afterwards. Aurea doesn't read your answer's body.
- Anything else fails the attempt: another status (a redirect too), no answer in 10 seconds, an address that is refused or doesn't resolve, a connection error.
- A failed delivery is sent again 30 seconds later, then after 1, 2, 4, 8, 16, 32, 64 and 128 minutes: **10 attempts over about four hours and a quarter**, then Aurea stops. You can put it back in the queue yourself (Send Again), or read the state through the API.
- Every attempt carries the same body and `X-Webhook-Id`, with its own timestamp and signature.
- The address is read again at each attempt: a replaced secret signs the retries, and an address removed, switched off or no longer asking for the type stops them.

## Deliveries and Attempts

- [List Deliveries](https://docs.aureahub.com/docs/webhook-deliveries-list.md): one row per event and address — the webhook of an environment or an endpoint — with its status (`pending`, `sending`, `delivered`, `skipped`, `retrying`, `failed`, `cancelled`), its attempts, the next one and the last one. Never the body.
- [The attempts log](https://docs.aureahub.com/docs/webhook-attempts-list.md): every attempt that posted, with its time, duration, and your server's status code or why none came. Kept 30 days.
- Both cover the webhook of each environment and the endpoints, and are read by the roles that read the webhooks — also `auditor` and `security_auditor`.

## Send Again

[Send Again](https://docs.aureahub.com/docs/webhook-deliveries-resend.md) puts deliveries back in the queue with 10 new attempts: one event's, those queued in a time range (at most 31 days), or every one that failed — in one environment, at every address or one. The same body goes again with the same event id, signed when it is sent: a server that already has it drops it by its `id`.

```bash
curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhook-deliveries/resend \
  -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "environment": "production", "failed": true }'
```

## Order and Duplicates

- **No order is promised.** Two deliveries — about one event or two — can arrive in either order, and a retry can arrive after a newer delivery. Compare `data.updatedAt` with what you stored, and don't let an older delivery overwrite a newer state.
- The same delivery can arrive more than once, for example when your answer was lost or a delivery is sent again. Keep the `id`s you handled and answer `2xx` to one you already have. Several of your addresses get the same event with the same `id`.

## Send a Test

[Send a Test](https://docs.aureahub.com/docs/tenant-webhook-test.md), and [its endpoint version](https://docs.aureahub.com/docs/webhook-endpoint-test.md), post one `webhook.test` right away, signed like every delivery, and answer what happened. It also works while the address is switched off, is never retried, is not in the attempts log, and is limited to 10 a minute. In the Aurea Admin console, **Send test** shows the outcome on the environment's card.

```json
{
  "id": "evt_4b7e19c0d25a8f63e1b9d07c5a2f8e14",
  "type": "webhook.test",
  "environment": "sandbox",
  "occurredAt": "2026-09-17T09:20:11.052Z",
  "createdAt": "2026-09-17T09:20:11.052Z",
  "data": {
    "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
    "webhookId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "message": "A test delivery from Aurea: check its signature, then answer 2xx."
  }
}
```

## Replace the Secret

[Replace the Secret](https://docs.aureahub.com/docs/tenant-webhook-rotate.md) (or [an endpoint's](https://docs.aureahub.com/docs/webhook-endpoint-rotate.md)) answers with a new secret, shown only then, and **every delivery to that address from that moment — retries included — is signed with it**. Deliveries your server refuses while it still has the old secret are retried, so put the new secret in place within a few minutes and nothing is lost. `secretHint`, the four characters before the secret's final `=`, tells you which secret the address uses.

## Switch Off or Remove

- Saving the webhook with `"status": "disabled"`, or changing an endpoint to it, switches it off: changes that happen meanwhile are not sent later, and deliveries still waiting are dropped. `"active"` switches it on again with the same secret.
- [Remove a Webhook](https://docs.aureahub.com/docs/tenant-webhook-remove.md) or [an endpoint](https://docs.aureahub.com/docs/webhook-endpoint-remove.md) deletes it and its secret; deliveries still waiting are dropped. Saving one again makes a new secret.
- Every change — made, changed, secret replaced, removed, tested, sent again — is written to your tenant's activity log with who did it, never with a secret.

---

Web version: https://docs.aureahub.com/#guide-events
