# RelyPost Email API documentation

RelyPost is a transactional email REST API for applications sending order confirmations, password resets, receipts and other service notifications from their own verified domains. API v1 supports plain text and HTML, inline images, file attachments, scheduled sending, message status, domain inspection and signed webhooks. Use it from any language that can make HTTPS requests.

Official guide: https://relypost.com/docs
OpenAPI 3.1: https://relypost.com/docs/openapi.json
API version: v1
Reviewed: 2026-09-14
Base URL: https://api.relypost.com/v1
Publisher: RelyPost, operated by Digital Shift LLC
Support: support@relypost.com

## Setup and authentication

1. Ask RelyPost support or your platform administrator to activate Email API for your organization. Review the API plan in Developer → Billing. Creating a key alone does not activate sending.
2. In Developer → Domains, choose an existing verified, active workspace domain, or add a domain and complete its DNS setup and verification.
3. In Developer → Mailboxes, create an Email API sender on that domain and wait until active. Creating the sender enables the domain for Email API within your plan limits; existing domains do not need to be added or verified again. A regular employee or shared mailbox cannot send through the public API.
4. An organization owner or administrator creates a key in Developer → API keys. For the quickstart grant emails:send and emails:read. Save the one-time secret in RELYPOST_API_KEY on your application server. Members can read these docs but need their administrator to configure the integration.
5. Set Authorization: Bearer rp_live_… and Content-Type: application/json. Use a stable Idempotency-Key of 8–200 characters for POST /emails. Save the response id.

Troubleshooting: Open Developer → API request logs to find requests by error code or request ID, including rejections before queueing. HTTP responses include X-Request-Id. Unknown keys and requests blocked before reaching RelyPost cannot be associated with your organization. Logs follow your API plan’s retention period. For accepted emails, use Delivery logs to inspect later delivery outcomes.

Keys are organization-scoped. Their creator must remain a verified owner or administrator. Rotate exposed keys and revoke old keys in the workspace. Never put keys in browser or mobile code, URLs or public repositories. A session cookie, mailbox password or SMTP password is not an API key.

| Scope | Permission |
| --- | --- |
| emails:send | Send application email. |
| emails:read | Read message status, recipients and events. |
| domains:read | Read your organization’s domains and DNS details. |
| attachments:write | Reserve, upload and finalize attachments. |
| webhooks:manage | Register endpoints and manage webhook deliveries. |

## Send your first email

Replace from with your active sender and to with your test recipient. Choose a unique idempotency key for each application event; retry an identical body with the same key after a timeout. Examples use placeholder data. No SDK is required.

### cURL

```bash
curl --fail-with-body 'https://api.relypost.com/v1/emails' \
  -H "Authorization: Bearer $RELYPOST_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-123-confirmation' \
  --data '{
  "from": "hello@your-domain.com",
  "to": [
    "customer@example.com"
  ],
  "subject": "Your order confirmation",
  "text": "Thank you for your order."
}'
```

### Node.js

```javascript
// Node.js 18+; run on your server. Set RELYPOST_API_KEY in the environment.
const response = await fetch('https://api.relypost.com/v1/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RELYPOST_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-123-confirmation',
  },
  body: JSON.stringify({
  "from": "hello@your-domain.com",
  "to": [
    "customer@example.com"
  ],
  "subject": "Your order confirmation",
  "text": "Thank you for your order."
}),
});
const result = await response.json();
if (!response.ok) {
  throw new Error(`${response.status} ${result.error?.code}: ${result.error?.message} (request ${result.error?.requestId})`);
}
console.log(result.id, result.state); // Save this ID to track the message.
```

### Python

```python
# Python 3 standard library. Set RELYPOST_API_KEY in the environment.
import json
import os
from urllib.request import Request, urlopen
from urllib.error import HTTPError

payload = {
  "from": "hello@your-domain.com",
  "to": [
    "customer@example.com"
  ],
  "subject": "Your order confirmation",
  "text": "Thank you for your order."
}
request = Request(
    'https://api.relypost.com/v1/emails',
    data=json.dumps(payload).encode('utf-8'),
    headers={
        'Authorization': 'Bearer ' + os.environ['RELYPOST_API_KEY'],
        'Content-Type': 'application/json',
        'Idempotency-Key': 'order-123-confirmation',
    },
    method='POST',
)
try:
    with urlopen(request, timeout=30) as response:
        result = json.load(response)
        print(result['id'], result['state'])
except HTTPError as error:
    print(error.code, error.read().decode('utf-8'))
    raise
```


