Docs

Verify webhook signatures

Check that received events are genuinely sent from Paddle by verifying webhook signatures. This helps you be sure they haven't been tampered with in-transit.

All webhooks sent by Paddle include a Paddle-Signature header. Paddle generates this header using a secret key that only you and Paddle know.

To verify, you can use the secret key to generate your own signature for each webhook. Since only you and Paddle know the secret key, if both signatures match then you can be sure that a received event came from Paddle.

Before you begin

Create a notification destination where the type is url (webhook), if you haven't already.

Use an AI agent

Get your endpoint secret key

To verify webhooks, you'll need to get the secret key for your notification destination.

Paddle generates a secret key for each notification destination that you create. If you've created more than one notification destination, get keys for each notification destination that you want to verify signatures for.

  1. Go to Paddle > Developer tools > Notifications.
  2. Click the button next to a notification destination in the list, then choose Edit destination from the menu.
  3. Click the copy icon in the secret key field to copy it.

Illustration showing the edit destination screen. The secret key field is spotlighted.

Send a GET request to the /notification-settings/{notification_setting_id} endpoint. If successful, Paddle returns the notification destination settings, including the endpoint_secret_key.

GET /notification-settings/{notification_setting_id}
Response
{
"data": {
"id": "ntfset_01gkpjp8bkm3tm53kdgkx6sms7",
"description": "Slack notifications",
"type": "url",
"endpoint_secret_key": "pdl_ntfset_01gkpjp8bkm3tm53kdgkx6sms7_6h3qd3uFSi9YCD3OLYAShQI90XTI5vEI",
"destination": "https://hooks.slack.com/example",
"active": true,
"api_version": 1,
"include_sensitive_fields": false,
"traffic_source": "all",
"subscribed_events": [
{
"name": "transaction.billed",
"description": "Occurs when a transaction is billed.",
"group": "Transaction",
"available_versions": [1]
},
{
"name": "transaction.canceled",
"description": "Occurs when a transaction is canceled.",
"group": "Transaction",
"available_versions": [1]
},
{
"name": "transaction.completed",
"description": "Occurs when a transaction is completed.",
"group": "Transaction",
"available_versions": [1]
},
{
"name": "transaction.created",
"description": "Occurs when a transaction is created.",
"group": "Transaction",
"available_versions": [1]
},
{
"name": "transaction.payment_failed",
"description": "Occurs when a payment fails for a transaction.",
"group": "Transaction",
"available_versions": [1]
},
{
"name": "transaction.ready",
"description": "Occurs when a transaction is ready.",
"group": "Transaction",
"available_versions": [1]
},
{
"name": "transaction.updated",
"description": "Occurs when a transaction is updated.",
"group": "Transaction",
"available_versions": [1]
},
{
"name": "subscription.activated",
"description": "Occurs when a subscription is activated.",
"group": "Subscription",
"available_versions": [1]
},
{
"name": "subscription.canceled",
"description": "Occurs when a subscription is canceled.",
"group": "Subscription",
"available_versions": [1]
},
{
"name": "subscription.created",
"description": "Occurs when a subscription is created.",
"group": "Subscription",
"available_versions": [1]
},
{
"name": "subscription.past_due",
"description": "Occurs when a subscription is past due.",
"group": "Subscription",
"available_versions": [1]
},
{
"name": "subscription.paused",
"description": "Occurs when a subscription is paused.",
"group": "Subscription",
"available_versions": [1]
},
{
"name": "subscription.resumed",
"description": "Occurs when a subscription is resumed.",
"group": "Subscription",
"available_versions": [1]
},
{
"name": "subscription.trialing",
"description": "Occurs when a subscription is trialing.",
"group": "Subscription",
"available_versions": [1]
},
{
"name": "subscription.updated",
"description": "Occurs when a subscription is updated.",
"group": "Subscription",
"available_versions": [1]
}
]
},
"meta": {
"request_id": "c45900af-c48b-46cd-a917-126e433feb21"
}
}

Response

