# Postar — integration guide for LLMs and developers 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 `. 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: ```bash 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":"

Log in

","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.vip` addresses cannot receive mail, so a reply to the From address bounces. Set the project's Reply-To in the backoffice once, or pass `reply_to` per 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, and `from` can be your own domain (e.g. `@myapp.com`) once SPF and DKIM are published there (backoffice > Domains shows the records). Until then, `@postar.vip` works without any DNS changes: set `reply_to` to 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 `html` and `text`. - 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/unsubscribes` with `{"email":"user@example.com"}`: the user opted out inside your app. `201` created, `200` already 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 (`source` `api`). A block the recipient made with the link (`source` `recipient`) returns `409`: send the user to the `resubscribe_url` instead. 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 ['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', '

...

'); */ 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: ```php '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: ```php 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+: ```js 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): ```python 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`.