HTTP 202 means queued, not delivered. Record the returned UUID and use GET /emails/{id} with emails:read to track it. Use that UUID, not the RFC Message-ID. Status includes recipients, SMTP responses, recipient_retry and events; the API returns 404 outside your plan’s retention window. Prefer webhooks over continuous polling.

| State | Meaning |
| --- | --- |
| queued | Saved for sending or waiting for schedule, quota or sending capacity. |
| submitting | An SMTP attempt is in progress. |
| submitted | RelyPost’s submission server accepted responsibility. Check recipient states for destination acceptance. |
| accepted (recipient) | The destination server accepted this recipient. This does not establish inbox placement or reading. |
| deferred | A temporary failure or delay. Eligible temporary failures are retried with a bounded budget. |
| uncertain | The server outcome is unknown. RelyPost reconciles evidence; do not create a new send or change the idempotency key to force a resend. |
| rejected / bounced | A permanent failure, exhausted retry budget, or recipient bounce. Read the diagnostic and per-recipient response. |
| cancelled | The queued operation was cancelled. There is no public API cancellation endpoint. |

## Send HTML email and inline images

Set html to your rendered HTML string and text to a useful plain-text alternative. RelyPost passes these bodies to the email transport. There is no template rendering endpoint or automatic CSS inlining service. Use email-compatible HTML, inline styles and meaningful image alt text; escape untrusted values before inserting them into a template.

For a hosted image, use an absolute HTTPS URL. For an attached image, upload and finalize the image first, then set its cid and reference exactly that value with cid: in the HTML. The example file ID below is a placeholder; replace it with your ready image ID. Test the rendered email in the mail clients your recipients use.

### HTML email request body

```json
{
  "from": "hello@your-domain.com",
  "to": [
    "customer@example.com"
  ],
  "subject": "Your order confirmation",
  "text": "Thank you for your order.",
  "html": "<!doctype html><html lang=\"en\"><body><h1>Thank you for your order</h1><p>Your order <strong>#123</strong> is confirmed.</p><p><a href=\"https://your-app.com/orders/123\">View your order</a></p></body></html>"
}
```

### Inline image request body

```json
{
  "from": "hello@your-domain.com",
  "to": [
    "customer@example.com"
  ],
  "subject": "Your order confirmation",
  "text": "Thank you for your order.",
  "html": "<h1>Order confirmed</h1><img src=\"cid:company-logo\" width=\"160\" alt=\"Company logo\"><p>Thank you for your order.</p>",
  "attachments": [
    {
      "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
      "cid": "company-logo"
    }
  ]
}
```


## Schedule email and preserve threading

Add scheduled_at as an ISO 8601 UTC timestamp ending in Z, no more than 365 days in the future. Omit it to queue immediately. A past timestamp is eligible for immediate processing. A schedule is the earliest sending time; queues, quotas and delivery retries can delay it. There is no public endpoint to cancel or modify an accepted email.

Generate a future timestamp once and persist the entire request with its Idempotency-Key. Do not recompute scheduled_at on a retry: a changed payload conflicts with the original key. Uploads have a staging lifetime, so prepare attachments close to the sending time rather than assuming they remain available for a distant schedule.

For a reply, set in_reply_to to the original RFC Message-ID and references to the prior Message-ID chain. These values use angle brackets, such as <original@your-domain.com>; they are different from RelyPost’s message UUID. Set reply_to to an email address for responses. priority is high, normal or low; it is not a delivery-speed guarantee. tags, metadata and X- headers let you associate messages with application context; they do not create search or filtering endpoints.


```javascript
// Node.js 18+. Generate once; persist payload and key before the first attempt.
const payload = {
  ...{
  "from": "hello@your-domain.com",
  "to": [
    "customer@example.com"
  ],
  "subject": "Your order confirmation",
  "text": "Thank you for your order."
},
  scheduled_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
const idempotencyKey = 'order-123-followup';
// Reuse these exact values for every retry of this application event.
const response = await fetch('https://api.relypost.com/v1/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RELYPOST_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) throw new Error(JSON.stringify(result.error));
console.log(result.id, result.scheduled_at);
```


## Response conventions and integration limits

