AventraGuard · API Documentation
API Reference Swagger ↗ Get an API key

AventraGuard Public API

A server-to-server REST API for ingesting transactions and parties, receiving real-time AML risk signals, and reading compliance artifacts — all secured with HMAC-SHA256 signed requests.

New here? Read this page top-to-bottom. You will have a working, signed API call in under 10 minutes. Experienced with HMAC-signed APIs? Jump straight to Request Signing.

Base URLs

There is no separate sandbox host — Staging is the sandbox. Use it with a ak_test_ key to integrate and test against real platform behaviour without touching production data or filing to FINTRAC.

EnvironmentBase URLKey prefix
Productionhttps://api.aventraguard.comak_live_
Staging (sandbox)https://staging.aventraguard.comak_test_
Devhttps://dev.aventraguard.comak_test_

The key prefix and the host must agree — a ak_live_ key on the staging/dev (test) hosts is rejected with HTTP 401, and a ak_test_ key on production is rejected too.

Step 1 — Get credentials

An API key is an opaque string that identifies your reporting entity and grants a set of permissions (scopes). It is issued by an AventraGuard operator — there is no public self-service sign-up.

A webhook secret is a separate credential used only to verify that events we push to your endpoint came from AventraGuard (not an attacker who found your URL).

  • 1
    Ask the admin or MLRO on your team to sign in to AventraGuard → Settings → Public API → Keys → Create key. Choose a label (e.g. acme-prod-2026), select scopes, set mode to Live for production or Test for the staging/dev (sandbox) hosts.
  • 2
    The raw key is shown once in a modal. Copy it directly into your secrets manager (AWS Secrets Manager, HashiCorp Vault, GitHub Actions secret, etc.). Once the modal closes, only the hashed form is stored — there is no recovery path.
  • 3
    To create a webhook signing secret, go to Settings → Public API → Webhooks → Add webhook. The secret field in the 201 response (or the UI modal) is also shown exactly once. Store it the same way.
Never
Commit the key to git, embed it in a browser bundle or mobile app, email it in plain text, or log it. The API is server-to-server only. If you suspect a leak, revoke immediately in the admin UI.

Step 2 — Make your first signed request

HMAC (Hash-based Message Authentication Code) is a way of proving you sent a specific request without a password travelling on the wire. You compute a cryptographic fingerprint of the request using your API key as the secret, then send the fingerprint in a header. The server recomputes the fingerprint and compares. If they match, you must have the key.

GET requests only require the X-API-Key header. POST/PATCH/DELETE additionally require X-Aventra-Timestamp and X-Aventra-Signature — details in Authentication & Signing. Here is a GET to get you started:

# Replace the key value with your real key.
curl -sS "https://api.aventraguard.com/v1/parties?limit=5" \
  -H "X-API-Key: ak_live_3f8a9e2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f"
import requests

resp = requests.get(
    "https://api.aventraguard.com/v1/parties",
    params={"limit": 5},
    headers={"X-API-Key": "ak_live_..."},
)
resp.raise_for_status()
parties = resp.json()
import aventra "github.com/solvanny/risk-aml-platform/sdk/go"

client, err := aventra.New(
    "https://api.aventraguard.com",
    "ak_live_...",
)
if err != nil { log.Fatal(err) }

parties, err := client.ListParties(ctx, aventra.PartyListParams{Limit: 5})
import { AventraClient } from "@aventraguard/aml-sdk";

const client = new AventraClient(
  "https://api.aventraguard.com",
  "ak_live_...",
);

const parties = await client.listParties({ limit: 5 });
<?php
$ch = curl_init("https://api.aventraguard.com/v1/parties?limit=5");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ["X-API-Key: ak_live_..."],
]);
$parties = json_decode(curl_exec($ch), true);
curl_close($ch);
import java.net.URI;
import java.net.http.*;

var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://api.aventraguard.com/v1/parties?limit=5"))
    .header("X-API-Key", "ak_live_...")
    .GET()
    .build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());

Response envelope

Every list endpoint uses the same envelope. A successful call with no data returns {"items":[],"total":0,"limit":5,"next_cursor":""} — expected on a fresh key before you push any data.

FieldTypeMeaning
itemsarrayThe page of results.
totalintegerTotal rows matching your filter across all pages.
limitintegerPage size used (1–200, default 50).
next_cursorstringOpaque cursor; empty on the last page. Pass as ?cursor=… on the next request.

Step 3 — Receive your first webhook

