# Guide to Podium Webhooks: Features and Best Practices

Podium webhooks tell your systems what's happening with a business's customers on Podium: a message arrives or fails to send, a contact is created or merged, a review is posted, a call is missed, or an invoice is paid. Podium provides messaging, reviews and payments for local businesses, and webhooks let partners and in-house teams react to that activity as it happens.

This guide covers how Podium webhooks are created and signed, the event types, how Podium queues events, and the practices that keep a Podium receiver reliable.

## What are Podium webhooks?

A Podium webhook belongs to exactly one location or organization and lists the event types it receives. You create webhooks only through the webhook API, with a token granted by a user of that location or organization. Each event type is tied to an API scope, so the token needs the scopes for every type you subscribe to.

The `secret` is optional when you create a webhook, and it decides whether deliveries are signed. Podium says "any webhook with no `secret` will not be signed". With a secret, each delivery carries `podium-timestamp` and `podium-signature`, an HMAC-SHA256 of the timestamp, a `.`, and the raw body.

## Podium webhook features

| Feature | Details |
| --- | --- |
| Configuration | The webhook API resource only |
| Scope | Exactly one location or organization per webhook |
| Permissions | A token from a user of that location or organization, with the scopes for the event types |
| Signature header | `podium-signature`. 64 hex characters in Podium's screenshots; the encoding isn't stated in text |
| Timestamp header | `podium-timestamp`, Unix milliseconds |
| Scheme | HMAC-SHA256 |
| Signed content | `<timestamp>.<raw body>` |
| Secret | Optional, set when you create the webhook. No secret means no signature |
| Payload | `{ data, metadata: { event_type, version } }` |
| Response timeout | 5 seconds |
| Queue | Events queue from creation, even while disabled, and expire after about 10 days |
| HTTP method | Not stated in Podium's docs |
| Retries | No schedule documented |
| Source IPs | Not published |

## Common events

Event types take the form `API_OBJECT.ACTION`. Podium's webhook object lists 26:

| Area | Event types |
| --- | --- |
| Messages | `message.received`, `message.sent`, `message.failed` |
| Contacts | `contact.created`, `contact.updated`, `contact.deleted`, `contact.merged`, `contact.unchanged` |
| Reviews | `review.created`, `review.updated`, `review.response_created`, `review.response_updated`, `review.invite_link_created`, `review.invite_link_updated` |
| Calls | `call.received`, `call.completed`, `call.missed`, `call.voicemail_left` |
| Invoices | `invoice.created`, `invoice.disabled`, `invoice.marked_as_paid`, `invoice.payment_created`, `invoice.payment_succeeded`, `invoice.payment_failed`, `invoice.refund_created`, `invoice.refund_failed` |

`message.failed` means Podium accepted a message request but couldn't send it to the customer. `contact.unchanged` fires when you try to create or update a contact but it, or the change, already exists. Podium's pages don't describe the call and invoice types beyond their names.

Podium doesn't publish a full example event. The only body in its docs is the illustration used for signing:

```json
{"data":{"bar":"apples","foo":"1"},"metadata":{"event_type":"test.event","version":"2021.04.01"}}

```

It shows the envelope: the object in `data`, and the event type and a version in `metadata`. Route on `metadata.event_type`.

## Setting up Podium webhooks

1. Get an access token from a user of the location or organization, with the scopes for the event types you need.
2. Create a webhook through the webhook API with your `url`, the `eventTypes`, the `locationUid` or `organizationUid`, and a `secret`. Choose a long random value for the secret and store it, for example as `PODIUM_WEBHOOK_SECRET`.
3. Start your receiver promptly. Podium begins queueing events as soon as the webhook exists, even if it's disabled.

**With Hookdeck.** Podium is a supported source type in Hookdeck. Set a secret when you create the Podium webhook: webhooks created without one are unsigned and fail verification. Paste the same secret into a Hookdeck source of the Podium type and use the Hookdeck source URL as the webhook's `url`. The source checks `podium-signature` against `<podium-timestamp>.<raw body>`. It pre-selects `POST` without managing the method. Podium's docs don't say which method deliveries use, so if the request log shows another method, allow it on the source as well, because the source only accepts the methods you allow. A request that fails the check still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Podium webhooks

Join the `podium-timestamp` header, a literal `.`, and the raw body bytes. Compute an HMAC-SHA256 with the secret as a UTF-8 string and compare it with `podium-signature` in constant time.

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

