AI integration guide
This page is written for an AI coding agent (and the developer guiding it) that must build a reseller's integration with the Golzak Domains API without help. It is self-contained: with this page and the OpenAPI file you have everything. When this page and the OpenAPI file disagree, the OpenAPI file wins.
For the agent: do not invent behaviour. Every rule you need is on this page, in the OpenAPI file, or on the pages linked here: Workflows (step-by-step tasks), Limits (every number and what the API does not do), Edge cases (timeouts, races, duplicates) and Errors (every code, whether to retry). If something is still unclear, stop and ask the developer instead of guessing.
1. Facts to hold on to
- Base URL
https://api.golzak.com/domains/v1; every path in the docs is relative to it. JSON in, JSON out, UTF-8. - Auth:
Authorization: Bearer <API key>on every call, key created on developers.golzak.com. Server-side only. - v1 offers only
.ma,.co.ma,.net.ma,.org.ma;.ac.ma,.gov.ma,.press.ma→tld_manual_only. Later versions may add extensions: read the list fromGET /prices, don't hard-code it. Anything else →tld_not_supported. Names: one lower-case ASCII label + extension. - Never hard-code a price. Read
GET /pricesorGET /check. Every amount in these docs is a placeholder. Amounts are decimal strings; parse with a decimal type. - Payment is the reseller's prepaid credit.
402 insufficient_creditmeans nothing was ordered. - Every POST needs an
Idempotency-Key. Create it once per intended action, persist it before sending, and reuse it on every retry of that action. - Registration, renewal and transfer are asynchronous:
202+ operation. Final states:active,refused(registration only),failed. Listen to webhooks; pollGET /operations/{id}as a fallback. - The holder is the reseller's client;
legal_iddepends ontype×moroccan(table in §4). - Errors are RFC 9457 problem details. Branch on
code(anderrors[].field+errors[].code), never on text. - No sandbox: test with
?dry_run=trueand with error cases, which never cost anything. - v1 only adds things. Treat an unknown error
code, domain status,pending_reason, operationtypeor webhook eventtypeas unknown, never as a crash: handle errors by HTTP status, acknowledge unknown events with200. Only operationstatusis a closed set (pending,active,refused,failed). - Domains start with
auto_renew: false. Renew withPOST /domains/{name}/renew, or enable auto-renew per domain (PUT /domains/{name}/auto-renew).yearsis at most 5, and a name never passes 5 years of validity. - To let a holder leave:
PUT /domains/{name}/transfer-lock{"locked": false}, thenGET /domains/{name}/epp-code(each call makes a new code). See Expiry, renewal and transfers out. - API keys only work from the IP addresses listed for them. A
403 ip_not_allowedin development means the machine's public IP is not on the key's allowlist.
Endpoints at a glance
| Call | Purpose | Sync? | Costs money | Needs Idempotency-Key |
|---|---|---|---|---|
GET /check?name= |
Availability + 1-year price | yes | no | no |
GET /prices |
Every price, allowed years, grace/redemption days | yes | no | no |
GET /balance |
Credit, cap, spent today | yes | no | no |
POST /domains |
Register | no (202 + operation) |
yes | yes |
GET /domains |
List your domains (paginated) | yes | no | no |
GET /domains/{name} |
One domain: status, expiry, nameservers, lock, auto-renew | yes | no | no |
POST /domains/{name}/renew |
Renew | no (202 + operation) |
yes | yes |
PUT /domains/{name}/nameservers |
Replace nameservers | yes | no | optional |
PUT /domains/{name}/transfer-lock |
Lock / unlock against transfer | yes | no | optional |
PUT /domains/{name}/auto-renew |
Turn automatic renewal on / off | yes | not now (later renewals do) | optional |
GET /domains/{name}/epp-code |
New transfer code (cancels the previous one) | yes | no | no |
POST /transfers |
Transfer a name in | no (202 + operation) |
yes | yes |
GET /operations/{id} |
State of an asynchronous operation | yes | no | no |
Every paid POST accepts ?dry_run=true: all checks, no order, 200 with the would-be charge.
2. What to build
| Component | Responsibility |
|---|---|
| API client | One function per endpoint; adds the bearer key, Idempotency-Key, parses problem details into a typed error with code. Retries 429 (after Retry-After), 502, 500 and network errors with the same idempotency key, exponential backoff, at most ~5 tries. Never retries other 4xx. |
| Holder mapper | Converts the reseller's client record into holder. Asks the end user for moroccan and the right document number (§4) instead of guessing. |
| Operation store | Table: operation_id, idempotency_key, domain, type, status, pending_reason, documents_required, documents_deadline, updated_at, raw JSON. |
| Webhook endpoint | Verifies the signature on the raw body, stores the event durably (deduplicated on Golzak-Webhook-Id), then answers 200; processing (upsert the operation if updated_at is newer) happens after. |
| Reconciler | Every 15 minutes: GET /operations/{id} for each local operation still pending. |
| Expiry watcher | Daily: for each domain with auto_renew: false and expires_at within 30 days, alert staff or renew. |
| Notifier | Tells the reseller's staff when documents_required becomes true (they must send documents to Golzak by e-mail before documents_deadline) and when an operation ends. |
3. The registration flow, step by step
1. GET /check?name=N → available? price (1 year)
2. GET /prices → allowed years, price for the chosen years
3. build holder (§4); validate locally (optional, the API validates anyway)
4. key = uuid4(); store {key, N, "register"}
5. POST /domains?dry_run=true (Idempotency-Key: new uuid) → 200 would_charge, or an error to show
6. POST /domains (Idempotency-Key: key) → 202 operation op_…
7. store op_…; show "pending"
8. webhook operation.pending → documents_required: tell staff, deadline
9. webhook operation.active → done; GET /domains/N for expiry
webhook operation.refused → tell the client; refund in operation.refund
webhook operation.failed → show operation.error.detail; refund in operation.refund
A dry run consumes its own idempotency key; use a different key for the real order.
4. Holder: decide legal_id
def legal_id_kind(holder_type: str, moroccan: bool) -> str:
if holder_type == "individual":
return "CIN" if moroccan else "passport number"
if holder_type == "company":
return "company tax ID (ICE / RC)" if moroccan else "company registration number or tax ID"
raise ValueError("holder.type must be individual or company")
type |
moroccan |
legal_id |
Extra required |
|---|---|---|---|
individual |
true |
CIN | – |
individual |
false |
passport number | – |
company |
true |
company tax ID (ICE / RC) | organization |
company |
false |
company registration number or tax ID | organization |
Always required: type, moroccan, legal_id, first_name, last_name (for a company: its contact person),
email, phone as +CC.NUMBER, address.line1, address.city, address.country (ISO two letters, upper case).
moroccan is the holder's nationality (individual) or country of incorporation (company), not the address
country: ask, don't infer. Do not validate the ID's format yourself; send it as written on the document.
5. Worked examples: the four holder cases
Every request below is POST /domains with headers:
Authorization: Bearer <API key>
Idempotency-Key: <new uuid per registration>
Content-Type: application/json
5.1 Moroccan individual → CIN
{
"name": "atlas-tours.ma",
"years": 1,
"holder": {
"type": "individual",
"moroccan": true,
"legal_id": "<CIN number>",
"first_name": "Salma",
"last_name": "Bennani",
"email": "salma.bennani@example.com",
"phone": "+212.612345678",
"address": { "line1": "12 Rue Example", "city": "Casablanca", "postal_code": "20000", "country": "MA" }
},
"nameservers": ["ns1.example-dns.com", "ns2.example-dns.com"]
}
Answer 202 Accepted, header Location: /v1/operations/op_01JA7X3M9Q2W8E5R6T7Y8U9I0P:
{
"id": "op_01JA7X3M9Q2W8E5R6T7Y8U9I0P",
"type": "register",
"initiator": "api",
"status": "pending",
"pending_reason": "processing",
"domain": "atlas-tours.ma",
"years": 1,
"charge": { "amount": "<your price, read live>", "currency": "<your account currency>" },
"refund": null,
"documents_required": false,
"documents_deadline": null,
"error": null,
"created_at": "2026-10-08T09:00:00Z",
"updated_at": "2026-10-08T09:00:00Z",
"completed_at": null
}
5.2 Non-Moroccan individual → passport number
{
"name": "kemal-yilmaz.ma",
"years": 1,
"holder": {
"type": "individual",
"moroccan": false,
"legal_id": "<passport number>",
"first_name": "Kemal",
"last_name": "Yilmaz",
"email": "kemal@example.com",
"phone": "+90.5321234567",
"address": { "line1": "Example Cd. 5", "city": "Istanbul", "postal_code": "34000", "country": "TR" }
},
"nameservers": ["ns1.example-dns.com", "ns2.example-dns.com"]
}
Answer: 202 with an operation like 5.1 (domain: "kemal-yilmaz.ma").
5.3 Moroccan company → company tax ID (ICE / RC)
{
"name": "atlas-export.co.ma",
"years": 2,
"holder": {
"type": "company",
"moroccan": true,
"legal_id": "<ICE or RC number>",
"organization": "Atlas Export SARL",
"first_name": "Youssef",
"last_name": "Alaoui",
"email": "contact@atlas-export.example",
"phone": "+212.522123456",
"address": { "line1": "4 Boulevard Example", "city": "Rabat", "postal_code": "10000", "country": "MA" }
},
"nameservers": ["ns1.example-dns.com", "ns2.example-dns.com"]
}
Answer: 202 with an operation like 5.1 (domain: "atlas-export.co.ma", years: 2), provided 2 is in your
register price table.
5.4 Foreign company → registration number or tax ID
{
"name": "anatolia-trade.ma",
"years": 1,
"holder": {
"type": "company",
"moroccan": false,
"legal_id": "<company registration number or tax ID>",
"organization": "Anatolia Trade A.S.",
"first_name": "Elif",
"last_name": "Demir",
"email": "it@anatolia-trade.example",
"phone": "+90.2121234567",
"address": { "line1": "Example Sk. 10", "city": "Istanbul", "country": "TR" }
},
"nameservers": ["ns1.example-dns.com", "ns2.example-dns.com"]
}
Answer: 202 with an operation like 5.1 (domain: "anatolia-trade.ma").
6. Worked examples: what each wrong case returns
Every error below is application/problem+json, and nothing is ordered or charged. request_id is omitted
for brevity.
6.1 Moroccan individual without a CIN
Body as 5.1 with "legal_id": "" (or the field missing). Answer 422:
{
"type": "https://developers.golzak.com/domains/v1/errors#validation_failed",
"title": "Validation failed",
"status": 422,
"code": "validation_failed",
"detail": "The request breaks 1 rule. Nothing was ordered.",
"errors": [
{ "field": "holder.legal_id", "code": "required", "message": "A Moroccan individual needs their CIN number in holder.legal_id." }
]
}
6.2 Non-Moroccan individual without a passport number
{ "field": "holder.legal_id", "code": "required", "message": "A non-Moroccan individual needs their passport number in holder.legal_id." }
(Same 422 validation_failed envelope as 6.1; only the errors[] entry differs.)
6.3 Moroccan company without its tax ID
{ "field": "holder.legal_id", "code": "required", "message": "A Moroccan company needs its company tax ID (ICE / RC) in holder.legal_id." }
6.4 Foreign company without registration number or tax ID
{ "field": "holder.legal_id", "code": "required", "message": "A foreign company needs its company registration number or tax ID in holder.legal_id." }
6.5 Company without organization, and several mistakes at once
Body as 5.3 without organization, with "phone": "0522123456" and without moroccan. Answer 422, every rule
at once:
{
"type": "https://developers.golzak.com/domains/v1/errors#validation_failed",
"title": "Validation failed",
"status": 422,
"code": "validation_failed",
"detail": "The request breaks 3 rules. Nothing was ordered.",
"errors": [
{ "field": "holder.moroccan", "code": "required", "message": "holder.moroccan must be true or false." },
{ "field": "holder.organization", "code": "required", "message": "A company holder needs its legal name in holder.organization." },
{ "field": "holder.phone", "code": "invalid_format", "message": "Write the phone as +<country code>.<number>, e.g. +212.612345678." }
]
}
6.6 Unknown holder type
"type": "person" → 422 validation_failed:
{ "field": "holder.type", "code": "invalid_value", "message": "holder.type must be individual or company." }
6.7 The holder is the reseller itself
Holder e-mail or organization equal to your own account's → 422:
{
"type": "https://developers.golzak.com/domains/v1/errors#holder_is_reseller",
"title": "Holder is the reseller",
"status": 422,
"code": "holder_is_reseller",
"detail": "The holder must be your client, not your own company. Register the name in your client's name."
}
6.8 Extension handled by hand
"name": "ministere.gov.ma" (also .ac.ma, .press.ma) → 422:
{
"type": "https://developers.golzak.com/domains/v1/errors#tld_manual_only",
"title": "Extension handled by hand",
"status": 422,
"code": "tld_manual_only",
"detail": ".gov.ma, .ac.ma and .press.ma are not available through the API. Contact Golzak to register one."
}
6.9 Not a .ma extension
"name": "atlas-tours.com" → 422, "code": "tld_not_supported",
"detail": "This API version registers .ma, .co.ma, .net.ma and .org.ma only.".
6.10 Not enough credit
Valid body, credit below the price → 402:
{
"type": "https://developers.golzak.com/domains/v1/errors#insufficient_credit",
"title": "Insufficient credit",
"status": 402,
"code": "insufficient_credit",
"detail": "Top up your Golzak account credit, then retry with a new Idempotency-Key. Nothing was ordered."
}
6.11 Name already registered
409, "code": "domain_not_available", "detail": "atlas-tours.ma is already registered. Nothing was ordered.".
6.12 Years not in the price table
"years": 7 when your register table has no 7 → 422 validation_failed with
{ "field": "years", "code": "years_not_offered", "message": "Order one of the year counts listed in GET /prices." }.
6.13 Missing idempotency key
428, "code": "idempotency_key_required".
7. Following the operation
What to do in each state
status |
pending_reason |
Meaning | Your code does |
|---|---|---|---|
pending |
processing |
Being ordered. | Nothing; wait. |
pending |
registry_review |
ANRT examines the name. | Alert a human: documents to Golzak before the deadline in Golzak's e-mail. |
pending |
transfer_pending |
Waiting for the losing registrar. | Nothing; wait (about 2 business days). |
pending |
any other value | A reason added later in v1. | Treat as "still pending". |
active |
null |
Done. | Mark done; refresh the domain with GET /domains/{name}. |
refused |
null |
ANRT refused the registration. | Tell the client; show refund.status. |
failed |
null |
Could not be done. | Show error.detail; show refund.status; offer to retry with a new key. |
Final states (active, refused, failed) never change. Ignore any later event that would move an operation out of a
final state.
Held for ANRT examination (GET /operations/{id} or webhook operation.pending):
{
"id": "op_01JA7X3M9Q2W8E5R6T7Y8U9I0P",
"type": "register",
"initiator": "api",
"status": "pending",
"pending_reason": "registry_review",
"domain": "atlas-tours.ma",
"documents_required": true,
"documents_deadline": null
}
(Shortened.) Tell the reseller's staff at once: Golzak has e-mailed them the ANRT form and document list, and the
documents must reach Golzak (reply to that e-mail or a ticket) before the deadline written in it
(documents_deadline repeats it when known). There is no upload endpoint in v1. Expect the registry's answer to
take up to about 3 weeks.
Refused: status: "refused", error.code: "registry_refused", refund: { "status": "credited", ... } or
"manual_review".
8. Webhook receiver (Node.js, Express)
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.GOLZAK_WEBHOOK_SECRET;
app.post("/golzak/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const ts = req.get("Golzak-Webhook-Timestamp") ?? "";
const sig = req.get("Golzak-Signature") ?? "";
const fresh = /^\d{10}$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const expected = "v1=" + crypto.createHmac("sha256", SECRET).update(`${ts}.`).update(req.body).digest("hex");
const ok = fresh && sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(400);
const eventId = req.get("Golzak-Webhook-Id");
const event = JSON.parse(req.body.toString("utf8"));
try {
await saveEventOnce(eventId, event); // your code: durable inbox, dedupe on eventId (fast)
} catch {
return res.sendStatus(500); // not stored: let Golzak retry
}
res.sendStatus(200); // acknowledge only what is stored
processInboxLater(eventId); // your code: upsert the operation if updated_at is newer
});
9. Acceptance checklist for the integration
- No price, currency or year count is hard-coded; all come from
GET /prices/GET /check. - Every POST sends an
Idempotency-Keythat is persisted before the request and reused on retry. -
429,500,502and timeouts are retried with the same key; other4xxare shown, not retried. - The four holder cases of §5 produce the right
legal_id;moroccanis asked, not inferred from the address. - A dry run of each §6 error case shows the right message to the user and orders nothing.
- Webhook signatures are verified on the raw body; bad signatures get
400; events are deduplicated. -
documents_required: truealerts a human, withdocuments_deadlinewhen it is notnull. - A reconciler polls operations still
pending. -
refusedandfailedshow the refund status to the reseller. - Expiring domains are renewed or flagged before
expires_at; nothing assumes auto-renew is on.