Skip to content
Skip to content

DEVELOPERS

Payment Collection API Documentation

Create a payment, send your customer to the payment page and receive the result by webhook or query.

  • Base URLhttps://www.cryptoavans.com/api/v3
  • API version3.0.0

Getting started

Environment

Test without moving real money.

Real payments. A live key is required.

Getting started

From a new account to the first live payment in four steps.

  1. Get your keys

    Create an account with “Sign up” or sign in, open “API Key & Integration” and generate a sandbox key. Add your server IP and a webhook address first. Details: Getting a key.

  2. Create a payment

    Send a signed POST /api/v3/payments (there is sample code) and take the customer to the returned payment_url. Fields and rules are in Payments.

  3. Test and receive the result

    Complete a test payment with a test card and review it in “Sandbox API orders”. Your webhook receives a signed payment.paid (Webhooks); you can also read GET /api/v3/payments/{code}. Complete the order only when the status is paid.

  4. Go live

    After your first sandbox order, generate the live key, add the live IP list and webhook address, and work through the go-live checklist.

Conventions

Base URL
https://www.cryptoavans.com/api/v3
Format
application/json · UTF-8
Amounts
Decimal strings: fiat amounts have 2 decimals (for example "250.00"). usdt_amount has 6.
Currencies
USD EUR AED TRY
Timestamps
UTC · ISO 8601
Error language
Set with the Accept-Language header. Turkish is the default.

Balance, refunds, withdrawals, disputes and webhook deliveries are described on the Account operations page.

To collect the card on your own payment form instead, see Direct API (v4).

Getting a key

Sandbox and live have separate keys, IP lists and webhook addresses. Both keys can be active at the same time.

  1. Open “API Key & Integration”.

  2. Copy the 6-digit account number at the top. Send it as X-CAV-Account-No with your signed requests.

  3. In “Sandbox”, add your server’s public IP to “IP allowlist” with “Add IP”. Enter one IPv4 or IPv6 address at a time. CIDR ranges are not supported.

  4. Enter a public HTTPS address in “HTTPS webhook URL”. It is required and saved when the key is generated. Later, use “Save webhook” to change it without regenerating the key.

  5. Click “Generate key”. Save the API key and secret, tick “I saved the secret key securely.”, then click “Saved, close”.

The Live card opens after you have created at least one sandbox order. The payment does not have to be completed.

  1. Create at least one order with your sandbox key (POST /api/v3/payments). The Live card unlocks in the panel.

  2. In the Live card, add your production server IP addresses to “IP allowlist”. The list is separate from the sandbox list. While it is empty every live request is rejected.

  3. Enter your live HTTPS webhook URL under “HTTPS webhook URL”. It is saved together with the key in the next step.

  4. Click “Generate key” and store the live secret key on your server.

  5. Configure the live API key and secret in your integration. The API base URL and request format stay the same.

IP allowlist

Requests are accepted only from IP addresses on your allowlist. A request from any other address is rejected with 403 (9000012) and nothing is processed.

  • The list is mandatory. Sandbox and live keep separate lists, and a key cannot be generated while the list of that environment is empty.
  • Enter single IPv4 or IPv6 addresses only; there are no ranges (CIDR). If your server connects out over both IPv4 and IPv6, add both.
  • The address that counts is the public one your server connects out from. Private addresses (10.x, 192.168.x) are useless; behind a NAT or proxy, add its outbound address.
  • Use “Remove IP” beside the address and confirm. To remove the last IP while a key is active, first add another address or revoke the key.
  • Changes take effect immediately. You do not need to regenerate the key.

Add every address you test from, such as a development server or your office's outbound address.

Add production server addresses to the live list. The sandbox list grants no access to live requests.

Regenerating and revoking keys

For a lost secret, use “Regenerate key”. This revokes the old key in that environment immediately. The other environment keeps its key.

  • Each environment has one active key. “Regenerate key” issues a new pair and revokes the old one immediately. Requests signed with the old key fail with 9000003.
  • “Revoke key” disables the key without issuing a new one. Requests are rejected at once.
  • The secret of a revoked key still signs webhooks for payments created with it. Keep the corresponding secret securely for those payments. If it is lost or compromised, verify their status using authenticated GET requests with your current key; do not trust webhooks signed with a compromised secret.