For example:

Ways that you can verify

Verify webhook signatures in one of two ways:

Verify using Paddle SDKs Recommended

Use our official SDKs to verify webhook signatures. You'll need to provide the event payload, Paddle-Signature header, and the endpoint secret key.

You can use a middleware to verify the signature of an incoming request before processing it.

Go
verifier := paddle.NewWebhookVerifier(os.Getenv("WEBHOOK_SECRET_KEY"))
// Wrap your handler with the verifier.Middleware method
handler := verifier.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The request making it this far means the webhook was verified
// Best practice here is to check if you have processed this webhook already using the event id
// At this point you should store for async processing
// For example a local queue or db entry
// Respond as soon as possible with a 200 OK
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success": true}`))
}))

You can also verify the signature of an incoming request manually.

Go
webhookVerifier := paddle.NewWebhookVerifier(os.Getenv("WEBHOOK_SECRET_KEY"))
// Note: the request (req *http.Request) should be passed exactly as it comes without altering it.
ok, err := webhookVerifier.Verify(req)

Learn more and clone @PaddleHQ/paddle-go-sdk on GitHub

TypeScript
import { Paddle, EventName } from "@paddle/paddle-node-sdk";
import express, { Request, Response } from "express";
const paddle = new Paddle("API_KEY");
const app = express();
// Create a `POST` endpoint to accept webhooks sent by Paddle.
// We need `raw` request body to validate the integrity. Use express raw middleware to ensure express doesn't convert the request body to JSON.
app.post(
"/webhooks",
express.raw({ type: "application/json" }),
async (req: Request, res: Response) => {
const signature = (req.headers["paddle-signature"] as string) || "";
// req.body should be of type `buffer`, convert to string before passing it to `unmarshal`.
// If express returned a JSON, remove any other middleware that might have processed raw request to object
const rawRequestBody = req.body.toString();
// Replace `WEBHOOK_SECRET_KEY` with the secret key in notifications from vendor dashboard
const secretKey = process.env["WEBHOOK_SECRET_KEY"] || "";
try {
if (signature && rawRequestBody) {
// The `unmarshal` function will validate the integrity of the webhook and return an entity
const eventData = await paddle.webhooks.unmarshal(
rawRequestBody,
secretKey,
signature,
);
switch (eventData.eventType) {
case EventName.ProductUpdated:
console.log(`Product ${eventData.data.id} was updated`);
break;
case EventName.SubscriptionUpdated:
console.log(`Subscription ${eventData.data.id} was updated`);
break;
default:
console.log(eventData.eventType);
}
} else {
console.log("Signature missing in header");
}
} catch (e) {
// Handle signature mismatch or other runtime errors
console.log(e);
}
// Return a response to acknowledge
res.send("Processed webhook event");
},
);
app.listen(3000);

Learn more and clone @PaddleHQ/paddle-node-sdk on GitHub

PHP
use Paddle\SDK\Notifications\Secret;
use Paddle\SDK\Notifications\Verifier;
(new Verifier())->verify(
$request,
new Secret('WEBHOOK_SECRET_KEY')
);

Learn more and clone @PaddleHQ/paddle-php-sdk on GitHub

Python
from paddle_billing.Notifications import Secret, Verifier
integrity_check = Verifier().verify(request, Secret('WEBHOOK_SECRET_KEY'))

Learn more and clone @PaddleHQ/paddle-python-sdk on GitHub

Verify manually

Get Paddle-Signature header

First, get the Paddle-Signature header from an incoming webhook sent by Paddle.

All webhook events sent by Paddle include a Paddle-Signature header. For example:

text
ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151

Signatures include two parts, separated by a semicolon:

ts string required

Timestamp as a Unix timestamp.

h1 string required

Webhook event signature. Signatures contain at least one h1. We may add support for secret rotation in the future. During secret rotation, more than one h1 is returned while secrets are rotated out.

Extract timestamp and signature from header

Now you have the Paddle-Signature header, parse it to extract the timestamp (ts) and signature values (h1).

