> ## Documentation Index
> Fetch the complete documentation index at: https://docs.salesive.com/llms.txt
> Use this file to discover all available pages before exploring further.

# DNS records

> Read and change a store's DNS records from an installed app, with a WRITE_DNS runtime grant scoped to one domain, protected storefront records, and a request flow for domains bought elsewhere.

## What an app can do

An installed app can manage DNS in two ways, depending on where the domain was bought:

* **Domains registered by Salesive** (`provider: "dynadot"`): read the zone and add, change or
  remove individual records.
* **Custom domains bought elsewhere** (`provider: null`): file a record request that the merchant
  applies at their own registrar. Salesive verifies it and notifies your app.

Every write is one record at a time. Apps never get the full zone replace, nameserver changes,
transfer lock, renewal or domain deletion. Those stay with the merchant and the platform.

| Action | Needs |
| - | - |
| Read the zone | OAuth scope `READ_DOMAINS` |
| Add, change or remove a record | `READ_DOMAINS` plus the `WRITE_DNS` runtime grant |
| File a request on a custom domain | `READ_DOMAINS` plus the `WRITE_DNS` runtime grant |

> [!NOTE]
>
> `WRITE_DOMAINS` is a different runtime permission. It covers adding and removing domains and
> changing the store subdomain. It does **not** allow DNS writes. The full zone replace
> (`PUT /domains/:id/dns`) is refused for app tokens even with every permission granted.

## Request the WRITE\_DNS permission

`WRITE_DNS` is a **runtime permission**, not an OAuth scope. Your app asks for it from inside its
iframe, the merchant sees a dashboard modal, and the grant is stored on the installation for a
duration the merchant chooses: `once`, `1day`, `7days`, `14days` or `31days`.

A `WRITE_DNS` grant is always scoped to **one domain**, so a bug or a bad actor cannot touch
another zone in the store. You can also limit it to specific record types, and give the merchant
a reason for the request.

| Field | Required | Description |
| - | - | - |
| `scope.domainId` | Yes | `_id` of the domain the grant covers. It must belong to the store. |
| `scope.domainName` | No | Display name such as `acme.com`, shown in the modal so the merchant knows which domain. |
| `scope.recordTypes` | No | Record types the grant covers, for example `["TXT", "MX"]`. Omit or pass an empty array to cover every type the DNS API allows. |
| `reason` | No | Why the app needs access. Shown in the modal, truncated to 200 characters. |

### Through the App Bridge

```js theme={null}
import { PERMISSIONS, DURATIONS, requestPermission } from "salesive-dev-tools/permissions";

const granted = await requestPermission(PERMISSIONS.WRITE_DNS, {
  options: [DURATIONS.ONE_DAY, DURATIONS.SEVEN_DAYS],
  scope: {
    domainId: "66b1f0a3c2d4e5f6a7b8c9d0",
    domainName: "acme.com",
    recordTypes: ["TXT", "MX"],
  },
  reason: "Add the email verification records for this domain",
});

if (!granted) return; // merchant denied, or the modal timed out
```

The second argument can also stay a plain duration array, so existing `requestPermission` calls
are unchanged.

### Raw postMessage

If you do not use the helper package, send the same fields to the dashboard:

```js theme={null}
window.parent.postMessage(
  {
    type: "salesive:request-permission",
    permission: "WRITE_DNS",
    options: ["1day", "7days"],
    scope: {
      domainId: "66b1f0a3c2d4e5f6a7b8c9d0",
      domainName: "acme.com",
      recordTypes: ["TXT", "MX"],
    },
    reason: "Add the email verification records for this domain",
  },
  "*"
);
```

The dashboard answers with a `salesive:permission-response` message carrying `permission`,
`granted`, `mode` and `expiresAt`.

> [!NOTE]
>
> Grants die with the installation. Reinstalling the app starts with no DNS access until the
> merchant approves a new grant. The merchant can revoke a grant at any time from the dashboard.

## Endpoints

