> For clean Markdown content of this page, append .md to this URL. For the complete documentation index, see https://docs.postnuvia.com/llms.txt.

# Changelog

# PostNuvia Changelog

Latest API and SDK updates. [Subscribe via RSS](https://docs.postnuvia.com/changelog.rss) · [Discord](https://discord.gg/hTYatWYWBc)

## October 7, 2026

## Summary

Invitations are easier to keep up with. Every event the inbox can respond to now says how the inbox has answered, so an agent can find the invitations still waiting for a reply from the agenda alone. And an invitation email now records the calendar event it was applied to, so an agent can go from a `message.received` email straight to its event.

### What's new?

* **`response_status` on calendar events**: the `status` of the inbox's own entry in `attendees`, on the events [Respond to Event](https://docs.postnuvia.com/api-reference/inboxes/calendar/respond-to-event) accepts (`email` events from an organizer that list the inbox). It is on agenda and instance list items too, which leave out `attendees`, and in calendar webhook and WebSocket events. A date of a recurring invitation answered on its own reports that date's response. Events the inbox organizes have no `response_status`.
* **`calendar_event_id` on messages**: set on an email once its calendar invitation, update, cancellation or attendee reply has been applied to the inbox's calendar, a few seconds after the email arrives. For an email about one date of a recurring event, it is that date's event ID. Pass it to Get Event. It is returned wherever a message is: Get Message, List Messages and Get Thread. Emails whose invitation was not imported have none.
* **SDKs**: Python 2.0.14 and TypeScript 0.5.42 include both fields (`response_status` and `calendar_event_id` in Python, `responseStatus` and `calendarEventId` in TypeScript).

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="YOUR_API_KEY")
inbox_id = "scheduler@postnuvia.com"

# Invitations still waiting for the inbox's reply
agenda = client.inboxes.calendar.get_agenda(inbox_id)
waiting = [event for event in agenda.events if event.response_status == "needs_action"]

# The event an invitation email is about
message = client.inboxes.messages.get(inbox_id, message_id)
if message.calendar_event_id:
    event = client.inboxes.calendar.get_event(inbox_id, message.calendar_event_id)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "YOUR_API_KEY" });
const inboxId = "scheduler@postnuvia.com";

// Invitations still waiting for the inbox's reply
const agenda = await client.inboxes.calendar.getAgenda(inboxId);
const waiting = agenda.events.filter((event) => event.responseStatus === "needs_action");

// The event an invitation email is about
const message = await client.inboxes.messages.get(inboxId, messageId);
if (message.calendarEventId) {
    const event = await client.inboxes.calendar.getEvent(inboxId, message.calendarEventId);
}
```

### Use cases

Build agents that:

* Answer every invitation still waiting for a reply, without getting each event
* Handle an invitation email by looking at its event: check for conflicts, accept or decline, add notes in `metadata`

> **Note**
>
> Calendar is in private beta in US production. See [Invitations](https://docs.postnuvia.com/calendar-invitations) for how invitations are received and answered.

## October 5, 2026

## Summary

Apps now say what kind of app they are. Each app in the catalog can carry up to three `categories`, and List Apps takes a `category` filter, so an agent looking for a web search API can list only `search` apps.

### What's new?

* `categories` on every app from `GET /v0/apps`, `GET /v0/apps/search` and `GET /v0/apps/{app_id}`, and on the app embedded in `GET /v0/apps/{app_id}/accounts`. Omitted when the app sets none.
* `category` on `GET /v0/apps` (`client.apps.list`, `postnuvia apps list --category`). An unknown value returns `400`.
* Values: `ai`, `search`, `scraping`, `browser`, `data`, `developer-tools`, `communication`, `productivity`, `payments`, `finance`, `commerce`, `marketing`, `analytics`, `security`, `infrastructure`, `other`.
* A filtered page can hold fewer than `limit` apps while more remain. Page until `next_page_token` is absent. A `page_token` works only with the `category` it was returned for; reusing it with another `category`, or with none, returns `400`.

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="YOUR_API_KEY")

page = client.apps.list(category="search")
for app in page.apps:
    print(app.app_id, app.name, app.categories)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "YOUR_API_KEY" });

const page = await client.apps.list({ category: "search" });
for (const app of page.apps) console.log(app.appId, app.name, app.categories);
```

### Use cases

Build agents that:

* Find an app for a job, such as web search or payments, without paging the whole catalog
* Show the catalog grouped by kind of app

> **Note**
>
> See [List Apps](https://docs.postnuvia.com/api-reference/apps/list) for the request and response shapes.

## October 5, 2026

## Summary

Apps now say what kind of app they are. Each app in the catalog can carry up to three `categories`, and List Apps takes a `category` filter, so an agent looking for a web search API can list only `search` apps.

### What's new?

* `categories` on every app from `GET /v0/apps`, `GET /v0/apps/search` and `GET /v0/apps/{app_id}`, and on the app embedded in `GET /v0/apps/{app_id}/accounts`. Omitted when the app sets none.
* `category` on `GET /v0/apps` (`client.apps.list`, `postnuvia apps list --category`). An unknown value returns `400`.
* Values: `ai`, `search`, `scraping`, `browser`, `data`, `developer-tools`, `communication`, `productivity`, `payments`, `finance`, `commerce`, `marketing`, `analytics`, `security`, `infrastructure`, `other`.
* A filtered page can hold fewer than `limit` apps while more remain. Page until `next_page_token` is absent. A `page_token` works only with the `category` it was returned for; reusing it with another `category`, or with none, returns `400`.

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="YOUR_API_KEY")

page = client.apps.list(category="search")
for app in page.apps:
    print(app.app_id, app.name, app.categories)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "YOUR_API_KEY" });