A webhook is an HTTP POST that AventraGuard sends to a URL you control whenever something happens — an alert fires, a case opens, a filing is submitted. Instead of polling GET /v1/alerts every few seconds, you register once and we push events to you.

  • 1
    Expose a public HTTPS URL (e.g. https://hooks.acme.com/aventraguard). It must resolve to a public IP — loopback and RFC 1918 addresses are rejected.
  • 2
    Register it: POST /v1/webhooks with {"url":"https://hooks.acme.com/aventraguard","label":"acme-prod"}. Copy the secret from the 201 response immediately.
  • 3
    In your handler, read the raw request body bytes before any JSON parsing, then verify the X-Aventra-Webhook-Signature header. Return HTTP 200 within 15 seconds (offload slow work to a background queue). See Signature verification.

API Keys

An API key identifies your reporting entity and carries a set of scopes. Every request must include it in the X-API-Key header.

Key format

ModePrefixExample
Live (production)ak_live_ak_live_3f8a9e2b…8e9f
Test (staging/dev sandbox)ak_test_ak_test_3f8a9e2b…8e9f

The body is 64 lowercase hex characters (32 bytes of entropy). Total length: 72 characters. We store only the SHA-256 hash — even an operator with full database access cannot recover the raw key.

Scopes

ScopeGrants
parties:readList parties, get a single party, list a party's screening hits.
parties:writeCreate/upsert a party, trigger tier recompute.
transactions:writeIngest a transaction (single or batch).
transactions:readRead back ingested transactions.
alerts:readList alerts, get a single alert.
alerts:writeDisposition an alert (true_positive / false_positive).
cases:readList cases, get a single case.
webhooks:readList webhook registrations.
webhooks:writeCreate / delete webhook registrations.
filings:readList STR filings, get a single filing.
audit:readRead audit log entries. Granted on request.

Request the smallest set you need. A missing scope returns 403 insufficient-scope with the required scope name in extensions.required_scope.

Request Signing (HMAC-SHA256)

Signing proves three things at once: you possess the secret, the request was not modified in transit, and (combined with the timestamp) it is not a replay of an old request.

When signing is required

MethodSigning
GETOptional — you may include it for defence in depth; the server does not require it.
POST, PATCH, DELETERequired.

The three headers

HeaderValue
X-API-KeyYour raw API key (ak_live_…).
X-Aventra-TimestampCurrent Unix time in seconds as a decimal string (e.g. 1748534400). Must be within 5 minutes of the server clock.
X-Aventra-Signature64 lowercase hex chars: hex(HMAC-SHA256(raw_api_key, string_to_sign)).

String to sign

Construct exactly this string with literal newline (\n) separators. No trailing newline.

METHOD\nPATH_WITH_QUERY\nTIMESTAMP\nBODY_SHA256_HEX
FieldDescription
METHODUppercase HTTP method: GET, POST, PATCH, DELETE.
PATH_WITH_QUERYPath + query string exactly as on the wire: /v1/parties?limit=5. No scheme/host. Do not re-encode.
TIMESTAMPSame decimal string as X-Aventra-Timestamp, byte-for-byte.
BODY_SHA256_HEXLowercase hex SHA-256 of the raw request body bytes. For an empty body: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Worked Example

Step-by-step signing of POST /v1/parties with a minimal body. The values below are real — you can verify them against the published test vectors.

StepValue
API keyak_test_3f8a9e2b1c0d4e5f6a7b8c9d0e1f2a3b
MethodPOST
Path/v1/parties
Timestamp1748534400
Body{"source_party_id":"acme-cust-00001","party_type":"individual"}
Body SHA-256a5394d35181f07466c1e5f70baa220d07a8f2548613422409c7761a05a593d97
String to signPOST\n/v1/parties\n1748534400\na5394d35…3d97
Signature6e0652be97da59f51cfce47f7ef84b2c1ad2923145f04dadd836aaff33819f4f
#!/usr/bin/env bash
API_KEY="ak_live_3f8a9e2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f"
BODY='{"source_party_id":"acme-cust-00001","party_type":"individual"}'
TS=$(date +%s)
PATH_Q="/v1/parties"
BODY_HASH=$(printf "%s" "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
TO_SIGN="POST\n${PATH_Q}\n${TS}\n${BODY_HASH}"
SIG=$(printf "%b" "$TO_SIGN" | openssl dgst -sha256 -hmac "$API_KEY" -hex | awk '{print $2}')

curl -sS https://api.aventraguard.com/v1/parties \
  -H "X-API-Key: $API_KEY" \
  -H "X-Aventra-Timestamp: $TS" \
  -H "X-Aventra-Signature: $SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
import hashlib, hmac, time

def sign(api_key, method, path_q, body):
    ts = str(int(time.time()))
    body_hash = hashlib.sha256(body.encode()).hexdigest()
    to_sign = f"{method}\n{path_q}\n{ts}\n{body_hash}"
    sig = hmac.new(api_key.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
    return ts, sig
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "strconv"
    "time"
)

func sign(apiKey, method, pathQ, body string) (ts, sig string) {
    ts = strconv.FormatInt(time.Now().Unix(), 10)
    h := sha256.Sum256([]byte(body))
    bodyHash := hex.EncodeToString(h[:])
    toSign := fmt.Sprintf("%s\n%s\n%s\n%s", method, pathQ, ts, bodyHash)
    mac := hmac.New(sha256.New, []byte(apiKey))
    mac.Write([]byte(toSign))
    sig = hex.EncodeToString(mac.Sum(nil))
    return
}
import { createHash, createHmac } from "node:crypto";

function sign(apiKey: string, method: string, pathQ: string, body: string) {
  const ts = String(Math.floor(Date.now() / 1000));
  const bodyHash = createHash("sha256").update(body).digest("hex");
  const toSign = `${method}\n${pathQ}\n${ts}\n${bodyHash}`;
  const sig = createHmac("sha256", apiKey).update(toSign).digest("hex");
  return { ts, sig };
}
<?php
function sign(string $apiKey, string $method, string $pathQ, string $body): array {
    $ts = (string) time();
    $bodyHash = hash('sha256', $body);
    $toSign = "$method\n$pathQ\n$ts\n$bodyHash";
    $sig = hash_hmac('sha256', $toSign, $apiKey);
    return [$ts, $sig];
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;

static String[] sign(String apiKey, String method, String pathQ, String body) throws Exception {
    String ts = String.valueOf(Instant.now().getEpochSecond());
    byte[] digest = MessageDigest.getInstance("SHA-256")
        .digest(body.getBytes(StandardCharsets.UTF_8));
    String bodyHash = HexFormat.of().formatHex(digest);
    String toSign = method + "\n" + pathQ + "\n" + ts + "\n" + bodyHash;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(apiKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    String sig = HexFormat.of().formatHex(mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8)));
    return new String[]{ ts, sig };
}

Test Vectors

Five precomputed vectors are published at docs/api/test-vectors/hmac-signing-v1.json. Run your signing implementation against these before sending any real request — the server's own test suite regenerates them on every run. Here is the post_party vector:

FieldValue
Keyak_test_3f8a9e2b1c0d4e5f6a7b8c9d0e1f2a3b
MethodPOST
Path/v1/parties
Timestamp1748534400
Body SHA-256a5394d35181f07466c1e5f70baa220d07a8f2548613422409c7761a05a593d97
Expected signature6e0652be97da59f51cfce47f7ef84b2c1ad2923145f04dadd836aaff33819f4f

Common drift causes: lowercase method, including the Host: header in the string, stripping the query string, uppercase hex output.

Idempotency

Idempotency means "doing the same thing twice has the same effect as doing it once." The Idempotency-Key header lets you safely retry a POST or PATCH after a network failure without creating duplicates.

  • A UUID v4 is recommended (any unique string up to 128 bytes is accepted).
  • Keys are scoped per API key — your key's acme-001 does not collide with another integrator's.
  • The first successful 2xx response is cached for 24 hours. Retries with the same key replay the cached response and add Idempotency-Replayed: true.
  • Only 2xx responses are cached. A validation error on the first try will not be replayed — the next attempt runs fresh.
  • Reusing the same key with a different request body returns 409 idempotency-conflict. Generate a fresh UUID per logical operation.
  • Cap your retry horizon below 24 hours for write endpoints — a retry after key expiry will re-execute.

Replay Protection

Two mechanisms prevent replayed requests:

  • Timestamp window: X-Aventra-Timestamp must be within 5 minutes of the server clock. Drift returns 401 timestamp-expired. Keep your servers on NTP; always compute the timestamp at request-build time, not at config load time.
  • Nonce cache: The server derives nonce = sha256(timestamp + ":" + signature) and stores it for 10 minutes. A repeated (timestamp, signature) pair returns 401 replay-detected. Always re-sign on retry with a fresh timestamp.

Rate Limits

OperationLimit
Read (GET)600 requests / minute per key
Write (POST / PATCH / DELETE)60 requests / minute per key

On 429 the server returns a Retry-After header. Sleep that many seconds, then re-sign and retry with the same Idempotency-Key on mutations. Response headers RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset are available on every response to let you pace proactively.

API Reference

Every endpoint, parameter, and response field. Signing column: required = POST/PATCH/DELETE; optional = GET.

Interactive · OpenAPI
Prefer to browse the machine-readable spec? Open the live Swagger / OpenAPI UI ↗ (served by the API host for this environment). Note it is primarily the platform/admin OpenAPI surface and its “Try it out” panel uses a dashboard session token — it does not perform the API-key + HMAC signing that the public /v1 endpoints require. To call /v1 for real, use the signed examples and SDKs on this page.

Parties

A party is a customer or business entity subject to AML monitoring. Parties are the unit of risk scoring, sanctions screening, and EDD tracking.

Create / upsert a party

POST /v1/parties

Insert a customer record, or update it if (re_id, "public_api", source_party_id) already exists. Scope: parties:write. Returns 201.

FieldTypeReqDescription
source_party_idstringyesYour stable customer ID. Forms the upsert key.
party_typestringyesindividual or business.
given_namestringnoIndividual given (first) name.
family_namestringnoIndividual family (last) name.
legal_namestringnoFull legal name for individuals.
business_namestringnoRegistered business name.
emailstringnoContact email.
phonestringnoE.164 format.
address_countrystringnoISO 3166-1 alpha-2 (e.g. CA).
address_regionstringnoProvince / state.
address_citystringnoCity.
address_postalstringnoPostal / ZIP.
birth_datestringnoISO 8601 YYYY-MM-DD. Individuals only. Stored encrypted, never returned.
kyc_statusstringnoFree-form KYC status from your vendor.
source_created_atstringnoRFC 3339 — when your system created the record.
government_id_numberstringnoStored AES-256-GCM encrypted; never returned. Individuals only.
curl -sS https://api.aventraguard.com/v1/parties \
  -H "X-API-Key: ak_live_..." \
  -H "X-Aventra-Timestamp: $TS" \
  -H "X-Aventra-Signature: $SIG" \
  -H "Idempotency-Key: party-acme-cust-00001" \
  -H "Content-Type: application/json" \
  -d '{"source_party_id":"acme-cust-00001","party_type":"individual","given_name":"Alex","family_name":"Morgan","address_country":"CA"}'
party, err := client.CreateParty(ctx, aventra.CreatePartyRequest{
    SourcePartyID: "acme-cust-00001",
    PartyType:     "individual",
    GivenName:     "Alex",
    FamilyName:    "Morgan",
    AddressCountry: "CA",
}, aventra.WithIdempotencyKey("party-acme-cust-00001"))
const party = await client.createParty(
  { source_party_id: "acme-cust-00001", party_type: "individual",
    given_name: "Alex", family_name: "Morgan", address_country: "CA" },
  { idempotencyKey: "party-acme-cust-00001" },
);

Response 201:

{
  "id": "10421",
  "source_party_id": "acme-cust-00001",
  "party_type": "individual",
  "risk_tier": "T1",
  "kyc_status": "",
  "address_country": "CA",
  "edd_required": false,
  "created_at": "2026-05-30T14:00:00Z",
  "updated_at": "2026-05-30T14:00:00Z"
}
StatusWhen
201Created or upserted.
400Body not JSON.
422Validation failed (unknown party_type, bad date format, etc.). Read extensions.fields[].
503DB write failed (transient). Retry with same Idempotency-Key.

List parties

GET /v1/parties

Scope: parties:read. Query params: limit (1–200, default 50), cursor, order (asc|desc).

Get a party

GET /v1/parties/{id}

Scope: parties:read. {id} is the AventraGuard numeric party ID (not your source_party_id). Returns 404 if the party does not exist or belongs to a different RE.

Recompute risk tier

POST /v1/parties/{id}/recompute

Scope: parties:write. Triggers an immediate re-evaluation of the party's risk tier. Body is ignored. Returns 200 with {"party_id":10421,"previous_tier":"T2","current_tier":"T3","action":"upgraded","change_id":88421}. Action values: unchanged, upgraded, downgrade_pending, downgraded.

Party screening hits

GET /v1/parties/{id}/screening-hits

Scope: parties:read. Returns sanctions, PEP, and adverse-media hits attached to this party, with full match detail.

Tipping-off / PII — PCMLTFA s.66
Fields entry_name, matched_name, screened_name, subject_name, and disposed_by are sensitive. If you persist these fields in your own store or console, strip them at the transform layer. Do not surface sanctioned/matched names in integrator UIs.

Key field notes:

  • list_source — the screening list identifier (e.g. OSFI). Not list_code.
  • dispositionpending | confirmed | cleared. Not status.
  • first_seen_at / last_seen_at — the timestamps. There is no created_at on hits.
{
  "items": [{
    "id": 902,
    "party_id": 10421,
    "list_source": "OSFI",
    "entry_name": "ALEKSANDR MORGAN",
    "hit_kind": "sanctions",
    "match_score": 0.94,
    "disposition": "confirmed",
    "first_seen_at": "2026-04-12T16:22:00Z",
    "last_seen_at": "2026-04-13T09:00:00Z"
  }],
  "total": 1
}

Transactions

Push transactions for AML rule evaluation. The 202 response is immediate — screening runs asynchronously.

Ingest a transaction

POST /v1/transactions

Scope: transactions:write. Idempotent with Idempotency-Key.

FieldTypeReqDescription
source_transaction_idstringyesYour stable transaction ID (upsert key).
source_party_idstringyesThe party who initiated. May arrive before the party is pushed — will auto-link when party is created.
amountstringyesDecimal as string ("123.45") to preserve precision.
currencystringyesISO 4217 (CAD, USD, EUR).
statusstringyesYour status code (e.g. completed).
methodstringyesPayment method (e.g. e_transfer, wire).
actionstringyessale | deposit | withdrawal | transfer | payout | refund | reserve | complete | cancel | void | verification | request | bill_payment | other.
directionstringnocredit | debit | neutral. Overrides action-derived heuristic when your source has authoritative direction.
occurred_atstringyesRFC 3339 — when the transaction happened.
source_created_atstringyesRFC 3339 — when your system created it.
feestringnoSame decimal-string format as amount.
curl -sS https://api.aventraguard.com/v1/transactions \
  -H "X-API-Key: ak_live_..." \
  -H "X-Aventra-Timestamp: $TS" \
  -H "X-Aventra-Signature: $SIG" \
  -H "Idempotency-Key: tx-acme-2026-05-30-0001" \
  -H "Content-Type: application/json" \
  -d '{"source_transaction_id":"acme-tx-001","source_party_id":"acme-cust-00001","amount":"9850.00","currency":"CAD","status":"completed","method":"e_transfer","action":"transfer","occurred_at":"2026-05-30T13:59:42Z","source_created_at":"2026-05-30T13:59:42Z"}'
resp, err := client.IngestTransaction(ctx, aventra.IngestTransactionRequest{
    SourceTransactionID: "acme-tx-001",
    SourcePartyID:       "acme-cust-00001",
    Amount:              "9850.00",
    Currency:            "CAD",
    Status:              "completed",
    Method:              "e_transfer",
    Action:              "transfer",
    OccurredAt:          "2026-05-30T13:59:42Z",
    SourceCreatedAt:     "2026-05-30T13:59:42Z",
}, aventra.WithIdempotencyKey("tx-acme-2026-05-30-0001"))
const resp = await client.ingestTransaction(
  { source_transaction_id: "acme-tx-001", source_party_id: "acme-cust-00001",
    amount: "9850.00", currency: "CAD", status: "completed",
    method: "e_transfer", action: "transfer",
    occurred_at: "2026-05-30T13:59:42Z", source_created_at: "2026-05-30T13:59:42Z" },
  { idempotencyKey: "tx-acme-2026-05-30-0001" },
);

Response 202: {"id":"0193b6a8-…","source_transaction_id":"acme-tx-001","status":"accepted","screening_queued":true}. Listen on the alert.created webhook (or poll GET /v1/alerts) for alerts produced by this transaction.

List / get transactions

GET /v1/transactions    GET /v1/transactions/{id}

Scope: transactions:read. List supports limit, cursor, and optional source_party_id filter. party_linked: false indicates an orphan transaction (party not yet ingested) — it will link automatically when the party arrives.

Screening Hits

List all screening hits

GET /v1/screening-hits

Scope: parties:read. RE-scoped, newest-first, offset-paginated — for reconciliation after downtime without iterating parties one-by-one. Supports ?since=<RFC3339> for incremental sync. Deliberately omits matched/screened names (tipping-off sensitive) — use GET /v1/parties/{id}/screening-hits for full per-party detail.

Key fields: hit_id, party_id, list_source, hit_kind, disposition (pending|confirmed|cleared), first_seen_at, last_seen_at.

Alerts

An alert is an AML risk signal produced by the rule engine, sanctions screening, PEP match, or behavioural model.

List / get alerts

GET /v1/alerts    GET /v1/alerts/{id}

Scope: alerts:read. List params: limit, cursor, order. Fields: id, alert_type, severity, status (open|escalated|dismissed), rule_code, detail, party_id, amount, currency.

Disposition an alert

PATCH /v1/alerts/{id}/disposition

Scope: alerts:write. Body: {"disposition":"true_positive"} or {"disposition":"false_positive","notes":"..."}. true_positiveescalated; false_positivedismissed.

Cases

A case is the unit of analyst investigation, opened when an alert is escalated. Read-only in Phase 1.

List / get cases

GET /v1/cases    GET /v1/cases/{id}

Scope: cases:read. Key fields include alert_count (number of alerts linked to the case), status, opened_at, due_at, overdue. On a closed case, closed_at is populated.

{
  "id": "1502",
  "party_id": "10421",
  "status": "investigating",
  "alert_count": 3,
  "opened_at": "2026-05-30T14:01:00Z",
  "due_at": "2026-06-29T14:01:00Z",
  "overdue": false
}

Filings

STR (Suspicious Transaction Report) filings are read-only via the API. The submission workflow remains in-platform because FINTRAC submission requires MLRO four-eyes review.

List / get filings

GET /v1/filings    GET /v1/filings/{id}

Scope: filings:read. Status values: draft, pending_approval, approved, submitted, returned. The fintrac_receipt_id field is set on submission — use this for your retention obligations.

Batches

Batch management endpoints — see also Bulk / Batch Ingest for the full workflow.

Batch status / errors / cancel

GET /v1/batches/{id}   GET /v1/batches/{id}/errors   POST /v1/batches/{id}/cancel

Scope: batch's resource read scope (transactions:read for transaction batches). state: acceptedprocessingcompleted | cancelled. Cancel requires write scope; idempotent for already-cancelled batches; 409 if already completed.

Webhooks (management)

Register a webhook

POST /v1/webhooks

Scope: webhooks:write. Body: url (required, HTTPS public), label (optional), event_filters (optional array — empty/omitted = all events). The 201 response contains secret exactly once — store it immediately in your secrets manager.

StatusWhen
201Created. Capture secret.
400url missing or body not JSON.
422URL validation failed (not HTTPS, private/loopback host, credentials in URL).

List / delete webhooks

GET /v1/webhooks    DELETE /v1/webhooks/{id}

List scope: webhooks:read. Delete scope: webhooks:write. Delete returns 204. In-flight deliveries already enqueued will still complete their retry schedule; no new events will be queued after revocation.

Audit Log

Access on request
The audit:read scope is not assigned by default. Contact contact@aventraguard.com to request access. The audit log records every state-changing operation across all resources with the actor identity, timestamp, and before/after state.

Bulk / Batch Ingest

Upload thousands of transactions or parties in a single request for historical backfills or high-volume onboarding.

What is NDJSON?

NDJSON (Newline-Delimited JSON) is simply one complete JSON object per line, lines separated by \n. There is no outer array and no trailing newline. Each line is exactly the same shape as the single-row POST body.

{"source_transaction_id":"tx-001","source_party_id":"cust-001","amount":"500.00","currency":"CAD","status":"completed","method":"eft","action":"deposit","occurred_at":"2026-01-01T10:00:00Z","source_created_at":"2026-01-01T10:00:00Z"}
{"source_transaction_id":"tx-002","source_party_id":"cust-002","amount":"1200.00","currency":"CAD","status":"completed","method":"wire","action":"transfer","occurred_at":"2026-01-01T11:00:00Z","source_created_at":"2026-01-01T11:00:00Z"}
  • Maximum body size: 4 MiB per request. Split larger backfills across multiple batches.
  • Invalid rows do not abort the batch — they are recorded as failed with a reason while valid rows proceed.
  • Blank lines are ignored.
  • Content-Type must be application/x-ndjson.

Async 202 flow

The server validates each line, enqueues valid rows for asynchronous processing, and returns 202 immediately with a batch_id. A 202 is not confirmation every row landed.

curl -sS -X POST https://api.aventraguard.com/v1/transactions/batch \
  -H "X-API-Key: ak_live_..." \
  -H "X-Aventra-Timestamp: $TS" \
  -H "X-Aventra-Signature: $SIG" \
  -H "Idempotency-Key: backfill-2026-q1-001" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @transactions.ndjson
batch, err := client.IngestTransactionsBatch(ctx, items,
    aventra.WithIdempotencyKey("backfill-2026-q1-001"))
// batch.BatchID, batch.StatusURL
const batch = await client.ingestTransactionsBatch(
  items,
  { idempotencyKey: "backfill-2026-q1-001" },
);
// batch.batchId, batch.statusUrl

202 response:

{
  "batch_id": "0193b6d4-9999-7a00-bf01-aaaabbbbcccc",
  "status_url": "/v1/batches/0193b6d4-9999-7a00-bf01-aaaabbbbcccc",
  "state": "accepted",
  "total_rows": 4821
}

Tracking status

Poll GET /v1/batches/{id} or subscribe to the batch.completed webhook. State progresses: acceptedprocessingcompleted.

status, err := client.GetBatch(ctx, batch.BatchID)
if status.FailedRows > 0 {
    errs, _ := client.ListBatchErrors(ctx, batch.BatchID, aventra.BatchErrorsParams{})
    // errs.Items: [{Line:17, Message:"currency: must be ISO 4217"}, ...]
}
const status = await client.getBatch(batch.batchId);
if (status.failedRows > 0) {
  const errs = await client.listBatchErrors(batch.batchId);
  // errs.items: [{ line: 17, message: "currency: must be ISO 4217" }]
}

Cancelling a batch

Stop processing the unprocessed remainder of a batch — e.g. a backfill submitted in error. Already-ingested rows stay ingested. Idempotent for already-cancelled batches. Returns 409 if the batch is already completed (its work is done).

status, err := client.CancelBatch(ctx, batch.BatchID, "wrong source file")
const status = await client.cancelBatch(batch.batchId, "wrong source file");

For parties, use POST /v1/parties/batch / client.IngestPartiesBatch / client.ingestPartiesBatch — identical shape and flow.

Webhooks

AventraGuard pushes events to your HTTPS endpoint the moment they happen — no polling required.

What is a webhook?

A webhook is an HTTP POST we send to a URL you control whenever something noteworthy happens — an alert fires, a case opens, a screening hit is confirmed. You give us a URL; we call it with a JSON payload. You return HTTP 200 to acknowledge, and we move on. If you return anything else, we retry on an exponential curve.

The key difference from polling: instead of asking "did anything happen?" every few seconds, you register once and we tell you immediately. This reduces latency and API load for both sides.

Event catalogue

Event typeWhen it firesKey use case
alert.createdA new AML alert is raised.Open a hold, notify an analyst.
alert.disposedAn alert is dispositioned (confirmed or dismissed).Release a hold; sync local alert state.
case.openedA case is opened for investigation.Surface to customer-service; track SLA.
case.closedA case is closed by analyst or MLRO.Restore account access or escalate.
filing.submittedAn STR is submitted to FINTRAC and a receipt ID is available.Archive receipt for retention obligations.
party.screening_hit.createdA new sanctions/PEP hit is detected (pre-review).Place a provisional hold immediately.
party.screening_hitA screening hit is confirmed by an analyst.Escalate provisional hold to firm hold.
party.risk_tier_changedA party's risk tier (T1–T5) changes.Update transaction-limit rules.
batch.completedA bulk-ingest batch finishes (terminal). Not fired on cancel.Avoid polling; reconcile counts.

Payload envelope

{
  "event_id": "0193b6d4-9999-7a00-bf01-aaaabbbbcccc",
  "event_type": "alert.created",
  "created_at": "2026-05-30T14:00:01Z",
  "data": {
    "alert_id": "33010",
    "re_id": "0193b6a8-1e1a-7c00-9d77-3f0a2b1c4d5e",
    "severity": "high",
    "status": "open",
    "rule_code": "STRUCTURED_DEPOSITS",
    "party_id": "10421"
  }
}

event_id is a UUID v7 that is stable across all retry attempts — use it as your idempotency key in your handler. created_at is the event creation time, not the delivery time.

Signature verification

You must verify every webhook before acting on its contents. An attacker who finds your URL could otherwise replay forged events.

The signature header

X-Aventra-Webhook-Signature: t=1748613602,v1=7c1a9b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9fa1b2
  • t — Unix timestamp (seconds) of the event creation time. Stable across retries.
  • v1hex(HMAC-SHA256(raw_secret, "<t>.<raw_body_bytes>")). The signed message binds the timestamp to the body.

During secret rotation the header carries two v1= values. Accept the delivery if any matches your stored secret.

Step-by-step verification

  1. Read the raw request body bytes before any JSON parsing. Re-serialising the parsed JSON breaks the hash.
  2. Parse X-Aventra-Webhook-Signature: split on ,, extract t= (integer) and all v1= values.
  3. Build the signed message: signed_msg = "<t>" + "." + raw_body_bytes.
  4. Compute expected = hex(HMAC-SHA256(raw_secret_utf8, signed_msg)).
  5. Compare expected to each v1= value in constant time. Accept if any matches.
  6. Optionally reject if abs(now - t) > 300 (5-minute clock skew tolerance).
  7. Use event_id as your idempotency key — deduplicate before processing.
import aventra "github.com/solvanny/risk-aml-platform/sdk/go"

func receiveWebhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20))
    err := aventra.VerifyWebhook(
        []byte("whsec_..."),                      // raw secret as UTF-8
        r.Header.Get("X-Aventra-Webhook-Signature"),
        body,
        5*time.Minute,                          // tolerance
    )
    if errors.Is(err, aventra.ErrSignatureMismatch) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    // Use event_id as idempotency key, then process
    w.WriteHeader(http.StatusOK)
}
import { verifyWebhook, SignatureMismatchError } from "@aventraguard/aml-sdk";

