API reference
Every endpoint of the postfly REST API with its scope, rate-limit bucket, parameters, request body and responses — generated from the OpenAPI description.
Authentication
Base URL https://api.postfly.app. JSON in, JSON out, camelCase fields. Every request carries an API key as a bearer token; each endpoint below names the scope it needs (any one of those listed) and the rate-limit bucket it counts against (default 120/min, dns 20/min per key — see Rate limits).
Authorization: Bearer pf_live_…Pagination
GET /v1/emails and GET /v1/suppressions return newest first, one page at a time: data, hasMore, nextCursor and prevCursor. Pass before=<nextCursor> for the next, older page and after=<prevCursor> to go back towards newer items — not both. hasMore refers to the direction you asked for; limit is 1–100, default 25. Cursors are opaque and encrypted: pass one back unchanged rather than parsing or building one, and treat anything we cannot read — edited, truncated, or issued before a key rotation — as 400 invalid_request and a reason to start the list again.
Emails
GET/v1/emails
List the messages this key can see, newest first
Query parameters
| Field | Type | Description |
|---|---|---|
| limit | integer | 1–100 · default 25 |
| before | string | nextCursor of a previous page — continue towards older items. Not with after. |
| after | string | prevCursor of a previous page — continue towards newer items. Not with before. |
| status | queued | sending | sent | delivered | bounced | complained | failed | |
| to | string (email) | Recipient address, case-insensitive exact match. |
| from | string (email) | Sender address, case-insensitive exact match. |
| tag | string | max 64 characters |
| since | string (date-time) | Created at or after (inclusive). |
| until | string (date-time) | Created before (exclusive). |
Responses
- 200 One page of messages.
- 400 Validation failed (invalid_request, unknown_field, invalid_json, …).
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| data | object[] | |
| data[].id | string | |
| data[].status | queued | sending | sent | delivered | bounced | complained | failed | |
| data[].from | string | |
| data[].to | string | |
| data[].subject | string | null | Null after content retention, 30 days after the message reaches its final state. |
| data[].tag | string | null | |
| data[].attachments | object[] | What was attached — never the content, which is deleted once the message is sent. |
| data[].attachments[].filename | string | |
| data[].attachments[].contentType | string | |
| data[].attachments[].size | integer | Bytes, decoded. |
| data[].attachments[].sha256 | string | Hex SHA-256 of the file as sent. |
| data[].bounce | object | null | Null unless the message bounced. |
| data[].bounce.reason | string | The bounce class: transient, mailbox_not_found, domain_not_found, policy_block, invalid_recipient or unknown. |
| data[].bounce.category | hard_bounce | soft_bounce | null | |
| data[].smtpResponse | string | null | |
| data[].timestamps | object | |
| data[].timestamps.created | string (date-time) | |
| data[].timestamps.sent | string (date-time) | null | |
| data[].timestamps.delivered | string (date-time) | null | |
| data[].timestamps.failed | string (date-time) | null | |
| hasMore | boolean | More items exist in the direction requested. |
| nextCursor | string | null | Pass as before for older items. |
| prevCursor | string | null | Pass as after for newer items. |
POST/v1/emails
Queue a transactional email
Request body
Either html or text is required.
| Field | Type | Description |
|---|---|---|
| fromrequired | string (email) | Address on a verified sender domain this key may send from.max 254 characters |
| fromName | string | No line breaks.max 255 characters |
| torequired | string (email) | Exactly one recipient.max 254 characters |
| replyTo | string (email) | max 254 characters |
| replyToName | string | Display name for replyTo. No line breaks.max 255 characters |
| subjectrequired | string | No line breaks.min 1 characters · max 998 characters |
| html | string | max 10485760 characters |
| text | string | max 10485760 characters |
| headers | Record<string, string> | Extra headers; ones postfly composes itself are ignored. |
| tag | string | max 64 characters · pattern ^[A-Za-z0-9._-]+$ |
| attachments | object[] | At most 10 files. Each at most 5 MiB decoded; the whole message as sent (headers, bodies, attachments base64-encoded, signature) at most 10 MiB. |
| attachments[].filenamerequired | string | Path segments and control characters are removed and the name is shortened to 150 characters. Executable and other dangerous extensions (exe, js, bat, scr, html, svg, …) are refused, and the extension must match contentType (pdf, xml, isdoc, isdocx, txt, csv, png, jpg, jpeg, zip).max 1024 characters |
| attachments[].contentTyperequired | string | One of application/pdf, application/xml, text/xml, text/plain, text/csv, image/png, image/jpeg, application/zip, application/x-isdoc, application/x-isdocx. image/jpg, image/pjpeg and application/x-zip-compressed are read as their standard type; a text type may carry a charset parameter.max 255 characters |
| attachments[].contentrequired | string | Standard base64 of the file. |
Responses
- 202 Queued; delivery is asynchronous.
- 400 Validation failed (invalid_request, unknown_field, invalid_json, …).
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 409 Conflicts with existing state (domain_taken, recipient_suppressed).
- 413 Over a size limit (payload_too_large, attachment_too_large, message_too_large).
- 422 An attachment postfly does not send (attachment_type_not_allowed, attachment_extension_blocked, attachment_extension_mismatch, too_many_attachments).
- 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
202 response body
| Field | Type | Description |
|---|---|---|
| id | string | |
| status | "queued" | |
| createdAt | string (date-time) |
GET/v1/emails/{id}
One message and its delivery status
Path parameters
| Field | Type | Description |
|---|---|---|
| idrequired | string | pattern ^m_ |
Responses
- 200 The message.
- 400 Validation failed (invalid_request, unknown_field, invalid_json, …).
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 404 No such resource this key can see.
- 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| id | string | |
| status | queued | sending | sent | delivered | bounced | complained | failed | |
| from | string | |
| to | string | |
| subject | string | null | Null after content retention, 30 days after the message reaches its final state. |
| tag | string | null | |
| attachments | object[] | What was attached — never the content, which is deleted once the message is sent. |
| attachments[].filename | string | |
| attachments[].contentType | string | |
| attachments[].size | integer | Bytes, decoded. |
| attachments[].sha256 | string | Hex SHA-256 of the file as sent. |
| bounce | object | null | Null unless the message bounced. |
| bounce.reason | string | The bounce class: transient, mailbox_not_found, domain_not_found, policy_block, invalid_recipient or unknown. |
| bounce.category | hard_bounce | soft_bounce | null | |
| smtpResponse | string | null | |
| timestamps | object | |
| timestamps.created | string (date-time) | |
| timestamps.sent | string (date-time) | null | |
| timestamps.delivered | string (date-time) | null | |
| timestamps.failed | string (date-time) | null |
Sender domains
GET/v1/domains
The sender domains this key can see (at most 50, not paginated)
Responses
- 200 All domains the key may see.
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| data | object[] | |
| data[].domain | string | |
| data[].status | pending | verified | failed | suspended | |
| data[].verified | boolean | |
| data[].verification | object | Stored per-record flags from the last conclusive checks. |
| data[].verification.spf | boolean | |
| data[].verification.dkim | boolean | |
| data[].verification.dmarc | boolean | |
| data[].lastCheckedAt | string (date-time) | null | |
| data[].verifiedAt | string (date-time) | null | |
| data[].createdAt | string (date-time) |
POST/v1/domains
Register a sender domain and get the DNS records to publish
Request body
domain, or Resend-style name.
| Field | Type | Description |
|---|---|---|
| domain | string | max 253 characters |
| name | string | max 253 characters |
Responses
- 201 Created (status pending).
- 400 Validation failed (invalid_request, unknown_field, invalid_json, …).
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 409 Conflicts with existing state (domain_taken, recipient_suppressed).
- 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
201 response body
| Field | Type | Description |
|---|---|---|
| domain | string | |
| status | pending | verified | failed | suspended | |
| verified | boolean | |
| verification | object | Stored per-record flags from the last conclusive checks. |
| verification.spf | boolean | |
| verification.dkim | boolean | |
| verification.dmarc | boolean | |
| lastCheckedAt | string (date-time) | null | |
| verifiedAt | string (date-time) | null | |
| createdAt | string (date-time) | |
| records | object[] | |
| records[].purpose | spf | dkim | dmarc | mx | |
| records[].type | TXT | MX | |
| records[].name | string | Fully qualified record name. |
| records[].value | string | null | null when postfly can't tell what to publish — see warning. |
| records[].required | boolean | false for the optional MX record. |
| records[].verified | boolean | null | null for MX (not checked). |
| records[].published | boolean | The domain publishes this record already and should keep it: value is what is in DNS now, not a replacement. postfly never suggests weakening a DMARC policy or replacing a DKIM key or MX records that are already right. |
| records[].replaces | string | null | SPF only: the current record to overwrite (one SPF record per domain). |
| records[].lookups | integer | null | SPF only: DNS-lookup terms in value (limit 10). |
| records[].warning | current_record_unavailable | too_many_dns_lookups | null | SPF only. |
GET/v1/domains/{domain}
Domain status and the DNS records to publish
Path parameters
| Field | Type | Description |
|---|---|---|
| domainrequired | string |
Responses
- 200 The domain with its records.
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 404 No such resource this key can see.
- 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| domain | string | |
| status | pending | verified | failed | suspended | |
| verified | boolean | |
| verification | object | Stored per-record flags from the last conclusive checks. |
| verification.spf | boolean | |
| verification.dkim | boolean | |
| verification.dmarc | boolean | |
| lastCheckedAt | string (date-time) | null | |
| verifiedAt | string (date-time) | null | |
| createdAt | string (date-time) | |
| records | object[] | |
| records[].purpose | spf | dkim | dmarc | mx | |
| records[].type | TXT | MX | |
| records[].name | string | Fully qualified record name. |
| records[].value | string | null | null when postfly can't tell what to publish — see warning. |
| records[].required | boolean | false for the optional MX record. |
| records[].verified | boolean | null | null for MX (not checked). |
| records[].published | boolean | The domain publishes this record already and should keep it: value is what is in DNS now, not a replacement. postfly never suggests weakening a DMARC policy or replacing a DKIM key or MX records that are already right. |
| records[].replaces | string | null | SPF only: the current record to overwrite (one SPF record per domain). |
| records[].lookups | integer | null | SPF only: DNS-lookup terms in value (limit 10). |
| records[].warning | current_record_unavailable | too_many_dns_lookups | null | SPF only. |
DELETE/v1/domains/{domain}
Remove a sender domain from this key's group
Path parameters
| Field | Type | Description |
|---|---|---|
| domainrequired | string |
Responses
- 200 Deleted.
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 404 No such resource this key can see.
- 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| domain | string | |
| deleted | true |
POST/v1/domains/{domain}/verify
Re-run the SPF / DKIM / DMARC checks now
Path parameters
| Field | Type | Description |
|---|---|---|
| domainrequired | string |
Responses
- 200 Stored state after the check, plus this check's result.
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 404 No such resource this key can see.
- 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| domain | string | |
| status | pending | verified | failed | suspended | |
| verified | boolean | |
| verification | object | Stored per-record flags from the last conclusive checks. |
| verification.spf | boolean | |
| verification.dkim | boolean | |
| verification.dmarc | boolean | |
| lastCheckedAt | string (date-time) | null | |
| verifiedAt | string (date-time) | null | |
| createdAt | string (date-time) | |
| result | object | |
| result.spf | pass | fail | unknown | unknown = DNS didn't answer; never un-verifies a domain. |
| result.dkim | pass | fail | unknown | unknown = DNS didn't answer; never un-verifies a domain. |
| result.dmarc | pass | fail | unknown | unknown = DNS didn't answer; never un-verifies a domain. |
| result.overall | pass | fail | unknown | unknown = DNS didn't answer; never un-verifies a domain. |
| result.source | authoritative | public | recursive | Which kind of source decided: the domain's own nameservers, the public resolvers postfly falls back to, or postfly's own resolver. |
| result.propagating | object[] | Records some sources see and others do not yet. They count as published; a domain becomes verified once a source other than its own nameservers sees them. |
| result.propagating[].record | spf | dkim | dmarc | |
| result.propagating[].ttl | integer | null | Seconds the old value may still be served, from the nameservers' answer. |
| result.propagating[].seenBy | integer | Sources that already see the record. |
| result.propagating[].of | integer | Sources that answered at all. |
| result.propagating[].elsewhere | boolean | Something other than the domain's own nameservers sees it. |
| result.sources | object[] | What each source answered, and how to ask it yourself. |
| result.sources[].kind | authoritative | public | system | |
| result.sources[].label | string | The nameserver or resolver asked, as text. |
| result.sources[].spf | pass | fail | unknown | unknown = DNS didn't answer; never un-verifies a domain. |
| result.sources[].dkim | pass | fail | unknown | unknown = DNS didn't answer; never un-verifies a domain. |
| result.sources[].dmarc | pass | fail | unknown | unknown = DNS didn't answer; never un-verifies a domain. |
| result.sources[].dig | Record<string, string> | The equivalent dig command per record. |
Suppressions
GET/v1/suppressions
Your suppressions, newest first; a global row only for an exact `email`
Query parameters
| Field | Type | Description |
|---|---|---|
| limit | integer | 1–100 · default 25 |
| before | string | nextCursor of a previous page — continue towards older items. Not with after. |
| after | string | prevCursor of a previous page — continue towards newer items. Not with before. |
| scope | project | account | global | project (or its alias account) means your account's list, the same for every key; global needs email (the global list is looked up, never browsed). |
| reason | hard_bounce | spam_complaint | unsubscribe | manual | blocklist | |
| string (email) | Exact address, case-insensitive; also returns the global row for it. |
Responses
- 200 One page of suppressions.
- 400 Validation failed (invalid_request, unknown_field, invalid_json, …).
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| data | object[] | |
| data[].email | string | |
| data[].scope | project | global | project is your account's list. |
| data[].reason | hard_bounce | spam_complaint | unsubscribe | manual | blocklist | |
| data[].detail | string | null | |
| data[].messageId | string | null | Source message; always null on global rows. |
| data[].createdAt | string (date-time) | |
| hasMore | boolean | More items exist in the direction requested. |
| nextCursor | string | null | Pass as before for older items. |
| prevCursor | string | null | Pass as after for newer items. |
POST/v1/suppressions
Add or update an address on your suppression list
Request body
| Field | Type | Description |
|---|---|---|
| emailrequired | string (email) | max 254 characters |
| reason | object | default manual |
| detail | string | max 200 characters |
| scope | project | account | global | account is the same as project: your account's list. "global" is refused (403 global_scope_forbidden). |
Responses
- 200 Updated an existing suppression of yours.
- 201 Added.
- 400 Validation failed (invalid_request, unknown_field, invalid_json, …).
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| string | ||
| scope | project | global | project is your account's list. |
| reason | hard_bounce | spam_complaint | unsubscribe | manual | blocklist | |
| detail | string | null | |
| messageId | string | null | Source message; always null on global rows. |
| createdAt | string (date-time) |
201 response body
| Field | Type | Description |
|---|---|---|
| string | ||
| scope | project | global | project is your account's list. |
| reason | hard_bounce | spam_complaint | unsubscribe | manual | blocklist | |
| detail | string | null | |
| messageId | string | null | Source message; always null on global rows. |
| createdAt | string (date-time) |
DELETE/v1/suppressions/{email}
Remove an address from your suppression list
Path parameters
| Field | Type | Description |
|---|---|---|
| emailrequired | string | URL-encoded address, case-insensitive. |
Responses
- 200 Deleted from your list.
- 401 Missing, malformed, revoked or paused API key.
- 403 Missing scope, a restricted key, paused sending or another rule;
codesays which. - 404 No such resource this key can see.
- 429 The key's rate-limit bucket is used up (
rate_limit_exceeded), or, on POST /v1/emails, the daily sending quota (daily_quota_exceeded) or too many large sends at once (send_concurrency_exceeded).
Errors come in the error envelope; the codes are in Error codes.
200 response body
| Field | Type | Description |
|---|---|---|
| string | ||
| deleted | true | |
| stillSuppressed | global | null | "global" when the address remains on the global list. |