Sign in Create account
API reference

Domains

Add domains, get their DNS records, verify them and claim domains registered in another account.

Add the domains you send from (and, if you want, receive on), see the DNS records you need to create and follow the verification. The DNS walkthrough is in Domains and DNS.

#List domains

GET/domains

Full-access key

Lists the team's domains, newest first. The list does not include the DNS records. The fields of a domain are in Status, records and events.

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • searchstring
    Searches the name.
  • statusstring
    One or more statuses separated by commas: not_started, pending, verified, partially_verified, partially_failed, failed.
  • regionstring
    Only domains of one region.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
GET /domains

cURL

curl 'https://api.postal.so/domains?status=verified' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/domains?status=verified', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/domains",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "status": "verified",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('domains', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'status' => 'verified',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "domain",
      "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
      "paused_by_plan": false,
      "name": "acmestore.com",
      "status": "verified",
      "awaiting_confirmation": false,
      "created_at": "2026-10-02T18:20:00.000000Z",
      "region": "sa-east-1",
      "dns_provider": "cloudflare",
      "open_tracking": false,
      "click_tracking": false,
      "tracking_subdomain": null,
      "tls": "opportunistic",
      "capabilities": {
        "sending": "enabled",
        "receiving": "disabled"
      },
      "records_missing": null
    }
  ]
}

#Create a domain

POST/domains

Full-access key

Adds the domain, generates the DKIM key and returns the DNS records for you to create at your provider. Then call POST /domains/{id}/verify to start the verification. If the domain is already registered in another account, use the claim flow. The records are also emailed to whoever added the domain (the dashboard user or the key's creator), and the domain.created webhook is triggered. To send the records to someone else, such as your IT team, use POST /domains/{id}/instructions.

Request body

  • namestringrequired
    Domain name, such as acmestore.com or mail.acmestore.com. It must be unique in Postal.
  • regionstring
    Sending region: sa-east-1 (São Paulo, default), us-east-1, us-east-2, eu-west-1 or ap-northeast-1.
  • custom_return_pathstring
    Subdomain of the Return-Path (MAIL FROM). Default: postal. Choosing another one depends on the plan's custom records feature.
  • open_trackingboolean
    Track opens. Default: false. Turning it on depends on the plan's tracking feature.
  • click_trackingboolean
    Track clicks. Default: false. Turning it on depends on the plan's tracking feature.
  • tracking_subdomainstring
    Your own subdomain for tracked links, such as links. It creates a CNAME record. It depends on the plan's custom records feature.
  • tlsstring
    opportunistic (default) uses TLS when the recipient's server supports it; enforced only delivers over TLS.
  • capabilitiesobject
    What the domain does.
    • sendingstring
      enabled (default) or disabled. At least one of the two capabilities must stay on.
    • receivingstring
      enabled or disabled (default). Receiving on your own domain is a paid-plan feature.

Errors

  • 422validation_error
    Invalid or already registered name, invalid region, or the domain is already set up with the sending provider by another system (use a subdomain). Also when no capability is left on.
  • 403plan_limit_reached
    The plan does not allow more domains. Also when the plan does not include what was asked for: open and click tracking, your own Return-Path or tracking subdomain, or receiving on your domain.
POST /domains

cURL

curl -X POST https://api.postal.so/domains \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acmestore.com",
    "region": "sa-east-1"
  }'

Node.js

const response = await fetch('https://api.postal.so/domains', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'acmestore.com',
    region: 'sa-east-1',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/domains",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "name": "acmestore.com",
        "region": "sa-east-1",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('domains', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'name' => 'acmestore.com',
        'region' => 'sa-east-1',
    ],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "domain",
  "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
  "paused_by_plan": false,
  "name": "acmestore.com",
  "status": "not_started",
  "awaiting_confirmation": false,
  "created_at": "2026-10-02T18:20:00.000000Z",
  "region": "sa-east-1",
  "dns_provider": null,
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": null,
  "tls": "opportunistic",
  "capabilities": {
    "sending": "enabled",
    "receiving": "disabled"
  },
  "records_missing": null,
  "records": [
    {
      "record": "DKIM",
      "name": "postal._domainkey",
      "type": "TXT",
      "ttl": "Auto",
      "status": "not_started",
      "value": "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr4k…IDAQAB",
      "diagnostic": null,
      "last_checked_at": null
    },
    {
      "record": "SPF",
      "name": "postal",
      "type": "MX",
      "ttl": "Auto",
      "status": "not_started",
      "value": "feedback-smtp.sa-east-1.amazonses.com",
      "priority": 10,
      "diagnostic": null,
      "last_checked_at": null
    },
    {
      "record": "SPF",
      "name": "postal",
      "type": "TXT",
      "ttl": "Auto",
      "status": "not_started",
      "value": "v=spf1 include:_spf.postal.so ~all",
      "diagnostic": null,
      "last_checked_at": null
    }
  ]
}