app.post("/webhooks/aventra", express.raw({ type: "*/*" }), (req, res) => {
  try {
    verifyWebhook(
      process.env.AVENTRA_WEBHOOK_SECRET,            // "whsec_..."
      req.headers["x-aventra-webhook-signature"],
      req.body,           // raw Buffer — before JSON.parse()
      300,                // 5-minute tolerance
    );
  } catch (err) {
    if (err instanceof SignatureMismatchError)
      return res.status(401).end();
    throw err;
  }
  const event = JSON.parse(req.body.toString());
  // dedupe on event.event_id, then process
  res.status(200).end();
});
header  = request.headers["X-Aventra-Webhook-Signature"]
parts   = split(header, ",")
t       = integer value of the "t=" part
v1s     = all "v1=" values (may be 2 during rotation)
msg     = encode_utf8(t + ".") + raw_body_bytes
expected = hex(HMAC-SHA256(raw_secret_utf8, msg))
valid   = ANY(constant_time_equal(expected, v) for v in v1s)
        AND (optional) abs(now() - t) < 300
if !valid: return HTTP 401

Secret rotation

Zero-downtime rotation replaces the signing secret without dropping deliveries. During the overlap window (default 14 days) AventraGuard signs every delivery with both secrets:

X-Aventra-Webhook-Signature: t=1748613602,v1=<sig_old>,v1=<sig_new>