const page = await client.apps.list({ category: "search" });
for (const app of page.apps) console.log(app.appId, app.name, app.categories);
```

### Use cases

Build agents that:

* Find an app for a job, such as web search or payments, without paging the whole catalog
* Show the catalog grouped by kind of app

> **Note**
>
> See [List Apps](https://docs.postnuvia.com/api-reference/apps/list) for the request and response shapes.

## October 5, 2026

## Summary

Apps now say what kind of app they are. Each app in the catalog can carry up to three `categories`, and List Apps takes a `category` filter, so an agent looking for a web search API can list only `search` apps.

### What's new?

* `categories` on every app from `GET /v0/apps`, `GET /v0/apps/search` and `GET /v0/apps/{app_id}`, and on the app embedded in `GET /v0/apps/{app_id}/accounts`. Omitted when the app sets none.
* `category` on `GET /v0/apps` (`client.apps.list`, `postnuvia apps list --category`). An unknown value returns `400`.
* Values: `ai`, `search`, `scraping`, `browser`, `data`, `developer-tools`, `communication`, `productivity`, `payments`, `finance`, `commerce`, `marketing`, `analytics`, `security`, `infrastructure`, `other`.
* A filtered page can hold fewer than `limit` apps while more remain. Page until `next_page_token` is absent. A `page_token` works only with the `category` it was returned for; reusing it with another `category`, or with none, returns `400`.

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="YOUR_API_KEY")

page = client.apps.list(category="search")
for app in page.apps:
    print(app.app_id, app.name, app.categories)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "YOUR_API_KEY" });

const page = await client.apps.list({ category: "search" });
for (const app of page.apps) console.log(app.appId, app.name, app.categories);
```

### Use cases

Build agents that:

* Find an app for a job, such as web search or payments, without paging the whole catalog
* Show the catalog grouped by kind of app

> **Note**
>
> See [List Apps](https://docs.postnuvia.com/api-reference/apps/list) for the request and response shapes.

## October 5, 2026

## Summary

Apps now say what kind of app they are. Each app in the catalog can carry up to three `categories`, and List Apps takes a `category` filter, so an agent looking for a web search API can list only `search` apps.

### What's new?

* `categories` on every app from `GET /v0/apps`, `GET /v0/apps/search` and `GET /v0/apps/{app_id}`, and on the app embedded in `GET /v0/apps/{app_id}/accounts`. Omitted when the app sets none.
* `category` on `GET /v0/apps` (`client.apps.list`, `postnuvia apps list --category`). An unknown value returns `400`.
* Values: `ai`, `search`, `scraping`, `browser`, `data`, `developer-tools`, `communication`, `productivity`, `payments`, `finance`, `commerce`, `marketing`, `analytics`, `security`, `infrastructure`, `other`.
* A filtered page can hold fewer than `limit` apps while more remain. Page until `next_page_token` is absent. A `page_token` works only with the `category` it was returned for; reusing it with another `category`, or with none, returns `400`.

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="YOUR_API_KEY")

page = client.apps.list(category="search")
for app in page.apps:
    print(app.app_id, app.name, app.categories)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "YOUR_API_KEY" });

const page = await client.apps.list({ category: "search" });
for (const app of page.apps) console.log(app.appId, app.name, app.categories);
```

### Use cases

Build agents that:

* Find an app for a job, such as web search or payments, without paging the whole catalog
* Show the catalog grouped by kind of app

> **Note**
>
> See [List Apps](https://docs.postnuvia.com/api-reference/apps/list) for the request and response shapes.

## October 1, 2026

## Summary

The API reference now documents pausing an inbox. The API already supported it: set an inbox's `status` to `paused` to stop it sending and receiving mail without deleting it, and set it back to `active` to resume.

### What's new?

* `status` on `PATCH /v0/inboxes/{inbox_id}` and `POST /v0/inboxes`, and on the pod routes for the same operations. Values are `paused` and `active`.
* Inbox responses include `status: "paused"` while an inbox is paused. Treat any other value, or no `status` field, as an inbox that sends and receives normally.
* Sends from a paused inbox return `403` with code `inbox_paused`. Scheduled drafts that come due while it is paused fail and are not retried on resume.
* Mail sent to a paused inbox is neither delivered nor bounced, then or when the inbox is resumed.
* The current Python and TypeScript SDK releases do not accept `status` yet; call the API directly until they do.

### Use cases

* Build agents that stop an inbox from acting on its own while a human reviews it.
* Take an inbox out of service without giving up its address, then bring it back later.

```bash
# pause, then resume
curl -X PATCH "https://api.postnuvia.com/v0/inboxes/support-agent@postnuvia.com" \
  -H "Authorization: Bearer $POSTNUVIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "paused"}'

curl -X PATCH "https://api.postnuvia.com/v0/inboxes/support-agent@postnuvia.com" \
  -H "Authorization: Bearer $POSTNUVIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}'
