REST API and smart contract reference for the hack.tez free Tezos subdomain registrar. Covers all endpoints, registration flow, label validation, and error handling.
skill_type: hybrid
domain: Tezos / hack.tez subdomain registrar
version: 1.0
api_base: https://hacktez.com
network_default: ghostnet
hack.tez is a free Tezos subdomain registrar. Anyone can claim name.hack.tez (mainnet) or name.hack.gho (ghostnet) at no cost. Claimed subdomains are real Tezos Domains (TED) on-chain records — the owner has full TED ownership and can set addresses and manage their record.
Key properties:
Access-Control-Allow-Origin: *)Base URL: https://hacktez.com
Local dev: http://localhost:8888
Response envelope (success): { "data": ..., "network": "ghostnet" | "mainnet" }
Response envelope (error): { "error": "...", "code": "INVALID_INPUT" | "UPSTREAM_ERROR" | "METHOD_NOT_ALLOWED" }
Cache: responses are CDN-cached at the edge; the /domains list is additionally cached in Upstash Redis with serve-stale-while-revalidate (60 s fresh, 10 min hard TTL)
Paginated list of all hack.tez registrations. Backed by TED GraphQL (domain + profile data) and TzKT (registration timestamps and operation hashes). Response is served from Redis cache when warm (~5 ms) with background revalidation.
Query parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 50 | 1000 | Number of results |
Response:
{
"data": [
{
"name": "skllz.hack.tez",
"label": "skllz",
"owner": "tz1Qi77tcJn9foeHHP1QHj6UX1m1vLVLMbuY",
"address": "tz1Qi77tcJn9foeHHP1QHj6UX1m1vLVLMbuY",
"registeredAt": "2025-03-27T08:01:29Z",
"opHash": "oo...",
"profile": {
"name": "skllz",
"bio": "building hack.tez",
"status": "building",
"skills": ["typescript", "smartpy"],
"picture": "ipfs://bafybei...",
"github": "skullzarmy",
"twitter": "skaborern"
}
}
],
"count": 1,
"limit": 50,
"network": "mainnet"
}
Usage:
// Fetch up to 50 registrations (includes profile data)
const res = await fetch("https://hacktez.com/api/v1/domains?limit=50");
const { data, count } = await res.json();
// Each item has: name, label, owner, address, registeredAt, opHash, profile
data.forEach((d) => console.log(d.label, d.profile.status));
Cache: s-maxage=120, stale-while-revalidate=300 + Redis SWR (60 s fresh / 10 min hard TTL)
Fetch the TED domain record for a hack.tez subdomain. Accepts either the bare label (alice) or the full name (alice.hack.tez / alice.hack.gho).
Returns { data: null, available: true } if the domain is not registered.
Response (registered):
{
"data": {
"name": "alice.hack.tez",
"label": "alice",
"address": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb",
"owner": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb"
},
"available": false,
"network": "mainnet"
}
Response (not registered):
{ "data": null, "available": true, "network": "mainnet" }
Usage:
const res = await fetch("https://hacktez.com/api/v1/domain/alice");
const { data, available } = await res.json();
if (available) {
console.log("alice.hack.tez is free to register");
} else {
console.log("Owned by:", data.owner);
}
Lightweight availability check. Returns only the available boolean — faster than /api/domain when you don't need the full record.
Response:
{ "label": "alice", "available": false, "network": "mainnet" }
Validation errors (400):
[a-z0-9-]Usage:
const { available } = await fetch("https://hacktez.com/api/v1/availability/myname").then((r) => r.json());
All hack.tez subdomains owned by a given Tezos wallet. Returns an empty array (not 404) if the address owns none.
Path parameter: Any valid tz1..., tz2..., tz3..., or KT1... address.
Response:
{
"data": [
{
"name": "alice.hack.tez",
"label": "alice",
"address": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb",
"owner": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb"
}
],
"count": 1,
"network": "mainnet"
}
Usage:
const { data } = await fetch("https://hacktez.com/api/v1/owner/tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb").then((r) =>
r.json(),
);
const hasHackTez = data.length > 0;
Reverse-resolve a Tezos address to its primary domain and all owned hack.tez subdomains. primary is the TED reverse record — what the user explicitly set. hackTez is an array of all hack.tez subdomains currently owned (they are NFTs and transferable, so ownership can change).
Response:
{
"address": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb",
"primary": "alice.tez",
"hackTez": ["alice.hack.tez", "builder.hack.tez"],
"network": "mainnet"
}
Fields:
primary — TED reverse record if set, else first owned hack.tez subdomain, else null.hackTez — array of all hack.tez subdomains currently owned by this address. Empty array if none.Usage:
const { primary, hackTez } = await fetch(`https://hacktez.com/api/v1/resolve/${walletAddress}`).then((r) =>
r.json(),
);
const displayName = primary ?? hackTez[0] ?? walletAddress.slice(0, 8) + "…";
Resolve a hack.tez name or any address to its identity on Tezos X previewnet: the native address on one interface plus its deterministic alias on the other, with live chain state for each. See the tezos-x skill for the underlying alias math and network reference.
Accepts a hack.tez name (label or full form), a Tezos address (tz1/tz2/tz3/KT1), or an EVM address (0x).
Resolution precedence for a name's EVM address: a declared TED etherlink:address record wins; otherwise the deterministic Tezos X alias derived from the resolved tz address. The evmSource field reports which one you got.
Response:
{
"data": {
"input": "alice",
"name": "alice.hack.tez",
"tz": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb",
"evm": "0x16142132dd616dd8f61b8972ae4b9fcf8a22a450",
"evmSource": "derived",
"kt1Alias": null,
"corners": [
{ "role": "native", "address": "tz1…", "interface": "michelson", "materialized": true, "balance": "1250000" },
{ "role": "alias", "address": "0x1614…a450", "interface": "evm", "materialized": false, "balance": "0" }
],
"cornersError": null
},
"network": "tezosx-previewnet"
}
Fields:
evmSource — "declared" (explicit TED etherlink:address record), "derived" (computed Tezos X alias), or "native" (the input itself was an EVM address).kt1Alias — the Michelson-side alias, only set when the input is a native EVM address.corners[].materialized — the account exists on chain. A derived-only alias is a valid destination but has no account yet.corners[].balance — raw units per interface: mutez (6 decimals) on michelson, wei-of-tez (18 decimals) on evm.cornersError — set when previewnet was unreachable; derivation fields are still valid.Usage:
const { data } = await fetch(`https://hacktez.com/api/v1/tezosx/${name}`).then((r) => r.json());
console.log(`${data.name} on Tezos X:`, data.tz, "→", data.evm, `(${data.evmSource})`);
Interactive version: the X-Ray lab at https://hacktez.com/labs/x-ray.
Current contract configuration. Use this before starting registration to get commit timing requirements and check if registration is paused.
Response:
{
"data": {
"minCommitAgeSec": 30,
"maxCommitAgeSec": 86400,
"maxPerWallet": 1,
"paused": false,
"registrarAddress": "KT1X2ZbjZBaeRnnkzLyaZ3FtGp7wKuaimbzg"
},
"network": "ghostnet"
}
Fields:
minCommitAgeSec — must wait at least this many seconds between commit and registermaxCommitAgeSec — commit expires after this many seconds (must re-commit if expired)maxPerWallet — max subdomains per wallet (currently 1)paused — if true, registration is disabled on-chainRecent on-chain claim and commit events, merged and sorted by time. Commit events have name: null since the hash is not recoverable off-chain. Used by the activity feed on the site.
Query params: limit (default 30, max 100)
Response:
{
"data": [
{
"type": "claimed",
"address": "tz1...",
"name": "alice.hack.gho",
"timestamp": "2025-01-01T00:00:00Z",
"opHash": "op..."
},
{
"type": "committed",
"address": "tz1...",
"name": null,
"timestamp": "2025-01-01T00:00:00Z",
"opHash": "op..."
}
],
"count": 2,
"limit": 30,
"network": "ghostnet"
}
Fields:
type — "claimed" (register entrypoint applied) or "committed" (commit entrypoint applied)name — full domain name for claims, null for commitsopHash — deduplication keyCache: s-maxage=20, stale-while-revalidate=40
Returns a generative avatar GIF for the given hack.tez subdomain label. The image is deterministically generated from a salted domain name — same domain always produces the same hackatar. Cached immutably in Netlify Blobs after first generation.
Path parameters:
| Param | Type | Description |
|---|---|---|
label | string | The subdomain label (e.g. skllz, not skllz.hack.tez) |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
static | "1" | — | If set, returns a single-frame still image |
Response: Binary GIF image (Content-Type: image/gif).
?static=1): Single-frame GIF, 192×192 pixelsCache-Control: public, max-age=31536000, immutableExample:
GET https://hacktez.com/api/v1/hackatar/skllz
→ animated GIF (binary)
GET https://hacktez.com/api/v1/hackatar/skllz?static=1
→ single-frame GIF (binary)
Errors:
| Status | Code | Reason |
|---|---|---|
| 400 | INVALID_INPUT | Label fails validation |
| 404 | NOT_FOUND | Domain not registered |
| 502 | UPSTREAM_ERROR | Server-side rendering error |
How it works:
Usage in HTML/Markdown:
<img src="https://hacktez.com/api/v1/hackatar/skllz" alt="skllz hackatar" />
Returns the parsed builder profile for a hack.tez subdomain. Accepts a bare label (alice) or full domain name (alice.hack.tez). Returns profile: {} if the domain exists but has no hack: data keys.
Response:
{
"data": {
"name": "alice.hack.tez",
"owner": "tz1...",
"address": "tz1...",
"profile": {
"name": "Alice",
"picture": "ipfs://bafybei...",
"bio": "building on tezos",
"status": "building",
"skills": ["typescript", "smartpy"],
"projects": [...]
},
"registrationHash": "op...",
"registeredAt": "2025-03-27T08:01:29Z"
},
"network": "ghostnet"
}
Notes:
profile uses the picture field (mapped from openid:picture), not avatarregistrationHash / registeredAt may be null for preloaded domainsprofile: {} if the domain exists but has no hack: data keysErrors:
| Status | Code | Reason |
|---|---|---|
| 400 | INVALID_INPUT | Invalid domain name |
| 404 | — | Domain not registered (returns { "error": "not found" }) |
The registrar uses a commit-reveal scheme to prevent front-running.
The commitment is a blake2b-256 hash of the packed Michelson bytes of (label, nonce, sender_address).
import { blake2b } from "blakejs";
import { encodePacked } from "@taquito/utils"; // or manual Michelson packing
// label: string (e.g. "alice")
// nonce: random 32-byte hex string
// sender: tz1... address of the registering wallet
function computeCommitment(label: string, nonce: string, sender: string): string {
// Pack as Michelson pair(pair(bytes(label), bytes(nonce)), address(sender))
// See src/lib/commitment.ts for the canonical implementation
const packed = packCommitmentMichelson(label, nonce, sender);
const hash = blake2b(packed, undefined, 32);
return Buffer.from(hash).toString("hex");
}
Important: The commitment hash MUST match what the on-chain contract computes. Use the canonical implementation in
src/lib/commitment.ts— do not re-implement.
// Via DAppClient (Beacon) — raw Michelson operation
await dAppClient.requestOperation({
operationDetails: [
{
kind: TezosOperationType.TRANSACTION,
destination: REGISTRAR_ADDRESS,
amount: "0",
parameters: {
entrypoint: "commit",
value: { string: commitmentHex }, // bytes in Michelson
},
},
],
});
// Store pending commit locally
localStorage.setItem(
"hack-tez-pending-commits",
JSON.stringify({
label,
nonce,
commitHash,
timestamp: Date.now(),
}),
);
const config = await fetch("https://hacktez.com/api/v1/config").then((r) => r.json());
const waitMs = config.data.minCommitAgeSec * 1000;
// Check /api/config for current values — don't hardcode
await new Promise((resolve) => setTimeout(resolve, waitMs));
await dAppClient.requestOperation({
operationDetails: [
{
kind: TezosOperationType.TRANSACTION,
destination: REGISTRAR_ADDRESS,
amount: "0",
parameters: {
entrypoint: "register",
value: {
prim: "Pair",
args: [
{ string: labelHex }, // label as Michelson bytes
{ string: nonceHex }, // nonce as Michelson bytes
// optional: address to resolve to, optional data map
],
},
},
},
],
});
See
src/lib/contract.tsfor the canonicalsubmitCommit()andsubmitRegister()implementations.
Before calling any API or contract endpoint, validate the label client-side:
function validateLabel(label: string): { valid: boolean; error?: string } {
if (label.length < 3) return { valid: false, error: "Min 3 characters" };
if (label.length > 63) return { valid: false, error: "Max 63 characters" };
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label)) {
return { valid: false, error: "Lowercase alphanumeric and hyphens only" };
}
return { valid: true };
}
Reserved labels (cannot be registered — enforced on-chain):
admin, support, help, www, mail, ftp, api, app, ns1, ns2, dns, mx, smtp, imap, pop, ssh, hack, tez, tezos, test, dev, staging, prod, official, bot, system, root, null, undefined
| Property | Ghostnet | Mainnet |
|---|---|---|
| TLD | .gho | .tez |
| Full domain format | label.hack.gho | label.hack.tez |
| Registrar | KT1X2ZbjZBaeRnnkzLyaZ3FtGp7wKuaimbzg | KT1UKAt5ioGdbKb435ziP25FRDzqgC7BUeB4 |
| TED GraphQL | https://ghostnet-api.tezos.domains/graphql | https://api.tezos.domains/graphql |
| TzKT API | https://api.ghostnet.tzkt.io | https://api.tzkt.io |
| RPC | https://rpc.ghostnet.teztnets.com | https://mainnet.tezos.marigold.dev |
Network is selected via VITE_TEZOS_NETWORK env var (default: ghostnet).
| Code | HTTP | When |
|---|---|---|
INVALID_INPUT | 400 | Bad label (too short/long/invalid chars), invalid Tezos address format |
METHOD_NOT_ALLOWED | 405 | Non-GET request |
UPSTREAM_ERROR | 502/503 | TED GraphQL or TzKT unreachable, or registrar address not configured |
All errors include a human-readable error string alongside the code.
async function checkAndShow(label: string) {
const [validationResult, configResult] = await Promise.all([
fetch(`https://hacktez.com/api/v1/availability/${label}`).then((r) => r.json()),
fetch("https://hacktez.com/api/v1/config").then((r) => r.json()),
]);
if (configResult.data.paused) return showPausedMessage();
if (!validationResult.available) return showTakenMessage();
showRegistrationForm({ minCommitAgeSec: configResult.data.minCommitAgeSec });
}
async function getDisplayName(address: string): Promise<string> {
try {
const { primary, hackTez } = await fetch(`https://hacktez.com/api/v1/resolve/${address}`).then((r) =>
r.json(),
);
return primary ?? hackTez[0] ?? `${address.slice(0, 6)}…${address.slice(-4)}`;
} catch {
return `${address.slice(0, 6)}…${address.slice(-4)}`;
}
}
// The /domains endpoint returns up to 1000 results.
// Default limit is 50; includes profile data for each domain.
const { data, count } = await fetch("https://hacktez.com/api/v1/domains?limit=200").then((r) => r.json());
console.log(`Got ${data.length} of ${count} total domains`);
// Profile data is included — no need for separate /profile calls
data.forEach((d) => {
console.log(d.label, d.profile.bio, d.profile.skills);
});