Authentication

Sign authenticated requests with HMAC-SHA256 using your secret. The signature covers the timestamp, method, path and body hash. GET /api/v3/service-status is public and needs no signature.

Required headers

HeaderDescription
X-CAV-Account-NoYour 6-digit account number.
X-CAV-Api-KeyThe API key of the environment you are calling (sandbox or live).
X-CAV-TimestampCurrent Unix time in seconds, 10 digits. Requests more than 300 seconds off are rejected.
X-CAV-SignatureHMAC-SHA256 of the canonical string, 64 lowercase hex characters.
Idempotency-KeyRequired on write requests. Use 8–128 characters from A-Z a-z 0-9 _ -. A UUID works.
Content-Typeapplication/json on requests with a body.

Keep the secret as described in Getting a key.

How to sign a request

  1. Serialise the JSON body once and compute sha256(body) as lowercase hex. For a request without a body use sha256("").

  2. Build the canonical string from timestamp, METHOD, path and the body hash, joined by line feeds (\n, no trailing newline). The path has no query string.

  3. Compute HMAC-SHA256(secret, canonical) and encode it as 64 lowercase hex characters.

  4. Send the four X-CAV-* headers (account number, API key, timestamp, signature); add Idempotency-Key on write requests.

Canonical signing string

timestamp + "\n" + METHOD + "\n" + path + "\n" + hex(sha256(body))
signature = hmac_sha256(secret, canonical)  // 64 lowercase hex characters

path is the full path without the query string (for example /api/v3/payments). A request without a body uses the hash of the empty string (sha256("")).

Sign the exact bytes you send: serialise the JSON once and use the same string for the hash and for the request body.

Worked example (fake credentials)

All values below are made up: account number 123456, secret sk_test_EXAMPLE. Use the values from your own panel.

Request body

{"amount":"250.00","currency_code":"USD","merchant_reference":"ORDER-2026-0001"}

sha256(body)

280db1ad2f6a457444b4838e9da76682edbd328a1b62f7b9b762852d9c4354f7

Canonical signing string

1790000000
POST
/api/v3/payments
280db1ad2f6a457444b4838e9da76682edbd328a1b62f7b9b762852d9c4354f7

X-CAV-Signature

3d3a95fcbda890bb424184c914ef3e2371c43602988cf3a698dc357b8074c5e9

Send a signed request

Each sample creates a payment. Credentials are read from environment variables. Do not put the secret in code. Every run uses a new merchant_reference.

#!/usr/bin/env bash
# export MERCHANT_ACCOUNT_NO=... MERCHANT_API_KEY=... MERCHANT_API_SECRET=...   (sandbox key)
# GET requests: METHOD=GET, REQ_PATH=/api/v3/payments/<code>, BODY='' (sha256 of the empty string), no Content-Type / --data-binary
set -euo pipefail

BASE_URL="https://www.cryptoavans.com"
METHOD="POST"
REQ_PATH="/api/v3/payments"
REFERENCE="ORDER-$(date +%s)-$(openssl rand -hex 4)"   # a new merchant_reference for every payment
BODY="{\"amount\":\"250.00\",\"currency_code\":\"USD\",\"merchant_reference\":\"$REFERENCE\",\"return_url\":\"http://localhost/return\"}"

TIMESTAMP="$(date +%s)"
BODY_SHA256="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
CANONICAL="$(printf '%s\n%s\n%s\n%s' "$TIMESTAMP" "$METHOD" "${REQ_PATH%%\?*}" "$BODY_SHA256")"
# the secret travels in the environment, not on the command line
SIGNATURE="$(printf '%s' "$CANONICAL" | python3 -c 'import hashlib,hmac,os,sys; print(hmac.new(os.environ["MERCHANT_API_SECRET"].encode(), sys.stdin.buffer.read(), hashlib.sha256).hexdigest())')"

curl --silent --show-error --request "$METHOD" "$BASE_URL$REQ_PATH" \
  --header "X-CAV-Account-No: $MERCHANT_ACCOUNT_NO" \
  --header "X-CAV-Api-Key: $MERCHANT_API_KEY" \
  --header "X-CAV-Timestamp: $TIMESTAMP" \
  --header "X-CAV-Signature: $SIGNATURE" \
  --header "Idempotency-Key: $(openssl rand -hex 16)" \
  --header "Content-Type: application/json" \
  --header "Accept: application/json" \
  --data-binary "$BODY"
