> ## Documentation Index
> Fetch the complete documentation index at: https://developers.podero.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reference

> Webhook Management API, Notification Content API, message envelope, headers, topics, signatures, and delivery semantics

# Notifications Reference

The complete contract for messages Podero delivers to your endpoint, and the APIs for managing your webhooks and reading notification content.

## Webhook Management API

All endpoints require an admin API token and are scoped to a single organization.

**Base URL:** `https://api.podero.com/api/partners/v2.0/org/{org_id}/notifications/webhooks`

### Create a webhook

`POST /webhooks`

Register an HTTPS endpoint and subscribe it to the given topics. The response includes a signing secret — returned **once** and never retrievable again.

<Warning>
  Each call creates a new, independent webhook. A retried POST with the same URL creates a second webhook with its own secret — guard retries on your side to avoid duplicates.
</Warning>

**Request body**

<ParamField body="url" type="string" required>
  The HTTPS URL to receive notifications. Must be publicly reachable with a valid TLS certificate.
</ParamField>

<ParamField body="topics" type="string[]" default="[]">
  Topics to subscribe to. An empty list creates the webhook with no subscriptions.
</ParamField>

```json Example request theme={null}
{
  "url": "https://your-endpoint.example.com/podero/notifications",
  "topics": ["device.action_required", "device.information"]
}
```

```json 201 Created theme={null}
{
  "webhook_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "url": "https://your-endpoint.example.com/podero/notifications",
  "enabled": true,
  "topics": ["device.action_required", "device.information"],
  "signing_secret": "whsec_MmQ3..."
}
```

***

### List webhooks

`GET /webhooks`

Returns all webhooks registered for the organization, paginated (50 per page). The signing secret is never included.

```json 200 OK theme={null}
{
  "items": [
    {
      "webhook_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "url": "https://your-endpoint.example.com/podero/notifications",
      "enabled": true,
      "topics": ["device.action_required", "device.information"],
      "created_at": "2026-07-16T09:00:00+00:00",
      "updated_at": "2026-07-16T09:00:00+00:00"
    }
  ],
  "count": 1
}
```

***

### Retrieve a webhook

`GET /webhooks/{webhook_id}`

Returns a single webhook. A webhook belonging to another organization returns `404`.

```json 200 OK theme={null}
{
  "webhook_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "url": "https://your-endpoint.example.com/podero/notifications",
  "enabled": true,
  "topics": ["device.action_required", "device.information"],
  "created_at": "2026-07-16T09:00:00+00:00",
  "updated_at": "2026-07-16T09:00:00+00:00"
}
```

***

### Update a webhook

`PATCH /webhooks/{webhook_id}`

Update a webhook's enabled state and/or its subscribed topics. Only the fields present in the request change. The endpoint URL is immutable — delete and recreate to change it.

<ParamField body="enabled" type="boolean">
  Set to `false` to pause deliveries, `true` to resume. While disabled, messages for this webhook are dropped, not queued.
</ParamField>

<ParamField body="topics" type="string[]">
  Replaces the entire subscription set. Pass an empty list to unsubscribe from all topics.
</ParamField>

```json Example request theme={null}
{
  "enabled": false,
  "topics": ["device.action_required"]
}
```

```json 200 OK theme={null}
{
  "webhook_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "url": "https://your-endpoint.example.com/podero/notifications",
  "enabled": false,
  "topics": ["device.action_required"],
  "created_at": "2026-07-16T09:00:00+00:00",
  "updated_at": "2026-07-16T10:15:00+00:00"
}
```

***

### Delete a webhook

`DELETE /webhooks/{webhook_id}`

Remove a webhook permanently. No further deliveries are made and the webhook disappears from the listing.

**Response:** `204 No Content`

***

### Rotate a webhook's signing secret

`POST /webhooks/{webhook_id}/rotate-secret`

Mint a fresh signing secret, superseding the previous one. The next delivery is signed with the new secret. The new secret is returned **once** in this response — store it immediately.

```json 200 OK theme={null}
{
  "signing_secret": "whsec_Njdk..."
}
```

<Note>
  After rotating, update your endpoint's verification code to use the new secret before the next delivery arrives.
</Note>

***

### Send a test message

`POST /webhooks/{webhook_id}/test`