function verifyPodiumSignature(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false; // unsigned webhooks fail
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false; // validate before decoding

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

// Optional replay check, off by default (0). podium-timestamp is in MILLISECONDS.
const TOLERANCE_SECONDS = Number(process.env.PODIUM_WEBHOOK_TOLERANCE_SECONDS || 0);

const app = express();

app.all('/webhooks/podium', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PODIUM_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const timestamp = req.get('podium-timestamp');
  if (!verifyPodiumSignature(req.body, req.get('podium-signature'), timestamp, secret)) {
    return res.sendStatus(401);
  }
  if (TOLERANCE_SECONDS > 0 && Math.abs(Date.now() - Number(timestamp)) / 1000 > TOLERANCE_SECONDS) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.metadata.event_type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_podium_signature(raw_body: bytes, signature, timestamp, secret: str) -> bool:
    if not signature or not timestamp or not secret:
        return False
    if not HEX_64.fullmatch(signature):
        return False
    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature), expected)

```

Podium describes the scheme in steps and screenshots rather than code. The screenshots show a 64-character lowercase hex `podium-signature`, which is what the samples above and Hookdeck's Podium source expect. Podium doesn't recommend a replay window. If you add one, remember the timestamp is in milliseconds, and that Hookdeck applies no timestamp tolerance and its retries and replays resend the original headers.

> **Make Podium webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `podium-signature` header, answers Podium well inside its 5-second timeout, and stores every message, review and invoice event until your service has processed it.

## Podium webhook limitations and pain points

### Unsigned unless you set a secret

**The Problem:** Deliveries arrive with no `podium-signature`, so you can't prove they came from Podium.

**Why It Happens:** The `secret` is optional when you create a webhook, and a webhook without one is never signed.

**Workarounds:**

* Always create webhooks with a secret, and reject requests that lack the signature.
* If an existing webhook has no secret, create a new one with a secret and delete the old one.

**How Hookdeck Can Help:** Hookdeck's Podium source requires the signature, so unsigned requests are recorded as failed verification and never reach your service.

### A 5-second timeout and an undocumented retry policy

**The Problem:** Slow handlers cause failed deliveries, and it isn't clear when Podium tries again.

**Why It Happens:** Podium says responses time out after 5 seconds. It describes a queue that holds events for around 10 days but doesn't publish a retry schedule.

**Workarounds:**

* Return `2xx` as soon as the signature checks out, then process asynchronously.
* If your endpoint is down for a long time, plan to backfill from the API, because events older than about 10 days expire from the queue.

**How Hookdeck Can Help:** Hookdeck answers Podium immediately and stores each delivery, then delivers to your service with [automatic retries](/docs/retries) on a schedule you control, so a slow handler or an outage doesn't run into Podium's limits.

### Disabled webhooks still fill the queue

**The Problem:** Re-enabling a webhook releases a backlog of old events, or events are lost after a long pause.

**Why It Happens:** Podium adds events to a webhook's queue from the moment it's created, even while it's disabled, and keeps them for about 10 days.

**Workarounds:**

* Expect a burst after re-enabling, and make handlers idempotent.
* Don't leave a webhook disabled for more than 10 days if you need its events.

**How Hookdeck Can Help:** Hookdeck can [rate limit](/docs/destinations#set-a-max-delivery-rate) delivery to your service, so a backlog arrives at a pace your service can handle.

### Sparse payload documentation

**The Problem:** It's hard to write a handler for an event you haven't seen.

**Why It Happens:** Podium publishes no example event bodies, and the call and invoice event types have no description pages.

**Workarounds:**

* Subscribe a test location and capture real events before writing parsing code.
* Treat fields as optional and log unknown event types instead of failing.

**How Hookdeck Can Help:** Hookdeck stores every request with its full body and headers, so you can inspect real Podium events and replay them against your handler while you build it.

## Best practices

### Verify against the raw body

The signature covers the body exactly as sent. Use `express.raw()` in Express, `await request.arrayBuffer()` in a Next.js route handler, and `await request.body()` in FastAPI.

### Route on `metadata.event_type`

One webhook can carry many event types. Switch on `metadata.event_type`, and answer `2xx` to types you don't handle so they don't count as failures.

### Mind the millisecond timestamp

`podium-timestamp` is in milliseconds, unlike most webhook timestamps. Divide by 1,000 before comparing it with a clock in seconds.

### Deduplicate

Podium doesn't document an event ID. Build a key from `metadata.event_type`, the object's ID in `data` and `podium-timestamp`, and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Verify, store the event, return `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Podium signs webhooks with `podium-signature`, an HMAC-SHA256 of `<podium-timestamp>.<raw body>`, but only when the webhook was created with a secret. Always set one, verify against the raw bytes, remember the timestamp is in milliseconds, route on `metadata.event_type`, and answer within 5 seconds.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Podium signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Podium webhooks reliably in minutes.