Reseller program

Reseller API

Manage your clients' domains and DNS records from your own tools: a client portal, a deploy script or your agency's back office.

Overview

  • Base URL: https://www.bluewhaledns.com/api/v1/reseller. HTTPS only. Use www: https://bluewhaledns.com/api/… also works, but the site itself lives on www.
  • REST: resources are nouns in the path, and the HTTP method says what happens to them.
  • Every request and response body is JSON (Content-Type: application/json), errors, deletes and unknown paths included.
  • The API works while your reseller plan is active. Keys are made in the reseller dashboard under API.
  • Changes to /api/v1 only add fields and endpoints: nothing your code reads is renamed or removed.

Quick start

  1. Create an API key in the reseller dashboard. It's shown once: keep it on your server.
  2. Send it as the bearer on every call.
# Your domains
curl https://www.bluewhaledns.com/api/v1/reseller/domains \
  -H "Authorization: Bearer bwd_live_6f1c…_9a2b…"

# Add a record
curl -X POST https://www.bluewhaledns.com/api/v1/reseller/domains/example.com/dns-records \
  -H "Authorization: Bearer bwd_live_6f1c…_9a2b…" \
  -H "Content-Type: application/json" \
  -d '{"type":"A","name":"www","value":"203.0.113.10","ttl":3600}'

Authentication

Every call sends Authorization: Bearer <credential>. The credential is your API key (bwd_live_…) or, if you'd rather not send the key on every call, a short-lived access token.

POST/auth/token
Optional. Send your API key as the bearer; the answer is an access token (a signed JWT) valid for one hour.
HTTP/1.1 200 OK
{
  "token_type": "Bearer",
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "expires_in": 3600,
  "expires_at": "2026-10-19T16:04:11.000Z"
}
  • An expired or altered token answers 401 invalid_token: request a new one.
  • Both are checked against your key and plan on every call: revoking the key, or a plan that lapses, stops them at once (401 key_revoked, 403 reseller_inactive).
  • Test keys (bwd_test_…) can read, but every change answers 403 test_mode_unavailable for now.
  • Never put an API key or token in a web page or app your clients can see. Call the API from your server.

Clients and ownership

The API acts for your reseller account only. You buy domains and services for your clients on your account, then put each one on a client in the dashboard (Domains, Services, or the client's page).

  • Every domain call checks that the domain in the path is an active registration on your account. {domain} can be the domain name (example.com) or its id from GET /domains.
  • Add ?client_id=<id> to any domain or record call to limit it to one client's domains. A portal you build for a client can pass that client's id on every call, so it can't reach anyone else's domains.
  • A domain that isn't yours, isn't active, or isn't that client's answers 404 domain_not_found. The API never says whether someone else holds it.

Rate limits

PlanRequests a minute
Starter60
Grow180
Pro600
  • One budget per account for the whole API (/api/v1/reseller/… and /api/v1/domains/… together), counted per minute.
  • Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time, in seconds, when the minute starts again).
  • Past the limit, calls answer 429 rate_limited with Retry-After (seconds). The token endpoint has its own limit of 30 requests a minute from one address.
  • When our domain Provider is limiting us or down, calls that need it answer 429 upstream_rate_limited or 503 upstream_unavailable, also with Retry-After.

Endpoints

POST/auth/token
GET/domains
GET/domains/check?domains=…
POST/domains/register
POST/domains/transfer
GET/domains/{domain_name_or_id}
POST/domains/{domain_name_or_id}/renew
GET/domains/{domain_name_or_id}/nameservers
PUT/domains/{domain_name_or_id}/nameservers
GET/domains/{domain_name_or_id}/dns-records
POST/domains/{domain_name_or_id}/dns-records
PUT/domains/{domain_name_or_id}/dns-records/rrset
GET/domains/{domain_name_or_id}/dns-records/{record_id}
PATCH/domains/{domain_name_or_id}/dns-records/{record_id}
PUT/domains/{domain_name_or_id}/dns-records/{record_id}
DELETE/domains/{domain_name_or_id}/dns-records/{record_id}
GET/orders/{order_id}
GET/pricing?tlds=…