```

> **Note**
>
> See [Pausing an inbox](https://docs.postnuvia.com/inboxes#pausing-an-inbox) for everything that stops and everything that is kept.

## September 30, 2026

## Summary

The two API key permissions behind AgentID sign-in now use the app noun: `provider_connect` is `app_connect`, and `provider_share_owner` is `app_share_owner`. Existing keys keep their grants. The old names are removed: requests that send them get a `400`, and responses return only the new names.

### What's new?

* **`app_connect`**: sign in to apps as an inbox: connect an app, authorize an inbox, and mint sign-in keys. Same meaning and default as `provider_connect`.
* **`app_share_owner`**: share the organization owner's name and email with apps at sign-in. Same meaning as `provider_share_owner`.
* **New names in requests**: `POST /v0/api-keys`, `POST /v0/pods/{pod_id}/api-keys`, `POST /v0/inboxes/{inbox_id}/api-keys`, and `PATCH /v0/api-keys/{api_key_id}` accept only `app_connect` and `app_share_owner`.
* **New names in responses**: API key responses return only `app_connect` and `app_share_owner`.
* **Error text**: a `403` with `code: "missing_permission"` names the new permission in its `fix`, for example `app_connect`. Branch on `code`, not on the text.

### Breaking changes

⚠️ **New SDK and CLI releases expose only the new names.** The SDK and CLI releases that follow this change drop `provider_connect` and `provider_share_owner` from `ApiKeyPermissions` (TypeScript: `providerConnect` and `providerShareOwner`). Switch the field names when you upgrade:

* **TypeScript**: `providerConnect` is a type error. In untyped JavaScript the SDK drops fields it does not know before sending, so a key created with `providerConnect: true` would not get the permission.
* **CLI**: `--permissions` is checked locally, so `provider_connect` fails as an unknown property.

Earlier releases know only the former names: earlier SDKs drop `appConnect` the same way, and earlier CLIs reject `app_connect`.

⚠️ **The API rejects the former names.** A create or update that sends `provider_connect` or `provider_share_owner` returns a `400` `ValidationError` at that field, for example `'provider_connect' was renamed to 'app_connect'`, instead of creating a key without the permission. Responses no longer include the former names. There is no deprecation window, so TypeScript SDK 0.5.32, Python SDK 2.0.6, CLI 1.7.0, and earlier releases cannot set or read these permissions; upgrade first.

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="YOUR_API_KEY")

# before: permissions={"inbox_read": True, "provider_connect": True}
key = client.api_keys.create(
    name="sign-in agent",
    permissions={"inbox_read": True, "app_connect": True},
)
print(key.api_key_id)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "YOUR_API_KEY" });

// before: permissions: { inboxRead: true, providerConnect: true }
const key = await client.apiKeys.create({
  name: "sign-in agent",
  permissions: { inboxRead: true, appConnect: true },
});
console.log(key.apiKeyId);
```

### Use cases

Build agents that:

* Hold a key that signs in to apps with `app_connect` alone, without `api_key_create`
* Share the owner's name and email with an app only from keys granted `app_share_owner`

> **Note**
>
> See [Permissions](https://docs.postnuvia.com/permissions) for every API key permission.

## September 30, 2026

## Summary

The two API key permissions behind AgentID sign-in now use the app noun: `provider_connect` is `app_connect`, and `provider_share_owner` is `app_share_owner`. Existing keys keep their grants. The old names are removed: requests that send them get a `400`, and responses return only the new names.

### What's new?

* **`app_connect`**: sign in to apps as an inbox: connect an app, authorize an inbox, and mint sign-in keys. Same meaning and default as `provider_connect`.
* **`app_share_owner`**: share the organization owner's name and email with apps at sign-in. Same meaning as `provider_share_owner`.
* **New names in requests**: `POST /v0/api-keys`, `POST /v0/pods/{pod_id}/api-keys`, `POST /v0/inboxes/{inbox_id}/api-keys`, and `PATCH /v0/api-keys/{api_key_id}` accept only `app_connect` and `app_share_owner`.
* **New names in responses**: API key responses return only `app_connect` and `app_share_owner`.
* **Error text**: a `403` with `code: "missing_permission"` names the new permission in its `fix`, for example `app_connect`. Branch on `code`, not on the text.

### Breaking changes

⚠️ **New SDK and CLI releases expose only the new names.** The SDK and CLI releases that follow this change drop `provider_connect` and `provider_share_owner` from `ApiKeyPermissions` (TypeScript: `providerConnect` and `providerShareOwner`). Switch the field names when you upgrade:

* **TypeScript**: `providerConnect` is a type error. In untyped JavaScript the SDK drops fields it does not know before sending, so a key created with `providerConnect: true` would not get the permission.
* **CLI**: `--permissions` is checked locally, so `provider_connect` fails as an unknown property.

Earlier releases know only the former names: earlier SDKs drop `appConnect` the same way, and earlier CLIs reject `app_connect`.

⚠️ **The API rejects the former names.** A create or update that sends `provider_connect` or `provider_share_owner` returns a `400` `ValidationError` at that field, for example `'provider_connect' was renamed to 'app_connect'`, instead of creating a key without the permission. Responses no longer include the former names. There is no deprecation window, so TypeScript SDK 0.5.32, Python SDK 2.0.6, CLI 1.7.0, and earlier releases cannot set or read these permissions; upgrade first.

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="YOUR_API_KEY")

# before: permissions={"inbox_read": True, "provider_connect": True}
key = client.api_keys.create(
    name="sign-in agent",
    permissions={"inbox_read": True, "app_connect": True},
)
print(key.api_key_id)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "YOUR_API_KEY" });

