Client Portal
Veelgestelde vragen API-documentatie NL EN
Base URL
https://client-api.vm429.drnl.nl

Authentication

There are two ways to authenticate. Which one you use depends on what you're building.

MethodUse it forHow
Session loginSigning a human into this portalTwo-step email/password login, returns a bearer token
API keyScripts, integrations, anything unattendedA long-lived key you generate yourself, below

You don't need both. A couple of endpoints — managing sub-accounts, managing your own API keys — are portal-login only; noted where relevant.

Getting an API key

  1. Log in to this portal, then go to Account.
  2. Create a new key, giving it a label and at least one allowed IP address or CIDR range (e.g. 203.0.113.4 or 203.0.113.0/24).
  3. Copy the key immediately — it's shown exactly once and cannot be retrieved again. If you lose it, revoke it and create a new one.
  4. Requests from any IP not in that key's allowlist are rejected, even with a valid key. There is no "allow from anywhere" option — this is by design.

Send it as a bearer token on every request:

Authorization: Bearer <your-api-key>

Session login

Login is two steps, because the email on an account can be shared across more than one of your organization's client records — for example, if you manage several accounts from one inbox. Step 1 tells you which account(s) that email belongs to; step 2 verifies the password for the one you pick.

POST/v1/auth/lookup
Request
{ "email": "you@example.com" }
Response
{ "status": "ok", "data": { "accounts": [ { "sublogin_id": 12, "client_id": 4, "client_display_name": "Example BV", "is_primary": true } ] } }

Exactly one account? Skip straight to step 2. More than one? Show a picker using client_display_name before asking for a password.

POST/v1/auth/login
Request
{ "sublogin_id": 12, "password": "..." }
Response
{ "status": "ok", "data": { "token": "a1b2c3...", "expires_at": "2026-08-12T20:00:00+00:00", "client_id": 4, "email": "you@example.com", "roles": ["owner"] } }

Use token as a bearer token on every subsequent request. Watch for 401 UNAUTHORIZED as the signal to log in again.

Both /v1/auth/lookup and /v1/auth/login are rate-limited per source IP (10 attempts / 5 minutes) as a brute-force guard. Expect 429 RATE_LIMITED if you exceed that — back off rather than retry immediately.
POST/v1/auth/logoutinvalidates current session
GET/v1/auth/mewho am I

Response format

Every response uses the same envelope:

{ "status": "ok", "data": { }, "error": null, "request_id": "a uuid, unique per request" }

On failure:

{ "status": "error", "data": null, "error": { "code": "SOME_CODE", "message": "Human-readable explanation" }, "request_id": "..." }

request_id is worth logging on your end — quote it if you ever report an issue, it lets us trace the exact request server-side.

Common error codes

Statuserror.codeMeaning
400VALIDATION_ERRORA required field was missing or invalid
401UNAUTHORIZEDMissing, invalid, or expired credentials
401INVALID_CREDENTIALSWrong password on login
403FORBIDDENAuthenticated, but not allowed to do this
404NOT_FOUNDDoesn't exist, or doesn't belong to your account
409CONFLICTDoesn't make sense in the resource's current state
429RATE_LIMITEDToo many requests — back off and retry later
500 / 502INTERNAL_ERROR, etc.Something went wrong on our end — safe to retry

Data isolation

Every endpoint below only ever returns or affects data belonging to your own account. Passing another account's ID anywhere returns 404 NOT_FOUND, the same as if it didn't exist. This is enforced on every request server-side, not just hidden in the portal UI.

Domains

GET/v1/domains

Lists every domain on your account.

{ "status": "ok", "data": [ { "id": 1, "domain_name": "example.nl", "tld": "nl", "status": "active", "client_id": 4, "autorenew": 1, "expires_at": "2027-01-01" } ] }
GET/v1/domains/{id}

A single domain, including its owner/admin/tech contacts.

GET/v1/contacts

