# Signup — API reference

Source: https://tenergy.me/docs/api/signup
Last updated: 2026-09-27

Creating an account without a credential yet: the address challenge, its verification,
account creation, and reading the deposit address before the first API key exists.

| Method | Path | Summary |
|---|---|---|
| `POST` | `/v1/accounts/challenge` | Issue a signing challenge for a TRON address |
| `POST` | `/v1/accounts/challenge/verify` | Verify a signed challenge and get a bootstrap token |
| `POST` | `/v1/accounts` | Create an account |
| `GET` | `/v1/accounts/deposit-address` | Deposit address before the first API key exists |

Generated from [`openapi.yaml`](https://tenergy.me/openapi.yaml) at build time. Base URL `https://api.tenergy.me/v1`, or `https://api-nile.tenergy.me/v1` on Nile ([Environments](https://tenergy.me/docs/environments)). Every request below is signed as in [Authentication](https://tenergy.me/docs/authentication) unless its **Auth** line says otherwise.

## Issue a signing challenge for a TRON address

`POST /v1/accounts/challenge` · `createAccountChallenge`

**Auth:** Public — no credentials.

Step 1 of the **address-challenge** signup and recovery model — the platform's one
signup model: ownership of an account is proved by signing a nonce with a TRON
address, off our infrastructure. We never see a private key, and there is nothing
here to phish — the signature proves control of the address and nothing else.

The returned `message` is human-readable and domain-bound (brand, purpose, nonce,
expiry) so that whoever signs it in a wallet can see what they are agreeing to. Sign it
with a TIP-191-style personal-sign; the signature must recover `address`.

Anonymous, rate-limited per IP and per address. Calling it for an address that already
has an account is indistinguishable from calling it for one that does not — this
endpoint is deliberately not an account-existence oracle.

### Request body

JSON (`AccountChallengeRequest`), required.

| Field | Type | Required | Description |
|---|---|---|---|
| `address` | string | yes | Base58Check TRON address (starts with `T`, 34 characters). |
| `purpose` | enum: `signup`, `login`, `recovery` |  | What the signature will be used for; it appears in the signed message. |

Example request body, from the contract (illustrative values):

```json
{"address":"TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE","purpose":"signup"}
```

### Responses

| Status | Meaning |
|---|---|
| `201` | Challenge issued. |
| `400` | Malformed request — bad JSON, unknown field, wrong type. |
| `429` | Too many requests. |
| `500` | Something broke on our side. |

#### Response fields (`AccountChallenge`)

| Field | Type | Required | Description |
|---|---|---|---|
| `nonce` | string | yes | Single-use |
| `address` | string | yes | Base58Check TRON address (starts with `T`, 34 characters). |
| `purpose` | enum: `signup`, `login`, `recovery` |  |  |
| `message` | string | yes | The exact string to sign, TIP-191 personal-sign style. Domain-bound and human-readable, so that a person signing it in a wallet can see what it says. Sign these bytes verbatim — do not re-wrap, trim or re-encode them. |
| `issued_at` | string (date-time) | yes |  |
| `expires_at` | string (date-time) | yes | Ten minutes after issue. An expired nonce gives `1010 challenge_invalid`. |

Example `201` response, from the contract (illustrative values — live numbers come from the API):

```json
{
  "nonce": "9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e",
  "address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
  "purpose": "signup",
  "message": "tenergy.me wants you to prove control of this address.\nPurpose: signup\nAddress: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE\nNonce: 9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e\nExpires: 2026-09-11T18:14:05.123Z\nSigning this creates no transaction and moves no funds.\n",
  "issued_at": "2026-09-11T18:04:05.123Z",
  "expires_at": "2026-09-11T18:14:05.123Z"
}
```

## Verify a signed challenge and get a bootstrap token

`POST /v1/accounts/challenge/verify` · `verifyAccountChallenge`

**Auth:** Public — no credentials.

Step 2. Checks that `signature` recovers `address` over the challenge `message` and
returns a short-lived **bootstrap token** — the credential that carries the caller from
"no account" to "first API key", and the answer to the contract's long-standing gap of
having no way to authenticate the calls that precede a key.

The bootstrap token is sent as `Authorization: Bearer abt_…` and opens exactly four
operations, nothing else: `POST /v1/accounts`, `GET /v1/accounts/deposit-address`,
`GET /v1/account` and `POST /v1/api-keys`. It expires in 15 minutes, is single-account,
and is never a substitute for an API key.

If the address already has an account on this brand, `account_id` and `account_status`
are returned — to this caller only, because only this caller proved the signature.

A nonce is single-use. A reused, expired or wrongly-signed nonce is one error,
`1010 challenge_invalid`, whatever went wrong, so that failures reveal nothing about
which addresses exist.

### Request body

JSON (`AccountChallengeVerifyRequest`), required.

| Field | Type | Required | Description |
|---|---|---|---|
| `address` | string | yes | Base58Check TRON address (starts with `T`, 34 characters). |
| `nonce` | string | yes |  |
| `signature` | string | yes | Hex signature over `message`. Must recover `address`; `0x` prefix optional. |

Example request body, from the contract (illustrative values):

```json
{
  "address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
  "nonce": "9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e",
  "signature": "1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b"
}
```

### Responses

| Status | Meaning |
|---|---|
| `200` | Signature valid. |
| `400` | Malformed request — bad JSON, unknown field, wrong type. |
| `401` | `1010 challenge_invalid` — unknown, expired or already-used nonce, or a signature that does not recover the address. Take a fresh challenge. |
| `429` | Too many requests. |
| `500` | Something broke on our side. |

#### Response fields (`BootstrapToken`)

| Field | Type | Required | Description |
|---|---|---|---|
| `bootstrap_token` | string | yes | Send as `Authorization: Bearer abt_…`. Fifteen-minute lifetime, four permitted operations, no ordering and no spending. Store it no longer than the signup flow. |
| `expires_at` | string (date-time) | yes |  |
| `account_id` | string \| null |  | The account this address already owns, or `null` when there is none yet. |
| `account_status` | enum: `unfunded`, `active`, `suspended`, `closed` \| null |  |  |

Example `200` response, from the contract (illustrative values — live numbers come from the API):

```json
{
  "bootstrap_token": "abt_3f8c2d1e9b7a4c6e8f0a2b4d6e8f0a2b",
  "expires_at": "2026-09-11T18:19:05.123Z",
  "account_id": "acc_01J9Z4K2M7Q8",
  "account_status": "active"
}
```

## Create an account

`POST /v1/accounts` · `createAccount`

**Auth:** Bootstrap token or public.

Step 3. Creates the account owned by the signing address and returns it together with
its deposit address. The account is created in `status: "unfunded"`: it exists, it can
be read, it can receive money and it can issue API keys; it cannot spend until a
deposit confirms.

Authenticate either with the bootstrap token from
`POST /v1/accounts/challenge/verify`, or by passing `nonce` + `signature` in the body
for a one-shot create. Both prove the same thing.

**Idempotent by address.** If the address already owns an account on this brand, the
existing account is returned with HTTP `200` and nothing is created.

`email` is optional and is not verified here — it is a recovery and invoicing channel,
attached later from the dashboard. Email magic-link and Telegram login are additional
*human* logins on the same account object, not a second signup model.

**API keys do not wait for a deposit** (since 2026-09-26). The balance, not
the credential, holds spending back: `POST /v1/orders` answers `4001 insufficient_funds`
until the ledger has a confirmed credit.

### Request body

JSON (`AccountCreateRequest`), required.

| Field | Type | Required | Description |
|---|---|---|---|
| `address` | string | yes | Base58Check TRON address (starts with `T`, 34 characters). |
| `nonce` | string |  |  |
| `signature` | string |  |  |
| `email` | string (email) \| null |  | Optional recovery and invoicing contact. Not verified here and not required; an account with no recovery channel is a support incident waiting to happen, so the dashboard nudges for one later. |
| `label` | string \| null |  |  |

Example request body, from the contract (illustrative values):

```json
{"address":"TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE"}
```

### Responses

| Status | Meaning |
|---|---|
| `200` | The address already owns an account; the existing one is returned. |
| `201` | Account created, unfunded. |
| `400` | Malformed request — bad JSON, unknown field, wrong type. |
| `401` | `1010 challenge_invalid`, or `1011 bootstrap_token_expired`. |
| `422` | Syntactically valid but semantically impossible. |
| `429` | Too many requests. |
| `500` | Something broke on our side. |

#### Response fields (`AccountCreated`)

| Field | Type | Required | Description |
|---|---|---|---|
| `account_id` | string | yes |  |
| `brand` | string | yes |  |
| `network` | enum: `mainnet`, `nile` | yes |  |
| `status` | enum: `unfunded`, `active`, `suspended`, `closed` | yes |  |
| `owner_address` | string | yes | The address whose signature owns this account and can recover it. |
| `deposit_addresses` | array of object (DepositAddress) | yes |  |
| `deposit_addresses[].currency` | enum: `TRX`, `USDT` | yes |  |
| `deposit_addresses[].address` | string | yes | Base58Check TRON address (starts with `T`, 34 characters). |
| `deposit_addresses[].memo` | string \| null |  | Always `null` since 2026-09-26: every account has an address of its own, so no memo is needed and any memo is ignored. Kept so existing clients do not break. |
| `deposit_addresses[].confirmations_required` | integer |  | Blocks waited before the deposit is credited. |
| `deposit_addresses[].contract` | string \| null |  | The TRC-20 contract accepted at this address (`USDT` row only; `null` for TRX). |
| `deposit_addresses[].rate_now` | null \| object |  | `USDT` row only. The current SunSwap (or fixed) TRX-per-USDT rate BEFORE the spread, cached 60 s. `null` when no rate is available right now; the deposit is still accepted and the worker prices it at credit time. Indicative: the credit uses the rate the worker reads when it credits, not this one. |
| `deposit_addresses[].rate_now.trx_per_usdt` | string | yes |  |
| `deposit_addresses[].rate_now.source` | enum: `sunswap_v3`, `fixed_env` | yes |  |
| `deposit_addresses[].rate_now.at` | string (date-time) | yes |  |
| `deposit_addresses[].spread_bps` | integer \| null |  | `USDT` row only: basis points taken off the rate (5 = 0.05 %). |
| `deposit_addresses[].min` | string \| null |  | `USDT` row only: smallest credited USDT deposit; smaller ones are held (`held_below_min`). |
| `deposit_addresses[].max` | string \| null |  | `USDT` row only: largest auto-credited USDT deposit; larger ones are held (`held_for_review`). |
| `next` | object |  | What the caller should do next, machine-readable, because an agent that cannot infer the next step will invent one. |
| `next.action` | string |  |  |
| `next.address` | string |  | Base58Check TRON address (starts with `T`, 34 characters). |
| `next.reason` | string |  |  |
| `created_at` | string (date-time) | yes |  |

## Deposit address before the first API key exists

`GET /v1/accounts/deposit-address` · `getSignupDepositAddress`

**Auth:** Bootstrap token.

The address to fund so that the account becomes `active` and can issue a key. Readable
with the bootstrap token, so an agent that has just created an account can report the
address and the minimum to its user without holding any long-lived credential.

Once a key exists, use `GET /v1/deposit-addresses`, which returns the same addresses.

### Parameters

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `currency` | query | enum: `TRX`, `USDT` |  |  |

### Responses

| Status | Meaning |
|---|---|
| `200` | OK |
| `401` | Missing, malformed or rejected credentials. |
| `429` | Too many requests. |
| `500` | Something broke on our side. |

#### Response fields

| Field | Type | Required | Description |
|---|---|---|---|
| `account_id` | string | yes |  |
| `status` | enum: `unfunded`, `active`, `suspended`, `closed` | yes |  |
| `data` | array of object (DepositAddress) | yes |  |
| `data[].currency` | enum: `TRX`, `USDT` | yes |  |
| `data[].address` | string | yes | Base58Check TRON address (starts with `T`, 34 characters). |
| `data[].memo` | string \| null |  | Always `null` since 2026-09-26: every account has an address of its own, so no memo is needed and any memo is ignored. Kept so existing clients do not break. |
| `data[].confirmations_required` | integer |  | Blocks waited before the deposit is credited. |
| `data[].contract` | string \| null |  | The TRC-20 contract accepted at this address (`USDT` row only; `null` for TRX). |
| `data[].rate_now` | null \| object |  | `USDT` row only. The current SunSwap (or fixed) TRX-per-USDT rate BEFORE the spread, cached 60 s. `null` when no rate is available right now; the deposit is still accepted and the worker prices it at credit time. Indicative: the credit uses the rate the worker reads when it credits, not this one. |
| `data[].rate_now.trx_per_usdt` | string | yes |  |
| `data[].rate_now.source` | enum: `sunswap_v3`, `fixed_env` | yes |  |
| `data[].rate_now.at` | string (date-time) | yes |  |
| `data[].spread_bps` | integer \| null |  | `USDT` row only: basis points taken off the rate (5 = 0.05 %). |
| `data[].min` | string \| null |  | `USDT` row only: smallest credited USDT deposit; smaller ones are held (`held_below_min`). |
| `data[].max` | string \| null |  | `USDT` row only: largest auto-credited USDT deposit; larger ones are held (`held_for_review`). |
| `min_deposit_sun` | integer (int64) |  | Below this a transfer is held as an unclaimed residual rather than credited. The value is a brand parameter. |

Example `200` response, from the contract (illustrative values — live numbers come from the API):

```json
{
  "account_id": "acc_01J9Z4K2M7Q8",
  "status": "unfunded",
  "data": [
    {
      "currency": "TRX",
      "address": "TDepositAddressExample1111111111111",
      "memo": null,
      "confirmations_required": 19
    }
  ],
  "min_deposit_sun": 1000000
}
```
