# SRS Postline: руководство по интеграции REST API

Актуально на 29 августа 2026 года. Это руководство описывает реализованный API. Полная машинно-читаемая схема: [`openapi/postline.yaml`](../openapi/postline.yaml).

## 1. Что делает Postline

Postline принимает транзакционное письмо через HTTPS, проверяет API key, scope, домен и suppression list, сохраняет сообщение и ставит его в очередь. Ответ `202 Accepted` означает «принято в очередь», а не «доставлено». Конечный статус нужно читать через `GET /v1/emails/{id}` или проверенный webhook.

Текущие ограничения:

- максимум 100 адресатов суммарно в `to`, `cc` и `bcc`;
- требуется хотя бы одно из полей `html` или `text`;
- тело письма (`html` + `text`) ограничено 2 MiB; edge дополнительно ограничивает размер HTTP-запроса;
- attachments и произвольные headers пока отклоняются с `400`;
- API key ограничен 100 запросами в секунду, проект — 1,000 запросами в минуту;
- один проект может отправить не более 1,000 получателей в минуту, 500 получателей на один recipient-домен в минуту и 50 писем одному адресу в час;
- marketing-отправка отключена до появления проверяемого consent/unsubscribe-контура; API предназначен для транзакционных писем;
- рабочий `srs_live_…` ключ доступен сразу после регистрации;
- до проверки собственного домена можно отправлять только с `sandbox@srs-postline.com` на email участника проекта;
- после проверки домена можно отправлять любым получателям в пределах квот тарифа.

## 2. Что подготовить

1. Войдите в dashboard: `https://app.srs-postline.com/en/login`.
2. Создайте минимальный API key со scope `emails:send`. Для чтения статуса добавьте `emails:read`.
3. Для немедленного smoke-теста используйте `sandbox@srs-postline.com` и email участника проекта. Для реальной отправки добавьте домен отправителя и дождитесь успешной проверки DNS.
4. Сохраните ключ только на сервере или в secret manager как `POSTLINE_API_KEY`.
5. Ключ `srs_live_…` не должен попадать в браузер, мобильное приложение, Git или Docker image.

Базовый URL production: `https://api.srs-postline.com`. Для локального compose используется `http://localhost:18080`.

## 3. Минимальный контракт отправки

```http
POST /v1/emails HTTP/1.1
Host: api.srs-postline.com
Authorization: Bearer srs_live_...
Content-Type: application/json
Idempotency-Key: order-284731-confirmation

{
  "from": "Shop <mail@example.com>",
  "to": ["customer@example.net"],
  "subject": "Order 284731",
  "text": "Your order was accepted.",
  "html": "<p>Your order was accepted.</p>",
  "tags": {"type": "transactional", "order_id": "284731"}
}
```

Успешный ответ:

```json
{
  "id": "msg_01JPOSTLINEEXAMPLE",
  "status": "queued",
  "created_at": "2026-08-21T10:00:00Z"
}
```

`Idempotency-Key` должен быть стабильным идентификатором логической отправки, например `order-{orderId}-confirmation`. Повтор того же запроса с тем же ключом возвращает уже созданное сообщение и не ставит второе письмо в очередь. Не генерируйте новый UUID на каждой попытке retry.

## 4. Обязательная обработка ошибок

| HTTP | Значение | Повторять? |
|---:|---|---|
| `400` | Неверный JSON, обязательное поле, адрес, attachments или headers | Нет, исправить запрос |
| `401` | Ключ отсутствует, отозван или истёк | Нет, заменить secret |
| `403` | Нет scope; домен не проверен; либо sandbox-письмо адресовано не участнику проекта | Нет, исправить доступ, отправителя, получателя или DNS |
| `404` | Ресурс не найден в проекте | Нет |
| `422` | Получатель в suppression list | Нет; не обходить suppression |
| `429` | Превышен rate limit | Да, exponential backoff + jitter |
| `500` | Внутренняя ошибка | Да, ограниченно и с тем же idempotency key |
| `503` | Очередь/rate limiter временно недоступны | Да, ограниченно и с тем же idempotency key |

Для `429`, `500` и `503` используйте, например, задержки 1 s, 2 s, 4 s, 8 s, 16 s с jitter и общим deadline. Логируйте request ID, HTTP status и `message.id`, но никогда не API key и не содержимое персональных писем без необходимости.

