Verify webhooks
Check the signature of a delivery before you trust what it says.
Verify a delivery
import com.sun.net.httpserver.HttpExchange;import com.sun.net.httpserver.HttpHandler;import java.io.IOException;import java.nio.charset.StandardCharsets;import java.util.Map;import uk.openemail.OpenEmail;import uk.openemail.exception.WebhookSignatureException; public final class WebhookHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { String rawBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); String secret = System.getenv("OPENEMAIL_WEBHOOK_SECRET"); int status = 204; try { Map<String, Object> event = OpenEmail.verifyWebhookSignature(rawBody, exchange.getRequestHeaders(), secret); System.out.println(event.get("type") + " " + event.get("id")); } catch (WebhookSignatureException error) { status = 400; } exchange.sendResponseHeaders(status, -1); exchange.close(); }}OpenEmail.verifyWebhookSignature reads the X-OpenEmail-Signature header, checks its HMAC over the timestamp and the body in constant time, and returns the event as a Map<String, Object>. The secret is the one client.webhooks().create returned.
The headers are a map with names in any case and text or a list of text as each value, so exchange.getRequestHeaders() and the HttpHeaders of Spring fit as they are. The value of the header itself works too.
Pass the raw body, exactly as it arrived. A body that was parsed and encoded again no longer matches its signature.
Old deliveries
String signature = "t=1767225600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd";Map<String, String> headers = Map.of("X-OpenEmail-Signature", signature); try { OpenEmail.verifyWebhookSignature("{}", headers, System.getenv("OPENEMAIL_WEBHOOK_SECRET"), Duration.ofMinutes(1));} catch (WebhookSignatureException error) { System.err.println(error.getMessage());}A delivery more than five minutes old is refused, so a captured request cannot be replayed later. A Duration as the last argument changes the five minutes, and Duration.ZERO accepts a delivery of any age.
- A missing or malformed header, a signature that does not match and a delivery that is too old all throw
WebhookSignatureException. - After a secret is rotated, a delivery can carry more than one signature, and any one that matches passes.
- Answer with a 2xx status quickly. A delivery that gets any other answer is tried again later.