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
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
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name.statusstringOne or more statuses separated by commas:not_started,pending,verified,partially_verified,partially_failed,failed.regionstringOnly domains of one region.
Errors
- 422
validation_errorA field is invalid. The message says which one.
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);
{
"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
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
namestringrequiredDomain name, such asacmestore.comormail.acmestore.com. It must be unique in Postal.regionstringSending region:sa-east-1(São Paulo, default),us-east-1,us-east-2,eu-west-1orap-northeast-1.custom_return_pathstringSubdomain of the Return-Path (MAIL FROM). Default:postal. Choosing another one depends on the plan's custom records feature.open_trackingbooleanTrack opens. Default:false. Turning it on depends on the plan's tracking feature.click_trackingbooleanTrack clicks. Default:false. Turning it on depends on the plan's tracking feature.tracking_subdomainstringYour own subdomain for tracked links, such aslinks. It creates a CNAME record. It depends on the plan's custom records feature.tlsstringopportunistic(default) uses TLS when the recipient's server supports it;enforcedonly delivers over TLS.capabilitiesobjectWhat the domain does.sendingstringenabled(default) ordisabled. At least one of the two capabilities must stay on.receivingstringenabledordisabled(default). Receiving on your own domain is a paid-plan feature.
Errors
- 422
validation_errorInvalid 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. - 403
plan_limit_reachedThe 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.
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);
{
"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}
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
idstringrequiredDomain ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
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
idstringrequiredDomain ID.
Request body
open_trackingbooleanTrack opens.click_trackingbooleanTrack clicks.tracking_subdomainstring | nullSubdomain of the tracked links.tlsstringopportunisticorenforced.capabilitiesobject{"sending": "enabled" | "disabled", "receiving": "enabled" | "disabled"}.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedReceiving 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. - 403
paused_by_planThe domain is paused because it is above the plan's domain limit. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "domain",
"id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f"
}
#Delete a domain
DELETE/domains/{id}
Removes the domain. Emails sent from it start to fail. It triggers the domain.deleted webhook.
Path parameters
idstringrequiredDomain ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "domain",
"id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"deleted": true
}
#Verify a domain
POST/domains/{id}/verify
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
idstringrequiredDomain ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "domain",
"id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f"
}
#Check the MX before receiving
GET/domains/{id}/receiving-check
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
idstringrequiredDomain ID.
mx_conflict:
| Field | Description |
|---|---|
has_other_mx | true when the domain has any MX that is not Postal's (Postal's MX, mx.postal.so, never counts as a conflict). |
records | The 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_domain | The 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
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
namestringrequiredDomain registered in another account.…The other fields ofPOST /domains, applied when the domain is transferred.
Errors
- 422
validation_errorThe domain is not in another account (create it withPOST /domains) or is already yours. - 403
plan_limit_reachedThe plan does not allow more domains, or does not include what the body asks for (tracking, custom records).
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);
{
"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
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_idstringrequiredThedomain_idreturned when the claim was created.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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_idstringrequiredThedomain_idof the claim.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredDomain ID.
Request body
tostring[]requiredFrom 1 to 5 email addresses, up to 254 characters each. Repeated ones count once.messagestringA note that goes in the email, up to 500 characters and with no links (the email already has everything the person needs).
Errors
- 422
validation_errortois empty, has more than 5 addresses or an invalid address, ormessageis too long or has a link. - 429
rate_limit_exceededMore than 3 requests per minute or 10 per hour for the team, or one of the addresses already received the instructions 5 times today. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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.
| Field | Description |
|---|---|
status | The 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_confirmation | true 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. |
capabilities | sending and receiving, each enabled or disabled. Only a verified domain with sending on can send. |
dns_provider | The detected DNS provider (such as cloudflare), or null. |
records_missing | null, 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_plan | true 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.
| record | Records | What for |
|---|---|---|
DKIM | TXT at postal._domainkey | The signature of the emails. Required to send. |
SPF | MX and TXT on the Return-Path subdomain (default postal) | Receives bounces and complaints and authorizes Postal to send. Required to send. |
Tracking | CNAME at the tracking_subdomain | Tracking links on your domain. It only exists with tracking on and a tracking subdomain; it does not block sending. |
Receiving | MX 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:
| Type | When |
|---|---|
domain.created | The domain was added. |
domain.verification_started | The verification started or restarted. |
domain.dns_verified | Every record was found. |
domain.verified | The domain was verified and can send. |
domain.partially_failed, domain.failed | The verification deadline passed with some of the records or none. |
domain.tracking_updated | Tracking or the tracking subdomain changed. |
domain.records_missing | A required record disappeared from a domain that was already working. |
domain.records_restored | The missing record came back. |
domain.verification_lost | The record did not come back in time and the domain stopped being verified. |
domain.provider_error | There 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.
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