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-signatureheader? - 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_orsucceeded call.completedevents? - 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_ |
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_ (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_. 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_ | 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.
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
- references/overview.md - What Podium webhooks are, events, payload shape
- references/setup.md - Create the webhook with a secret, Hookdeck source note
- references/verification.md - Signature details, byte-exact signing, replay window, debugging