Handle verification results
When you verify an organisation or applicant with a validation submission, the submission's status tells you whether to grant the benefit. On some programs, that status can change after the first result, in either direction.
This page explains when a result changes, which webhooks you receive, and how to build a handler that grants and removes benefits correctly. It applies to cause verification and education verification.
Read Webhooks first. It covers subscriptions, signatures, retries, and the store-then-process pattern this page builds on.
When a result can change
Goodstack can change a submission's result after it's decided:
- A
succeededsubmission can change tofailed. For example, a later fraud review finds that the application was fraudulent. - A
failedsubmission can change tosucceeded. For example, a review confirms the applicant is eligible, or the applicant provides new information.
What to expect:
- A changed result usually arrives 24 hours or more after the first one, and can take up to a week.
- There's no cut-off, so don't treat any result as final.
- Goodstack turns this on for each program. On programs without it,
succeededandfaileddon't change. - The handler on this page works either way, so you can build it before it's turned on for your program.
What's changing for existing programs
Goodstack is turning on changing results for existing programs. Once it's on for yours:
- a
succeededorfailedsubmission can change status - Goodstack sends
validation_submission.updatedfor every status change - a change to an agent verification result can change the submission's status
Before it's turned on for your program:
- Subscribe to
validation_submission.updatedas well ascreated,succeeded, andfailed. See Update an existing subscription. - Update your handler to work from the latest status you retrieve, not the event name. See Build your handler.
- Agree what happens to a benefit you've already granted if the submission changes to
failed. See Decide what happens when a result changes. If you have questions, or want to know when this will happen for your program, contact your Goodstack account team.
Statuses
| Status | Meaning | What to do |
|---|---|---|
pending | Goodstack is still checking, or checking again. | Keep the benefit gated. |
processed | Checks are still in progress. This isn't a result. | Don't grant the benefit, and don't treat it as a failure. |
succeeded | All the checks in your program pass. | Grant the benefit. |
failed | At least one check failed. | Keep the benefit gated, or follow your policy if you've already granted it. |
The changes out of succeeded and failed only happen on programs where results can change.
Write your handler so it works from the current status, whatever came before:
- If the status is
succeeded, the applicant is verified. - If the status is
failed, the applicant isn't verified. - If the status is anything else, including one your code doesn't recognise, the verification is in progress. Don't grant the benefit, and don't treat it as a failure.
A failed API request isn't a failed result. A submission only gets a failed status after Goodstack has created it and checked it.
Events you receive
Subscribe to all four validation_submission events.
| When | Goodstack sends |
|---|---|
| A submission is created | validation_submission.created |
The status changes to succeeded | validation_submission.succeeded and validation_submission.updated |
The status changes to failed | validation_submission.failed and validation_submission.updated |
The status changes to anything else, such as pending | validation_submission.updated |
On programs where results don't change, Goodstack doesn't send validation_submission.updated. You receive created, then succeeded or failed.
How to handle them:
- Handle all four events in one code path. They have the same
eventData. - Retrieve the submission and act on its
status, not the event name. Events can arrive out of order, and thestatusineventDatacan already be newer than the event. - Make the update safe to repeat. A change to
succeededorfailedsends two events with different event IDs, so checking the event ID won't stop you processing the same change twice. See Build your handler. - Don't skip
validation_submission.updated. It's the only event sent for every status change. Without it, you won't find out when a submission goes back topending. - To spot a changed result, compare the status you retrieve with the one you've stored. No field in the event tells you.
Agent verification changes
If your program checks that the applicant is associated with the organisation, that check is an agent verification. Its result can change too. When it does:
- Goodstack sends a new
agent_verification.approvedoragent_verification.rejectedevent, in the same shape as the first decision. - On programs where submission results can change, Goodstack then works out the submission's status again, and sends the submission events above if it changes.
Which events you need:
- If you grant benefits from the submission, as most programs do, you only need the submission events.
- If you track applicants separately from submissions, also subscribe to
agent_verification.approvedandagent_verification.rejected.
Eligibility changes
If you use eligibility subscriptions directly:
results.eligibilityStatuscan change betweenpassandfail, for example when an organisation's activity tags change.- Goodstack sends
eligibility_subscription.updatedwhenever the results change. - The event doesn't include the previous value, so compare it with the one you've stored.
Event data
eventData contains the submission. Retrieve the submission before you act on it:
- Goodstack builds
eventDataafter the status change, so it can already include later changes. - A retry sends the same
eventDataas the first attempt, so it can be hours old by the time it arrives.
| Field | Type | Description |
|---|---|---|
id | string | The validation submission ID. Use it to retrieve the latest state. |
status | string | The status when the event data was built. See Statuses. |
createdAt | string | When the submission was created (ISO 8601). |
processedAt | string | null | When the submission was processed (ISO 8601). |
metadata | object | The metadata you sent when you created the submission, or {}. Use it to find your own record. |
partnerFields | object | The values of any additional fields set up for your program. |
organisationId | string | null | The Goodstack organisation. null until we've validated an organisation that was entered manually. |
organisationName | string | null | The organisation's name. Can be missing when there's no organisationId. |
validationSubmissionHostedConfigurationId | string | The program configuration for the submission. configurationId has the same value. |
validationInviteId | string | null | The validation invite used to create the submission, if there was one. |
validationRequestId | string | null | The validation request for an organisation that was entered manually, if there was one. |
validationRequestDocumentId | string | null | The supporting document attached to that validation request, if there was one. |
agentVerificationId | string | null | The agent verification, if your program has one. |
eligibilitySubscriptionId | string | null | The eligibility check, if your program has one. |
monitoringSubscriptionId | string | null | The compliance check, if your program has one. |
agentVerification | object | null | A summary of the agent verification. |
validationRequest | object | null | A summary of the validation request. |
failureReasons | array | Only present when status is failed. See Failure reasons. |
The applicant's name and email aren't included at the top level of eventData. There's no updatedAt and no previous status.
agentVerification has these fields:
| Field | Type | Description |
|---|---|---|
id | string | The agent verification ID. |
firstName | string | The applicant's first name. |
lastName | string | The applicant's last name. |
email | string | The applicant's email. Left out if the applicant was identified another way. |
identifiers | object | Other identifiers for the applicant. Left out if there aren't any. |
status | string | pending, pending_user_verification, pending_review, approved, or rejected. |
rejectionReasonCode | string | null | Why the agent verification was rejected, if it was. |
rejectionReason | string | null | Deprecated. Use rejectionReasonCode. |
validationRequest has these fields:
| Field | Type | Description |
|---|---|---|
id | string | The validation request ID. |
name | string | The organisation name that was submitted. |
countryCode | string | The organisation's country (ISO 3166-1 alpha-3). |
website | string | null | The organisation website that was submitted. |
organisationTypes | array | null | The organisation types the request was checked against. |
acceptedAt | string | null | When the request was accepted (ISO 8601). |
rejectedAt | string | null | When the request was rejected (ISO 8601). |
The nested statuses show progress, but the submission's top-level status is the result. Don't decide whether to grant a benefit from agentVerification.status or the validation request timestamps.
Here's the eventData for a submission that succeeded:
{
"id": "validationsubmission_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"status": "succeeded",
"createdAt": "2026-08-07T14:52:10.000Z",
"processedAt": "2026-08-07T14:58:41.000Z",
"metadata": {
"applicationId": "application_12345"
},
"partnerFields": {},
"organisationId": "organisation_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"organisationName": "Example Charity",
"validationSubmissionHostedConfigurationId": "hostedconfiguration_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"configurationId": "hostedconfiguration_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"validationInviteId": null,
"validationRequestId": null,
"validationRequestDocumentId": null,
"agentVerificationId": "agentverification_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"eligibilitySubscriptionId": "eligibilitysubscription_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"monitoringSubscriptionId": "monitoringsubscription_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"agentVerification": {
"id": "agentverification_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"firstName": "Alex",
"lastName": "Example",
"email": "alex@example.org",
"status": "approved",
"rejectionReason": null,
"rejectionReasonCode": null
},
"validationRequest": null
}
Get the check results
eventData has IDs and summaries, but not the full check results. Eligibility and compliance results are only included in failureReasons, so a succeeded event won't tell you which activity sub-tags were confirmed. To get them, retrieve the submission:
GET https://sandbox-api.goodstack.io/v1/validation-submissions/validationsubmission_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: <secret-key>
Accept: application/json
See Retrieve a Validation Submission for the response.
Redacted applicant details
Your program can redact any of firstName, lastName, and email in agentVerification:
- A redacted field is still present, but its value is the string
[REDACTED]. - A field that would otherwise be left out stays left out, such as
emailwhen the applicant was identified another way. - Don't validate
emailas an email address. - Use
metadataor the submission ID to find your own record, not the applicant's details.
Failure reasons
When status is failed, failureReasons in the webhook lists the checks that failed. Each item has a check and a reason. The fields in reason depend on the check.
check | Fields in reason | Meaning |
|---|---|---|
validation_request | rejectionReasonCode | We couldn't validate the organisation that was entered manually. |
agent_verification | rejectionReasonCode, and sometimes documentReuploadLimitReached | We couldn't verify that the applicant is associated with the organisation. |
eligibility | status, results | The organisation didn't meet the eligibility criteria, or we couldn't determine it. |
compliance | results | The organisation didn't pass the compliance policy for your program. |
country_compliance | status, results | The organisation didn't pass the country compliance check for your program. |
{
"id": "validationsubmission_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"status": "failed",
"failureReasons": [
{
"check": "agent_verification",
"reason": {
"rejectionReasonCode": "invalid_documentation",
"documentReuploadLimitReached": true
}
},
{
"check": "eligibility",
"reason": {
"status": "live",
"results": {
"eligibilityStatus": "fail",
"confirmedActivitySubTags": [],
"rejectedActivitySubTags": []
}
}
}
]
}
A few things to know:
- If the validation request failed, it's the only reason in the array. The other checks can't run until we've validated the organisation.
- Otherwise, you can get more than one reason. Agent verification, eligibility, and compliance failures can appear together.
documentReuploadLimitReachedis only included when it'strue. It means the applicant has used all the document re-uploads your program allows.- The array can be empty, and we can add checks and codes. Use
statusto decide the result, and send anything you don't recognise to your general review path. failureReasonsis only in the webhook. Retrieve a Validation Submission doesn't return it. See Get failure details from the API.
Get failure details from the API
The submission's status is the result. If you need to know why a submission failed and you don't have the webhook, find the details for each check here:
| Check | Where to find the details |
|---|---|
| Agent verification | agentVerification.rejectionReasonCode in the retrieved submission. |
| Validation request | rejectionReasonCode from Retrieve a Validation Request, using the submission's validationRequestId. |
| Eligibility | eligibility.status and eligibility.results in the retrieved submission. |
| Compliance | results from Retrieve a Monitoring Subscription, using the submission's monitoringSubscriptionId. |
| Country compliance | Not available from the API. Keep the country_compliance item from the webhook's failureReasons. |
If you keep failure reasons from webhooks:
documentReuploadLimitReachedis also only in the webhook.- Each failure reason describes the submission when that event was built. Don't let an older failure reason override the status you retrieve.
Fraud failures
If an application fails Goodstack's fraud and risk checks, the agent verification is rejected with the rejectionReasonCode suspicious_application, and the submission changes to failed. You can find it in either place:
- Webhook
- Retrieved submission
A failureReasons item with check set to agent_verification:
"failureReasons": [
{
"check": "agent_verification",
"reason": {
"rejectionReasonCode": "suspicious_application"
}
}
]
agentVerification.rejectionReasonCode in Retrieve a Validation Submission:
"agentVerification": {
"status": "rejected",
"rejectionReasonCode": "suspicious_application"
}
Any other code means the agent verification failed for a different reason, such as invalid_documentation.
| Situation | What you see |
|---|---|
| The fraud checks need a person to review it | Agent verification is pending_review, and the submission stays in progress. Wait. |
| A fraud review fails a submission that succeeded | The submission changes from succeeded to failed, on programs where results can change. See Decide what happens when a result changes. |
Don't show failure reasons, raw codes, or compliance results to applicants. Use them for support, analytics, and choosing the recovery path.
Build your handler
Use the endpoint and worker from Build your handler on the Webhooks page: verify the webhook, store it with a unique event ID, and respond. For each submission event, the worker retrieves the latest submission and updates your system.
The steps below are written as pseudo-code, so you can use them in any language.
WORKER process the next unprocessed validation_submission event
reconcileSubmission(event.eventData.id)
if it fails:
stop, and leave the event unprocessed so the worker tries it again
mark the event processed
reconcileSubmission(submissionId)
submission = GET /v1/validation-submissions/{submissionId}
if the request fails:
fail, and don't change anything
// A failed request isn't a failed result.
newStatus =
"verified" if submission.status is "succeeded"
"not_verified" if submission.status is "failed"
"in_progress" for anything else, including pending, processed,
and statuses your code doesn't recognise
// Do the rest in one database transaction, keyed on submission.id,
// so repeated events give the same result.
storedStatus = the status you've stored for submission.id
if newStatus is the same as storedStatus:
stop
save newStatus and submission.organisationId
save the action for the change from storedStatus to newStatus
The action for each change:
| Stored status | New status | What to do |
|---|---|---|
| Any | The same | Nothing. It's a duplicate, or the second event for the same change. |
in_progress | verified | Grant the benefit. |
in_progress | not_verified | Keep the benefit gated and follow your recovery path. |
verified | not_verified | Follow your policy for removing a benefit you've already granted. |
not_verified | verified | Grant the benefit. The earlier failure has been reversed. |
verified | in_progress | Follow your policy for a verification that's being checked again. |
not_verified | in_progress | Keep the benefit gated, and wait for the next result. |
If you run more than one worker, or your scheduled check can run at the same time as your worker, take a lock on the submission ID around reconcileSubmission. See Run more than one worker.
Grant and remove benefits
How you grant and remove benefits is up to you. The guidance below is what we recommend, not a requirement.
We recommend storing whether you've delivered the benefit separately from the verification status. That way, if delivery fails after a succeeded result, the stored status can stay verified while you retry the delivery.
What we recommend depends on what granting or removing the benefit changes:
- If it only changes your own database, such as setting a flag on the account, we recommend making the change in the same transaction that saves the status.
- If it calls another system, such as a billing provider, the call can fail after you've saved the new status. The next event has the same status, so your handler won't try again. A pattern like the one below helps make sure the change still happens.
One way to make changes in another system reliably:
- In the transaction that saves the status, also save a task for the grant or removal, with its own ID.
- Have a separate job carry out saved tasks, retrying each one until it succeeds, then marking it as done.
- If the other system accepts an idempotency key, use the task ID, so a retry can't grant or remove the benefit twice.
- Run the tasks for each application one at a time, in the order you saved them, and check before each one that the application's stored status still needs it. This stops a delayed grant from running after a removal.
Decide what happens when a result changes
Goodstack tells you that a result has changed. You decide what that means for the applicant.
When a succeeded submission changes to failed, you might:
- downgrade the account or remove the benefit
- suspend the account while you review it
- close the account, if the change was caused by fraud
- tell the applicant, and how they can contact you
When a failed submission changes to succeeded, grant the benefit and tell the applicant they now qualify.
Before your program goes live, or before changing results are turned on for it:
- Agree these policies. Don't remove access without one in place.
- Check what you send applicants. Don't include failure reasons, raw codes, or compliance results.
Reconcile results
Webhooks are the main way to find out about a result, but don't rely on them alone. If your endpoint is down for longer than the retry window, you'll miss events.
Run a scheduled job for submissions that don't have a result yet:
- Pass each one to
reconcileSubmission, the same function your webhook worker uses. Use the same lock, if you have one. - Stop checking a submission once it has a result, or when it reaches the age limit agreed for your program.
You don't need to check submissions you've already acted on. Goodstack sends validation_submission.updated if their result changes.