Skip to content

Signature Verification ​

Verify the authenticity of webhook notifications using Ed25519 signatures.

Overview ​

All webhook notifications from the Notifications API are signed using Ed25519 digital signatures. You should verify these signatures to ensure:

  1. The notification came from PayNetWorx
  2. The notification content was not tampered with
  3. The notification is not a replay of an old message

Signature Header Format ​

Every webhook request includes an X-Webhook-Signature header with the following format:

X-Webhook-Signature: t=1704067200,kid=webhook-key-v1,v1=base64signature==

Header Components ​

ComponentDescription
tUnix timestamp (seconds) when the signature was generated
kidKey identifier for looking up the public key in JWKS
v1Base64-encoded Ed25519 signature

Key Rotation

During key rotation, the header may contain multiple signatures (one for each active key). Your verification code should check if any of the signatures are valid.


Get JWKS ​

Retrieve the JSON Web Key Set containing the public keys for signature verification. This endpoint is on the Notifications API domain and does not require authentication.

Request ​

GET https://api.notifications.paynetworx.cloud/.well-known/jwks.json

curl Example ​

bash
curl -X GET "https://api.notifications.paynetworx.cloud/.well-known/jwks.json"

Response ​

json
{
  "keys": [
    {
      "kty": "OKP",
      "use": "sig",
      "kid": "webhook-key-v1",
      "crv": "Ed25519",
      "x": "base64url-encoded-public-key"
    }
  ]
}

Response Fields ​

FieldDescription
keysArray of JSON Web Keys
ktyKey type (OKP for Ed25519)
useKey usage (sig for signature)
kidKey ID - matches the kid in the signature header
crvCurve type (Ed25519)
xPublic key (Base64URL encoded)

Verification Process ​

Step 1: Parse the Signature Header ​

Extract the components from the X-Webhook-Signature header:

t=1704067200,kid=webhook-key-v1,v1=base64signature==

Parse this into:

  • timestamp: 1704067200
  • keyId: webhook-key-v1
  • signature: base64signature== (Base64 decode this)

Step 2: Reconstruct the Signed Payload ​

The signed payload is constructed as:

{timestamp}.{raw_request_body}

For example, if the timestamp is 1704067200 and the body is {"event":"test"}:

1704067200.{"event":"test"}

Important

Use the raw request body exactly as received, without parsing or reformatting the JSON. Any modification to the body will cause signature verification to fail.

Step 3: Fetch the Public Key ​

Fetch the JWKS from /.well-known/jwks.json and find the key matching the kid from the signature header.

Caching

Cache the JWKS response to avoid fetching it for every webhook. Refresh the cache:

  • Periodically (e.g., every hour)
  • When you encounter an unknown kid

Step 4: Verify the Signature ​

Using the Ed25519 public key, verify the signature against the reconstructed payload.

Step 5: Check Timestamp Freshness ​

To prevent replay attacks, verify that the timestamp is recent (within 5 minutes is recommended):

javascript
const tolerance = 300; // 5 minutes in seconds
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > tolerance) {
  throw new Error('Signature timestamp is too old or in the future');
}

Code Examples ​

TypeScript ​

typescript
import crypto from 'crypto';

interface SignatureParts {
  t: string;
  kid: string;
  v1: string;
}

interface JWK {
  kty: string;
  kid: string;
  crv: string;
  x: string;
}

interface JWKS {
  keys: JWK[];
}

async function verifyWebhook(signatureHeader: string, rawBody: string): Promise<boolean> {
  // Parse the signature header
  const parts: Partial<SignatureParts> = {};
  signatureHeader.split(',').forEach(part => {
    const [key, value] = part.split('=');
    parts[key as keyof SignatureParts] = value;
  });

  const timestamp = parseInt(parts.t!, 10);
  const keyId = parts.kid!;
  const signature = Buffer.from(parts.v1!, 'base64');

  // Check timestamp freshness (5 minute tolerance)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) {
    throw new Error('Signature timestamp is too old');
  }

  // Fetch JWKS (cache this in production)
  const jwksResponse = await fetch('https://api.notifications.paynetworx.cloud/.well-known/jwks.json');
  const jwks: JWKS = await jwksResponse.json();

  // Find the matching key
  const jwk = jwks.keys.find(k => k.kid === keyId);
  if (!jwk) {
    throw new Error('Unknown key ID');
  }

  // Import the Ed25519 public key
  const publicKey = crypto.createPublicKey({
    key: {
      kty: jwk.kty,
      crv: jwk.crv,
      x: jwk.x
    },
    format: 'jwk'
  });

  // Reconstruct the signed payload
  const signedPayload = `${timestamp}.${rawBody}`;

  // Verify the signature
  const isValid = crypto.verify(
    null, // Ed25519 doesn't use a separate hash algorithm
    Buffer.from(signedPayload),
    publicKey,
    signature
  );

  return isValid;
}

Security Best Practices ​

  1. Always verify signatures - Never process webhook payloads without verification
  2. Check timestamp freshness - Reject notifications with timestamps more than 5 minutes old
  3. Cache JWKS - Avoid fetching JWKS for every request; refresh periodically
  4. Handle key rotation - During rotation, multiple keys may be active; verify against all
  5. Use the raw body - Verify against the exact bytes received, not parsed JSON
  6. Use HTTPS - Ensure your webhook endpoint uses HTTPS
  7. Respond quickly - Return a 2xx response within a few seconds to avoid retries

Troubleshooting ​

Signature Verification Failed ​

  1. Check the payload: Ensure you're verifying against the raw request body exactly as received
  2. Verify timestamp format: The timestamp should be Unix seconds, not milliseconds
  3. Check Base64 encoding: The signature (v1) uses standard Base64, while the JWK public key (x field) uses Base64URL encoding
  4. Confirm the key: Ensure you're using the correct public key matching the kid

Unknown Key ID ​

The JWKS may have been rotated. Refresh your cached JWKS and try again. If the problem persists, the key may have been deprecated.

Timestamp Too Old ​

If notifications are consistently arriving with old timestamps:

  • Check if your server's clock is synchronized (use NTP)
  • Ensure you're processing webhooks promptly
  • Consider increasing the timestamp tolerance if network latency is high

Connection Issues ​

If you can't reach the JWKS endpoint:

  • Ensure your server can make outbound HTTPS requests
  • Check firewall rules for the PayNetWorx API domain
  • Implement retry logic with exponential backoff