Verifying Signatures
Every call is signed with ECDSA P-256 and SHA-256 (ES256) so you can check that it comes from DEON and was not changed on the way.
| Header | Example |
|---|---|
Webhook-Id | 5b0c8d4e-… (the deliveryId) |
Webhook-Timestamp | 1758622500 (Unix seconds) |
Webhook-Signature | es256;kid=webhooks-1;MEUCIQ… |
The signed content is {Webhook-Id}.{Webhook-Timestamp}.{raw request body}, UTF-8 encoded. The signature is the
base64-encoded 64-byte r||s value (IEEE P1363, the format used by JOSE).
Public keys
GET https://api.deon.cloud/api/v2/webhooks/keys
No authentication is needed. The response is a JSON Web Key Set:
{
"keys": [
{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…", "kid": "webhooks-1", "use": "sig", "alg": "ES256" }
]
}
Cache the keys for at most 5 minutes and load them again when a signature names a kid you do not know. A new
key is published at least 5 minutes before it is used for signing, and during a rotation the set contains both
the old and the new key.
Checks
To accept a call:
- Find the key whose
kidmatches the signature header and verify the signature over the raw body, before parsing it. - Reject timestamps more than 5 minutes away from your clock.
- Check that
projectIdandwebhookIdin the body are the ones you registered. The same key signs calls for all projects. - Ignore
deliveryIds you already processed.
C#
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
static bool Verify(string id, string timestamp, string signatureHeader, string rawBody, JsonWebKey[] keys)
{
var parts = signatureHeader.Split(';');
if (parts.Length != 3 || parts[0] != "es256") return false;
var jwk = keys.FirstOrDefault(k => "kid=" + k.Kid == parts[1]);
if (jwk is null) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return false;
using var key = ECDsa.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = Base64Url.DecodeFromChars(jwk.X), Y = Base64Url.DecodeFromChars(jwk.Y) },
});
return key.VerifyData(Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}"),
Convert.FromBase64String(parts[2]), HashAlgorithmName.SHA256);
}
record JsonWebKey(string Kid, string X, string Y);
Node.js
import crypto from 'node:crypto';
function verify(headers, rawBody, jwks) {
const [alg, kidPart, signature] = headers['webhook-signature'].split(';');
const jwk = jwks.keys.find((k) => `kid=${k.kid}` === kidPart);
if (alg !== 'es256' || !jwk) return false;
const timestamp = Number(headers['webhook-timestamp']);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const key = crypto.createPublicKey({ key: jwk, format: 'jwk' });
return crypto.verify(
'sha256',
Buffer.from(`${headers['webhook-id']}.${timestamp}.${rawBody}`),
{ key, dsaEncoding: 'ieee-p1363' },
Buffer.from(signature, 'base64'),
);
}
Python
import base64
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
def b64url(value):
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def verify(headers, raw_body: bytes, jwks) -> bool:
alg, kid_part, signature = headers["Webhook-Signature"].split(";")
jwk = next((k for k in jwks["keys"] if f"kid={k['kid']}" == kid_part), None)
timestamp = headers["Webhook-Timestamp"]
if alg != "es256" or jwk is None or abs(time.time() - int(timestamp)) > 300:
return False
key = ec.EllipticCurvePublicNumbers(
int.from_bytes(b64url(jwk["x"]), "big"),
int.from_bytes(b64url(jwk["y"]), "big"),
ec.SECP256R1(),
).public_key()
raw = base64.b64decode(signature)
der = utils.encode_dss_signature(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))
try:
key.verify(der, f"{headers['Webhook-Id']}.{timestamp}.".encode() + raw_body, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
return False