Paths are relative to https://www.bluewhaledns.com/api/v1/reseller. The earlier https://www.bluewhaledns.com/api/v1/domains, /domains/check, /domains/register and /domains/transfer stay available with the same answers.

Domains

The domain object

FieldType
idstring | nullThe domain's id; null while it's pending.
namestringThe domain name, lower case.
statusstringactive, or pending while its registration or transfer is being set up.
expires_atstring | nullWhen the registration ends (ISO 8601).
auto_renewboolean
client_idstring | nullThe client it's on, if any.
order_idstringPending domains only: the order to follow with GET /orders/{order_id}.
GET/domains?page=1&per_page=25&client_id=…
Pending domains first, then your active domains, A to Z; 25 a page (at most 100). client_id is optional.
{
  "page": 1,
  "per_page": 25,
  "total": 3,
  "domains": [
    { "id": null, "name": "new-client.com", "status": "pending", "expires_at": null,
      "auto_renew": true, "client_id": null, "order_id": "5b1e…" },
    { "id": "d6a1…", "name": "client-a.com", "status": "active",
      "expires_at": "2027-03-02T00:00:00Z", "auto_renew": true, "client_id": "c41f…" },
    { "id": "8b02…", "name": "client-b.net", "status": "active",
      "expires_at": "2026-12-18T00:00:00Z", "auto_renew": true, "client_id": null }
  ]
}
GET/domains/{domain_name_or_id}
{ "domain": { … } }. A pending domain is found by its name.

The earlier GET /api/v1/domains lists every registration on the account, newest first, with the same object plus domain, years and created_at.

Orders

Orders are priced at your plan, charged at once to the card saved under API in the reseller dashboard, and set up in the background: they answer 202 with an order_id to follow.

GET/domains/check?domains=acme.com,acme.io
Availability and your price for a first year, 1 to 20 domains.
{ "results": [
  { "domain": "acme.com", "available": true, "price_cents": 1499, "currency": "USD" },
  { "domain": "acme.io", "available": false, "price_cents": null, "currency": "USD" }
] }
POST/domains/register
{ "domain": "acme.com", "years": 1, "nameservers": ["ns1.example.net", "ns2.example.net"] }. nameservers is optional (2 to 8); without it the domain gets our DNS, which you manage with the DNS record endpoints.
HTTP/1.1 202 Accepted
{
  "order_id": "5b1e…", "job_id": "9c07…", "domain": "acme.com", "years": 1,
  "price_cents": 1499, "currency": "USD", "status": "queued",
  "registration": { "id": null, "name": "acme.com", "status": "pending", "expires_at": null,
                    "auto_renew": true, "client_id": null, "order_id": "5b1e…" }
}
POST/domains/transfer
{ "domain": "acme.com", "auth_code": "…", "years": 1 }. The domain keeps its current nameservers, so its DNS keeps working while it moves (usually up to 5 days).
POST/domains/{domain}/renew
{ "years": 1 }. Adds years to an active domain, at most up to 10 years from today. The answer also has renews_to (the new expiry date). A domain bought in the store, with a plan in the dashboard, renews through its plan instead (409 renews_with_billing).
GET/orders/{order_id}
Where an order is.
{
  "id": "5b1e…", "kind": "register", "domain": "acme.com", "years": 1,
  "status": "completed", "price_cents": 1499, "currency": "USD",
  "created_at": "2026-10-20T09:00:00Z", "completed_at": "2026-10-20T09:41:12Z"
}
  • kind: register, transfer or renew. status: queued, processing, completed or failed (then with error: code and message). A transfer stays processing until the registry completes it.
  • warnings appears when something needs you, e.g. nameservers_not_set: the registry didn't take the nameservers sent with the registration.
GET/pricing?tlds=com,net,co.uk
Your yearly prices per extension, 1 to 20 extensions.
{ "prices": [
  { "tld": "com", "register_cents": 1499, "renew_cents": 1699, "transfer_cents": 1499, "currency": "USD" },
  { "tld": "co.uk", "register_cents": 899, "renew_cents": 899, "transfer_cents": null, "currency": "USD" }
] }

