---
title: Truehost Domains Reseller API
description: "**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 | |…"
url: "https://truehost.com/support/knowledge-base/truehost-domains-reseller-api/"
updated: "2026-11-03"
category: Guides / Truehost Reseller Panel
---

# 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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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 |

This is for: developers at a Truehost reseller who want to register, transfer, renew and manage domains from their own system, without WHMCS or the Truehost Reseller Panel. If you use either, use its Truehost module instead; it makes these calls for you, and the panel's module also keeps your extension prices in step with your price list.

## **Contents**

## Table of Contents

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

{ "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 currency**:

[

  {

    "tld": ".co.ke",

    "years": [1],

    "registrationPrice": "999.00",

    "transferPrice": "1000.00",

    "renewalPrice": "1500.00",

    "currencyCode": "KES"

  },

  {

    "tld": ".co.ke",

    "years": [1],

    "registrationPrice": "7.69",

    "transferPrice": "7.70",

    "renewalPrice": "9.24",

    "currencyCode": "USD"

  }

]

| **Field** | **Meaning** |
| --- | --- |
| tld | Extension, with the leading dot |
| years | Registration periods sold, in years |
| registrationPrice / transferPrice / renewalPrice | Your cost, per year. A negative value means not sold |
| currencyCode | The currency of the prices in this entry |
| graceDays / graceFee, redemptionDays / redemptionFee | Also sent: the grace and redemption periods after expiry, and their fees |

**The same extension appears once per currency.** truehost.co.ke, for example, lists every extension in KES, USD and NGN (about 520 extensions, so some 1,560 entries). Keep only the entries in **your account's currency**, the one your balance is charged in: reading the list by tld alone leaves you with whichever currency came last, which on truehost.co.ke is naira.

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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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**

{ "success": true }

**Confirm every registration.** After a successful answer, call[POST /domains/{domain}/sync](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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**

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

If expirydate is present, store it. Otherwise read the new date with[sync](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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**

{

  "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**

{ "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**

{ "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:

"locked"

{ "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**

{ "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:

{

  "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]=chelsie

contactdetails[Registrant][Last Name]=Kalya

contactdetails[Registrant][Email Address]=chelsiekalya@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.

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

## 15. DNS and email forwarding

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

**Response**

[

  { "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**

[ { "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)**

SITE="truehost.cloud"

BASE="https://SITE/cloud/modules/addons/DomainsReseller/api/index.php"THUSER="TRUEHOST_USERNAME"   # not USERNAME:zshreservesthatnameTHKEY="TRUEHOST_API_KEY"

HOUR=(date-u+"%y-%m-%d%H")TOKEN=(printf '%s' "$TH_KEY" \

  | openssl dgst -sha256 -hmac "THUSER:HOUR" -hex \

  | sed 's/^.*= //' | tr -d '\n' | base64 -w0)

# Balance

curl -s "$BASE/billing/credits" -H "username: $TH_USER" -H "token: TOKEN"#Changenameserverscurl-s-XPOST"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

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=curlinit(this->base().'/'.ltrim(path,'/'));curlsetoptarray(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,CURLOPTPOSTFIELDS,httpbuildquery(body)); // bracket notation

        }

        raw=(string)curlexec(ch);

        status=(int)curlgetinfo(ch, CURLINFO_HTTP_CODE);

        curlError=curlerror(ch);

        curl_close(ch);if(curlError !== '') {

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

        }

        data=jsondecode(raw, true);

        $error = match (true) {

            is_array($data) && ! empty($data['error']) => (string) data['error'],isarray(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**

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)**

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())`;consthex=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}`);

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.

  return data;

}

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

## 19. Recommended integration flow

### **Selling a domain**

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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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](https://claude.ai/chat/49748676-241b-4d96-9e39-4f1578022604#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 of your Truehost site.
