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.

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.

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.