HTTP API¶
Every endpoint below is derived from the running application's OpenAPI schema,
so it cannot describe an endpoint that does not exist or miss a field that
does. The raw schema is openapi.json; a running instance also
serves it at /openapi.json, with interactive documentation at /docs.
Authentication¶
Every /v1 endpoint takes an API key as a bearer token:
Keys are scoped to a project and created in the dashboard. A missing header and
an invalid token both return 401 with the same message — distinguishing them
would tell whoever is probing which half they had right.
Errors¶
One shape, always:
Branch on type, which is stable; show message to humans. The full list is
in Errors.
Rate limits¶
Per project, not per key. Every response carries X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset; exceeding the limit returns
429.
One endpoint is deliberately absent
POST /v1/events/ses receives SES notifications from Amazon SNS and is
excluded from the schema, because it is not for callers — it authenticates
by verifying SNS's signature rather than by API key, and is only mounted
when EVENT_INGESTION asks for it. See
delivery events.
Endpoints¶
| Method | Path | |
|---|---|---|
GET |
/v1/api-keys |
List API keys |
GET |
/v1/domains |
List sending domains |
POST |
/v1/emails |
Send an email |
GET |
/v1/emails |
List emails |
GET |
/v1/emails/{email_id} |
Retrieve an email |
GET |
/v1/inbound |
List received emails |
GET |
/v1/inbound/{inbound_id} |
Retrieve a received email |
GET |
/v1/inbound/{inbound_id}/attachments/{index} |
Download an attachment |
GET |
/v1/inbound/{inbound_id}/raw |
Download the original message |
GET |
/v1/webhooks |
List webhook endpoints |
GET |
/v1/webhooks/{endpoint_id}/deliveries |
List recent webhook deliveries |
GET /v1/api-keys¶
List API keys
Every key belonging to the calling key's project.
Scoped by the authenticated project rather than by anything in the request, so there is no parameter a caller could change to see someone else's keys.
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
429 |
Rate limit exceeded. |
GET /v1/domains¶
List sending domains
Every domain this key's project can send from.
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
429 |
Rate limit exceeded. |
POST /v1/emails¶
Send an email
Accept a message for sending.
Parameters
| Name | In | ||
|---|---|---|---|
Idempotency-Key |
header | optional | Repeat a request safely. A second send with the same key returns the first message's id and sends nothing further, so a retry after a timeout cannot deliver twice. Scoped to the project. |
Request body
| Field | Type | ||
|---|---|---|---|
from |
string | required | The sender, optionally with a display name. The address or its domain must be verified in SES — an unverified sender is refused by SES, not by SESKit, so it fails at send time rather than here. |
to |
string[] or string | required | One recipient, or a list of them. While your account is in the SES sandbox every recipient must also be verified. |
subject |
string | required | Max 998 characters, which is the RFC 5322 line limit. |
html |
string | optional | HTML body. Provide html, text, or both; a message with neither is refused. |
text |
string | optional | Plain-text body. Sending both makes a multipart message, which is what clients that will not render HTML fall back to. |
cc |
string[] or string | optional | Visible to every recipient. |
bcc |
string[] or string | optional | Hidden from every recipient. Recorded, but never returned by the API — a blind copy readable from a GET is not blind. |
reply_to |
string[] or string | optional | Where replies go, if not to from. Needs no SES verification. |
headers |
object | optional | Custom headers to add to the message. Names are RFC 5322 field-names: printable ASCII with no colon, spaces or line breaks. |
attachments |
AttachmentRequest[] | optional | The size limit applies to the assembled message, not to each file: base64 inflates content by about a third, and it is the assembled size SES rejects. See EMAIL_MAX_MESSAGE_BYTES (10 MiB by default). |
Responses
| Status | |
|---|---|
201 |
Successful Response |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
GET /v1/emails¶
List emails
This key's project, newest first.
Paged by cursor rather than by offset. Ids sort in the order they were
created, so starting_after names a fixed point in the list and stays
correct while new messages arrive underneath it. An offset does not: a send
between two pages shifts every row down one, and the reader silently skips
the message that moved across the boundary. For a send log that is data
loss nobody can see.
An unknown starting_after is a 404 rather than an empty page. The
comparison is lexical, so an id from another project would otherwise return
a page of real messages positioned by an id the caller cannot see - a wrong
answer that looks like a right one.
Parameters
| Name | In | ||
|---|---|---|---|
limit |
query | optional | How many messages to return, newest first. |
starting_after |
query | optional | Return messages older than this id - the last id from the previous page. The id must belong to this project. |
status |
query | optional | Only messages in this status. |
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
GET /v1/emails/{email_id}¶
Retrieve an email
One message, if it belongs to this key's project.
Ownership is part of the query, so an id from another project is a 404 rather than a 403 - which would confirm the id exists.
Parameters
| Name | In | ||
|---|---|---|---|
email_id |
path | required | The id returned when the message was accepted. |
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
GET /v1/inbound¶
List received emails
This key's project, newest first, without bodies.
Paged by cursor for the reason /v1/emails is: ids sort in the order they
were created, so starting_after names a fixed point and stays correct
while mail arrives underneath it, where an offset would silently skip the
message that moved across the boundary. An unknown starting_after is a 404
and not an empty page, because the comparison is lexical and an id from
another project would otherwise position a page of real messages by a value
the caller cannot see.
Sorted by id and not by received_at. A backlog means a message can be
recorded long after it arrived, so the two orders can differ; id order is the
one a cursor can be stable over, and received_at is on every row.
Parameters
| Name | In | ||
|---|---|---|---|
limit |
query | optional | How many messages to return, newest first. |
starting_after |
query | optional | Return messages older than this id - the last id from the previous page. The id must belong to this project. |
domain |
query | optional | Only messages received for this domain. |
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
GET /v1/inbound/{inbound_id}¶
Retrieve a received email
One message, parsed. Bodies, headers, attachments and what SES concluded.
The bodies are the sender's, not SESKit's. In particular the HTML is whatever was sent, and has to be treated as hostile by anything that renders it.
Parameters
| Name | In | ||
|---|---|---|---|
inbound_id |
path | required | The id from the list, or from an email.received event. |
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
GET /v1/inbound/{inbound_id}/attachments/{index}¶
Download an attachment
One attachment's bytes, fetched from the stored message.
Always served as application/octet-stream, whatever the sender declared,
with Content-Disposition: attachment, nosniff and a sandboxing CSP. The
declared type is on the message's attachments for you to read; it is never
one this endpoint believes. These are files a stranger sent, served from your
API's origin.
Needs the stored message, so it stops working when retention removes it. The parsed message does not.
Parameters
| Name | In | ||
|---|---|---|---|
inbound_id |
path | required | The id of the received message. |
index |
path | required | The attachment's index from the message's attachments. |
Responses
| Status | |
|---|---|
200 |
The bytes. |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
GET /v1/inbound/{inbound_id}/raw¶
Download the original message
The message exactly as SES received it, as an .eml file.
Unparsed and unmodified: every header, every part, every attachment. Useful for debugging a message that did not parse as expected, and for handing to another tool. It is the sender's bytes, served as a download with the same protections as an attachment.
Needs the stored message, so it stops working when retention removes it.
Parameters
| Name | In | ||
|---|---|---|---|
inbound_id |
path | required | The id of the received message. |
Responses
| Status | |
|---|---|
200 |
The original message, byte for byte. |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
GET /v1/webhooks¶
List webhook endpoints
Every webhook endpoint registered on this key's project.
The signing secret is not included - see the note in
schemas/webhooks.py. It is available on the dashboard, where it is read
once by a person rather than returned to code on every call.
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
429 |
Rate limit exceeded. |
GET /v1/webhooks/{endpoint_id}/deliveries¶
List recent webhook deliveries
Recent delivery attempts for one endpoint, newest first.
The delivery row is the queue as well as the log, so what comes back is the actual state of each attempt - including one still pending a retry, with the time it is next due.
Parameters
| Name | In | ||
|---|---|---|---|
endpoint_id |
path | required | The endpoint whose deliveries to list. |
Responses
| Status | |
|---|---|
200 |
Successful Response |
401 |
Invalid or missing API key. |
422 |
Validation Error |
429 |
Rate limit exceeded. |
Models¶
AttachmentRequest¶
One attachment, base64 encoded.
JSON has no byte type, so content arrives encoded. The size limit is applied to the assembled message rather than to this field, because base64 inflates content by about a third and it is the assembled size SES rejects.
| Field | Type | ||
|---|---|---|---|
filename |
string | required | The name the recipient sees. Max 255 characters. |
content |
string | required | Base64-encoded file content. |
content_type |
string | optional | MIME type. Defaults to application/octet-stream, which most clients offer as a download rather than displaying. |