// before: permissions: { inboxRead: true, providerConnect: true }
const key = await client.apiKeys.create({
  name: "sign-in agent",
  permissions: { inboxRead: true, appConnect: true },
});
console.log(key.apiKeyId);
```

### Use cases

Build agents that:

* Hold a key that signs in to apps with `app_connect` alone, without `api_key_create`
* Share the owner's name and email with an app only from keys granted `app_share_owner`

> **Note**
>
> See [Permissions](https://docs.postnuvia.com/permissions) for every API key permission.

## September 29, 2026

## Summary

Ask Claude, ChatGPT, Cursor, Claude Code, or Codex to "create an account at Firecrawl for my agent" and it does, using an PostNuvia inbox as the agent's identity through AgentID. A new guide walks through creating accounts, getting API keys, and checking where your agent has accounts, and the PostNuvia plugin ships a skill for it. A provider that is registered but not listed in the catalog can also be read and connected by its ID.

### What's new?

**New docs and tooling:**

* [AgentID in Claude, ChatGPT, and Cursor](https://docs.postnuvia.com/agentid-in-assistants): set up each assistant, then create accounts, find a provider for a need, get an API key, and list accounts with the `list_providers`, `search_providers`, `get_provider`, `connect_provider`, and `list_accounts` MCP tools.
* ChatGPT setup on the [MCP page](https://docs.postnuvia.com/integrations/mcp), using developer mode and OAuth.
* The PostNuvia plugin for Claude Code, Cursor, and Codex (0.4.0) adds the `agentid` skill (`postnuvia-agentid` on skills.sh).

**Unlisted providers by ID:**

* `GET /v0/providers/{provider_id}` returns the ID and name of a registered provider that the catalog does not list, without `updated_at`.
* `POST /v0/providers/{provider_id}/connect` works for any registered provider with a sign-in entry point, listed or not.
* `GET /v0/providers` and `GET /v0/providers/search` still return catalog entries only.

### Use cases

* Give your agent its own Firecrawl, search, or database account from the assistant you already use, without a sign-up form or password.
* Audit which inboxes hold accounts at which providers without writing code.
* Connect an inbox to a provider that shared its ID with you before it appears in the catalog.

> **Note**
>
> See [AgentID in Claude, ChatGPT, and Cursor](https://docs.postnuvia.com/agentid-in-assistants) for the full walkthrough.

## September 29, 2026

## Summary

Ask Claude, ChatGPT, Cursor, Claude Code, or Codex to "create an account at Firecrawl for my agent" and it does, using an PostNuvia inbox as the agent's identity through AgentID. A new guide walks through creating accounts, getting API keys, and checking where your agent has accounts, and the PostNuvia plugin ships a skill for it. A provider that is registered but not listed in the catalog can also be read and connected by its ID.

### What's new?

**New docs and tooling:**

* [AgentID in Claude, ChatGPT, and Cursor](https://docs.postnuvia.com/agentid-in-assistants): set up each assistant, then create accounts, find a provider for a need, get an API key, and list accounts with the `list_providers`, `search_providers`, `get_provider`, `connect_provider`, and `list_accounts` MCP tools.
* ChatGPT setup on the [MCP page](https://docs.postnuvia.com/integrations/mcp), using developer mode and OAuth.
* The PostNuvia plugin for Claude Code, Cursor, and Codex (0.4.0) adds the `agentid` skill (`postnuvia-agentid` on skills.sh).

**Unlisted providers by ID:**

* `GET /v0/providers/{provider_id}` returns the ID and name of a registered provider that the catalog does not list, without `updated_at`.
* `POST /v0/providers/{provider_id}/connect` works for any registered provider with a sign-in entry point, listed or not.
* `GET /v0/providers` and `GET /v0/providers/search` still return catalog entries only.

### Use cases

* Give your agent its own Firecrawl, search, or database account from the assistant you already use, without a sign-up form or password.
* Audit which inboxes hold accounts at which providers without writing code.
* Connect an inbox to a provider that shared its ID with you before it appears in the catalog.

> **Note**
>
> See [AgentID in Claude, ChatGPT, and Cursor](https://docs.postnuvia.com/agentid-in-assistants) for the full walkthrough.

## September 28, 2026

## Summary

Agents can now sign up without a human's email address. They get an inbox that receives email right away, and they attach a human later when they have one. Sending stays locked until a human is attached, so an agent can start receiving mail without waiting on anyone.

### What's new?

**New endpoints:**

* `POST /v0/agent/human`: attach a human to an unverified agent organization. The human is emailed a 6-digit OTP for `POST /v0/agent/verify`, and the agent can email that human until it verifies.

**Changes:**

* `human_email` is now optional on `POST /v0/agent/sign-up`. Without it, the inbox is receive-only and cannot send to anyone until a human is attached. The API key also cannot be recovered, so store it durably: calling sign-up again without `human_email` creates a new organization, which needs a different `username`.
* Calling `POST /v0/agent/human` again with the same email resends the OTP if it was never delivered, or issues a new one if it expired, without rotating the API key. Any unverified agent can use this to get a new OTP.
* Calling it with a different email replaces the attached human, up to 2 times per organization.
* The `pod_update` API key permission, which controls updating pods, is now listed in the API key permissions.

### Breaking changes

⚠️ **Unverified agent organizations can no longer create, update, or delete pods.**

`pod_create`, `pod_update`, and `pod_delete` now require a verified organization, like creating API keys and list entries already did. These requests return a `403` with `code: "missing_permission"` until the organization completes `POST /v0/agent/verify`. Reading pods is unchanged.

### Use cases

* Build agents that sign themselves up and start receiving email before anyone has given them a human contact.
* Build onboarding flows where the agent asks for its human's email later, then attaches it.
* Let agents fix a mistyped human email before verifying, without starting over.

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

# sign up with no human email: the inbox is receive-only
response = PostNuvia().agent.sign_up(username="my-agent")

# later, attach a human; they are emailed an OTP
client = PostNuvia(api_key=response.api_key)
client.agent.attach_human(human_email="you@example.com")
client.agent.verify(otp_code="123456")
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

// sign up with no human email: the inbox is receive-only
const response = await new PostNuviaClient().agent.signUp({ username: "my-agent" });

// later, attach a human; they are emailed an OTP
const client = new PostNuviaClient({ apiKey: response.apiKey });
await client.agent.attachHuman({ humanEmail: "you@example.com" });
await client.agent.verify({ otpCode: "123456" });
```

