Agent skill

Podium Webhooks Skill

Receive and verify Podium webhooks. Use when setting up Podium webhook handlers, debugging podium-signature verification (hex HMAC-SHA256 over podium-timestamp + "." + raw body), creating the webhook with a secret, or handling message.received, contact.created, review.created, invoice.payment_succeeded and call.completed events.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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

PropertyValue
Signature headerpodium-signature: bare lowercase hex digest (64 chars), no prefix
Timestamp headerpodium-timestamp: Unix milliseconds, e.g. 1620936594591
AlgorithmHMAC-SHA256, hex-encoded
Signed content{podium-timestamp}.{raw request body}
KeyThe webhook's secret as UTF-8 bytes, used as-is (not hex- or base64-decoded)
Replay windowNone 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.

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
}
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/, examples/nextjs/, 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:

EventTriggered When
message.receivedA message arrives for a location
message.sentA message is sent
contact.createdA contact is created
contact.updatedA contact is updated
review.createdA review is posted
invoice.payment_succeededAn invoice payment succeeds
call.completedA 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.

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

Environment Variables

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:

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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →