Accept inbound deposits

Learn how to get notified when money arrives in your Utila vault, and when it’s safe to credit your users. This guide walks through real-time alerts (webhooks) and a simple backup check, with examples you can follow.

This guide explains how to reliably detect incoming deposits to your Utila vault using two complementary methods:

  1. Webhooks (recommended): Utila pushes events to your endpoint in real time.
  2. Polling: your application periodically calls the ListTransactions API with filters.

Use webhooks as your primary mechanism and polling as a backup/reconciliation layer. Together they give you real-time notifications plus a safety net if your endpoint was unreachable.

Required permissions: deposit monitoring is read-only. Both API calls it uses - GetTransaction and ListTransactions - only read data, so a service account with the VIEWER role is enough. A signer role is not required and should not be granted under least privilege: a VIEWER key can read transactions but cannot move funds. Receiving webhook events needs no role at all (your endpoint only verifies Utila's public key); VIEWER credentials are needed to enrich and classify transactions via GetTransaction, and for polling.


1. How a deposit flows through Utila

When funds arrive at one of your wallet addresses, Utila creates an incoming transaction (direction = INCOMING) in your vault.

Important distinction:

  • TRANSACTION_CREATED is a webhook event (Utila notifying you that a transaction record exists).
  • MINED / CONFIRMED / … are transaction states on the transaction object.

There is no transaction state named Created / CREATED.

Typical deposit states

StateMeaning
MINEDThe transaction was included in a block and Utila ingested it. Common first state on EVM, Bitcoin, Solana, and similar networks.
CONFIRMEDThe transaction reached the required number of confirmations. This is when the deposit is final - credit only now.

On some networks (notably Stellar, and similarly Canton/Aleo), Utila may create the transaction already as CONFIRMED (no extra confirmation wait). In that case you may only receive TRANSACTION_CREATED, and GetTransaction already returns state = CONFIRMED.

In practice for most chains:

  1. Webhook: TRANSACTION_CREATED → GetTransaction → often MINED, direction = INCOMING → treat as pending (do not credit).
  2. Webhook: TRANSACTION_STATE_UPDATED with newState = CONFIRMED → re-fetch → credit.

Important: only credit a deposit when direction == "INCOMING" and state == "CONFIRMED" (or, on TRANSACTION_CREATED, when GetTransaction already returns both).

Failed and rejected transactions

  • DECLINED / DECLINED_BY_AML_POLICY: outgoing-lifecycle only (transactions you initiate through Utila). They do not apply to deposits.
  • MINED_FAILED / FAILED: can appear for incoming transactions on some networks (especially EVM failed contract calls). Do not credit these. Treat only CONFIRMED + INCOMING as settleable.
  • On some networks (e.g. Stellar), Utila may not create a new incoming row for a failed on-chain payment at all - so there is no webhook. Behavior is chain-dependent; always gate credit on CONFIRMED.

Reorgs

In a rare block reorg, a deposit may leave MINED / fail to reach CONFIRMED, or (on reorg-sensitive chains) temporarily move back before being re-confirmed. This is why you always wait for CONFIRMED and keep crediting idempotent.

Timestamps: for deposits, createTime is when Utila created the vault transaction record; mineTime is when it was mined on-chain. On some networks (e.g. Stellar) these match; on many others (EVM, BTC, Solana, …) they do not. Do not rely on createTime == mineTime as a deposit detector.

1.1 Memo networks (Stellar, XRP, TON)

On memo networks, many integrations use one shared deposit address and distinguish end users by an on-chain memo (or destination tag), rather than one wallet per user. Utila does not assign or validate those memos for you: you generate unique values, show each user the address and their memo, and map the memo back to a user when a deposit arrives.

NetworkWhat the sender must includeWhere it appears (GetTransaction / ListTransactions)
Stellarmemo (TEXT, ID, and possibly HASH / RETURN)stellarTransaction.memo{ "type", "data" }
XRPdestination tag (0..4294967295)xrplTransaction.jsonTransactionData.DestinationTag
TONcomment / memotonTransaction.memo

These fields are on the chain-specific transaction details whenever Utila has them; you do not need view=FULL only to read memos. Use includeReferencedResources=true when you want wallet/asset display names in the same response.

A deposit with a missing or unknown memo should go to a manual reconciliation queue (match by amount, time, and tx hash). Address-based wallets on other networks do not need this step.


2. Method 1: Webhooks (recommended)

For webhook creation, expected endpoint response, retries, and signature validation, see the Webhooks reference.

2.1 Which events should you subscribe to?

Configure webhooks in the Console under Vault Settings → Webhooks → Add Webhook. Choose which events to deliver:

EventRelevant for deposit tracking?
Transaction Created (TRANSACTION_CREATED)Yes. Fires when Utila creates the transaction in your vault (for deposits, typically when it is first seen/mined - state may already be MINED or even CONFIRMED).
Transaction State Updated (TRANSACTION_STATE_UPDATED)Yes. Fires on state changes. For deposits you care especially about newState: "CONFIRMED".
Wallet Created (WALLET_CREATED)No
Wallet Address Created (WALLET_ADDRESS_CREATED)No
Transaction AML Screening Result Ready (TRANSACTION_AML_SCREENING_RESULT_READY)Optional, if you screen incoming deposits before (or after) crediting

Select both Transaction Created and Transaction State Updated. Both also fire for outgoing transactions, so always classify with GetTransaction before treating anything as a deposit:

  • TRANSACTION_CREATED → call GetTransaction:
    • INCOMING → log pending (amount, wallet, asset). Do not credit unless state is already CONFIRMED (common on Stellar) - then credit immediately and stay idempotent if a later STATE_UPDATED also arrives.
    • OUTGOING → skip once and remember the tx id so later state updates stay silent.
  • TRANSACTION_STATE_UPDATED with newState: "CONFIRMED" → if not already credited, re-check direction == "INCOMING", then credit. Ignore intermediate states for known-outgoing / already-confirmed txs. Do not credit MINED_FAILED / FAILED.

2.2 What the event payloads look like

Transaction Created

{
  "id": "94ea3ce9-fb6d-41f3-91aa-8c42f17df1f6",
  "vault": "vaults/3bf247bc8ee2c",
  "type": "TRANSACTION_CREATED",
  "resourceType": "TRANSACTION",
  "resource": "vaults/3bf247bc8ee2c/transactions/b6e3cd32e827"
}

Transaction State Updated

{
  "id": "9c683b37-bfb6-42f4-bcaa-1a6f46d3b6f8",
  "vault": "vaults/3bf247bc8ee2c",
  "type": "TRANSACTION_STATE_UPDATED",
  "details": {
    "transactionStateUpdated": {
      "newState": "CONFIRMED"
    }
  },
  "resourceType": "TRANSACTION",
  "resource": "vaults/3bf247bc8ee2c/transactions/b6e3cd32e827"
}

2.3 Fetch the transaction details with GetTransaction

The webhook payload intentionally contains only references (vault ID, transaction ID), not amounts, wallet names, or direction. The resource field is vaults/{vault_id}/transactions/{transaction_id}.

Best practice:

  1. Verify the signature.
  2. Acknowledge with HTTP 2xx as soon as you safely can (in production: enqueue work, then return 200; the example below does enrichment inline for clarity).
  3. On TRANSACTION_CREATED, call GetTransaction and classify as above.
  4. On TRANSACTION_STATE_UPDATED with CONFIRMED, re-fetch and credit only if still INCOMING and not already credited.
  5. If GetTransaction fails transiently, do not treat the event as done - return a non-2xx (or skip idempotency marking) so Utila can retry.
GET https://api.utila.io/v2/vaults/{vault_id}/transactions/{transaction_id}?includeReferencedResources=true

includeReferencedResources=true returns wallet and asset objects (display names) alongside the transaction so you do not need extra lookups.

2.4 Complete webhook example

Typical log lines:

Deposit PENDING   | tx=b6e3cd32e827 address=0x742d35Cc...b6 wallet=Treasury Wallet asset=Ethereum amount=0.5
Deposit CONFIRMED | tx=b6e3cd32e827 address=0x742d35Cc...b6 wallet=Treasury Wallet asset=Ethereum amount=0.5
Deposit CONFIRMED | tx=a1b2c3d4e5f6 address=GC7UQL... wallet=Shared Stellar asset=XLM amount=10.5 memo=user-4711
"""
Utila deposit-monitoring webhook.

Receives Utila webhook events, verifies the RSA-PSS signature, and calls
GetTransaction on CREATED (pending deposit) and CONFIRMED (credit) events
to identify and log incoming deposits.

Requirements:
    pip install flask requests pyjwt cryptography pycryptodomex python-dotenv
    pip install pyngrok   # only if USE_NGROK=true

Environment variables:
    UTILA_SA_EMAIL        - service account email (VIEWER role is sufficient)
    UTILA_SA_PRIVATE_KEY  - RSA private key PEM, or a path to a PEM file
                            (required for GetTransaction enrichment)
    UTILA_PUBLIC_KEY      - Utila webhook public key PEM, or a path to a PEM file
                            (optional: defaults to Utila's published key)
    USE_NGROK             - "true" to expose a public URL via ngrok (dev/staging only)
    PORT                  - HTTP port (default: 5000)

Note: multi-line PEM values in .env must be wrapped in double quotes, e.g.
    UTILA_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\\n...\\n-----END PUBLIC KEY-----"
"""

from __future__ import annotations

import base64
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable

import jwt  # PyJWT
import requests
from Cryptodome.Hash import SHA512
from Cryptodome.PublicKey import RSA
from Cryptodome.Signature import pss
from dotenv import load_dotenv
from flask import Flask, Response, request

_env_file = Path(__file__).resolve().parent / ".env" if "__file__" in globals() else Path(".env")
load_dotenv(_env_file)

UTILA_API = "https://api.utila.io"
PORT = int(os.getenv("PORT", "5000"))
USE_NGROK = os.getenv("USE_NGROK", "").strip().lower() in ("1", "true", "yes")

if USE_NGROK:
    from pyngrok import ngrok  # type: ignore[import]


def _load_pem(value: str, name: str) -> str:
    """Accept either a PEM string or a file path pointing to one."""
    value = value.strip()
    if not value:
        return ""
    if not value.startswith("-----"):
        try:
            value = Path(value).expanduser().read_text()
        except OSError as exc:
            raise SystemExit(f"{name}: cannot read key file: {exc}")
    return value.strip().replace("\\n", "\n")


# Utila's webhook public key. Same for all customers.
# See https://docs.utila.io/reference/webhooks
_DEFAULT_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAulI1XPGRDFcymdf2zXvD
spfdTXA1g0NOavZ50+AtcQP7f+KTpXoO1bkr6x9dO2Jq8FHImRT1sbhKhcNXT4WC
dLSa/2Zh60QE3tp9d51o1XDSnzRMwcGbFyJ7C30DVpVEIwqD2Z5GRlzXinqIeVdY
GOubuVol/wOAynS32DX+6y2PiqbYj7P84csBOgpNT27Mc6InEqKb7LWQtU8LPttx
tfyceOPXE5G4h+UujPsPG6WN5MHHVbP9r6oneEF3knbfL3hCJRjwV9HfTtG6JyYr
25Dy6SOCphrlEZi8IGcKxL6fEMetDGGVCjm7XfHyt6fYoUonD9lZsvbSyUsRwf/1
+x77F2LxtzQyvMJR9jD16WUyzm+fUBSVQixxKnKSrVkeLqkmGboDTY5kw3doSVTP
zcGDzWkzqC3lgwRLnSg4J+koQY+yo9jYBbFdSp+/PfVmp9NEaBuCV63mp/85VWIh
1FRYe6lEdGZWdmIcbDvNYU/Cui/yGZoID7+sJJq/rWN0Qxx/0skEaT/083+iYLVA
QNLvWtmQfgNKPm6GeQknRUEWyWUJtq6ANeP/8hGVM1G/edOdLn+KfhXZvw41O5z1
uKHEqHIV+NaCNnFbDj924bJhA/fWNKxYv7/Nm44Wy1nXlgqdHiFkSqtjUBPmzE/n
yj92azWBq1RbGHY+9/POguMCAwEAAQ==
-----END PUBLIC KEY-----"""

_SA_EMAIL = os.getenv("UTILA_SA_EMAIL", "")
_SA_PRIVATE_KEY = _load_pem(os.getenv("UTILA_SA_PRIVATE_KEY", ""), "UTILA_SA_PRIVATE_KEY")
SA_CONFIGURED = bool(_SA_EMAIL and _SA_PRIVATE_KEY)

UTILA_PUBLIC_KEY = _load_pem(
    os.getenv("UTILA_PUBLIC_KEY", _DEFAULT_PUBLIC_KEY),
    "UTILA_PUBLIC_KEY",
)

if not UTILA_PUBLIC_KEY:
    raise SystemExit(
        "UTILA_PUBLIC_KEY is not set; every webhook would be rejected. "
        "See https://docs.utila.io/reference/webhooks"
    )
try:
    _PUBLIC_KEY = RSA.import_key(UTILA_PUBLIC_KEY)
except (ValueError, IndexError, TypeError) as exc:
    raise SystemExit(f"UTILA_PUBLIC_KEY is not a valid PEM public key: {exc}")

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("deposits.log"),
    ],
)
logger = logging.getLogger("deposit-monitor")

if not SA_CONFIGURED:
    logger.warning(
        "UTILA_SA_EMAIL / UTILA_SA_PRIVATE_KEY not set: transactions "
        "cannot be classified as deposits (GetTransaction is unavailable)."
    )

app = Flask(__name__)

# In-memory idempotency / state. Replace with Redis / DB in production.
_processed_event_ids: set[str] = set()
_pending_deposits: set[str] = set()
_confirmed_deposits: set[str] = set()
_known_outgoing: set[str] = set()


class EnrichmentError(Exception):
    """Transient failure fetching transaction details — ask Utila to retry."""


def _access_token() -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": _SA_EMAIL,
        "aud": "https://api.utila.io/",
        "iat": now,
        "exp": now + timedelta(hours=1),
    }
    return jwt.encode(payload, _SA_PRIVATE_KEY, algorithm="RS256")


def _verify_signature(signature_b64: str, body: bytes) -> bool:
    try:
        digest = SHA512.new(body)
        pss.new(_PUBLIC_KEY).verify(digest, base64.b64decode(signature_b64))
        return True
    except (ValueError, TypeError) as exc:
        logger.warning("Signature verification failed: %s", exc)
        return False


def _get_transaction(vault_id: str, tx_id: str) -> dict[str, Any]:
    resp = requests.get(
        f"{UTILA_API}/v2/vaults/{vault_id}/transactions/{tx_id}",
        params={"includeReferencedResources": "true"},
        headers={"Authorization": f"Bearer {_access_token()}"},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()


def _parse_resource(event: dict[str, Any]) -> tuple[str, str] | None:
    parts = event.get("resource", "").split("/")
    if len(parts) < 4:
        logger.warning("Unexpected resource format: %s", event.get("resource"))
        return None
    return parts[1], parts[3]


def _extract_deposit_memo(transaction: dict[str, Any]) -> str | None:
    """Return on-chain memo / destination tag used to attribute the user."""
    stellar_memo = (transaction.get("stellarTransaction") or {}).get("memo")
    if isinstance(stellar_memo, dict):
        data = stellar_memo.get("data")
        if data not in (None, ""):
            return str(data)

    ton_memo = (transaction.get("tonTransaction") or {}).get("memo")
    if isinstance(ton_memo, str) and ton_memo:
        return ton_memo

    xrpl = transaction.get("xrplTransaction") or {}
    tag = (xrpl.get("jsonTransactionData") or {}).get("DestinationTag")
    # 0 is a valid XRP destination tag; only treat missing as absent.
    if tag is not None and tag != "":
        return str(tag)

    return None


def _report_deposit(data: dict[str, Any], tx_id: str, *, stage: str) -> bool:
    """Log an incoming transfer at PENDING or CONFIRMED.

    Returns True if INCOMING, False if skipped as outgoing.
    A transaction may contain multiple transfers — log each; credit logic
    in production should define how multi-asset deposits are settled.
    """
    transaction = data.get("transaction", data)
    if transaction.get("direction") != "INCOMING":
        logger.info("Skipping outgoing transaction %s", transaction.get("name"))
        return False

    # Never treat failed-looking states as settleable deposits.
    if stage == "CONFIRMED" and transaction.get("state") != "CONFIRMED":
        logger.info(
            "Not crediting tx %s: expected CONFIRMED, got %s",
            tx_id,
            transaction.get("state"),
        )
        return True  # still an incoming tx, just not final

    referenced_resources: dict[str, Any] = data.get("referencedResources", {})
    referenced_addresses: dict[str, Any] = data.get("referencedAddressesInfo", {})
    memo = _extract_deposit_memo(transaction)

    for transfer in transaction.get("transfers", []):
        destination = transfer.get("destinationAddress", {})
        address = destination.get("value", "unknown")

        wallet_name = "unknown wallet"
        info_ref = destination.get("infoRef")
        if info_ref:
            wallet_resource = referenced_addresses.get(info_ref, {}).get("wallet")
            if wallet_resource and wallet_resource in referenced_resources:
                wallet_name = referenced_resources[wallet_resource]["wallet"]["displayName"]

        asset_resource = transfer.get("asset", "")
        asset_name = (
            referenced_resources.get(asset_resource, {})
            .get("asset", {})
            .get("displayName", asset_resource)
        )

        if memo is not None:
            logger.info(
                "Deposit %s | tx=%s address=%s wallet=%s asset=%s amount=%s memo=%s",
                stage,
                tx_id,
                address,
                wallet_name,
                asset_name,
                transfer.get("amount", "?"),
                memo,
            )
        else:
            logger.info(
                "Deposit %s | tx=%s address=%s wallet=%s asset=%s amount=%s",
                stage,
                tx_id,
                address,
                wallet_name,
                asset_name,
                transfer.get("amount", "?"),
            )
    return True


def _handle_transaction_created(event: dict[str, Any]) -> None:
    parsed = _parse_resource(event)
    if not parsed:
        return
    vault_id, tx_id = parsed
    if not SA_CONFIGURED:
        raise EnrichmentError(f"SA credentials not configured for tx {tx_id}")

    try:
        data = _get_transaction(vault_id, tx_id)
    except requests.RequestException as exc:
        raise EnrichmentError(f"GetTransaction failed for {tx_id}: {exc}") from exc

    if not _report_deposit(data, tx_id, stage="PENDING"):
        _known_outgoing.add(tx_id)
        return

    _pending_deposits.add(tx_id)
    transaction = data.get("transaction", data)
    # Already final at create time (e.g. Stellar): credit now.
    if transaction.get("state") == "CONFIRMED" and tx_id not in _confirmed_deposits:
        _report_deposit(data, tx_id, stage="CONFIRMED")
        _confirmed_deposits.add(tx_id)
        _pending_deposits.discard(tx_id)


def _handle_transaction_state_updated(event: dict[str, Any]) -> None:
    parsed = _parse_resource(event)
    if not parsed:
        return
    vault_id, tx_id = parsed

    if tx_id in _known_outgoing or tx_id in _confirmed_deposits:
        return

    new_state = (
        event.get("details", {})
        .get("transactionStateUpdated", {})
        .get("newState")
    )

    if new_state != "CONFIRMED":
        if tx_id in _pending_deposits:
            logger.info(
                "Pending deposit %s -> %s (not confirmed yet, still waiting)",
                tx_id,
                new_state,
            )
        return

    if not SA_CONFIGURED:
        raise EnrichmentError(f"SA credentials not configured for tx {tx_id}")

    try:
        data = _get_transaction(vault_id, tx_id)
    except requests.RequestException as exc:
        raise EnrichmentError(f"GetTransaction failed for {tx_id}: {exc}") from exc

    if _report_deposit(data, tx_id, stage="CONFIRMED"):
        if data.get("transaction", data).get("state") == "CONFIRMED":
            _confirmed_deposits.add(tx_id)
            _pending_deposits.discard(tx_id)
    else:
        _known_outgoing.add(tx_id)


_EVENT_HANDLERS: dict[str, Callable[[dict[str, Any]], None]] = {
    "TRANSACTION_CREATED": _handle_transaction_created,
    "TRANSACTION_STATE_UPDATED": _handle_transaction_state_updated,
}


@app.route("/webhook", methods=["POST"])
def webhook() -> Response:
    # Production tip: verify signature, enqueue the event, return 200 immediately,
    # then process asynchronously. This example processes inline for clarity.
    if not request.is_json:
        logger.warning("Rejected: content-type %s is not JSON", request.content_type)
        return Response("Expected JSON", status=400)

    signature = request.headers.get("x-utila-signature")
    if not signature:
        logger.warning("Rejected: missing x-utila-signature header")
        return Response("Missing signature", status=400)

    if not _verify_signature(signature, request.data):
        event_id = (request.get_json(silent=True) or {}).get("id", "?")
        logger.warning("Rejected: invalid signature for event id=%s", event_id)
        return Response("Invalid signature", status=400)

    event: dict[str, Any] = request.get_json()
    event_id = event.get("id", "")

    if event_id and event_id in _processed_event_ids:
        return Response(status=200)

    event_type = event.get("type", "")
    handler = _EVENT_HANDLERS.get(event_type)
    try:
        if handler:
            handler(event)
        else:
            logger.info("Ignoring unknown event type: %s", event_type)
    except EnrichmentError as exc:
        # Non-2xx so Utila retries (up to ~24h). Do not mark processed.
        logger.error("Enrichment failed, requesting retry: %s", exc)
        return Response("Temporary enrichment failure", status=503)

    if event_id:
        _processed_event_ids.add(event_id)
    return Response(status=200)


if __name__ == "__main__":
    if USE_NGROK:
        tunnel = ngrok.connect(PORT, "http")
        print(f"\n{'=' * 60}")
        print(f"ngrok tunnel:   {tunnel.public_url}/webhook")
        print(f"{'=' * 60}\n")

    app.run(host="0.0.0.0", port=PORT)

2.5 Testing locally with ngrok

pip install pyngrok
ngrok config add-authtoken <your-token>   # one-time setup

USE_NGROK=true python3 webhook_server.py

Paste the printed …/webhook URL into Vault Settings → Webhooks → Add Webhook, select Transaction Created and Transaction State Updated, and click Test. Free ngrok URLs change on restart - update the Console URL after restarting. Production should use a stable HTTPS URL without USE_NGROK.

2.6 Webhook best practices checklist

  • Use HTTPS for your endpoint.
  • Verify x-utila-signature (RSA-4096, SHA-512, PSS, base64). Reject failures.
  • Respond with HTTP 2xx quickly in production (enqueue, then ack). Utila retries failed deliveries with backoff for up to ~24 hours, then discards the event. Persistent non-2xx responses can also cause delivery problems; log every rejection reason.
  • Be idempotent by event id (and by transaction resource name + credit stage).
  • Don't trust the payload alone. Fetch the transaction; always require direction == "INCOMING" and state == "CONFIRMED" before credit.
  • Classify early on CREATED. Skip outgoing once; credit immediately only if already CONFIRMED at create time.
  • On enrichment failure, return non-2xx (or don't mark the event processed) so Utila can retry.
  • Extract memos on memo networks; queue missing/unknown memos for manual reconciliation.
  • Handle unknown event types with 200 and ignore.
  • Test with the Console Test button.

3. Method 2: Polling with ListTransactions

If you can't expose a public HTTPS endpoint, or you want reconciliation on top of webhooks:

GET /v2/vaults/{vault_id}/transactions

3.1 Useful filters

FilterExampleUse for
directiondirection(INCOMING)Only deposits
statestate(CONFIRMED)Only final deposits (optional; see below)
to_walletto_wallet("vaults/…/wallets/…")One wallet
create_timecreate_time > "2026-07-21T00:00:00Z"Newer than checkpoint
spamspam(false)Exclude spam/dust

Combine with AND. For early detection and confirmation, do not filter only state(CONFIRMED):

direction(INCOMING) AND spam(false) AND create_time > "2026-07-21T00:00:00Z"

Then in your app:

  • state != CONFIRMED → log Deposit PENDING (do not credit); keep the tx in a pending set
  • state == CONFIRMED → log Deposit CONFIRMED and credit

Checkpoint pitfall: create_time does not change when a deposit later becomes CONFIRMED. If you advance the checkpoint past a pending deposit's create_time and only query by create_time, you can miss the confirmation. Keep a pending set and re-check those transactions each cycle (GetTransaction), or never advance the checkpoint past the oldest still-pending create_time.

3.2 Polling example (Python)

"""
Poll Utila for incoming deposits: detect PENDING early, credit on CONFIRMED.

Uses create_time with an overlap window, plus an explicit pending set that is
re-fetched every cycle so confirmations are not lost when the checkpoint moves.

Requirements:
    pip install requests pyjwt cryptography python-dotenv

Environment variables:
    UTILA_SA_EMAIL, UTILA_SA_PRIVATE_KEY  - VIEWER service account
    UTILA_VAULT_ID                        - required
    UTILA_WALLET                          - optional wallet id or full resource name
    POLL_INTERVAL_SECONDS                 - default 30
    CHECKPOINT_OVERLAP_SECONDS            - default 120 (re-read recent txs)
"""

from __future__ import annotations

import logging
import os
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

import jwt
import requests
from dotenv import load_dotenv

_env_file = Path(__file__).resolve().parent / ".env" if "__file__" in globals() else Path(".env")
load_dotenv(_env_file)

UTILA_API = "https://api.utila.io"
POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", "30"))
CHECKPOINT_OVERLAP_SECONDS = int(os.getenv("CHECKPOINT_OVERLAP_SECONDS", "120"))


def _load_pem(value: str, name: str) -> str:
    value = value.strip()
    if not value:
        return ""
    if not value.startswith("-----"):
        try:
            value = Path(value).expanduser().read_text()
        except OSError as exc:
            raise SystemExit(f"{name}: cannot read key file: {exc}")
    return value.strip().replace("\\n", "\n")


_SA_EMAIL = os.getenv("UTILA_SA_EMAIL", "")
_SA_PRIVATE_KEY = _load_pem(os.getenv("UTILA_SA_PRIVATE_KEY", ""), "UTILA_SA_PRIVATE_KEY")
VAULT_ID = os.getenv("UTILA_VAULT_ID", "").strip()
_WALLET_RAW = os.getenv("UTILA_WALLET", "").strip()

if not _SA_EMAIL or not _SA_PRIVATE_KEY:
    raise SystemExit("UTILA_SA_EMAIL and UTILA_SA_PRIVATE_KEY are required")
if not VAULT_ID:
    raise SystemExit("UTILA_VAULT_ID is required")

if not _WALLET_RAW:
    WALLET = ""
elif _WALLET_RAW.startswith("vaults/"):
    WALLET = _WALLET_RAW
else:
    WALLET = f"vaults/{VAULT_ID}/wallets/{_WALLET_RAW}"

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=[logging.StreamHandler(), logging.FileHandler("deposits.log")],
)
logger = logging.getLogger("deposit-poller")


def _access_token() -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": _SA_EMAIL,
        "aud": "https://api.utila.io/",
        "iat": now,
        "exp": now + timedelta(hours=1),
    }
    return jwt.encode(payload, _SA_PRIVATE_KEY, algorithm="RS256")


def _build_filter(since_iso: str) -> str:
    parts = [
        "direction(INCOMING)",
        "spam(false)",
        f'create_time > "{since_iso}"',
    ]
    if WALLET:
        parts.insert(1, f'to_wallet("{WALLET}")')
    return " AND ".join(parts)


def list_incoming_deposits(since_iso: str) -> dict[str, Any]:
    transactions: list[dict[str, Any]] = []
    referenced_resources: dict[str, Any] = {}
    referenced_addresses: dict[str, Any] = {}
    page_token = None
    filter_str = _build_filter(since_iso)

    while True:
        params: dict[str, Any] = {
            "filter": filter_str,
            "orderBy": "create_time asc",
            "pageSize": 50,
            "includeReferencedResources": "true",
        }
        if page_token:
            params["pageToken"] = page_token

        response = requests.get(
            f"{UTILA_API}/v2/vaults/{VAULT_ID}/transactions",
            params=params,
            headers={"Authorization": f"Bearer {_access_token()}"},
            timeout=30,
        )
        response.raise_for_status()
        data = response.json()

        transactions.extend(data.get("transactions", []))
        referenced_resources.update(data.get("referencedResources", {}))
        referenced_addresses.update(data.get("referencedAddressesInfo", {}))

        page_token = data.get("nextPageToken")
        if not page_token:
            break

    return {
        "transactions": transactions,
        "referencedResources": referenced_resources,
        "referencedAddressesInfo": referenced_addresses,
    }


def get_transaction(tx_name: str) -> dict[str, Any]:
    # tx_name is vaults/{vault}/transactions/{id}
    resp = requests.get(
        f"{UTILA_API}/v2/{tx_name}",
        params={"includeReferencedResources": "true"},
        headers={"Authorization": f"Bearer {_access_token()}"},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()


def extract_deposit_memo(transaction: dict[str, Any]) -> str | None:
    stellar_memo = (transaction.get("stellarTransaction") or {}).get("memo")
    if isinstance(stellar_memo, dict):
        data = stellar_memo.get("data")
        if data not in (None, ""):
            return str(data)

    ton_memo = (transaction.get("tonTransaction") or {}).get("memo")
    if isinstance(ton_memo, str) and ton_memo:
        return ton_memo

    xrpl = transaction.get("xrplTransaction") or {}
    tag = (xrpl.get("jsonTransactionData") or {}).get("DestinationTag")
    if tag is not None and tag != "":
        return str(tag)
    return None


def report_deposit(
    transaction: dict[str, Any],
    referenced_resources: dict[str, Any],
    referenced_addresses: dict[str, Any],
    *,
    stage: str,
) -> None:
    if transaction.get("direction") != "INCOMING":
        return

    tx_id = transaction.get("name", "").rsplit("/", 1)[-1]
    memo = extract_deposit_memo(transaction)
    for transfer in transaction.get("transfers", []):
        destination = transfer.get("destinationAddress", {})
        address = destination.get("value", "unknown")

        wallet_name = "unknown wallet"
        info_ref = destination.get("infoRef")
        if info_ref:
            wallet_resource = referenced_addresses.get(info_ref, {}).get("wallet")
            if wallet_resource and wallet_resource in referenced_resources:
                wallet_name = referenced_resources[wallet_resource]["wallet"]["displayName"]

        asset_resource = transfer.get("asset", "")
        asset_name = (
            referenced_resources.get(asset_resource, {})
            .get("asset", {})
            .get("displayName", asset_resource)
        )

        if memo is not None:
            logger.info(
                "Deposit %s | tx=%s address=%s wallet=%s asset=%s amount=%s memo=%s",
                stage, tx_id, address, wallet_name, asset_name,
                transfer.get("amount", "?"), memo,
            )
        else:
            logger.info(
                "Deposit %s | tx=%s address=%s wallet=%s asset=%s amount=%s",
                stage, tx_id, address, wallet_name, asset_name,
                transfer.get("amount", "?"),
            )


def process_transaction(
    transaction: dict[str, Any],
    referenced_resources: dict[str, Any],
    referenced_addresses: dict[str, Any],
    pending: dict[str, str],       # name -> create_time iso
    confirmed: set[str],
) -> None:
    if transaction.get("direction") != "INCOMING":
        return

    name = transaction["name"]
    state = transaction.get("state")

    if state in ("MINED_FAILED", "FAILED"):
        pending.pop(name, None)
        return

    if state == "CONFIRMED":
        if name in confirmed:
            return
        if name not in pending:
            report_deposit(
                transaction, referenced_resources, referenced_addresses, stage="PENDING",
            )
        report_deposit(
            transaction, referenced_resources, referenced_addresses, stage="CONFIRMED",
        )
        confirmed.add(name)
        pending.pop(name, None)
        return

    # Pre-confirmation (typically MINED): detect only.
    if name not in pending and name not in confirmed:
        report_deposit(
            transaction, referenced_resources, referenced_addresses, stage="PENDING",
        )
        pending[name] = transaction.get("createTime") or datetime.now(timezone.utc).isoformat()


def main() -> None:
    # Persist checkpoint + pending + confirmed in production.
    checkpoint = datetime.now(timezone.utc)
    pending: dict[str, str] = {}
    confirmed: set[str] = set()
    scope = WALLET if WALLET else f"vault {VAULT_ID} (all wallets)"
    logger.info(
        "Polling incoming deposits on %s every %ss",
        scope,
        POLL_INTERVAL_SECONDS,
    )

    while True:
        try:
            since = (checkpoint - timedelta(seconds=CHECKPOINT_OVERLAP_SECONDS)).isoformat()
            data = list_incoming_deposits(since)

            newest_create: datetime | None = None
            for transaction in data["transactions"]:
                process_transaction(
                    transaction,
                    data["referencedResources"],
                    data["referencedAddressesInfo"],
                    pending,
                    confirmed,
                )
                ct = transaction.get("createTime")
                if ct:
                    ts = datetime.fromisoformat(ct.replace("Z", "+00:00"))
                    if newest_create is None or ts > newest_create:
                        newest_create = ts

            # Re-check pendings that may have fallen outside the create_time window.
            for name in list(pending):
                if any(t.get("name") == name for t in data["transactions"]):
                    continue  # already processed this cycle
                try:
                    detail = get_transaction(name)
                except requests.RequestException as exc:
                    logger.error("GetTransaction failed for pending %s: %s", name, exc)
                    continue
                tx = detail.get("transaction", detail)
                process_transaction(
                    tx,
                    detail.get("referencedResources", {}),
                    detail.get("referencedAddressesInfo", {}),
                    pending,
                    confirmed,
                )

            if newest_create is not None and newest_create > checkpoint:
                checkpoint = newest_create

        except requests.RequestException as exc:
            logger.error("Polling failed, will retry: %s", exc)

        time.sleep(POLL_INTERVAL_SECONDS)


if __name__ == "__main__":
    main()

3.3 Polling best practices

  • Filter direction(INCOMING) (and usually spam(false)).
  • Detect early, credit late: only settle on state == CONFIRMED.
  • Persist a create_time checkpoint and a pending-tx set; re-fetch pendings so confirmations are not lost.
  • Use an overlap window (e.g. 1–2 minutes) and dedupe by transaction name + stage.
  • Handle pagination (nextPageToken).
  • Interval ~15–60s; back off on 429/5xx.
  • Use includeReferencedResources=true for display names.
  • Leave UTILA_WALLET empty for the whole vault, or set it for one wallet.

4. Recommended architecture: webhooks + polling

LayerRole
Webhook (TRANSACTION_CREATED + TRANSACTION_STATE_UPDATED)Real-time: classify on CREATED; credit on CONFIRMED when direction == INCOMING.
Polling (direction(INCOMING) + checkpoint + pending re-check)Reconciliation / alternative: PENDING then CONFIRMED.

Both paths should feed the same idempotent credit handler keyed on the transaction resource name (vaults/{vault}/transactions/{id}), so a webhook credit is not double-applied when the poller sees the same tx.


5. Quick reference

  • Webhooks: https://docs.utila.io/reference/webhooks
  • ListTransactions: https://docs.utila.io/reference/transactions_listtransactions
  • GetTransaction: GET https://api.utila.io/v2/vaults/{vault_id}/transactions/{transaction_id}
  • Role: VIEWER is enough.
  • Flow: TRANSACTION_CREATED → GetTransaction → pending if INCOMING (credit immediately only if already CONFIRMED); else wait for STATE_UPDATED / CONFIRMED.
  • Credit when: direction == INCOMING and state == CONFIRMED.
  • Do not credit MINED_FAILED / FAILED.
  • Event resource: vaults/{vault_id}/transactions/{transaction_id}.
  • Memos: Stellar stellarTransaction.memo.data, XRP DestinationTag, TON tonTransaction.memo. Utila does not manage memo ↔ user mapping.
  • createTime vs mineTime: may or may not match depending on network - do not use equality as a deposit signal.