diff --git a/config/mariadb/initdb.d/init.sql b/config/mariadb/initdb.d/init.sql
index ce52aaa3d6cf2e48b9a71e024c573703505ebbc1..a99c760ec6f197cff457e006af6353a91b1d1635 100644
--- a/config/mariadb/initdb.d/init.sql
+++ b/config/mariadb/initdb.d/init.sql
@@ -38,3 +38,15 @@ CREATE TABLE IF NOT EXISTS `PrivateProjectEvent` (
 );
 
 ALTER TABLE CommitValidationMessage ADD COLUMN IF NOT EXISTS `committerEmail` varchar(255) DEFAULT NULL;
+
+CREATE TABLE GithubWebhookTracking (
+  id SERIAL NOT NULL,
+  fingerprint varchar(127) NOT NULL,
+  installationId varchar(63) NOT NULL,
+  deliveryId varchar(63) NOT NULL,
+  headSha varchar(63) NOT NULL,
+  repositoryFullName varchar(127) NOT NULL,
+  pullRequestNumber int NOT NULL,
+  lastUpdated datetime DEFAULT NULL,
+  PRIMARY KEY (id)
+);
\ No newline at end of file
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
index d68e938cc0e4b6a783ccaaa86fc9a3da8b37c7bf..4f4032b46fc5e62238d1e63829ca3855df1df378 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/GithubAPI.java
@@ -22,6 +22,7 @@ import javax.ws.rs.core.Response;
 import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
 import org.eclipsefoundation.git.eca.api.models.GithubAccessToken;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