#Retrieve a domain

GET/domains/{id}

Full-access key

Returns the domain with its DNS records (each with its own status and a diagnostic when something is wrong) and the event history. What each status, record and event means is in Status, records and events.

Path parameters

  • idstringrequired
    Domain ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /domains/{id}

cURL

curl https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain",
  "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
  "paused_by_plan": false,
  "name": "acmestore.com",
  "status": "verified",
  "awaiting_confirmation": false,
  "created_at": "2026-10-02T18:20:00.000000Z",
  "region": "sa-east-1",
  "dns_provider": "cloudflare",
  "open_tracking": false,
  "click_tracking": false,
  "tracking_subdomain": null,
  "tls": "opportunistic",
  "capabilities": {
    "sending": "enabled",
    "receiving": "disabled"
  },
  "records_missing": null,
  "records": [
    {
      "record": "DKIM",
      "name": "postal._domainkey",
      "type": "TXT",
      "ttl": "Auto",
      "status": "verified",
      "value": "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr4k…IDAQAB",
      "diagnostic": null,
      "last_checked_at": "2026-10-02T18:31:44.000000Z"
    },
    {
      "record": "SPF",
      "name": "postal",
      "type": "MX",
      "ttl": "Auto",
      "status": "verified",
      "value": "feedback-smtp.sa-east-1.amazonses.com",
      "priority": 10,
      "diagnostic": null,
      "last_checked_at": "2026-10-02T18:31:44.000000Z"
    },
    {
      "record": "SPF",
      "name": "postal",
      "type": "TXT",
      "ttl": "Auto",
      "status": "verified",
      "value": "v=spf1 include:_spf.postal.so ~all",
      "diagnostic": null,
      "last_checked_at": "2026-10-02T18:31:44.000000Z"
    }
  ],
  "events": [
    {
      "type": "domain.created",
      "occurred_at": "2026-10-02T18:20:00.000000Z",
      "data": null
    },
    {
      "type": "domain.verification_started",
      "occurred_at": "2026-10-02T18:22:10.000000Z",
      "data": null
    },
    {
      "type": "domain.verified",
      "occurred_at": "2026-10-02T18:31:44.000000Z",
      "data": null
    }
  ]
}

#Update a domain

PATCH/domains/{id}

Full-access key

Turns tracking, enforced TLS, sending and receiving on or off. Changing the capabilities or the tracking subdomain changes the DNS records: retrieve the domain again to see what to create. It triggers the domain.updated webhook. You cannot change the name, the region or the Return-Path of a domain: create another one. A domain paused by the plan cannot be changed.

Path parameters

  • idstringrequired
    Domain ID.

Request body

  • open_trackingboolean
    Track opens.
  • click_trackingboolean
    Track clicks.
  • tracking_subdomainstring | null
    Subdomain of the tracked links.
  • tlsstring
    opportunistic or enforced.
  • capabilitiesobject
    {"sending": "enabled" | "disabled", "receiving": "enabled" | "disabled"}.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403plan_limit_reached
    Receiving on your own domain is not included in the plan. Also when the plan does not include open and click tracking or your own tracking subdomain.
  • 403paused_by_plan
    The domain is paused because it is above the plan's domain limit.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /domains/{id}

cURL

curl -X PATCH https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "open_tracking": true,
    "click_tracking": true,
    "capabilities": {
      "receiving": "enabled"
    }
  }'

