ird-clj
Full-coverage Clojure client for the ird public API — .ro domains, DNS (hosted zones, DNSSEC, bring-your-own-domain claims), VPS, tunnels, IPFS, P2P apps (including their offline mailbox), and async jobs (LLM, NLP, embeddings, bulk domain checks).
Every one of the API's 161 operations is wrapped by a thin, documented function, verified one-to-one against the OpenAPI spec by the test suite.
Install
;; deps.edn
ro.ird/ird-clj {:git/url "https://ird.cod-sursa.ro/clj.git" :git/sha "…"}
Pin :git/sha to the latest commit:
git ls-remote https://ird.cod-sursa.ro/clj.git HEAD
The landing page at https://ird.cod-sursa.ro/clj/ shows the current release coordinates ready to paste, and https://ird.cod-sursa.ro/releases.json carries them for scripts and agents (head, tag, tag_sha per repo).
Quickstart
(require '[ird.client :as ird]
'[ird.domains :as domains]
'[ird.credits :as credits])
;; API key from your account panel (or an account JWT — same field)
(def c (ird/make-client {:api-key (System/getenv "IRD_API_KEY")}))
(credits/balance c)
;; => {:balance {:credits 1234.5 ...}}
(domains/check c "example.ro")
;; => {:domain "example.ro" :status "registered" ...}
;; a few endpoints work without a key:
(domains/check (ird/make-client {}) "example.ro")
Authentication
One Bearer credential, two forms — pass either as :api-key:
- API key (
ird_...) created in the account panel.
- Account JWT from the device login flow:
(require '[ird.auth :as auth])
(def tokens (auth/device-login (ird/make-client {})))
;; prints a verification URL, polls until you approve, prompts for the code
(def c (ird/make-client {:api-key (:access_token tokens)}))
A few account-level areas accept only an account JWT (API keys are rejected with 401): everything in ird.sharing and ird.transfers, plus the API-key management functions in ird.auth.
Domain gate (client-side safety net)
Restrict what a client may touch, independent of account permissions — useful for agents and automation:
;; this client can never mutate prod.ro or anything under it,
;; even though the account can:
(def c (ird/make-client {:api-key key
:denied-domains ["prod.ro" "*.prod.ro"]}))
;; or allow-list: ONLY these may be mutated
(def c (ird/make-client {:api-key key
:allowed-domains ["staging.ro" "*.dev.ro"]}))
Domain-touching mutations (registrations, DNS zones and claims, sharing invites, tunnel/gateway/p2p domain attach and detach/delete, changes to the pins a gateway domain serves, VPS domain connect/disconnect, action cancellation) are checked before any request is sent; violations throw an :ird/domain-denied ex-info. Reads are never gated. Registration-id-based operations resolve through a domain cache built at client creation; a cache miss falls back to fetching the single registration (and caches it) — ird.gate/refresh! still re-syncs the whole cache. Ids that still cannot be resolved fail closed under an allow-list. An empty :allowed-domains collection denies every domain mutation.
Namespaces
| Namespace |
Endpoints |
Covers |
ird.domains |
9 |
availability check/search, utilities, .ro web directory |
ird.registrations |
27 |
register, renew, transfer, trade, hosts, DS data, registrant |
ird.dns |
18 |
hosted DNS zones, records, DNSSEC, nameserver sets, bring-your-own-domain claims |
ird.sharing |
7 |
delegated-access grants, both directions |
ird.vps |
34 |
tiers, instances, one-click apps, volumes, snapshots |
ird.transfers |
9 |
cross-account VPS resource handover |
ird.tunnels |
6 |
HTTPS tunnels on your own domain |
ird.ipfs |
18 |
pinning, direct uploads, IPNS, gateway domains and the pins they serve |
ird.pins |
5 |
standard IPFS Pinning Service API at /pins |
ird.p2p |
12 |
p2p apps, TURN and offline-mailbox credentials, custom domains |
ird.mailbox |
2* |
the offline mailbox itself — deposit/replay on the app's own node |
ird.jobs |
3 |
async jobs: LLM chat/completion, embeddings, NLP, bulk checks |
ird.credits |
3 |
balance, ledger, prices |
ird.support |
4 |
support threads |
ird.auth |
6 |
device login, API keys |
* ird.mailbox talks to https://<app-host>/mailbox/v1 rather than api.ird.ro, so its two endpoints sit outside the 161 and outside the coverage table.
Conventions (shared by every function):
- First argument is always the client map from
ird.client/make-client — except in ird.mailbox, where deposit/replay take the credentials map instead, since they address a node rather than the API.
- Path parameters are positional arguments; optional query/body maps come last.
- Keys are the API's own names as keywords, unrenamed — almost always snake_case (
:auto_renew, :size_gb). Three response keys are hyphenated instead, so reach for :job-id on jobs/get-result and jobs/run, and :api-key/:api-keys on auth/create-api-key/auth/list-api-keys. The snake_case spelling silently reads as nil.
- Responses are parsed maps; the HTTP status is available as
(:ird/status (meta resp)) (202 means an async action was queued).
- Money is a decimal string, never a JSON number — balances, prices, costs. It carries more precision than a double survives, so compare and arithmetic with
bigdec, not >.
- Errors are
ex-infos: :ird/api-error (with :code, :status, :response), :ird/transport-error, :ird/domain-denied. Predicates in ird.errors.
Pagination
Cursor-paginated listings have a *-all companion that follows next_cursor and returns the collected vector: registrations/list-all-registrations, registrations/list-all-actions, credits/ledger-all, domains/directory-all, support/list-all-threads.
Offset-paginated listings have one too, walking limit/offset until a short page: ipfs/list-all-pins, ipfs/list-all-domain-pins. Both take an optional query map (filters plus a page :limit, default 100).
Async patterns
Jobs — enqueue → poll → result, or in one call:
(require '[ird.jobs :as jobs])
(jobs/run c (jobs/domain-availability-bulk ["a.ro" "b.ro"]))
(jobs/run c (jobs/llm-chat [{:role "user" :content "Salut!"}] {:max_tokens 256}))
(jobs/run c (jobs/embedding "text to embed"))
(jobs/run c (jobs/nlp-detect-language "ce limbă e asta?"))
Builders exist for all job types: domain-availability-bulk, embedding, llm-chat, llm-raw-completion, nlp-analyze, nlp-detect-language. Multimodal content-part helpers (jobs/text-part, jobs/image-part, jobs/user-message) are included forward-compatibly; the public API currently accepts string content only.
Registry / VPS actions — 202 responses return an action; poll registrations/get-action / vps/get-action until terminal.
IPFS uploads — grant → raw-byte POST → poll, or in one call:
(require '[ird.ipfs :as ipfs])
(ipfs/add-file c "site.tar" {:name "site"})
;; => {:cid "bafy..." :pin_id "..." :status "completed" ...}
IPFS gateway domains — serve your own pins from your own domain:
(ipfs/attach-domain c {:domain "cdn.example.ro" :pin_ids ["pin-1"]})
;; => {:domain {:id "gw-1" :dns_target "1.2.3.4" :pin_count 1 ...}}
;; point cdn.example.ro's A record at :dns_target (linked rows get it for free)
(ipfs/update-domain-pins c "gw-1" {:add ["pin-2"] :remove ["pin-1"]})
(ipfs/list-all-domain-pins c "gw-1")
;; => [{:id "pin-2" :cid "bafy..." :cid_v1 "bafy..." :state "pinned" ...}]
Bring-your-own-domain DNS — host a domain registered elsewhere on ird DNS:
(require '[ird.dns :as dns])
;; take the copy while the domain still resolves at its current host
(let [snapshot (domains/dns-snapshot c "example.com")
claim (:claim (dns/create-claim c {:domain "example.com"}))]
(dns/seed-claim c (:id claim) {:snapshot_id (:id snapshot)})
;; set (:nameservers claim) at the registrar, then:
(dns/verify-claim c (:id claim)))
P2P offline mailbox — the catch-up path for peers that are never online at the same time. Turn it on per app, then deposit an encrypted payload for a room and let a peer arriving later replay it:
(require '[ird.p2p :as p2p]
'[ird.mailbox :as mailbox])
(p2p/update-app c "app-1" {:mailbox_enabled true})
;; one short-lived token, reused across calls (every mint is charged)
(def creds (mailbox/credentials c "app-1" {:room db-address :ttl_sec 3600}))
;; => {:token "..." :endpoint "https://sueta.ap2p.ro/mailbox/v1" :ttl 3600 ...}
(mailbox/deposit creds db-address ciphertext)
;; => {:seq "1874163641836634112" :id "51f2..." :expires_at ... :deduped false}
(mailbox/replay-all creds db-address {:after cursor})
;; => {:messages [{:seq "..." :id "..." :ts ... :payload "<base64>"}]
;; :cursor "1874163641836634112" :gap? false}
Calls go to the app's own node, not api.ird.ro; credentials mints the token and hands you the :endpoint in one map. Payloads are opaque to the node, which holds no keys — encrypt before depositing. mailbox/payload-bytes and payload-string decode a replayed message.
Two things the node's design asks of you:
:seq is a string and must stay one. It packs a millisecond clock into its high bits and is far past what a JSON number survives; compare seqs as opaque ordered tokens. mailbox/history-gap? does it correctly.
- Storage is bounded, by TTL and by a per-app ring buffer, so history can be truncated between your visits. Check your cursor against each page with
mailbox/history-gap? (replay-all returns it as :gap?) and report a gap rather than pretending the room was quiet.
Identical payloads deduplicate per room, so retries — and the same block deposited by five peers — store and bill once (:deduped true).
Tests
clojure -M:test # unit + full-coverage table (offline)
clojure -M:test --focus-meta :integration # live read-only tests; needs IRD_API_KEY (env or .env)
The coverage test invokes every one of the 161 wrapped operations against a capturing transport and asserts the exact method and URL, so client and spec cannot drift silently. spec/openapi.json is the spec snapshot the client was built against.
That pins the client to the spec, but says nothing about whether the spec still matches the API. The :integration run adds a conformance sweep that closes the gap: it calls every read endpoint live and validates each response body against the schema the spec declares for it — types, required keys, enum membership, and (since every object is additionalProperties: false) fields the server returns that the schema never mentions. It is read-only, creates nothing, and skips endpoints whose resource the account does not have.
It has found three real spec bugs so far — two endpoints returning fields their own schema forbade, and an action-type enum missing a value the API demonstrably returns — all since fixed upstream. ird.spec-check-test runs offline and pins each class of drift the detector is meant to catch, because a conformance sweep whose detector quietly stopped detecting would pass forever.
Build
clojure -T:build jar # target/ird-clj-0.2.0.jar
clojure -T:build install # to local maven repo
clojure -T:build deploy # to Clojars (CLOJARS_USERNAME/CLOJARS_PASSWORD)
License
MIT