API documentation
Send an email with one authenticated HTTP call. This page has the full guide for developers and a ready-made prompt for AI coding agents.
Use it with an AI agent
Paste this into Claude Code, Cursor, Copilot or any coding agent. It tells the agent where to read the guide and how to handle the key.
Add transactional email sending to this project using Postar. Read the integration guide first: https://api.postar.vip/llm.txt Machine-readable API spec: https://api.postar.vip/openapi.json Read the API key from the POSTAR_API_KEY environment variable. Never hard-code it and never expose it in client-side code.
Postar is a transactional email API (login links, notifications). You queue an email over HTTP; it is delivered asynchronously with SPF/DKIM-signed mail. Do not use it for bulk marketing.
Base URL: https://api.postar.vip
Sent through: postar.vip (the service host; from can be any domain you own once its DNS is set up, or @postar.vip as a no-setup default)
Authentication
Every request needs Authorization: Bearer <API key>. Keys look like pk_ + 48 hex characters,
are created in the backoffice per project, and are shown only once. Store the key in an environment
variable (e.g. POSTAR_API_KEY), never in git or client-side code.
Send an email
POST https://api.postar.vip/v1/send (Content-Type: application/json)
| field | required | notes |
|---|---|---|
| from | yes | any local part, but the domain must be one allowed for the API key, e.g. login@postar.vip |
| to | yes | one recipient address |
| subject | yes | no line breaks |
| html and/or text | one of them | send both when possible |
| name | no | display name of the sender, e.g. "My App" |
| reply_to | no | where replies go, e.g. the app's real mailbox office@myapp.com. If omitted, the project's default Reply-To (set in the backoffice) is used; a project can also be configured as no-reply |
Example:
curl -X POST https://api.postar.vip/v1/send \
-H "Authorization: Bearer $POSTAR_API_KEY" -H "Content-Type: application/json" \
-d '{"from":"login@postar.vip","name":"My App","reply_to":"office@myapp.com",
"to":"user@example.com","subject":"Your login link",
"html":"<p><a href=\"https://myapp.com/login?token=...\">Log in</a></p>","text":"Log in: https://myapp.com/login?token=..."}'
Success: HTTP 202 {"id":"...","status":"queued"}. "queued" means accepted, not yet delivered.
Errors (JSON {"error":"..."}):
| HTTP | meaning | what to do |
|---|---|---|
| 401 | missing/invalid/revoked key | check the key |
| 403 | from domain not allowed for this key | use an allowed domain or ask the admin to add it |
| 422 | invalid field, missing body, or recipient is suppressed (bounced/complained) | fix the request; do not retry |
| 429 | hourly rate limit of the key reached | retry later |
| 5xx / network | temporary | retry with backoff |
Rules
- Replies:
postar.vipaddresses cannot receive mail, so a reply to the From address bounces. Set the project's Reply-To in the backoffice once, or passreply_toper request, pointing at a mailbox you read. Use a no-reply project only for mail nobody should answer (the From address is then a dead end). - You do not have to send from
postar.vip. Mail goes through this service, andfromcan be your own domain (e.g.@myapp.com) once SPF and DKIM are published there (backoffice > Domains shows the records). Until then,@postar.vipworks without any DNS changes: setreply_toto the app's real mailbox. - Treat a failed call to this API as non-fatal where possible (e.g. show "try again" on the login form).
- Magic links: generate a single-use, short-lived token in your app; put the full URL in both
htmlandtext. - Support: office@rapttor.com
Unsubscribes (mandatory; do not add your own link)
Every email carries an unsubscribe link at the bottom plus List-Unsubscribe headers. This cannot be turned off, because the recipient's right to opt out is not the project's to remove. Do not build your own unsubscribe link for these emails.
When the recipient opens the link, a page names the project and its description and asks them to confirm; their confirmation (or their mailbox provider's one-click request) records the opt-out. After that, POST /v1/send returns 422 with {"error":"Recipient has unsubscribed from this project","code":"recipient_unsubscribed","resubscribe_url":"..."} for every kind of mail to that address, including login links. Do not retry. Tell the user that their email is blocked and show them the resubscribe_url: they enter their address there and receive a confirmation link from Postar (valid 1 hour); only they can finish it, so your app cannot resubscribe them. A bounced or complained address returns code: recipient_suppressed instead.
Keep your own list in sync with the same Bearer key (scoped to the key's project):
POST https://api.postar.vip/v1/unsubscribeswith{"email":"user@example.com"}: the user opted out inside your app.201created,200already blocked.GET https://api.postar.vip/v1/unsubscribes?email=user@example.com: returns{"email","unsubscribed","source","since"}.DELETE https://api.postar.vip/v1/unsubscribes?email=user@example.com: the user re-enabled email in your app (an authenticated action). Lifts a block your app placed (sourceapi). A block the recipient made with the link (sourcerecipient) returns409: send the user to theresubscribe_urlinstead.
Set the project description in the backoffice so recipients see who is emailing them.
Yii2 component (drop-in)
Save as components/Postar.php in the Yii2 app, then register it and call send().
<?php
namespace app\components;
use yii\base\Component;
use yii\base\InvalidConfigException;
/**
* Client for the Postar mail API. Needs ext-curl.
*
* 'components' => ['postar' => ['class' => \app\components\Postar::class]]
*
* Settings not given in the config are read from the environment (.env):
* POSTAR_BASE_URL, POSTAR_API_KEY, POSTAR_FROM (domain must be allowed for the key),
* POSTAR_FROM_NAME, POSTAR_REPLY_TO (optional: overrides the project's default Reply-To).
* Any property can still be set in the config to override the environment.
*
* Yii::$app->postar->send('user@example.com', 'Your login link', '<p>...</p>');
*/
class Postar extends Component
{
public string $baseUrl = '';
public string $apiKey = '';
public string $from = '';
public ?string $fromName = null;
public ?string $replyTo = null;
public int $timeout = 10;
public function init(): void
{
parent::init();
$this->baseUrl = $this->baseUrl ?: self::env('POSTAR_BASE_URL');
$this->apiKey = $this->apiKey ?: self::env('POSTAR_API_KEY');
$this->from = $this->from ?: self::env('POSTAR_FROM');
$this->fromName = $this->fromName ?? (self::env('POSTAR_FROM_NAME') ?: null);
$this->replyTo = $this->replyTo ?? (self::env('POSTAR_REPLY_TO') ?: null);
}
private static function env(string $name): string
{
$value = $_ENV[$name] ?? getenv($name);
return $value === false ? '' : (string)$value;
}
/**
* Queues one email. Returns ['id' => ..., 'status' => 'queued'] or throws \RuntimeException
* (message = API error text, code = HTTP status; 0 for network errors).
*
* @param array $options optional overrides: from, name, reply_to, text
*/
public function send(string $to, string $subject, string $html, array $options = []): array
{
if ($this->baseUrl === '' || $this->apiKey === '' || $this->from === '') {
throw new InvalidConfigException('Postar needs baseUrl, apiKey and from.');
}
$payload = array_filter([
'from' => $this->from,
'name' => $this->fromName,
'reply_to' => $this->replyTo,
'to' => $to,
'subject' => $subject,
'html' => $html,
], static fn($v) => $v !== null);
$payload = array_merge($payload, $options);
$ch = curl_init(rtrim($this->baseUrl, '/') . '/v1/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
]);
$body = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new \RuntimeException("Postar request failed: $error", 0);
}
$data = json_decode($body, true);
if ($status !== 202 || !is_array($data)) {
throw new \RuntimeException($data['error'] ?? "Postar error (HTTP $status)", $status);
}
return $data;
}
Add to the app's .env (and .env.example without the key):
POSTAR_BASE_URL=https://api.postar.vip
POSTAR_API_KEY=pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
POSTAR_FROM=myapp@postar.vip
POSTAR_FROM_NAME="My App"
# optional: overrides the project's default Reply-To
POSTAR_REPLY_TO=
Config (config/web.php and config/console.php); the component reads the variables above itself:
'components' => [
'postar' => ['class' => \app\components\Postar::class],
],
Any property (baseUrl, apiKey, from, fromName, replyTo, timeout) can also be set in the config to override the environment.
Usage:
try {
Yii::$app->postar->send($user->email, 'Your login link', $html, ['text' => $plainText]);
} catch (\RuntimeException $e) {
Yii::error('Postar: ' . $e->getMessage() . ' (HTTP ' . $e->getCode() . ')');
}
Other languages
Same environment variables everywhere (POSTAR_BASE_URL, POSTAR_API_KEY, POSTAR_FROM, optional POSTAR_FROM_NAME,
POSTAR_REPLY_TO). Success is always HTTP 202. Use a 5-10 second timeout.
Node.js 18+:
const res = await fetch(`${process.env.POSTAR_BASE_URL}/v1/send`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.POSTAR_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: process.env.POSTAR_FROM, to, subject, html, text }),
signal: AbortSignal.timeout(10000),
});
if (res.status !== 202) throw new Error(`Postar ${res.status}: ${(await res.json()).error}`);
Python (requests):
r = requests.post(os.environ["POSTAR_BASE_URL"] + "/v1/send",
headers={"Authorization": "Bearer " + os.environ["POSTAR_API_KEY"]}, timeout=10,
json={"from": os.environ["POSTAR_FROM"], "to": to, "subject": subject, "html": html})
r.status_code == 202 or raise_error(r.text)
PHP without a framework (cURL):
$ch = curl_init(getenv('POSTAR_BASE_URL') . '/v1/send');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('POSTAR_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['from' => getenv('POSTAR_FROM'), 'to' => $to, 'subject' => $subject,
'html' => $html])]);
$body = curl_exec($ch); // expect curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 202
Go, Ruby, Java, C#, Rust, TypeScript and PowerShell examples are in the repository: docs/API.md.