Node.js

const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    open_tracking: true,
    click_tracking: true,
    capabilities: {
      receiving: 'enabled',
    },
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "open_tracking": True,
        "click_tracking": True,
        "capabilities": {
            "receiving": "enabled",
        },
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->patch('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'open_tracking' => true,
        'click_tracking' => true,
        'capabilities' => [
            'receiving' => 'enabled',
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain",
  "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f"
}

#Delete a domain

DELETE/domains/{id}

Full-access key

Removes the domain. Emails sent from it start to fail. It triggers the domain.deleted webhook.

Path parameters

  • idstringrequired
    Domain ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /domains/{id}

cURL

curl -X DELETE https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.delete(
    "https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->delete('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain",
  "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
  "deleted": true
}

#Verify a domain

POST/domains/{id}/verify

Full-access key

Starts (or restarts) the verification of the DNS records in the background. The response is immediate; follow the result with GET /domains/{id} or with the domain.updated webhook. The verification keeps going on its own for up to 72 hours. On an already verified domain that lost a record, the call only asks for an immediate check, and the domain keeps sending meanwhile.

Path parameters

  • idstringrequired
    Domain ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
POST /domains/{id}/verify

cURL

curl -X POST https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/verify \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/verify",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/verify', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain",
  "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f"
}

#Check the MX before receiving

GET/domains/{id}/receiving-check

Full-access key

Before turning on capabilities.receiving, find out whether the domain already has MX records from another provider (Google Workspace, Microsoft 365…). They would compete with Postal's MX, and part of the email would keep going to the old mailbox. Postal queries the DNS right now (with a short timeout, and keeps the answer for about 1 minute) and returns in mx_conflict the records that are not Postal's and the suggestion to use a subdomain. It is information only: nothing is blocked and PATCH /domains/{id} works the same way. Use it to show a warning before turning receiving on.

Path parameters

  • idstringrequired
    Domain ID.

mx_conflict:

FieldDescription
has_other_mxtrue when the domain has any MX that is not Postal's (Postal's MX, mx.postal.so, never counts as a conflict).
recordsThe MX records of other providers, from the lowest to the highest priority: {"priority", "host"}. A null MX (RFC 7505) shows up with host ".".
suggestion"subdomain" when there is a conflict (receive on a subdomain, without touching the main domain's MX); otherwise null.
suggested_domainThe suggested name, such as inbound.acmestore.com (entrada.… in Portuguese, following Accept-Language), or null without a suggestion. Add it with POST /domains.

If the DNS cannot be queried right now (failure or timeout), the call still returns 200 and mx_conflict is null: do not show a warning, and try again later. The receiving field says whether receiving is already on for the domain.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /domains/{id}/receiving-check

cURL

curl https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/receiving-check \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/receiving-check', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/receiving-check",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/receiving-check', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain_receiving_check",
  "domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
  "receiving": "disabled",
  "mx_conflict": {
    "has_other_mx": true,
    "records": [
      {
        "priority": 1,
        "host": "aspmx.l.google.com"
      },
      {
        "priority": 5,
        "host": "alt1.aspmx.l.google.com"
      }
    ],
    "suggestion": "subdomain",
    "suggested_domain": "inbound.acmestore.com"
  }
}

#Claim a domain

POST/domains/claim

Full-access key

When the domain is already registered in another Postal account (for example, a former employee's), prove that you control its DNS to transfer it. The body is the same as POST /domains. Create the TXT record returned in record and call the verification. The request is valid for 7 days; repeating the same request returns the one that is already open (status 200).

Request body

  • namestringrequired
    Domain registered in another account.
  • …
    The other fields of POST /domains, applied when the domain is transferred.

Errors

  • 422validation_error
    The domain is not in another account (create it with POST /domains) or is already yours.
  • 403plan_limit_reached
    The plan does not allow more domains, or does not include what the body asks for (tracking, custom records).
POST /domains/claim

cURL

curl -X POST https://api.postal.so/domains/claim \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acmestore.com"
  }'

Node.js

