Appearance
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:
- The notification came from PayNetWorx
- The notification content was not tampered with
- 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
| Component | Description |
|---|---|
t | Unix timestamp (seconds) when the signature was generated |
kid | Key identifier for looking up the public key in JWKS |
v1 | Base64-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.jsoncurl 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
| Field | Description |
|---|---|
keys | Array of JSON Web Keys |
kty | Key type (OKP for Ed25519) |
use | Key usage (sig for signature) |
kid | Key ID - matches the kid in the signature header |
crv | Curve type (Ed25519) |
x | Public 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:1704067200keyId:webhook-key-v1signature: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
- Always verify signatures - Never process webhook payloads without verification
- Check timestamp freshness - Reject notifications with timestamps more than 5 minutes old
- Cache JWKS - Avoid fetching JWKS for every request; refresh periodically
- Handle key rotation - During rotation, multiple keys may be active; verify against all
- Use the raw body - Verify against the exact bytes received, not parsed JSON
- Use HTTPS - Ensure your webhook endpoint uses HTTPS
- Respond quickly - Return a 2xx response within a few seconds to avoid retries
Troubleshooting
Signature Verification Failed
- Check the payload: Ensure you're verifying against the raw request body exactly as received
- Verify timestamp format: The timestamp should be Unix seconds, not milliseconds
- Check Base64 encoding: The signature (
v1) uses standard Base64, while the JWK public key (xfield) uses Base64URL encoding - 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
