> For the complete documentation index, see [llms.txt](https://docs.elkyc.com/home/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.elkyc.com/home/integration/crypto-aml-integration-guide.md).

# Crypto AML Integration Guide

> **Who it's for:** backend developers who want to screen crypto wallets and transactions for AML risk from their own server. **Time to integrate:** \~30 minutes. **What you need to know:** basic HTTP/JSON and Node.js (the examples use it, but the logic is the same in any language).

***

## What you will have at the end

Your backend will be able to:

1. Submit a **wallet address** or a **transaction hash** for screening.
2. Get the result (via a **webhook** or polling): **risk score**, sources of funds, and related entities.
3. Generate and download a **PDF report** for compliance.

## How it works

```
Your server                        Allpass
    │  1. POST /crypto-aml/search      │
    │ ───────────────────────────────► │  state: PROCESSING
    │ ◄─────────────────────────────── │  { id: amlId, state }
    │                                   │  ...the check runs asynchronously...
    │  2. webhook cryptoAmlResult       │
    │ ◄─────────────────────────────── │  full result: score, sources of funds, entities
    │  3. POST /{id}/pdf-reports        │
    │ ───────────────────────────────► │  202 Accepted, status: PENDING
    │  4. webhook cryptoAmlPdfReportResult
    │ ◄─────────────────────────────── │  status: GENERATED, path
    │  5. GET /api/public/file/{path}   │  download the PDF
```

The check is **asynchronous**. The `search` request does not return a score. It returns the check `id` (`amlId`), and the result is delivered later by a webhook (or can be polled).

***

## Step 0. Preparation

### 0.1. Create an Application in Workplace

1. Open **Applications** in Workplace and create a new Application.
2. Choose an **Encryption Method**: `AES-256-CBC` or `AES-256-GCM`.
3. Save. After saving, the following keys appear:

| Key                 | Purpose                                                            |
| ------------------- | ------------------------------------------------------------------ |
| **Private App Key** | Authorizes requests to the Public API (`x-private-app-key` header) |
| **Crypto Key**      | Encrypts/decrypts data (hex, 32 bytes)                             |
| **HMAC Key**        | `AES-256-CBC` only: signs data                                     |

{% hint style="warning" %}
These keys give full access to the API. Store them only on the server (secrets, env variables) and **never** in a browser or mobile app. Regenerating the Crypto Key invalidates the old one.
{% endhint %}

### 0.2. Set up a webhook

In your Application, open the **Webhook** tab, click **Add notifications via webhook**, and enter the URL of your endpoint (for example, `https://your-app.com/webhooks/allpass`).

Your endpoint **must respond with HTTP `200`**. Otherwise Allpass retries: first after 30 seconds, then with exponential backoff, up to 10 attempts. Failed deliveries are listed in the **Failed Webhook Deliveries** tab.

### 0.3. Test vs Live

Use **Test mode** (sandbox) for development and switch to **Live** in production. Each mode has its own keys. Details: [Test and Live Modes](/home/get-started/test-and-live-modes.md).

### 0.4. Environment variables

```bash
ALLPASS_BASE_URL=<Public API base URL>
ALLPASS_PRIVATE_APP_KEY=<Private App Key>
ALLPASS_CRYPTO_KEY=<Crypto Key, hex>
ALLPASS_HMAC_KEY=<HMAC Key, hex; AES-256-CBC only>
```

***

## Step 1. Response format and encryption

### Response envelope

All Public API responses share the same wrapper:

```json
{
  "status": true,
  "statusCode": 200,
  "data": "<encrypted string>",
  "time": "2026-10-08T12:00:00.000Z"
}
```

The `data` field is always encrypted with your Crypto Key. What the **`x-allpass-signature`** header contains depends on the Encryption Method of your Application:

* **AES-256-CBC:** the header contains an **HMAC-SHA256** of the `data` string (IV + ciphertext), computed with your HMAC Key. Verify it **before** decrypting.
* **AES-256-GCM:** the header contains the **GCM auth tag**. Pass it to the decipher: decryption fails if the data was modified. There is no separate HMAC step.

### Encrypted data format

|                       | AES-256-CBC                                               | AES-256-GCM                                               |
| --------------------- | --------------------------------------------------------- | --------------------------------------------------------- |
| IV                    | 16 bytes, hex, at the start of the string (32 characters) | 12 bytes, hex, at the start of the string (24 characters) |
| Rest of the string    | ciphertext in hex                                         | ciphertext in hex                                         |
| `x-allpass-signature` | `HMAC-SHA256(hmacKey, iv+ciphertext)` in hex              | auth tag in hex                                           |
| Keys                  | Crypto Key + HMAC Key                                     | Crypto Key only                                           |
| Integrity check       | Manual: compare the HMAC before decrypting                | Built in: `decipher.final()` throws on mismatch           |

### Encryption helper

Use the tab that matches the Encryption Method of your Application. Both variants are saved as `allpass-crypto.js` and expose the same `decrypt` and `encrypt` functions, so the rest of the guide works with either.

{% tabs %}
{% tab title="AES-256-CBC" %}

```js
// allpass-crypto.js (AES-256-CBC)
import crypto from 'node:crypto';

const key = Buffer.from(process.env.ALLPASS_CRYPTO_KEY, 'hex');
const hmacKey = Buffer.from(process.env.ALLPASS_HMAC_KEY, 'hex');

export function decrypt(data, signature) {
  // Verify the HMAC signature first
  const expected = crypto.createHmac('sha256', hmacKey).update(data).digest('hex');
  const ok =
    signature &&
    expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  if (!ok) throw new Error('Invalid x-allpass-signature');

  const iv = Buffer.from(data.slice(0, 32), 'hex');
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
  return decipher.update(data.slice(32), 'hex', 'utf8') + decipher.final('utf8');
}

export function encrypt(text) {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
  const payload = iv.toString('hex') + cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
  const signature = crypto.createHmac('sha256', hmacKey).update(payload).digest('hex');
  return { payload, signature };
}
```

{% endtab %}

{% tab title="AES-256-GCM" %}

```js
// allpass-crypto.js (AES-256-GCM)
import crypto from 'node:crypto';

const key = Buffer.from(process.env.ALLPASS_CRYPTO_KEY, 'hex');

export function decrypt(data, signature) {
  // The signature is the GCM auth tag
  const iv = Buffer.from(data.slice(0, 24), 'hex');
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(Buffer.from(signature, 'hex'));
  return Buffer.concat([
    decipher.update(Buffer.from(data.slice(24), 'hex')),
    decipher.final(), // throws if the data was tampered with
  ]).toString('utf8');
}

export function encrypt(text) {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
  return {
    payload: iv.toString('hex') + enc.toString('hex'),
    signature: cipher.getAuthTag().toString('hex'),
  };
}
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
The request body can be sent either as plain JSON or encrypted (`{ "data": "<encrypted>" }` plus the `x-allpass-signature` header). The examples below send plain JSON requests and always decrypt the **responses**.
{% endhint %}

### API client

```js
// allpass-client.js
import { decrypt } from './allpass-crypto.js';

export async function allpass(path, { method = 'GET', body } = {}) {
  const res = await fetch(`${process.env.ALLPASS_BASE_URL}${path}`, {
    method,
    headers: {
      'x-private-app-key': process.env.ALLPASS_PRIVATE_APP_KEY,
      'content-type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  const envelope = await res.json();
  if (!res.ok || envelope.status === false) {
    // errors arrive unencrypted
    throw Object.assign(new Error(envelope.message ?? 'Allpass API error'), {
      statusCode: res.status,
      envelope,
    });
  }

  const signature = res.headers.get('x-allpass-signature');
  return JSON.parse(decrypt(envelope.data, signature));
}
```

The following steps use `allpass(...)`.

***

## Step 2. Start a check

`POST /api/public/crypto-aml/search`

Pass the **blockchain** and **exactly one** of `address` or `txHash`.

| Field        | Type   | Description                                                                                                                                               |
| ------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `blockchain` | enum   | One of: `BTC`, `LTC`, `BSV`, `ETH`, `TRON`, `POLYGON`, `BNB`, `ARBITRUM`, `BASE`, `AVALANCHE`, `MOONBEAM`, `DOT`, `SOLANA`, `TON`, `CARDANO`, `WORLDCOIN` |
| `address`    | string | Wallet address. **Mutually exclusive** with `txHash`                                                                                                      |
| `txHash`     | string | Transaction hash (64 hex characters, `0x` optional). **Mutually exclusive** with `address`                                                                |

**Check a wallet:**

```js
const check = await allpass('/api/public/crypto-aml/search', {
  method: 'POST',
  body: {
    blockchain: 'ETH',
    address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
  },
});
// => { id: 'c1f0...', state: 'PROCESSING' }
```

**Check a transaction:**

```js
const check = await allpass('/api/public/crypto-aml/search', {
  method: 'POST',
  body: {
    blockchain: 'ETH',
    txHash: '0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060',
  },
});
```

**Response** (`201 Created`, after decryption):

```json
{ "id": "c1f0a5e2-...", "state": "PROCESSING" }
```

The `id` is the `amlId` used in the following steps.

Notes:

* Passing both `address` and `txHash` (or neither) returns `400`.
* Every check is **charged to your balance**. If the balance is insufficient, the request is rejected before the check starts.
* An address format that is invalid for the chosen blockchain returns `400`.

***

## Step 3. Get the result

Check states (`state`):

| State        | Meaning                             |
| ------------ | ----------------------------------- |
| `INITIAL`    | Created, not yet picked up          |
| `PROCESSING` | Analysis in progress                |
| `FINISHED`   | Done, the result can be read        |
| `FAILED`     | Error, the reason is in `errReason` |

### Option A (recommended): webhook

When the check finishes, Allpass sends a **`cryptoAmlResult`** event to your webhook. The `data` field of the event contains the **full check result**, so no additional request is needed. It is the same object that polling returns (see Option B), except that `pdfReports` is included only in the polling response.

The request body uses the same envelope as API responses (`data` is encrypted, the signature is in `x-allpass-signature`). The decrypted event looks like this:

```json
{
  "type": "cryptoAmlResult",
  "id": "c1f0a5e2-...",
  "appKey": "<your application key>",
  "data": { "...": "full check result, see the fields below" },
  "timestamp": 1791483215053,
  "eventId": "7b0e5a3c-..."
}
```

| Field       | Description                                                                                                    |
| ----------- | -------------------------------------------------------------------------------------------------------------- |
| `type`      | Event type: `cryptoAmlResult` or `cryptoAmlPdfReportResult`                                                    |
| `id`        | Entity the event is about: the `amlId` for `cryptoAmlResult`, the `requestId` for `cryptoAmlPdfReportResult`   |
| `appKey`    | Key of the Application the event belongs to                                                                    |
| `data`      | Event payload (the check result, or the PDF report status)                                                     |
| `timestamp` | Time the event was created, in Unix milliseconds                                                               |
| `eventId`   | Unique identifier of this delivery. Stays the same on every retry of the same delivery. Use it for idempotency |

If the check could not be completed, `data` contains `{ statusCode, error, details }` instead of the result.

```js
// webhook.js (Express)
import express from 'express';
import { decrypt } from './allpass-crypto.js';

const app = express();
app.use(express.json());

app.post('/webhooks/allpass', async (req, res) => {
  try {
    const signature = req.get('x-allpass-signature');
    const event = JSON.parse(decrypt(req.body.data, signature));

    // Respond 200 immediately and move heavy work out of the handler.
    // Otherwise Allpass treats the delivery as failed and retries it.
    res.sendStatus(200);

    // Idempotency: skip events that were already processed
    if (await isProcessed(event.eventId)) return;
    await handleAllpassEvent(event); // event.data holds the result
    await markProcessed(event.eventId);
  } catch (e) {
    // Signature mismatch or corrupted data: do not process
    console.error('Webhook rejected:', e.message);
    res.sendStatus(400);
  }
});
```

Webhooks may be delivered **more than once** (retries), so the handler must be **idempotent**: store the `eventId` of every processed event and skip repeats. Do not deduplicate by `id`: the same `amlId` can legitimately appear in more than one event.

### Option B: polling (fallback)

If a webhook is not available, poll the details endpoint every 2–5 seconds with an overall time limit:

`GET /api/public/crypto-aml/{amlId}/details?type=WALLET|TRANSACTION`

{% hint style="warning" %}
The **`type` parameter is required** and must match what you checked: `WALLET` for `address`, `TRANSACTION` for `txHash`.
{% endhint %}

```js
async function waitForResult(amlId, type, { timeoutMs = 120_000, intervalMs = 3_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const aml = await allpass(`/api/public/crypto-aml/${amlId}/details?type=${type}`);
    if (aml.state === 'FINISHED') return aml;
    if (aml.state === 'FAILED') throw new Error(`AML failed: ${aml.errReason}`);
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error('AML check timed out');
}

const aml = await waitForResult(check.id, 'WALLET');
console.log(aml.state);  // FINISHED
console.log(aml.score);  // numeric risk score
```

### Result fields

**For a wallet (`type: WALLET`):**

| Field                         | Description                                                                                                 |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `id`, `blockchain`, `address` | Check identification                                                                                        |
| `state`, `errReason`          | State and error reason                                                                                      |
| `score`                       | Risk score                                                                                                  |
| `sof` / `uof`                 | Sources of funds (`sof`) and uses of funds (`uof`): a list with `owner`, `type`, `share`, `amount`, `depth` |
| `entities`                    | Related entities (exchanges, mixers, etc.): `name`, `scoreType`, `score`                                    |
| `sharesByType`, `riskByType`  | Breakdown by risk type                                                                                      |
| `addressInfo`                 | Balance, transaction count, tags, first/last seen                                                           |
| `isMonitoring`                | Whether the wallet is under monitoring                                                                      |
| `reviewStatus`                | `UNKNOWN` / `APPROVED` / `REJECTED`, the manual review decision                                             |
| `pdfReports`                  | PDF report history (polling response only)                                                                  |

**For a transaction (`type: TRANSACTION`):** `hash`, `score`, `state`, `blockNumber`, `timestamp`, `amount`, `uof`, `sof`, `assets`, `entities`.

***

## Step 4. PDF report (optional)

PDF generation is **asynchronous** and follows the same pattern: request, status, download.

### 4.1. Request generation

`POST /api/public/crypto-aml/{amlId}/pdf-reports` → `202 Accepted`

The `requestId` (a UUID) is generated on your side and identifies the report request.

```js
import { randomUUID } from 'node:crypto';

const requestId = randomUUID();
const report = await allpass(`/api/public/crypto-aml/${amlId}/pdf-reports`, {
  method: 'POST',
  body: { requestId },
});
// => { requestId, amlId, type, status: 'PENDING', createdAt, updatedAt }
```

### 4.2. Check the status

Statuses: `PENDING` → `GENERATED` (with a `path` field) or `FAILED` (with an `error: { code, message }` object).

* Webhook event: **`cryptoAmlPdfReportResult`** (`id` = `requestId`, report status in `data`).
* Or request: `GET /api/public/crypto-aml/{amlId}/pdf-reports/{requestId}`.
* History of all reports: `GET /api/public/crypto-aml/{amlId}/pdf-reports?limit=20&offset=0` (`limit` 1–100, default 20). Response: `{ rows: [...], total }`.

### 4.3. Download the file

When `status = GENERATED`, `path` looks like `crypto-reports/<dir>/<clientKey>/<id>/<file>.pdf`. The file is served at:

```
GET /api/public/file/{path}
```

Streamed files are encrypted differently from JSON: **always `aes-256-cbc`, Crypto Key only, no HMAC**, regardless of the Encryption Method of your Application. The IV is taken from the **`x-initialization-vector`** response header (hex, 16 bytes).

```js
import { createDecipheriv } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

async function downloadPdf(path, outFile) {
  const res = await fetch(`${process.env.ALLPASS_BASE_URL}/api/public/file/${path}`, {
    headers: { 'x-private-app-key': process.env.ALLPASS_PRIVATE_APP_KEY },
  });
  if (!res.ok) throw new Error(`Download failed: ${res.status}`);

  const iv = Buffer.from(res.headers.get('x-initialization-vector'), 'hex');
  const decipher = createDecipheriv(
    'aes-256-cbc',
    Buffer.from(process.env.ALLPASS_CRYPTO_KEY, 'hex'),
    iv,
  );

  await pipeline(Readable.fromWeb(res.body), decipher, createWriteStream(outFile));
}
```

{% hint style="info" %}
File access is restricted to your `clientKey`: reports that belong to someone else return `404`.
{% endhint %}

***

## Step 5. Complete example

A single scenario file: screen a wallet, get the result, make a decision, and download the PDF if needed. It uses `allpass-crypto.js` and `allpass-client.js` from Step 1.

```js
// run.js
import { randomUUID } from 'node:crypto';
import { allpass } from './allpass-client.js';
// downloadPdf from Step 4.3 lives in a separate module
import { downloadPdf } from './download-pdf.js';

const RISK_THRESHOLD = 50; // EXAMPLE ONLY. Define it according to your policy.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function waitFor(fn, { timeoutMs = 120_000, intervalMs = 3_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const result = await fn();
    if (result) return result;
    await sleep(intervalMs);
  }
  throw new Error('Timed out');
}

export async function screenWallet({ blockchain, address }) {
  // 1. Start the check
  const { id: amlId } = await allpass('/api/public/crypto-aml/search', {
    method: 'POST',
    body: { blockchain, address },
  });

  // 2. Wait for the result (in production, replace polling with a webhook)
  const aml = await waitFor(async () => {
    const d = await allpass(`/api/public/crypto-aml/${amlId}/details?type=WALLET`);
    if (d.state === 'FAILED') throw new Error(`AML failed: ${d.errReason}`);
    return d.state === 'FINISHED' ? d : null;
  });

  // 3. Decision
  const decision = aml.score >= RISK_THRESHOLD ? 'MANUAL_REVIEW' : 'APPROVED';

  // 4. PDF for the audit trail (only if manual review is needed)
  let pdfFile = null;
  if (decision === 'MANUAL_REVIEW') {
    const requestId = randomUUID();
    await allpass(`/api/public/crypto-aml/${amlId}/pdf-reports`, {
      method: 'POST',
      body: { requestId },
    });

    const report = await waitFor(async () => {
      const r = await allpass(`/api/public/crypto-aml/${amlId}/pdf-reports/${requestId}`);
      if (r.status === 'FAILED') throw new Error(`PDF failed: ${r.error?.message}`);
      return r.status === 'GENERATED' ? r : null;
    });

    pdfFile = `./aml-${amlId}.pdf`;
    await downloadPdf(report.path, pdfFile);
  }

  return { amlId, score: aml.score, decision, pdfFile };
}

// Run
screenWallet({
  blockchain: 'ETH',
  address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
})
  .then((r) => console.log(r))
  .catch((e) => {
    console.error(e.statusCode ?? '', e.message);
    process.exit(1);
  });
```

Run it:

```bash
node --env-file=.env run.js
```

Requires Node.js 20.6+ for `--env-file`, and Node 18+ for the built-in `fetch`.

***

## Common errors and how to fix them

| Symptom                                         | Cause                                                                             | Fix                                                                                                                                 |
| ----------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `401 App private key is empty / not correct`    | Missing or wrong `x-private-app-key`                                              | Check the key and that it belongs to the right mode (Test/Live)                                                                     |
| `400 Decrypted input data is not correct`       | You encrypt the request body, but the signature/key/method do not match           | Check that the helper matches the Encryption Method of your Application, the Crypto/HMAC keys, and the `x-allpass-signature` header |
| `Invalid x-allpass-signature` during decryption | Wrong HMAC Key or encryption method, or the Crypto Key was regenerated            | Compare the keys with the Application in Workplace                                                                                  |
| `400` on `/search`                              | Both `address` and `txHash` passed, or neither; or an invalid address/hash format | Pass exactly one field, in the format for the chosen blockchain                                                                     |
| `400` on `/details`                             | `type` is missing or wrong                                                        | `?type=WALLET` or `?type=TRANSACTION`                                                                                               |
| Request rejected before the check starts        | Insufficient balance                                                              | Top up your balance in Billing                                                                                                      |
| Webhook arrives many times                      | Your endpoint did not respond `200` in time                                       | Respond `200` immediately, move processing to a queue, and deduplicate by `eventId`                                                 |
| `state: FAILED`                                 | Blockchain data could not be retrieved                                            | See `errReason`; retry `search` if needed                                                                                           |
| `404` when downloading a PDF                    | `path` does not belong to your `clientKey`, or is wrong                           | Use the `path` from the report status unchanged                                                                                     |