const response = await fetch('https://api.postal.so/domains/claim', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'acmestore.com',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/domains/claim",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "name": "acmestore.com",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('domains/claim', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'name' => 'acmestore.com',
    ],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "domain_claim",
  "id": "019a0a10-5c6d-7e7f-8a9b-0c1d2e3f4a5b",
  "name": "acmestore.com",
  "status": "pending",
  "domain_id": "019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b",
  "region": "sa-east-1",
  "record": {
    "type": "TXT",
    "name": "acmestore.com",
    "value": "postal-domain-verification=Qm8rT2xKp4Vn7sWd1Yc5Hf3Lz9Ag6Ej0",
    "ttl": "Auto"
  },
  "blocked_reason": null,
  "failure_reason": null,
  "created_at": "2026-10-24T13:10:00.000000Z",
  "expires_at": "2026-10-31T13:10:00.000000Z"
}

#Retrieve a claim

GET/domains/{domain_id}/claim

Full-access key

Shows the progress. status can be pending, verified, completed (the domain is now yours, with the ID domain_id), blocked (for example, there are scheduled emails in the other account: blocked_reason explains), expired, superseded, canceled or failed.

Path parameters

  • domain_idstringrequired
    The domain_id returned when the claim was created.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /domains/{domain_id}/claim

cURL

curl https://api.postal.so/domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain_claim",
  "id": "019a0a10-5c6d-7e7f-8a9b-0c1d2e3f4a5b",
  "name": "acmestore.com",
  "status": "pending",
  "domain_id": "019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b",
  "region": "sa-east-1",
  "record": {
    "type": "TXT",
    "name": "acmestore.com",
    "value": "postal-domain-verification=Qm8rT2xKp4Vn7sWd1Yc5Hf3Lz9Ag6Ej0",
    "ttl": "Auto"
  },
  "blocked_reason": null,
  "failure_reason": null,
  "created_at": "2026-10-24T13:10:00.000000Z",
  "expires_at": "2026-10-31T13:10:00.000000Z"
}

#Verify a claim

POST/domains/{domain_id}/claim/verify

Full-access key

Looks for the TXT record and, if it is there, transfers the domain in the background. Retrieve the claim to see the result; while the TXT record does not show up, failure_reason says so.

Path parameters

  • domain_idstringrequired
    The domain_id of the claim.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
POST /domains/{domain_id}/claim/verify

cURL

curl -X POST https://api.postal.so/domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim/verify \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim/verify",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('domains/019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b/claim/verify', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain_claim",
  "id": "019a0a10-5c6d-7e7f-8a9b-0c1d2e3f4a5b",
  "name": "acmestore.com",
  "status": "pending",
  "domain_id": "019a0a10-5c6e-7f80-9a1b-2c3d4e5f6a7b",
  "region": "sa-east-1",
  "record": {
    "type": "TXT",
    "name": "acmestore.com",
    "value": "postal-domain-verification=Qm8rT2xKp4Vn7sWd1Yc5Hf3Lz9Ag6Ej0",
    "ttl": "Auto"
  },
  "blocked_reason": null,
  "failure_reason": null,
  "created_at": "2026-10-24T13:10:00.000000Z",
  "expires_at": "2026-10-31T13:10:00.000000Z"
}

#Email the DNS instructions

POST/domains/{id}/instructions

Full-access key

Emails the DNS records of the domain and the step-by-step for your DNS provider to up to 5 addresses, such as your IT team or the agency that looks after your DNS. Whoever receives it does not need a Postal account: the email has everything, and says who asked. It is a system email and does not count toward the sending quota.

Path parameters

  • idstringrequired
    Domain ID.

Request body

  • tostring[]required
    From 1 to 5 email addresses, up to 254 characters each. Repeated ones count once.
  • messagestring
    A note that goes in the email, up to 500 characters and with no links (the email already has everything the person needs).

Errors

  • 422validation_error
    to is empty, has more than 5 addresses or an invalid address, or message is too long or has a link.
  • 429rate_limit_exceeded
    More than 3 requests per minute or 10 per hour for the team, or one of the addresses already received the instructions 5 times today.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /domains/{id}/instructions

cURL

curl -X POST https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/instructions \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["[email protected]"],
    "message": "Hi! Could you create these records in the domain'\''s DNS?"
  }'