Send a synthetic sample for one subscribed topic to the webhook and return the delivery outcome synchronously. The topic must be one the webhook is currently subscribed to.

<ParamField body="topic" type="string" required>
  The topic to test. Must be in the webhook's current subscription set.
</ParamField>

```json Example request theme={null}
{
  "topic": "device.action_required"
}
```

```json 200 OK theme={null}
{
  "topic": "device.action_required",
  "schema_version": 1,
  "outcome": "delivered",
  "status_code": 200
}
```

<ResponseField name="outcome" type="string">
  `delivered` — your endpoint accepted the test (check `status_code`). `failed` — transport error (`detail` explains). `cancelled` — the webhook is disabled or was removed (`detail` explains).
</ResponseField>

<ResponseField name="status_code" type="integer | null">
  The HTTP status code your endpoint returned, present only when `outcome` is `delivered`.
</ResponseField>

<ResponseField name="detail" type="string | null">
  Error or cancellation reason, present when `outcome` is `failed` or `cancelled`.
</ResponseField>

<Note>
  The HTTP response is always `200` regardless of the delivery outcome — branch on `outcome`, not the HTTP status.
</Note>

***

## Notification Content API

A webhook message tells you *that* something happened; these read endpoints tell you *what* is currently pending. When a `device.action_required` or `device.information` message arrives, call these to fetch the actual items to surface to your users. See [acting on a notification](/partner-api/notifications/integration#5-fetch-the-notification-content) for the integration pattern.

**Base URL:** `https://api.podero.com/api/partners/v2.0/org/{org_id}/notifications`

All three endpoints accept an optional `owner_id` query parameter. An admin token may pass any user in the organization (or omit it for org-wide results); a non-admin token is always scoped to its own user. The two list endpoints additionally filter by `device_id` and `device_type` — all three filters match the fields a [webhook message's `data`](#topics) carries, so you can pass them straight through.

### Get notification status

`GET /status`

Counts of pending action items and unread information notifications, grouped by device type. Useful for badge counts and for deciding which list to fetch.

<ParamField query="owner_id" type="string (UUID)">
  Restrict counts to a single user's devices. Omit for org-wide counts (admin only).
</ParamField>

```json 200 OK theme={null}
{
  "electric_vehicle": { "action_required": 1, "information_available": 0 },
  "heat_pump": { "action_required": 0, "information_available": 2 },
  "inverter": { "action_required": 0, "information_available": 0 },
  "wallbox": { "action_required": 0, "information_available": 0 }
}
```

All four device types are always present, so the shape is predictable regardless of which devices the organization owns. `action_required` counts pending action items (the same semantic as the per-device `action_needed` flag); `information_available` counts unread information notifications.

***

### List pending actions

`GET /actions`

The organization's pending user-facing actions, newest first — every item a user still needs to act on, plus unread information notices, merged into one list. This is the endpoint to call when a `device.action_required` webhook message arrives.

<ParamField query="owner_id" type="string (UUID)">
  Restrict to a single user's actions. Omit for org-wide results (admin only).
</ParamField>

<ParamField query="device_id" type="string (UUID)">
  Restrict to a single device's actions.
</ParamField>

<ParamField query="device_type" type="string">
  Restrict to one device type: `electric_vehicle`, `heat_pump`, `inverter`, or `wallbox`.
</ParamField>

<ParamField query="limit" type="integer" default="50">
  Page size, 1–100.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Items to skip.
</ParamField>

Each item is discriminated by its `code`:

```json 200 OK theme={null}
[
  {
    "code": "reauthenticate",
    "device_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "device_type": "electric_vehicle",
    "resolution_link": {
      "url": "/api/partners/v2.0/cues/6f1c2e58-.../resolve?secret=eyJhb...",
      "required_parameters": ["success_url", "cancel_url", "language"]
    }
  },
  {
    "code": "information",
    "device_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "device_type": "electric_vehicle",
    "resolution_link": {
      "url": "/api/partners/v2.0/cues/89ab01cd-.../information/resolve?secret=eyJhb...",
      "required_parameters": []
    },
    "metadata": {
      "message": "Your vehicle's connected-services subscription has lapsed."
    },
    "sent_at": "2026-07-16T09:30:00+00:00"
  }
]
```

<ResponseField name="code" type="string">
  The action type. `reauthenticate` — the user must re-authenticate their device against the manufacturer. `information` — a message to show the user, carried in `metadata.message`.
</ResponseField>

<ResponseField name="device_id" type="string (UUID) | null">
  The device the action belongs to.
</ResponseField>

<ResponseField name="device_type" type="string | null">
  The device's type key (`electric_vehicle`, `heat_pump`, `inverter`, `wallbox`) — the same vocabulary the status roll-up and the webhook `data.device_type` use.
</ResponseField>

<ResponseField name="resolution_link" type="object">
  How to resolve the action. `url` is the relative URL to call — it already carries the item's ID and a signed `secret` query parameter bound to the notification's owner. `required_parameters` names the body fields you must supply when calling it (empty for information items, which are resolved by the call alone).
</ResponseField>

<Warning>
  The `secret` in a resolution link expires **one hour** after the response is issued. Fetch actions when you need them rather than storing resolution links.
</Warning>

***

### List information notifications

`GET /information`

The organization's information notification history, newest first — including already-dismissed items. This is the endpoint to call when a `device.information` webhook message arrives.

<ParamField query="owner_id" type="string (UUID)">
  Restrict to a single user's notifications. Omit for org-wide results (admin only).
</ParamField>

<ParamField query="device_id" type="string (UUID)">
  Restrict to a single device's notifications.
</ParamField>

<ParamField query="device_type" type="string">
  Restrict to one device type: `electric_vehicle`, `heat_pump`, `inverter`, or `wallbox`.
</ParamField>

<ParamField query="limit" type="integer" default="50">
  Page size, 1–100.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Items to skip.
</ParamField>

```json 200 OK theme={null}
[
  {
    "id": "89ab01cd-1234-5678-9abc-def012345678",
    "message": "Your vehicle's connected-services subscription has lapsed.",
    "sent_at": "2026-07-16T09:30:00+00:00",
    "dismissed": false,
    "device_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "device_type": "electric_vehicle",
    "resolution_link": {
      "url": "/api/partners/v2.0/cues/89ab01cd-.../information/resolve?secret=eyJhb...",
      "required_parameters": []
    }
  }
]
```

<ResponseField name="dismissed" type="boolean">
  Whether the user has already dismissed the notification. Call the `resolution_link` to dismiss it.
</ResponseField>

<ResponseField name="device_id" type="string (UUID) | null">
  The device the notification relates to, if any.
</ResponseField>

<ResponseField name="device_type" type="string | null">
  The device's type key (`electric_vehicle`, `heat_pump`, `inverter`, `wallbox`), matching the webhook `data.device_type` vocabulary.
</ResponseField>

***

## Request

Each notification is delivered as a single HTTP `POST` with a JSON body.

|              |                                                    |
| ------------ | -------------------------------------------------- |
| Method       | `POST`                                             |
| Content-Type | `application/json`                                 |
| User-Agent   | `Podero-Notifications/1.0`                         |
| Transport    | HTTPS only, valid certificate required             |
| Redirects    | Not followed — a `3xx` counts as a failed delivery |

### Headers

<ResponseField name="Content-Type" type="string">
  Always `application/json`.
</ResponseField>

<ResponseField name="User-Agent" type="string">
  Always `Podero-Notifications/1.0`. Identifies deliveries originating from Podero.
</ResponseField>

<ResponseField name="X-Podero-Signature" type="string">
  The HMAC-SHA256 signature and timestamp for the request body. Format: `t=<unix_seconds>,v1=<hex sha256>`. See [Signatures](#signatures).
</ResponseField>

## Message envelope

The request body is a JSON object with the following fields:

```json theme={null}
{
  "message_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "message_type": "device.action_required",
  "schema_version": 1,
  "occurred_at": "2026-07-16T09:30:00+00:00",
  "data": {
    "device_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "device_type": "electric_vehicle",
    "owner_id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
    "action": "reauthentication_required"
  }
}
```

<ResponseField name="message_id" type="string (UUID)" required>
  A stable identifier for this message. Constant across redeliveries of the same message — use it as your **idempotency key** to dedupe.
</ResponseField>

<ResponseField name="message_type" type="string" required>
  The [topic](#topics) this message belongs to, e.g. `device.action_required`. Branch your handling on this value. Test messages carry `podero.test`.
</ResponseField>

<ResponseField name="schema_version" type="integer" required>
  The version of the `data` schema for this `message_type`. Within a version, `data` is additive — new fields may be introduced without a version bump. A breaking change to a topic's shape is published as a new version.
</ResponseField>

<ResponseField name="occurred_at" type="string (ISO 8601)" required>
  When the originating event occurred, in UTC. Use this for ordering rather than the arrival time.
</ResponseField>

<ResponseField name="data" type="object" required>
  The topic-specific payload. Its shape depends on `message_type` and `schema_version` — see [Topics](#topics).
</ResponseField>

<Note>
  Internal identifiers are deliberately not exposed on the wire. You will not receive Podero's internal notification or endpoint IDs.
</Note>

## Topics

A topic is both what you subscribe to and the `message_type` carried on the wire. Topics are stable, coarse categories; the specific event rides inside `data`.

The boundary between the two topics is **who resolves the issue**: an action a partner can drive to resolution *through Podero's API* rides `device.action_required`; everything else — advisory notices, and issues the user resolves at the manufacturer — rides `device.information`.

For both topics, `data` carries the device as the subject, its owner, and a reason discriminator:

<ResponseField name="device_id" type="string (UUID)" required>
  The device the message is about. Feed it straight into the [Notification Content API](#notification-content-api)'s `device_id` filter.
</ResponseField>

<ResponseField name="device_type" type="string" required>
  The kind of device: `electric_vehicle`, `heat_pump`, `inverter`, or `wallbox` — the same keys the [status roll-up](#get-notification-status) uses and the `device_type` filter accepts.
</ResponseField>

<ResponseField name="owner_id" type="string (UUID)" required>
  The user who owns the device — the person who must act. Feed it straight into the Content API's `owner_id` filter, or use it to deep-link the right user in your own app.
</ResponseField>

<ResponseField name="action / notice" type="string" required>
  The reason discriminator — `action` on `device.action_required`, `notice` on `device.information`. See the value catalogues below.
</ResponseField>

<Tabs>
  <Tab title="device.action_required">
    A device needs an action that is resolvable **through Podero's API** before Podero can keep steering or monitoring it. On receipt, fetch the pending items with [`GET /notifications/actions`](#list-pending-actions) and resolve them via their resolution links. See the [`action` catalogue](#action-values-device-action-required).

    ```json theme={null}
    {
      "message_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
      "message_type": "device.action_required",
      "schema_version": 1,
      "occurred_at": "2026-07-16T09:30:00+00:00",
      "data": {
        "device_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
        "device_type": "electric_vehicle",
        "owner_id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
        "action": "reauthentication_required"
      }
    }
    ```
  </Tab>

  <Tab title="device.information">
    An advisory device update — informational notices, status changes, and issues the user resolves **at the manufacturer** rather than through Podero's API. On receipt, fetch the notices with [`GET /notifications/information`](#list-information-notifications); the user-visible message text is in each item's `message` field. See the [`notice` catalogue](#notice-values-device-information).

    ```json theme={null}
    {
      "message_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "message_type": "device.information",
      "schema_version": 1,
      "occurred_at": "2026-07-16T09:30:00+00:00",
      "data": {
        "device_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
        "device_type": "electric_vehicle",
        "owner_id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
        "notice": "subscription_required"
      }
    }
    ```
  </Tab>
</Tabs>

### `action` values (device.action\_required)

| Value                       | Meaning                                                                                                                                                                  |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `reauthentication_required` | The manufacturer connection has lapsed and the end-user must re-authenticate. Fetch the pending actions and redirect the user into the `reauthenticate` resolution flow. |

### `notice` values (device.information)

| Value                          | Meaning                                                                                                                    |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `information`                  | A general informational message for the user.                                                                              |
| `steering_recovery_failed`     | An electric vehicle is not expected to reach its charge target by its deadline, despite recovery attempts.                 |
| `oem_charge_cap_detected`      | A charge limit configured in the manufacturer's app was detected; Podero respects it when planning charging.               |
| `wallbox_current_cap_detected` | A wallbox is capped by a local current limit that steering cannot exceed; raising it (if safe) restores full optimization. |
| `subscription_required`        | The device's manufacturer connected-services subscription has lapsed; the user must renew it with the manufacturer.        |
| `remote_access_disabled`       | Remote access is disabled in the manufacturer's app; the user must re-enable it there.                                     |

<Note>
  New `action` and `notice` values may be introduced within a schema version — treat an unknown value as "something needs attention" and fall back to the [Notification Content API](#notification-content-api), which always carries ready-to-display message text and resolution links.
</Note>

## Signatures

Every delivery is signed so you can prove it originated from Podero and was not altered in transit.

### Header format

```
X-Podero-Signature: t=<unix_seconds>,v1=<hex sha256>
```

<ResponseField name="t" type="integer">
  The unix timestamp (seconds) at which the request was signed. Also included in the signed string.
</ResponseField>

<ResponseField name="v1" type="string">
  The hex-encoded HMAC-SHA256 digest. `v1` names the scheme version so a future scheme can be added alongside it.
</ResponseField>

### Signing scheme

The digest is computed over the timestamp and the exact request body:

```
signed_payload = "{t}." + raw_body        # bytes: the ASCII timestamp, a dot, then the body
v1             = HMAC_SHA256(secret, signed_payload)   # hex-encoded
```

To verify, recompute `v1` over the **raw body bytes** you received and compare using a constant-time comparison. Reject the request if the timestamp is more than **300 seconds** from your current time. See the [Integration Guide](/partner-api/notifications/integration#verifying-the-signature) for working code.

<Warning>
  Always verify against the raw bytes as received — not a re-serialized copy of the parsed JSON.
</Warning>

## Responding

<ResponseField name="2xx" type="Acknowledged">
  Any `2xx` marks the delivery as successful. Only the status code is read; the response body is ignored.
</ResponseField>

<ResponseField name="Anything else" type="Failed attempt">
  A non-`2xx` status, a redirect, a timeout, or a connection error marks the delivery as a failed attempt.
</ResponseField>

<Note>
  Respond within a few seconds. Podero applies short connect and read timeouts and reads only enough of your response to obtain the status code.
</Note>

## Delivery semantics

<AccordionGroup>
  <Accordion title="At-least-once" icon="repeat">
    A message is delivered at least once. You may occasionally receive a duplicate; dedupe on `message_id`. See [handling duplicates](/partner-api/notifications/integration#4-handle-duplicates).
  </Accordion>

  <Accordion title="Independent, unordered" icon="arrow-down-wide-short">
    Each message is delivered on its own. Do not assume messages arrive in the order their events occurred — use `occurred_at`.
  </Accordion>

  <Accordion title="Tenant-scoped" icon="building">
    A message is only ever delivered to endpoints registered under the organization that owns the source event.
  </Accordion>

  <Accordion title="HTTPS with address validation" icon="lock">
    Deliveries go only to HTTPS endpoints with a valid certificate. Podero resolves and validates the destination on every delivery and refuses private, loopback, link-local, carrier-grade NAT, multicast, and cloud-metadata addresses.
  </Accordion>
</AccordionGroup>

## Endpoint lifecycle

<AccordionGroup>
  <Accordion title="Registration" icon="plus">
    [Create a webhook](#create-a-webhook) to register your HTTPS endpoint. The signing secret is returned once in the response — store it immediately.
  </Accordion>

  <Accordion title="Subscriptions" icon="list-check">
    Each webhook subscribes to a set of topics. A message is delivered only if the webhook is subscribed to that message's topic. [Update the webhook](#update-a-webhook) to change subscriptions at any time.
  </Accordion>

  <Accordion title="Disable / enable" icon="toggle-off">
    [Update the webhook](#update-a-webhook) with `enabled: false` to pause deliveries. While disabled, messages are dropped rather than queued — they are not flushed when re-enabled. Subscriptions are preserved.
  </Accordion>

  <Accordion title="Secret rotation" icon="rotate">
    [Rotate the secret](#rotate-a-webhooks-signing-secret) to mint a fresh signing key. The new secret is returned once; update your verification code before the next delivery arrives.
  </Accordion>

  <Accordion title="Removal" icon="trash">
    [Delete the webhook](#delete-a-webhook) to stop all deliveries permanently. The webhook is removed from the listing and cannot be recovered.
  </Accordion>
</AccordionGroup>
