# 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.

## 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 your tenant has a webhook in that environment asking for that kind of change, posts the updated record to your server — normally a few seconds after the bank ramp's event reached Aurea.

- **One webhook per environment.** Production and sandbox each have their own URL, secret, event types and status; a sandbox event never reaches the production webhook.
- **Who sets it up:** an Aurea administrator, or your tenant's administrator (role `tenantadmin`), in the Aurea Admin console or with the endpoints under **Tenant Webhooks** in the API Reference. 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 webhook.** 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 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.code` `NOAH_WEBHOOK_INVALID`, `details.field` `url` and `details.reason`, 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.

## Event Types

`eventTypes` lists the types the webhook receives; an empty list receives every type, including any published later.

| 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. |

- `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 while the webhook 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 of this delivery; 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 webhook'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>` |

## Verify the Signature

Check every delivery before trusting it. 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 verifyNoahDelivery(
  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/noah', express.raw({ type: 'application/json' }), async (req, res) => {
  const verified = verifyNoahDelivery(
    req.body,
    req.get('x-webhook-id'),
    req.get('x-webhook-timestamp'),
    req.get('x-webhook-signature'),
    process.env.AUREA_NOAH_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_noah_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 Aurea: they accept a genuine one — also with non-ASCII text in the body — 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.

## 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. Read the state through the API for anything your server missed; your Aurea contact can also put a delivery that stopped back in the queue, with the same `id` and a new set of attempts.
- Every attempt carries the same body and `X-Webhook-Id`, with its own timestamp and signature.
- The webhook is read again at each attempt: a replaced secret signs the retries, and a webhook removed, switched off or no longer asking for the type stops them.

## 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. Keep the `id`s you handled and answer `2xx` to one you already have.

## Send a Test

[Send a Test](https://docs.aureahub.com/docs/tenant-webhook-test.md) posts one `webhook.test` right away, signed like every delivery, and answers what happened. It also works while the webhook is switched off, is never retried, 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) answers with a new secret, shown only then, and **every delivery 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 webhook uses.

## Switch Off or Remove

- Saving the webhook with `"status": "disabled"` 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) 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 — is written to your tenant's activity log with who did it, never with the secret.

---

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