Prices are for a standard name; a premium or sale-priced name differs, and /domains/check gives its exact price. null means that operation isn't available for the extension.

Retrying safely: Idempotency-Key

Register, transfer and renew charge your card, so a retry after a timeout must not order twice. Send an Idempotency-Key header (1 to 255 characters, e.g. your own order id or a UUID) and send the same key, with the same body, on every retry of that order.

curl -X POST https://www.bluewhaledns.com/api/v1/reseller/domains/register \
  -H "Authorization: Bearer bwd_live_6f1c…_9a2b…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-81723" \
  -d '{"domain":"acme.com","years":1}'
  • The same key and body within 24 hours answers with the first answer (same status and body, plus Idempotent-Replayed: true). Your card is charged once.
  • Answers from before the charge (a validation error, the domain taken, the Provider busy) don't use the key up: fix the problem and retry with the same key. Once the card was charged or declined, the answer is final for that key; after a declined card, use a new key.
  • The same key with a different body, or on another endpoint or domain, answers 422 idempotency_key_reused.
  • A retry while the first request is still running answers 409 request_in_progress: wait a few seconds and retry.
  • Without a key, a second order for a domain whose first order is still being set up answers 409 order_pending with details.order_id.

Nameservers

GET/domains/{domain}/nameservers
{ "domain": { "id", "name", "client_id" }, "nameservers": ["…", "…"] }
PUT/domains/{domain}/nameservers
{ "nameservers": ["ns1.example.net", "ns2.example.net"] }: 2 to 8 host names. The answer has the new list and changed (false when they were already set).
  • With other nameservers, the domain's records are managed there: the DNS record endpoints then answer 409 dns_not_managed.
  • Nameservers inside the domain itself (e.g. ns1.acme.com for acme.com) need glue records: 400 nameservers_need_glue, contact support.
  • To move a domain to BlueWhale DNS, use its Nameservers tab in the dashboard, which copies the records into a zone first (400 bluewhale_nameservers here).

DNS records

The record object

FieldType
idstringThe record's id (see Record ids).
typestringA, AAAA, CNAME, MX, TXT, SRV, NS, CAA, ALIAS
namestringRelative to the domain; @ is the domain itself.
fqdnstringThe full name, for reading only.
valuestringDepends on the type (see Validation rules).
ttlintegerSeconds, as the zone really serves it.
priorityinteger | nullMX and SRV only.
weightinteger | nullSRV only.
portinteger | nullSRV only.
read_onlybooleanTrue for records the API can't change, such as the domain's own NS records.
managed_bystring | nullalias for the A/AAAA records an ALIAS record keeps up to date (they're read-only); otherwise null.

List records

GET/domains/{domain}/dns-records?type=MX&name=@
Every record in the zone. type and name narrow the list.
{
  "domain": { "id": "d6a1…", "name": "client-a.com", "client_id": "c41f…" },
  "dns": {
    "host": "bluewhale_dns",
    "nameservers": ["ns1.bluewhaledns.net", "ns2.bluewhaledns.net", "ns3.bluewhaledns.net", "ns4.bluewhaledns.net"],
    "record_types": ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "ALIAS"],
    "ttl": { "fixed": 3600 }
  },
  "records": [
    { "id": "rec_3f9a0c1e2b7d4a5f6e8c9b0a", "type": "A", "name": "@", "fqdn": "client-a.com",
      "value": "203.0.113.10", "ttl": 3600, "priority": null, "weight": null, "port": null,
      "read_only": false, "managed_by": null },
    { "id": "rec_9c2e4b6a8d0f1e3c5a7b9d1f", "type": "MX", "name": "@", "fqdn": "client-a.com",
      "value": "mx1.example-mail.net", "ttl": 3600, "priority": 10, "weight": null, "port": null,
      "read_only": false, "managed_by": null }
  ],
  "total": 2
}

Get one record

GET/domains/{domain}/dns-records/{record_id}
{ "domain", "dns", "record" }

Create a record