Your verification code already handles this if you iterate all v1= values. No code change needed — just update your stored secret and the overlap window covers the rest.

Endpoints: POST /api/admin/public-api/webhooks/{id}/rotate-secret and POST /api/admin/public-api/webhooks/{id}/finalize-secret-rotation. Both require admin/super_user + TOTP.

Retries & dead-letter

AventraGuard retries a failed delivery 6 more times (7 total attempts) on a graduated curve:

AttemptNominal delayRange (±20% jitter)
1 (initial)Immediate
21 min48 s – 1 m 12 s
35 min4 m – 6 m
430 min24 m – 36 m
52 h1 h 36 m – 2 h 24 m
68 h6 h 24 m – 9 h 36 m
724 h19 h 12 m – 28 h 48 m

After all 7 attempts fail the delivery is marked dead_lettered. The operator can manually re-trigger from the admin UI. Per-delivery timeout: 15 seconds. HTTP 4xx permanent failures (400, 401, 403, 404, 405, 410, 415, 422) skip the retry curve and go straight to dead-letter.

Each delivery includes X-AM-Delivery-Attempt: <n> (1-indexed) for logging.

Tipping-off / PII — PCMLTFA s.66

Tipping-off / PII — PCMLTFA s.66
The party.screening_hit.created and party.screening_hit webhook events are deliberately trigger-only. They carry hit_id, re_id, hit_kind, and party_id — but no matched name, list source, or score. This is intentional: disclosing screening matches to the subject is a criminal offence under PCMLTFA s.66. Fetch full detail via GET /v1/parties/{party_id}/screening-hits within your own access-controlled system. Fields to strip before persisting to external stores: entry_name, matched_name, screened_name, subject_name, disposed_by, return_reason.

