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
Treat your endpoint secret key like a password. Keep it safe and never share it with apps or people you don't trust.
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.
- Go to Paddle > Developer tools > Notifications.
- Click the button next to a notification destination in the list, then choose Edit destination from the menu.
- Click the copy icon in the secret key field to copy it.
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.
/notification-settings/{notification_setting_id} { "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 helper functions or classes in our official SDKs to verify webhook signatures. - Verify manually
Build your own logic to verify webhook signatures.
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.
Don't transform or process the raw body of the request, including adding whitespace or applying other formatting. This results in a different signed payload, meaning signatures won't match when you compare.
You can use a middleware to verify the signature of an incoming request before processing it.
verifier := paddle.NewWebhookVerifier(os.Getenv("WEBHOOK_SECRET_KEY"))// Wrap your handler with the verifier.Middleware methodhandler := 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.
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
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
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
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
To prevent replay attacks, our SDK helper methods check the timestamp (ts) against the current time and reject events that are too old. The default tolerance between the timestamp and the current time is five seconds.
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:
ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151Signatures include two parts, separated by a semicolon:
Timestamp as a Unix timestamp.
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.
To prevent replay attacks, you may like to check the timestamp (ts) against the current time and reject events that are too old. Our SDKs have a default tolerance of five seconds between the timestamp and the current time.
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
Don't transform or process the raw body of the request, including adding whitespace or applying other formatting. This results in a different signed payload, meaning signatures won't match when you compare.
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.
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}`);});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 }, ); }}require 'sinatra'require 'openssl'require 'json'require 'time'require 'dotenv'
# Load environment variablesDotenv.load
# This allows testing in development (i.e. with a tunnel solution) without having to specify a hostconfigure :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 endend
# Start Sinatra serverport = ENV['PORT'] || 3000
set :port, portputs "Server is running on port http://localhost:#{port}"from flask import Flask, request, jsonifyimport hashlibimport hmacimport timeimport osfrom dotenv import load_dotenv
# Load environment variables from .env fileload_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)// WebhookController.java// Replace 'your.package.name' with the actual package namepackage 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;
@RestControllerpublic 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 namepackage 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;
@Servicepublic 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; } }}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.