POST/domains/{domain}/dns-records
Answers 201 with the new record and a Location header. warnings says when the zone serves a different TTL from the one you sent.
POST /domains/client-a.com/dns-records
{ "type": "MX", "name": "@", "value": "mx2.example-mail.net", "priority": 20, "ttl": 300 }

HTTP/1.1 201 Created
Location: /api/v1/reseller/domains/d6a1…/dns-records/rec_1d7f…
{
  "domain": { … },
  "dns": { … },
  "record": { "id": "rec_1d7f…", "type": "MX", "name": "@", "fqdn": "client-a.com",
              "value": "mx2.example-mail.net", "ttl": 3600, "priority": 20, "weight": null, "port": null, "read_only": false },
  "warnings": [ { "code": "ttl_adjusted", "message": "BlueWhale DNS serves every record with a 3600-second TTL." } ]
}

Update a record

PATCH/domains/{domain}/dns-records/{record_id}
Changes only the fields you send; the others stay as they are. Changing the type drops the old priority, weight and port unless you send new ones.
PUT/domains/{domain}/dns-records/{record_id}
Replaces the record: send every field, as for a new record.
PATCH /domains/client-a.com/dns-records/rec_9c2e4b6a8d0f1e3c5a7b9d1f
{ "priority": 5 }

HTTP/1.1 200 OK
{ "domain": { … }, "dns": { … }, "record": { "id": "rec_77b0…", "type": "MX", "priority": 5, … }, "warnings": [] }

The answer carries the record's new id.

Delete a record

DELETE/domains/{domain}/dns-records/{record_id}
Removes it permanently.
HTTP/1.1 200 OK
{ "deleted": true, "id": "rec_77b0…", "domain": { "id": "d6a1…", "name": "client-a.com", "client_id": "c41f…" } }

Record sets

Say what a name's records of one type should be, and we make the zone match in one change: records already right are left alone, the rest are added or removed. Sending the same set again changes nothing, so it's safe to call on every deploy instead of listing, comparing, deleting and adding records one at a time.

PUT/domains/{domain}/dns-records/rrset
{ "name", "type", "values": [...], "ttl"?, "priority"? }
FieldRule
nameAs for a record (@ for the domain itself).
typeOne of A, AAAA, CNAME, MX, TXT, SRV, NS, CAA, ALIAS.
valuesRequired: the record values, each checked as a record's value is; at most 50. Duplicates are dropped. [] deletes every record of that name and type. CNAME and ALIAS take one value.
ttlOptional, as for a record; the same for every value.
priorityMX and SRV. A value may carry its own instead: "10 mx1.example.net" for MX, "10 5 5060 sip.example.net" (priority, weight, port, target) for SRV.
PUT /domains/client-a.com/dns-records/rrset
{ "name": "@", "type": "MX", "values": ["10 mx1.example-mail.net", "20 mx2.example-mail.net"], "ttl": 3600 }

HTTP/1.1 200 OK
{
  "domain": { … },
  "dns": { … },
  "name": "@",
  "type": "MX",
  "records": [
    { "id": "rec_9c2e…", "type": "MX", "name": "@", "value": "mx1.example-mail.net", "priority": 10, … },
    { "id": "rec_51ad…", "type": "MX", "name": "@", "value": "mx2.example-mail.net", "priority": 20, … }
  ],
  "changed": { "added": 1, "removed": 0, "updated": 0, "kept": 1 },
  "warnings": []
}
  • records is the set after the change. changed counts records added, removed, updated (only their TTL changed) and kept as they were.
  • The same conflicts as single records apply (409 cname_conflict, alias_conflict). Records in the set the API can't change answer 409 record_read_only.
  • With "type": "ALIAS" it sets or deletes the domain's ALIAS record, replacing any A and AAAA records on the domain itself (see ALIAS records).

ALIAS records

A CNAME can't be on the domain itself, so pointing client-a.com (not just www) at your app usually means copying your app's IP addresses into A records, which go stale when they change. An ALIAS record does it for you: give it a host name and we keep the domain's A and AAAA records set to that host's addresses.

POST /domains/client-a.com/dns-records
{ "type": "ALIAS", "name": "@", "value": "saas-target.example.net" }

