Agent skill
Wave Webhooks Skill
Receive and verify Wave (waveapps.com) webhooks. Use when setting up Wave webhook handlers, debugging x-wave-signature verification, or handling accounting events like invoice.paid, invoice.sent, invoice.overdue, estimate.accepted, or checkout.paid.
Install this skill
npx skills add hookdeck/webhook-skills --skill wave-webhooks
When to Use This Skill
- How do I receive Wave webhooks?
- How do I verify the
x-wave-signatureheader? - How do I handle
invoice.paid,invoice.overdue,estimate.accepted, orcheckout.paidevents? - Why is my Wave webhook signature verification failing?
- Why does a retried Wave webhook fail the 5-minute timestamp check?
Essential Code (USE THIS)
Wave sends two headers on every webhook:
x-wave-signature: t=<unix seconds>,v1=<hex>x-wave-timestamp: <unix seconds>(the same timestamp as a string; it isn't signed, so verify withtfrom the signature header)
v1 is HMAC-SHA256 (hex) of "<t>.<raw request body>", keyed with the webhook secret as UTF-8. The event name is event_type in the JSON body (there is no event header).
Verification (Node.js)
const crypto = require('crypto');
function safeEqual(a, b) {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyWave(rawBody, headers, secret) {
const header = headers['x-wave-signature'];
if (!secret || !header) return false;
// "t=1714409916,v1=<hex>"
const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split(/=(.*)/s).slice(0, 2)));
if (!parts.t || !parts.v1) return false;
// Optional freshness check, off by default (see "Timestamp window" below)
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.`).update(rawBody).digest('hex');
return safeEqual(parts.v1.toLowerCase(), expected);
}
Verification (Python)
import hashlib, hmac
def verify_wave(raw_body: bytes, headers, secret: str) -> bool:
header = headers.get("x-wave-signature")
if not secret or not header:
return False
parts = dict(kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv)
if "t" not in parts or "v1" not in parts:
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"].lower(), expected)
Return 400 for a bad signature. Wave doesn't retry 4xx responses, and 10 consecutive failed deliveries suspend the subscription, so test with a real delivery before going live.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window (off by default)
Wave's guide says to reject a timestamp more than 5 minutes from now. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries (and anything delivered after downtime). The examples therefore skip the check unless you set WAVE_ to a positive number of seconds (300 matches Wave's guidance). Use event_id for idempotency instead. references/verification.md shows exactly where to drop or widen the window.
Common Event Types
| Event | Description |
|---|---|
invoice.sent | An invoice was sent to a customer |
invoice.viewed | A customer viewed an invoice (not fired when the owner views it while logged in) |
invoice.paid | An invoice was paid in full |
invoice.partially_paid | A partial payment was made |
invoice.overdue | An invoice passed its due date unpaid |
invoice.approved | An invoice was approved |
estimate.sent | An estimate was sent |
estimate.accepted | A customer accepted an estimate |
checkout.paid | A Wave Checkout payment completed |
Wave also documents invoice.overpaid and estimate.approved, .viewed, .reset, .expired, .paid, .partially_paid, .overpaid. See references/overview.md.
For the full event reference, see Wave Webhooks Setup Guide.
Important Headers
| Header | Description |
|---|---|
x-wave-signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
x-wave-timestamp | Timestamp as a string; same value as t |
Environment Variables
WAVE_WEBHOOK_SECRET=your_webhook_secret # Wave's guide doesn't say where it is shown; see references/setup.md
WAVE_TIMESTAMP_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 wave --path /webhooks/wave
Reference Materials
- references/overview.md - Wave webhook concepts, events, payloads, retries
- references/setup.md - Pro plan, app, OAuth scopes, endpoint configuration
- references/verification.md - Signature details and the timestamp window