> **Note**
>
> See [Sign up without a human email](https://docs.postnuvia.com/agent-onboarding#sign-up-without-a-human-email) in the Agent Onboarding guide.

## September 23, 2026

## Summary

This update makes signed CDN URLs download attachments on direct browser navigation instead of rendering them as pages. It reduces the risk of executing sender-controlled content while preserving backend file processing and supported image, video, and audio subresources.

### What's new?

* The change covers attachment `download_url` values, including thread-level attachments, raw-message `.eml` `download_url` values, and optional extracted attachment `text_url` values.
* Responses add download disposition, `nosniff`, frame denial, a restrictive sandboxed content security policy, a no-referrer policy, and HSTS.
* File bytes, `content_type`, and `filename` metadata are unchanged. Existing filename parameters in download headers are preserved. Email MIME disposition is separate from browser download behavior.
* Server-side fetches and supported `img`, `video`, and `audio` subresources continue to work, including CSS image references. Stylesheets still need a correct MIME type with `nosniff`.

### Breaking changes

⚠️ **After rollout, direct navigation downloads files and document previews need a different flow.**

Do not use these URLs for `iframe`, `embed`, or `object` document previews, such as embedded PDF viewers. Download disposition changes navigation behavior, and `X-Frame-Options: DENY` plus CSP `frame-ancestors 'none'` block framing in supporting browsers. Ordinary supported image, video, and audio subresources do not need a new proxy for this change.

Use the HTTPS URLs returned by the API without changing their scheme. Requests made over `http://` receive `403 Forbidden` instead of an HTTPS redirect after rollout.

Signed URLs expire at `expires_at`, currently one hour after retrieval. A URL saved in page markup can expire before a user clicks it. Point the download link at your own backend route instead:

```html
<!-- before: a document preview using a stored signed URL -->
<iframe src="ATTACHMENT_DOWNLOAD_URL" title="Attachment preview"></iframe>
<!-- after: your backend fetches a fresh URL when clicked -->
<a href="/attachments/ATTACHMENT_ID/download">Download attachment</a>
```

In that route, authenticate the user and verify their access to the attachment before calling the helper below with its inbox, message, and attachment IDs. Redirect to the returned URL with `Cache-Control: private, no-store`, or stream the bytes with download headers. Keep the PostNuvia API key on your server; a global API key does not replace your application's per-user authorization.

**`Python`**

```python title="Python"
import os
from postnuvia import PostNuvia

client = PostNuvia(api_key=os.environ["POSTNUVIA_API_KEY"])

def fresh_download_url(inbox_id, message_id, attachment_id):
    attachment = client.inboxes.messages.get_attachment(
        inbox_id=inbox_id, message_id=message_id, attachment_id=attachment_id
    )
    return attachment.download_url
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: process.env.POSTNUVIA_API_KEY! });

async function freshDownloadUrl(
  inboxId: string, messageId: string, attachmentId: string,
) {
  const attachment = await client.inboxes.messages.getAttachment(
    inboxId, messageId, attachmentId,
  );
  return attachment.downloadUrl;
}
```

If your application needs document previews, fetch the bytes on your backend and render only validated file types through an isolated preview flow. Do not serve arbitrary HTML or SVG as documents under your application origin.

### Use cases

* Build agents that offer fresh attachment downloads for users to inspect locally.
* Build agents that fetch attachment bytes on the backend for document processing.

> **Note**
>
> See the [Get Attachment API reference](https://docs.postnuvia.com/api-reference/inboxes/messages/get-attachment) for `download_url`, optional `text_url`, and `expires_at`.

## September 21, 2026

## Summary

Disable an inbox's sign-in at a provider, and re-enable it later, with one call. Accounts now carry a `status`, and accounts can be listed and read under pods and inboxes so an agent can audit sign-ins at exactly the scope its key holds.

### What's new?

**New endpoints:**

* `PATCH /v0/accounts/{account_id}/update` - Update one account. Send `{ "status": "disabled" }` to refuse every new sign-in for that inbox at that provider, or `{ "status": "enabled" }` to re-enable it. Requires the new `account_update` permission.
* `GET /v0/pods/{pod_id}/accounts` and `GET /v0/pods/{pod_id}/accounts/{account_id}` - List and read the accounts held by inboxes in one pod.
* `GET /v0/inboxes/{inbox_id}/accounts` and `GET /v0/inboxes/{inbox_id}/accounts/{account_id}` - List and read the accounts held by one inbox.

**New features:**

* **Account status**: a disabled account carries `status: "disabled"` and `disabled_at` on every read. An account without `status` can sign in.
* **Idempotent updates**: disabling an already disabled account keeps its original `disabled_at`, and enabling an enabled account is a no-op, so a retry never conflicts.
* **`account_update` permission**: a separate grant for the write. Unrestricted keys hold it; restricted keys get it only when granted. Reading accounts still needs only `inbox_read`.
* **Scoped views**: the pod and inbox forms return the same account object as `GET /v0/accounts`, narrowed to the pod or inbox in the path. An account outside that scope is a 404.

### Use cases

Build agents that:

* Cut off a provider that is misbehaving, without deleting the sign-in record
* Pause one inbox's access to a provider during an incident and restore it afterward
* Audit, per pod or per inbox, which providers each agent has signed in to
* Give a tenant's key visibility into its own accounts and nothing beyond its pod

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="your-api-key")

# disable an inbox's sign-in at a provider
account = client.accounts.update(
    account_id="3f1c2a4e-8b7d-4c6e-9a1f-2d3e4f5a6b7c",
    status="disabled",
)
print(account.status, account.disabled_at)

# list the accounts one inbox holds
accounts = client.inboxes.accounts.list(inbox_id="agent@postnuvia.com")
for item in accounts.accounts:
    print(item.provider_name, item.status or "ok")
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuvia } from "postnuvia";

const client = new PostNuvia({ apiKey: "your-api-key" });

// disable an inbox's sign-in at a provider
const account = await client.accounts.update("3f1c2a4e-8b7d-4c6e-9a1f-2d3e4f5a6b7c", {
  status: "disabled",
});
console.log(account.status, account.disabledAt);

// list the accounts one inbox holds
const accounts = await client.inboxes.accounts.list("agent@postnuvia.com");
for (const item of accounts.accounts) {
  console.log(item.providerName, item.status ?? "ok");
}
```

> **Note**
>
> See [Update Account](https://docs.postnuvia.com/api-reference/accounts/update) in the API reference for the request and response shapes.

## September 16, 2026

## Summary

`POST /v0/inboxes/{inbox_id}/authorize` now returns only what the agent needs after authorizing a waiting sign-in: the key's `api_key_id` and an `instructions` line. The full key, including `status` and `permissions`, is read with Get API Key, the same call every other credential uses.

### Breaking changes

⚠️ The authorize response no longer carries the pending public key's fields. Read `status`, `permissions`, and `expires_at` from `GET /v0/api-keys/{api_key_id}` instead.

**Migration guide:**

**`Python`**

```python title="Python"
import os
import httpx