Receiver checklist

  • Receiver is HTTPS and resolves to a public IP.
  • Verifies X-Aventra-Webhook-Signature on every request; returns 401 on mismatch.
  • Deduplicates by event_id (idempotent handler).
  • Returns 200 within 15 seconds; defers slow work to a background queue.
  • Returns 5xx on transient failures (so retries help); 4xx only on permanent errors.
  • Logs event_id, event_type, X-AM-Delivery-Attempt, and processing status.
  • Webhook secret is stored in a secrets manager, not source control.
  • Error responses do not echo back request fields — avoids PII in the delivery log.

Errors

Every error from the AventraGuard API follows RFC 7807 (Problem Details for HTTP APIs) so you only need one parser for your whole integration.

Error shape

{
  "type": "https://aventra.io/errors/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "One or more fields did not pass validation.",
  "instance": "/v1/transactions",
  "extensions": {
    "fields": [
      { "field": "currency", "message": "must be a valid ISO 4217 code" }
    ]
  }
}

Switch on type, not title or detailtype is part of the contract; title/detail are wording we may refine. The type URI last segment is the stable code string.

All error codes

HTTPType codeWhenAction
400invalid-requestBody not JSON, URL param not an integer, alert already closed.Fix the request — will not succeed as-is.
401missing-api-keyNo X-API-Key header.Add the header on every request.
401invalid-api-keyKey not found or revoked. Same code for both by design.Check your secrets manager and admin UI.
401signature-mismatchHMAC did not match.Verify your signed-string format and that you are using raw body bytes.
401timestamp-expiredTimestamp outside 5-minute window.Check NTP sync; compute timestamp at request-build time.
401replay-detectedSame (timestamp, signature) within 10 minutes.Re-sign with a fresh timestamp on every retry.
403insufficient-scopeKey lacks the required scope.Check extensions.required_scope; ask operator to add scope.
404not-foundResource does not exist or belongs to another RE.Confirm the ID and the key.
405method-not-allowedMethod not supported. Check Allow header.Use a method listed in Allow.
409idempotency-conflictSame Idempotency-Key reused with a different body.Generate a fresh UUID for each logical operation.
422validation-errorField validation failed.Read extensions.fields[] and fix the input.
429rate-limit-exceededPer-key rate limit hit.Sleep Retry-After seconds, re-sign, retry.
500internal-errorUnexpected server failure.Retry with capped exponential backoff; email contact@aventraguard.com if persistent.
503service-unavailableTransient downstream failure (DB, screening queue).Retry with exponential backoff; keep Idempotency-Key stable.