## 5. Примеры на языках программирования

Во всех примерах замените `order-284731-confirmation` стабильным ID вашего события. Ключ читается из `POSTLINE_API_KEY`.

### cURL / Bash

```bash
curl --fail-with-body https://api.srs-postline.com/v1/emails \
  -X POST \
  -H "Authorization: Bearer $POSTLINE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-284731-confirmation" \
  --data '{"from":"Shop <mail@example.com>","to":["customer@example.net"],"subject":"Order 284731","text":"Your order was accepted."}'
```

### JavaScript (Node.js 20+)

```js
const response = await fetch("https://api.srs-postline.com/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POSTLINE_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-284731-confirmation",
  },
  body: JSON.stringify({
    from: "Shop <mail@example.com>",
    to: ["customer@example.net"],
    subject: "Order 284731",
    text: "Your order was accepted.",
  }),
});
const body = await response.json();
if (!response.ok) throw new Error(`Postline ${response.status}: ${body.error}`);
console.log(body.id);
```

### TypeScript

```ts
type QueuedEmail = { id: string; status: string; created_at: string };

export async function sendEmail(apiKey: string, idempotencyKey: string): Promise<QueuedEmail> {
  const response = await fetch("https://api.srs-postline.com/v1/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({
      from: "Shop <mail@example.com>",
      to: ["customer@example.net"],
      subject: "Order 284731",
      text: "Your order was accepted.",
    }),
  });
  const body = await response.json();
  if (!response.ok) throw new Error(`Postline ${response.status}: ${body.error}`);
  return body as QueuedEmail;
}
```

### Python 3 (standard library)

```python
import json, os, urllib.error, urllib.request

payload = json.dumps({
    "from": "Shop <mail@example.com>",
    "to": ["customer@example.net"],
    "subject": "Order 284731",
    "text": "Your order was accepted.",
}).encode()
request = urllib.request.Request(
    "https://api.srs-postline.com/v1/emails",
    data=payload,
    method="POST",
    headers={
        "Authorization": f"Bearer {os.environ['POSTLINE_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": "order-284731-confirmation",
    },
)
try:
    with urllib.request.urlopen(request, timeout=15) as response:
        print(json.load(response)["id"])
except urllib.error.HTTPError as error:
    raise RuntimeError(f"Postline {error.code}: {error.read().decode()}") from error
```

### PHP 8

```php
<?php
$ch = curl_init('https://api.srs-postline.com/v1/emails');
$payload = json_encode([
    'from' => 'Shop <mail@example.com>',
    'to' => ['customer@example.net'],
    'subject' => 'Order 284731',
    'text' => 'Your order was accepted.',
], JSON_THROW_ON_ERROR);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 15,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('POSTLINE_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: order-284731-confirmation',
    ],
    CURLOPT_POSTFIELDS => $payload,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status < 200 || $status >= 300) {
    throw new RuntimeException("Postline $status: " . ($body ?: curl_error($ch)));
}
echo json_decode($body, true, 512, JSON_THROW_ON_ERROR)['id'];
```

### Go

```go
payload := []byte(`{"from":"Shop <mail@example.com>","to":["customer@example.net"],"subject":"Order 284731","text":"Your order was accepted."}`)
req, err := http.NewRequest(http.MethodPost, "https://api.srs-postline.com/v1/emails", bytes.NewReader(payload))
if err != nil { log.Fatal(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("POSTLINE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "order-284731-confirmation")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Fatalf("Postline %d: %s", resp.StatusCode, body) }
fmt.Println(string(body))
```

### Java 11+

```java
String json = "{\"from\":\"Shop <mail@example.com>\",\"to\":[\"customer@example.net\"],\"subject\":\"Order 284731\",\"text\":\"Your order was accepted.\"}";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.srs-postline.com/v1/emails"))
    .timeout(Duration.ofSeconds(15))
    .header("Authorization", "Bearer " + System.getenv("POSTLINE_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "order-284731-confirmation")
    .POST(HttpRequest.BodyPublishers.ofString(json))
    .build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
    throw new IOException("Postline " + response.statusCode() + ": " + response.body());
}
System.out.println(response.body());
```

### Kotlin (JVM 11+)