The public base URL ends in /v1. All 12 operations require a Bearer API key and return JSON. Paths below are relative to that base; UUID path values identify resources inside the key’s organization. Send application/json for request bodies. No list operation accepts a documented pagination, search or filter parameter; webhook delivery history returns at most 100 records.

Unknown top-level email fields are rejected. Omitted optional fields receive the defaults shown in OpenAPI; null is not an omission. API email addresses are lowercased by validation. Responses can contain null for timestamps or diagnostics that are not yet available. Treat new response fields and unknown event types as forward-compatible additions.

Success responses include X-Request-Id. Errors use error.code, error.message and error.requestId; validation errors may also include error.fields entries with path and message. Keep the request ID for support. Never log your API key, SMTP app password, webhook secret or signed upload URL.

Request JSON is limited to 1,500,000 bytes. Default combined recipients: 20; each address array has a schema maximum of 100. Default complete MIME message limit: 25 MiB, including headers and attachment encoding. Default upload limit: 100 MiB, which does not mean a 100 MiB file fits in an email. Other limits depend on the deployment and plan. Upload sessions last one hour; API attachment staging defaults to 24 hours.

Sending quotas count recipients at admission, including cc and bcc. Dispatch rate limits can keep accepted messages queued. There is no public endpoint for querying remaining quota or a promise of rate-limit headers. See Developer → Billing. Message status is available only within your plan’s log-retention window.

For timeouts or temporary failures, retry the identical payload with the same organization-scoped Idempotency-Key and exponential backoff with jitter. Reusing a key with changed content returns 409 idempotency_conflict. Use a new key for each new application event. Do not treat idempotency as a permanent archive after data retention; store your own business-event/message mapping.



## Complete endpoint reference

All paths are relative to https://api.relypost.com/v1. Path placeholders are UUIDs. The OpenAPI document provides all request and response schemas, required fields, defaults, nullability and the webhook contract.

### POST /emails

Reference: https://relypost.com/docs#post-emails

Required scope: emails:send

Queue one message. Requires Idempotency-Key (8–200 characters). Returns 202; save the id for tracking.

Request example:
```json
{
  "from": "hello@your-domain.com",
  "to": [
    "customer@example.com"
  ],
  "subject": "Your order confirmation",
  "text": "Thank you for your order."
}
```

Response example:
```json
{
  "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
  "state": "queued",
  "revision": 1,
  "scheduled_at": "2026-09-14T12:00:00.000Z"
}
```

### GET /emails/{id}

Reference: https://relypost.com/docs#get-emails-id

Required scope: emails:read

Read one API message from this organization within your plan’s log-retention window. Use the UUID returned by POST, not the RFC Message-ID.

Response example:
```json
{
  "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
  "state": "submitted",
  "message_id": "<message@mail01.relypost.com>",
  "created_at": "2026-09-14T12:00:00.000Z",
  "scheduled_at": "2026-09-14T12:00:00.000Z",
  "submitted_at": "2026-09-14T12:00:01.000Z",
  "smtp_code": "250",
  "diagnostic": "Accepted by submission server",
  "recipients": [
    {
      "address": "customer@example.com",
      "kind": "to",
      "state": "accepted",
      "enhanced_status": "2.0.0",
      "remote_response": "status=sent (250 accepted)",
      "observed_at": "2026-09-14T12:00:02.000Z"
    }
  ],
  "recipient_retry": null,
  "events": [
    {
      "type": "email.queued",
      "created_at": "2026-09-14T12:00:00.000Z",
      "details": {}
    }
  ]
}
```

### GET /domains

Reference: https://relypost.com/docs#get-domains

Required scope: domains:read

List your organization’s domains. This API reads domains; add or verify them in Developer → Domains.

Response example:
```json
[
  {
    "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
    "name": "your-domain.com",
    "status": "active",
    "verified_at": "2026-09-14T10:00:00.000Z"
  }
]
```

### GET /domains/{id}

Reference: https://relypost.com/docs#get-domains-id

Required scope: domains:read

Read a domain’s status and DNS configuration. Returns id, name, status, verified_at, dkim_selector, dkim_txt and dns_state.

Response example:
```json
{
  "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
  "name": "your-domain.com",
  "status": "active",
  "verified_at": "2026-09-14T10:00:00.000Z",
  "dkim_selector": "dkim",
  "dkim_txt": "v=DKIM1; k=rsa; p=YOUR_PUBLIC_KEY",
  "dns_state": []
}
```

