API authentication and API keys
Every request uses an API key in the Authorization header. Keys can have full access or sending-only access, and can be restricted to one domain.
#API key
Every request needs an API key in the Authorization header, using the Bearer scheme:
Authorization: Bearer ps_4Fq9xLmT2vKp8sWd1Yc5Hf3Lz9Ag6Ej0Qm8rBnX
Keys start with ps_. Create one under API keys in the dashboard, or through the API with POST /api-keys. The full token is shown only once, when you create it: Postal stores just the hash and the first characters (token_start), so you can recognize the key in the list.
cURL
curl https://api.postal.so/domains \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/domains', {
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']}"},
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')],
]);
$data = json_decode($response->getBody(), true);
#Permissions
| Permission | What it can do |
|---|---|
full_accessFull access | The whole API: sending, domains, templates, webhooks, logs, keys… |
sending_accessSending only | Send: POST /emails, POST /emails/batch, POST /events and SMTP.Read: the status and events of the emails it sent itself, by API or SMTP ( GET /emails/{id}; emails sent by other keys or from the dashboard answer 404), and the plan, quotas and rate limit in GET /usage. Everything else needs full access.It can be restricted to one domain ( domain_id): it then sends only from addresses on that domain. |
Creating sending-only or domain-restricted keys depends on the plan (restricted_api_keys), and so does the number of keys (api_keys). See Limits.
Use sending-only keys in systems that just trigger emails or events (the checkout, WordPress, the ERP) and keep full-access keys for infrastructure automation. If a sending-only key leaks, it cannot change your settings or read your contacts, domains or the emails of other keys: it only sees what it sent itself and the plan limits.
#Authentication errors
| Status | Name | When it happens |
|---|---|---|
401 | missing_api_key | The Authorization header is missing. |
403 | invalid_api_key | The key does not exist, was deleted or does not start with ps_. |
401 | restricted_api_key | A sending-only key tried to use another endpoint (it can send, read the emails it sent with GET /emails/{id} and call GET /usage). |
403 | restricted_api_key | A key restricted to one domain tried to send from another domain. |
403 | paused_by_plan | The key went over the API key limit of the current plan and was paused (paused_by_plan: true in GET /api-keys). It does not authenticate until the plan or the count allows it again; see Limits. |
403 | team_suspended | The account was suspended by the platform that manages it. |
#Message language and source of changes
Two optional choices per request:
Accept-Language: pt-BR(oren) picks the language of themessagein errors and of the other texts the API writes for your request (see the table below). Without the header it comes in English. Thenameis always the same.X-Postal-Source: erplabels the changes the key makes to contacts. The label appears insourcein the contact webhooks and in the contact'slast_change_source, and lets a webhook skip what your own system did.
The language of each text Postal writes depends on who it is for:
| Text | Language |
|---|---|
| The texts in API responses | The error message, the labels (such as the label in paused of GET /usage), the sending.reason of the same endpoint, the diagnostic of the domain DNS records, the suffix of copy names (“(copy)” or “(cópia)”), the [Test] or [Teste] prefix of the broadcast test and the template test and the “Other:” or “Outro:” in the form answer CSV files. They follow the request's Accept-Language (pt-BR or en, the first one Postal knows). With an API key or without credentials, they come in English without the header; GET /plans, which is public, comes in Portuguese without it. |
| Text of the diagnostics AI | POST /ai/emails/{id}/fix-insights: the language parameter (pt-BR, en or es); without it, the language of the request. |
| Sending pause webhooks | The reason comes in the team's language, the one the owner chose in the dashboard; English until they choose. The reason_code does not depend on language. |
| Unsubscribe footer and preferences page | Each recipient's language: the broadcast's language, the contact's properties, the team's language. See Footer and page language. |
| Public forms and confirmation email | The form's settings.language, else the Accept-Language of whoever fills it in, else pt-BR. See Form language. |
| Portuguese on purpose | The default templates of the Supabase authentication emails, which you edit on the connection. |
#Best practices
- Keep the key in an environment variable or a secrets vault, never in code. Do not use the key in a browser or a mobile app: anyone could copy it. Call the API from your server.
- Use one key per system and per environment. That way you can revoke one without breaking the others and see in the logs who made each request.
- If a key leaks, delete it right away (
DELETE /api-keys/{id}) and create another. - To give an AI assistant access to your account, prefer OAuth on the MCP server, which limits access to one team and to scopes and can be revoked without changing a key.