Gareth Wilson Gareth Wilson

Guide to Wave Webhooks: Features and Best Practices

Published


Wave webhooks tell your application when money and paperwork move in a Wave business: an invoice is sent, viewed, paid or goes overdue, an estimate is accepted, or a Wave Checkout payment completes. If you build on Wave, the accounting and invoicing platform for small businesses, webhooks are how those changes reach your CRM, fulfilment system or reporting without polling the Public API.

This guide covers how Wave webhooks are configured and signed, the events and their payloads, the plan and permission rules that decide whether anything is delivered at all, and the practices that keep a Wave receiver healthy in production.

What are Wave webhooks?

Wave webhooks belong to a developer application rather than to a single business. You create an app, register one endpoint URL for it, and choose which events it listens to. Wave then sends a JSON POST for each matching event in every business that has connected to your app through OAuth.

Two conditions decide whether a business's events reach you. The business must be on a Wave Pro subscription, and its owner must have granted your app the OAuth scope that the event needs. If either is missing, Wave skips delivery for that business without telling you.

Every request carries an x-wave-signature header of the form t=<timestamp>,v1=<signature>. The signature is a hex HMAC-SHA256 of the timestamp, a period and the raw body, keyed with your webhook secret.

Wave webhook features

FeatureDetails
ConfigurationManage Applications to create the app, then the Webhooks page to set the endpoint and events
ScopeOne endpoint per app, receiving events from every connected Pro business
Plan requirementThe connected business must be on Wave Pro. Starter and Standard businesses get no deliveries
PermissionsEach event needs an OAuth scope (invoice:read, estimate:read or checkout:read, or the * form)
Signature headerx-wave-signature: t=<unix seconds>,v1=<hex>
Timestamp headerx-wave-timestamp, the same timestamp as a string
SchemeHMAC-SHA256, lowercase hex
Signed content<t>.<raw request body>
Replay windowWave asks receivers to reject timestamps more than 5 minutes old
Endpoint rulesPublic HTTPS, CA-signed certificate, TLS 1.2 or higher, no private or internal networks
Retries5xx, timeouts and connection errors: up to 6 attempts over about 14 hours
Permanent failures4xx responses and SSL errors are not retried
Auto-suspendAfter 10 consecutive delivery failures
ToolingLogs & deliveries shows each payload and your response, and can re-trigger an event

Wave does not publish source IP addresses for webhook traffic, so the signature is how you know a request came from Wave.

Common events

Wave documents 17 events, all listed in the OAuth scope table of its Webhooks Setup Guide, and says more are coming.

AreaEventsMinimum scope
Invoicesinvoice.sent, invoice.viewed, invoice.approved, invoice.paid, invoice.partially_paid, invoice.overpaid, invoice.overdueinvoice:read or invoice:*
Estimatesestimate.sent, estimate.viewed, estimate.approved, estimate.accepted, estimate.reset, estimate.expired, estimate.paid, estimate.partially_paid, estimate.overpaidestimate:read or estimate:*
Checkoutscheckout.paidcheckout:read or checkout:*

invoice.viewed and estimate.viewed do not fire when someone views the document while logged in to the business that created it, so testing them from your own account produces nothing.

Every payload has the same four top-level keys: event_id, event_type, business_id and data. The event name is in event_type; there is no event header. Here is Wave's invoice.paid example:

{
  "business_id": "63c77f29-875c-4cdc-947d-07430f628043",
  "data": {
    "amount_paid": "8.98",
    "currency_code": "USD",
    "customer_id": "21840161",
    "invoice_id": "2496756670638588934",
    "paid_date": "2026-04-29",
    "remaining_balance": "0.00"
  },
  "event_id": "2f210c44-f1ab-551e-89fa-333fb8d2a5fe",
  "event_type": "invoice.paid"
}

The keys in data change from event to event. invoice.overdue carries invoice_balance and due_date, the estimate payment events carry estimate_total_amount, deposit_amount_paid and remaining_deposit_balance, and checkout.paid carries the buyer's name, email, phone number and address. Amounts are strings, and on the overpaid events the remaining balance is negative, representing the customer's credit.