<?php
$config = [
    'base'    => 'https://www.cryptoavans.com',
    'account' => getenv('MERCHANT_ACCOUNT_NO'),
    'key'     => getenv('MERCHANT_API_KEY'),
    'secret'  => getenv('MERCHANT_API_SECRET'),
];

function signedRequest(array $config, string $method, string $path, ?array $payload = null, ?string $idempotencyKey = null): array
{
    $method = strtoupper($method);
    $signingPath = explode('?', $path, 2)[0];
    // serialise once: the same bytes are hashed and sent
    $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    $timestamp = (string) time();
    $canonical = implode("\n", [$timestamp, $method, $signingPath, hash('sha256', $body)]);
    $signature = hash_hmac('sha256', $canonical, $config['secret']);

    $headers = [
        'X-CAV-Account-No: ' . $config['account'],
        'X-CAV-Api-Key: ' . $config['key'],
        'X-CAV-Timestamp: ' . $timestamp,
        'X-CAV-Signature: ' . $signature,
        'Content-Type: application/json',
        'Accept: application/json',
    ];
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    $ch = curl_init($config['base'] . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
    ]);
    if ($body !== '') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    }
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($response === false) {
        throw new RuntimeException('No response; retry the same operation and Idempotency-Key.');
    }

    return [$status, json_decode((string) $response, true)];
}