409 Idempotency conflict

You reused an Idempotency-Key for a logically different operation. The first response was cached and replaying it would be wrong for the new body. Fix: generate a fresh UUID per logical operation. The pattern aventra:tx:<your-tx-id> (deterministic from your source ID) is safe and convenient.

429 Rate limit & Retry-After

HTTP/1.1 429 Too Many Requests
Retry-After: 60
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 1748534460

{
  "type": "https://aventra.io/errors/rate-limit-exceeded",
  "status": 429,
  "extensions": { "retry_after_seconds": 60 }
}

Go SDK

Official Go client — stdlib only, zero third-party dependencies, concurrent-safe.

Install

go get github.com/solvanny/risk-aml-platform/sdk/go

Requires Go 1.25+.

Quick start

  • 1
    Import the package as aventra.
  • 2
    Call aventra.New(baseURL, apiKey, opts...). The API key is the HMAC signing key — no separate signing secret.
  • 3
    Call methods on the returned *Client. Every method accepts a context.Context as first argument.
  • 4
    For write methods, supply aventra.WithIdempotencyKey("your-id") to enable safe retries and server-side deduplication.
import (
    aventra "github.com/solvanny/risk-aml-platform/sdk/go"
    "context"
    "time"
)

client, err := aventra.New(
    "https://api.aventraguard.com",
    "ak_live_...",
    aventra.WithTimeout(10*time.Second),
    aventra.WithRetry(aventra.DefaultRetryPolicy),
)

