Webhook Authentication
Every webhook delivery from the Business Wallet carries an HMAC SHA-256 signature so your receiver can verify the message has not been tampered with and was sent by us. You can additionally configure an API key header and/or OAuth2 client_credentials to authenticate the sender against your existing infrastructure.
This page walks through each method, with verification samples in Node.js, Python, and Java.
Comparison
| Method | Always on? | When to use | Security characteristics |
|---|---|---|---|
| HMAC SHA-256 signature | Yes — emitted on every webhook delivery | Default integrity + authenticity check for any webhook receiver. Recommended for all customers. | Verifies the body has not been tampered with and proves the sender knows the shared secret. Includes a timestamp (t=) so receivers can detect replay attacks. Two-secret rotation is supported: a previous secret remains valid for a one-hour grace window. |
| API key | Optional — configured per channel | Lightweight authentication for receivers that already gate inbound traffic on a custom header. Often paired with HMAC. | The shared key is sent verbatim in a custom HTTP header. Prefer pairing with HMAC; an API key alone proves nothing about the body. Rotate the key any time by updating the channel. |
| OAuth2 client_credentials | Optional — configured per channel | Receivers that already accept Bearer tokens minted by their own identity provider (Auth0, Azure AD, Okta, Keycloak, …). | The wallet fetches a token from the receiver's token endpoint using client_id + client_secret + optional scope, then attaches Authorization: Bearer <token> to each delivery. Tokens are cached server-side until 60 seconds before expiry. The credentials never leave the wallet; only short-lived bearer tokens cross the network. |
Combining methods. HMAC is always on. API key and OAuth2 are independent and may both be enabled on the same channel; in that case the request carries both the API-key header and the Authorization: Bearer … header in addition to the HMAC signature.
HMAC SHA-256 signature
Every outbound delivery includes the header:
X-Credenco-Signature: t=<unix-epoch-seconds>,v1=<hex-sha256>
tis the time the signature was generated, in seconds since the Unix epoch.v1is the lower-case hexadecimal representation ofHMAC_SHA256(secret, "<t>.<raw-body>").
The string that is signed is exactly t, then a literal ., then the raw HTTP body (no re-serialization). When you verify, do not parse the JSON first — operate on the bytes you received over the wire.
Verification recipe
- Read the
X-Credenco-Signatureheader. - Split on
,and parse thet=andv1=parts. - Reject the request if
tis more than 5 minutes older or newer than your server clock — this prevents replay attacks. - Compute
expected = hex(HMAC_SHA256(secret, t + "." + body)). - Compare
expectedwith the receivedv1using a constant-time comparison. Never use==on strings here — that leaks timing information. - Return
2xxif everything matches; otherwise401(or silently drop).
Dual-secret rotation
When you rotate a webhook secret in the wallet UI, the previous secret remains valid for one hour. During that grace window the wallet will sign every delivery with the new secret, but you should accept either signature. After the grace window expires, only the new secret is valid.
Implement rotation by holding two secrets on the receiver side: current and previous. Compute the expected signature with each, and accept the request if either matches.
Replay protection
The t timestamp is part of the signed string, so an attacker cannot tamper with it without invalidating the signature. Reject requests where |now - t| > 300 seconds to refuse replays of an old, captured request.
Node.js (pure crypto)
const crypto = require('crypto');
function verifyCredencoSignature(rawBody, header, secrets, toleranceSeconds = 300) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.trim().split('=')),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - t);
if (skew > toleranceSeconds) return false;
const signed = `${t}.${rawBody}`;
const v1Buf = Buffer.from(v1, 'hex');
// Try every known secret (current + previous, during rotation grace window).
return secrets.some((secret) => {
const expected = crypto
.createHmac('sha256', secret)
.update(signed)
.digest();
return (
expected.length === v1Buf.length &&
crypto.timingSafeEqual(expected, v1Buf)
);
});
}
In an Express handler, capture the raw body (e.g. via express.raw({ type: 'application/json' })) and pass req.body.toString('utf8') as rawBody.
Python
import hashlib
import hmac
import time
def verify_credenco_signature(raw_body: bytes, header: str, secrets: list[str], tolerance: int = 300) -> bool:
if not header:
return False
parts = dict(part.strip().split("=", 1) for part in header.split(","))
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > tolerance:
return False
signed = f"{t}.".encode("utf-8") + raw_body
for secret in secrets:
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, v1):
return True
return False
In a Flask app, read request.get_data() (which preserves the raw body) and pass it as raw_body.
Java (JDK 17+)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
public final class CredencoSignatureVerifier {
private static final long TOLERANCE_SECONDS = 300L;
public static boolean verify(byte[] rawBody, String header, List<String> secrets) throws Exception {
if (header == null) {
return false;
}
long t = -1L;
String v1 = null;
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
switch (kv[0]) {
case "t" -> t = Long.parseLong(kv[1]);
case "v1" -> v1 = kv[1];
}
}
if (t < 0 || v1 == null) return false;
if (Math.abs(Instant.now().getEpochSecond() - t) > TOLERANCE_SECONDS) {
return false;
}
byte[] signedPrefix = (t + ".").getBytes(StandardCharsets.UTF_8);
byte[] received = HexFormat.of().parseHex(v1);
for (String secret : secrets) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update(signedPrefix);
byte[] expected = mac.doFinal(rawBody);
if (MessageDigest.isEqual(expected, received)) {
return true;
}
}
return false;
}
}
MessageDigest.isEqual performs a constant-time comparison since JDK 6u17.
API key
When configured, the wallet attaches a static value to a custom HTTP header on every delivery. You choose the header name (often X-API-Key) and the value when you create the channel. The wallet stores the value encrypted at rest and never returns it through the API.
Use API keys for receivers that already gate inbound traffic on a custom header, or as a second factor alongside HMAC. An API key alone proves nothing about the body — always combine it with HMAC verification.
To rotate, edit the channel and replace the value. Unlike the HMAC secret, API-key rotation is immediate — there is no grace window. Coordinate with the receiver before rotating.
OAuth2 client_credentials
For receivers that already accept Bearer tokens minted by their own identity provider (Auth0, Azure AD, Okta, Keycloak, …), the wallet can perform an OAuth2 client_credentials exchange and attach the resulting token to each delivery as Authorization: Bearer <token>.
Configuration on the channel:
| Field | Description |
|---|---|
| Token URL | The receiver's OAuth2 token endpoint, e.g. https://login.example.com/oauth/token. |
| Client ID | The OAuth2 client identifier the wallet uses. |
| Client secret | The matching client secret. Stored encrypted at rest; never returned by the API. |
| Scope (optional) | A space-separated list of scopes to request. |
The wallet caches issued access tokens in memory until 60 seconds before their expires_in deadline, then transparently fetches a fresh token. There is no token-refresh round trip on every delivery, and tokens never leave the wallet — only the resulting Bearer header crosses the network.
If the token endpoint returns 4xx, the delivery is marked FAILED immediately (it is not retried) and the response body excerpt is captured for diagnosis. Transient 5xx and network errors are retried with the standard back-off.
:::tip Layer auth methods HMAC, API key, and OAuth2 are independent. A common, conservative configuration is HMAC + OAuth2: HMAC proves the body is intact and was minted with the shared secret, while the Bearer token authenticates the wallet against your existing identity infrastructure. :::
Where to next
- Event catalog — the events you will receive on a webhook.
- CloudEvents envelope — what the headers and body look like.
- Integrations overview — channels, permissions, and delivery semantics.