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

typeThe entity, and how to fetch its current state
INVOICEAn invoice. Use Get an invoice
CREDITA credit. Use Get a credit (opens in a new tab)
FINANCIAL_REPORTA financial report. Use Retrieve financial report file
FINANCIAL_REPORT_BUNDLEA bundle of financial reports. Use Get a financial report bundle

Handle a webhook

  1. Read the request body as bytes, before anything parses it.
  2. If signing is enabled for your integration, verify the signature. Reject the request, for example with 401, when the check fails.
  3. Read type, id and creditorId from the JSON.
  4. 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:

  1. Read the waya-webhook-timestamp and waya-webhook-signature headers. If one is missing, or the timestamp is not a whole number, reject the webhook.
  2. 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.
  3. Calculate the HMAC-SHA256 of the signed payload with the signing secret, as a hex string.
  4. Compare it with waya-webhook-signature in 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:

LanguageUsesVersion
Javajavax.crypto.Mac and java.security.MessageDigest17 or later, for HexFormat
Node.jsnode:crypto18 or later
Pythonhmac and hashlib3.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);
  }
}

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 of express.json(). req.body is then a Buffer. Register the route before any global express.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.Body into a byte[] with a MemoryStream, 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:

Shell
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-signature are 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.