> ## 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.

# Integration Guide

> Build, secure, and verify an endpoint that receives Podero notifications

# Integrating notifications

This guide walks through building an endpoint that receives Podero notifications, verifying that each delivery is authentic, and handling the delivery semantics correctly.

## Prerequisites

1. **An API token** with admin access for the organization. See [Authentication](/partner-api/overview/authentication).
2. **A public HTTPS endpoint** — a URL reachable from the public internet, with a valid TLS certificate. Podero refuses plain `http`, embedded credentials, and non-public addresses (loopback, private ranges, link-local, cloud metadata).

## Register a webhook

Register your endpoint with the [Create Webhook](/partner-api/notifications/reference#create-a-webhook) endpoint. The response includes a **signing secret** — store it securely.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST \
    "https://api.podero.com/api/partners/v2.0/org/{org_id}/notifications/webhooks" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-endpoint.example.com/podero/notifications",
      "topics": ["device.action_required", "device.information"]
    }'
  ```

  ```python Python theme={null}
  import requests

  resp = requests.post(
      f"https://api.podero.com/api/partners/v2.0/org/{org_id}/notifications/webhooks",
      headers={"Authorization": f"Bearer {token}"},
      json={
          "url": "https://your-endpoint.example.com/podero/notifications",
          "topics": ["device.action_required", "device.information"],
      },
  )
  webhook = resp.json()
  signing_secret = webhook["signing_secret"]  # store this now
  ```
</CodeGroup>

```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..."
}
```

<Warning>
  The signing secret is returned **once** in this response and cannot be retrieved again. Store it in a secrets manager immediately. If it is lost, [rotate it](/partner-api/notifications/reference#rotate-a-webhooks-signing-secret) to get a new one.
</Warning>

## 1. Receive a delivery

A notification arrives as an HTTP `POST` with a JSON body and a signature header:

```http theme={null}
POST /podero/notifications HTTP/1.1
Host: your-endpoint.example.com
Content-Type: application/json
User-Agent: Podero-Notifications/1.0
X-Podero-Signature: t=1752660000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a25f2c9e51e0b0b5b1a1234

