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