Lists contacts linked to your account. Use a returned id for bulk contact replacement.

Pricing

GET/v1/pricing/resolve?tld=nl&action=renewal&period_years=1

Resolves the price for a given TLD + action (+ period, for period-based actions like registration/renewal/transfer). Reflects any account-specific pricing you have — this is the price you'd actually be charged.

{ "status": "ok", "data": { "price_amount": "9.95", "currency": "EUR", "tld": "nl", "action": "renewal", "period_years": 1 } }

Returns 404 PRICE_NOT_FOUND if that TLD/action combination isn't orderable for your account.

Domain actions

The way you request anything to actually happen to a domain — a renewal, a DNS change, an ownership transfer, and so on.

POST/v1/domain-actions
{ "domain_id": 1, "action": "renewal", "period_years": 1 }

Registering a brand-new domain? Omit domain_id and pass tld and domain_name instead — the domain is created as part of the request.

{ "status": "ok", "data": { "id": 42, "status": "sent_to_supplier", "price_amount": "0.00", "currency": "EUR" } }

What happens next

GET/v1/domain-actions?domain_id=1

Lists actions requested on a specific domain. domain_id is required — this endpoint always scopes to one domain.

POST/v1/domain-actions/bulk

Requests the same action across up to 500 domains. The request continues after an individual failure and returns one result per domain. Supported actions are renewal, nameserver change, and contact replacement.

{ "domain_ids": [1, 2, 3], "action": "nameserver_change", "nameservers": [{ "hostname": "ns1.example.net", "glue_ips": [] }] } // Or: { "domain_ids": [1, 2], "action": "renewal", "period_years": 1 } // Or: { "domain_ids": [1, 2], "action": "contact_replacement", // "contact_id": 42, "contact_role": "admin" }

Nameservers and contacts are updated per domain before the matching registry action is requested. Paid actions retain the normal open-order and checkout flow.

Send an Idempotency-Key header on POST /v1/domain-actions if you might retry the same request (e.g. after a timeout) — a retried request with the same key won't create a duplicate action.

Invoices

GET/v1/invoices
GET/v1/invoices/{id}

List / fetch a single invoice, including its line items.

GET/v1/invoices/{id}/pdf
{ "status": "ok", "data": { "filename": "INV-42.pdf", "content_base64": "..." } }

Decode content_base64 to get the raw PDF file bytes.

Sub-accounts portal login only

Lets an account owner create and manage additional logins under the same account — separate billing/tech contacts, each with their own credentials. Requires a portal session with the owner role; not available via API key.

GET/v1/subloginslist
POST/v1/sublogins
{ "email": "...", "password": "...", "roles": ["billing", "tech"] }
PUT/v1/sublogins/{id}
{ "status": "active", "password": "...", "roles": ["billing"] }

API keys portal login only

Self-service management of your own API keys, including the one you're currently using — also portal-login only, so a compromised key can never mint another key or widen its own access.

GET/v1/api-keysnever returns the raw secret again
POST/v1/api-keys
{ "label": "Billing sync", "ip_allowlist": ["203.0.113.0/24"] }

Returns the raw key once, in the response — save it now.

DELETE/v1/api-keys/{id}revoke, soft-disable
POST/v1/api-keys/{id}/ip-allowlist
{ "ip_cidr": "198.51.100.5" }
DELETE/v1/api-keys/{id}/ip-allowlist/{allowlist_id}

Rate limits

BucketLimit
Login attempts (/v1/auth/lookup, /v1/auth/login)10 / 5 min, per source IP
API key usage120 / min, per key

Exceeding either returns 429 RATE_LIMITED. Build in a backoff/retry — repeatedly retrying immediately will just keep tripping the limit.

Health check

GET/healthno auth required

Returns {"status":"ok"} — useful for your own monitoring, doesn't touch anything else.

Questions, or something not behaving as documented? Include the request_id from the response when you reach out.