Go client for the regfish API v1 — domains, DNS records and zones, DNSSEC, web hosting and TLS certificates.
go get github.com/regfish/regfish-dnsapi-gopackage main
import (
"context"
"fmt"
"log"
"os"
regfishapi "github.com/regfish/regfish-dnsapi-go"
)
func main() {
client := regfishapi.NewClient(os.Getenv("RF_API_KEY"))
ctx := context.Background()
record, err := client.AddRecord(ctx, regfishapi.Record{
Name: "www.example.com.",
Type: regfishapi.RecordTypeA,
Data: "10.2.3.4",
TTL: 600,
})
if err != nil {
log.Fatal(err)
}
fmt.Println("created rrid", record.ID)
}The API key is created in the regfish dash under Account → Security → API
keys and is sent as the x-api-key header.
Runnable programs live in examples/:
| Example | Shows |
|---|---|
create-record |
create, read and delete a record; branching on the error code |
permission-check |
inspecting the key and failing a deploy on an under-scoped one |
zone-export |
zone summary and BIND export |
resilient-writer |
a retry loop that does not hammer a permanent refusal |
RF_API_KEY=... RF_TEST_DOMAIN=example.com go run ./examples/permission-checkclient := regfishapi.NewClient(apiKey,
regfishapi.WithBaseURL("https://api.regfish.com"),
regfishapi.WithHTTPClient(&http.Client{Timeout: 10 * time.Second}),
regfishapi.WithUserAgent("my-app/1.0"),
)The default endpoint is https://api.regfish.de. https://api.regfish.com
serves the same API and is the endpoint listed in the OpenAPI specification.
Every failed call returns an *APIError carrying the HTTP status as well as
the regfish error code and message:
_, err := client.AddRecord(ctx, record)
switch {
case regfishapi.ErrorCode(err) == regfishapi.CodeResourceRecordAlreadyExists:
// record with this name/type/data already exists
case regfishapi.IsRateLimited(err):
// daily or per-minute limit reached
case err != nil:
log.Fatal(err)
}Use err.Reason() rather than reading Message or Detail directly — the API
puts its explanation in message on some endpoints and in error on others.
An HTTP status is not enough to decide what to do about a refusal. DenialOf
turns the response into one value you can branch on:
switch regfishapi.DenialOf(err) {
case regfishapi.DenialPermission:
if perm, ok := regfishapi.MissingPermission(err); ok {
log.Fatalf("grant %s to this API key in the dash", perm)
}
log.Fatalf("this API key is not permitted to do that: %v", err)
case regfishapi.DenialScope:
log.Fatal("this is a scope-bound platform credential; use a customer key")
case regfishapi.DenialUnmappedOperation:
op, _ := regfishapi.DeniedOperation(err) // server-side gap, report it
log.Fatalf("regfish has no permission mapping for %q", op)
case regfishapi.DenialGuardian:
log.Print("Domain-Guardian blocked this — confirm it in the dash")
case regfishapi.DenialQuota:
// retry after the window or the UTC day rolls over
case regfishapi.DenialUnauthenticated:
log.Fatal("the API key was not accepted")
}MissingPermission and DeniedOperation read the name out of the API's own
message, so they are never stale — but they report false if the server ever
rewords it. Always handle the not-ok branch.
The shorthands are IsPermissionDenied, IsDomainGuardianBlocked and
IsPermanent.
If you retry on 5xx, check IsDomainGuardianBlocked first. The API reports
a Domain-Guardian block as HTTP 403 on the domain endpoints but as HTTP 500
on the DNS record and DNSSEC endpoints, where nothing but the message text
separates it from a genuine, retryable server fault. A naive retry loop will
hammer a decision that cannot succeed until a human clicks a button in the dash:
for attempt := 0; attempt < 5; attempt++ {
_, err = client.AddRecord(ctx, record)
if err == nil || regfishapi.IsPermanent(err) {
break
}
time.Sleep(time.Duration(1<<attempt) * time.Second)
}IsPermanent is one-directional: true means no retry can clear it, false means
only "not known to be permanent". Cap your retries either way.
A key is one of three kinds, and TokenInfo.Kind is the only safe way to tell
them apart:
| Kind | permissions on the wire |
Meaning |
|---|---|---|
KeyKindLegacy |
null |
Created before RBAC — full access |
KeyKindRBAC |
[...], possibly [] |
Exactly the listed permissions; [] means none |
KeyKindCapability |
null, plus a scope |
Platform credential (e.g. ACME DNS-01), limited to a fixed set of operations whatever its permissions |
null and [] mean opposite things, and a capability-scoped key looks like a
legacy key if you only read Permissions. Use Permits, which returns a
tri-state and is scope-aware:
info, err := client.GetTokenInfo(ctx)
switch info.Permits(regfishapi.PermissionDNSWrite) {
case regfishapi.DecisionAllowed: // probably fine
case regfishapi.DecisionDenied: // will be refused
case regfishapi.DecisionUnknown: // scope-bound key — permissions do not describe it
}Check the key once at startup, so an under-scoped key fails the deploy instead of the first write hours later:
report, err := client.CheckPermissions(ctx,
regfishapi.PermissionDNSRead, regfishapi.PermissionDNSWrite)
if err != nil {
log.Fatal(err)
}
if !report.Satisfied() {
log.Fatal(report) // "regfish API key kAbC [rbac]: missing dns:write"
}Gate on Satisfied(), not on len(report.Missing): a capability-scoped
credential has no missing permissions and still cannot do the work, so those
land in report.Undetermined and a Missing-only check would wave it through.
The library never refuses a call on its own. It keeps no copy of the
server's operation-to-permission table, so it can never deny something the
server would have allowed, and it cannot go stale when regfish adds an
endpoint. CheckPermissions and Permits are advisory diagnostics; the API
remains the only authority. A clean report is not a promise either — it cannot
see Domain-Guardian rules, domain ownership, plan entitlements or quotas.
To rehydrate cached token metadata, use ParseTokenInfo rather than
json.Unmarshal: a payload that lost its permissions member would otherwise
decode to a nil slice and read as a legacy key with full access.
All v1 operations are implemented. Every method takes a context.Context as
its first argument.
The Permission column is what to pass to CheckPermissions. It is
documentation, not enforcement — the library never consults it, so it cannot
cause a request to be refused. It mirrors the server's mapping as of API
version 1.6.2 (2026-08-01); /meta/token and the dash are authoritative. Note
that read and write do not split the way the method names suggest:
DownloadCertificate needs tls:read, and every hosting method needs
hosting:read.
The operationId column matters when handling denials: DeniedOperation
returns the API's operationId, which differs from the Go method name in one
case (ListRecordsByDomain → GetRecordsByDomain).
| Method | Endpoint | operationId | Permission |
|---|---|---|---|
GetTokenInfo |
GET /meta/token |
GetTokenInfo |
— (any valid key) |
CheckPermissions |
GET /meta/token |
GetTokenInfo |
— (any valid key) |
DownloadOpenAPISpec |
GET /openapi.yaml |
DownloadOpenAPISpec |
— (any valid key) |
| Method | Endpoint | operationId | Permission |
|---|---|---|---|
ListDomains |
GET /domains |
ListDomains |
domain:read |
GetDomainByName |
GET /domains/{domain} |
GetDomainByName |
domain:read |
GetNameserversByDomain |
GET /domains/{domain}/nameservers |
GetNameserversByDomain |
domain:read |
PutNameserversByDomain |
PUT /domains/{domain}/nameservers |
PutNameserversByDomain |
domain:write |
RequestAuthinfoByDomain |
POST /domains/{domain}/authinfo |
RequestAuthinfoByDomain |
domain:write |
| Method | Endpoint | operationId | Permission |
|---|---|---|---|
ListRecordsByDomain |
GET /dns/{domain}/rr |
GetRecordsByDomain |
dns:read |
GetRecordByRRID |
GET /dns/rr/{rrid} |
GetRecordByRRID |
dns:read |
AddRecord |
POST /dns/rr |
AddRecord |
dns:write |
PatchRecord |
PATCH /dns/rr |
PatchRecord |
dns:write |
PatchRecordByRRID |
PATCH /dns/rr/{rrid} |
PatchRecordByRRID |
dns:write |
DeleteRecordByRRID |
DELETE /dns/rr/{rrid} |
DeleteRecordByRRID |
dns:write |
AddRecord detects the zone from the record's fully qualified Name.
PatchRecord matches an existing record by name and type — and, for every
type except A and AAAA, by its current data — and requires a unique match;
use PatchRecordByRRID to change a record's name or type. ListRecordsByDomain
does not include NS records; GetDNSZoneByDomain does.
| Method | Endpoint | operationId | Permission |
|---|---|---|---|
ListDNSZones |
GET /dns/zones |
ListDNSZones |
dns:read |
GetDNSZoneByDomain |
GET /dns/zones/{domain} |
GetDNSZoneByDomain |
dns:read |
ExportDNSZoneBindByDomain |
GET /dns/zones/{domain}/export |
ExportDNSZoneBindByDomain |
dns:read |
| Method | Endpoint | operationId | Permission |
|---|---|---|---|
GetDNSSECByDomain |
GET /dns/{domain}/dnssec |
GetDNSSECByDomain |
dns:read |
PutDNSSECByDomain |
PUT /dns/{domain}/dnssec |
PutDNSSECByDomain |
dns:write |
CancelDNSSECByDomain |
POST /dns/{domain}/dnssec/cancel |
CancelDNSSECByDomain |
dns:write |
VerifyDNSSECByDomain |
POST /dns/{domain}/dnssec/verify |
VerifyDNSSECByDomain |
dns:write |
ListDNSSECJobsByDomain |
GET /dns/{domain}/dnssec/jobs |
ListDNSSECJobsByDomain |
dns:read |
| Method | Endpoint | operationId | Permission |
|---|---|---|---|
ListHostingPackages |
GET /hosting/packages |
ListHostingPackages |
hosting:read |
GetHostingPackage |
GET /hosting/packages/{id} |
GetHostingPackage |
hosting:read |
ListHostingAliases |
GET /hosting/packages/{id}/aliases |
ListHostingAliases |
hosting:read |
ListHostingDatabases |
GET /hosting/packages/{id}/databases |
ListHostingDatabases |
hosting:read |
| Method | Endpoint | operationId | Permission |
|---|---|---|---|
ListTLSProducts |
GET /tls/products |
ListTLSProducts |
tls:read |
ListCertificates |
GET /tls/certificate |
ListCertificates |
tls:read |
CreateCertificate |
POST /tls/certificate |
CreateCertificate |
tls:write |
GetCertificateByID |
GET /tls/certificate/{id} |
GetCertificateByID |
tls:read |
CompleteCertificate |
POST /tls/certificate/{id}/complete |
CompleteCertificate |
tls:write |
CancelCertificate |
POST /tls/certificate/{id}/cancel |
CancelCertificate |
tls:write |
RevokeCertificate |
POST /tls/certificate/{id}/revoke |
RevokeCertificate |
tls:write |
CancelIssuedCertificateOrder |
POST /tls/certificate/{id}/order-cancel |
CancelIssuedCertificateOrder |
tls:write |
ReissueCertificate |
POST /tls/certificate/{id}/reissue |
ReissueCertificate |
tls:write |
DownloadCertificate |
GET /tls/certificate/{id}/download/{format} |
DownloadCertificate |
tls:read |
ListOrganizations |
GET /tls/organization |
ListOrganizations |
tls:read |
CreateOrganization |
POST /tls/organization |
CreateOrganization |
tls:write |
GetOrganizationByID |
GET /tls/organization/{id} |
GetOrganizationByID |
tls:read |
PatchOrganizationByID |
PATCH /tls/organization/{id} |
PatchOrganizationByID |
tls:write |
The previous methods still work and delegate to the new ones, but they are deprecated because they cannot be cancelled:
| Deprecated | Replacement |
|---|---|
GetRecord(rrid) |
GetRecordByRRID(ctx, rrid) |
CreateRecord(rec) |
AddRecord(ctx, rec) |
UpdateRecord(rec) |
PatchRecord(ctx, rec) |
UpdateRecordById(rrid, rec) |
PatchRecordByRRID(ctx, rrid, rec) |
DeleteRecord(rrid) |
DeleteRecordByRRID(ctx, rrid) |
GetRecordsByDomain(domain) |
ListRecordsByDomain(ctx, domain) |
Request(...) |
RequestContext(ctx, ...) |
Behavioural changes to be aware of — this is not a drop-in no-op:
- Failed requests now return
*APIErrorwith the API's error code and message instead of a barerequest failed with status code N. Code that matched on the old error string will not match any more. NewClientsets a 60 second timeout on its default HTTP client, where v0 had none. Calls that previously hung indefinitely now fail; passWithHTTPClient(&http.Client{})to restore the old behaviour.- Write payloads no longer carry
id, andRecordgained four read-only response fields (Auto,Active,TSCreated,TSUpdated), which changes whatjson.Marshalof aRecordproduces.
Unit tests run against a stub server and need no credentials:
go test ./...The integration tests talk to the live API and create, update and delete a real DNS record on the domain you point them at:
RF_API_KEY=... RF_TEST_DOMAIN=example.com go test -tags integration -vRF_API_BASE_URL overrides the endpoint. Subtests whose permission the key does
not hold are skipped rather than failed, so a least-privilege RBAC key reports
honestly instead of looking like a library regression.