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)

fieldrequirednotes
fromyesany local part, but the domain must be one allowed for the API key, e.g. login@postar.vip
toyesone recipient address
subjectyesno line breaks
html and/or textone of themsend both when possible
namenodisplay name of the sender, e.g. "My App"
reply_tonowhere 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":"..."}):

HTTPmeaningwhat to do
401missing/invalid/revoked keycheck the key
403from domain not allowed for this keyuse an allowed domain or ask the admin to add it
422invalid field, missing body, or recipient is suppressed (bounced/complained)fix the request; do not retry
429hourly rate limit of the key reachedretry later
5xx / networktemporaryretry with backoff

Rules

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):

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.