# MegTrust client integration

Use MegTrust's REST API from your backend, or connect an AI agent through the included MCP server. Node.js 22 or later is required for the supplied client. The REST API works with any language capable of Ed25519 signing and HTTPS.

## 1. Get access

Ask your organisation administrator to open **Developers → API keys** in the intended MegTrust environment. Generate an Ed25519 keypair on your own server:

```sh
node --input-type=module -e 'import{generateKeyPairSync}from"node:crypto";import{writeFileSync}from"node:fs";const{publicKey,privateKey}=generateKeyPairSync("ed25519");writeFileSync("megtrust-private.pem",privateKey.export({type:"pkcs8",format:"pem"}),{mode:0o600,flag:"wx"});console.log(publicKey.export({type:"spki",format:"der"}).toString("base64"))'
```

Register the printed **public** key and copy the resulting Key ID. Keep the PEM file in your backend secret store. Never put private keys or legacy HMAC secrets in browser code, mobile bundles, prompts, or source control.

Each key is fixed to one workspace, without its child workspaces. Expiry, revocation and any configured IP allowlist apply to every request. Use separate keys and private key files for each environment and integration. To rotate, register a replacement public key, switch your integration, verify a read, then revoke the old key in the console.

Set the exact origin shown in your environment's Developers page; do not append `/api/v1`:

```sh
export MEGTRUST_BASE_URL=https://YOUR-MEGTRUST-HOST
export MEGTRUST_KEY_ID=mk_live_REPLACE_ME
export MEGTRUST_PRIVATE_KEY_PATH=/absolute/path/megtrust-private.pem
```

Do not infer sandbox versus production from the Key ID prefix. Confirm the host. These keys belong to MegTrust's client API; no database or custody-provider credentials are needed.

## 2. Download the integration files

Download from the same MegTrust host. These files contain public documentation and code; no client credentials are embedded. A deployment protected by its hosting provider may still require an operator-provided protection bypass.

```sh
mkdir megtrust-integration
cd megtrust-integration
for file in client.mjs mcp.mjs package.json README.md openapi.json; do
  curl --fail --show-error "$MEGTRUST_BASE_URL/integrations/megtrust/$file" --output "$file"
done
```

The REST client (`client.mjs`) uses Node's standard library and needs no npm install. `openapi.json` is the OpenAPI 3.1 reference: import it into Postman or your code generator, then set the server origin. Generated clients still need the signing step below; an OpenAPI API-key field alone does not sign a request.

## 3. Make a REST call

Create `example.mjs` alongside the downloaded files:

```js
import { clientFromEnv } from "./client.mjs";
const api = clientFromEnv();
const result = await api.request("GET", "/api/v1/wallets");
if (result.status !== 200) throw new Error(JSON.stringify(result));
console.log(result.body.data);
```

Run `node example.mjs`. Each result has `{ status, body, requestId, retryAfter }`. HTTP error responses are returned with their status; transport failures throw. No call is automatically retried.