### POST /attachments

Reference: https://relypost.com/docs#post-attachments

Required scope: attachments:write

Reserve an upload; returns 201. id is the eventual attachment ID; sessionId is used for uploading/finalizing. For multipart:false, the response also includes url and fields.

Request example:
```json
{
  "name": "receipt.pdf",
  "size": 12345,
  "type": "application/pdf",
  "multipart": false
}
```

Response example:
```json
{
  "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
  "sessionId": "94f3e2c8-ff6e-45cf-90ef-8b037111ae64",
  "multipart": false,
  "url": "https://your-signed-storage-url.example",
  "fields": {
    "key": "temporary/…",
    "policy": "…",
    "x-amz-signature": "…"
  }
}
```

### POST /attachments/{sessionId}/part

Reference: https://relypost.com/docs#post-attachments-sessionid-part

Required scope: attachments:write

For multipart uploads only: get a signed PUT URL for a 1-based part number. Upload exactly that part’s bytes to the URL and save its ETag.

Request example:
```json
{
  "part": 1
}
```

Response example:
```json
{
  "url": "https://your-signed-storage-url.example/part"
}
```

### POST /attachments/{sessionId}/finalize

Reference: https://relypost.com/docs#post-attachments-sessionid-finalize

Required scope: attachments:write

Verify and scan the upload. Single uploads use parts:[]; multipart uploads use ordered {PartNumber,ETag} entries. Only state:ready can be attached to email.

Request example:
```json
{
  "parts": []
}
```

Response example:
```json
{
  "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
  "state": "ready"
}
```

### GET /webhooks

Reference: https://relypost.com/docs#get-webhooks

Required scope: webhooks:manage

List endpoints. Returns id, url, enabled, failures and created_at. Secrets are not returned.

Response example:
```json
[
  {
    "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
    "url": "https://your-app.com/webhooks/relypost",
    "enabled": true,
    "failures": 0,
    "created_at": "2026-09-14T12:00:00.000Z"
  }
]
```

### POST /webhooks

Reference: https://relypost.com/docs#post-webhooks

Required scope: webhooks:manage

Register a public HTTPS endpoint on port 443, without URL credentials or a fragment. Returns 201 and a secret shown only once. No event-selection field is supported.

Request example:
```json
{
  "url": "https://your-app.com/webhooks/relypost"
}
```

Response example:
```json
{
  "id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
  "secret": "SAVE_THE_ONE_TIME_SECRET"
}
```

### PATCH /webhooks/{id}

Reference: https://relypost.com/docs#patch-webhooks-id

Required scope: webhooks:manage

Enable or disable an existing endpoint. This also resets its failure counter.

Request example:
```json
{
  "enabled": true
}
```

Response example:
```json
{
  "ok": true
}
```

### GET /webhooks/{id}/deliveries

Reference: https://relypost.com/docs#get-webhooks-id-deliveries

Required scope: webhooks:manage

Read up to 100 webhook delivery records. Fields: id, event_id, state, attempts, last_status and next_attempt_at. This endpoint has no pagination parameters.

Response example:
```json
[
  {
    "id": "94f3e2c8-ff6e-45cf-90ef-8b037111ae64",
    "event_id": "a19f458e-a915-4aac-8ea1-8c859b41d934",
    "state": "pending",
    "attempts": 0,
    "last_status": null,
    "next_attempt_at": "2026-09-14T12:00:00.000Z"
  }
]
```

### POST /webhooks/{id}/retry

Reference: https://relypost.com/docs#post-webhooks-id-retry

Required scope: webhooks:manage

Requeue an existing delivery using its delivery-record id. Expired delivery payloads cannot be retried. This retries the webhook notification, not the email.

Request example:
```json
{
  "deliveryId": "94f3e2c8-ff6e-45cf-90ef-8b037111ae64"
}
```

Response example:
```json
{
  "ok": true
}
```


## Email request fields

Unknown top-level email fields are rejected. The OpenAPI EmailInput schema is generated from the same validator used by the live handler; business rules described in this guide apply in addition to schema validation.