All routes are under `https://api.salesive.com/api/v1/domains`. Your app token is bound to one
store, so you never send a shop id.

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/domains/:id/dns` | Read the zone in the existing dashboard shape. |
| `GET` | `/domains/:id/dns/records` | Read the zone with record ids, ownership and protection flags. |
| `POST` | `/domains/:id/dns/records` | Add one record. |
| `PATCH` | `/domains/:id/dns/records/:recordId` | Replace one record. |
| `DELETE` | `/domains/:id/dns/records/:recordId` | Remove one record. |
| `GET` | `/domains/:id/dns/changes` | Read the audit trail, newest first. |
| `POST` | `/domains/:id/dns/changes/:changeId/revert` | Revert a change. Merchant only. |
| `GET` | `/domains/:id/dns/requests` | List pending record requests for a custom domain. |
| `POST` | `/domains/:id/dns/requests` | File a record request on a custom domain. |
| `DELETE` | `/domains/:id/dns/requests/:requestId` | Reject a request. Merchant only. |

### List records

`GET /domains/:id/dns/records` is the read to use before a write. Each record carries:

* `id`: the deterministic record id, used in `PATCH` and `DELETE`.
* `owner`: `app`, `merchant` or `platform`.
* `installationId`: the installation that owns an app-created record, otherwise `null`.
* `protected`: `true` when the record cannot be changed by an app.

```json theme={null}
{
  "status": 200,
  "success": true,
  "message": "DNS records retrieved",
  "data": {
    "domain": "acme.com",
    "ttl": 86400,
    "records": [
      {
        "id": "1f0c9a2b7d4e6f8a0b1c2d3e",
        "host": "@",
        "type": "A",
        "value": "207.180.227.118",
        "value2": null,
        "owner": "platform",
        "installationId": null,
        "protected": true
      },
      {
        "id": "9b2e4c6d8f0a1b3c5d7e9f2a",
        "host": "send",
        "type": "TXT",
        "value": "v=spf1 include:_spf.example.com ~all",
        "value2": null,
        "owner": "app",
        "installationId": "66c2a1b3c4d5e6f7a8b9c0d1",
        "protected": false
      }
    ]
  }
}
```

`GET /domains/:id/dns` returns the same zone in the dashboard's older shape
(`{ domain, ttl, records: [{ id: "main-0", host, type, value, value2 }] }`). Its ids are
positional, not stable, so use `/dns/records` when you intend to write.

### Add a record

```http theme={null}
POST /api/v1/domains/66b1f0a3c2d4e5f6a7b8c9d0/dns/records
Authorization: Bearer app_<your-app-access-token>
Content-Type: application/json
```

```json theme={null}
{
  "host": "send",
  "type": "TXT",
  "value": "v=spf1 include:_spf.example.com ~all"
}
```

Use `@` for the root record, or the subdomain label only (`send`, not `send.acme.com`). MX
records carry their priority in `value2` as a numeric string:

```json theme={null}
{
  "host": "@",
  "type": "MX",
  "value": "mx1.example.com",
  "value2": "10"
}
```

```json theme={null}
{
  "status": 200,
  "success": true,
  "message": "DNS record added",
  "data": {
    "id": "9b2e4c6d8f0a1b3c5d7e9f2a",
    "host": "send",
    "type": "TXT",
    "value": "v=spf1 include:_spf.example.com ~all",
    "value2": null
  }
}
```

The record endpoints do not change the zone TTL. The zone keeps its existing TTL, which is
between 60 and 86400 seconds.

### Change a record

`PATCH /domains/:id/dns/records/:recordId` replaces the whole record, so send `host`, `type`,
`value` and `value2` every time. A PATCH is not a partial update.

```json theme={null}
{
  "host": "send",
  "type": "TXT",
  "value": "v=spf1 include:_spf.example.com include:mail.example.net ~all"
}
```

```json theme={null}
{
  "status": 200,
  "success": true,
  "message": "DNS record updated",
  "data": {
    "id": "c4d5e6f7a8b9c0d1e2f3a4b5",
    "host": "send",
    "type": "TXT",
    "value": "v=spf1 include:_spf.example.com include:mail.example.net ~all",
    "value2": null
  }
}
```

The id is derived from the record's content, so it **changes when the record changes**. Use the
`id` in the response for any follow-up write, or list the zone again.

### Remove a record

```http theme={null}
DELETE /api/v1/domains/66b1f0a3c2d4e5f6a7b8c9d0/dns/records/9b2e4c6d8f0a1b3c5d7e9f2a
Authorization: Bearer app_<your-app-access-token>
```

```json theme={null}
{
  "status": 200,
  "success": true,
  "message": "DNS record deleted",
  "data": { "id": "9b2e4c6d8f0a1b3c5d7e9f2a", "deleted": true }
}
```

### How recordId is built

Dynadot has no record ids, so Salesive derives one:

```text theme={null}
sha1(host|type|value|value2)  ->  first 24 hex characters
```

`host` and `type` are lowercased and values are trimmed before hashing. The same record always
has the same id, so your app can address a record straight from a list response without a second
lookup. Because content is part of the hash, an edit produces a new id.

## Protected records and ownership

Apps own only the records they created. Ownership is stored next to the zone, keyed by the
deterministic record id, and it is checked before every write.

| Rule | Result |
| - | - |
| The record has no app ownership row | `404` `DNS_RECORD_NOT_FOUND` |
| The record belongs to another installation | `403` `DNS_NOT_OWNER` |
| Root (`@`) `A` or `AAAA` record | `403` `DNS_APEX_LOCKED` |
| Any record whose value is the storefront address | `403` `DNS_PROTECTED_VALUE` |
| `NS` or `SOA` record | `403` `DNS_RESERVED_TYPE` |
| Full zone replace (`PUT /domains/:id/dns`) | `403`, apps are refused |

The root `A` and `AAAA` records point the domain at the storefront. A record carrying the
storefront address is what makes the store resolve, so no app may set or remove it. Nameservers,
transfer lock, renewal and domain deletion are never reachable with an app token.

> [!NOTE]
>
> `protected: true` on a list response is advisory: it tells your UI which records cannot be
> edited, and the same checks run again on the write. A record can be protected because of its
> type or value even when no app owns it.

## Limits

| Limit | Value | Error |
| - | - | - |
| Records owned by one app on one domain | 25 | `403` `DNS_RECORD_LIMIT` |
| Writes by one app on one domain | 10 per 10 minutes | `429` `DNS_RATE_LIMITED` |
| Root (`@`) records in a zone | 20 | `400` |
| Subdomain records in a zone | 100 | `400` |
| Zone TTL | 60 to 86400 seconds | `400` |

Writes to a domain are serialised so two requests cannot read, change and write the same zone at
once. If another write holds the domain lock, the request fails with `409` `DNS_BUSY` and can be
retried after a moment.

## Error codes

DNS errors return the message and a top level `code`:

```json theme={null}
{
  "success": false,
  "message": "The root A and AAAA records belong to the storefront and cannot be changed by an app.",
  "code": "DNS_APEX_LOCKED"
}
```

| Status | Code | Meaning |
| - | - | - |
| `400` | none | Invalid record: bad type, host or value, or an MX record without a numeric priority. |
| `400` | `DNS_DOMAIN_MANAGED` | A record request was filed against a domain Salesive already manages. Write the record directly instead. |
| `400` | `DNS_NOTHING_TO_REVERT` | The change created a record, so there is no previous state to restore. |
| `403` | `RUNTIME_PERMISSION_REQUIRED` | No active `WRITE_DNS` grant. Request it from inside the app. |
| `403` | `DNS_APEX_LOCKED` | The root `A` or `AAAA` record cannot be changed by an app. |
| `403` | `DNS_PROTECTED_VALUE` | The value is the storefront address. |
| `403` | `DNS_RESERVED_TYPE` | `NS` and `SOA` records cannot be changed through the API. |
| `403` | `DNS_GRANT_SCOPE` | The grant covers a different domain. |
| `403` | `DNS_GRANT_TYPE` | The grant does not cover this record type. |
| `403` | `DNS_RECORD_LIMIT` | The app owns 25 records on this domain already. |
| `403` | `DNS_NOT_OWNER` | The record belongs to another installation. |
| `404` | `DOMAIN_NOT_FOUND` | The domain is not in this store, or the route does not accept its provider. Record writes need a Salesive-managed domain. |
| `404` | `DNS_RECORD_NOT_FOUND` | No record with that id, or the app has no ownership row for it. |
| `404` | `DNS_RECORD_GONE` | The record is no longer in the live zone. Re-read the zone. |
| `409` | `DNS_RECORD_EXISTS` | An identical record already exists. The body includes its `recordId`. |
| `409` | `DNS_BUSY` | Another DNS write is in progress for this domain. Retry shortly. |
| `429` | `DNS_RATE_LIMITED` | More than 10 writes in 10 minutes for this app and domain. |

A missing runtime grant answers with the code nested under `data`:

```json theme={null}
{
  "status": 403,
  "success": false,
  "message": "Runtime permission \"WRITE_DNS\" is required. Request it from within the app.",
  "data": { "code": "RUNTIME_PERMISSION_REQUIRED", "permission": "WRITE_DNS" }
}
```

A missing OAuth scope answers with `403` and a message naming the missing scope.

## Custom domains bought elsewhere

Salesive cannot write DNS at a registrar it does not control. For a custom domain connected to
the store, your app files a request instead. The merchant copies the values into their own
registrar, and a recurring job resolves the record until it matches.

**1. Find the domain id**

Call `GET /domains` with `READ_DOMAINS` and pick the custom domain. The id is the `_id` of
the domain, not the domain name.

**2. Request WRITE\_DNS for that domain**

Send the App Bridge request with `scope.domainId` and, if you know them, the record types you
will need.

**3. File the record request**

`POST /domains/:id/dns/requests` with the host, type, value, optional TTL and a short purpose.

**4. Merchant applies it**

The domain page lists pending requests with copy and paste values and a check now button.
The merchant adds the record at their registrar.

**5. Salesive verifies it**

The verifier runs every five minutes, resolves the record, and marks the request `verified`.
Your app receives a `domains/dns_verified` webhook.

```json theme={null}
POST /api/v1/domains/66b1f0a3c2d4e5f6a7b8c9d0/dns/requests
{
  "host": "send",
  "type": "TXT",
  "value": "v=spf1 include:_spf.example.com ~all",
  "ttl": 3600,
  "purpose": "Resend domain verification"
}
```

```json theme={null}
{
  "status": 200,
  "success": true,
  "message": "DNS request created",
  "data": {
    "_id": "66d3b2c4d5e6f7a8b9c0d1e2",
    "shop": "6680aabbccddeeff00112200",
    "domain": "66b1f0a3c2d4e5f6a7b8c9d0",
    "installation": "66c2a1b3c4d5e6f7a8b9c0d1",
    "host": "send",
    "type": "txt",
    "value": "v=spf1 include:_spf.example.com ~all",
    "ttl": 3600,
    "purpose": "Resend domain verification",
    "status": "pending",
    "requestedAt": "2026-11-06T10:15:00.000Z",
    "verifiedAt": null,
    "lastCheckedAt": null,
    "lastError": null,
    "checks": 0,
    "createdAt": "2026-11-06T10:15:00.000Z",
    "updatedAt": "2026-11-06T10:15:00.000Z"
  }
}
```

The same protection rules apply to requests: root `A` and `AAAA`, the storefront address, `NS`
and `SOA` are refused. Filing the same pending request twice returns the existing request instead
of creating a duplicate.

`GET /domains/:id/dns/requests` lists requests for the domain. An app token sees only the
requests filed by its own installation. The verifier checks `A`, `AAAA`, `CNAME`, `MX`, `TXT`,
`NS` and `SRV` records and gives up after 120 checks, which is about ten hours at the five
minute cadence. A request that never matches is marked `rejected`, with the reason in
`lastError`.

## Webhooks

DNS changes send their own topics instead of the generic `domains/updated`, so your app can tell
a record write apart from a domain being added or removed. Every delivery needs `READ_DOMAINS`,
and like all Salesive webhooks it carries identifiers only. Re-fetch the zone with
`GET /domains/:id/dns/records`.

| Topic | Fires when |
| - | - |
| `domains/dns_updated` | A record is added, changed or removed, the zone is replaced, or a change is reverted. |
| `domains/dns_requested` | An app files a record request on a custom domain. |
| `domains/dns_verified` | The verifier sees a requested record resolve. Delivered only to the requesting installation. |

`domains/dns_verified` adds the request id and the record that was verified:

```json theme={null}
{
  "id": "8f1c2e3a-...-9b0d",
  "topic": "domains/dns_verified",
  "resource": "domains",
  "action": "dns_verified",
  "resourceId": "66b1f0a3c2d4e5f6a7b8c9d0",
  "shopId": "6680aabbccddeeff00112200",
  "occurredAt": "2026-11-06T10:35:00.000Z",
  "requestId": "66d3b2c4d5e6f7a8b9c0d1e2",
  "record": {
    "host": "send",
    "type": "txt",
    "value": "v=spf1 include:_spf.example.com ~all"
  }
}
```

## Audit trail

Every DNS write is recorded, whether it came from an app or the merchant. The entry stores the
installation, the actor, the action, the record id, and the before and after snapshots.

`GET /domains/:id/dns/changes` returns entries newest first. `limit` defaults to 50 and is capped
at 200. Reverting is a merchant action (`POST /domains/:id/dns/changes/:changeId/revert`), because
it can restore a record the app does not own.

## Next steps

> [card] [Scopes & permissions](https://docs.salesive.com/apps/scopes-permissions.md)
>
> The OAuth scopes and runtime permissions an app can request.

> [card] [App Bridge](https://docs.salesive.com/apps/app-bridge.md)
>
> Request runtime permissions and handle lifecycle events.

> [card] [Webhooks](https://docs.salesive.com/apps-api/webhooks.md)
>
> Signature verification, retries and the full topic list.

> [card] [List domains](https://docs.salesive.com/apps-api/endpoint/domains/list-domains.md)
>
> Find the domain id before requesting a grant.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.