```kotlin
val json = """{"from":"Shop <mail@example.com>","to":["customer@example.net"],"subject":"Order 284731","text":"Your order was accepted."}"""
val request = HttpRequest.newBuilder(URI("https://api.srs-postline.com/v1/emails"))
    .timeout(Duration.ofSeconds(15))
    .header("Authorization", "Bearer ${System.getenv("POSTLINE_API_KEY")}")
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "order-284731-confirmation")
    .POST(HttpRequest.BodyPublishers.ofString(json))
    .build()
val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString())
check(response.statusCode() in 200..299) { "Postline ${response.statusCode()}: ${response.body()}" }
println(response.body())
```

### C# / .NET 8

```csharp
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
client.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("POSTLINE_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.srs-postline.com/v1/emails");
request.Headers.Add("Idempotency-Key", "order-284731-confirmation");
request.Content = JsonContent.Create(new {
    from = "Shop <mail@example.com>",
    to = new[] { "customer@example.net" },
    subject = "Order 284731",
    text = "Your order was accepted."
});
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"Postline {(int)response.StatusCode}: {body}");
Console.WriteLine(body);
```

### Ruby 3

```ruby
require "json"
require "net/http"
uri = URI("https://api.srs-postline.com/v1/emails")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('POSTLINE_API_KEY')}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = "order-284731-confirmation"
request.body = {from: "Shop <mail@example.com>", to: ["customer@example.net"], subject: "Order 284731", text: "Your order was accepted."}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 15) { |http| http.request(request) }
raise "Postline #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
puts response.body
```

### Rust (`reqwest` + `serde_json`)

```rust
let client = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?;
let response = client.post("https://api.srs-postline.com/v1/emails")
    .bearer_auth(std::env::var("POSTLINE_API_KEY")?)
    .header("Idempotency-Key", "order-284731-confirmation")
    .json(&serde_json::json!({
        "from": "Shop <mail@example.com>",
        "to": ["customer@example.net"],
        "subject": "Order 284731",
        "text": "Your order was accepted."
    }))
    .send().await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() { anyhow::bail!("Postline {status}: {body}"); }
println!("{body}");
```

### Swift 5 (`URLSession`)

```swift
var request = URLRequest(url: URL(string: "https://api.srs-postline.com/v1/emails")!)
request.httpMethod = "POST"
request.timeoutInterval = 15
request.setValue("Bearer \(ProcessInfo.processInfo.environment["POSTLINE_API_KEY"]!)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("order-284731-confirmation", forHTTPHeaderField: "Idempotency-Key")
request.httpBody = try JSONSerialization.data(withJSONObject: [
    "from": "Shop <mail@example.com>", "to": ["customer@example.net"],
    "subject": "Order 284731", "text": "Your order was accepted."
])
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as! HTTPURLResponse).statusCode
guard (200..<300).contains(status) else { throw NSError(domain: "Postline", code: status, userInfo: [NSLocalizedDescriptionKey: String(data: data, encoding: .utf8)!]) }
print(String(data: data, encoding: .utf8)!)
```

### Dart / Flutter

```dart
final client = HttpClient()..connectionTimeout = const Duration(seconds: 15);
final request = await client.postUrl(Uri.parse('https://api.srs-postline.com/v1/emails'));
request.headers.set('Authorization', 'Bearer ${Platform.environment['POSTLINE_API_KEY']}');
request.headers.set('Content-Type', 'application/json');
request.headers.set('Idempotency-Key', 'order-284731-confirmation');
request.write(jsonEncode({
  'from': 'Shop <mail@example.com>', 'to': ['customer@example.net'],
  'subject': 'Order 284731', 'text': 'Your order was accepted.'
}));
final response = await request.close();
final body = await utf8.decoder.bind(response).join();
if (response.statusCode < 200 || response.statusCode >= 300) throw HttpException('Postline ${response.statusCode}: $body');
print(body);
```

### PowerShell 7

```powershell
$headers = @{
  Authorization = "Bearer $env:POSTLINE_API_KEY"
  "Idempotency-Key" = "order-284731-confirmation"
}
$body = @{
  from = "Shop <mail@example.com>"
  to = @("customer@example.net")
  subject = "Order 284731"
  text = "Your order was accepted."
} | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "https://api.srs-postline.com/v1/emails" -Headers $headers -ContentType "application/json" -Body $body
```

### C++ (`libcurl`)