# or, replacing any A/AAAA records the domain has:
PUT /domains/client-a.com/dns-records/rrset
{ "name": "@", "type": "ALIAS", "values": ["saas-target.example.net"] }
  • Only on @, with a fully qualified host name as the value (not the domain itself). ttl defaults to 900.
  • We look up the host's IPv4 and IPv6 addresses and write them as the domain's A and AAAA records in the same change. A host with no addresses answers 422 (alias_target_unresolvable).
  • Every 10 minutes we look the host up again and change only the records that differ. If the host can't be looked up, the records are kept as they are.
  • Lists show one ALIAS record, and the A/AAAA records it keeps with "read_only": true and "managed_by": "alias". Change or delete the ALIAS, not those records.
  • While a domain has an ALIAS it can't have other A/AAAA records on @ (409 alias_conflict); a plain create is refused when it already has some (use the record set, which replaces them). Deleting the ALIAS removes its A/AAAA records.
  • Check for ALIAS in dns.record_types before relying on it.

How soon a change of address is seen

dns.hostLongest wait after your host's addresses change
bluewhale_dns10 minutes, plus up to 3600 seconds (1 hour) for resolvers' caches.
provider_dns10 minutes, plus the ALIAS's TTL (at least 900 seconds) for resolvers' caches.

When you move your app to new addresses, keep the old ones answering for that long.

Validation rules

A record that breaks a rule answers 422 validation_failed, with every problem listed in error.details.errors (each has field, code and message). Unknown fields are refused too, so a typo like prio doesn't pass silently.

FieldRule
typeRequired. One of A, AAAA, CNAME, MX, TXT, SRV, NS, CAA, ALIAS (case doesn't matter).
nameRequired. A subdomain such as www, shop.eu, _dmarc or *.dev, or @ for the domain itself. Letters, digits, dashes and underscores; each part at most 63 characters; * only first. A full name is shortened for you: www.example.com (or www.example.com.) becomes www, and example.com becomes @.
valueRequired. Checked against the type (below).
ttlOptional, default 3600 (900 for ALIAS). A whole number of seconds from 60 to 86400.
priorityRequired for MX and SRV: a whole number from 0 to 65535. Must be null or left out for every other type.
weight, portSRV only, each a whole number from 0 to 65535. Leave them out for other types.
TypeValue
AAn IPv4 address, e.g. 203.0.113.10.
AAAAAn IPv6 address, e.g. 2001:db8::10 (stored in its standard short form).
CNAMEA fully qualified host name, e.g. client-a.netlify.app. Not on @, and not on a name that has other records.
MXThe mail server's host name, with a priority.
TXTText up to 2048 characters, printable ASCII. Surrounding quotes are optional; long values are split for you.
SRVThe target host name, with priority, weight and port ("5 5060 sip.example.net" as the value also works). The name starts with _service._protocol, e.g. _sip._tcp.

Host names (CNAME, MX, NS and SRV targets) must be fully qualified: at least two labels ending in a real extension. A final dot is removed. An exact copy of an existing record answers 409 duplicate_record; a CNAME sharing a name with other records answers 409 cname_conflict.

Where records are served

The domain's nameservers decide where its records live, and so what they can hold. dns.host in every answer says which.

dns.hostNameserversTypesTTL
bluewhale_dnsns1–ns4.bluewhaledns.netA, AAAA, CNAME, MX, TXT, SRV, ALIASAlways 3600 seconds; any TTL you send is accepted and noted in warnings.
provider_dnsThe registrar's ownA, AAAA, CNAME, MX, TXT, SRV, NS, CAA, ALIASOne of 900, 3600, 10800, 21600, 43200, 86400; others are raised to the next one up, and noted in warnings.

A type the zone doesn't take answers 422 with unsupported_record_type. A domain on other nameservers (Cloudflare, another host…) answers 409 dns_not_managed: its records are managed there. Both zones take ALIAS: it's written as A and AAAA records.

Record ids

A record's id comes from what it holds (type, name, value, priority, weight and port), so it stays the same however often you read the zone, and changes when the record changes. A TTL-only change keeps it. After an update use the id in the answer; an old id answers 404 record_not_found. If in doubt, list the zone again.

Webhooks

Instead of polling orders, let us tell you. Add up to 5 HTTPS endpoints in the reseller dashboard under API → Webhooks, for every event or the ones you pick. Each endpoint gets a signing secret (whsec_…), shown once when you add it.

EventWhen
domain.registeredA registration finished: the domain is yours.
domain.registration_failedA registration failed for good (support refunds or finishes it).
domain.transfer_completedA transfer in finished at the registry.
domain.transfer_failedA transfer in was refused or failed at the registry.
domain.renewedA domain was renewed.
domain.expiringA domain expires in 30 days, and again at 7 days.
order.failedAny register, transfer or renew order failed for good.

Each event is a POST with a JSON body. order.failed comes with domain.registration_failed or domain.transfer_failed where one applies, and on its own for a renewal. The dashboard's "Send test event" sends webhook.test.

POST https://app.example.com/webhooks/bluewhale
content-type: application/json
webhook-id: msg_2c1f9e0b8a7d4c3e9f1a6b5d4c3e2f1a
webhook-timestamp: 1792512000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "type": "domain.registered",
  "timestamp": "2026-10-20T14:00:00.000Z",
  "data": {
    "domain": { "name": "client-a.com", "client_id": "c41f…", "expires_at": "2027-10-20T14:00:00.000Z" },
    "order_id": "7b0e…",
    "years": 1
  }
}
Eventdata
domain.registered{ domain: { name, client_id, expires_at }, order_id, years }
domain.renewed{ domain: { name, client_id, expires_at }, order_id, years }
domain.transfer_completed{ domain: { name, client_id, expires_at }, order_id }
domain.registration_failed, domain.transfer_failed{ domain: { name, client_id }, order_id, error: { code, message } }
domain.expiring{ domain: { name, client_id, expires_at, auto_renew }, days_left, threshold: 30 | 7 }
order.failed{ order: { id, kind: "register" | "transfer" | "renew", domain, status: "failed", error } }