### Endpoints

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/v1/wallets` | Workspace wallets, deposit addresses, balances |
| GET | `/api/v1/wallets/{id}` | One wallet; foreign/unknown IDs return 404 |
| GET | `/api/v1/transactions?limit=25&cursor=…` | On-chain movements, newest first; follow `meta.next_cursor` |
| GET | `/api/v1/transactions/{id}` | Every leg of one movement (an array); foreign/unknown IDs return 404 |
| GET | `/api/v1/counterparties` | Destination book; check `can_send` |
| POST | `/api/v1/counterparties` | Register a pending destination |
| GET | `/api/v1/withdrawals?limit=25&offset=0` | Recorded withdrawals; follow `meta.next_offset` |
| GET | `/api/v1/withdrawals?idempotency_key=…` | Recover a submission by its original payment key |
| POST | `/api/v1/withdrawals` | Request a withdrawal; requires `Idempotency-Key` |
| GET | `/api/v1/withdrawals/{id}` | Refresh withdrawal status and network hash |
| POST | `/api/v1/withdrawals/{id}/cancel` | Cancel a withdrawal that is still `pending_approval` |

Wallet balances carry `{ asset, network, amount, available, usd_value }`. Amounts are decimal strings; preserve them as strings or use decimal arithmetic. `amount` is the total balance, while `available` is the spendable balance. USD figures are indicative. Never show an unavailable balance as zero. A failed balance read returns an error.

For deposits, select `deposit_addresses[]` by its exact `network`. The legacy `address` field is only a display convenience. Use the deposit address for the chain on which funds will be sent.

Transactions list on-chain movements newest first and page with a cursor: pass `meta.next_cursor` back as `cursor` until it is `null`. `created_after` and `created_before` narrow the range; don't page with them, because movements recorded in the same instant can fall on both sides of a boundary. A page can hold slightly fewer rows than `limit` (network fees fold into their payout row) or more (a move between two of your own wallets is two rows, an `out` and an `in`, sharing one id). Payouts waiting for approval are not listed; follow them through `/api/v1/withdrawals`. Withdrawal listing reads recorded state, supports `wallet_id` and `idempotency_key` filters, and accepts offsets 0–10000. New rows can shift offsets: deduplicate by withdrawal ID when paging. Use individual status reads for live progress. If a response gives `meta.stale: true`, its data is the last recorded state because the live refresh failed.

### Register a destination

```js
const result = await api.request("POST", "/api/v1/counterparties", {
  network: "ethereum", address: "REPLACE_WITH_FULL_ADDRESS",
  counterparty_type: "company", company_name: "Example Company",
  wallet_type: "unhosted", country: "MY", direction: "send"
});
```

For an individual, supply `first_name` and `last_name`; for a company, supply `company_name`. Hosted wallets use `wallet_type: "vasp"` and require `vasp_name`. Country is a two-letter code. New destinations start pending: complete ownership verification in the console. Creating an entry cannot approve or verify it. An already-registered address returns the existing entry with `meta.already_exists: true`; it does not edit it. `can_send` means the destination passed the book's verification gate; other policy checks may still refuse a withdrawal.

### Request and follow a withdrawal

Obtain the payment instruction and persist one idempotency key **before** the first attempt:

```js
// These values come from your persisted payment record.
const paymentKey = "invoice-2026-0042";
const payment = {
  wallet_id: "WALLET_ID_FROM_API", asset: "USDC", amount: "25.50",
  network: "ethereum", destination: "VERIFIED_DESTINATION_ADDRESS",
  fee_priority: "medium"
};
const result = await api.request("POST", "/api/v1/withdrawals", payment, paymentKey);
if (result.status === 202 || result.status === 200) {
  const status = await api.request("GET", `/api/v1/withdrawals/${result.body.data.id}`);
  console.log(status.body);
}
```

Use the exact asset and network from the wallet's balances. Omitting network works only when the asset resolves unambiguously to one balance with a known network. Amount precision must fit the asset. Bodies reject unknown fields. Idempotency keys contain 1–128 printable ASCII characters without spaces and are unique within the organisation.

A fresh request returns **202**, which means accepted for processing, not settled. Every withdrawal requires human approval under the current maker-checker policy. Neither the REST API nor MCP exposes an approval tool. Read `data.status` even on a successful HTTP response:

| Status | Meaning |
| --- | --- |
| `received` | Recorded; processing outcome not yet established |
| `pending_approval` | Pending approval or processing checks |
| `approved` | Approval completed; settlement not established |
| `processing` | In progress; settlement not established |
| `completed` | Settled; use the returned `tx_hash` when available |
| `rejected`, `failed`, `expired` | Terminal unsuccessful outcome; investigate before requesting a new payment |
| `cancelled` | You cancelled it before approval; nothing was sent |

If a POST times out, retry with the **same key and same body**. The client signs a fresh attempt. A replay returns 200 with `meta.idempotent_replay: true`, including any recorded failed or still-received status. It never resumes or reissues the payment. A request refused before it is accepted (400, 404, 422 for the wallet or asset, or a 503) records nothing, so retrying it with the same key is evaluated afresh. Reusing a key with another payload returns 409 `idempotency_conflict`. An organisation-wide collision outside your workspace also returns a conflict without exposing that payment. Do not generate a replacement key for an uncertain payment. Look it up by `idempotency_key`; contact your administrator with the withdrawal ID and request ID if it remains `received` or otherwise unresolved.

### Cancel a withdrawal

While a withdrawal is `pending_approval` you can withdraw it:

```js
const result = await api.request("POST", `/api/v1/withdrawals/${withdrawalId}/cancel`);
```

No body and no `Idempotency-Key`. Repeating the call is safe: a cancelled withdrawal is returned unchanged with 200. Once an approver has acted, or the payout has moved past `pending_approval`, the call returns 409 `withdrawal_not_cancellable` and the payout continues; keep following it with `GET /api/v1/withdrawals/{id}`. A `503` means the outcome is not yet known: retry the same call.

### Signing from another language

Send `x-api-key-id`, `x-api-timestamp`, `x-api-signature`, and, for withdrawals, `idempotency-key`. The timestamp is Unix seconds within ±300 seconds of the server clock. Build UTF-8 bytes from these **five lines**, joined with a single LF:

```text
timestamp
UPPERCASE_METHOD
/api/v1/path?query-exactly-as-sent
lowercase_sha256_hex_of_raw_body
idempotency-key-or-empty-string
```

The fifth line is empty if there is no idempotency key, so the canonical string then ends with one LF. Otherwise there is no trailing LF. A GET body is empty bytes. Sign with Ed25519 and send canonical padded base64. Legacy HMAC keys instead send lowercase hex HMAC-SHA256 over the same canonical bytes, keyed by the issued secret string (do not hex-decode it).

Every signature is accepted once. Re-signing identical content within the same second otherwise produces the same signature. The supplied client adds a random `_request_id` query parameter to **every attempt**, including retries, and signs the resulting path. Other clients should do the same. Preserve the body and `Idempotency-Key` on payment retries. No additional query parameter changes a payment's identity.

Requests are limited to 120 per key per minute and bodies to 64 KiB. Responses include `Cache-Control: no-store` and `X-Request-Id`. For 429 and 503, honour `Retry-After` in seconds, then sign a new attempt. Retry 500/503 or network failures only with the same payment key/body for withdrawals. For 401, check credentials, expiry, IP restrictions, clock, canonical encoding and duplicate signatures. Other 4xx responses require correcting the request. Errors use `{ error: { code, message, details? } }`; the OpenAPI reference lists all codes.

## 4. Connect an AI agent with MCP

The included server implements **MCP over stdio** using the [official MCP SDK](https://ts.sdk.modelcontextprotocol.io/). Your agent launches a local Node process; that process signs HTTPS REST requests to MegTrust. There is no hosted `/mcp` URL or OAuth flow in this integration. Use an agent host that can launch a stdio MCP server.

Install the connector dependencies inside the downloaded directory:

```sh
npm install --ignore-scripts
```

Keep the generated lockfile with your integration. Add the following to your agent host's MCP configuration, replacing the paths and environment values. It uses the common `mcpServers` configuration shape; hosts may use a different settings file or UI.

```json
{
  "mcpServers": {
    "megtrust": {
      "command": "node",
      "args": ["/absolute/path/megtrust-integration/mcp.mjs"],
      "env": {
        "MEGTRUST_BASE_URL": "https://YOUR-MEGTRUST-HOST",
        "MEGTRUST_KEY_ID": "mk_live_REPLACE_ME",
        "MEGTRUST_PRIVATE_KEY_PATH": "/absolute/path/megtrust-private.pem"
      }
    }
  }
}
```

Restart or reconnect the agent's MCP session. Discover these seven read tools: `list_wallets`, `get_wallet`, `list_transactions`, `get_transaction`, `list_counterparties`, `list_withdrawals`, `get_withdrawal`. Try: **“Use MegTrust to list my wallets and show the deposit address for each network.”** The agent can also read `megtrust://docs/integration_guide` and `megtrust://docs/openapi` as MCP resources without an API call.

