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:
- Webhooks (recommended): Utila pushes events to your endpoint in real time.
- 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
VIEWERrole is enough. A signer role is not required and should not be granted under least privilege: aVIEWERkey can read transactions but cannot move funds. Receiving webhook events needs no role at all (your endpoint only verifies Utila's public key);VIEWERcredentials 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_CREATEDis 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
| State | Meaning |
|---|---|
MINED | The transaction was included in a block and Utila ingested it. Common first state on EVM, Bitcoin, Solana, and similar networks. |
CONFIRMED | The 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:
- Webhook:
TRANSACTION_CREATED→ GetTransaction → oftenMINED,direction = INCOMING→ treat as pending (do not credit). - Webhook:
TRANSACTION_STATE_UPDATEDwithnewState = CONFIRMED→ re-fetch → credit.
Important: only credit a deposit when
direction == "INCOMING"andstate == "CONFIRMED"(or, onTRANSACTION_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 onlyCONFIRMED+INCOMINGas 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,
createTimeis when Utila created the vault transaction record;mineTimeis 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 oncreateTime == mineTimeas 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.
| Network | What the sender must include | Where it appears (GetTransaction / ListTransactions) |
|---|---|---|
| Stellar | memo (TEXT, ID, and possibly HASH / RETURN) | stellarTransaction.memo → { "type", "data" } |
| XRP | destination tag (0..4294967295) | xrplTransaction.jsonTransactionData.DestinationTag |
| TON | comment / memo | tonTransaction.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:
| Event | Relevant 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 unlessstateis alreadyCONFIRMED(common on Stellar) - then credit immediately and stay idempotent if a laterSTATE_UPDATEDalso arrives.OUTGOING→ skip once and remember the tx id so later state updates stay silent.
TRANSACTION_STATE_UPDATEDwithnewState: "CONFIRMED"→ if not already credited, re-checkdirection == "INCOMING", then credit. Ignore intermediate states for known-outgoing / already-confirmed txs. Do not creditMINED_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:
- Verify the signature.
- 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).
- On
TRANSACTION_CREATED, call GetTransaction and classify as above. - On
TRANSACTION_STATE_UPDATEDwithCONFIRMED, re-fetch and credit only if stillINCOMINGand not already credited. - 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=trueincludeReferencedResources=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.pyPaste 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"andstate == "CONFIRMED"before credit. - Classify early on CREATED. Skip outgoing once; credit immediately only if already
CONFIRMEDat 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}/transactions3.1 Useful filters
| Filter | Example | Use for |
|---|---|---|
direction | direction(INCOMING) | Only deposits |
state | state(CONFIRMED) | Only final deposits (optional; see below) |
to_wallet | to_wallet("vaults/…/wallets/…") | One wallet |
create_time | create_time > "2026-07-21T00:00:00Z" | Newer than checkpoint |
spam | spam(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→ logDeposit PENDING(do not credit); keep the tx in a pending setstate == CONFIRMED→ logDeposit CONFIRMEDand credit
Checkpoint pitfall:
create_timedoes not change when a deposit later becomesCONFIRMED. If you advance the checkpoint past a pending deposit'screate_timeand only query bycreate_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-pendingcreate_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 usuallyspam(false)). - Detect early, credit late: only settle on
state == CONFIRMED. - Persist a
create_timecheckpoint 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=truefor display names. - Leave
UTILA_WALLETempty for the whole vault, or set it for one wallet.
4. Recommended architecture: webhooks + polling
| Layer | Role |
|---|---|
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:
VIEWERis enough. - Flow:
TRANSACTION_CREATED→ GetTransaction → pending ifINCOMING(credit immediately only if alreadyCONFIRMED); else wait forSTATE_UPDATED/CONFIRMED. - Credit when:
direction == INCOMINGandstate == CONFIRMED. - Do not credit
MINED_FAILED/FAILED. - Event
resource:vaults/{vault_id}/transactions/{transaction_id}. - Memos: Stellar
stellarTransaction.memo.data, XRPDestinationTag, TONtonTransaction.memo. Utila does not manage memo ↔ user mapping. createTimevsmineTime: may or may not match depending on network - do not use equality as a deposit signal.
