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-signature header?
  • How do I handle invoice.paid, invoice.overdue, estimate.accepted, or checkout.paid events?
  • 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 with t from 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_TIMESTAMP_TOLERANCE_SECONDS 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

EventDescription
invoice.sentAn invoice was sent to a customer
invoice.viewedA customer viewed an invoice (not fired when the owner views it while logged in)
invoice.paidAn invoice was paid in full
invoice.partially_paidA partial payment was made
invoice.overdueAn invoice passed its due date unpaid
invoice.approvedAn invoice was approved
estimate.sentAn estimate was sent
estimate.acceptedA customer accepted an estimate
checkout.paidA 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

HeaderDescription
x-wave-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
x-wave-timestampTimestamp 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 4, 2026

View on GitHub →