{
  "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"
  }
}
```

Your endpoint should read the **raw request body** before doing anything else — you need the exact bytes to verify the signature (see below). Do not verify against a re-serialized copy of the parsed JSON, as key ordering and whitespace will differ.

## 2. Verify the signature

Every message is signed so you can prove it came from Podero. The `X-Podero-Signature` header carries a timestamp and an HMAC-SHA256 digest:

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

To verify a delivery:

<Steps>
  <Step title="Parse the header">
    Split on `,` and read the `t` (unix timestamp) and `v1` (hex digest) fields.
  </Step>

  <Step title="Check freshness">
    Reject the request if `t` is more than **300 seconds** away from your current time. This bounds how long a captured request can be replayed.
  </Step>

  <Step title="Recompute the digest">
    Compute `HMAC-SHA256(secret, "{t}." + raw_body)` and compare it to `v1` using a constant-time comparison.
  </Step>
</Steps>

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 300

  def verify(secret: str, raw_body: bytes, header: str | None) -> bool:
      if not header:
          return False
      fields = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
      try:
          timestamp = int(fields["t"])
          provided = fields["v1"]
      except (KeyError, ValueError):
          return False

      if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
          return False

      signed = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, provided)
  ```

  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  const TOLERANCE_SECONDS = 300;

  function verify(secret, rawBody, header) {
    if (!header) return false;
    const fields = Object.fromEntries(
      header.split(',').filter(p => p.includes('=')).map(p => p.split('=', 2)),
    );
    const timestamp = parseInt(fields.t, 10);
    if (Number.isNaN(timestamp)) return false;

    if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

    const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
    const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(fields.v1 || '', 'utf8');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```
</CodeGroup>

<Warning>
  Verify against the **raw body bytes**, exactly as received. Many web frameworks parse the body into an object before your handler runs — configure yours to expose the raw payload (for example, Express's `express.raw()` or Flask's `request.get_data()`).
</Warning>

## 3. Acknowledge the delivery

Respond with any `2xx` status code to acknowledge the message. Podero reads only the response status — the body is ignored.

<Note>
  Respond quickly. Podero uses tight timeouts (a few seconds) and does **not** follow redirects — a `3xx` is treated as a failed delivery. Acknowledge first, then do slow work asynchronously.
</Note>

Any non-`2xx` response, a redirect, a timeout, or a connection error marks the delivery as failed for that attempt.

## 4. Handle duplicates

Because delivery is **at-least-once**, you may occasionally receive the same message more than once. The `message_id` is stable across redeliveries of the same message — use it as an idempotency key:

```python theme={null}
def handle(message: dict) -> None:
    if already_processed(message["message_id"]):
        return  # a duplicate delivery — safely ignored
    process(message)
    mark_processed(message["message_id"])
```

<Tip>
  Persist processed `message_id`s (with a suitable retention window) so a duplicate that arrives after a restart is still recognised.
</Tip>

## 5. Fetch the notification content

A webhook message is a signal, not the full story: it tells you a device needs attention, and the [Notification Content API](/partner-api/notifications/reference#notification-content-api) tells you exactly what is pending right now. Branch on `message_type` and fetch the matching list:

| `message_type`           | Fetch                                                                                                   | Returns                                                                          |
| ------------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `device.action_required` | [`GET /notifications/actions`](/partner-api/notifications/reference#list-pending-actions)               | Pending actions the user must take, each with a `resolution_link`                |
| `device.information`     | [`GET /notifications/information`](/partner-api/notifications/reference#list-information-notifications) | Information notices to show the user, each dismissable via its `resolution_link` |

The message's `data` fields feed straight into the list filters — `owner_id`, `device_id`, and `device_type` share one vocabulary across the wire payload and the Content API — so you can scope the fetch to exactly the user and device the message is about:

```python theme={null}
def process(message: dict) -> None:
    data = message["data"]
    scope = {"owner_id": data["owner_id"], "device_id": data["device_id"]}
    match message["message_type"]:
        case "device.action_required":
            actions = api.get(f"/org/{ORG_ID}/notifications/actions", params=scope)
            # Surface each action to the user; its resolution_link
            # tells you the URL to call and the parameters it needs.
        case "device.information":
            notices = api.get(f"/org/{ORG_ID}/notifications/information", params=scope)
            # Show unread notices; call each resolution_link to dismiss.
```

From here it's a user-experience problem: showing the items, redirecting the user through a reauthentication, dismissing notices. The [Notifications journey](/partner-api/user-journeys/end-user/notifications) covers that end to end, including badge counts via [`GET /notifications/status`](/partner-api/notifications/reference#get-notification-status).

<Warning>
  Resolution-link secrets expire **one hour** after the response is issued. Fetch the lists when you act on a message rather than caching links from an earlier fetch.
</Warning>

## 6. Test end to end

Send a test delivery to your webhook with the [Test Webhook](/partner-api/notifications/reference#send-a-test-message) endpoint. It fires a synthetic sample for one of your subscribed topics, signed exactly like a real notification:

```bash theme={null}
curl -X POST \
  "https://api.podero.com/api/partners/v2.0/org/{org_id}/notifications/webhooks/{webhook_id}/test" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"topic": "device.action_required"}'
```

The response tells you the outcome immediately:

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

If `outcome` is `"delivered"` and your signature check passed, you are ready to go live. A `"failed"` outcome includes a `detail` field with the transport error.

## Best practices

<AccordionGroup>
  <Accordion title="Return fast, process later" icon="bolt">
    Acknowledge with `2xx` immediately and hand off processing to a background queue. Slow handlers risk timing out and being marked as failed.
  </Accordion>

  <Accordion title="Always verify signatures" icon="shield-halved">
    Reject any request whose signature does not verify or whose timestamp is outside the tolerance window. Never act on an unverified payload.
  </Accordion>

  <Accordion title="Be tolerant of new fields" icon="layer-group">
    Within a `schema_version`, the `data` object is additive — new fields may appear. Ignore fields you do not recognise rather than failing.
  </Accordion>

  <Accordion title="Keep your secret safe" icon="key">
    Store the signing secret in a secrets manager, never in source control. If it is exposed, [rotate it](/partner-api/notifications/reference#rotate-a-webhooks-signing-secret) immediately.
  </Accordion>
</AccordionGroup>

## Next steps

<Card title="Reference" icon="code" href="/partner-api/notifications/reference">
  The full message envelope, headers, topics, and delivery semantics
</Card>
