This document provides a technical reference for the Universal Billable Module. It covers the database schema, configuration settings, and REST API specification.
The module relies on standard Django settings.
| Setting | Default | Description |
|---|---|---|
INSTALLED_APPS |
- | Must include "billable". |
AUTH_USER_MODEL |
"auth.User" |
The Django user model to link orders and products to. |
BILLABLE_API_TOKEN |
None |
Required. Secret token for Bearer authentication in REST API. |
BILLABLE_SHOW_DOCS |
True |
Include OpenAPI docs at /docs when the API is mounted. |
BILLABLE_API_TITLE |
"Billable Engine API" |
Title for the OpenAPI schema. |
BILLABLE_CURRENCY |
"USD" |
Default currency code (optional, depends on implementation). |
Database (PostgreSQL, async): When using the module in async mode (ASGI, bots), set CONN_MAX_AGE=0 for the PostgreSQL database in DATABASES. Persistent connections (CONN_MAX_AGE > 0) are not safe across async event loop context and can cause connection reuse issues; CONN_MAX_AGE=0 closes the connection after each request/task.
To avoid AppRegistryNotReady errors during initialization (e.g., in tests or with pytest-django), do not import models directly from the billable package.
Correct way to import models:
from billable.models import ExternalIdentity, Product, OfferCorrect way to import services:
from billable.services import TransactionService, BalanceService, CustomerServiceAsync context (bots, ASGI): Prefer async methods of the API and services (e.g. TransactionService.acheck_quota, OrderService.acreate_order) so as not to block the event loop.
This section contains practical examples (sync and async) intended for application-layer integrations.
from billable.models import Offer, TrialHistory
from billable.services import TransactionService
from asgiref.sync import sync_to_async
class PromotionService:
@classmethod
async def claim_welcome_trial(cls, user_id: int, telegram_id: str):
# 1. Fraud check (using billable tool)
identities = {"telegram": telegram_id}
if await TrialHistory.ahas_used_trial(identities=identities):
return {"success": False, "reason": "trial_already_used"}
# 2. Find trial offer (create in Django admin)
offer = await Offer.objects.aget(sku="off_welcome_trial")
# 3. Grant using billable engine
batches = await sync_to_async(TransactionService.grant_offer)(
user_id=user_id,
offer=offer,
source="welcome_bonus"
)
# 4. Mark as used
await TrialHistory.objects.acreate(
identity_type="telegram",
identity_hash=TrialHistory.generate_identity_hash(telegram_id),
trial_plan_name="Welcome Trial"
)
return {"success": True, "batches": batches}from django.dispatch import receiver
from billable.signals import order_confirmed
from billable.services import TransactionService
@receiver(order_confirmed)
async def on_first_purchase(sender, order, **kwargs):
# Check if this is the first purchase
if not await Order.objects.filter(user=order.user, status=Order.Status.PAID).exclude(id=order.id).aexists():
# Atomically claim bonus
referral = await Referral.objects.filter(referee=order.user).afirst()
if referral:
# Sync wrapper or async equivalent for model method required in async context
claimed = await sync_to_async(referral.claim_bonus)()
if claimed:
bonus_offer = await Offer.objects.aget(sku="off_referral_bonus")
await sync_to_async(TransactionService.grant_offer)(
user_id=referral.referrer_id,
offer=bonus_offer,
source="referral_bonus",
metadata={
"referee_id": referral.referee_id, # Required for webhook payload
"order_id": order.id, # Required for webhook payload
}
)Important: When creating a referral bonus transaction, always include referee_id and order_id in the metadata parameter. This ensures that webhook payloads (e.g., referral_bonus_granted events) can include referee_external_id by looking up the referee's ExternalIdentity record. Without these fields in metadata, the webhook will only contain referrer_external_id and referee_external_id will be null.
All database tables are prefixed with billable_. In every model, user (FK to settings.AUTH_USER_MODEL) denotes the Billing account — the entity to which orders and product rights are attributed.
The catalog of available resources.
product_key(CharField, PK): Unique string identifier for accounting (e.g.,DIAMONDS,VIP_ACCESS). Stored in uppercase (CAPS). Automatically normalized on save.name/description: Display fields.product_type(Choice):PERIOD: Time-based access.QUANTITY: Consumable units.UNLIMITED: Permanent access.
is_active: Boolean flag. IfFalse, cannot be used in new offers.is_currency: Boolean flag. IfTrue, this product is treated as a currency (e.g. internal credits).created_at: Timestamp.metadata: Arbitrary JSON data.
Tracks links between inviters and invitees.
referrer: FK to User (the inviter).referee: FK to User (the invitee).bonus_granted: Boolean flag.created_at: Timestamp.metadata(JSONField): Stores configuration.- Key
features: List of feature strings.
- Key
- Methods:
claim_bonus() -> bool: Atomically marksbonus_granted=Trueandbonus_granted_atto now. ReturnsTrueif successful,Falseif already granted. Use this for idempotency in bonus logic.
Note: Product does NOT contain price or quantity. These are defined in Offers.
Marketing packages that bundle products.
sku(CharField, PK): Commercial identifier. Stored in uppercase (CAPS). Automatically normalized on save.name: Display name (e.g., "Premium Bundle").price/currency: Cost (EUR, USD, XTR, INTERNAL).image/description: UI metadata.is_active: Visibility flag.metadata: Additional configuration (JSON).
Links products to offers with quantity and expiration rules.
offer: FK to Offer.product: FK to Product.quantity: How many units of the product.period_value/period_unit: Expiration (DAYS, MONTHS, YEARS, FOREVER).
User's "wallet" of resources. Each batch represents a grant of a specific product.
user_id: FK to User.product: FK to Product.source_offer: FK to Offer (nullable, for audit).order_item: FK to OrderItem (nullable, if purchased).initial_quantity: Original amount granted.remaining_quantity: Current balance.valid_from/expires_at: Validity period.state: ACTIVE, EXHAUSTED, EXPIRED, REVOKED.created_at: Timestamp for FIFO ordering.
Why multiple batches? A user's total balance for a product is the sum of
remaining_quantityacross all ACTIVE batches. The system uses FIFO (First-In, First-Out) logic to consume from the oldest batches first.
Immutable ledger of all balance changes.
user_id: FK to User.quota_batch: FK to QuotaBatch.amount: Quantity changed.direction: CREDIT (grant) or DEBIT (consume).action_type: Source (e.g., "purchase", "trial_activation", "usage").object_id: Optional external reference.metadata: Context (JSON). This field is critical for Rich Transaction History. Apps can store IDs (e.g.,message_id) here.created_at: Timestamp.
Use the metadata field to provide context for balance changes. For example, when consuming quota for a vacancy response:
TransactionService.aconsume_quota(
user_id=user.id,
product_key="VACANCY_RESPONSE",
metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Python Developer"}
)This allows the frontend to display a detailed log: "Used for response to 'Python Developer' (ID: linkedin:12345)".
Represents a financial transaction intent.
user_id: FK to User.status(Choice):PENDING,PAID,CANCELLED,REFUNDED.total_amount/currency: Financial totals.payment_method: String identifier (e.g.,stripe,telegram_payments).payment_id: External transaction ID (for idempotency).created_at/paid_at: Timestamps.metadata: Application-specific IDs (e.g.,{"report_id": 123}).
Individual lines within an order.
order: FK to Order.offer: FK to Offer.quantity: Number of offers purchased.price: Price per offer at the moment of purchase.
Fraud prevention tool. Does NOT enforce trial logic — your application layer should check this before granting.
identity_hash(CharField, indexed): SHA-256 hash of the user's external ID. The ID is normalized to lowercase before hashing for maximum compatibility.identity_type: Type of ID hashed (e.g.,telegram,email).trial_plan_name: The specific trial name used.- Methods:
ahas_used_trial(identities: dict): Async check if any identity has used a trial.generate_identity_hash(value): Static method to hash identities.
To prevent trial abuse across different platforms, follow the provider:external_id pattern.
- Normalize: Convert to lowercase and strip whitespace.
- Hash: Use
TrialHistory.generate_identity_hash().
Pattern: linkedin:12345, tg:5454776146, email:user@example.com.
# Recommended normalization before hashing
raw_id = " LINKEDIN:12345 "
normalized = raw_id.strip().lower() # "linkedin:12345"
identity_hash = TrialHistory.generate_identity_hash(normalized)External identity mapping for integrations.
provider(CharField, indexed, default="default"): Identity source (e.g.,telegram,n8n).external_id(CharField, indexed): Stable external identifier.user(FK, nullable): Optional link tosettings.AUTH_USER_MODEL.metadata(JSONField): Provider-specific payload. Use this to store project-specific data (e.g.,bot_id,slug,category) to avoid extending the library tables.- Uniqueness:
(provider, external_id). - Methods:
get_user_by_identity(external_id, provider="default"): Synchronously retrieves a User by their external identity.aget_user_by_identity(external_id, provider="default"): Asynchronously retrieves a User by their external identity.get_external_id_for_user(user, provider="default"): Synchronously retrieves external_id for a user by provider.aget_external_id_for_user(user, provider="default"): Asynchronously retrieves external_id for a user by provider.
Creates ExternalIdentity records from the values of a field on the User (or Custom User) model. Used for one-off migration of existing identifiers (e.g. telegram_id, chat_id, stripe_id) into the identities table. Idempotent: does not create duplicates for (provider, external_id).
Syntax:
python manage.py migrate_identities <field> <provider> [--dry-run] [--limit N]| Argument / option | Description |
|---|---|
field |
Name of the field on the User model that holds the identifier (e.g. chat_id, telegram_id, stripe_id). |
provider |
Provider name for ExternalIdentity (e.g. telegram, stripe). |
--dry-run |
Print the plan without writing to the database. |
--limit N |
Process at most N users (0 = no limit). |
Behaviour: For each user with a non-empty field value, an ExternalIdentity(provider=..., external_id=str(value), user=user) record is created if that (provider, external_id) pair does not already exist. Users with an empty or None value for the field are skipped. If the field does not exist on the model, the command writes an error to stderr and exits without creating any records.
Examples:
python manage.py migrate_identities telegram_id telegram
python manage.py migrate_identities chat_id telegram --dry-run
python manage.py migrate_identities stripe_id stripe --limit 100You can run the command multiple times with different fields and providers for the same user; that user will have multiple ExternalIdentity records (one per provider/external_id pair).
The TransactionService is the primary interface for managing balances.
| Method | Sync / Async | Idempotency Support | Description |
|---|---|---|---|
check_quota |
Both | No | Returns whether user has enough balance. |
consume_quota / aconsume_quota |
Both | Yes (via idempotency_key) |
Debits balance using FIFO. Returns usage ID and metadata. |
grant_offer |
Both | No* | Grants products from an Offer. |
exchange |
Both | No* | Swaps internal currency for an Offer. |
* Note: Credits (grants) do not have a built-in idempotency key. See Migration Best Practices for implementation patterns.
Quota Check
# Sync
res = TransactionService.check_quota(user_id=1, product_key="CREDITS")
# Async
res = await TransactionService.acheck_quota(user_id=1, product_key="CREDITS")
# Returns: {"can_use": bool, "remaining": int, "message": str}Consumption
# Sync
res = TransactionService.consume_quota(
user_id=user.id,
product_key="CREDITS",
amount=1,
action_type="usage",
idempotency_key="request_uuid_123",
metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)
# Async
res = await TransactionService.aconsume_quota(
user_id=user.id,
product_key="CREDITS",
amount=1,
action_type="usage",
idempotency_key="request_uuid_123",
metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)Granting (Purchase/Bonus)
# Sync
batches = TransactionService.grant_offer(
user_id=user.id,
offer=offer_obj,
source="bonus",
metadata={"campaign": "early_bird"}
)
# Async
batches = await TransactionService.agrant_offer(
user_id=user.id,
offer=offer_obj,
source="bonus",
metadata={"campaign": "early_bird"}
)check_quota/acheck_quota: no idempotency key.consume_quota/aconsume_quota: supportsidempotency_key; repeated calls with the same key return the previously created usage.grant_offer/agrant_offer: no built-in idempotency key.exchange/aexchange: no dedicated idempotency key argument; idempotency must be ensured at the integration layer if required.
Use transaction metadata to bind each debit/credit to a concrete business object (vacancy, report, campaign, migration record). This is the primary way to build a human-readable ledger in UI.
# Sync
TransactionService.consume_quota(
user_id=user.id,
product_key="VACANCY_RESPONSE",
action_type="usage",
idempotency_key="vacancy-response:linkedin:12345:user:42",
metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)
# Async
await TransactionService.aconsume_quota(
user_id=user.id,
product_key="VACANCY_RESPONSE",
action_type="usage",
idempotency_key="vacancy-response:linkedin:12345:user:42",
metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)Frontend can use metadata.vacancy_title and metadata.vacancy_id from transaction history endpoint (GET /wallet/transactions) to render records like:
- "Debited for response to vacancy 'Senior Python Developer'"
- "Source: linkedin:12345"
When migrating balances from legacy systems, use TransactionService.grant_offer with protective metadata and explicit deduplication check.
Since grant_offer / agrant_offer don't have idempotency_key, use metadata.migration_source_id as your integration-level idempotency marker.
- Check: Before granting, check if a transaction with
migration_source_idalready exists. - Grant: If not found, grant the offer and store the source ID in metadata.
- Key format: Prefer provider-scoped keys like
legacy_system:record_idto avoid collisions between migration sources.
def safe_migrate_balance(user_id, legacy_amount, migration_id):
# 1. Idempotency check via metadata
exists = Transaction.objects.filter(
user_id=user_id,
metadata__migration_source_id=migration_id
).exists()
if exists:
return
# 2. Grant (e.g., using a special "Migration" offer)
offer = Offer.objects.get(sku="OFF_MIGRATION_CREDITS")
TransactionService.grant_offer(
user_id=user_id,
offer=offer,
source="migration",
metadata={"migration_source_id": migration_id, "original_amount": legacy_amount}
)async def asafe_migrate_balance(user_id, migration_id):
exists = await Transaction.objects.filter(
user_id=user_id,
metadata__migration_source_id=migration_id
).aexists()
if exists:
return
offer = await Offer.objects.aget(sku="OFF_MIGRATION_CREDITS")
await TransactionService.agrant_offer(
user_id=user_id,
offer=offer,
source="migration",
metadata={"migration_source_id": migration_id}
)Service for managing customer-related operations, particularly merging accounts.
from billable.services import CustomerService
# Merge source_user data into target_user
# Moves all orders, quota batches, transactions, identities, and referrals.
stats = CustomerService.merge_customers(target_user_id=101, source_user_id=102)
# Returns: {'moved_orders': 2, 'moved_batches': 5, ...}
# Async version
stats = await CustomerService.amerge_customers(target_user_id=101, source_user_id=102)Billable provides enhanced Django Admin interfaces for managing products and analyzing customer usage.
A hierarchical report view available in the Customer admin. It groups transactions by QuotaBatch (the source of funds), showing:
- Inflows: How the product was acquired (Order, Offer, or Manual Grant).
- Spending: How the product was consumed (Transactions).
- Running Balance: The balance history calculated chronologically.
This view helps support agents audit complex usage scenarios where a user has multiple active quotas for the same product.
When integrating Billable with external systems (e.g., n8n, Zapier), you may need to implement specific webhook contracts.
Sent by the application layer when a referral bonus is successfully claimed (typically after the referee's first paid order).
Method: POST
Expected Payload (JSON):
{
"event": "referral_bonus_granted",
"referrer_external_id": "123456789", // e.g. Telegram Chat ID
"referee_external_id": "987654321",
"order_id": 55, // ID of the order that triggered the bonus
"idempotency_key": "referral_bonus:10:order:55" // Unique key for deduplication
}The API is built with Django Ninja.
Base URL: /api/v1/billing (typical configuration).
Authentication: Header Authorization: Bearer <BILLABLE_API_TOKEN>.
Normalization Note: All endpoints are case-insensitive for sku and product_key inputs. The system automatically converts these fields to uppercase before processing.
Get current quotas for the authenticated user.
- Response: List of active
product_keyand remaining limits.
List active quota batches, optionally filtered by product_key.
- Query params:
user_id(int, optional): Local user id.product_key(str, optional): Resource key to filter by.external_id(str, optional): External identifier.provider(str, optional): Identity provider.
- Response (200):
List[ActiveBatch] - Response (404): If
external_idis provided but user does not exist.
Get aggregated balance for all active products.
- Query params:
user_idor (external_id+provider). - Response (200):
{"user_id": int, "balances": {"PRODUCT_KEY": int, ...}} - Response (404): If user not found (lookup only).
Detailed list of all active quota batches (with expires_at).
- Query params:
user_idor (external_id+provider). - Response (200):
List[QuotaBatchSchema] - Response (404): If user not found (lookup only).
Transaction history (ledger) for the user.
- Query params:
user_idor (external_id+provider),product_key,action_type,date_from. - Response (200):
List[TransactionSchema](up to 100 recent items). - Response (404): If user not found (lookup only).
Identify an external identity and ensure a local User exists (create and link if missing).
- Body:
{ "provider": "telegram", "external_id": "123456789", "profile": { "telegram_username": "alice", "first_name": "Alice" } } - Notes:
- If
provideris omitted,"default"is used. - User is always created or resolved; the response always includes
user_id.
- If
Consume quota for a specific product (admin / server-to-server).
- Body:
user_idor (external_id+provider),product_key,action_type, optionalaction_id,idempotency_key, optionalmetadata(JSON). The metadata is stored on the created DEBIT transaction. - Note: Automatically creates a local
Userif missing. - Response (200):
{"success": true, "message": "...", "data": {"usage_id": "...", "remaining": N, "metadata": {...}}}. Thedata.metadatais the stored transaction metadata (on idempotent replay, the existing transaction's metadata is returned).
(Demo/Reference implementation) Grant a trial offer with abuse protection.
- Body:
Optional
{ "sku": "off_trial_pack", "user_id": 123, "metadata": { "campaign_id": "winter2024" } }metadata(JSON) is merged with internal data (e.g. identities) and stored on the created CREDIT transaction(s); the same merged metadata is returned in the response. - Notes:
- Uses
TrialHistoryto prevent double-granting. - Automatically creates a local
Userifexternal_id+provideris used and user is missing. - This is a reference implementation; move logic to
PromotionServicein production.
- Uses
- Response (200):
{"success": true, "message": "Trial granted", "data": {"products": [...], "metadata": {...}}}
Exchange internal currency for an offer (spend product_key, grant sku).
- Entry point for internal currency purchases.
- Logic: Atomically consumes internal balance and grants the offer via
TransactionService.grant_offer(source="exchange"). Optional requestmetadatais merged with internal data (e.g. price) and stored on the created Transaction; the same metadata is returned in the response. - Body: Send the JSON object directly as the request body (no top-level
"data"wrapper). Content-Type:application/json. Optional field:metadata(JSON). - By user ID:
{ "sku": "off_premium_pack", "user_id": 123, "metadata": { "source": "telegram_menu" } } - By external identity (e.g. Telegram): provide
external_idandproviderinstead ofuser_id. User is resolved viaExternalIdentity.{ "sku": "off_premium_pack", "external_id": "322056265", "provider": "telegram", "metadata": { "source": "telegram_menu" } } - Notes:
skuis required. Eitheruser_idor (external_id+provider) must be present. Ifprovideris omitted when using external identity,"default"is used. - Auto-creation: If a new
external_id+provideris used, the system automatically creates a localUserbefore processing the exchange. - Response (200):
{"success": true, "message": "Exchange successful", "data": {"success": true, "message": "Exchanged", "metadata": {...}}}. Thedata.metadatais the stored transaction metadata (includes at leastprice; plus any request metadata if provided).
Create a new order (financial intent).
- Body:
{ "user_id": 123, "items": [ {"sku": "off_diamonds_100", "quantity": 1} ] } - Auto-creation: If a new
external_id+provideris used (instead ofuser_id), the system automatically creates a localUser.
Confirm payment for an order and grant products.
- Entry point for real money purchases (RUB, USD, XTR). Webhooks from Stripe, YooKassa, or Telegram Payments are handled in your application: the app receives the webhook, extracts
order_idandpayment_id, then calls this endpoint. Billable does not expose a built-in webhook URL. - Logic: Transitions order to
PAIDand callsTransactionService.grant_offer(source="purchase"). Reprocessing the samepayment_iddoes not create duplicate batches or transactions. - Body:
{ "payment_id": "tx_abc_123", "payment_method": "stripe" }
Refund a paid order and revoke associated products.
- Logic: Transitions order to
REFUNDEDand createsDEBITtransactions for any remaining quantity in the batches granted by this order. Batches are marked asREVOKED. - Body:
{ "reason": "Customer request" }
Create a referral link between referrer and referee. Supports two input modes.
- By user IDs — body:
referrer_id,referee_id(int), optionalmetadata. - By external identity — body:
provider,referrer_external_id,referee_external_id(str), optionalmetadata. Only existingExternalIdentityrecords are used; if either identity is missing, returns 400 without creating the referral. - Response (200):
{"success": true, "message": "Referral assigned", "data": {"created": bool, "referral_id": int, "metadata": {...}}}. Themetadatais the stored Referral metadata (from the request or existing record).
Referral statistics (e.g. count of invited users) for the referrer.
- Query params:
user_id(int, optional): Local user id.external_id(str, optional): External identifier (used ifuser_idis not provided).provider(str, optional): Identity provider forexternal_id. Defaults to"default".
- Response (200):
{"success": true, "message": "Stats retrieved", "data": {"count": N}} - Response (404): If
external_idis provided but user does not exist.
Merge two customers: move all data from source_user to target_user.
- Body:
{ "target_user_id": 1, "source_user_id": 2 } - Response (200):
CustomerMergeResponse(success flag, message, and counts of moved items). - Notes: Atomically moves orders, batches, transactions, identities, and referrals.
List all active offers (catalog) with nested offer items and products.
- Logic: Returns offers with
is_active=True, prefetcheditemsand products. - Response fields:
sku,name,price,currency,description,image,is_active,items,metadata. - Query params (optional):
sku(str, repeatable): Filter by SKU list. Example:?sku=off_a&sku=off_b. Preserves input order; returns only found offers. If omitted, returns full catalog.
- Response (200):
List[OfferSchema]
Get a single active offer by SKU.
- Path:
sku— unique offer identifier (e.g.off_credits_100). - Logic: Exact match on
skuandis_active=True. Prefetchesitems__product. - Response fields:
sku,name,price,currency,description,image,is_active,items,metadata. - Response (200):
OfferSchema - Response (404):
CommonResponse—{"success": false, "message": "Offer not found"}if offer does not exist or is inactive.
Response Contract (OfferSchema):
| Field | Type | Description |
|---|---|---|
sku |
string | Commercial identifier (e.g., off_diamonds_100, pack_premium). |
name |
string | Display name of the offer. |
price |
number (Decimal) | Price per unit. |
currency |
string | Currency code: EUR, USD, XTR, INTERNAL, etc. |
description |
string | Description for UI. |
image |
string | null | Image URL or null. |
is_active |
boolean | Visibility in catalog. |
items |
array | List of offer items (products and quantities). |
metadata |
object | Additional configuration (JSON). |
Structure of items element (OfferItemSchema):
| Field | Type | Description |
|---|---|---|
product |
object | Product in the position (see below). |
quantity |
integer | Quantity of units of the product in the offer. |
period_unit |
string | Period unit: DAYS, MONTHS, YEARS, FOREVER. |
period_value |
integer | null | Period value; null for FOREVER. |
Nested product object (ProductSchema):
| Field | Type | Description |
|---|---|---|
id |
integer | Product primary key. |
product_key |
string | null | Identifier for accounting (e.g., diamonds, vip_access). |
name |
string | Display name of the product. |
description |
string | Text description. |
product_type |
string | PERIOD, QUANTITY, UNLIMITED. |
is_active |
boolean | Availability in new offers. |
metadata |
object | JSON configuration. |
created_at |
string (datetime) | Creation timestamp. |
Response Example (fragment):
[
{
"sku": "pack_premium",
"name": "Premium Bundle",
"price": "9.99",
"currency": "USD",
"description": "Monthly premium access",
"image": null,
"is_active": true,
"items": [
{
"product": {
"id": 1,
"product_key": "vip_access",
"name": "VIP Access",
"description": "",
"product_type": "PERIOD",
"is_active": true,
"metadata": {},
"created_at": "2025-01-01T00:00:00"
},
"quantity": 1,
"period_unit": "MONTHS",
"period_value": 1
}
],
"metadata": {}
}
]