party, err := client.CreateParty(ctx, aventra.CreatePartyRequest{
    SourcePartyID: "cust-001",
    PartyType:     "individual",
}, aventra.WithIdempotencyKey("cust-001"))

All methods

MethodEndpointNotes
CreateParty(ctx, req, opts...)POST /v1/partiesReturns PartyResponse.
ListParties(ctx, params)GET /v1/partiesReturns PartyListResponse.
GetParty(ctx, id)GET /v1/parties/{id}
GetPartyScreeningHits(ctx, partyID, params)GET /v1/parties/{id}/screening-hits
IngestTransaction(ctx, req, opts...)POST /v1/transactionsReturns IngestTransactionResponse.
GetTransaction(ctx, id)GET /v1/transactions/{id}
ListTransactions(ctx, params)GET /v1/transactionsOptional SourcePartyID filter.
IngestTransactionsBatch(ctx, items, opts...)POST /v1/transactions/batchNDJSON; 4 MiB guard; returns BatchAccepted.
IngestPartiesBatch(ctx, items, opts...)POST /v1/parties/batchSame NDJSON shape.
GetBatch(ctx, batchID)GET /v1/batches/{id}Returns BatchStatus.
ListBatchErrors(ctx, batchID, params)GET /v1/batches/{id}/errorsReturns BatchErrorsPage.
CancelBatch(ctx, batchID, reason)POST /v1/batches/{id}/cancelPass "" to omit reason.

Webhook verification

err := aventra.VerifyWebhook(
    []byte("whsec_3f8a9e2b..."),              // raw secret UTF-8 bytes
    r.Header.Get("X-Aventra-Webhook-Signature"),
    body,                                  // raw bytes before JSON parse
    5*time.Minute,
)
switch {
case errors.Is(err, aventra.ErrSignatureMismatch):
    // reject
case errors.Is(err, aventra.ErrTimestampOutOfTolerance):
    // reject stale delivery
case errors.Is(err, aventra.ErrMalformedSignature):
    // reject malformed header
}

Error handling

var apiErr *aventra.APIError
if errors.As(err, &apiErr) {
    switch apiErr.Type {
    case "https://aventra.io/errors/not-found":
        // 404
    case "https://aventra.io/errors/rate-limit-exceeded":
        retryAfter := apiErr.Extensions["retry_after_seconds"]
    case "https://aventra.io/errors/validation-error":
        fields := apiErr.Extensions["fields"]
    }
}

Retry policy

Retry is off by default. Enable with WithRetry(aventra.DefaultRetryPolicy) — 3 total attempts, 1 s base delay, 30 s cap, 429 and 5xx only. POST/PATCH/DELETE requests without an Idempotency-Key are never retried (unsafe). The SDK re-signs with a fresh timestamp on each retry, satisfying the server's replay-protection requirement.

// Run tests:
go test -count=1 -race ./...

TypeScript SDK

Official TypeScript/Node client — stdlib only, zero third-party runtime dependencies, Node 18+.

Install

npm install @aventraguard/aml-sdk

Requires Node 18+ (uses global fetch and node:crypto).

Quick start

  • 1
    Import AventraClient from "@aventraguard/aml-sdk".
  • 2
    Construct: new AventraClient(baseURL, apiKey, options). The API key is the HMAC signing key — no separate secret.
  • 3
    Await any method. All methods are async and return typed results.
  • 4
    For writes, pass { idempotencyKey: "your-id" } as the last argument to enable safe retries.
import { AventraClient, DEFAULT_RETRY_POLICY } from "@aventraguard/aml-sdk";

const client = new AventraClient(
  "https://api.aventraguard.com",
  "ak_live_...",
  { retry: DEFAULT_RETRY_POLICY },
);

const result = await client.ingestTransaction(
  {
    source_transaction_id: "tx-001",
    source_party_id:       "cust-001",
    amount:                "1500.00",
    currency:              "CAD",
    status:                "completed",
    method:                "e_transfer",
    action:                "transfer",
    occurred_at:           new Date().toISOString(),
    source_created_at:     new Date().toISOString(),
  },
  { idempotencyKey: "tx-001" },
);

All methods

