# Podium Webhooks

Podium (podium.com) is a customer messaging, reviews and payments platform for local businesses.
Webhooks are created through the webhook API for one location or organization, and every delivery
of a webhook that has a `secret` is signed with HMAC-SHA256.

## When to Use This Skill

* How do I receive Podium webhooks in Express, Next.js, or FastAPI?
* How do I verify the `podium-signature` header?
* Why does my Podium signature verification fail, or why are my Podium webhooks unsigned?
* How do I handle `message.received`, `contact.created`, `review.created`, `invoice.payment_succeeded` or `call.completed` events?
* Where do I get the Podium webhook secret?

> Set a secret when you create the Podium webhook. Webhooks created without one are unsigned and fail verification.

## Signature at a Glance

| Property | Value |
| --- | --- |
| Signature header | `podium-signature`: bare lowercase hex digest (64 chars), no prefix |
| Timestamp header | `podium-timestamp`: Unix **milliseconds**, e.g. `1620936594591` |
| Algorithm | HMAC-SHA256, hex-encoded |
| Signed content | `{podium-timestamp}.{raw request body}` |
| Key | The webhook's `secret` as UTF-8 bytes, used as-is (not hex- or base64-decoded) |
| Replay window | None by default; opt in with `PODIUM_WEBHOOK_TOLERANCE_SECONDS` |

## Verification (core)

Build the signed message as bytes: the timestamp header string as latin1, a literal `.`, then the
raw body bytes exactly as received. Never `JSON.parse`/`JSON.stringify` first.

```javascript
const crypto = require('crypto');

function verifyPodium(rawBody /* Buffer */, signature, timestamp, secret) {
  if (!secret || !signature || !timestamp) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // validate before decoding
  const signed = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(signed).digest();
  return crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex')); // hex is case-insensitive
}

```

```python
import hashlib, hmac, re

def verify_podium(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if not (secret and signature and timestamp) or not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    signed = b"".join([timestamp.encode("latin-1"), b".", raw_body])
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(expected, bytes.fromhex(signature))

```

Missing or empty headers, a malformed signature or a mismatch all return `401`. A missing secret
returns `500` (fail closed). An unsigned delivery (a webhook created without a secret) has no
`podium-signature` and is rejected.

**Timestamp window:** off by default. Hookdeck retries and replays resend the original headers and
timestamp, and Hookdeck applies no tolerance, so a fixed window would reject legitimate retries.
The examples offer it as opt-in: set `PODIUM_WEBHOOK_TOLERANCE_SECONDS=300` (unset or `0` = off;
the header is in milliseconds and the examples convert). The check runs after the signature check.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/podium-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/podium-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/podium-webhooks/examples/fastapi/).

## HTTP Methods

Podium's docs don't name the HTTP method of a delivery. Hookdeck's Podium source pre-selects POST
without managing the method (you can change it), so any other method the sender uses must also be
allowed on the source. The examples accept GET, POST, PUT, PATCH and DELETE, verify the signature
first for every method, and treat a missing body as empty bytes. Read the body raw:
`express.raw({ type: '*/*' })` (Express), `await request.arrayBuffer()` (Next.js), `await request.body()` (FastAPI).

## Common Event Types

The body is `{ "data": {...}, "metadata": { "event_type": "...", "version": "..." } }`. Dispatch on
`metadata.event_type`. Podium documents 26 event types; the examples handle these seven:

| Event | Triggered When |
| --- | --- |
| `message.received` | A message arrives for a location |
| `message.sent` | A message is sent |
| `contact.created` | A contact is created |
| `contact.updated` | A contact is updated |
| `review.created` | A review is posted |
| `invoice.payment_succeeded` | An invoice payment succeeds |
| `call.completed` | A call completes |

Others include `message.failed`, `contact.deleted`, `contact.merged`, `review.updated`,
`invoice.created`, `invoice.payment_failed`, `call.missed` and `call.voicemail_left`; see
[references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/podium-webhooks/references/overview.md).

Respond within 5 seconds (Podium times out after that); acknowledge fast and process asynchronously.

## Environment Variables

```bash
PODIUM_WEBHOOK_SECRET=your_webhook_secret     # the `secret` you set when creating the webhook
PODIUM_WEBHOOK_TOLERANCE_SECONDS=0            # optional replay window; unset or 0 = off

```

## Local Development

Receive webhooks locally with the Hookdeck CLI, no account required:

```bash
npx hookdeck-cli listen 3000 podium --path /webhooks/podium

```

Use the printed URL as the webhook `url` when you create the webhook (use port `8000` for FastAPI).

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/podium-webhooks/references/overview.md) - What Podium webhooks are, events, payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/podium-webhooks/references/setup.md) - Create the webhook with a secret, Hookdeck source note
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/podium-webhooks/references/verification.md) - Signature details, byte-exact signing, replay window, debugging