| Field | Type | How to use it |
| --- | --- | --- |
| from | string · required | Full address of an active Email API sender on your verified, API-enabled domain. Display names such as “Team <hello@…>” are not accepted. |
| to | string[] · required | At least one recipient. Do not repeat an address across to, cc and bcc. The combined recipient limit is 20 by default; your deployment may configure a different limit. |
| subject | string · required | Up to 998 characters, with no line breaks. |
| text / html | string | Plain text and/or HTML body, up to 500,000 characters each. Provide at least one for a useful message. text defaults to an empty string. |
| cc / bcc | string[] | Additional recipients; each array accepts up to 100 addresses, subject to the combined recipient limit. |
| reply_to | string | One reply-to email address. |
| attachments | {id, cid?}[] | Up to 20 finalized file IDs. An optional cid (1–100 letters, digits, _, ., @ or -) lets HTML reference an inline attachment with cid:your-id. |
| scheduled_at | string | Optional ISO 8601 UTC timestamp ending in Z. Schedule no more than 365 days ahead. Omit for immediate queuing. |
| headers | object | Custom X- headers only. Names: X- plus 1–60 letters, digits or hyphens; values: up to 500 characters with no line breaks. |
| tags | string[] | Up to 10 tags, each up to 50 characters. |
| metadata | object | Up to 20 string values; keys up to 50 characters and values up to 500. |
| in_reply_to / references | string / string[] | Message-ID values such as <message@example.com>. Up to 30 references, each up to 998 characters. |
| priority | string | high, normal (default), or low. |

## Upload and attach files

Use attachments:write to reserve and finalize uploads, and emails:send to send them. POST /attachments accepts name (1–180 characters; no slash, backslash or control characters), size (positive byte count), type (MIME type, up to 120 characters), multipart (false by default), and folder (up to 500 characters, default /; no . or .. path segments). purpose is accepted by shared validation but always overridden to api-attachments; omit it.

For a single upload, POST every returned fields entry and then the file as multipart form data to the returned url. Let FormData set Content-Type, and do not send the API key to storage. Finalize with {"parts":[]} and require state:ready before attaching the resulting id.


```javascript
// Node.js 20+; upload a file, finalize it, then attach its ID.
import { readFile } from 'node:fs/promises';
const file = await readFile('./receipt.pdf');
async function api(path, body) {
  const response = await fetch('https://api.relypost.com/v1' + path, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RELYPOST_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const result = await response.json();
  if (!response.ok) throw new Error(JSON.stringify(result));
  return result;
}
const upload = await api('/attachments', {
  name: 'receipt.pdf', size: file.length, type: 'application/pdf', multipart: false,
});
const form = new FormData();
for (const [key, value] of Object.entries(upload.fields)) form.append(key, String(value));
form.append('file', new Blob([file], { type: 'application/pdf' }), 'receipt.pdf');
// Do not send your API key or set Content-Type on this storage request.
const stored = await fetch(upload.url, { method: 'POST', body: form });
if (!stored.ok) throw new Error('File upload failed: ' + stored.status);
const ready = await api('/attachments/' + upload.sessionId + '/finalize', { parts: [] });
if (ready.state !== 'ready') throw new Error('Attachment is not ready');
console.log(ready.id);
// Add attachments: [{ id: ready.id }] to your POST /emails payload.
// Use a new Idempotency-Key for this new message.
```


For multipart:true, reservation returns id, sessionId, partSize (5,242,880 bytes) and parts (number of parts). Request each signed part URL with {"part":1}, PUT that part’s exact bytes and retain its ETag. Parts are numbered from 1; the last part may be smaller. Finalize with all {PartNumber,ETag} pairs in ascending order. The part request accepts numbers 1–205, subject to the actual reserved size. The finalize list accepts at most 205 entries; ETag is 1–200 characters. Uploading alone is not finalization: validation and malware scanning must succeed.

## Webhook delivery and security

Register your server using POST /webhooks with {"url":"https://your-app.com/webhooks/relypost"} and webhooks:manage. The URL must use public HTTPS on port 443, without credentials or fragments. Save the secret at creation; GET /webhooks will not return it again. There is no event-selection field. Endpoints receive organization events, not only this API key’s sends.

The JSON envelope is {id,type,created_at,data}. The top-level id and webhook-id header identify the event. For email events, data.id identifies the email. Other data fields depend on the event; do not assume every event has a recipient or SMTP response. Known email types:

