Skip to main content

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.

HeaderExample
Webhook-Id5b0c8d4e-… (the deliveryId)
Webhook-Timestamp1758622500 (Unix seconds)
Webhook-Signaturees256;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:

  1. Find the key whose kid matches the signature header and verify the signature over the raw body, before parsing it.
  2. Reject timestamps more than 5 minutes away from your clock.
  3. Check that projectId and webhookId in the body are the ones you registered. The same key signs calls for all projects.
  4. 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