# Truehost Domains Reseller API

**Reference for integrating domain registration into your own website or software**

| | |
|---|---|
| Version | 1.0 (October 2026) |
| Protocol | HTTPS, REST-style paths |
| Request format | `application/x-www-form-urlencoded` |
| Response format | JSON (one exception: [domain lookup](#post-domainslookup)) |
| Authentication | Username plus an hourly HMAC-SHA256 token |
| Time zone | UTC for all times |
| Standards | ISO 8601 dates, ISO 3166-1 alpha-2 country codes, ITU-T E.164 phone numbers, IDNA 2008 (Punycode) for internationalised names |

> **Who this is for:** developers at a Truehost reseller who want to register,
> transfer, renew and manage domains from their own system, without WHMCS or
> PNLCS. If you use WHMCS or PNLCS, install the Truehost module instead; it
> makes these calls for you.

## Contents

1. [Before you start](#1-before-you-start)
2. [Base URL](#2-base-url)
3. [Authentication](#3-authentication)
4. [Requests](#4-requests)
5. [Responses and errors](#5-responses-and-errors)
6. [Endpoint summary](#6-endpoint-summary)
7. [Account](#7-account)
8. [Availability](#8-availability)
9. [Orders](#9-orders)
10. [Domain status](#10-domain-status)
11. [Nameservers](#11-nameservers)
12. [Child nameservers (glue records)](#12-child-nameservers-glue-records)
13. [Transfer lock and EPP code](#13-transfer-lock-and-epp-code)
14. [Contacts and WHOIS privacy](#14-contacts-and-whois-privacy)
15. [DNS and email forwarding](#15-dns-and-email-forwarding)
16. [Release and deletion](#16-release-and-deletion)
17. [Data formats](#17-data-formats)
18. [Code examples](#18-code-examples)
19. [Recommended integration flow](#19-recommended-integration-flow)
20. [Troubleshooting](#20-troubleshooting)

---

## 1. Before you start

You need:

| Item | Where to get it |
|---|---|
| A **reseller account** on a Truehost site, for example truehost.cloud or truehost.co.ke | Sign up on that site and ask Truehost to switch on domain reselling |
| Your **API username** | The email address you log in to that site with |
| Your **API key** | That site's client area → **Domains Reseller** → **API settings** |
| A **reseller balance** | Top up in the same client area. Every registration, transfer and renewal is paid from it |
| A server with an **accurate clock** | Use NTP. The token changes every hour (see [Authentication](#3-authentication)) |

> ⚠️ **Each Truehost site is separate.** Accounts, API keys, balances and price
> lists are not shared between sites. Always call the site where your
> reseller account was opened, with the key issued by that site.

> 🔒 **Keep the API key on your server.** Never put it in a browser, mobile
> app or public repository. Anyone with the key can spend your balance.

## 2. Base URL

```
https://{site}/cloud/modules/addons/DomainsReseller/api/index.php
```

`{site}` is the Truehost site of your reseller account. Examples:

| Site | Base URL |
|---|---|
| truehost.cloud | `https://truehost.cloud/cloud/modules/addons/DomainsReseller/api/index.php` |
| truehost.co.ke | `https://truehost.co.ke/cloud/modules/addons/DomainsReseller/api/index.php` |

Every endpoint is appended to the base URL with a `/`. For example, the
balance endpoint on truehost.cloud is:

```
https://truehost.cloud/cloud/modules/addons/DomainsReseller/api/index.php/billing/credits
```

In this document, `{BASE}` means the base URL and `{domain}` means a domain
name such as `example.co.ke`.

## 3. Authentication

Send these two HTTP headers with **every** request:

| Header | Value |
|---|---|
| `username` | Your API username (login email) |
| `token` | The hourly token described below |

### Building the token

```
hour  = current UTC time formatted as "yy-mm-dd HH"     e.g. "26-10-26 14"
key   = username + ":" + hour                           e.g. "reseller@example.com:26-10-26 14"
hex   = HMAC-SHA256(message = API key, key = key), as lowercase hexadecimal
token = Base64(hex)
```

Note the details that are easy to get wrong:

- The **API key is the message** and `username:hour` is the **HMAC key**.
- The hour uses a **two-digit year**, a **space**, and the **24-hour clock** in **UTC**.
- Base64 is applied to the **64-character hex string**, not to the raw 32-byte digest.

### Test vector

Check your implementation against these values (sample credentials, not real ones):

| Input | Value |
|---|---|
| username | `reseller@example.com` |
| API key | `EXAMPLE-API-KEY-123` |
| UTC hour | `26-10-26 14` (14:00–14:59 UTC on 26 October 2026) |
| hex | `c57336cac850d7b04d27cf524932d3804fa2f2fdb8a8c54f34341fb99843bbdf` |
| **token** | `YzU3MzM2Y2FjODUwZDdiMDRkMjdjZjUyNDkzMmQzODA0ZmEyZjJmZGI4YThjNTRmMzQzNDFmYjk5ODQzYmJkZg==` |

Generate a fresh token for each request, or at least each hour. A server clock
that is wrong by an hour or more makes every request fail with *token is invalid*.

## 4. Requests

| Rule | Detail |
|---|---|
| Methods | `GET` to read, `POST` to change or order |
| `GET` | No body and no query parameters. Everything is in the path |
| `POST` | Body encoded as `application/x-www-form-urlencoded` |
| Lists and objects | PHP bracket notation: `nameservers[0]=ns1.example.com`, `contacts[registrant][firstname]=Jane` |
| Domain in the path | Lowercase ASCII. Internationalised names in Punycode (`xn--…`). URL-encode the value |
| Domain in the body | Domain endpoints that take a body also expect `domain`, with the same value as in the path |
| Redirects | Do not follow redirects. A redirect turns a `POST` into a `GET` |
| Timeouts | Allow at least 60 seconds for orders. Registries can be slow |
| Character set | UTF-8 |

Example of a form body with nested values:

```
domain=example.co.ke&regperiod=1&nameservers%5B0%5D=ns1.example.com&nameservers%5B1%5D=ns2.example.com&addons%5Bidprotection%5D=1
```

## 5. Responses and errors

Responses are JSON. Treat a response as **failed** if any of these is true:

1. The HTTP status is 400 or higher.
2. The body has a non-empty `error` field: `{"error": "Domain name is invalid"}`.
3. The body has `"result": "error"` (the reason is in `message`).
4. The body has `"success": false` (the reason is in `message`).
5. The body is empty when you expected data, or is not valid JSON (an HTML page means a proxy or server error).

Otherwise the call succeeded. A successful change usually answers
`{"success": true}`; a successful read answers the data.

> ⚠️ Do not treat "no error field" alone as success. A server error page has
> no `error` field either. Apply all five rules.

### Common error messages

| HTTP | Message contains | Meaning | What to do |
|---|---|---|---|
| 401 | `Username has not been provided` | The `username` header is missing | Send both headers |
| 401 | `username is invalid` | This site has no reseller account with that username | Use the site where your account was opened, and your login email |
| 401 | `token is invalid` | Wrong API key, key from another site, or clock off | Copy the key again from this site; check the server clock (UTC) |
| — | `Action not found` | The endpoint path is wrong | Check the path and method |
| — | `not been found in reseller account` | The domain is not in your reseller account | Check the spelling, or register or transfer it first |
| — | `Provided domain period is unavailable for sale` | The extension is not in your price list, or not for that number of years | Check [`GET tlds/pricing`](#get-tldspricing) |
| — | balance or credit messages | Not enough balance | Top up, then retry |

Some error messages include the IP address the API sees (`Your IP is: …`).
If your API settings restrict access by IP address, that IP must be allowed.

## 6. Endpoint summary

| Method | Endpoint | Purpose |
|---|---|---|
| GET | `/billing/credits` | Reseller balance |
| GET | `/tlds/pricing` | Your price list per extension |
| POST | `/domains/lookup` | Check availability |
| POST | `/order/domains/register` | Register a domain |
| POST | `/order/domains/transfer` | Transfer a domain in |
| POST | `/order/domains/renew` | Renew a domain |
| POST | `/domains/{domain}/sync` | Expiry date and status |
| POST | `/domains/{domain}/transfersync` | Transfer progress |
| GET | `/domains/{domain}/nameservers` | Read nameservers |
| POST | `/domains/{domain}/nameservers` | Change nameservers |
| POST | `/domains/{domain}/nameservers/register` | Create a child nameserver |
| POST | `/domains/{domain}/nameservers/modify` | Change a child nameserver's IP |
| POST | `/domains/{domain}/nameservers/delete` | Delete a child nameserver |
| GET | `/domains/{domain}/lock` | Read the transfer lock |
| POST | `/domains/{domain}/lock` | Set the transfer lock |
| GET | `/domains/{domain}/eppcode` | Get the EPP (auth) code |
| GET | `/domains/{domain}/contact` | Read WHOIS contacts |
| POST | `/domains/{domain}/contact` | Change WHOIS contacts |
| POST | `/domains/{domain}/protectid` | WHOIS privacy on or off |
| GET | `/domains/{domain}/dns` | Read DNS records (DNS management add-on) |
| POST | `/domains/{domain}/dns` | Save DNS records |
| GET | `/domains/{domain}/email` | Read email forwarding |
| POST | `/domains/{domain}/email` | Save email forwarding |
| POST | `/domains/{domain}/release` | Release to another registrar by tag (.uk and similar) |
| POST | `/domains/{domain}/delete` | Request deletion |

> The response examples below show the fields you should read. Responses can
> contain more fields than shown; ignore the ones you don't need.

---

## 7. Account

### GET /billing/credits

Your reseller balance, in your account's currency on that site. Use it as a
connection test: it changes nothing.

**Request**

```
GET {BASE}/billing/credits
```

**Response**

```json
{ "credits": "1520.00" }
```

The amount can also arrive as `credit` or `balance`, or as a bare number.
Read it as a decimal.

### GET /tlds/pricing

The extensions you can sell, with your cost for each, from your reseller
price list.

**Request**

```
GET {BASE}/tlds/pricing
```

**Response**: a list, one entry per extension and period:

```json
[
  {
    "tld": ".co.ke",
    "years": [1, 2, 3],
    "registrationPrice": "999.00",
    "transferPrice": "999.00",
    "renewalPrice": "999.00"
  }
]
```

| Field | Meaning |
|---|---|
| `tld` | Extension, with the leading dot |
| `years` | Registration periods sold, in years |
| `registrationPrice` / `transferPrice` / `renewalPrice` | Your cost. A negative value means not sold |

Cache this list (for example daily) rather than calling it on every order.

---

## 8. Availability

### POST /domains/lookup

Checks one name against one or more extensions in a single call.

**Request**

```
POST {BASE}/domains/lookup
```

| Field | Type | Required | Description |
|---|---|---|---|
| `searchTerm` | string | yes | The name without extension: `mybrand` |
| `punyCodeSearchTerm` | string | yes | The same name in Punycode. Equal to `searchTerm` for ASCII names |
| `tldsToInclude[]` | list of strings | yes | Extensions with the leading dot: `tldsToInclude[0]=.com&tldsToInclude[1]=.co.ke` |
| `isIdnDomain` | `0` or `1` | yes | `1` when the name has non-ASCII characters |
| `premiumEnabled` | `0` or `1` | yes | Send `0` |
| `isWhmcs` | `0` or `1` | yes | Send `1` (see the format note below) |

**Response**

> ⚠️ **Not JSON.** This endpoint answers in **PHP serialisation format**: a
> list of result objects. In PHP, read it with
> `unserialize($body, ['allowed_classes' => false])`. Never allow classes:
> that would let the response create objects in your application. In other
> languages, use a PHP-serialisation parser (for example `phpserialize` in
> Python or `php-serialize` in Node.js). Some installations wrap the
> serialised string in a JSON string; decode the JSON first in that case.

Each result object contains, among other fields:

| Field | Meaning |
|---|---|
| `domainName` | The full name checked: `mybrand.co.ke` (or `sld` plus `tld`) |
| `isAvailable` | `true` if it can be registered |
| `isRegistered` | `true` if it is taken, including names already registered through Truehost |
| `legacyStatus` / `status` | Text status. `error` or `unknown` means the registry did not answer |

How to read a result:

1. `legacyStatus` is `error` or `status` is `unknown` → **unknown**. Do not
   tell your customer the name is taken: retry or check WHOIS.
2. `isRegistered` is `true` → **taken**.
3. Otherwise use `isAvailable`.

Private field names in the serialised data have a prefix (`\0*\0domainName`).
Strip it before reading.

---

## 9. Orders

Orders are charged to your reseller balance at your price-list cost. Check the
balance and price list before ordering, and confirm the result afterwards
(see [Recommended integration flow](#19-recommended-integration-flow)).

### POST /order/domains/register

**Request**

```
POST {BASE}/order/domains/register
```

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | Full domain name: `mybrand.co.ke` |
| `regperiod` | integer | yes | Years, one of the periods in your price list |
| `nameservers[0]` … `nameservers[4]` | string | yes | Up to five nameservers. Send at least two; send empty strings for unused positions |
| `contacts[registrant][…]` | object | yes | The registrant. Fields in [Contact object](#contact-object-for-orders) |
| `addons[dnsmanagement]` | `0` or `1` | yes | DNS management add-on |
| `addons[emailforwarding]` | `0` or `1` | yes | Email forwarding add-on |
| `addons[idprotection]` | `0` or `1` | yes | WHOIS privacy add-on |
| `domainfields` | string | yes | Extra registry fields: Base64 of a **PHP-serialised** array. With no extra fields send `YTowOnt9` (an empty array) |

Add-ons may be charged separately according to your price list.

**Example body** (shown decoded, one field per line for reading):

```
domain=mybrand.co.ke
regperiod=1
domainfields=YTowOnt9
addons[dnsmanagement]=0
addons[emailforwarding]=0
addons[idprotection]=1
nameservers[0]=ns1.cloudoon.com
nameservers[1]=ns2.cloudoon.net
nameservers[2]=ns3.cloudoon.org
nameservers[3]=
nameservers[4]=
contacts[registrant][firstname]=Jane
contacts[registrant][lastname]=Wanjiru
contacts[registrant][companyname]=Mybrand Ltd
contacts[registrant][email]=jane@mybrand.co.ke
contacts[registrant][address1]=1 Kenyatta Avenue
contacts[registrant][address2]=
contacts[registrant][city]=Nairobi
contacts[registrant][state]=Nairobi
contacts[registrant][postcode]=00100
contacts[registrant][country]=KE
contacts[registrant][phonenumber]=712345678
contacts[registrant][phonecc]=254
contacts[registrant][fullphonenumber]=+254.712345678
contacts[registrant][tax_id]=
```

**Response**

```json
{ "success": true }
```

> ⚠️ **Confirm every registration.** After a successful answer, call
> [`POST /domains/{domain}/sync`](#post-domainsdomainsync). Only treat the
> domain as registered when it answers without *not been found in reseller
> account*. Then read the nameservers back with
> [`GET /domains/{domain}/nameservers`](#get-domainsdomainnameservers), and
> send them again if none are set.

### POST /order/domains/transfer

Same fields as **register**, plus:

| Field | Type | Required | Description |
|---|---|---|---|
| `eppcode` | string | yes | The authorisation (EPP) code from the current registrar |

`regperiod` is usually `1`: most registries add one year on transfer.

**Response**: `{"success": true}` means the transfer **started**. Follow it
with [`POST /domains/{domain}/transfersync`](#post-domainsdomaintransfersync)
until it completes or fails. Transfers usually take five to seven days.

### POST /order/domains/renew

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | Full domain name |
| `regperiod` | integer | yes | Years to add |
| `addons[dnsmanagement]` | `0` or `1` | yes | Keep the DNS management add-on |
| `addons[emailforwarding]` | `0` or `1` | yes | Keep the email forwarding add-on |
| `addons[idprotection]` | `0` or `1` | yes | Keep WHOIS privacy |

**Response**

```json
{ "success": true, "expirydate": "2028-10-26" }
```

If `expirydate` is present, store it. Otherwise read the new date with
[`sync`](#post-domainsdomainsync).

---

## 10. Domain status

### POST /domains/{domain}/sync

The domain's current expiry date and status at the registry. Use it to
confirm registrations and in a daily synchronisation job. It changes nothing.

| Field | Type | Required |
|---|---|---|
| `domain` | string | yes |

**Response**

```json
{
  "active": true,
  "expired": false,
  "transferredAway": false,
  "expirydate": "2027-10-26"
}
```

| Field | Meaning |
|---|---|
| `active` | Registered and in force |
| `expired` | Past its expiry date |
| `transferredAway` | Moved to another registrar |
| `expirydate` | Expiry date, `YYYY-MM-DD`. Ignore `0000-00-00` |

An error containing *not been found in reseller account* means the domain is
not in your account.

### POST /domains/{domain}/transfersync

Progress of a transfer started with `order/domains/transfer`.

| Field | Type | Required |
|---|---|---|
| `domain` | string | yes |

**Response**

```json
{ "completed": true, "expirydate": "2027-10-26" }
```

| Field | Meaning |
|---|---|
| `completed` | The transfer finished; the domain is now active in your account |
| `failed` | The transfer failed; the reason is in `reason` |
| `expirydate` | New expiry date, when completed |

Neither `completed` nor `failed` means the transfer is **still in progress**.
Check again later, for example daily.

---

## 11. Nameservers

### GET /domains/{domain}/nameservers

**Request**

```
GET {BASE}/domains/{domain}/nameservers
```

**Response**

```json
{ "ns1": "ns1.cloudoon.com", "ns2": "ns2.cloudoon.net", "ns3": "ns3.cloudoon.org", "ns4": "", "ns5": "" }
```

Read `ns1` to `ns5` and ignore empty values. To be robust, also accept the
values wrapped in `nameservers` or `data`, or as a plain list.

### POST /domains/{domain}/nameservers

Replaces all nameservers.

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | |
| `ns1` … `ns5` | string | `ns1`, `ns2` yes | Host names. Send empty strings for unused positions |

**Response**: `{"success": true}`

Truehost's default nameservers are `ns1.cloudoon.com`, `ns2.cloudoon.net` and
`ns3.cloudoon.org`.

---

## 12. Child nameservers (glue records)

Child nameservers are host names under the domain itself, such as
`ns1.mybrand.co.ke`, registered with an IP address at the registry. They let
you offer branded nameservers.

> The API has no endpoint that **lists** child nameservers. Store the ones
> you create, with their IP addresses, in your own system.

### POST /domains/{domain}/nameservers/register

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | The parent domain: `mybrand.co.ke` |
| `nameserver` | string | yes | Full host name: `ns1.mybrand.co.ke` |
| `ipaddress` | string | yes | One IPv4 address |

### POST /domains/{domain}/nameservers/modify

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | |
| `nameserver` | string | yes | Full host name |
| `currentipaddress` | string | yes | The IP address registered now |
| `newipaddress` | string | yes | The new IP address |

### POST /domains/{domain}/nameservers/delete

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | |
| `nameserver` | string | yes | Full host name |

**Response** (all three): `{"success": true}`

---

## 13. Transfer lock and EPP code

### GET /domains/{domain}/lock

**Response**: the lock status, either as a bare JSON string or in a field:

```json
"locked"
```

```json
{ "lockstatus": "locked" }
```

`unlocked` (or `0`, `false`, `off`) means unlocked; anything else means
locked. If the call fails, assume **locked**.

### POST /domains/{domain}/lock

| Field | Type | Required | Values |
|---|---|---|---|
| `domain` | string | yes | |
| `lockstatus` | string | yes | `locked` or `unlocked` |

**Response**: `{"success": true}`

### GET /domains/{domain}/eppcode

**Response**

```json
{ "eppcode": "aB3#kL9$xQ" }
```

Some registries do not return the code and email it to the registrant
instead. In that case the call succeeds with no `eppcode`: tell your customer
to check the registrant's email.

> 🔒 The EPP code allows the domain to be transferred away. Show it only to the
> domain's owner, over HTTPS, and never log it.

---

## 14. Contacts and WHOIS privacy

### GET /domains/{domain}/contact

**Response**: one object per contact role. Field names use underscores:

```json
{
  "Registrant": {
    "First_Name": "Jane",
    "Last_Name": "Wanjiru",
    "Company_Name": "Mybrand Ltd",
    "Email_Address": "jane@mybrand.co.ke",
    "Address_1": "1 Kenyatta Avenue",
    "City": "Nairobi",
    "State": "Nairobi",
    "Postcode": "00100",
    "Country": "KE",
    "Phone_Number": "+254.712345678"
  },
  "Admin": { "…": "…" },
  "Technical": { "…": "…" },
  "Billing": { "…": "…" }
}
```

Roles and fields vary by registry. Some registries give the name whole
(`Contact_Name`), and some add their own fields (for example an ID number for
.ke). The registrant role may be called `Registrant` or `Owner`.

### POST /domains/{domain}/contact

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | |
| `contactdetails[{Role}][{Field Name}]` | string | yes | Every role and field, with **spaces instead of underscores** in field names |

Example (decoded):

```
domain=mybrand.co.ke
contactdetails[Registrant][First Name]=Jane
contactdetails[Registrant][Last Name]=Wanjiru
contactdetails[Registrant][Email Address]=jane@mybrand.co.ke
contactdetails[Registrant][Phone Number]=+254.712345678
contactdetails[Registrant][Country]=KE
…
```

> 💡 **Read, change, send back.** Call `GET …/contact` first, change the values
> you need, and send back **every role and field you received**, under the
> same names (underscores replaced by spaces). This keeps registry-specific
> fields that you don't edit. If the read fails, send the four roles
> `Registrant`, `Admin`, `Technical` and `Billing` with the fields
> `First Name`, `Last Name`, `Company Name`, `Email Address`, `Address 1`,
> `Address 2`, `City`, `State`, `Postcode`, `Country`, `Phone Number`.

**Response**: `{"success": true}`

### POST /domains/{domain}/protectid

| Field | Type | Required | Values |
|---|---|---|---|
| `domain` | string | yes | |
| `status` | integer | yes | `1` on, `0` off |

**Response**: `{"success": true}`. Not every extension supports WHOIS privacy;
the error message says so when it is not available.

---

## 15. DNS and email forwarding

These work only when the domain has the matching add-on (`dnsmanagement` or
`emailforwarding`) and uses Truehost's nameservers.

### GET /domains/{domain}/dns

**Response**

```json
[
  { "hostname": "@", "type": "A", "address": "203.0.113.10", "priority": "" },
  { "hostname": "www", "type": "CNAME", "address": "mybrand.co.ke", "priority": "" },
  { "hostname": "@", "type": "MX", "address": "mail.mybrand.co.ke", "priority": "10" }
]
```

### POST /domains/{domain}/dns

Replaces **all** records: send the complete list.

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | |
| `dnsrecords[n][hostname]` | string | yes | `@` for the domain itself, or a sub-name |
| `dnsrecords[n][type]` | string | yes | `A`, `AAAA`, `CNAME`, `MX`, `TXT`, and so on |
| `dnsrecords[n][address]` | string | yes | The value |
| `dnsrecords[n][priority]` | string | MX only | MX priority |

### GET /domains/{domain}/email

**Response**

```json
[ { "prefix": "info", "forwardto": "jane@gmail.com" } ]
```

### POST /domains/{domain}/email

Replaces all forwarders. The two lists are matched by position.

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | |
| `prefix[n]` | string | yes | The part before `@`: `info` |
| `forwardto[n]` | string | yes | The destination address |

**Response** (both POSTs): `{"success": true}`

---

## 16. Release and deletion

> ⚠️ Both actions are hard or impossible to undo. Ask your customer to
> confirm first.

### POST /domains/{domain}/release

Moves the domain to another registrar by **transfer tag** (IPS tag), used by
.uk and similar registries.

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | |
| `transfertag` | string | yes | The receiving registrar's tag |

### POST /domains/{domain}/delete

Asks the registry to delete the domain.

| Field | Type | Required |
|---|---|---|
| `domain` | string | yes |

**Response** (both): `{"success": true}`

---

## 17. Data formats

### Contact object for orders

Used as `contacts[registrant][…]` in register and transfer.

| Field | Required | Format | Example |
|---|---|---|---|
| `firstname` | yes | Text | `Jane` |
| `lastname` | yes | Text | `Wanjiru` |
| `companyname` | no | Text; empty for individuals | `Mybrand Ltd` |
| `email` | yes | Email address | `jane@mybrand.co.ke` |
| `address1` | yes | Street address | `1 Kenyatta Avenue` |
| `address2` | no | Text | |
| `city` | yes | Text | `Nairobi` |
| `state` | yes | Region or county | `Nairobi` |
| `postcode` | yes | Postal code | `00100` |
| `country` | yes | ISO 3166-1 alpha-2, uppercase | `KE` |
| `phonenumber` | yes | National number, digits only, no leading zero | `712345678` |
| `phonecc` | yes | Country calling code, digits only, no `+` | `254` |
| `fullphonenumber` | yes | `+{phonecc}.{phonenumber}` | `+254.712345678` |
| `tax_id` | no | VAT or tax number | |

### Phone numbers

Registries use the EPP form of E.164: `+` country code, a dot, then the
national number without its trunk zero.

| Customer typed | `phonecc` | `phonenumber` | `fullphonenumber` |
|---|---|---|---|
| `0712 345 678` (Kenya) | `254` | `712345678` | `+254.712345678` |
| `+234 803 123 4567` (Nigeria) | `234` | `8031234567` | `+234.8031234567` |
| `+27 82 123 4567` (South Africa) | `27` | `821234567` | `+27.821234567` |

### Other formats

| Data | Format |
|---|---|
| Dates | ISO 8601 calendar date `YYYY-MM-DD`. Treat `0000-00-00` as unknown |
| Domain names | Lowercase ASCII; internationalised names in Punycode, e.g. `xn--mnchen-3ya.de` |
| Extensions | With the leading dot: `.co.ke` |
| Money | Decimal string in your reseller account's currency, e.g. `"999.00"` |
| Booleans | `true`/`false` in responses; `1`/`0` in requests |
| Empty `domainfields` | `YTowOnt9` (Base64 of the PHP-serialised empty array `a:0:{}`) |

---

## 18. Code examples

Replace the placeholder credentials with your own, loaded from environment
variables or a secrets store, never written in code.

### cURL (Bash)

```bash
SITE="truehost.cloud"
BASE="https://$SITE/cloud/modules/addons/DomainsReseller/api/index.php"
TH_USER="$TRUEHOST_USERNAME"   # not $USERNAME: zsh reserves that name
TH_KEY="$TRUEHOST_API_KEY"

HOUR=$(date -u +"%y-%m-%d %H")
TOKEN=$(printf '%s' "$TH_KEY" \
  | openssl dgst -sha256 -hmac "$TH_USER:$HOUR" -hex \
  | sed 's/^.*= //' | tr -d '\n' | base64 -w0)

# Balance
curl -s "$BASE/billing/credits" -H "username: $TH_USER" -H "token: $TOKEN"

# Change nameservers
curl -s -X POST "$BASE/domains/mybrand.co.ke/nameservers" \
  -H "username: $TH_USER" -H "token: $TOKEN" \
  --data-urlencode "domain=mybrand.co.ke" \
  --data-urlencode "ns1=ns1.cloudoon.com" \
  --data-urlencode "ns2=ns2.cloudoon.net" \
  --data-urlencode "ns3=" --data-urlencode "ns4=" --data-urlencode "ns5="
```

On macOS use `base64` without `-w0`.

### PHP

```php
<?php

final class TruehostApi
{
    public function __construct(
        private string $site,      // e.g. "truehost.cloud"
        private string $username,
        private string $apiKey,
    ) {}

    private function base(): string
    {
        return "https://{$this->site}/cloud/modules/addons/DomainsReseller/api/index.php";
    }

    private function token(): string
    {
        $hour = gmdate('y-m-d H');

        return base64_encode(hash_hmac('sha256', $this->apiKey, "{$this->username}:{$hour}"));
    }

    /** @return array{ok: bool, data: mixed, error: ?string, raw: string} */
    public function call(string $method, string $path, array $body = []): array
    {
        $ch = curl_init($this->base().'/'.ltrim($path, '/'));
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_TIMEOUT => 60,
            CURLOPT_HTTPHEADER => [
                'username: '.$this->username,
                'token: '.$this->token(),
            ],
        ]);
        if ($method === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($body)); // bracket notation
        }

        $raw = (string) curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($curlError !== '') {
            return ['ok' => false, 'data' => null, 'error' => $curlError, 'raw' => ''];
        }

        $data = json_decode($raw, true);
        $error = match (true) {
            is_array($data) && ! empty($data['error']) => (string) $data['error'],
            is_array($data) && strtolower((string) ($data['result'] ?? '')) === 'error' => (string) ($data['message'] ?? 'Error'),
            is_array($data) && ($data['success'] ?? null) === false => (string) ($data['message'] ?? 'Failed'),
            $status >= 400 => "HTTP {$status}",
            json_last_error() !== JSON_ERROR_NONE && $path !== 'domains/lookup' => 'Response is not JSON',
            default => null,
        };

        return ['ok' => $error === null, 'data' => $data, 'error' => $error, 'raw' => $raw];
    }
}

$api = new TruehostApi('truehost.cloud', getenv('TRUEHOST_USERNAME'), getenv('TRUEHOST_API_KEY'));

// Availability (serialised PHP response)
$r = $api->call('POST', 'domains/lookup', [
    'searchTerm' => 'mybrand',
    'punyCodeSearchTerm' => 'mybrand',
    'tldsToInclude' => ['.com', '.co.ke'],
    'isIdnDomain' => 0,
    'premiumEnabled' => 0,
    'isWhmcs' => 1,
]);
$results = @unserialize($r['raw'], ['allowed_classes' => false]);

// Registration
$r = $api->call('POST', 'order/domains/register', [
    'domain' => 'mybrand.co.ke',
    'regperiod' => 1,
    'domainfields' => base64_encode(serialize([])),
    'addons' => ['dnsmanagement' => 0, 'emailforwarding' => 0, 'idprotection' => 0],
    'nameservers' => ['ns1.cloudoon.com', 'ns2.cloudoon.net', 'ns3.cloudoon.org', '', ''],
    'contacts' => ['registrant' => [
        'firstname' => 'Jane', 'lastname' => 'Wanjiru', 'companyname' => '',
        'email' => 'jane@mybrand.co.ke', 'address1' => '1 Kenyatta Avenue', 'address2' => '',
        'city' => 'Nairobi', 'state' => 'Nairobi', 'postcode' => '00100', 'country' => 'KE',
        'phonenumber' => '712345678', 'phonecc' => '254', 'fullphonenumber' => '+254.712345678',
        'tax_id' => '',
    ]],
]);
```

### Python

```python
import base64, hashlib, hmac, os
from datetime import datetime, timezone
from urllib.parse import quote
import requests  # pip install requests

SITE = "truehost.cloud"
BASE = f"https://{SITE}/cloud/modules/addons/DomainsReseller/api/index.php"
USERNAME = os.environ["TRUEHOST_USERNAME"]
API_KEY = os.environ["TRUEHOST_API_KEY"]


def token() -> str:
    hour = datetime.now(timezone.utc).strftime("%y-%m-%d %H")
    hexdigest = hmac.new(f"{USERNAME}:{hour}".encode(), API_KEY.encode(), hashlib.sha256).hexdigest()
    return base64.b64encode(hexdigest.encode()).decode()


def flatten(data, prefix=""):
    """PHP bracket notation: {"a": {"b": 1}} -> [("a[b]", 1)]."""
    pairs = []
    items = data.items() if isinstance(data, dict) else enumerate(data)
    for key, value in items:
        name = f"{prefix}[{key}]" if prefix else str(key)
        if isinstance(value, (dict, list)):
            pairs += flatten(value, name)
        else:
            pairs.append((name, value))
    return pairs


def call(method: str, path: str, body: dict | None = None):
    response = requests.request(
        method,
        f"{BASE}/{path}",
        headers={"username": USERNAME, "token": token()},
        data=flatten(body) if body else None,
        timeout=60,
        allow_redirects=False,
    )
    try:
        data = response.json()
    except ValueError:
        data = None
    if isinstance(data, dict):
        if data.get("error"):
            raise RuntimeError(data["error"])
        if str(data.get("result", "")).lower() == "error" or data.get("success") is False:
            raise RuntimeError(data.get("message", "Truehost reported a failure"))
    if response.status_code >= 400:
        raise RuntimeError(f"HTTP {response.status_code}")
    return data


print(call("GET", "billing/credits"))
domain = "mybrand.co.ke"
print(call("POST", f"domains/{quote(domain)}/sync", {"domain": domain}))
```

### Node.js (18 or later)

```javascript
import crypto from "node:crypto";

const SITE = "truehost.cloud";
const BASE = `https://${SITE}/cloud/modules/addons/DomainsReseller/api/index.php`;
const USERNAME = process.env.TRUEHOST_USERNAME;
const API_KEY = process.env.TRUEHOST_API_KEY;

function token() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, "0");
  const hour = `${p(d.getUTCFullYear() % 100)}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}`;
  const hex = crypto.createHmac("sha256", `${USERNAME}:${hour}`).update(API_KEY).digest("hex");
  return Buffer.from(hex).toString("base64");
}

function flatten(data, prefix = "", out = new URLSearchParams()) {
  for (const [key, value] of Object.entries(data)) {
    const name = prefix ? `${prefix}[${key}]` : key;
    if (value !== null && typeof value === "object") flatten(value, name, out);
    else out.append(name, value ?? "");
  }
  return out;
}

async function call(method, path, body) {
  const res = await fetch(`${BASE}/${path}`, {
    method,
    redirect: "manual",
    headers: { username: USERNAME, token: token() },
    body: body ? flatten(body) : undefined,
  });
  const text = await res.text();
  let data = null;
  try { data = JSON.parse(text); } catch {}
  if (data && typeof data === "object") {
    if (data.error) throw new Error(data.error);
    if (String(data.result ?? "").toLowerCase() === "error" || data.success === false) {
      throw new Error(data.message ?? "Truehost reported a failure");
    }
  }
  if (res.status >= 400) throw new Error(`HTTP ${res.status}`);
  return data;
}

console.log(await call("GET", "billing/credits"));
```

---

## 19. Recommended integration flow

### Selling a domain

1. **Search**: `POST /domains/lookup`. Show "unknown" results as "try again", never as "taken".
2. **Price**: charge your customer your own selling price. Keep it above your cost from `GET /tlds/pricing`.
3. **Take payment** from your customer.
4. **Check balance**: `GET /billing/credits` covers the cost.
5. **Register**: `POST /order/domains/register`.
6. **Confirm**: `POST /domains/{domain}/sync`. Mark the domain active only if it is in your account.
7. **Nameservers**: `GET /domains/{domain}/nameservers`. If empty, `POST` the ordered ones.
8. **Store** the expiry date from `sync`.

If step 5 or 6 fails, keep the order **pending**, show the error to your
staff, and retry once the cause (usually balance) is fixed. Do not register
the same domain twice: run step 6 before any retry, because the first attempt
may have succeeded.

### Daily jobs

| Job | Calls |
|---|---|
| Synchronise every domain | `POST /domains/{domain}/sync` (expiry, status) and `GET …/nameservers` |
| Follow transfers | `POST /domains/{domain}/transfersync` for each pending transfer |
| Watch the balance | `GET /billing/credits`; alert below a threshold |
| Renew | Invoice your customers before expiry; call `POST /order/domains/renew` once paid |

Spread the sync calls over time (for example one per second) rather than
sending hundreds at once.

### Good practice

- **Log** every request and response, without the `token` header, API key or EPP codes.
- **Retry** only on network errors and HTTP 5xx, with backoff. Never retry an order blindly: check with `sync` first.
- **Time out** at 60 seconds or more for orders.
- **Keep your clock** synchronised with NTP.

---

## 20. Troubleshooting

| Problem | Cause and fix |
|---|---|
| Every call fails with `token is invalid` | Wrong key, key from another Truehost site, or server clock off by an hour or more. Check against the [test vector](#test-vector) |
| `username is invalid` | The account is on another Truehost site. Use that site's base URL |
| An HTML page instead of JSON | Wrong base URL, or a server or proxy error. Check the URL includes `/cloud/modules/addons/DomainsReseller/api/index.php` |
| `Action not found` | Wrong path or method. Compare with the [endpoint summary](#6-endpoint-summary) |
| A `POST` behaves like a `GET` | Your HTTP client followed a redirect. Turn redirects off and call the exact base URL |
| Lookup results can't be read | The response is PHP-serialised, not JSON. See [lookup](#post-domainslookup) |
| `unavailable for sale` | The extension or period isn't in your price list. Ask Truehost to add it |
| Registration accepted but domain missing | Confirm with `sync`; check the balance; contact Truehost support with the time of the request |
| Contact changes lose fields | Send back every role and field from `GET …/contact`, not only the ones you changed |

For account, pricing or balance questions, contact Truehost support through
the client area of your Truehost site.