| Event type | Meaning |
| --- | --- |
| email.queued | The email was saved for sending, including scheduled messages. |
| email.submitted | The submission server accepted responsibility for the email. |
| email.deferred | The message was deferred after a temporary submission problem. |
| email.uncertain | Submission outcome is unknown; wait for reconciliation instead of creating another send. |
| email.rejected | Submission failed permanently or exhausted its retry budget. |
| email.recipient_accepted | The destination server accepted a recipient; this is not a read receipt. |
| email.recipient_deferred | A recipient had a temporary delivery failure. |
| email.recipient_resubmitted | A retry for a deferred recipient was submitted. |
| email.bounced | A recipient could not be delivered to, or a bounce was reported. |
| email.submission_recovered | Reconciliation recovered evidence about a previously uncertain submission. |
| email.complained | A complaint was reported by a configured feedback provider; availability depends on that provider. |

Verify before parsing JSON. Headers: webhook-id (event UUID), webhook-timestamp (Unix seconds), webhook-signature (hex HMAC-SHA256). The signed content is id.timestamp.rawBody, using the exact received bytes and your saved webhook secret. Reject timestamps more than five minutes in the past or future and compare signatures in constant time.


```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody must be the exact request bytes, before JSON parsing.
// headers is a Headers object; secret is your saved webhook secret.
export function verifyRelyPostWebhook(rawBody, headers, secret, now = Date.now()) {
  const id = headers.get('webhook-id');
  const timestamp = headers.get('webhook-timestamp');
  const signature = headers.get('webhook-signature') ?? '';
  if (!id || !timestamp || !/^\d+$/.test(timestamp)) return false;
  if (!/^[a-f0-9]{64}$/i.test(signature)) return false;
  if (Math.abs(now / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(id + '.' + timestamp + '.')
    .update(rawBody)
    .digest();
  return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}

// In your webhook handler:
// 1. Read the raw body and verify it; return 401 on failure.
// 2. Parse JSON only after verification.
// 3. Atomically store/enqueue the event using webhook-id as a UNIQUE key.
// 4. Return 2xx after durable acceptance, including for a duplicate.
// 5. Process stored events in your worker; do not send another email here.
```


Durably deduplicate webhook-id, accept unknown event types gracefully, and return 2xx after durable acceptance, including duplicates. Process expensive work asynchronously. Events may arrive more than once or out of order. Redirects are not followed; request timeout is 10 seconds. Automatic delivery retries use exponential backoff with a budget of 10 attempts; repeated failures disable the endpoint at the deployment’s configured threshold (default 10). Successful deliveries reset the endpoint failure counter.

Inspect GET /webhooks/{id}/deliveries (at most 100 records, no pagination). After fixing your receiver, PATCH /webhooks/{id} with {"enabled":true} to enable it and reset failures. POST /webhooks/{id}/retry accepts the delivery-record deliveryId, which is different from the event ID, and requeues that webhook notification. It does not resend the email or restore expired payloads; expired/unavailable delivery payloads return 404 delivery_expired.

## Errors and troubleshooting


```json
{
  "error": {
    "code": "validation_error",
    "message": "Invalid request",
    "requestId": "a19f458e-a915-4aac-8ea1-8c859b41d934",
    "fields": [
      {
        "path": "to.0",
        "message": "Invalid email address"
      }
    ]
  }
}
```


