Java · Code recipe
Verify a webhook signature in Java
Prove a delivery came from URLpipe before you act on it, in Java 17+ with java.net.http.HttpClient. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.
By Roger Campos · Last updated: September 2026
TL;DR
To verify a URLpipe webhook in Java, compute HMAC-SHA256 over the X-URLpipe-Timestamp value, a dot and the raw request body, keyed with your whsec_ secret. Prefix it with v1= and compare it with MessageDigest.isEqual against each comma-separated value of X-URLpipe-Signature; reject timestamps more than five minutes off.
Free plan, no credit card. 1,000 credits a month.
Your report_to URL accepts a POST from anyone who learns it. Turn on webhook signing for the project and every delivery carries X-URLpipe-Timestamp and X-URLpipe-Signature, so the receiver can prove the body came from URLpipe, unchanged, in the last five minutes.
The Java receiver below does the whole check: it reads the raw body (@RequestBody byte[] in Spring, getRequestBody() in HttpServer), recomputes the HMAC, compares it in constant time with MessageDigest.isEqual, and rejects stale timestamps. To send it a signed test delivery, use the shell script on the cURL page.
Setup
Before you start
Nothing to add: javax.crypto computes the HMAC and the JDK's own com.sun.net.httpserver serves the endpoint, so the receiver runs from source with no jar. Turn signing on under Settings → Webhook Signing and copy the secret (it starts with whsec_).
export URLPIPE_WEBHOOK_SECRET="whsec_your_signing_secret"
Receiver
A receiver that verifies every delivery
verify is the part to copy into your service. In Spring, take the body as @RequestBody byte[] so you verify the bytes that were signed. MessageDigest.isEqual is the constant-time compare; String.equals stops at the first difference and leaks where it was.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class WebhookReceiver {
static final byte[] SECRET = System.getenv("URLPIPE_WEBHOOK_SECRET").getBytes(StandardCharsets.UTF_8);
static final long TOLERANCE_SECONDS = 5 * 60;
// True when the delivery was signed with SECRET in the last five minutes.
static boolean verify(byte[] body, String timestamp, String signatureHeader) {
if (timestamp == null || signatureHeader == null || !timestamp.matches("\\d{1,18}")) {
return false;
}
if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
byte[] digest;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
digest = mac.doFinal(body);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e); // every JDK ships HmacSHA256
}
byte[] expected = ("v1=" + HexFormat.of().formatHex(digest)).getBytes(StandardCharsets.UTF_8);
// One signature normally, two during a secret rotation: accept any match.
for (String signature : signatureHeader.split(",")) {
if (MessageDigest.isEqual(signature.strip().getBytes(StandardCharsets.UTF_8), expected)) {
return true;
}
}
return false;
}
static void handle(HttpExchange exchange) throws IOException {
try {
if (!exchange.getRequestMethod().equals("POST")) {
exchange.sendResponseHeaders(405, -1);
return;
}
// The raw bytes, exactly as sent.
byte[] body = exchange.getRequestBody().readAllBytes();
var headers = exchange.getRequestHeaders();
if (!verify(body, headers.getFirst("X-URLpipe-Timestamp"), headers.getFirst("X-URLpipe-Signature"))) {
exchange.sendResponseHeaders(401, -1);
return;
}
// Verified: hand the body to your JSON library and queue the work.
System.out.println("Verified delivery (" + body.length + " bytes)");
exchange.sendResponseHeaders(200, -1);
} finally {
exchange.close();
}
}
public static void main(String[] args) throws IOException {
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8000"));
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/webhooks/urlpipe", WebhookReceiver::handle);
server.start();
}
}
Run it: java WebhookReceiver.java
Details
What to know about signed deliveries
- Signing is off until you turn it on under Settings → Webhook Signing; the secret starts with
whsec_. Enabling it is safe at any time — the body does not change — so enable it first and deploy the check after. - Rotating the secret opens a 24-hour window in which every delivery carries two signatures, the new one first. That is why the header is a list and any match is accepted.
- Each attempt is signed with a fresh timestamp, so a retry passes the five-minute check like the first delivery did.
- A delivery your endpoint rejects is retried with backoff, up to six attempts over roughly twenty minutes, and can be resent by hand from the dashboard afterwards.
- Make the handler idempotent on
token: the same result can arrive more than once.
Other languages
Verify a webhook signature in another language
More Java: every Java recipe · how signing works, in the docs
FAQ
Frequently asked questions
Why verify against the raw body?
Why can the signature header hold more than one value?
What should a receiver answer when the check fails?
Why a five-minute tolerance?
Get a key and run it.
Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.