Checking the signature

Events are signed with the Standard Webhooks scheme, so their libraries (e.g. standardwebhooks on npm) verify them with your secret. By hand: HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of the secret after whsec_, base64-encoded, must equal one of the space-separated v1,… signatures. Refuse timestamps more than 5 minutes from now.

import crypto from "node:crypto";

function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
  return headers["webhook-signature"].split(" ").some((s) => {
    const sig = s.slice(s.indexOf(",") + 1);
    return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

Delivery and retries

  • Answer with any 2xx within 10 seconds; do slow work afterwards. Redirects aren't followed.
  • Anything else is retried about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours later, with a last try 24 hours after the event. Retries go out with our 10-minute run, so allow a few minutes either way.
  • A retry has the same webhook-id (a new timestamp and signature): use it to ignore an event you've already handled. Events can arrive out of order.
  • The dashboard shows the latest deliveries and the last error for each endpoint. Turning an endpoint off drops its retries.

Errors

Every error, on every /api/v1 path, has the same shape: error.code for your code to check, error.message for people, and sometimes error.details.

{
  "error": {
    "code": "validation_failed",
    "message": "The record isn't valid.",
    "details": {
      "errors": [
        { "field": "value", "code": "invalid_ipv4", "message": "An A record's value must be an IPv4 address, e.g. 203.0.113.10." },
        { "field": "ttl", "code": "invalid_ttl", "message": "ttl must be a whole number of seconds from 60 to 86400." }
      ]
    }
  }
}
StatusCodeMeaning
400invalid_request, invalid_json, invalid_client_id, invalid_nameThe body or a query parameter isn't valid.
400invalid_idempotency_keyThe Idempotency-Key header isn't 1 to 255 printable characters.
400invalid_nameservers, nameservers_need_glue, bluewhale_nameserversSee Nameservers.
401invalid_auth, invalid_key, key_revoked, key_expired, invalid_secret, invalid_tokenNo credential, an unknown, revoked or expired key, or an expired or altered token.
402no_payment_method, payment_failedNo saved card, or the card was declined.
402authentication_requiredThe card needs the cardholder (3-D Secure); details.payment_intent_client_secret completes it.
403no_tier, tier_inactive, reseller_inactiveNo active reseller plan or pricing for this key.
403test_mode_unavailableA test key (bwd_test_…) tried to change something.
404domain_not_found, record_not_found, order_not_found, not_foundNot on your account (or that client's), no such record or order, or no endpoint at that path.
405method_not_allowedThe endpoint doesn't take that method; Allow lists the ones it does.
409duplicate_record, cname_conflict, record_read_only, dns_not_managedThe change clashes with the zone, or the zone isn't managed here.
409alias_conflict, alias_existsAn ALIAS and the domain's own A/AAAA records can't sit together, and a domain has one ALIAS.
409unavailable, already_with_us, not_transferable, too_many_years, renews_with_billingThe domain can't be ordered or renewed this way.
409order_pending, request_in_progressAn earlier order or request for it is still running.
409premium_dns, nameservers_unavailableIts nameservers can't be changed here: contact support.
413body_too_largeBodies are at most 64 KB.
422validation_failedSee details.errors.
422idempotency_key_reusedThe key was used for a different request.
429rate_limited, upstream_rate_limitedWait for Retry-After seconds, then retry (same Idempotency-Key).
500order_failed, queue_failedPaid, but the order needs support. Don't retry with a new key: contact support.
502dns_rejected, registry_rejectedThe DNS server or registry didn't keep the change. Nothing was half-done; try again.
503upstream_unavailable, no_price, no_expiry, not_configured, resolver_unavailableBriefly unavailable: wait for Retry-After (or a minute), then retry.

Warnings aren't errors: warnings in an answer lists things worth knowing, e.g. ttl_adjusted (the zone serves another TTL) or nameservers_not_set on an order.

Code examples

JavaScript (Node 18+)

const BASE = "https://www.bluewhaledns.com/api/v1/reseller";
const KEY = process.env.BLUEWHALE_API_KEY;

async function api(method, path, data, idempotencyKey) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      ...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
    },
    body: data ? JSON.stringify(data) : undefined,
  });
  const body = await res.json();
  if (!res.ok) throw Object.assign(new Error(body.error.message), body.error, { status: res.status });
  return body;
}