To enable `create_counterparty`, `request_withdrawal` and `cancel_withdrawal`, add `"MEGTRUST_MCP_ALLOW_WRITES": "true"` to the process environment and restart it. No other value enables writes. The MCP withdrawal tool requires an explicit network and a caller-provided, persisted idempotency key. Revoking the API key cuts off subsequent API calls.

Read-only mode controls this connector's exposed tools; it does **not** turn the underlying API credential into a server-enforced read-only key. Choose an agent host and machine you trust with that credential. Tool output can include client-entered names and labels: treat these as data, not instructions.

This package is downloaded from your MegTrust environment; it has not been published to npm. Do not assume an `npx @megtrust/...` package exists.

## 5. Receive webhooks

Register a public HTTPS receiver in **Developers → Webhooks** and save its one-time secret separately from your API key. Supported events are `withdrawal.status_changed`, `deposit.received`, and `deposit.completed`.

```json
{
  "id": "evt_wd_EXAMPLE_completed",
  "type": "withdrawal.status_changed",
  "created_at": "2026-09-08T00:00:00.000Z",
  "data": { "id": "WITHDRAWAL_ID", "status": "completed" }
}
```

The example omits the remaining withdrawal fields for brevity; a real status event's data has the withdrawal schema in OpenAPI. Deposit events have their own schema there.

Verify the **raw request bytes before JSON parsing**:

```js
import { verifyWebhook } from "./client.mjs";
const valid = verifyWebhook(rawBody,
  request.headers.get("x-webhook-timestamp"),
  request.headers.get("x-webhook-signature"),
  process.env.MEGTRUST_WEBHOOK_SECRET);
if (!valid) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(rawBody.toString("utf8"));
// Durably record event.id and queue processing in one transaction, ignoring duplicates.
// Return 2xx only after that write succeeds; process the queue outside the request.
```

Signature: lowercase hex HMAC-SHA256 of `timestamp + "." + rawBody`. Timestamp is Unix seconds, accepted within ±5 minutes. Deliveries may be duplicated, delayed or arrive out of order. Deduplicate by `event.id`, reconcile withdrawal status when needed, and never credit an unsettled deposit as completed. Receivers should respond within 10 seconds. Failed deliveries retry with exponential backoff, up to eight attempts; delivery health is visible in the console. Delivery depends on the environment's configured scheduler, so use polling for recovery rather than relying exclusively on events.

## Before going live

Use the intended sandbox host to confirm key registration, a signed read, a foreign-wallet 404, withdrawal retry recovery, required approvals, final settlement and your webhook receiver. Then repeat the read-only authentication check against production with a separate production key. The supplied unit/protocol checks do not prove your deployment's credentials, policies, network funding or webhook scheduler are configured.