headers = {"Authorization": f"Bearer {os.environ['POSTNUVIA_API_KEY']}"}
response = httpx.post(
    "https://api.postnuvia.com/v0/inboxes/agent%40example.com/authorize",
    headers=headers,
    json={"auth_token": os.environ["AGENTID_AUTH_TOKEN"]},
    timeout=30,
)
response.raise_for_status()
authorization = response.json()

# before
# print(authorization["status"])

# after: read the key for its status
key = httpx.get(
    f"https://api.postnuvia.com/v0/api-keys/{authorization['api_key_id']}",
    headers=headers,
    timeout=30,
)
key.raise_for_status()
print(key.json()["status"])
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: process.env.POSTNUVIA_API_KEY! });
const authorization = await client.inboxes.authorize("agent@example.com", {
  authToken: process.env.AGENTID_AUTH_TOKEN!,
});

// before
// console.log(authorization.status);

// after: read the key for its status
const key = await client.apiKeys.get(authorization.apiKeyId);
if (key.type === "public_key") console.log(key.status);
```

> **Note**
>
> See [Authorize Inbox](https://docs.postnuvia.com/api-reference/inboxes/authorize) and the [AgentID Sign-In guide](https://docs.postnuvia.com/agentid-sign-in).

## September 5, 2026

## Summary

Signing in to a provider as an inbox now produces an API key: an inbox-scoped public key, managed with the same list, get, update, and delete calls as every other API key. Start one with provider connect, or authorize a sign-in that is already waiting, then poll it until it is active, list it alongside your bearer keys, and revoke it when it is no longer needed, without handling key material or enrollment internals.

### What's new?

**New endpoints:**

* `POST /v0/inboxes/{inbox_id}/authorize` - Authorize the AgentID sign-in a client is already waiting in, for relying parties reached directly rather than through connect. Mints and returns the inbox's pending public key in the api-keys shape; `accept_disclosure: true` accepts that relying party's disclosure.
* `GET /v0/api-keys/{api_key_id}` - Get one credential of any family. For a sign-in key, poll it to watch `status` go from `pending` to `active`.
* `PATCH /v0/api-keys/{api_key_id}` - Rename or re-permission a bearer key or a public-key credential.

**New features:**

* **One vocabulary for every credential family**: `GET /v0/api-keys` lists every credential in one list, newest first; `type` restricts to one family; the pod- and inbox-nested routes restrict to one scope. Items identify their credential `type` and include applicable scope, permissions, and timestamps. Public keys include `created_by`; sign-in keys add `status`.
* **One handle for the whole lifecycle**: connect responses carry `api_key_id`, the same ID Get, List, and Delete use before and after activation.
* **Delete covers every family**: `DELETE /v0/api-keys/{api_key_id}` deletes a bearer key, revokes a registered public key, cancels a pending sign-in key, or revokes an active one.
* **Sign-in keys carry their own permissions**: exactly `provider_connect` and `provider_share_owner`, snapshotted from the creating bearer key, enforced from the key itself, and editable with `PATCH /v0/api-keys/{api_key_id}`. `provider_share_owner` is one grouped permission: a bearer key holding only one of the older `owner_profile` / `owner_email` permissions mints keys with it `false`.
* **Public keys outlive their creator**: a sign-in key or registered public key is independent of the bearer key that created it. Deleting or narrowing that bearer key afterward does not revoke or change it; `created_by` is provenance only. A sign-in key expires 30 days after activation; a registered key keeps the expiry given at registration.
* **Sign-in is its own permission**: `provider_connect` on a bearer key gates connect, authorize, and minting sign-in keys; `provider_share_owner` gates sharing the owner's name and email with providers. Omitted on a new key means false.
* **Public keys live under api-keys**: `POST /v0/api-keys` with a `public_key` body registers a public-key credential at the route's scope (`/pods/{pod_id}/api-keys` and `/inboxes/{inbox_id}/api-keys` for a pod or inbox, no `scope` body field), and list, get, update, and delete cover it alongside bearer keys.
* **Client IDs for public keys**: register a public key with a caller-chosen `client_id`, unique within the organization across every public key, and use it in place of `api_key_id` on get, update, and delete. Registration is idempotent on it.

### Breaking changes

⚠️ Public-key management now uses the shared API Keys routes. Dedicated credential, enrollment, consent, and bulk-revocation routes have been removed. Remembered consent is managed by AgentID.

Before, creating a pending sign-in key used the inbox API Keys route:

```http
POST /v0/inboxes/{inbox_id}/api-keys
```

Now, authorize a pending sign-in with its `auth_token`:

```http
POST /v0/inboxes/{inbox_id}/authorize
Content-Type: application/json

{"auth_token": "AAAAAAAAAAAAAAAAAAAAAA"}
```

Connect returns `api_key_id`, `magic_url`, and `expires_at`. Use that key ID with the shared API Keys routes for status and revocation.

### Use cases

Build agents that:

* Sign in to a relying party as an inbox and complete the AgentID step with one API call
* Wait on a pending sign-in by polling one endpoint instead of diffing a list
* Audit and revoke every credential that can act as an inbox, bearer and sign-in keys alike, from one list

Python SDK 0.5.10 does not include these routes; the Python example uses `httpx`. The TypeScript example uses SDK 0.5.25.

**`Python`**

```python title="Python"
import os
import uuid
import httpx