```cpp
CURL* curl = curl_easy_init();
curl_slist* headers = nullptr;
headers = curl_slist_append(headers, ("Authorization: Bearer " + std::string(std::getenv("POSTLINE_API_KEY"))).c_str());
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, "Idempotency-Key: order-284731-confirmation");
const char* json = R"({"from":"Shop <mail@example.com>","to":["customer@example.net"],"subject":"Order 284731","text":"Your order was accepted."})";
curl_easy_setopt(curl, CURLOPT_URL, "https://api.srs-postline.com/v1/emails");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
CURLcode result = curl_easy_perform(curl);
long status = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
if (result != CURLE_OK || status < 200 || status >= 300) throw std::runtime_error("Postline request failed");
curl_slist_free_all(headers); curl_easy_cleanup(curl);
```

## 6. Проверка статуса

```http
GET /v1/emails/msg_01JPOSTLINEEXAMPLE
Authorization: Bearer srs_live_...
```

Ключу нужен scope `emails:read`. Не считайте `queued` доставкой. Типовой жизненный цикл: `queued` → `sent`/`delivered`, либо `deferred`/`bounced`/`complained`/`suppressed`.

## 7. Webhooks

Создавайте endpoint только на публичном HTTPS URL. Private, loopback, link-local адреса и redirects блокируются защитой SSRF. Сохраните выданный `whsec_…` secret; повторно показать его может быть невозможно.

Postline отправляет:

```http
Postline-Event-Id: evt_xxx
Postline-Timestamp: 1787306400
Postline-Signature: v1=<hex hmac sha256>
```

Подписываемое сообщение — байты `timestamp + NUL + event_id + NUL + raw_body`. Ключ HMAC — webhook secret. Проверяйте подпись constant-time до JSON parsing, отклоняйте старый timestamp и дедуплицируйте `Postline-Event-Id`. Для повторной доставки отвечайте не-2xx; расписание: 1 min, 5 min, 30 min, 2 h, 8 h, 24 h, максимум 6 попыток по умолчанию.

Пример Node.js:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyPostlineWebhook({ rawBody, timestamp, eventId, signature, secret }) {
  const signed = Buffer.concat([Buffer.from(timestamp), Buffer.from([0]), Buffer.from(eventId), Buffer.from([0]), rawBody]);
  const expected = `v1=${createHmac("sha256", secret).update(signed).digest("hex")}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

## 8. Production checklist

- API key хранится в secret manager и имеет минимальные scopes.
- From-домен проверен, SPF/DKIM/DMARC показывают ожидаемый статус.
- Каждый send использует стабильный idempotency key.
- Приложение различает queued и delivered.
- Реализованы bounded retry только для `429/500/503`.
- Suppression `422` не обходится повторной отправкой.
- Webhook проверяется по raw bytes, timestamp и event ID.
- В логах нет API keys, session tokens и полного body письма.
- Есть alert на рост bounce/complaint и на постоянные `401/403/429/503`.
- До проверки From-домена sandbox используется только для участников проекта; после DNS-проверки live-ключ отправляет внешним получателям в пределах квот и abuse-контролей.

## 9. Правила для AI-агента

1. Не выдумывать API key, live approval, SDK package, endpoint или поле, которых нет в OpenAPI.
2. Использовать raw HTTPS, если официального SDK для языка нет.
3. Никогда не помещать key в client-side код.
4. Всегда передавать стабильный `Idempotency-Key` и сохранять его рядом с бизнес-событием.
5. Считать `202` только постановкой в очередь.
6. Не повторять `400/401/403/404/422` без изменения причины.
7. Не добавлять attachments или custom headers: текущий API их отклоняет.
8. Перед изменением интеграции перечитать `/openapi/postline.yaml` и этот документ.

## 10. Дополнительные ресурсы

- OpenAPI: `https://srs-postline.com/openapi/postline.yaml`
- AI index: `https://srs-postline.com/llms.txt`
- auth.md: `https://srs-postline.com/auth.md`
- OAuth protected resource: `https://srs-postline.com/.well-known/oauth-protected-resource`
- API catalog: `https://srs-postline.com/.well-known/api-catalog`
- Документация: `https://srs-postline.com/en/docs/`
- API page: `https://srs-postline.com/en/api/`
- Webhooks: `https://srs-postline.com/en/webhooks/`
