Z Zise Developers 简体中文

Subscribe: flexible balance pool / individual fixed-term order

POST /v1/earn/subscriptions scope: earn:write
On behalf of a member · x-on-behalf-of required Requires x-idempotency-key Moves funds · Member available balance → Earn account, reclassified under the same member with balanced ledger legs
This endpoint moves funds

See the response table below for failure handling. Retry timeouts (504) with the same idempotency key — we may already have processed the request; use a new key after a business failure; the same key replays that failure.

Move the member’s available balance into an Earn account. Funds remain within our ledger; there is no upstream or external transfer. This is a reclassification under the same member.

Response shape depends on the product’s kind, not on a request option:

  • Flexible: id starts with ers_, identifying a subscription operation; status is always accruing.
  • Fixed-term: id starts with ero_, identifying an order; status is always pending_start.

⚠⚠ The renewal enum is principal_interest, not both. This is a common trap: our member client silently falls back to the product default for unknown values. A user can select principal-and-interest renewal in the UI while another option executes, without any warning. The Open API rejects this immediately with invalid_request, so configuration mistakes are visible now rather than months later at reconciliation. Only three values are valid: none / principal / principal_interest.

⚠ rollover_mode matters only for fixed-term products. If the product-level renewal switch is off, execution always uses none, ignoring the supplied value without an error.

⚠ Flexible accrual starts at subscription time + the product’s configured T+N, not immediately at execution. A position queried just after subscription includes the principal even though it has not begun earning.

⚠ Failures are immediate, not queued. Rates are snapshotted at execution; a subscription executed ten minutes later would be worse than immediate failure. If your prepaid funds are insufficient, members see service_unavailable without an explanation of the cause.

Prerequisites

  • Product available to you, on sale, within its sale window, with remaining total and per-member capacity
  • Member meets minimum KYC and account is neither frozen nor closed
  • Member’s available asset balance ≥ amount
  • Your prepaid account has sufficient balance in the asset
FieldTypeRequiredDescription
x-on-behalf-of string Required Member for whom the subscription is made.
x-idempotency-key string Required
x-step-up string Optional UUID. Also the funds-layer deduplication key: same-key replay retrieves the prior result without a second subscription.

Request Body

FieldTypeRequiredDescription
product_id string Required Product ID, with or without ern_.
amount string Required Subscription amount. Fixed-point string using the product’s ledger_scale.USDT uses 6 decimals, for example 1000.000000
rollover_mode "none" | "principal" | "principal_interest" Optional Maturity handling, relevant only to fixed-term products. Omission uses the product default. ⚠ both is rejected with invalid_request; it does not exist. Use principal_interest.

Response

201Accepted. The example below is fixed-term; flexible returns { id: "ers_…", kind: "flexible", status: "accruing" }. ⚠ The response contains no amount, accrual date, or maturity date. Retrieve GET /v1/earn/orders for fixed-term or GET /v1/earn/positions for flexible details.
{
  "id": "ero_c41d7f92-8a03-4bb6-9e10-5f2c8d3a7061",
  "kind": "fixed",
  "status": "pending_start"
}
400Registered public codes: invalid_request for amount format or rollover_mode outside the three valid values; product_not_available, product nonexistent or unauthorized; limit_exceeded, insufficient total product capacity; invalid_fields, amount ≤ 0 or out of magnitude range; request_rejected, member frozen, closed, or blocklisted; service_unavailable, insufficient prepaid funds or Earn not enabled for you; step_up_required, product requires strong authentication through the two-stage hosted flow, action earn_subscribe:<product_id>; idempotency_key_required; idempotency_key_invalid; member_context_required; member_not_found. ⚠ Still returning 500 api_error: insufficient per-member capacity or member balance; below minimum / above transaction maximum; insufficient KYC; product sale stopped, delisted, not started, or past its deadline.
409idempotency_key_reused · idempotency_in_progress

Emitted Events

Green = successful terminal state · Red = terminal state requiring action · Purple = intermediate state. Open an event for its payload and signature verification details.

Request
curl -X POST 'https://api.zinfra.vip/v1/earn/subscriptions' \
  -H 'x-auth-token: Bearer $TOKEN' \
  -H 'x-on-behalf-of: $MEMBER_ID' \
  -H 'x-idempotency-key: $IDEMPOTENCY_KEY' \
  -H 'content-type: application/json' \
  -d '{
    "product_id": "ern_7d2b48ae-19c3-4f60-8c55-0ab3e9f21744",
    "amount": "1000.000000",
    "rollover_mode": "principal_interest"
  }'
const res = await fetch("https://api.zinfra.vip/v1/earn/subscriptions", {
  method: "POST",
  headers: {
    "x-auth-token": "Bearer $TOKEN",
    "x-on-behalf-of": "$MEMBER_ID",
    "x-idempotency-key": "$IDEMPOTENCY_KEY",
    "content-type": "application/json",
  },
  body: JSON.stringify({
    "product_id": "ern_7d2b48ae-19c3-4f60-8c55-0ab3e9f21744",
    "amount": "1000.000000",
    "rollover_mode": "principal_interest"
  }),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();
import requests

res = requests.post(
    "https://api.zinfra.vip/v1/earn/subscriptions",
    headers={
        "x-auth-token": "Bearer $TOKEN",
        "x-on-behalf-of": "$MEMBER_ID",
        "x-idempotency-key": "$IDEMPOTENCY_KEY",
        "content-type": "application/json",
    },
    json={
      "product_id": "ern_7d2b48ae-19c3-4f60-8c55-0ab3e9f21744",
      "amount": "1000.000000",
      "rollover_mode": "principal_interest"
    },
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()
req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/earn/subscriptions",
    strings.NewReader(`{
  "product_id": "ern_7d2b48ae-19c3-4f60-8c55-0ab3e9f21744",
  "amount": "1000.000000",
  "rollover_mode": "principal_interest"
}`))
req.Header.Set("x-auth-token", "Bearer $TOKEN")
req.Header.Set("x-on-behalf-of", "$MEMBER_ID")
req.Header.Set("x-idempotency-key", "$IDEMPOTENCY_KEY")
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
// Decode amount fields as string, not float64.
HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create("https://api.zinfra.vip/v1/earn/subscriptions"))
    .header("x-auth-token", "Bearer $TOKEN")
    .header("x-on-behalf-of", "$MEMBER_ID")
    .header("x-idempotency-key", "$IDEMPOTENCY_KEY")
    .header("content-type", "application/json")
    .method("POST", HttpRequest.BodyPublishers.ofString("""
{
  "product_id": "ern_7d2b48ae-19c3-4f60-8c55-0ab3e9f21744",
  "amount": "1000.000000",
  "rollover_mode": "principal_interest"
}
"""))
    .build();
// Use String / BigDecimal for amounts, not double.
$ch = curl_init('https://api.zinfra.vip/v1/earn/subscriptions');
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => 'POST',
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'x-auth-token: Bearer $TOKEN',
    'x-on-behalf-of: $MEMBER_ID',
    'x-idempotency-key: $IDEMPOTENCY_KEY',
    'content-type: application/json',
  ],
  CURLOPT_POSTFIELDS => <<<'JSON'
{
  "product_id": "ern_7d2b48ae-19c3-4f60-8c55-0ab3e9f21744",
  "amount": "1000.000000",
  "rollover_mode": "principal_interest"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
201
{
  "id": "ero_c41d7f92-8a03-4bb6-9e10-5f2c8d3a7061",
  "kind": "fixed",
  "status": "pending_start"
}