response = httpx.post(
    "https://api.postnuvia.com/v0/providers/d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32/connect",
    headers={"Authorization": f"Bearer {os.environ['POSTNUVIA_API_KEY']}",
             "Idempotency-Key": str(uuid.uuid4())},
    json={"inbox_id": "agent@example.com"},
    timeout=30,
)
response.raise_for_status()
connection = response.json()
print(connection["magic_url"], connection["api_key_id"])
```

**`TypeScript`**

```typescript title="TypeScript"
import { randomUUID } from "node:crypto";
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: process.env.POSTNUVIA_API_KEY! });
const connection = await client.providers.connect(
  "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32",
  { inboxId: "agent@example.com" },
  { headers: { "Idempotency-Key": randomUUID() } },
);
console.log(connection.magicUrl); // open to complete sign-in
const key = await client.apiKeys.get(connection.apiKeyId);
if (key.type === "public_key") console.log(key.status);
```

> **Note**
>
> See the [AgentID Sign-In guide](https://docs.postnuvia.com/agentid-sign-in) for setup and the complete flow.

## September 1, 2026

## Summary

Browse the AgentID provider catalog, see which providers each inbox is signed in to, and start a sign-in at a provider, all from the PostNuvia API. The API reference now documents the providers and accounts resources so agents can discover, inspect, and connect to providers without leaving the REST surface.

### What's new?

**New endpoints:**

* `GET /v0/providers` - The curated provider catalog, most popular first.
* `GET /v0/providers/search` - Prefix search over the catalog.
* `GET /v0/providers/{provider_id}` - One provider. Providers in the catalog return the full entry; a provider you are signed in to but that is not in the catalog returns its ID and name.
* `GET /v0/providers/{provider_id}/accounts` - Your accounts at one provider, most recent sign-in first, with the provider embedded under `provider`.
* `POST /v0/providers/{provider_id}/connect` - Start signing an inbox in to a provider and receive a five-minute `magic_url`. Requires `provider_connect` and an `Idempotency-Key` header (supplied automatically by the CLI).
* `GET /v0/accounts` - Every AgentID account your API key can see, across all providers. Each row carries `provider_id` so it joins back to the provider.
* `GET /v0/accounts/{account_id}` - One account by its own ID.

**New features:**

* **One Account schema**: the same account object is returned by `GET /v0/accounts` and the per-provider list, so a client parses both with one type.
* **Stable account IDs**: every account carries an `account_id` you can address it by directly, without a provider in the URL.
* **Provider identity by ID**: any `provider_id` on an account resolves through `GET /v0/providers/{provider_id}`, so a provider ID on an account is never a dead end.

### Use cases

Build agents that:

* Show a marketplace of providers an inbox can sign in to
* Audit which providers each inbox has signed in to, and when
* Kick off a sign-in at a provider from an agent workflow and continue through the returned sign-in URL
* Resolve any `provider_id` on an account to a human-readable provider

> **Note**
>
> See the [Providers API reference](https://docs.postnuvia.com/api-reference/providers) for request and response shapes.

## July 20, 2026

## Summary

Register scoped P-256 credentials while keeping private key material in your own keystore. Agents can use independently managed keys with permissions and expiry appropriate to their work.

### What's new?

Register and manage public-key credentials through the API Keys endpoints:

* `POST /v0/api-keys` with `public_key`: register a public P-256 JWK. Use `/v0/pods/{pod_id}/api-keys` or `/v0/inboxes/{inbox_id}/api-keys` to select a pod or inbox scope.
* `GET /v0/api-keys?type=public_key`: list public-key credentials.
* `GET /v0/api-keys/{api_key_id}`: inspect a credential.
* `PATCH /v0/api-keys/{api_key_id}`: change its name or permissions.
* `DELETE /v0/api-keys/{api_key_id}`: revoke a credential. List and delete keys individually for multiple revocations.

### Use cases

Build agents that:

* Keep private keys in a trusted keystore
* Use credentials scoped to an organization, pod, or inbox
* Rotate credentials by registering and verifying a replacement before deleting the old key

> **Note**
>
> Follow the [AgentID Public-Key Authentication guide](https://docs.postnuvia.com/agentid-public-key-authentication) for current registration examples and credential management.

## June 25, 2026

## Summary

You can now create and manage webhooks scoped to a single pod or inbox from dedicated endpoints, instead of only filtering an organization-level webhook with `pod_ids` / `inbox_ids`. The scope comes from the path, so pod- and inbox-scoped API keys can manage just their own webhooks.

### What's new?

**New endpoints:**

* `GET|POST /v0/pods/:pod_id/webhooks` and `GET|PATCH|DELETE /v0/pods/:pod_id/webhooks/:webhook_id` - Manage webhooks scoped to a pod.
* `GET|POST /v0/inboxes/:inbox_id/webhooks` and `GET|PATCH|DELETE /v0/inboxes/:inbox_id/webhooks/:webhook_id` - Manage webhooks scoped to an inbox.

**Behavior:**

* A **pod-scoped** webhook receives events for the whole pod, and can be narrowed to specific inboxes in the pod with `inbox_ids`. You don't pass `pod_ids` — the pod is the path.
* An **inbox-scoped** webhook is fixed to that inbox; only its `event_types` can be changed.
* A scoped webhook must always keep at least one pod or inbox subscription.

### Use cases

Build agents that:

* Give each tenant's pod-scoped API key its own webhook, isolated from other tenants
* Register a webhook for a single high-volume inbox without touching org-wide delivery
* Let an inbox-scoped key manage only its own event subscriptions

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="your-api-key")

# webhook scoped to a single pod
client.pods.webhooks.create(
    pod_id="pod_abc123",
    url="https://your-server.com/webhooks",
    event_types=["message.received"],
)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "your-api-key" });

// webhook scoped to a single pod
await client.pods.webhooks.create("pod_abc123", {
  url: "https://your-server.com/webhooks",
  eventTypes: ["message.received"],
});
```

