Webhooks
Receive webhooks that carry identifiers only, fetch the current state from the API, and verify the optional HMAC-SHA256 signature.
How webhooks work
Swagger describes when Waya sends a webhook, and what it contains, under Webhooks (opens in a new tab). If you do not use webhooks, poll for updates instead.
Payload
Swagger (opens in a new tab) shows an example payload and describes its fields.
IDs are unique only within an entity type, so an invoice and a credit can have the same id. Identify an entity by type and id together.
Entity types
type | The entity, and how to fetch its current state |
|---|---|
INVOICE | An invoice. Use Get an invoice |
CREDIT | A credit. Use Get a credit (opens in a new tab) |
FINANCIAL_REPORT | A financial report. Use Retrieve financial report file |
FINANCIAL_REPORT_BUNDLE | A bundle of financial reports. Use Get a financial report bundle |
Handle a webhook
- Read the request body as bytes, before anything parses it.
- If signing is enabled for your integration, verify the signature. Reject the request, for example with
401, when the check fails. - Read
type,idandcreditorIdfrom the JSON. - Fetch the entity from the API with an authorization token for the right client's account, as the table above shows.
Since a webhook says that an entity changed and not what changed, handling the same entity twice does no harm. Fetch and store the current state each time.
Verify the signature
Swagger describes the two headers and the signed payload under HMAC signing (opens in a new tab).
To verify a webhook:
- Read the
waya-webhook-timestampandwaya-webhook-signatureheaders. If one is missing, or the timestamp is not a whole number, reject the webhook. - Reject a timestamp that is too far from the current time. The samples below allow 300 seconds either way. That tolerance is a recommendation, not a requirement. Keep your server's clock accurate.
- Calculate the HMAC-SHA256 of the signed payload with the signing secret, as a hex string.
- Compare it with
waya-webhook-signaturein constant time, ignoring case. Compare the decoded bytes, or lower-case both strings first. Do not use a plain string comparison, because it can reveal how much of the signature was right.
Use the raw body exactly as received
Calculate the signature over the bytes of the request body as they arrived. If you parse the JSON and serialize it again, the formatting, and with it the signature, may change. Do not trim, decode or re-encode the body either.
Verification code
Each sample is a function that takes the raw body, the value of waya-webhook-timestamp, the value of waya-webhook-signature and the signing secret. It returns true only when the webhook is fresh and the signature is right. It returns false for a missing or malformed timestamp or signature header, for a timestamp that is too old or too new, and for a signature that does not match. Pass the body as bytes, as Get the raw body explains. The functions use only the standard library:
| Language | Uses | Version |
|---|---|---|
| Java | javax.crypto.Mac and java.security.MessageDigest | 17 or later, for HexFormat |
| Node.js | node:crypto | 18 or later |
| Python | hmac and hashlib | 3.10 or later |
| C# | System.Security.Cryptography | .NET 8 |
Keep the signing secret out of your source code, and load it from your configuration or a secret store.
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Locale;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class WayaWebhook {
// How far from now a timestamp may be. 300 seconds is a recommendation, not a requirement.
private static final long TOLERANCE_SECONDS = 300;
/** The lower-case hex HMAC-SHA256 of "<timestamp>.<raw body>". */
static String computeSignature(byte[] rawBody, String timestamp, String secret)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(mac.doFinal(rawBody));
}
static boolean verifyWebhook(
byte[] rawBody, String timestampHeader, String signatureHeader, String secret)
throws GeneralSecurityException {
// The timestamp must be whole Unix seconds. [0-9] rejects signs, spaces and other digits.
if (timestampHeader == null
|| signatureHeader == null
|| !timestampHeader.matches("[0-9]{1,18}")) {
return false;
}
long age = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestampHeader));
if (age > TOLERANCE_SECONDS) {
return false;
}
// Lower-case both sides, because the hex digits may arrive in either case.
byte[] expected =
computeSignature(rawBody, timestampHeader, secret).getBytes(StandardCharsets.UTF_8);
byte[] received = signatureHeader.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8);
// Compare the lengths first, so isEqual only ever sees arrays of the same length.
return expected.length == received.length && MessageDigest.isEqual(expected, received);
}
}
// verify-webhook.mjs, for Node.js 18 or later
import { createHmac, timingSafeEqual } from 'node:crypto';
// How far from now a timestamp may be. 300 seconds is a recommendation, not a requirement.
const TOLERANCE_SECONDS = 300;
// The lower-case hex HMAC-SHA256 of "<timestamp>.<raw body>". rawBody is a Buffer.
export function computeSignature(rawBody, timestamp, secret) {
return createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
}
export function verifyWebhook(rawBody, timestampHeader, signatureHeader, secret) {
// The timestamp must be whole Unix seconds. Check the text, so nothing is parsed leniently.
if (!/^[0-9]+$/.test(timestampHeader ?? '') || typeof signatureHeader !== 'string') {
return false;
}
if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > TOLERANCE_SECONDS) {
return false;
}
// Lower-case both sides, because the hex digits may arrive in either case.
const expected = Buffer.from(computeSignature(rawBody, timestampHeader, secret));
const received = Buffer.from(signatureHeader.toLowerCase());
// timingSafeEqual throws when the lengths differ, so compare them first.
return expected.length === received.length && timingSafeEqual(expected, received);
}
import hashlib
import hmac
import re
import time
# How far from now a timestamp may be. 300 seconds is a recommendation, not a requirement.
TOLERANCE_SECONDS = 300
def compute_signature(raw_body: bytes, timestamp: str, secret: str) -> str:
"""The lower-case hex HMAC-SHA256 of "<timestamp>.<raw body>"."""
message = timestamp.encode() + b"." + raw_body
return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
def verify_webhook(
raw_body: bytes,
timestamp_header: str | None,
signature_header: str | None,
secret: str,
) -> bool:
if not timestamp_header or not signature_header:
return False
# The timestamp must be whole Unix seconds. [0-9] rejects signs, spaces and other digits,
# and the cap of 18 digits keeps int() away from huge numbers, which can raise.
if re.fullmatch(r"[0-9]{1,18}", timestamp_header) is None:
return False
if abs(time.time() - int(timestamp_header)) > TOLERANCE_SECONDS:
return False
# Lower-case both sides, because the hex digits may arrive in either case.
expected = compute_signature(raw_body, timestamp_header, secret).encode()
received = signature_header.lower().encode()
# Compare bytes: compare_digest raises TypeError for a str with non-ASCII characters.
return hmac.compare_digest(expected, received)
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static class WayaWebhook
{
// How far from now a timestamp may be. 300 seconds is a recommendation, not a requirement.
const long ToleranceSeconds = 300;
// The lower-case hex HMAC-SHA256 of "<timestamp>.<raw body>".
public static string ComputeSignature(byte[] rawBody, string timestamp, string secret)
{
using var hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret));
hmac.AppendData(Encoding.UTF8.GetBytes(timestamp + "."));
hmac.AppendData(rawBody);
return Convert.ToHexString(hmac.GetHashAndReset()).ToLowerInvariant();
}
public static bool VerifyWebhook(
byte[] rawBody, string? timestampHeader, string? signatureHeader, string secret)
{
if (timestampHeader is null || signatureHeader is null) return false;
// NumberStyles.None accepts digits only: no sign and no spaces.
if (!long.TryParse(timestampHeader, NumberStyles.None, CultureInfo.InvariantCulture, out var timestamp))
return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > ToleranceSeconds)
return false;
// Lower-case both sides, because the hex digits may arrive in either case.
var expected = Encoding.UTF8.GetBytes(ComputeSignature(rawBody, timestampHeader, secret));
var received = Encoding.UTF8.GetBytes(signatureHeader.ToLowerInvariant());
// FixedTimeEquals takes the same time whatever the contents. Compare the lengths first.
return expected.Length == received.Length && CryptographicOperations.FixedTimeEquals(expected, received);
}
}
Get the raw body
Most frameworks parse a JSON body before your code sees it. Ask for the bytes instead, and pass those to the function:
- Java, Servlet API:
request.getInputStream().readAllBytes(). - Java, Spring MVC: a
byte[]parameter annotated with@RequestBody, not a model class. - Node.js, Express:
express.raw({ type: '*/*' })as middleware on the webhook route, instead ofexpress.json().req.bodyis then aBuffer. Register the route before any globalexpress.json(), or that parser reads the body first. - Python, Flask:
request.get_data(), which returns bytes. - Python, FastAPI:
await request.body(). Use these bytes, not a parsed model. - C#, ASP.NET Core: copy
Request.Bodyinto abyte[]with aMemoryStream, and do not bind the body to a model.
Worked example
Use this example to test your code. The inputs are:
Signing secret: my-signing-secret
waya-webhook-timestamp: 1761987600
Raw body: {"type":"INVOICE","id":"8Xsfuo80StY","creditorId":"toE0QN5RrcM"}
The signed payload is the timestamp, a dot and the raw body:
1761987600.{"type":"INVOICE","id":"8Xsfuo80StY","creditorId":"toE0QN5RrcM"}
The signature is the HMAC-SHA256 of that text, keyed by the signing secret, as a hex string:
waya-webhook-signature: c254bfa11383a389193731f3afbaa9cbbf4357f60a4610461f54dcbe471e6d31
You can reproduce it with OpenSSL. It prints the same hex string after a label:
printf '%s' '1761987600.{"type":"INVOICE","id":"8Xsfuo80StY","creditorId":"toE0QN5RrcM"}' \
| openssl dgst -sha256 -hmac 'my-signing-secret'
Check these things in your own code:
computeSignature, or its counterpart in your language, returns the signature above for these inputs.- The signature changes if you change one byte of the body, the timestamp or the secret.
- Upper-case hex digits in
waya-webhook-signatureare accepted. - A timestamp that is not a whole number is rejected without an exception.
The timestamp 1761987600 is 1 November 2025, 09:00:00 UTC, so a function that checks freshness rejects this example as stale. To test the whole function, pin the clock to the timestamp in your test, or sign a message with the current time and send that.
Static headers and fields
Static headers and fields (opens in a new tab) in Swagger describes what Waya can add to every webhook. If you verify the raw body, as above, the static fields are covered without any extra code, but do not expect a body of only type, id and creditorId.
Verifying signatures and timestamps is one of the items on the go-live checklist. For the webhooks that announce new reports, see Reports and reconciliation.