// a new merchant_reference for every payment: seconds plus a random suffix
[$status, $payment] = signedRequest(
    $config,
    'POST',
    '/api/v3/payments',
    ['amount' => '250.00', 'currency_code' => 'USD', 'return_url' => 'http://localhost/return', 'merchant_reference' => 'ORDER-' . time() . '-' . bin2hex(random_bytes(4))],
    bin2hex(random_bytes(16))
);
echo $status, ' ', json_encode($payment, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;

if (!in_array($status, [200, 201], true)) {
    exit(1); // the error code is not a payment code
}

// GET: no body, so the hash is sha256("")
[$status, $fetched] = signedRequest($config, 'GET', '/api/v3/payments/' . $payment['code']);
echo $status, ' ', $fetched['status'] ?? '', PHP_EOL;

Requests with an old timestamp are rejected, so keep your server clock synchronized with NTP. Repeated failed signatures from one IP trigger a temporary lockout (429).

Request rules

Idempotency

Send an Idempotency-Key header on every write request. It makes retries safe: however many times you send the same request, the operation runs once.

  • Repeating a request with the same key and body returns the original result and never creates a second operation. Use GET when you need the current state.
  • The same key with a different body returns 409 with code 9000010; an invalid key returns 422 with code 1000009.
  • Generate a new random key (a UUID is fine) for every new operation and store it with your order, so a retry can reuse it.

Rate limits

Requests are limited per API key and per IP address. Back off and retry later when you receive 429.

  • On 429, wait for Retry-After when present, then retry with exponential backoff and jitter.
  • On 503, retry later with the same Idempotency-Key and body. Resolve other 4xx errors before retrying; use a new key only for a new operation.

If a request is rejected, find its code in Error codes. The most common causes are a wrong signature (9000001), an old timestamp (9000002), wrong credentials (9000003) and an IP that is not on the allowlist (9000012).

Payments

Create a payment and read its status.

Payment statuses

FieldDescription
createdThe payment is open. Its deadline is expires_at; the payment page is valid for 24 hours.
paidThe payment was collected. A payment.paid notification is sent.
expiredThe payment page expired without a confirmed payment.

Read the current status with GET /api/v3/payments/{code}. After a decline the customer can try again on the same payment_url while the payment is created. If it is expired, create a new payment with a new merchant_reference and Idempotency-Key.

Rules

  • The amount must be within your account's payment limits. The limit is checked in US dollars after conversion; the default is 3.00 to 1100.00 USD. Outside it the API returns 422 with code 9000005. Ask CryptoAvans for a different limit.
  • merchant_reference is at most 64 characters and unique per account and environment. It cannot be reused even after a payment has expired (409, 9000007), so send a new one for every new payment.
  • Take the customer to payment_url as a normal page (top-level navigation). The payment page cannot be shown inside an iframe.
  • return_url is optional. When the payment ends, the result page shows a button that takes the customer to it, with code, status and, if you sent one, merchant_reference added to the query. There is no automatic redirect.
  • Unknown request fields are ignored. Confirm payment through a signed webhook or a server-side GET; the customer's return to your site is informational.

In sandbox, payment_url opens the same payment page; pay with a test card from Test environment.

In live, the payment is collected from a real card and settles to your balance after its value date.

POST/api/v3/paymentsIdempotency-Key required

Create a payment

The 201 response contains payment_url and expires_at. Take the customer to payment_url; the payment page is valid for 24 hours. If 409 / 9000039 is returned, repeat the same request with the same Idempotency-Key.

Request body

FieldTypeRequiredDescription
amountstringRequiredPositive amount, as a fixed-scale decimal string with 2 decimals (e.g. "250.00"), in currency_code. It must be within the payment limits of your account (checked in USD at the current rate after conversion; view your limits on the panel’s API Key & Integration page); otherwise the request returns 422 9000005.
currency_codestringRequiredSource currency.
USDEURAEDTRY
merchant_referencestring | nullOptionalYour own order reference (at most 64 characters). Unique per account and environment: a reference cannot be reused, not even for an expired payment (409 9000007). Optional.
maxLength 64
webhook_urlstring | nullOptionalhttps:// URL that receives the signed notifications (payment.paid, payment.refunded, payment.failed). The host must resolve in DNS to a public address (no private or reserved IP, no localhost), the URL is at most 500 characters and must not contain a user name or password. Optional: when omitted, the webhook URL saved in the panel for the environment of your signing key is used; if none is saved, no notification is sent.
maxLength 500
merchant_namestring | nullOptionalName shown on the payment screen. Optional; defaults to Hesap <account number> (your 6-digit account number). A value longer than 120 characters is truncated, not rejected.
return_urlstring · uri | nullOptionalOptional return_url (up to 2048 characters). Live requires HTTPS; sandbox also accepts HTTP on localhost, 127.0.0.1 or [::1]. No user information, fragment, control characters or whitespace. International host names are normalized to ASCII. Invalid URL: 422 / 9000038. When the payment ends, the result page shows a button that takes the customer to this address with code, status and, if you sent one, merchant_reference added to the query; there is no automatic redirect. Keep it unchanged when retrying with the same Idempotency-Key.
maxLength 2048

Request body · Example

{
  "amount": "10.00",
  "currency_code": "USD",
  "merchant_reference": "INV-104",
  "merchant_name": "Test Shop",
  "return_url": "https://shop.example.invalid/return?basket=104"
}

limits holds currency_code, min_amount and max_amount (null: no upper limit). The amounts below are examples, not your limits.

HTTP 422 / 9000005

{
  "type": "https://www.cryptoavans.com/errors/9000005",
  "title": "Invalid amount",
  "status": 422,
  "code": "9000005",
  "correlation_id": "example",
  "detail": "Amount outside the account limits.",
  "limits": {
    "currency_code": "USD",
    "min_amount": "25.00",
    "max_amount": null
  }
}

Response · 201

Payment created. Returns 200 instead when replayed with the same Idempotency-Key.

Payment

FieldTypeDescription
codestringShort payment code (4–16 lowercase chars).
merchant_referencestring | nullYour order reference, echoed back.
statusstringPayment lifecycle status: created until the customer pays, then paid; expired when the link lapses unpaid.
createdpaidexpired
amountstringGross amount, as a fixed-scale decimal string with 2 decimals, in currency_code.
currency_codestringSource currency of this payment; read the returned value rather than assuming the example currency.
USDEURAEDTRY
environmentstringEnvironment this payment belongs to.
sandboxlive
payment_urlstringTake the customer to the returned payment_url as a normal page (top-level navigation); do not build or change the address yourself. The page cannot be embedded in an iframe. No card data is sent to this API.
created_atstring · date-timeCreation time (UTC, ISO-8601).
expires_atstring · date-timeUTC. The payment page is valid for 24 hours after creation. Create a new payment after expiry; there is no renewal endpoint. Verify payment with GET /api/v3/payments/{code} or a signed webhook; the customer’s return to your site is informational.
paid_atstring · date-time | nullWhen it was paid, or null.
payment_url_kindstringAlways platform (our payment page). The field is kept for existing integrations; provider_hosted is no longer returned.
platformprovider_hosted
cardMerchantCardSummary | nullCard evidence from the successful paid attempt; null until paid or when unavailable.
failurePaymentFailureSummary | nullLatest failed attempt while unpaid, otherwise null. Non-terminal: the payment may still be paid until expires_at. attempt_count counts failed attempts across retries.

Response · Example

{
  "code": "a1b2c3d4",
  "merchant_reference": "INV-104",
  "status": "created",
  "amount": "10.00",
  "currency_code": "USD",
  "environment": "live",
  "payment_url": "https://pay.example.com/l/a1b2c3d4",
  "payment_url_kind": "platform",
  "created_at": "2026-10-05T12:00:00Z",
  "expires_at": "2026-10-06T12:00:00Z",
  "paid_at": null,
  "card": null,
  "failure": null
}

Other responses

  • 200Idempotent replay: the original payment is returned; no second payment is created.
  • 422Amount outside the account limits (9000005, including conversion below 0.01 or beyond capacity) includes limits in the account base currency. Sandbox uses the same validation. Other validation errors may omit limits.

Possible errors

GET/api/v3/payments/{code}

Get payment status

Parameters

FieldTypeRequiredDescription
codestring · pathRequiredThe payment code.
pattern ^[a-z0-9]{4,16}$

Response · 200

The payment.

Object: Payment (fields are listed under Create a payment).

Response · Example

{
  "code": "ab34cdef",
  "merchant_reference": "ORDER-2026-0001",
  "status": "created",
  "amount": "250.00",
  "currency_code": "USD",
  "environment": "live",
  "payment_url": "https://example.com/l/ab34cdef",
  "created_at": "2026-09-26T09:00:00.000Z",
  "expires_at": "2026-09-27T09:00:00.000Z",
  "paid_at": null,
  "payment_url_kind": "platform"
}

Possible errors

Failed attempts

Payment.card contains information from the successful attempt; failure.card comes from the latest failed attempt. Unavailable values may be null. The cardholder name is personal data: process it according to your legal obligations. The full card number and expiry date are never provided.

payment.failed is sent once, for the first failed attempt: a card decline, failed 3-D Secure or payer cancellation. The payment stays created; the customer can retry until expires_at, and payment.paid may arrive later. Later failures update Payment.failure without another event. Its attempt_count counts failed attempts; detail_code, if available, is informational. Use the stable failure.code values below.

PaymentFailure

FieldTypeRequiredDescription
detail_codestring | nullRequiredDetailed response code from the payment network, for example 058 or 511; dotted sandbox codes remain supported. No transaction: timeout with a null detail. Integrations should branch on the stable code field.
pattern ^[0-9]{3}(?:\.[0-9]{3})?$
cardMerchantCardSummary | nullRequiredOptional card evidence from the verified paid or failed attempt. issuing_country uses ISO 3166-1 alpha-3. No complete card number, expiry, security code or provider-only identifiers.
codestringRequiredStable safe reason from the last payment transaction, otherwise from authentication. 511/51: insufficient_funds; 058/004: authentication_failed; 022: timeout; 001/002: cancelled; 527/501: declined; 526: provider_error. No transaction, or successful authentication without payment: timeout. Unknown failures: declined. No provider message or sensitive card data.
declinedinsufficient_fundsauthentication_failedcard_invalidcancelledprovider_errorexpired_cardinvalid_cvvtimeout
occurred_atstring · date-timeRequiredFailed attempt completion time in UTC with milliseconds.

Webhooks

Signed JSON notifications cover payment.paid, payment.failed and payment.refunded. Handle only known event types and ignore unknown ones; a failed attempt is not a final payment result.

Verify every notification

  1. Read the raw body before parsing JSON. Reject a missing or malformed signature or timestamp. The samples reject timestamps more than 300 seconds from your server clock; keep it synchronized.

  2. Rebuild the canonical string from X-CAV-Timestamp, POST, the path of your webhook URL (no query string) and the sha256 of the raw body.

  3. Compute HMAC-SHA256 with your secret key and compare it with X-CAV-Signature in constant time. Use the secret of the key that created the payment, even if you have renewed your key since.

  4. After verifying the signature, match the signed environment, payment code, amount and currency against your order. Use event_type from the signed body to distinguish payment.paid / payment.failed from payment.refunded; a refund must not trigger fulfillment.

  5. Deduplicate atomically using event_id from the verified body. Compare it with X-CAV-Delivery without regard to case and reject a mismatch; the header itself is not signed. Do not rely on delivery order; reconcile an unexpected event using the status endpoints.

  6. Return 2xx quickly after durably saving the verified event or completing its processing. If you cannot save it, return a failure so it can be retried. Return 2xx for an already saved duplicate. The response body is ignored and redirects are not followed.

Use a valid TLS certificate issued by a public certificate authority; certificate checks cannot be disabled.

Signature check samples

<?php
// $path is the path of YOUR webhook URL (no query string), e.g. /payment-webhook
function isValidWebhook(string $rawBody, array $headers, string $path, string $secret): bool
{
    $headers = array_change_key_case($headers, CASE_LOWER);
    $timestamp = $headers['x-cav-timestamp'] ?? '';
    $signature = $headers['x-cav-signature'] ?? '';
    if (!is_string($timestamp) || !is_string($signature)
        || preg_match('/^[0-9]{10}$/D', $timestamp) !== 1
        || preg_match('/^[0-9a-f]{64}$/D', $signature) !== 1
        || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $canonical = implode("\n", [$timestamp, 'POST', $path, hash('sha256', $rawBody)]);

    return hash_equals(hash_hmac('sha256', $canonical, $secret), $signature);
}

$rawBody = file_get_contents('php://input');
$headers = [
    'X-CAV-Timestamp' => $_SERVER['HTTP_X_CAV_TIMESTAMP'] ?? '',
    'X-CAV-Signature' => $_SERVER['HTTP_X_CAV_SIGNATURE'] ?? '',
];
if (!isValidWebhook($rawBody, $headers, '/payment-webhook', getenv('MERCHANT_API_SECRET'))) {
    http_response_code(401);
    exit;
}

// After verification: compare event_id from the signed body with X-CAV-Delivery (case-insensitive),
// save the event once (deduplicate by event_id), then answer 2xx. On a save error answer 5xx so it is retried.
$event = json_decode($rawBody, true);
http_response_code(204);
const crypto = require("node:crypto");

// path is the path of YOUR webhook URL (no query string), e.g. /payment-webhook
function isValidWebhook(rawBody, headers, path, secret) {
  const timestamp = headers["x-cav-timestamp"] ?? "";
  const signature = headers["x-cav-signature"] ?? "";
  if (typeof timestamp !== "string" || typeof signature !== "string"
      || !/^[0-9]{10}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)
      || Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
  const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
  const canonical = [timestamp, "POST", path, bodyHash].join("\n");
  const expected = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// rawBody must be the untouched request bytes (Buffer), not re-serialised JSON.
// After verification, deduplicate using event_id from the signed body; check X-CAV-Delivery matches.
// Check event_type and order fields, then durably save before 2xx (see the verification steps).
module.exports = { isValidWebhook };
import hashlib
import hmac
import re
import time


def is_valid_webhook(raw_body: bytes, headers: dict, path: str, secret: str) -> bool:
    """path is the path of YOUR webhook URL (no query string), e.g. /payment-webhook."""
    headers = {key.lower(): value for key, value in headers.items()}
    timestamp = headers.get("x-cav-timestamp", "")
    signature = headers.get("x-cav-signature", "")
    if (not isinstance(timestamp, str) or not isinstance(signature, str)
            or not re.fullmatch(r"[0-9]{10}", timestamp)
            or not re.fullmatch(r"[0-9a-f]{64}", signature)
            or abs(int(time.time()) - int(timestamp)) > 300):
        return False
    canonical = "\n".join([timestamp, "POST", path, hashlib.sha256(raw_body).hexdigest()])
    expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


# After verification, deduplicate by event_id from the signed body; check X-CAV-Delivery matches.
# Check event_type and order fields, then durably save before 2xx (see the verification steps).

Retries

Any response other than 2xx, a timeout (about 10 seconds) or a connection error counts as a failed attempt. There are up to 6 attempts, waiting at least the times below between them. After the last failed attempt the delivery is marked failed; you can requeue it with POST /api/v3/webhooks/deliveries/{id}/retry.

The body and X-CAV-Delivery are the same on every attempt; X-CAV-Timestamp and X-CAV-Signature are recomputed each time. The delivery id, the X-CAV-Delivery header and event_id in the body are the same uppercase UUID; compare it without regard to case.

AttemptWait before it
1—
260 s (1 min)
3300 s (5 min)
4900 s (15 min)
53600 s (1 h)
621600 s (6 h)

Notifications sent to your server

EventSent when
payment.paidA payment was collected.
payment.failedThe first failed attempt of a payment that is still open. Not final: the customer can try again.
payment.refundedA refund was completed.
POSTpayment.paid

Signed payment.paid notification to the merchant URL

Header

FieldTypeRequiredDescription
X-CAV-Eventstring · headerRequired
payment.paid
X-CAV-Environmentstring · headerRequired
sandboxlive
X-CAV-Deliverystring · uuid · headerRequiredDelivery id. Unchanged across retries.
X-CAV-Timestampstring · headerRequired
pattern ^[0-9]{10}$
X-CAV-Signaturestring · headerRequired
pattern ^[0-9a-f]{64}$

Request body · Example

{
  "event_id": "3BAEB108-41A8-40AE-81EF-0A82AA1132D6",
  "event_type": "payment.paid",
  "environment": "sandbox",
  "occurred_at": "2026-10-05T12:01:00.000Z",
  "payment": {
    "code": "a1b2c3d4",
    "merchant_reference": "INV-104",
    "status": "paid",
    "amount": "10.00",
    "currency_code": "USD",
    "created_at": "2026-10-05T12:00:00.000Z",
    "paid_at": "2026-10-05T12:01:00.000Z",
    "card": {
      "brand": "visa",
      "bin": "400000",
      "last4": "0010",
      "holder_name": "TEST CARDHOLDER",
      "issuing_country": "TUR",
      "issuing_bank": "TEST BANK",
      "funding_type": "credit",
      "category": "STANDARD"
    }
  }
}
POSTpayment.failed

Signed non-terminal payment attempt failure notification

Request body · Example

{
  "event_id": "3BAEB108-41A8-40AE-81EF-0A82AA1132D6",
  "event_type": "payment.failed",
  "environment": "sandbox",
  "occurred_at": "2026-10-05T12:01:00.000Z",
  "payment": {
    "code": "a1b2c3d4",
    "merchant_reference": "INV-104",
    "status": "created",
    "amount": "10.00",
    "currency_code": "USD",
    "created_at": "2026-10-05T12:00:00.000Z",
    "expires_at": "2026-10-06T12:00:00.000Z"
  },
  "failure": {
    "code": "declined",
    "occurred_at": "2026-10-05T12:01:00.000Z",
    "detail_code": null,
    "card": {
      "brand": "visa",
      "bin": "400000",
      "last4": "0010",
      "holder_name": "TEST CARDHOLDER",
      "issuing_country": "TUR",
      "issuing_bank": "TEST BANK",
      "funding_type": "credit",
      "category": "STANDARD"
    }
  }
}

The envelope is the same as for payment.paid. payment carries expires_at instead of paid_at, and failure describes the attempt (see Failed attempts).

To inspect deliveries or requeue a failed one, see Webhook deliveries.

Test environment

Sandbox requests use virtual data and never move real money. Open the payment_url and pay with one of the test cards below.

How it works

Every request signed with a sandbox key runs on isolated virtual data. Rules, fields and error codes are the same as in live. The payment_url opens the same payment page your customers see in live. When a test payment is approved, payment.paid is sent to the payment's webhook URL with environment set to sandbox.

  • The page shows a test notice and accepts only the test cards below; real card numbers are refused. For 3-D Secure cards it asks you to approve or reject the authentication. The API does not accept card numbers; enter the card on the payment page.
  • No real money, real card or chain transaction is involved, and nothing reaches your live balance.

Sandbox test cards

Use these synthetic numbers only on the payment page of a sandbox payment, never on a live payment. Enter any card holder name of at least 2 letters, an expiry date in the current month or later and a 3 or 4 digit CVV.

Card numberResult codeWhat happens
4000000000000010approvedApproved without 3-D Secure.
4000000000000051declinedDeclined without 3-D Secure.
4000000000000002three_ds_approvedThe payment page shows a 3-D Secure step; approving it approves the payment, rejecting it declines the payment.
4000000000000093three_ds_declinedThe payment page shows a 3-D Secure step; the payment is declined whichever result you choose.
4000000000000044timeoutThe result stays pending (no final result); do not treat it as a final decline. The attempt is closed as expired later.

A declined payment stays created and sends payment.failed once, on its first failed attempt; the customer can try again (10 attempts per hour per IP address). The timeout card and amounts ending in .77 leave the result pending: the page shows a pending result, no webhook is sent and the result is never entered manually. The attempt is closed as expired after about 30 minutes; the payment stays created and the customer can start again. The .77 rule looks at the amount in US dollars.

Request log

In the panel, “Sandbox request log” shows your recent sandbox requests: the last 90 days, with the source IP kept for 30 days. Requests that failed signature or credential checks are not logged; find them by the correlation_id in the response. Live requests are not logged.

Tests for refunds, withdrawals and disputes are described on the Account operations page.

Error codes

Errors are returned as application/problem+json. The code field is one of the codes below; include the correlation_id when you contact support. title and detail follow the Accept-Language header; rely only on code in your code.

Error response example

application/problem+json

{
  "type": "https://www.cryptoavans.com/errors/9000011",
  "title": "Signature headers are missing",
  "status": 400,
  "detail": "X-CAV-Account-No, X-CAV-Api-Key, X-CAV-Timestamp and X-CAV-Signature are mandatory.",
  "code": "9000011",
  "correlation_id": "3BAEB108-41A8-40AE-81EF-0A82AA1132D6"
}

Code table

If the response is lost or you receive 500 or 503, the outcome may be unknown. Retry with the same Idempotency-Key and body, signing again with a current timestamp. Respect Retry-After on 503. Do not create a new payment just because a request timed out.

CodeHTTPTitle
9000001401Signature is invalid
9000002401Timestamp outside window
9000003401Credentials are invalid
9000004403Account cannot make transactions
9000005422Amount is invalid
9000006422Merchant reference is invalid
9000007409Merchant reference has already been used
9000008422Notification address is invalid
9000009404Payment or refund not found
9000010409Idempotency conflict
9000011400Signature headers are missing
9000012403IP not allowed
9000013409Sandbox unavailable
9000014409Refund or void request already exists
9000015404Dispute not found
9000016409Dispute response not accepted
9000017422The TRC20 address is invalid
9000018422Address confirmation required
9000019422Amount is invalid
9000020409Insufficient balance
9000021409Daily withdrawal limit exceeded
9000022409Withdrawal in progress
9000023404Withdrawal not found
9000024404Webhook delivery not found
9000025409Retry not allowed
9000026503Sandbox schema pending
9000027409Sandbox wallet busy
9000030409Sandbox credit invalid
9000033409Insufficient sandbox balance
9000038422Invalid return URL
9000039409Payment is being prepared
9000040403API access is off
1000001422Validation error shared
1000002404No record found shared
1000005429Request limit exceeded shared
1000006500Server error shared
1000007405Method not supported shared
1000009422Invalid idempotency key shared
1000011503Service is currently unavailable shared
1000013503Key infrastructure is not ready shared
1000014503Service under maintenance shared
1000020422Invalid amount format shared
1000021422Unsupported currency shared
1000023422Amount exceeds capacity shared
1000030422Address cannot be used
3000001503The rate is currently unavailable shared

Go-live checklist

Complete every item before you go live.

  • You tested payment, webhook, refund and withdrawal flows end to end in sandbox.

  • The live key and secret are stored only on your server and are not written to logs.

  • Your live IP allowlist and live HTTPS webhook URL are set.

  • Webhook signature verification is in place and rejects invalid signatures; the handler answers 2xx quickly and tolerates repeated deliveries.

  • Orders are completed only when the webhook or GET /payments/{code} shows paid, never from the customer's browser.

  • Write requests send an Idempotency-Key and retries reuse it; every payment gets a new merchant_reference.

  • Your server clock is synchronized with NTP.

  • A live terminal is assigned to your account, and you made a small live payment together with CryptoAvans.

Talk to your account manager about the live terminal and the final end-to-end test. Enter panel