Node.js

const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/instructions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: ['[email protected]'],
    message: 'Hi! Could you create these records in the domain\'s DNS?',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/instructions",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "to": ["[email protected]"],
        "message": "Hi! Could you create these records in the domain's DNS?",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f/instructions', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'to' => ['[email protected]'],
        'message' => 'Hi! Could you create these records in the domain\'s DNS?',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "domain",
  "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
  "sent_to": ["[email protected]"]
}

#Status, records and events

Besides its name and region, the domain object has the state of the verification and of the DNS records.

FieldDescription
statusThe state of the verification: not_started (created, not verified yet), pending (being verified, for up to 72 hours), partially_verified (only some records were found), verified (can send), partially_failed (the deadline passed with some records found) and failed (the deadline passed with no record found).
awaiting_confirmationtrue when every record needed to send was found and only the sending provider's final confirmation is left, which usually takes 5 to 30 minutes.
capabilitiessending and receiving, each enabled or disabled. Only a verified domain with sending on can send.
dns_providerThe detected DNS provider (such as cloudflare), or null.
records_missingnull, or, on a domain that was working and lost a required record, {"since", "deadline_at", "records"}: since when it has been missing, the deadline and the records (record, type, name). The domain keeps sending until the 72-hour deadline, and Postal looks for the record every hour; if it comes back, the warning goes away, and if the deadline passes, the domain stops being verified.
paused_by_plantrue when the domain is above the plan's domain limit. It stays in the list, but it cannot be changed and does not send: see Plan limits.

DNS records

Each item of records has record (Postal's record kind), type (the DNS type), name, ttl, value, priority (only on MX records), status, diagnostic and last_checked_at. The diagnostic is text for people that explains what the latest check found (null when the record is right) and comes in the language of the request: Accept-Language: pt-BR or en, English without the header. Do not match on the text: decide by status.

recordRecordsWhat for
DKIMTXT at postal._domainkeyThe signature of the emails. Required to send.
SPFMX and TXT on the Return-Path subdomain (default postal)Receives bounces and complaints and authorizes Postal to send. Required to send.
TrackingCNAME at the tracking_subdomainTracking links on your domain. It only exists with tracking on and a tracking subdomain; it does not block sending.
ReceivingMX at @Delivers the received emails to the inboxes. Only with capabilities.receiving on.

The status of a record is not_started, pending, verified, failed or temporary_failure (the DNS lookup failed; Postal tries again). A verified domain is checked again every day.

Domain events

The events of GET /domains/{id} tell the history of the domain, oldest first:

TypeWhen
domain.createdThe domain was added.
domain.verification_startedThe verification started or restarted.
domain.dns_verifiedEvery record was found.
domain.verifiedThe domain was verified and can send.
domain.partially_failed, domain.failedThe verification deadline passed with some of the records or none.
domain.tracking_updatedTracking or the tracking subdomain changed.
domain.records_missingA required record disappeared from a domain that was already working.
domain.records_restoredThe missing record came back.
domain.verification_lostThe record did not come back in time and the domain stopped being verified.
domain.provider_errorThere was an error talking to the sending provider (data.stage and data.code); Postal tries again.

Status changes also arrive by webhook: domain.created, domain.updated and domain.deleted (see Webhooks).

#With the SDKs

The official SDKs have domains.list, domains.get, domains.create and domains.verify, with the API fields.

Create and verify a domain

Node.js

import { Postal } from 'postal-so'

const postal = new Postal() // reads POSTAL_API_KEY

const domain = await postal.domains.create({ name: 'acmestore.com', region: 'sa-east-1' })
console.log(domain.records) // the DNS records to create

await postal.domains.verify(domain.id)
const current = await postal.domains.get(domain.id) // status and records

PHP

use Postal\Client;

$postal = new Client(); // reads POSTAL_API_KEY

$domain = $postal->domains->create(['name' => 'acmestore.com', 'region' => 'sa-east-1']);
$records = $domain['records']; // the DNS records to create

$postal->domains->verify($domain['id']);
$current = $postal->domains->get($domain['id']); // status and records