You can do this by splitting using a semicolon character (;) to get elements, then splitting again using an equals sign character (=) to get key-value pairs.

Build signed payload

Paddle creates a signature by first concatenating the timestamp (ts) with the body of the request, joined with a colon (:).

Build your own signed payload by concatenating:

  • The extracted timestamp (ts) +
  • A colon (:) +
  • The raw body of the request

Hash signed payload

Next, hash your signed payload to generate a signature.

Paddle generates signatures using a keyed-hash message authentication code (HMAC) with SHA256 and a secret key.

Compute the HMAC of your signed payload using the SHA256 algorithm, using the secret key for this notification destination as the key.

This should give you the expected signature of the webhook event.

Compare signatures

Finally, compare the signature within the Paddle-Signature header (the value of h1) to the signature you just computed in the previous step.

If they don't match, you should reject the webhook event. Someone may be sending malicious requests to your webhook endpoint.

Complete examples

These code snippets demonstrate how to verify webhooks manually.

Node.js (Express)
import express, { Request, Response } from "express";
import { createHmac, timingSafeEqual } from "crypto";
import dotenv from "dotenv";
dotenv.config();
const app = express();
// Create a `POST` endpoint to accept webhooks sent by Paddle.
// We need `raw` request body to validate the integrity. Use express raw middleware to ensure express doesn't convert the request body to JSON.
app.post(
"/webhooks",
express.raw({ type: "application/json" }),
async (req: Request, res: Response): Promise<any> => {
try {
// (Optional) Check if the request body is a buffer
// This is to ensure that the request body is not converted to JSON by any middleware
if (!Buffer.isBuffer(req.body)) {
console.error("Request body is not a buffer");
return res.status(500).json({ error: "Server misconfigured" });
}
// 1. Get Paddle-Signature header
const paddleSignature = req.headers["paddle-signature"] as string;
const secretKey = process.env.PADDLE_WEBHOOK_SECRET_KEY;
// (Optional) Check if header and secret key are present and return error if not
if (!paddleSignature) {
console.error("Paddle-Signature not present in request headers");
return res.status(400).json({ error: "Invalid request" });
}
if (!secretKey) {
console.error("Secret key not defined");
return res.status(500).json({ error: "Server misconfigured" });
}
// 2. Extract timestamp and signature from header
if (!paddleSignature || !paddleSignature.includes(";")) {
console.error("Invalid Paddle-Signature format");
return res.status(400).json({ error: "Invalid request" });
}
const parts = paddleSignature.split(";");
if (parts.length !== 2) {
console.error("Invalid Paddle-Signature format");
return res.status(400).json({ error: "Invalid request" });
}
const [timestampPart, signaturePart] = parts.map(
(part) => part.split("=")[1],
);
if (!timestampPart || !signaturePart) {
console.error(
"Unable to extract timestamp or signature from Paddle-Signature header",
);
return res.status(400).json({ error: "Invalid request" });
}
const timestamp = timestampPart;
const signature = signaturePart;
// (Optional) Check timestamp against current time and reject if it's over 5 seconds old
const timestampInt = parseInt(timestamp) * 1000;
if (isNaN(timestampInt)) {
console.error("Invalid timestamp format");
return res.status(400).json({ error: "Invalid request" });
}
const currentTime = Date.now();
if (currentTime - timestampInt > 5000) {
console.error(
"Webhook event expired (timestamp is over 5 seconds old):",
timestampInt,
currentTime,
);
return res.status(408).json({ error: "Event expired" });
}
// 3. Build signed payload
const bodyRaw = req.body.toString(); // Converts from buffer to string
const signedPayload = `${timestamp}:${bodyRaw}`;
// 4. Hash signed payload using HMAC SHA256 and the secret key
const hashedPayload = createHmac("sha256", secretKey)
.update(signedPayload, "utf8")
.digest("hex");
// 5. Compare signatures
if (
!timingSafeEqual(Buffer.from(hashedPayload), Buffer.from(signature))
) {
console.error("Computed signature does not match Paddle signature");
return res.status(401).json({ error: "Invalid signature" });
}
// 6. Process the webhook event
console.log("Webhook Data:", req.body); // Replace with own handling of webhook
res.status(200).json({ success: true });
} catch (error) {
const errorMessage = (error as Error).message;
console.error(
"Failed to verify and process Paddle webhook",
errorMessage,
);
res
.status(500)
.json({ error: "Failed to verify and process Paddle webhook" });
}
},
);
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
Node.js (Next.js)
import { NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "crypto";
export async function POST(request: Request) {
try {
// 1. Get Paddle-Signature header
const headers = request.headers;
const paddleSignature = headers.get("paddle-signature");
const secretKey = process.env.PADDLE_WEBHOOK_SECRET_KEY;
// (Optional) Check if header and secret key are present and return error if not
if (!paddleSignature) {
console.error("Paddle-Signature not present in request headers");
return NextResponse.json({ message: "Invalid request" }, { status: 400 });
}
if (!secretKey) {
console.error("Secret key not defined");
return NextResponse.json(
{ message: "Server misconfigured" },
{ status: 500 },
);
}
// 2. Extract timestamp and signature from header
if (!paddleSignature.includes(";")) {
console.error("Invalid Paddle-Signature format");
return NextResponse.json({ message: "Invalid request" }, { status: 400 });
}
const parts = paddleSignature.split(";");
if (parts.length !== 2) {
console.error("Invalid Paddle-Signature format");
return NextResponse.json({ message: "Invalid request" }, { status: 400 });
}
const [timestampPart, signaturePart] = parts.map(
(part) => part.split("=")[1],
);
if (!timestampPart || !signaturePart) {
console.error(
"Unable to extract timestamp or signature from Paddle-Signature header",
);
return NextResponse.json({ message: "Invalid request" }, { status: 400 });
}
const timestamp = timestampPart;
const signature = signaturePart;
// (Optional) Check timestamp against current time and reject if it's over 5 seconds old
const timestampInt = parseInt(timestamp) * 1000; // Convert seconds to milliseconds
if (isNaN(timestampInt)) {
console.error("Invalid timestamp format");
return NextResponse.json({ message: "Invalid request" }, { status: 400 });
}
const currentTime = Date.now();
if (currentTime - timestampInt > 5000) {
console.error(
"Webhook event expired (timestamp is over 5 seconds old):",
timestampInt,
currentTime,
);
return NextResponse.json({ message: "Event expired" }, { status: 408 });
}
// 3. Build signed payload
const bodyRaw = await request.text();
const signedPayload = `${timestamp}:${bodyRaw}`;
// 4. Hash signed payload using HMAC SHA256 and the secret key
const hashedPayload = createHmac("sha256", secretKey)
.update(signedPayload, "utf8")
.digest("hex");
// 5. Compare signatures
if (!timingSafeEqual(Buffer.from(hashedPayload), Buffer.from(signature))) {
console.error("Computed signature does not match Paddle signature");
return NextResponse.json(
{ message: "Invalid signature" },
{ status: 401 },
);
}
// 6. Process the webhook event
const bodyJson = JSON.parse(bodyRaw);
console.log("Webhook Data:", bodyJson); // Replace with own handling of webhook
return NextResponse.json({ message: "Success" }, { status: 200 });
} catch (error) {
console.error(
"Failed to verify and process Paddle webhook",
(error as Error).message,
);
return NextResponse.json(
{ message: "Failed to verify and process Paddle webhook" },
{ status: 500 },
);
}
}
Ruby
require 'sinatra'
require 'openssl'
require 'json'
require 'time'
require 'dotenv'
# Load environment variables
Dotenv.load
# This allows testing in development (i.e. with a tunnel solution) without having to specify a host
configure :development do
set :host_authorization, { permitted_hosts: [] }
puts "HostAuthorization protection disabled for development."
end
post '/webhooks' do
begin
# 1. Get Paddle-Signature header
paddle_signature = request.env['HTTP_PADDLE_SIGNATURE']
if paddle_signature.nil? || paddle_signature.empty?
puts "Paddle-Signature not present in request headers"
halt 400, { error: 'Invalid request' }.to_json
end
secret_key = ENV['PADDLE_WEBHOOK_SECRET_KEY']
if secret_key.nil? || secret_key.empty?
puts "Secret key not defined"
halt 500, { error: 'Server misconfigured' }.to_json
end
# 2. Extract timestamp and signature from header
parts = paddle_signature.split(';')
if parts.length != 2
puts "Invalid Paddle-Signature format"
halt 400, { error: 'Invalid request' }.to_json
end
timestamp = parts[0].split('=')[1]
signature = parts[1].split('=')[1]
if timestamp.nil? || signature.nil?
puts "Unable to extract timestamp or signature from Paddle-Signature header"
halt 400, { error: 'Invalid request' }.to_json
end
# (Optional) Check timestamp against current time and reject if it's over 5 seconds old
event_time = timestamp.to_i
current_time = Time.now.to_i
if current_time - event_time > 5
puts "Webhook event expired (timestamp is over 5 seconds old): #{event_time}, #{current_time}"
halt 408, { error: 'Event expired' }.to_json
end
request.body.rewind
body_raw = request.body.read
# 3. Build signed payload
signed_payload = "#{timestamp}:#{body_raw}"
# 4. Hash signed payload using HMAC SHA256 and the secret key
computed_hash = OpenSSL::HMAC.hexdigest('SHA256', secret_key, signed_payload)
# 5. Compare signatures
unless Rack::Utils.secure_compare(computed_hash, signature)
puts "Computed signature does not match Paddle signature"
halt 401, { error: 'Invalid signature' }.to_json
end
# 6. Process the webhook event
puts "Webhook Data: #{body_raw}" # Replace with own handling of webhook
{ success: true }.to_json
rescue => e
puts "Failed to verify and process Paddle webhook: #{e.message}"
halt 500, { error: 'Failed to verify and process Paddle webhook' }.to_json
end
end
# Start Sinatra server
port = ENV['PORT'] || 3000
set :port, port
puts "Server is running on port http://localhost:#{port}"
Python
from flask import Flask, request, jsonify
import hashlib
import hmac
import time
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
app = Flask(__name__)
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
try:
# 1. Get Paddle-Signature header
paddle_signature = request.headers.get('Paddle-Signature')
if not paddle_signature:
print("Paddle-Signature not present in request headers")
return jsonify({"error": "Invalid request"}), 400
secret_key = os.getenv('PADDLE_WEBHOOK_SECRET_KEY')
if not secret_key:
print("Secret key not defined")
return jsonify({"error": "Server misconfigured"}), 500
# 2. Extract timestamp and signature from header
signature_parts = paddle_signature.split(';')
if len(signature_parts) != 2:
print("Invalid Paddle-Signature format")
return jsonify({"error": "Invalid request"}), 400
timestamp = signature_parts[0].split('=')[1]
signature = signature_parts[1].split('=')[1]
if not timestamp or not signature:
print("Unable to extract timestamp or signature from Paddle-Signature header")
return jsonify({"error": "Invalid request"}), 400
# (Optional) Check timestamp against current time and reject if it's over 5 seconds old
event_time = int(timestamp)
current_time = int(time.time())
if current_time - event_time > 5:
print("Webhook event expired (timestamp is over 5 seconds old):", event_time, current_time)
return jsonify({"error": "Event expired"}), 408
# We need `raw` request body to validate the integrity. Use as_text=True to ensure it's not converted to JSON.
body_raw = request.get_data(as_text=True)
# 3. Build signed payload
signed_payload = f"{timestamp}:{body_raw}"
# 4. Hash signed payload using HMAC SHA256 and the secret key
computed_hash = hmac.new(secret_key.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
# 5. Compare signatures
if not hmac.compare_digest(computed_hash, signature):
print("Computed signature does not match Paddle signature")
return jsonify({"error": "Invalid signature"}), 401
# 6. Process the webhook event
print(f"Webhook Data: {body_raw}") # Replace with own handling of webhook
return jsonify({"success": True})
except Exception as e:
print("Failed to verify and process Paddle webhook", str(e))
return jsonify({"error": "Failed to verify and process Paddle webhook"}), 500
if __name__ == '__main__':
app.run(debug=True, port=3000)
Java
// WebhookController.java
// Replace 'your.package.name' with the actual package name
package your.package.name;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebhookController {
private final WebhookHandler webhookHandler;
public WebhookController(WebhookHandler webhookHandler) {
this.webhookHandler = webhookHandler;
}
@PostMapping("/webhook")
public ResponseEntity<Object> handleWebhook(
@RequestBody String body, // Extract raw body from the request
@RequestHeader("Paddle-Signature") String paddleSignature) { // Extract the signature from the header
return webhookHandler.handleWebhook(body, paddleSignature); // Pass the signature and body to WebhookHandler
}
}
// WebhookHandler.java
// Replace 'your.package.name' with the actual package name
package your.package.name;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import io.github.cdimascio.dotenv.Dotenv;
import org.springframework.stereotype.Service;
import java.util.logging.Logger;
import org.springframework.http.ResponseEntity;
@Service
public class WebhookHandler {
private static final Logger logger = Logger.getLogger(WebhookHandler.class.getName());
private final Dotenv dotenv;
public WebhookHandler() {
this.dotenv = Dotenv.load();
}
// Method to handle webhook verification and processing
public ResponseEntity<Object> handleWebhook(String bodyRaw, String paddleSignature) {
try {
// 1. Verify Paddle-Signature header
if (paddleSignature == null || paddleSignature.isEmpty()) {
logger.severe("Paddle-Signature not present in request headers");
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid request"));
}
String secretKey = dotenv.get("PADDLE_WEBHOOK_SECRET_KEY");
if (secretKey == null || secretKey.isEmpty()) {
logger.severe("Secret key not defined");
return ResponseEntity.status(500).body(new ErrorResponse("Server misconfigured"));
}
// 2. Extract timestamp and signature from header
String[] signatureParts = paddleSignature.split(";");
if (signatureParts.length != 2) {
logger.severe("Invalid Paddle-Signature format");
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid request"));
}
String timestamp = null;
String signature = null;
for (String part : signatureParts) {
String[] keyValue = part.split("=");
if (keyValue.length != 2) {
logger.severe("Invalid Paddle-Signature part format");
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid request"));
}
if ("ts".equals(keyValue[0])) {
timestamp = keyValue[1];
} else if ("h1".equals(keyValue[0])) {
signature = keyValue[1];
}
}
if (timestamp == null || signature == null) {
logger.severe("Unable to extract timestamp or signature from Paddle-Signature header");
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid request"));
}
// (Optional) Check timestamp against current time and reject if it's over 5 seconds old
long eventTime;
try {
eventTime = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
logger.severe("Invalid timestamp format in Paddle-Signature header");
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid request"));
}
long currentTime = System.currentTimeMillis() / 1000;
if (currentTime - eventTime > 5) {
logger.severe("Webhook event expired: Event time is over 5 seconds old");
return ResponseEntity.status(408).body(new ErrorResponse("Event expired"));
}
// 3. Build signed payload
String signedPayload = timestamp + ":" + bodyRaw;
// 4. Hash signed payload using HMAC SHA256 and the secret key
String computedHash = computeHMACSHA256(signedPayload, secretKey);
// 5. Compare signatures
if (!timingSafeEquals(computedHash.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8))) {
logger.severe("Computed signature does not match Paddle signature");
return ResponseEntity.status(401).body(new ErrorResponse("Invalid signature"));
}
// 6. Process the webhook event
logger.info("Webhook Data: " + bodyRaw); // Replace with your own handling of the webhook
return ResponseEntity.ok().body(new SuccessResponse("Webhook processed successfully"));
} catch (Exception e) {
logger.severe("Error processing webhook: " + e.getMessage());
return ResponseEntity.status(500).body(new ErrorResponse("Failed to verify and process webhook"));
}
}
private String computeHMACSHA256(String data, String key) throws Exception {
try {
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256_HMAC.init(secretKey);
byte[] hash = sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new Exception("HMAC SHA256 algorithm not available", e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
private boolean timingSafeEquals(byte[] a, byte[] b) {
if (a.length != b.length) {
return false;
}
int result = 0;
for (int i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result == 0;
}
public static class ErrorResponse {
private String error;
public ErrorResponse(String error) {
this.error = error;
}
public String getError() {
return error;
}
}
public static class SuccessResponse {
private String message;
public SuccessResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
}
C#
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
[ApiController]
[Route("webhooks")]
public class PaddleWebhookController : ControllerBase
{
private readonly ILogger<PaddleWebhookController> _logger;
public PaddleWebhookController(ILogger<PaddleWebhookController> logger)
{
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> HandleWebhook()
{
try
{
Request.EnableBuffering();
// 1. Get Paddle-Signature header
if (!Request.Headers.TryGetValue("Paddle-Signature", out var paddleSignature) || string.IsNullOrEmpty(paddleSignature))
{
_logger.LogError("Paddle-Signature not present in request headers");
return BadRequest(new { error = "Invalid request" });
}
var secretKey = Environment.GetEnvironmentVariable("PADDLE_WEBHOOK_SECRET_KEY");
if (string.IsNullOrEmpty(secretKey))
{
_logger.LogError("Secret key not defined");
return StatusCode(500, new { error = "Server misconfigured" });
}
// 2. Extract timestamp and signature from header
var parts = paddleSignature.ToString().Split(";");
if (parts.Length < 2)
{
_logger.LogError("Invalid Paddle-Signature format");
return BadRequest(new { error = "Invalid request" });
}
var timestamp = parts[0].Split("=")[1];
var signature = parts[1].Split("=")[1];
// (Optional) Check timestamp against current time and reject if it's over 5 seconds old
if (!long.TryParse(timestamp, out var timestampInt))
{
_logger.LogError("Invalid timestamp format");
return BadRequest(new { error = "Invalid request" });
}
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var eventTime = timestampInt * 1000; // Convert seconds to milliseconds
if (currentTime - eventTime > 5000)
{
_logger.LogError("Webhook event expired (timestamp is over 5 seconds old):");
_logger.LogError("Event time: {0}", eventTime);
_logger.LogError("Current time: {0}", currentTime);
return StatusCode(408, new { error = "Event expired" });
}
// We need `raw` request body to validate the integrity. Use StreamReader to ensure the request body isn't converted to JSON.
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var bodyRaw = await reader.ReadToEndAsync();
Request.Body.Position = 0;
// 3. Build signed payload
var signedPayload = $"{timestamp}:{bodyRaw}";
// 4. Hash signed payload using HMAC SHA256 and the secret key
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var computedSignature = BitConverter.ToString(computedHash).Replace("-", "").ToLower();
// 5. Compare signatures
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(computedSignature), Encoding.UTF8.GetBytes(signature)))
{
_logger.LogError("Computed signature does not match Paddle signature");
return Unauthorized(new { error = "Invalid signature" });
}
// 6. Process the webhook event
_logger.LogInformation("Webhook Data: {0}", bodyRaw); // Replace with own handling of webhook
return Ok(new { success = true });
}
catch (Exception ex)
{
_logger.LogError("Failed to verify and process Paddle webhook", ex.Message);
return StatusCode(500, new { error = "Failed to verify and process webhook" });
}
}
}

Test signature verification

You can send a simulated webhook to test that your webhook signature verification is working.

Webhook simulator sends an exact replica of a webhook request, including the Paddle-Signature header, to the URL you provide.

Was this page helpful?