MethodEndpointNotes
createParty(req, opts?)POST /v1/partiesReturns PartyResponse.
listParties(params?, opts?)GET /v1/parties
getParty(id, opts?)GET /v1/parties/{id}
getPartyScreeningHits(partyId, params?, opts?)GET /v1/parties/{id}/screening-hits
ingestTransaction(req, opts?)POST /v1/transactions
getTransaction(id, opts?)GET /v1/transactions/{id}
listTransactions(params?, opts?)GET /v1/transactionsOptional source_party_id filter.
ingestTransactionsBatch(items, opts?)POST /v1/transactions/batchNDJSON; 4 MiB guard; returns BatchAccepted.
ingestPartiesBatch(items, opts?)POST /v1/parties/batchSame shape.
getBatch(batchId, opts?)GET /v1/batches/{id}Returns BatchStatus (camelCase).
listBatchErrors(batchId, params?, opts?)GET /v1/batches/{id}/errors
cancelBatch(batchId, reason?, opts?)POST /v1/batches/{id}/cancelPass undefined to omit reason.

Webhook verification

import { verifyWebhook, SignatureMismatchError, MalformedSignatureError } from "@aventraguard/aml-sdk";

// In Express: use express.raw({ type: "*/*" }) to get raw Buffer
app.post("/webhooks/aventra", express.raw({ type: "*/*" }), (req, res) => {
  try {
    verifyWebhook(
      process.env.AVENTRA_WEBHOOK_SECRET,
      req.headers["x-aventra-webhook-signature"] as string,
      req.body as Buffer,
      300,
    );
  } catch (err) {
    if (err instanceof SignatureMismatchError || err instanceof MalformedSignatureError)
      return res.status(401).end();
    throw err;
  }
  const event = JSON.parse((req.body as Buffer).toString());
  // dedupe on event.event_id, then process
  res.status(200).end();
});

Error handling

import { ApiError } from "@aventraguard/aml-sdk";

try {
  await client.getTransaction("missing-id");
} catch (err) {
  if (err instanceof ApiError) {
    switch (err.type) {
      case "https://aventra.io/errors/not-found": break;
      case "https://aventra.io/errors/rate-limit-exceeded":
        console.log(err.extensions?.retry_after_seconds); break;
      case "https://aventra.io/errors/validation-error":
        console.log(err.extensions?.fields); break;
    }
    // err.status is always the real HTTP status
  }
}

Retry policy

Retry is off by default. Enable with retry: DEFAULT_RETRY_POLICY — 3 total attempts, 1 s base delay, 30 s cap, 429 and 5xx only. POST/PATCH/DELETE without idempotencyKey are never retried. The SDK re-signs on each retry. Pass an AbortSignal in CallOptions for per-request cancellation.

Security properties: HTTPS enforced (loopback excepted for local dev); redirects are blocked (redirect: "manual"); all v1= comparisons use timingSafeEqual; success responses bounded to 4 MiB.

# Run tests:
npm run build && npm test

Reference

Rate limits

OperationDefault limitWindow
Read (GET)600 requests / keyRolling 60 seconds
Write (POST / PATCH / DELETE)60 requests / keyRolling 60 seconds
Batch body4 MiB per request

Response headers: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (IETF draft form) plus X-RateLimit-* aliases. Limits are tunable by the operator via environment variables.

Idempotency semantics

  • Header: Idempotency-Key: <up to 128 bytes>
  • Cache duration: 24 hours from first 2xx response.
  • Scope: per API key (not global).
  • Replay adds: Idempotency-Replayed: true response header.
  • Reusing a key with a different body: 409 idempotency-conflict.
  • Non-2xx first responses are not cached — the second attempt runs fresh.

Scopes

ScopeRead/WriteEndpoints
parties:readReadGET /v1/parties, GET /v1/parties/{id}, GET /v1/parties/{id}/screening-hits, GET /v1/screening-hits
parties:writeWritePOST /v1/parties, POST /v1/parties/{id}/recompute, POST /v1/parties/batch
transactions:readReadGET /v1/transactions, GET /v1/transactions/{id}, GET /v1/batches/{id}*
transactions:writeWritePOST /v1/transactions, POST /v1/transactions/batch, POST /v1/batches/{id}/cancel*
alerts:readReadGET /v1/alerts, GET /v1/alerts/{id}
alerts:writeWritePATCH /v1/alerts/{id}/disposition
cases:readReadGET /v1/cases, GET /v1/cases/{id}
filings:readReadGET /v1/filings, GET /v1/filings/{id}
webhooks:readReadGET /v1/webhooks
webhooks:writeWritePOST /v1/webhooks, DELETE /v1/webhooks/{id}
audit:readReadAudit log (granted on request).

* Batch lifecycle endpoints require the batch's own resource scope (transactions or parties).

Changelog summary

VersionDateHighlights
UnreleasedBulk batch ingest (NDJSON, ≤4 MiB); batch status/errors/cancel; batch.completed (9th webhook event); party.screening_hit.created (pre-review detection event); GET /v1/screening-hits (bulk reconciliation); GET /v1/transactions[/{id}] read-back; transaction-to-party orphan re-link on party creation; PCMLTFA individual fields (birth_date, government_id_*, occupation, etc.); zero-downtime webhook secret rotation (ADR-0013); list endpoints reject invalid pagination with 400; unsupported methods return 405.
v1.2.02026-06-05Critical fix: canonical_direction was hardcoded to credit; fixed direction derivation from action. Critical fix: 503 on all /v1/transactions and /v1/parties calls (schema CHECK constraint). Optional direction field on POST /v1/transactions. 409 on idempotency-key body-hash mismatch. Published HMAC signing test vectors. Rate-limit response headers. Webhook payload schemas (7/7 events).
v1.1.02026-05-30Retry curve extended to 7 attempts; permanent 4xx dead-lettering; X-AM-Delivery-Attempt header; per-webhook retry policy override; dead-letter notifications.
v1.0.02026-05-30Initial release. 16 endpoints; HMAC-SHA256 signing; idempotency; replay protection; rate limiting; 7 webhook event types; RFC 7807 errors.

Full changelog: docs/api/changelog.md in the repository. Breaking changes always ship as a new URL prefix (/v2/); the /v1/ contract is never silently broken.