// Register a domain for one of your orders (safe to retry with the same key).
const order = await api("POST", "/domains/register", { domain: "client-a.com", years: 1 }, "my-order-1042");
console.log(order.order_id, order.registration.status); // "…", "pending"

// Point a client's domain at your app: www by CNAME, the domain itself by ALIAS.
// Safe to run on every deploy: a set that's already right isn't touched.
await api("PUT", "/domains/client-a.com/dns-records/rrset", { name: "www", type: "CNAME", values: ["saas-target.example.net"] });
await api("PUT", "/domains/client-a.com/dns-records/rrset", { name: "@", type: "ALIAS", values: ["saas-target.example.net"] });

// Point www at a new server for one client's domain, one record at a time.
const clientId = "c41f…";
const { records } = await api("GET", `/domains/client-a.com/dns-records?type=A&name=www&client_id=${clientId}`);
if (records[0]) {
  await api("PATCH", `/domains/client-a.com/dns-records/${records[0].id}?client_id=${clientId}`, { value: "203.0.113.20" });
} else {
  await api("POST", `/domains/client-a.com/dns-records?client_id=${clientId}`, { type: "A", name: "www", value: "203.0.113.20" });
}

curl: replace and delete

curl -X PUT https://www.bluewhaledns.com/api/v1/reseller/domains/client-a.com/dns-records/rec_3f9a0c1e2b7d4a5f6e8c9b0a \
  -H "Authorization: Bearer $BLUEWHALE_API_KEY" -H "Content-Type: application/json" \
  -d '{"type":"CNAME","name":"shop","value":"shops.myshopify.com","ttl":3600}'

curl -X DELETE https://www.bluewhaledns.com/api/v1/reseller/domains/client-a.com/dns-records/rec_3f9a0c1e2b7d4a5f6e8c9b0a \
  -H "Authorization: Bearer $BLUEWHALE_API_KEY"