| HTTP status | Code/category | What to do |
| --- | --- | --- |
| 400 | duplicate_recipient / recipient_limit / metadata_limit / schedule_too_far | Use unique recipients within the combined limit, no more than 20 metadata keys, and a schedule within 365 days. |
| 400 | invalid_folder / invalid_part / invalid_parts | Use a folder without . or .. segments, a part number within the reserved file, and a complete ordered PartNumber/ETag list. |
| 400 | invalid_webhook / unsafe_webhook | Use a public HTTPS receiver on port 443. Private IPs, URL credentials and fragments are rejected. |
| 403 | key_owner_unavailable / organization_suspended | Ask an active administrator to review organization access and rotate the key if its owner is unavailable. |
| 413 | size_mismatch | Upload nonempty bytes within the reserved size, then finalize. Start a new reservation if the file changed. |
| 422 | unsafe_file | The file did not pass malware scanning. Do not retry or attach the same unsafe file. |
| 502 / 503 | storage_copy_failed / versioning_required / disk_emergency | Attachment storage is unavailable or needs operator attention. Contact support with the request ID; only attach files after finalization returns ready. |
| 400 | validation_error / invalid_json / idempotency_required | Check error.fields, send valid JSON and supply an Idempotency-Key of 8–200 characters. |
| 401 | invalid_api_key | Check the Bearer key, revocation, organization API activation, and whether its creator is still a verified owner/admin. A paused organization can also make a key invalid. |
| 402 | billing_attention | Ask an administrator to restore the service in Billing. |
| 403 | missing_scope / sender_not_authorized / api_sender_required / plan_limit | Use the required scope and an active Email API sender on a verified API-enabled domain within plan limits. |
| 404 | not_found / attachment_not_found / delivery_expired | Use the correct organization key and resource ID. Finalize attachments first. Check log retention. |
| 409 | idempotency_conflict / plan_limit / quota_exceeded / upload_expired | Reuse a key only with the same payload. Review Billing or start a new upload session if the previous one expired. |
| 413 / 415 | message_too_large / file_too_large / request_too_large / json_required | Reduce the body or attachment size; API request bodies must use application/json. Upload file bytes only to signed storage URLs. |
| 422 | recipient_suppressed | Review Suppressions with your administrator. A blocked recipient prevents this request from queuing. |
| 429 | Rate limit | Back off with jitter and retry the same payload with the same idempotency key. Sending limits may also leave admitted messages queued. |
| 500 / 502 / 503 | internal_error / upstream error / api_disabled / recovery_maintenance | For a temporary error or timeout, retry with the same idempotency key. For disabled service, ask support about API activation; check the status page. |

Handle errors by code and HTTP status, not by parsing the human-readable message. For support, include the operation, timestamp, error.code and error.requestId, without credentials. Check https://relypost.com/status for incidents.

## SMTP integration

Administrators can open Organization → SMTP settings for the actual host, port, TLS mode, sender username and SMTP app-password creation. Use those values in an external mail library or application. Do not substitute an API key for the SMTP password. SMTP sending has its own connection protocol; these REST paths and HTTP idempotency keys do not apply to external SMTP. Use delivery logs to inspect SMTP outcomes.

## Frequently asked questions

### Does the RelyPost API support HTML emails?

Yes. POST /v1/emails accepts an html string of up to 500,000 characters. Include text for a plain-text alternative. Render your template in your application before sending; the API does not provide a hosted template endpoint.

### Can I send attachments and inline images?

Yes. Reserve an upload, upload the bytes to the signed storage URL, and finalize it. Reference the ready file ID in attachments. For inline images, set cid on the attachment and use the matching cid: value in HTML. Up to 20 attachments are accepted, subject to message-size and plan limits.

### Can I schedule an email?

Yes. Set scheduled_at to a UTC ISO 8601 timestamp ending in Z, no more than 365 days ahead. Save the exact payload and idempotency key for retries. There is no public cancellation or rescheduling endpoint.

### Which programming languages and frameworks work?

Any server environment with HTTPS support can use the REST API. This guide includes cURL, Node.js fetch and Python standard-library examples; no RelyPost SDK is required. Keep keys on your backend, including when your frontend is a mobile app.

### Does HTTP 202 mean the email was delivered?

No. HTTP 202 means the request was accepted into the queue. Read GET /v1/emails/{id} or receive webhooks for later outcomes. Destination acceptance does not prove inbox placement or that a person opened the email.

### Are API access and SMTP credentials the same?

No. API requests use a scoped Bearer API key. External SMTP uses a sender address and a separate SMTP app password with the connection settings shown in Organization → SMTP settings. Both depend on account activation, sender authorization and applicable limits.

### Can the API read inboxes, send campaigns or manage domains?

The public API sends transactional email and reads domain status and DNS details. It does not provide inbox reading, message listing, domain creation, hosted templates, campaign management or open/click tracking endpoints. Configure domains and senders in the workspace.

### Where can I find pricing, limits and help?

See the public Pricing page for plans and Developer → Billing for your organization’s allowances. Defaults described here may be configured differently per deployment. For help, contact support@relypost.com with error.code and error.requestId, without sharing credentials.

Plans: https://relypost.com/pricing
Service status: https://relypost.com/status
Support: https://relypost.com/support

This document covers the supported public API. Internal workspace session routes are not public integration contracts. API access requires enablement and applicable limits. No delivery time, inbox placement, search ranking or AI recommendation is guaranteed.
