Everything a custom app needs: the endpoints it calls on Opami and the endpoints it implements for Opami to
call. Readable without an Opami account. A member creates the custom app and its API key in Opami's settings;
the key is shown once.
POST /v1/sources/{id}/messages
Send one message. One message, in the documented shape or in any JSON shape the source's confirmed field mapping can read.
A payload in the documented shape is always taken as it is. The message enters the same pipeline as a
Gmail message (filters, grouping, summary, learning).
- Authentication
- API key:
Authorization: Bearer opk_…
- Body limit
- 262,144 bytes (
413 above)
- Rate limits
source:ip, source:key
- Idempotency
- Idempotent by
external_id per source. Sending the same external_id again answers 200 with the first message's id and duplicate: true, and writes nothing. Safe to retry after a timeout or a 503.
Path parameters
id | string (uuid) | The custom app's id, shown with its API key. |
Request body
| Field | Type | Required | Notes |
|---|
external_id | string | yes | length 1–512. Your id for the message; the idempotency key. |
conversation_id | string | | length 1–512. Groups messages into one thread. |
sent_at | string (date-time) | | With a time zone offset. Defaults to now. |
sender | object | yes | Other keys in sender are ignored. |
kind | "email" | "phone" | "platform_user" | | default "platform_user". |
value | string | yes | length 1–320. |
display | string | null | | length 0–320. |
subject | string | null | | length 0–998. |
text | string | yes | length 1–∞. |
html | string | null | | |
source_url | string | null (uri) | | |
uploads | string (uuid)[] | | 0–50 items. |
Responses
| Status | Meaning and body |
|---|
200 | This source already sent this external_id. Nothing was written.| Field | Type | Required | Notes |
|---|
message_id | string (uuid) | yes | | duplicate | true | yes | |
|
202 | Accepted.| Field | Type | Required | Notes |
|---|
message_id | string (uuid) | yes | | status | "received" | yes | |
|
401 | No key, a malformed, unknown or revoked key, a key of another app, a disabled app, an app without this capability, or an id that is not a uuid. All the same answer.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | yes | |
|
413 | The body is over the endpoint's limit (x-opami-max-body-bytes).| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "too_large" | yes | |
|
422 | The body is not JSON (invalid_json), or it is neither in the documented shape nor readable by the confirmed mapping (unmappable). An unknown or already-used upload id is { field: "uploads", code: "invalid" }.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | "too_large" | "rate_limited" | "unavailable" | "not_found" | "invalid_json" | "unmappable" | "invalid" | "invalid_events" | "calendar_not_usable" | yes | | errors | object[] | yes | | field | string | yes | The target field | path | string | null | yes | The path in your payload the mapping read it from. | code | "missing" | "invalid" | "unmapped" | yes | |
|
429 | Over the address or the key throttle. Wait Retry-After seconds.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "rate_limited" | yes | |
|
503 | Opami could not complete the request. Retry with backoff.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unavailable" | yes | |
|
Examples
curl
curl -X POST "https://app.opami.ai/v1/sources/$OPAMI_SOURCE_ID/messages" \
-H "Authorization: Bearer $OPAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"external_id":"A-1001","conversation_id":"T-77","sent_at":"2026-10-27T09:00:00Z","sender":{"kind":"email","value":"ana@example.com","display":"Ana Silva"},"subject":"Invoice 2026-114","text":"Hi, please find the October invoice attached.","uploads":["8d0f5b1e-3c1a-4c8e-9b7a-2f4e1d6c9a01"]}'
JavaScript
const res = await fetch(`https://app.opami.ai/v1/sources/${process.env.OPAMI_SOURCE_ID}/messages`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPAMI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
"external_id": "A-1001",
"conversation_id": "T-77",
"sent_at": "2026-10-27T09:00:00Z",
"sender": {
"kind": "email",
"value": "ana@example.com",
"display": "Ana Silva"
},
"subject": "Invoice 2026-114",
"text": "Hi, please find the October invoice attached.",
"uploads": [
"8d0f5b1e-3c1a-4c8e-9b7a-2f4e1d6c9a01"
]
}),
});
console.log(res.status, await res.json());
Python
import os, requests
res = requests.post(
f"https://app.opami.ai/v1/sources/{os.environ['OPAMI_SOURCE_ID']}/messages",
headers={"Authorization": f"Bearer {os.environ['OPAMI_API_KEY']}"},
json={
"external_id": "A-1001",
"conversation_id": "T-77",
"sent_at": "2026-10-27T09:00:00Z",
"sender": {
"kind": "email",
"value": "ana@example.com",
"display": "Ana Silva"
},
"subject": "Invoice 2026-114",
"text": "Hi, please find the October invoice attached.",
"uploads": [
"8d0f5b1e-3c1a-4c8e-9b7a-2f4e1d6c9a01"
]
},
timeout=30,
)
print(res.status_code, res.json())
GET /v1/sources/{id}/messages/{message_id}
Read a message's processing status.
- Authentication
- API key:
Authorization: Bearer opk_…
- Body limit
- No body
- Rate limits
source:ip, source:key
- Idempotency
- A read; safe to repeat.
Path parameters
id | string (uuid) | The custom app's id, shown with its API key. |
message_id | string (uuid) | The message_id answered when the message was sent. |
Responses
| Status | Meaning and body |
|---|
200 | The message's status.| Field | Type | Required | Notes |
|---|
message_id | string (uuid) | yes | | status | "received" | "processing" | "done" | "failed" | yes | received: not picked up yet; processing: on its way to an item; done: on an item the member can see; failed: its job died or its item failed. |
|
401 | No key, a malformed, unknown or revoked key, a key of another app, a disabled app, an app without this capability, or an id that is not a uuid. All the same answer.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | yes | |
|
404 | This source sent no message with this id.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "not_found" | yes | |
|
429 | Over the address or the key throttle. Wait Retry-After seconds.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "rate_limited" | yes | |
|
503 | Opami could not complete the request. Retry with backoff.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unavailable" | yes | |
|
Examples
curl
curl -X GET "https://app.opami.ai/v1/sources/$OPAMI_SOURCE_ID/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $OPAMI_API_KEY"
JavaScript
const res = await fetch(`https://app.opami.ai/v1/sources/${process.env.OPAMI_SOURCE_ID}/messages/${process.env.MESSAGE_ID}`, {
method: "GET",
headers: { Authorization: `Bearer ${process.env.OPAMI_API_KEY}` },
});
console.log(res.status, await res.json());
Python
import os, requests
res = requests.get(
f"https://app.opami.ai/v1/sources/{os.environ['OPAMI_SOURCE_ID']}/messages/{os.environ['MESSAGE_ID']}",
headers={"Authorization": f"Bearer {os.environ['OPAMI_API_KEY']}"},
timeout=30,
)
print(res.status_code, res.json())
POST /v1/sources/{id}/uploads
Announce a file. Answers a signed upload URL. PUT the file's bytes to upload_url, then name upload_id in one message's
uploads. An upload attaches to one message only.
- Authentication
- API key:
Authorization: Bearer opk_…
- Body limit
- 4,096 bytes (
413 above)
- Rate limits
source:ip, source:key
- Idempotency
- Not idempotent: every call creates a new upload. An upload never attached to a message is never shown.
Path parameters
id | string (uuid) | The custom app's id, shown with its API key. |
Request body
| Field | Type | Required | Notes |
|---|
name | string | yes | length 1–255. |
mime | string | yes | length 0–255, pattern ^[\w.+-]+/[\w.+-]+$. |
bytes | integer | yes | 0–26214400. |
Responses
| Status | Meaning and body |
|---|
201 | Created.| Field | Type | Required | Notes |
|---|
upload_id | string (uuid) | yes | | upload_url | string (uri) | yes | A signed URL; PUT the file there. |
|
401 | No key, a malformed, unknown or revoked key, a key of another app, a disabled app, an app without this capability, or an id that is not a uuid. All the same answer.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | yes | |
|
413 | The body is over the endpoint's limit (x-opami-max-body-bytes).| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "too_large" | yes | |
|
422 | Not a valid upload request (invalid).| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | "too_large" | "rate_limited" | "unavailable" | "not_found" | "invalid_json" | "unmappable" | "invalid" | "invalid_events" | "calendar_not_usable" | yes | | errors | object[] | yes | | field | string | yes | The target field | path | string | null | yes | The path in your payload the mapping read it from. | code | "missing" | "invalid" | "unmapped" | yes | |
|
429 | Over the address or the key throttle. Wait Retry-After seconds.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "rate_limited" | yes | |
|
503 | Opami could not complete the request. Retry with backoff.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unavailable" | yes | |
|
Examples
curl
curl -X POST "https://app.opami.ai/v1/sources/$OPAMI_SOURCE_ID/uploads" \
-H "Authorization: Bearer $OPAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"invoice-2026-114.pdf","mime":"application/pdf","bytes":48213}'
JavaScript
const res = await fetch(`https://app.opami.ai/v1/sources/${process.env.OPAMI_SOURCE_ID}/uploads`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPAMI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
"name": "invoice-2026-114.pdf",
"mime": "application/pdf",
"bytes": 48213
}),
});
console.log(res.status, await res.json());
Python
import os, requests
res = requests.post(
f"https://app.opami.ai/v1/sources/{os.environ['OPAMI_SOURCE_ID']}/uploads",
headers={"Authorization": f"Bearer {os.environ['OPAMI_API_KEY']}"},
json={
"name": "invoice-2026-114.pdf",
"mime": "application/pdf",
"bytes": 48213
},
timeout=30,
)
print(res.status_code, res.json())
POST /v1/sources/{id}/calendar-events
Push changed calendar events. A push is a signal: the events are checked against the event format and the app's calendar is marked
changed, so Opami reads it again rather than trusting an older copy. Same API key as the inbox source; the
app must offer Calendar. Apps that cannot push are polled through events.list instead.
- Authentication
- API key:
Authorization: Bearer opk_…
- Body limit
- 1,048,576 bytes (
413 above)
- Rate limits
source:ip, source:key
- Idempotency
- Idempotent. Pushing the same events again only marks the calendar changed again.
Path parameters
id | string (uuid) | The custom app's id, shown with its API key. |
Request body
| Field | Type | Required | Notes |
|---|
events | Event[] | yes | 1–500 items. |
id | string | yes | length 1–1024. |
calendar_id | string | yes | length 1–1024. |
title | string | | length 0–1024, default "". |
start | string (date-time) | yes | |
end | string (date-time) | yes | |
all_day | boolean | | default false. |
status | "confirmed" | "tentative" | "cancelled" | yes | |
attendees | object[] | | 0–500 items, default []. |
email | string | yes | length 0–320. |
name | string | null | | length 0–256. |
response | "needsAction" | "accepted" | "declined" | "tentative" | | default "needsAction". |
self | boolean | | True on the attendee who is the calendar's owner. |
location | string | null | | length 0–2048. |
description | string | null | | length 0–65536. |
updated_at | string (date-time) | yes | |
Responses
| Status | Meaning and body |
|---|
202 | Accepted.| Field | Type | Required | Notes |
|---|
accepted | integer | yes | 1–500. |
|
401 | No key, a malformed, unknown or revoked key, a key of another app, a disabled app, an app without this capability, or an id that is not a uuid. All the same answer.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | yes | |
|
409 | The calendar has not passed its conformance test (calendar_not_usable).| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | "too_large" | "rate_limited" | "unavailable" | "not_found" | "invalid_json" | "unmappable" | "invalid" | "invalid_events" | "calendar_not_usable" | yes | |
|
413 | The body is over the endpoint's limit (x-opami-max-body-bytes).| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "too_large" | yes | |
|
422 | Not JSON (invalid_json) or not in the event format (invalid_events), with field paths.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unauthenticated" | "too_large" | "rate_limited" | "unavailable" | "not_found" | "invalid_json" | "unmappable" | "invalid" | "invalid_events" | "calendar_not_usable" | yes | | errors | object[] | yes | 0–50 items. | path | string | yes | | code | string | yes | |
|
429 | Over the address or the key throttle. Wait Retry-After seconds.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "rate_limited" | yes | |
|
503 | Opami could not complete the request. Retry with backoff.| Field | Type | Required | Notes |
|---|
error | string | yes | An English sentence. | code | "unavailable" | yes | |
|
Examples
curl
curl -X POST "https://app.opami.ai/v1/sources/$OPAMI_SOURCE_ID/calendar-events" \
-H "Authorization: Bearer $OPAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"events":[{"id":"evt_123","calendar_id":"team","title":"Call with Ana","start":"2026-11-03T14:00:00+01:00","end":"2026-11-03T14:30:00+01:00","all_day":false,"status":"confirmed","attendees":[{"email":"ana@example.com","name":"Ana Silva","response":"accepted"}],"location":null,"description":null,"updated_at":"2026-10-27T09:12:44Z"}]}'
JavaScript
const res = await fetch(`https://app.opami.ai/v1/sources/${process.env.OPAMI_SOURCE_ID}/calendar-events`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPAMI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
"events": [
{
"id": "evt_123",
"calendar_id": "team",
"title": "Call with Ana",
"start": "2026-11-03T14:00:00+01:00",
"end": "2026-11-03T14:30:00+01:00",
"all_day": false,
"status": "confirmed",
"attendees": [
{
"email": "ana@example.com",
"name": "Ana Silva",
"response": "accepted"
}
],
"location": null,
"description": null,
"updated_at": "2026-10-27T09:12:44Z"
}
]
}),
});
console.log(res.status, await res.json());
Python
import os, requests
res = requests.post(
f"https://app.opami.ai/v1/sources/{os.environ['OPAMI_SOURCE_ID']}/calendar-events",
headers={"Authorization": f"Bearer {os.environ['OPAMI_API_KEY']}"},
json={
"events": [
{
"id": "evt_123",
"calendar_id": "team",
"title": "Call with Ana",
"start": "2026-11-03T14:00:00+01:00",
"end": "2026-11-03T14:30:00+01:00",
"all_day": False,
"status": "confirmed",
"attendees": [
{
"email": "ana@example.com",
"name": "Ana Silva",
"response": "accepted"
}
],
"location": None,
"description": None,
"updated_at": "2026-10-27T09:12:44Z"
}
]
},
timeout=30,
)
print(res.status_code, res.json())