+import org.jboss.resteasy.util.HttpHeaderNames;
 
 /**
  * Bindings used by the webhook logic to retrieve data about the PR to be validated.
@@ -35,16 +36,17 @@ public interface GithubAPI {
 
     @GET
     @Path("repos/{repoFull}/pulls/{pullNumber}/commits")
-    public Response getCommits(@HeaderParam("authorization") String bearer, @HeaderParam("X-GitHub-Api-Version") String apiVersion,
-            @PathParam("repoFull") String repoFull, @PathParam("pullNumber") int pullNumber);
+    public Response getCommits(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
+            @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("repoFull") String repoFull,
+            @PathParam("pullNumber") int pullNumber);
 
     @POST
     @Path("repos/{repoFull}/statuses/{prHeadSha}")
-    public Response updateStatus(@HeaderParam("authorization") String bearer, @HeaderParam("X-GitHub-Api-Version") String apiVersion,
+    public Response updateStatus(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer, @HeaderParam("X-GitHub-Api-Version") String apiVersion,
             @PathParam("repoFull") String repoFull, @PathParam("prHeadSha") String prHeadSha, GithubCommitStatusRequest commitStatusUpdate);
 
     @POST
     @Path("app/installations/{installationId}/access_tokens")
-    public GithubAccessToken getNewAccessToken(@HeaderParam("authorization") String bearer,
+    public GithubAccessToken getNewAccessToken(@HeaderParam(HttpHeaderNames.AUTHORIZATION) String bearer,
             @HeaderParam("X-GitHub-Api-Version") String apiVersion, @PathParam("installationId") String installationId);
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/HCaptchaCallbackAPI.java b/src/main/java/org/eclipsefoundation/git/eca/api/HCaptchaCallbackAPI.java
new file mode 100644
index 0000000000000000000000000000000000000000..0f3e5d4d75263ee7469f5eaeccc7dfe61865d8dd
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/HCaptchaCallbackAPI.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2023 Eclipse Foundation
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * Author: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.api;
+
+import javax.ws.rs.FormParam;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+import org.eclipsefoundation.git.eca.api.models.CaptchaResponseData;
+
+/**
+ * Binding to validate hcaptcha validation requests.
+ * 
+ * @author Martin Lowe
+ *
+ */
+@Produces(MediaType.APPLICATION_FORM_URLENCODED)
+@RegisterRestClient(baseUri = "https://hcaptcha.com")
+public interface HCaptchaCallbackAPI {
+
+    @POST
+    @Path("siteverify")
+    CaptchaResponseData validateCaptchaRequest(@FormParam("response") String response, @FormParam("secret") String secret,
+            @FormParam("sitekey") String sitekey);
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/CaptchaResponseData.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/CaptchaResponseData.java
new file mode 100644
index 0000000000000000000000000000000000000000..b6e8a1da8f9b31465c327fcf2cee98a5072e4545
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/CaptchaResponseData.java
@@ -0,0 +1,54 @@
+/**
+ * Copyright (c) 2023 Eclipse Foundation
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * Author: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.api.models;
+
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+import org.eclipsefoundation.git.eca.namespace.HCaptchaErrorCodes;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.auto.value.AutoValue;
+
+/**
+ * @author Martin Lowe
+ *
+ */
+@AutoValue
+@JsonDeserialize(builder = AutoValue_CaptchaResponseData.Builder.class)
+public abstract class CaptchaResponseData {
+
+    public abstract boolean getSuccess();
+    
+    @Nullable
+    @JsonProperty("error-codes")
+    public abstract List<HCaptchaErrorCodes> getErrorCodes();
+
+    public static Builder builder() {
+        return new AutoValue_CaptchaResponseData.Builder();
+    }
+
+    @AutoValue.Builder
+    @JsonPOJOBuilder(withPrefix = "set")
+    public abstract static class Builder {
+
+        public abstract Builder setSuccess(boolean success);
+
+        @JsonProperty("error-codes")
+        public abstract Builder setErrorCodes(@Nullable List<HCaptchaErrorCodes> errorCodes);
+
+        public abstract CaptchaResponseData build();
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java
index 7c0e93ac18311c64aae0b083257276b75d13cc5d..90d8b50ba8237e94fb027933e3b49951bf0b8f1e 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/api/models/GithubWebhookRequest.java
@@ -11,6 +11,8 @@
  */
 package org.eclipsefoundation.git.eca.api.models;
 
+import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
+
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
 import com.google.auto.value.AutoValue;
@@ -25,22 +27,46 @@ import com.google.auto.value.AutoValue;
 @JsonDeserialize(builder = AutoValue_GithubWebhookRequest.Builder.class)
 public abstract class GithubWebhookRequest {
 
-    public abstract String getAction();
-
     public abstract Installation getInstallation();
 
     public abstract Repository getRepository();
 
     public abstract PullRequest getPullRequest();
 
+    /**
+     * Generate basic builder with default properties for constructing a webhook request.
+     * 
+     * @return the default builder for constructing a webhook request model.
+     */
     public static Builder builder() {
         return new AutoValue_GithubWebhookRequest.Builder();
     }
 
+    /**
+     * Reconstructs a webhook request from the tracked webhook data.
+     * 
+     * @param tracking the tracked webhook request data to use in reconstructing the request.
+     * @return the reconstructed request.
+     */
+    public static GithubWebhookRequest buildFromTracking(GithubWebhookTracking tracking) {
+        return builder()
+                .setInstallation(Installation.builder().setId(tracking.getInstallationId()).build())
+                .setPullRequest(PullRequest
+                        .builder()
+                        .setNumber(tracking.getPullRequestNumber())
+                        .setHead(PullRequestHead.builder().setSha(tracking.getHeadSha()).build())
+                        .build())
+                .setRepository(Repository
+                        .builder()
+                        .setFullName(tracking.getRepositoryFullName())
+                        .setHtmlUrl("https://github.com/" + tracking.getRepositoryFullName())
+                        .build())
+                .build();
+    }
+
     @AutoValue.Builder
     @JsonPOJOBuilder(withPrefix = "set")
     public abstract static class Builder {
-        public abstract Builder setAction(String action);
 
         public abstract Builder setInstallation(Installation installation);
 
@@ -79,6 +105,7 @@ public abstract class GithubWebhookRequest {
     public abstract static class PullRequest {
 
         public abstract Integer getNumber();
+
         public abstract PullRequestHead getHead();
 
         public static Builder builder() {
@@ -89,12 +116,13 @@ public abstract class GithubWebhookRequest {
         @JsonPOJOBuilder(withPrefix = "set")
         public abstract static class Builder {
             public abstract Builder setNumber(Integer number);
+
             public abstract Builder setHead(PullRequestHead head);
 
             public abstract PullRequest build();
         }
     }
-    
+
     @AutoValue
     @JsonDeserialize(builder = AutoValue_GithubWebhookRequest_PullRequestHead.Builder.class)
     public abstract static class PullRequestHead {
diff --git a/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java b/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java
new file mode 100644
index 0000000000000000000000000000000000000000..4135793778db40a2705c4c07d4b133492cb6232e
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/dto/GithubWebhookTracking.java
@@ -0,0 +1,221 @@
+/**
+ * Copyright (c) 2023 Eclipse Foundation
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * Author: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.dto;
+
+import java.time.ZonedDateTime;
+import java.util.Objects;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames;
+import org.eclipsefoundation.persistence.dto.BareNode;
+import org.eclipsefoundation.persistence.dto.filter.DtoFilter;
+import org.eclipsefoundation.persistence.model.DtoTable;
+import org.eclipsefoundation.persistence.model.ParameterizedSQLStatement;
+import org.eclipsefoundation.persistence.model.ParameterizedSQLStatementBuilder;
+
+/**
+ * Github Webhook request tracking info. Tracking is required to trigger revalidation through the Github API from this
+ * service.
+ * 
+ * @author Martin Lowe
+ *
+ */
+@Entity
+@Table
+public class GithubWebhookTracking extends BareNode {
+    public static final DtoTable TABLE = new DtoTable(GithubWebhookTracking.class, "gwt");
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+    private String fingerprint;
+    private String installationId;
+    private String deliveryId;
+    private String repositoryFullName;
+    private String headSha;
+    private Integer pullRequestNumber;
+    private ZonedDateTime lastUpdated;
+
+    @Override
+    public Long getId() {
+        return id;
+    }
+
+    /**
+     * @param id the id to set
+     */
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    /**
+     * @return the fingerprint
+     */
+    public String getFingerprint() {
+        return fingerprint;
+    }
+
+    /**
+     * @param fingerprint the fingerprint to set
+     */
+    public void setFingerprint(String fingerprint) {
+        this.fingerprint = fingerprint;
+    }
+
+    /**
+     * @return the installationId
+     */
+    public String getInstallationId() {
+        return installationId;
+    }
+
+    /**
+     * @param installationId the installationId to set
+     */
+    public void setInstallationId(String installationId) {
+        this.installationId = installationId;
+    }
+
+    /**
+     * @return the deliveryId
+     */
+    public String getDeliveryId() {
+        return deliveryId;
+    }
+
+    /**
+     * @param deliveryId the deliveryId to set
+     */
+    public void setDeliveryId(String deliveryId) {
+        this.deliveryId = deliveryId;
+    }
+
+    /**
+     * @return the repositoryFullName
+     */
+    public String getRepositoryFullName() {
+        return repositoryFullName;
+    }
+
+    /**
+     * @param repositoryFullName the repositoryFullName to set
+     */
+    public void setRepositoryFullName(String repositoryFullName) {
+        this.repositoryFullName = repositoryFullName;
+    }
+
+    /**
+     * @return the headSha
+     */
+    public String getHeadSha() {
+        return headSha;
+    }
+
+    /**
+     * @param headSha the headSha to set
+     */
+    public void setHeadSha(String headSha) {
+        this.headSha = headSha;
+    }
+
+    /**
+     * @return the pullRequestNumber
+     */
+    public Integer getPullRequestNumber() {
+        return pullRequestNumber;
+    }
+
+    /**
+     * @param pullRequestNumber the pullRequestNumber to set
+     */
+    public void setPullRequestNumber(Integer pullRequestNumber) {
+        this.pullRequestNumber = pullRequestNumber;
+    }
+
+    /**
+     * @return the lastUpdated
+     */
+    public ZonedDateTime getLastUpdated() {
+        return lastUpdated;
+    }
+
+    /**
+     * @param lastUpdated the lastUpdated to set
+     */
+    public void setLastUpdated(ZonedDateTime lastUpdated) {
+        this.lastUpdated = lastUpdated;
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result
+                + Objects.hash(deliveryId, fingerprint, headSha, id, installationId, lastUpdated, pullRequestNumber, repositoryFullName);
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!super.equals(obj)) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        GithubWebhookTracking other = (GithubWebhookTracking) obj;
+        return Objects.equals(deliveryId, other.deliveryId) && Objects.equals(fingerprint, other.fingerprint)
+                && Objects.equals(headSha, other.headSha) && Objects.equals(id, other.id)
+                && Objects.equals(installationId, other.installationId) && Objects.equals(lastUpdated, other.lastUpdated)
+                && Objects.equals(pullRequestNumber, other.pullRequestNumber)
+                && Objects.equals(repositoryFullName, other.repositoryFullName);
+    }
+
+    @Singleton
+    public static class GithubWebhookTrackingFilter implements DtoFilter<GithubWebhookTracking> {
+
+        @Inject
+        ParameterizedSQLStatementBuilder builder;
+
+        @Override
+        public ParameterizedSQLStatement getFilters(MultivaluedMap<String, String> params, boolean isRoot) {
+            ParameterizedSQLStatement statement = builder.build(TABLE);
+
+            // fingerprint check
+            String fingerprint = params.getFirst(GitEcaParameterNames.FINGERPRINT.getName());
+            if (StringUtils.isNotBlank(fingerprint)) {
+                statement
+                        .addClause(
+                                new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".fingerprint = ?", new Object[] { fingerprint }));
+            }
+
+            return statement;
+        }
+
+        @Override
+        public Class<GithubWebhookTracking> getType() {
+            return GithubWebhookTracking.class;
+        }
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/CaptchaHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/CaptchaHelper.java
new file mode 100644
index 0000000000000000000000000000000000000000..6555b3a30f23e4fa00e0fea3fd61658665ec5286
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/CaptchaHelper.java
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2023 Eclipse Foundation
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * Author: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.helper;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import javax.inject.Singleton;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipse.microprofile.rest.client.inject.RestClient;
+import org.eclipsefoundation.git.eca.api.HCaptchaCallbackAPI;
+import org.eclipsefoundation.git.eca.namespace.HCaptchaErrorCodes;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helper for validating captcha responses.
+ * 
+ * @author Martin Lowe
+ *
+ */
+@Singleton
+public class CaptchaHelper {
+    private static final Logger LOGGER = LoggerFactory.getLogger(CaptchaHelper.class);
+
+    @ConfigProperty(name = "eclipse.hcaptcha.enabled", defaultValue = "true")
+    boolean isEnabled;
+    @ConfigProperty(name = "eclipse.hcaptcha.sitekey")
+    String siteKey;
+    @ConfigProperty(name = "eclipse.hcaptcha.secret")
+    String captchaSecret;
+
+    @RestClient
+    HCaptchaCallbackAPI hcaptchaApi;
+
+    /**
+     * Validates a captcha response through the designated captcha server.
+     * 
+     * @param response the challenege response for a request
+     * @return a list of errors if any occurred, or an empty list for a good request.
+     */
+    public List<HCaptchaErrorCodes> validateCaptchaResponse(String response) {
+        // allow for disabling the captcha in case there are service issues, or in testing
+        if (!isEnabled) {
+            LOGGER.trace("Captcha validation is currently disabled, skipping captcha check");
+            return Collections.emptyList();
+        } else if (StringUtils.isBlank(response)) {
+            LOGGER.trace("Cannot process empty response, returning early");
+            return Arrays.asList(HCaptchaErrorCodes.MISSING_INPUT_RESPONSE);
+        }
+        // validate the hcaptcha response with the site API, passing the secret and response
+        return Optional
+                .ofNullable(hcaptchaApi.validateCaptchaRequest(response, captchaSecret, siteKey).getErrorCodes())
+                .orElse(Collections.emptyList());
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java b/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java
index 4a737d7394d9e85d324c247aaf6d935e4e17db05..33d22c81224149bf2f036c092969b73d7798b2c1 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/helper/JwtHelper.java
@@ -15,21 +15,75 @@ import java.io.FileReader;
 import java.nio.file.Paths;
 import java.security.PrivateKey;
 
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
 import org.bouncycastle.openssl.PEMKeyPair;
 import org.bouncycastle.openssl.PEMParser;
 import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipse.microprofile.rest.client.inject.RestClient;
+import org.eclipsefoundation.git.eca.api.GithubAPI;
+import org.eclipsefoundation.git.eca.api.models.GithubAccessToken;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import io.quarkus.cache.CacheResult;
+import io.smallrye.jwt.auth.principal.JWTParser;
+import io.smallrye.jwt.build.Jwt;
+
 /**
  * Helper to load external resources as a JWT key.
  * 
  * @author Martin Lowe
  *
  */
+@Singleton
 public class JwtHelper {
     private static final Logger LOGGER = LoggerFactory.getLogger(JwtHelper.class);
 
+    @ConfigProperty(name = "smallrye.jwt.sign.key.location")
+    String location;
+    @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
+    String apiVersion;
+
+    @RestClient
+    GithubAPI ghApi;
+
+    @Inject
+    JWTParser parser;
+
+    /**
+     * Retrieve the bearer token string for the current installation.
+     * 
+     * @param installationId the installation to generate a bearer string for
+     * @return the bearer string for the current request
+     */
+    public String getGhBearerString(String installationId) {
+        return "Bearer " + getGithubAccessToken(installationId).getToken();
+    }
+
+    /**
+     * Retrieve a new cached Github access token, using a JWT generated from the GH provided PKCS#1 private key.
+     * 
+     * @param installationId the installation that the access token is being generated for
+     * @return the access token to be used in the requests.
+     */
+    @CacheResult(cacheName = "accesstoken")
+    public GithubAccessToken getGithubAccessToken(String installationId) {
+        return ghApi.getNewAccessToken("Bearer " + generateJwt(), apiVersion, installationId);
+    }
+
+    /**
+     * Generate the JWT to use for Github requests. This is stored in a special one-shot cache method secured to this class,
+     * and not to be used for other requests that require JWTs.
+     * 
+     * @return signed JWT using the issuer and secret defined in the secret properties.
+     */
+    private String generateJwt() {
+        return Jwt.subject("EclipseWebmaster").sign(JwtHelper.getExternalPrivateKey(location));
+    }
+
     /**
      * Reads in external PEM keys in a way that supports both PKCS#1 and PKCS#8. This is needed as the GH-provided RSA key
      * is encoded using PKCS#1 and is not available for consumption in OOTB Java/smallrye, and smallrye's default PEM reader
@@ -53,6 +107,4 @@ public class JwtHelper {
         return null;
     }
 
-    private JwtHelper() {
-    }
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/namespace/HCaptchaErrorCodes.java b/src/main/java/org/eclipsefoundation/git/eca/namespace/HCaptchaErrorCodes.java
new file mode 100644
index 0000000000000000000000000000000000000000..153bec402de58b37ca06cc2a5f2fdcca9a1d1991
--- /dev/null
+++ b/src/main/java/org/eclipsefoundation/git/eca/namespace/HCaptchaErrorCodes.java
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) 2023 Eclipse Foundation
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * Author: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.namespace;
+
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+import io.quarkus.qute.TemplateEnum;
+
+/**
+ * Mapping of hCaptcha error codes to human readable messages
+ * 
+ * @author Martin Lowe
+ *
+ */
+@TemplateEnum
+public enum HCaptchaErrorCodes {
+
+    MISSING_INPUT_SECRET("missing-input-secret", "The secret key is not set, cannot validate request."),
+    INVALID_INPUT_SECRET("invalid-input-secret", "The secret key is invalid or malformed, cannot validate requests."),
+    MISSING_INPUT_RESPONSE("missing-input-response", "The response parameter is missing."),
+    INVALID_INPUT_RESPONSE("invalid-input-response", "The response parameter is invalid or malformed."),
+    BAD_REQUEST("bad-request", "The request is invalid or malformed."),
+    INVALID_OR_ALREADY_SEEN_RESPONSE("invalid-or-already-seen-response",
+            "The response parameter has already been checked, or has another issue."),
+    NOT_USING_DUMMY_PASSCODE("not-using-dummy-passcode", "You have used a testing sitekey but have not used its matching secret."),
+    SITEKEY_SECRET_MISMATCH("sitekey-secret-mismatch", "The sitekey is not registered with the provided secret.");
+
+    private String code;
+    private String message;
+
+    private HCaptchaErrorCodes(String code, String message) {
+        this.code = code;
+        this.message = message;
+    }
+
+    @JsonCreator
+    public static HCaptchaErrorCodes forValue(String value) {
+        return Stream.of(values()).filter(err -> err.code.equalsIgnoreCase(value)).findFirst().orElse(null);
+    }
+
+    @JsonValue
+    public String toValue() {
+        return this.code;
+    }
+
+    public String getMessage() {
+        return this.message;
+    }
+}
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
index 26685b2d59badef91c41f62935599172820a5d08..96cebffb4ba3534a8ed8a109a71244f8a3733111 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/GithubWebhooksResource.java
@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2022 Eclipse Foundation
+ * Copyright (c) 2022, 2023 Eclipse Foundation
  *
  * This program and the accompanying materials are made
  * available under the terms of the Eclipse Public License 2.0
@@ -12,39 +12,51 @@
 package org.eclipsefoundation.git.eca.resource;
 
 import java.net.URI;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
 import java.util.stream.Collectors;
 
 import javax.inject.Inject;
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.NotFoundException;
 import javax.ws.rs.POST;
 import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.core.MultivaluedMap;
 import javax.ws.rs.core.Response;
 
 import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.eclipse.microprofile.rest.client.inject.RestClient;
+import org.eclipsefoundation.core.helper.DateTimeHelper;
 import org.eclipsefoundation.core.model.RequestWrapper;
 import org.eclipsefoundation.core.service.APIMiddleware;
+import org.eclipsefoundation.core.service.CachingService;
 import org.eclipsefoundation.git.eca.api.GithubAPI;
-import org.eclipsefoundation.git.eca.api.models.GithubAccessToken;
 import org.eclipsefoundation.git.eca.api.models.GithubCommit;
 import org.eclipsefoundation.git.eca.api.models.GithubCommitStatusRequest;
 import org.eclipsefoundation.git.eca.api.models.GithubWebhookRequest;
+import org.eclipsefoundation.git.eca.dto.GithubWebhookTracking;
+import org.eclipsefoundation.git.eca.helper.CaptchaHelper;
 import org.eclipsefoundation.git.eca.helper.JwtHelper;
 import org.eclipsefoundation.git.eca.model.Commit;
 import org.eclipsefoundation.git.eca.model.GitUser;
 import org.eclipsefoundation.git.eca.model.ValidationRequest;
 import org.eclipsefoundation.git.eca.model.ValidationResponse;
+import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames;
 import org.eclipsefoundation.git.eca.namespace.GithubCommitStatuses;
+import org.eclipsefoundation.git.eca.namespace.HCaptchaErrorCodes;
 import org.eclipsefoundation.git.eca.namespace.ProviderType;
 import org.eclipsefoundation.git.eca.service.ValidationService;
+import org.eclipsefoundation.persistence.dao.PersistenceDao;
+import org.eclipsefoundation.persistence.model.RDBMSQuery;
+import org.eclipsefoundation.persistence.service.FilterService;
 import org.jboss.resteasy.annotations.jaxrs.HeaderParam;
+import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import io.quarkus.cache.CacheResult;
-import io.smallrye.jwt.auth.principal.JWTParser;
-import io.smallrye.jwt.build.Jwt;
-
 /**
  * Resource for processing Github pull request events, used to update commit status entries for the Git ECA application.
  * 
@@ -55,13 +67,12 @@ import io.smallrye.jwt.build.Jwt;
 public class GithubWebhooksResource {
     private static final Logger LOGGER = LoggerFactory.getLogger(GithubWebhooksResource.class);
 
-    @ConfigProperty(name = "smallrye.jwt.sign.key.location")
-    String location;
+    private static final String VALIDATION_LOGGING_MESSAGE = "Setting validation state for {}/#{} to {}";
+
     @ConfigProperty(name = "eclipse.webhooks.github.context")
     String context;
     @ConfigProperty(name = "eclipse.webhooks.github.server-target")
     String serverTarget;
-
     @ConfigProperty(name = "eclipse.github.default-api-version", defaultValue = "2022-11-28")
     String apiVersion;
 
@@ -71,9 +82,17 @@ public class GithubWebhooksResource {
     APIMiddleware middleware;
     @Inject
     ValidationService validationService;
+    @Inject
+    JwtHelper jwtHelper;
+    @Inject
+    CaptchaHelper captchaHelper;
 
     @Inject
-    JWTParser parser;
+    CachingService cache;
+    @Inject
+    PersistenceDao dao;
+    @Inject
+    FilterService filters;
 
     @RestClient
     GithubAPI ghApi;
@@ -91,27 +110,104 @@ public class GithubWebhooksResource {
      * @return an OK status when done processing.
      */
     @POST
-    @Path("check")
-    public Response processGithubCheck(@HeaderParam("X-GitHub-Delivery") String deliveryId, @HeaderParam("X-GitHub-Event") String eventType,
-            GithubWebhookRequest request) {
+    public Response processGithubWebhook(@HeaderParam("X-GitHub-Delivery") String deliveryId,
+            @HeaderParam("X-GitHub-Hook-ID") String hookId, @HeaderParam("X-GitHub-Event") String eventType, GithubWebhookRequest request) {
         // If event isn't a pr event, drop as we don't process them
         if (!"pull_request".equalsIgnoreCase(eventType)) {
             return Response.ok().build();
         }
         LOGGER.trace("Processing PR event for install {} with delivery ID of {}", request.getInstallation().getId(), deliveryId);
-        // start validation process
+        // prepare for validation process by pre-processing into standard format
         ValidationRequest vr = generateRequest(request);
         String fingerprint = validationService.generateRequestHash(vr);
+        // track the request before we start processing
+        trackWebhookRequest(fingerprint, deliveryId, request);
+        // process the request
+        handleGithubWebhookValidation(request, vr, fingerprint);
+
+        return Response.ok().build();
+    }
+
+    /**
+     * Endpoint for triggering revalidation of a past request. Uses the fingerprint hash to lookup the unique request and to
+     * rebuild the request and revalidate if it exists.
+     * 
+     * @param fingerprint sha hash of unique identifiers for the request.
+     * @param captchaResponse the passed captcha challenge response
+     * @return redirect to the pull request once done processing
+     */
+    @POST
+    @Path("revalidate/{fingerprint}")
+    public Response revalidateWebhookRequest(@PathParam("fingerprint") String fingerprint,
+            @FormParam("h-captcha-response") String captchaResponse) {
+        // check the captcha challenge response
+        List<HCaptchaErrorCodes> errors = captchaHelper.validateCaptchaResponse(captchaResponse);
+        if (!errors.isEmpty()) {
+            // use debug logging as this could be incredibly noisy
+            LOGGER
+                    .debug("Captcha challenge failed with the following errors for request with fingerprint '{}': {}", fingerprint,
+                            errors.stream().map(HCaptchaErrorCodes::getMessage));
+            throw new BadRequestException("hCaptcha challenge response failed for this request");
+        }
+
+        Optional<GithubWebhookTracking> optTracking = findTrackedRequest(fingerprint);
+        if (optTracking.isEmpty()) {
+            throw new NotFoundException("Cannot find a tracked pull request with fingerprint " + fingerprint);
+        }
+
+        // get the tracking class, convert back to a GH webhook request, and validate the request
+        GithubWebhookTracking tracking = optTracking.get();
+        GithubWebhookRequest request = GithubWebhookRequest.buildFromTracking(tracking);
+        boolean isSuccessful = handleGithubWebhookValidation(request, generateRequest(request), fingerprint);
+        LOGGER.debug("Revalidation for request with fingerprint '{}' was {}successful.", fingerprint, isSuccessful ? "" : " not");
+
+        // update the tracking for the update time
+        trackWebhookRequest(fingerprint, tracking.getDeliveryId(), request);
+        // build the url for pull request page
+        StringBuilder sb = new StringBuilder();
+        sb.append("https://github.com/");
+        sb.append(tracking.getRepositoryFullName());
+        sb.append("/pull/");
+        sb.append(tracking.getPullRequestNumber());
+        // redirect to the pull request page on successful trigger of the webhook
+        return Response.temporaryRedirect(URI.create(sb.toString())).build();
+    }
+
+    /**
+     * Process the current request and update the checks state to pending then success or failure. Contains verbose TRACE
+     * logging for more info on the states of the validation for more information
+     * 
+     * @param request information about the request from the GH webhook on what resource requested revalidation. Used to
+     * target the commit status of the resources
+     * @param vr the pseudo request generated from the contextual webhook data. Used to make use of existing validation
+     * logic.
+     * @param fingerprint the generated SHA hash for the request
+     * @return true if the validation passed, false otherwise.
+     */
+    private boolean handleGithubWebhookValidation(GithubWebhookRequest request, ValidationRequest vr, String fingerprint) {
+        // update the status before processing
+        LOGGER
+                .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
+                        GithubCommitStatuses.PENDING);
         updateCommitStatus(request, GithubCommitStatuses.PENDING, fingerprint);
 
         // validate the response
+        LOGGER
+                .trace("Begining validation of request for {}/#{}", request.getRepository().getFullName(),
+                        request.getPullRequest().getNumber());
         ValidationResponse r = validationService.validateIncomingRequest(vr, wrapper);
         if (r.getPassed()) {
+            LOGGER
+                    .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
+                            GithubCommitStatuses.SUCCESS);
             updateCommitStatus(request, GithubCommitStatuses.SUCCESS, fingerprint);
-        } else {
-            updateCommitStatus(request, GithubCommitStatuses.FAILURE, fingerprint);
+            return true;
         }
-        return Response.ok().build();
+        LOGGER
+                .trace(VALIDATION_LOGGING_MESSAGE, request.getRepository().getFullName(), request.getPullRequest().getNumber(),
+                        GithubCommitStatuses.FAILURE);
+        updateCommitStatus(request, GithubCommitStatuses.FAILURE, fingerprint);
+        return false;
     }
 
     /**
@@ -124,10 +220,10 @@ public class GithubWebhooksResource {
     private void updateCommitStatus(GithubWebhookRequest request, GithubCommitStatuses state, String fingerprint) {
         LOGGER
                 .trace("Generated access token for installation {}: {}", request.getInstallation().getId(),
-                        getAccessToken(request.getInstallation().getId()).getToken());
+                        jwtHelper.getGithubAccessToken(request.getInstallation().getId()).getToken());
         ghApi
-                .updateStatus(getBearerString(request.getInstallation().getId()), apiVersion, request.getRepository().getFullName(),
-                        request.getPullRequest().getHead().getSha(),
+                .updateStatus(jwtHelper.getGhBearerString(request.getInstallation().getId()), apiVersion,
+                        request.getRepository().getFullName(), request.getPullRequest().getHead().getSha(),
                         GithubCommitStatusRequest
                                 .builder()
                                 .setDescription(state.getMessage())
@@ -137,6 +233,52 @@ public class GithubWebhooksResource {
                                 .build());
     }
 
+    /**
+     * Create a new entry in the webhook request tracking table to facilitate revalidation requests from the ECA system
+     * rather than just from Github.
+     * 
+     * @param fingerprint the unique hash for the request
+     * @param deliveryId unique ID of the Github Webhook Delivery object
+     * @param request the webhook event payload from Github
+     * @return the persisted webhook tracking data, or null if there was an error in creating the tracking
+     */
+    private GithubWebhookTracking trackWebhookRequest(String fingerprint, String deliveryId, GithubWebhookRequest request) {
+        Optional<GithubWebhookTracking> existingTracker = findTrackedRequest(fingerprint);
+        GithubWebhookTracking calculatedTracker = new GithubWebhookTracking();
+        calculatedTracker.setFingerprint(fingerprint);
+        calculatedTracker.setInstallationId(request.getInstallation().getId());
+        calculatedTracker.setDeliveryId(deliveryId);
+        calculatedTracker.setPullRequestNumber(request.getPullRequest().getNumber());
+        calculatedTracker.setRepositoryFullName(request.getRepository().getFullName());
+        calculatedTracker.setHeadSha(request.getPullRequest().getHead().getSha());
+        calculatedTracker.setLastUpdated(DateTimeHelper.now());
+        // check if we already have an ID for the current request to prevent duplicates
+        existingTracker.ifPresent(gwt -> calculatedTracker.setId(gwt.getId()));
+
+        // push the new/updated webhook request to the DB for future usage in revalidation
+        List<GithubWebhookTracking> results = dao
+                .add(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class)), Arrays.asList(calculatedTracker));
+        if (results.isEmpty()) {
+            LOGGER
+                    .error("Could not save the webhook metadata, functionality will be restricted for request with fingerprint {}",
+                            fingerprint);
+            return null;
+        }
+        return results.get(0);
+    }
+
+    /**
+     * Retrieves the tracked information for a previous webhook validation request if available.
+     * 
+     * @param fingerprint the unique hash for the request that was previously tracked
+     * @return an optional containing the tracked webhook request information, or an empty optional if it can't be found.
+     */
+    private Optional<GithubWebhookTracking> findTrackedRequest(String fingerprint) {
+        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
+        params.add(GitEcaParameterNames.FINGERPRINT_RAW, fingerprint);
+        return dao.get(new RDBMSQuery<>(wrapper, filters.get(GithubWebhookTracking.class), params)).stream().findFirst();
+    }
+
     /**
      * Generate the validation request for the current GH Webhook request.
      * 
@@ -144,20 +286,23 @@ public class GithubWebhooksResource {
      * @return the Validation Request body to be used in validating data
      */
     private ValidationRequest generateRequest(GithubWebhookRequest request) {
+        return generateRequest(request.getInstallation().getId(), request.getRepository().getFullName(),
+                request.getPullRequest().getNumber(), request.getRepository().getHtmlUrl());
+    }
+
+    private ValidationRequest generateRequest(String installationId, String repositoryFullName, int pullRequestNumber,
+            String repositoryUrl) {
         // get the commits that will be validated, don't cache as changes can come in too fast for it to be useful
         List<GithubCommit> commits = middleware
                 .getAll(i -> ghApi
-                        .getCommits(getBearerString(request.getInstallation().getId()), apiVersion, request.getRepository().getFullName(),
-                                request.getPullRequest().getNumber()),
+                        .getCommits(jwtHelper.getGhBearerString(installationId), apiVersion, repositoryFullName, pullRequestNumber),
                         GithubCommit.class);
-        LOGGER
-                .trace("Retrieved {} commits for PR {} in repo {}", commits.size(), request.getPullRequest().getNumber(),
-                        request.getRepository().getHtmlUrl());
+        LOGGER.trace("Retrieved {} commits for PR {} in repo {}", commits.size(), pullRequestNumber, repositoryUrl);
         // set up the validation request from current data
         return ValidationRequest
                 .builder()
                 .setProvider(ProviderType.GITHUB)
-                .setRepoUrl(URI.create(request.getRepository().getHtmlUrl()))
+                .setRepoUrl(URI.create(repositoryUrl))
                 .setCommits(commits
                         .stream()
                         .map(c -> Commit
@@ -178,34 +323,4 @@ public class GithubWebhooksResource {
                 .build();
     }
 
-    /**
-     * Retrieve the bearer token string for the current installation.
-     * 
-     * @param installationId the installation to generate a bearer string for
-     * @return the bearer string for the current request
-     */
-    protected String getBearerString(String installationId) {
-        return "Bearer " + getAccessToken(installationId).getToken();
-    }
-
-    /**
-     * Retrieve a new cached Github access token, using a JWT generated from the GH provided PKCS#1 private key.
-     * 
-     * @param installationId the installation that the access token is being generated for
-     * @return the access token to be used in the requests.
-     */
-    @CacheResult(cacheName = "accesstoken")
-    protected GithubAccessToken getAccessToken(String installationId) {
-        return ghApi.getNewAccessToken("Bearer " + generateJwt(), apiVersion, installationId);
-    }
-
-    /**
-     * Generate the JWT to use for Github requests. This is stored in a special one-shot cache method secured to this class,
-     * and not to be used for other requests that require JWTs.
-     * 
-     * @return signed JWT using the issuer and secret defined in the secret properties.
-     */
-    private String generateJwt() {
-        return Jwt.subject("EclipseWebmaster").sign(JwtHelper.getExternalPrivateKey(location));
-    }
 }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
index 7985ad0a78d5c826edf8ec3dc8daf40571dc90b6..98b58b95568007af7639c90fd8548821517e26ea 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/resource/ValidationResource.java
@@ -130,7 +130,8 @@ public class ValidationResource {
         return Response
                 .ok()
                 .entity(membershipTemplateWeb
-                        .data("statuses", statuses, "repoUrl", statuses.get(0).getRepoUrl(), "project", ps.isEmpty() ? null : ps.get(0))
+                        .data("statuses", statuses, "repoUrl", statuses.get(0).getRepoUrl(), "project", ps.isEmpty() ? null : ps.get(0),
+                                "fingerprint", fingerprint)
                         .render())
                 .build();
     }
diff --git a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
index f1fb93df0b9c5b3741c4aca9637fd0dd3ef922bd..76d319e7d6b86a95c2307fada3e8fdc6b67e7431 100644
--- a/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
+++ b/src/main/java/org/eclipsefoundation/git/eca/service/impl/DefaultValidationService.java
@@ -51,6 +51,13 @@ import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+/**
+ * Default service for validating external requests for ECA validation, as well as storing and retrieving information
+ * about historic requests.
+ * 
+ * @author Martin Lowe
+ *
+ */
 @ApplicationScoped
 public class DefaultValidationService implements ValidationService {
     private static final Logger LOGGER = LoggerFactory.getLogger(DefaultValidationService.class);
@@ -74,7 +81,6 @@ public class DefaultValidationService implements ValidationService {
 
     @Override
     public ValidationResponse validateIncomingRequest(ValidationRequest req, RequestWrapper wrapper) {
-
         // get the projects associated with the current request, if any
         List<Project> filteredProjects = projects.retrieveProjectsForRequest(req);
         // build the response object that we can populate with messages (the message/error arrays are mutable unlike the rest of
@@ -386,6 +392,12 @@ public class DefaultValidationService implements ValidationService {
         return false;
     }
 
+    /**
+     * Checks against internal allow list for global bypass users, like webmaster and dependabot.
+     * 
+     * @param mail the mail address to check for allow list
+     * @return true if user email is in allow list, false otherwise
+     */
     private boolean isAllowedUser(String mail) {
         return StringUtils.isNotBlank(mail) && allowListUsers.indexOf(mail) != -1;
     }
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 6d7f3ddad721bbbd8496c12365ea8b108a7180a2..41425558d831a452d5b74a06245f4e10f8132292 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -51,6 +51,3 @@ eclipse.mail.allowlist=noreply@github.com,49699333+dependabot[bot]@users.noreply
 %dev.eclipse.scheduled.private-project.enabled=false
 
 eclipse.system-hook.pool-size=5
-
-#%dev.quarkus.log.level=DEBUG
-#quarkus.log.category."org.eclipsefoundation".level=DEBUG
\ No newline at end of file
diff --git a/src/main/resources/templates/simple_fingerprint_ui.html b/src/main/resources/templates/simple_fingerprint_ui.html
index 9351fed8895060f588bed558684e33091dac52c2..497698c44206cc3192bf29e7e620aaa98217a4cb 100644
--- a/src/main/resources/templates/simple_fingerprint_ui.html
+++ b/src/main/resources/templates/simple_fingerprint_ui.html
@@ -56,7 +56,7 @@
               {/if}
             </li>
             <li><strong>Eclipse Foundation Specification Project:</strong>
-              {#if project.getSpecWorkingGroup}
+              {#if project ne null && project.getSpecWorkingGroup}
               YES
               {#else}
               NO
@@ -109,35 +109,45 @@
           {/for}
         </div>
       </div>
-        <div class="row">
-          <div class="col-md-24 margin-top-20 margin-bottom-20">
-            <div class="panel info-panel">
-              <h3 class="panel-heading margin-top-0"><i class="fa fa-info margin-right-15" aria-hidden="true"></i>Common troubleshooting tips</h3>
-              <div class="panel-body">
-                <p>
-                  A common error experienced by users is that the email associated with commits do not align with the email set in the Eclipse Foundation account. If you have an Eclipse Foundation account and a signed ECA-equivalent document, there are ways to validate the users and email addresses associated with a commit.
-                </p>
-                <p>
-                  Below are 3 sample commands that will display the committer and author emails for a given commit or range. The first command shows the latest commit, while the second command will show results for a given commit, replacing `&lt;SHA&gt;` with the commit SHA. The last example will render a range of sequential commits. As is, it will show the last 5 commits starting from HEAD for the current branch. Both sides of the range can be replaced with commit SHAs to represent a different range. 
-                </p>
-                <code>
-                  <span>git show -s --format='Commit: %h, Committer: %ce, Author: %ae</span>
-                  <br>
-                  <span>git show -s --format='Commit: %h, Committer: %ce, Author: %ae' &lt;SHA&gt;</span>
-                  <br>
-                  <span>git show -s --format='Commit: %h, Committer: %ce, Author: %ae' HEAD...HEAD~5</span>
-                </code>
-              </div>
+      <div class="row">
+        <div class="col-md-24 margin-top-20 margin-bottom-20">
+          <div class="panel info-panel">
+            <h3 class="panel-heading margin-top-0"><i class="fa fa-info margin-right-15" aria-hidden="true"></i>Common troubleshooting tips</h3>
+            <div class="panel-body">
+              <p>
+                A common error experienced by users is that the email associated with commits do not align with the email set in the Eclipse Foundation account. If you have an Eclipse Foundation account and a signed ECA-equivalent document, there are ways to validate the users and email addresses associated with a commit.
+              </p>
+              <p>
+                Below are 3 sample commands that will display the committer and author emails for a given commit or range. The first command shows the latest commit, while the second command will show results for a given commit, replacing `&lt;SHA&gt;` with the commit SHA. The last example will render a range of sequential commits. As is, it will show the last 5 commits starting from HEAD for the current branch. Both sides of the range can be replaced with commit SHAs to represent a different range. 
+              </p>
+              <code>
+                <span>git show -s --format='Commit: %h, Committer: %ce, Author: %ae</span>
+                <br>
+                <span>git show -s --format='Commit: %h, Committer: %ce, Author: %ae' &lt;SHA&gt;</span>
+                <br>
+                <span>git show -s --format='Commit: %h, Committer: %ce, Author: %ae' HEAD...HEAD~5</span>
+              </code>
             </div>
           </div>
         </div>
-        {#else}
-        <div class="panel panel-success">
-          <div class="panel-heading"><i class="fa fa-check" aria-hidden="true"></i>
-            Author(s) covered by necessary legal agreements
-          </div>
+      </div>
+      {#else}
+      <div class="panel panel-success">
+        <div class="panel-heading"><i class="fa fa-check" aria-hidden="true"></i>
+          Author(s) covered by necessary legal agreements
         </div>
-        {/if}
+      </div>
+      {/if}
+      {#if statuses.0.provider == ProviderType:GITHUB}
+      <div>
+        <form action="/git/webhooks/github/revalidate/{fingerprint}" method="POST">
+          <div class="captcha">
+            <div class="h-captcha" data-sitekey="{config:['eclipse.hcaptcha.sitekey']}"></div>
+          </div>
+          <button class="btn-success margin-top-10 btn form-submit" type="submit">Revalidate</button>
+        </form>
+      </div>
+      {/if}
     </section>
 
     <aside id="main-sidebar-secondy" role="complementary" class="col-md-6 col-sm-8 margin-bottom-60">
@@ -178,10 +188,11 @@
           </aside>
         </div>
       </section>
-  </div>
+    </div>
   </aside>
 </div>
 </div>
+<script src="https://hcaptcha.com/1/api.js?hl=en" async="async" defer="defer"></script>
 {|
 <script>
   document.addEventListener("DOMContentLoaded", function () {
diff --git a/src/test/java/org/eclipsefoundation/git/eca/helper/CaptchaHelperTest.java b/src/test/java/org/eclipsefoundation/git/eca/helper/CaptchaHelperTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..4aadcd6ced78b0f3d30832635e565506af25416b
--- /dev/null
+++ b/src/test/java/org/eclipsefoundation/git/eca/helper/CaptchaHelperTest.java
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2023 Eclipse Foundation
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * Author: Martin Lowe <martin.lowe@eclipse-foundation.org>
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipsefoundation.git.eca.helper;
+
+import javax.inject.Inject;
+
+import org.eclipsefoundation.git.eca.namespace.HCaptchaErrorCodes;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.test.junit.QuarkusTest;
+
+/**
+ * Tests for validating success cases for the
+ * 
+ * @author Martin Lowe
+ *
+ */
+@QuarkusTest
+class CaptchaHelperTest {
+    public static final String SUCCESS_CASE_RESPONSE_VALUE = "20000000-aaaa-bbbb-cccc-000000000002";
+
+    @Inject
+    CaptchaHelper helper;
+
+    @Test
+    void validateCaptchaResponse_success() {
+        Assertions.assertTrue(helper.validateCaptchaResponse(SUCCESS_CASE_RESPONSE_VALUE).isEmpty());
+    }
+
+    @Test
+    void validateCaptchaResponse_failure_badResponse() {
+        Assertions.assertTrue(helper.validateCaptchaResponse("sample bad response").contains(HCaptchaErrorCodes.INVALID_INPUT_RESPONSE));
+    }
+
+    @Test
+    void validateCaptchaResponse_failure_emptyResponse() {
+        Assertions.assertTrue(helper.validateCaptchaResponse("").contains(HCaptchaErrorCodes.MISSING_INPUT_RESPONSE));
+    }
+}
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index b127c9e09da4a30f2e6d6eb2712594270b831a69..98a247c88062082d6fb2bb77282ff0a327b0029c 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -25,4 +25,9 @@ quarkus.http.port=8080
 quarkus.oidc.enabled=false
 quarkus.keycloak.devservices.enabled=false
 quarkus.oidc-client.enabled=false
-smallrye.jwt.sign.key.location=test.pem
\ No newline at end of file
+smallrye.jwt.sign.key.location=test.pem
+
+
+# hCaptcha test key and secret
+eclipse.hcaptcha.sitekey=20000000-ffff-ffff-ffff-000000000002
+eclipse.hcaptcha.secret=0x0000000000000000000000000000000000000000
\ No newline at end of file
diff --git a/src/test/resources/database/default/V1.0.0__default.sql b/src/test/resources/database/default/V1.0.0__default.sql
index 53a1cc81b6906816b02ef10381742b4ff777dfd1..1f941ac350a9b30358da165ec1d11e50b8bb22db 100644
--- a/src/test/resources/database/default/V1.0.0__default.sql
+++ b/src/test/resources/database/default/V1.0.0__default.sql
@@ -45,4 +45,16 @@ CREATE TABLE PrivateProjectEvent (
   PRIMARY KEY (userId, projectId, projectPath)
 );
 INSERT INTO PrivateProjectEvent(userId, projectId, projectPath, ef_username, creationDate) VALUES(1, 133, 'eclipse/test/project', 'testtesterson', '2022-11-11T12:00:00.000');
-INSERT INTO PrivateProjectEvent(userId, projectId, projectPath, ef_username, parentProject, creationDate, deletionDate) VALUES(1, 150, 'eclipse/test/project/fork', 'tyronetesty', 133, '2022-11-15T12:00:00.000', '2022-11-20T12:00:00.000');
\ No newline at end of file
+INSERT INTO PrivateProjectEvent(userId, projectId, projectPath, ef_username, parentProject, creationDate, deletionDate) VALUES(1, 150, 'eclipse/test/project/fork', 'tyronetesty', 133, '2022-11-15T12:00:00.000', '2022-11-20T12:00:00.000');
+
+CREATE TABLE GithubWebhookTracking (
+  id SERIAL NOT NULL,
+  fingerprint varchar(127) NOT NULL,
+  installationId varchar(63) NOT NULL,
+  deliveryId varchar(63) NOT NULL,
+  headSha varchar(63) NOT NULL,
+  repositoryFullName varchar(127) NOT NULL,
+  pullRequestNumber int NOT NULL,
+  lastUpdated datetime DEFAULT NULL,
+  PRIMARY KEY (id)
+);
\ No newline at end of file