The IDs in payloads are raw values. To pass one to Wave's Public API, format it with its business, for example Business:<business_id>;Invoice:<invoice_id>, and base64-encode the result.

Setting up Wave webhooks

  1. Log in to Wave, open Manage Applications and click + Create New Application (or pick an existing app). Enter a name and a redirect URI for OAuth, and save the Client ID and Client Secret.
  2. Make sure your OAuth flow requests the scopes for the events you want. If you add events that need new scopes later, update the flow and ask each user to re-authorise your app.
  3. Open the Webhooks page and select the app. A new app shows Needs Configuration.
  4. Paste your Endpoint URL.
  5. In the Listening tab, tick the events to receive.
  6. Store your webhook secret server-side, for example as WAVE_WEBHOOK_SECRET.

Wave's guide tells you to verify with "your webhook secret" but does not say where in the dashboard that secret is shown, or whether it is the app's Client Secret. Check the app's Webhooks page and its settings under Manage Applications, and ask Wave developer support if you can't find it. Don't assume it is the Client Secret until a real delivery verifies with it.

If you receive through Hookdeck, create a Wave source, use its URL as the endpoint, and paste the same secret into the source's Webhook Signing Secret field.

Securing Wave webhooks

Split x-wave-signature on commas, take t and v1, build the string <t>.<raw body>, compute an HMAC-SHA256 with the secret as a UTF-8 key, encode it as hex, and compare it with v1 in constant time. Use t from the signature header rather than x-wave-timestamp, because only t is part of what was signed.

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

function parseWaveHeader(header) {
  const parts = {};
  for (const pair of header.split(',')) {
    const i = pair.indexOf('=');
    if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
  }
  return parts;
}

function verifyWaveSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !secret) return false;
  const { t, v1 } = parseWaveHeader(signatureHeader);
  if (!t || !v1 || !/^\d+$/.test(t)) return false;

  // Optional freshness check. Off by default: retries and replays carry the original timestamp.
  if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) {
    return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(v1.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

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

  if (!verifyWaveSignature(req.body, req.get('x-wave-signature'), secret)) {
    return res.sendStatus(401);
  }

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

The same check in Python:

import hashlib
import hmac
import time


def verify_wave_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not secret:
        return False
    parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or not t.isdigit():
        return False

    # Optional freshness check, off by default.
    if tolerance_seconds > 0 and abs(int(time.time()) - int(t)) > tolerance_seconds:
        return False

    signed = t.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1.lower().encode("utf-8"), expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_wave_signature(raw_body, request.headers.get("x-wave-signature"),
#                       os.environ["WAVE_WEBHOOK_SECRET"])

Wave's own Python sample rejects any timestamp more than 300 seconds from the current time. The samples above make that check optional and leave it off by default. A queue such as Hookdeck keeps the original headers when it retries or when you replay an event, so a 5-minute window in your receiver would reject legitimate redeliveries that arrive late. If you want replay protection, pass a wider tolerance or deduplicate on event_id, which is covered by the signature along with the rest of the body.

Make Wave webhooks production-ready. Hookdeck Event Gateway verifies the x-wave-signature header, answers Wave straight away, and durably queues every invoice, estimate and checkout event before your service sees it.

Wave webhook limitations and pain points

Events that never arrive

The Problem: The subscription is active and the endpoint works, but some businesses produce no events at all.

Why It Happens: Wave delivers only for businesses on a Pro subscription, and only for events whose OAuth scope the business owner granted. When either is missing, Wave skips the delivery quietly. Adding an event that needs a new scope does nothing for existing users until they re-authorise.

Workarounds:

  • Request every scope your subscribed events need in the OAuth flow from the start.
  • When you add events, prompt existing users to reconnect.
  • Check the business's plan when a user reports missing data, and use the Logs & deliveries filter by business_id to see whether anything was sent.

How Hookdeck Can Help: Hookdeck logs every request it receives, so you can tell at a glance whether Wave sent nothing or sent something your service mishandled.

A 4xx counts against you immediately

The Problem: A short-lived bug in your handler gets the whole subscription suspended.

Why It Happens: Wave treats any 4xx response, and any SSL certificate error, as a permanent failure with no retry. 5xx responses are retried up to 6 times over about 14 hours and count as one failure only after the last attempt. Ten consecutive failures suspend the subscription, and nothing more is delivered until someone reactivates it under Tools > Webhooks.

Workarounds:

  • Return 4xx only when verification fails. For your own errors, return 5xx so Wave retries.
  • Keep your certificate valid and your hostname correct.
  • Check Logs & deliveries for red status tags, and reactivate the subscription once the cause is fixed. Reactivating resets the failure count.

How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives, so Wave sees a healthy endpoint while your service is down or returning errors. Hookdeck then retries delivery to your service on a schedule you set.

The secret's location is undocumented

The Problem: You have working code and no secret to give it.

Why It Happens: Wave's guide says to sign with "your webhook secret" but never says where it is shown. The dashboard's Manage App link leads to the Client ID and Client Secret settings, which may or may not be the same value.

Workarounds:

  • Look on the app's Webhooks page and in its settings, and ask Wave developer support if neither shows it.
  • Confirm the value against a real delivery before you rely on it, using Re-trigger event to resend a known payload.

How Hookdeck Can Help: With a Hookdeck source in front, you can re-trigger a Wave delivery and see in the request log whether it verified, without redeploying your own service.

A short replay window and long retries

The Problem: Following Wave's advice to the letter rejects retries and replays.

Why It Happens: Wave asks receivers to reject timestamps more than 5 minutes old, but its own retries span about 14 hours, and Wave does not say whether a retry is signed with a new timestamp. A replay from a queue keeps the original timestamp.

Workarounds:

  • Leave the timestamp check off or widen it, and rely on event_id for deduplication.
  • If you enforce a window, log the timestamp age on failures so you can tell stale deliveries from bad signatures.

How Hookdeck Can Help: Hookdeck verifies Wave signatures without a timestamp window, so retried and replayed events still pass verification.

Example payloads you can't fully trust

The Problem: Code written against the published examples breaks on real data.

Why It Happens: Some of Wave's examples are placeholders. The invoice.approved example is not valid JSON as published, and the invoice.overdue, invoice.viewed and invoice.approved examples use test-event-id, test-business-id and "123" instead of real-looking IDs.

Workarounds:

  • Don't validate ID formats strictly.
  • Capture one real delivery for each event you handle before finalising your parsing.

How Hookdeck Can Help: Hookdeck stores the full body and headers of every Wave request, so you can inspect real payloads and replay them against your handler while you build it.

Best practices

Verify against the raw body

Wave's guide is explicit that parsing and re-serialising the JSON breaks the signature. In Express, mount express.raw() on the route. In Next.js, read await request.text(). In FastAPI, read await request.body().

Deduplicate on event_id

Every payload has an event_id. Store it when you process an event and skip events you have already seen, so retries and manual re-triggers do no harm. See our guide to webhook idempotency.

Read data defensively

The fields in data depend on the event. Treat amounts as decimal strings and parse them with a decimal type rather than floating point. Expect negative balances on the overpaid events.

Encode IDs before calling the API

To fetch the full invoice or estimate after an event, build the Business:<id>;Invoice:<id> string and base64-encode it. The Logs & deliveries filter, by contrast, takes the raw business_id.

Acknowledge fast and process asynchronously

Wave does not document a response timeout, but it retries network timeouts. Verify, store the event, return 200, and do the work afterwards. See why to process webhooks asynchronously.

Conclusion

Wave signs every webhook with a hex HMAC-SHA256 of <t>.<raw body> in x-wave-signature, and only delivers for Pro businesses that granted the right OAuth scopes. Verify against the raw bytes, keep any timestamp check optional, deduplicate on event_id, return 4xx only for bad signatures, and watch for a suspended subscription after repeated failures.

Hookdeck Event Gateway sits in front of your receiver, checks every Wave signature, and holds each event until your service has processed it, so a bad deploy on your side doesn't count towards Wave's suspension limit.

Get started with Hookdeck for free and handle Wave webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.