> **Note**
>
> See [Scoping a webhook to a pod or inbox](https://docs.postnuvia.com/webhooks-overview) for details.

## June 25, 2026

## Summary

You can now create and manage webhooks scoped to a single pod or inbox from dedicated endpoints, instead of only filtering an organization-level webhook with `pod_ids` / `inbox_ids`. The scope comes from the path, so pod- and inbox-scoped API keys can manage just their own webhooks.

### What's new?

**New endpoints:**

* `GET|POST /v0/pods/:pod_id/webhooks` and `GET|PATCH|DELETE /v0/pods/:pod_id/webhooks/:webhook_id` - Manage webhooks scoped to a pod.
* `GET|POST /v0/inboxes/:inbox_id/webhooks` and `GET|PATCH|DELETE /v0/inboxes/:inbox_id/webhooks/:webhook_id` - Manage webhooks scoped to an inbox.

**Behavior:**

* A **pod-scoped** webhook receives events for the whole pod, and can be narrowed to specific inboxes in the pod with `inbox_ids`. You don't pass `pod_ids` — the pod is the path.
* An **inbox-scoped** webhook is fixed to that inbox; only its `event_types` can be changed.
* A scoped webhook must always keep at least one pod or inbox subscription.

### Use cases

Build agents that:

* Give each tenant's pod-scoped API key its own webhook, isolated from other tenants
* Register a webhook for a single high-volume inbox without touching org-wide delivery
* Let an inbox-scoped key manage only its own event subscriptions

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="your-api-key")

# webhook scoped to a single pod
client.pods.webhooks.create(
    pod_id="pod_abc123",
    url="https://your-server.com/webhooks",
    event_types=["message.received"],
)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "your-api-key" });

// webhook scoped to a single pod
await client.pods.webhooks.create("pod_abc123", {
  url: "https://your-server.com/webhooks",
  eventTypes: ["message.received"],
});
```

> **Note**
>
> See [Scoping a webhook to a pod or inbox](https://docs.postnuvia.com/webhooks-overview) for details.

## June 12, 2026

## Summary

You can now track cumulative usage over time. The new usage endpoint returns running totals of storage, messages, threads, inboxes, domains, and pods — for your whole organization, a single pod, or a single inbox. Event counts also move to a dedicated `/metrics/events` path, so the metrics API now cleanly separates "what happened" (events) from "what you have" (usage).

### What's new?

**New endpoints:**

* `GET /v0/metrics/usage` - Cumulative usage series for the organization.
* `GET /v0/pods/:pod_id/metrics/usage` - Cumulative usage series for a pod.
* `GET /v0/inboxes/:inbox_id/metrics/usage` - Cumulative usage series for an inbox.
* `GET /v0/metrics/events` (and pod/inbox variants) - The canonical path for event counts, replacing bare `GET /v0/metrics`.

**New features:**

* **Usage series**: Each point is the running total of a usage type at that timestamp, not the change within the bucket. An idle scope renders a flat line at its current level, so charts stay meaningful even with no activity in the window.
* **Usage types**: `storage_bytes`, `message_count`, `thread_count`, `inbox_count`, `domain_count`, and `pod_count`. Filter with the `usage_types` query parameter, or omit it to get every type the scope carries. Inboxes carry the first three; pods add `inbox_count` and `domain_count`; organizations add `pod_count`.
* **Bucketing**: `period` sets the bucket size in seconds. The range divided by `period` must not exceed 1000 buckets — narrow the range or coarsen the period for fine-grained series.

**Changes:**

* `GET /v0/metrics` (and its pod/inbox variants) is replaced by `/metrics/events`. The old path still responds, so existing clients keep working, but it's removed from the docs and SDKs — use `client.metrics.queryEvents()` in place of `client.metrics.query()`.
* Metric queries now clamp a future `end` to the current time instead of returning phantom future points, and `period`/`limit` must be whole numbers.
* The documented metric event types now match what the API accepts: added `message.received.spam`, `message.received.blocked`, `message.received.unauthenticated`, and `domain.verified`; removed `message.delayed`, which the API never accepted.

### Use cases

Build agents that:

* Chart storage growth over time and archive old threads before hitting quota
* Monitor message and thread volume per inbox to spot runaway automations
* Verify cleanup actually happened by watching usage drop after bulk deletes
* Compare pods by inbox and domain footprint to balance workloads

**`Python`**

```python title="Python"
from postnuvia import PostNuvia

client = PostNuvia(api_key="your-api-key")

# storage growth for the last week, one point per hour
usage = client.metrics.query_usage(
    usage_types=["storage_bytes"],
    start="2026-06-05T00:00:00Z",
    period=3600,
)

for point in usage["storage_bytes"]:
    print(point.timestamp, point.value)

# usage for a single inbox (storage, messages, threads)
inbox_usage = client.inboxes.metrics.query_usage(inbox_id="support@postnuvia.com")

# event counts now live at /metrics/events
events = client.metrics.query_events(
    event_types=["message.sent", "message.bounced"],
    period=3600,
)
```

**`TypeScript`**

```typescript title="TypeScript"
import { PostNuviaClient } from "postnuvia";

const client = new PostNuviaClient({ apiKey: "your-api-key" });

// storage growth for the last week, one point per hour
const usage = await client.metrics.queryUsage({
  usageTypes: ["storage_bytes"],
  start: "2026-06-05T00:00:00Z",
  period: 3600,
});

for (const point of usage["storage_bytes"] ?? []) {
  console.log(point.timestamp, point.value);
}

// usage for a single inbox (storage, messages, threads)
const inboxUsage = await client.inboxes.metrics.queryUsage("support@postnuvia.com");

// event counts now live at /metrics/events
const events = await client.metrics.queryEvents({
  eventTypes: ["message.sent", "message.bounced"],
  period: 3600,
});
```

> **Note**
>
> Check out the [Metrics API reference](https://docs.postnuvia.com/api-reference/metrics) for full parameter details.

_Showing the 20 most recent of 30 entries. Append `/llms.txt` to the changelog URL for the complete index._