Webhooks
Goodstack sends a webhook to your server when something you've subscribed to changes: a verification gets a result, that result changes, or a donation moves to its next stage.
This page covers how to subscribe, what Goodstack sends, how to verify it, and how to build a handler that copes with retries, duplicates, and events that arrive out of order.
How it works
- You create a webhook subscription with the events you want and an
httpsURL. - When one of those events happens, Goodstack sends a
POSTrequest to your URL. - Your endpoint checks the signature, stores the event, and returns a
2xxresponse. - A worker picks up the stored event, retrieves the latest state from the API, and updates your system.
Step 4 matters. Goodstack can send the same event more than once, and events can arrive out of order. On some programs a verification result can also change after you first receive it. See Handle verification results. Use the webhook to find out that something changed, then read the current state from the API before you grant or remove anything.
Create a subscription
Call Create a Webhook Subscription from your backend with your secret key. The URL must use https and be no longer than 255 characters.
POST https://sandbox-api.goodstack.io/v1/webhook-subscriptions
Authorization: <secret-key>
Content-Type: application/json
{
"events": [
"validation_submission.created",
"validation_submission.succeeded",
"validation_submission.failed",
"validation_submission.updated"
],
"url": "https://example.org/webhooks/goodstack"
}
Goodstack returns the subscription, including its secret:
{
"data": {
"id": "webhooksubscription_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"events": [
"validation_submission.created",
"validation_submission.succeeded",
"validation_submission.failed",
"validation_submission.updated"
],
"url": "https://example.org/webhooks/goodstack",
"secret": "<webhook-subscription-secret>",
"createdAt": "2026-08-07T15:00:00.000Z",
"updatedAt": "2026-08-07T15:00:00.000Z",
"deletedAt": null
},
"object": "webhook_subscription"
}
Store the secret in your backend's secret store. You'll use it to verify every webhook sent to this subscription. It isn't one of your API keys, and each subscription has its own.
Create a subscription in sandbox and another in production. If you create two subscriptions for the same event and URL, you'll receive the event twice, signed with different secrets.
Update an existing subscription
To add validation_submission.updated, retrieve your subscription, then call Edit a Webhook Subscription with its ID. The events array replaces the existing array, so include every event you want to keep.
For example, if your subscription has the other three submission events, send all four. If it has other events too, include those as well:
PATCH https://sandbox-api.goodstack.io/v1/webhook-subscriptions/webhooksubscription_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: <secret-key>
Content-Type: application/json
{
"events": [
"validation_submission.created",
"validation_submission.succeeded",
"validation_submission.failed",
"validation_submission.updated"
]
}
Editing the events keeps the subscription's URL and signing secret, so you don't need to change your verification code. Update your subscriptions in both sandbox and production.
Event format
Every webhook is a POST request with a JSON body and a Goodstack-Signature header. All events use the same envelope:
{
"object": "event",
"data": {
"id": "event_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"createdAt": "2026-08-07T15:00:00.000Z",
"eventType": "validation_submission.succeeded",
"eventData": {}
}
}
| Field | Type | Description |
|---|---|---|
object | string | Always event. |
data.id | string | The event ID. If Goodstack retries a webhook, the retry has the same ID. |
data.createdAt | string | When Goodstack recorded the event (ISO 8601). This isn't the delivery time. |
data.eventType | string | The event name, for example validation_submission.succeeded. |
data.eventData | object | The resource the event is about. The shape depends on the event. |
Goodstack builds eventData after the change that triggered the event, so it can already include later changes. For example, a validation_submission.succeeded event can arrive with a status of failed if the submission changed again in between. Delivery can also be delayed, and a retry sends the same eventData as the first attempt. Treat eventData as a summary, and retrieve the resource from the API before you act on it.
data.createdAt tells you when the event was recorded, not when the resource changed. Don't use it to decide which of two events is newer. For monetary_donation_verification events, data.createdAt is when Goodstack built the webhook, just before it was queued for delivery.
Goodstack can add events, fields, and enum values over time. Ignore events and fields you don't recognise, and don't reject an event because of a value you haven't seen before.
Verifying webhooks
Check the Goodstack-Signature header on every webhook before you process it. The header contains the HMAC-SHA256 of the request body, hex encoded, using the subscription's secret as the key.
To verify a webhook:
- Read the raw request body. Don't parse and re-serialise it first.
- Compute the HMAC-SHA256 of the raw body with the subscription
secretas the key, and hex encode it. - Compare your result with the
Goodstack-Signatureheader using a constant-time comparison. - If they don't match, return an error and don't process the event.
The signature covers the request body only. It doesn't include a timestamp, so also check the event ID against the events you've already stored. That way a replayed request has no effect.
Get in contact for support with webhook verification on any systems not listed below
- JavaScript
- PHP
- Python
- Go
- Java
const crypto = require('crypto')
const express = require('express')
const app = express()
// The `secret` from your webhook subscription. This isn't an API key.
const subscriptionSecret = process.env.GOODSTACK_WEBHOOK_SECRET
const verifySignature = (secret, rawBody, signature) => {
if (typeof signature !== 'string') {
return false
}
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
const expectedBuffer = Buffer.from(expected)
const receivedBuffer = Buffer.from(signature)
return (
expectedBuffer.length === receivedBuffer.length &&
crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
)
}
app.post(
'/webhooks/goodstack',
// Keep the raw body so the signature matches the bytes Goodstack sent.
express.raw({ type: 'application/json' }),
(request, response) => {
// Node lower-cases header names.
const signature = request.headers['goodstack-signature']
if (!verifySignature(subscriptionSecret, request.body, signature)) {
return response.status(401).send()
}
const { data: event } = JSON.parse(request.body.toString('utf8'))
// Store the event for your worker. See "Build your handler".
return response.status(200).send()
},
)
<?php
define('WEBHOOK_SUBSCRIPTION_SECRET', 'your_webhook_subscription_secret');
function verify_webhook($data, $hmac_header)
{
# Calculate HMAC (hex encoded)
$calculated_hmac = hash_hmac('sha256', $data, WEBHOOK_SUBSCRIPTION_SECRET);
# Constant-time comparison
return hash_equals($calculated_hmac, $hmac_header);
}
# Extract the signature header
$hmac_header = $_SERVER['HTTP_GOODSTACK_SIGNATURE'] ?? '';
# Get the raw body
$data = file_get_contents('php://input');
# Compare HMACs
$verified = verify_webhook($data, $hmac_header);
error_log('Webhook verified: '.var_export($verified, true));
if ($verified) {
# Do something with the webhook
} else {
http_response_code(401);
}
?>
from flask import Flask, request, abort
import hmac
import hashlib
app = Flask(__name__)
WEBHOOK_SUBSCRIPTION_SECRET = 'your_webhook_subscription_secret'
def verify_webhook(data, hmac_header):
# Calculate HMAC (hex encoded)
digest = hmac.new(WEBHOOK_SUBSCRIPTION_SECRET.encode('utf-8'), data, digestmod=hashlib.sha256).hexdigest()
# Constant-time comparison
return hmac.compare_digest(digest, hmac_header)
@app.route('/webhook', methods=['POST'])
def handle_webhook():
# Get raw body
data = request.get_data()
# Compare HMACs
verified = verify_webhook(data, request.headers.get('Goodstack-Signature', ''))
if not verified:
abort(401)
# Do something with the webhook
return ('', 200)
package verifywebhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
)
// Hook is an inbound webhook
type Hook struct {
Signature string
Payload []byte
}
func signBody(secret, body []byte) string {
// Calculate HMAC-SHA256 (hex encoded)
computed := hmac.New(sha256.New, secret)
computed.Write(body)
return hex.EncodeToString(computed.Sum(nil))
}
func (h *Hook) SignedBy(secret []byte) bool {
expected := signBody(secret, h.Payload)
return hmac.Equal([]byte(expected), []byte(h.Signature))
}
func (h *Hook) Extract(dst interface{}) error {
return json.Unmarshal(h.Payload, dst)
}
func New(req *http.Request) (hook *Hook, err error) {
hook = new(Hook)
if req.Method != "POST" {
return nil, errors.New("Unknown method!")
}
// Extract signature
if hook.Signature = req.Header.Get("Goodstack-Signature"); len(hook.Signature) == 0 {
return nil, errors.New("No signature!")
}
// Get raw body
hook.Payload, err = io.ReadAll(req.Body)
return
}
func Parse(secret []byte, req *http.Request) (hook *Hook, err error) {
hook, err = New(req)
// Compare HMACs
if err == nil && !hook.SignedBy(secret) {
err = errors.New("Invalid signature")
}
return
}
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
class SignatureVerificationHandler implements HttpHandler {
private String encodingAlgorithm = "HmacSHA256";
private String secretKey = "yourWebhookSubscriptionSecret";
private String headerThatContainsSignature = "Goodstack-Signature";
private boolean verifySignature(byte[] payload, String signature) throws NoSuchAlgorithmException, InvalidKeyException {
if (signature == null) {
return false;
}
var sha256_HMAC = Mac.getInstance(encodingAlgorithm);
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), encodingAlgorithm);
sha256_HMAC.init(secretKeySpec);
byte[] hash = sha256_HMAC.doFinal(payload);
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
hexString.append(String.format("%02x", b));
}
// Constant-time comparison
return MessageDigest.isEqual(
hexString.toString().getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8)
);
}
@Override
public void handle(HttpExchange httpExchange) throws IOException {
Headers headers = httpExchange.getRequestHeaders();
String signature = headers.getFirst(headerThatContainsSignature);
byte[] payload = readBody(httpExchange);
try {
boolean isValidMessage = verifySignature(payload, signature);
if (isValidMessage){
returnWithStatus(httpExchange, 200);
return;
}
} catch (Exception e) {
returnWithStatus(httpExchange, 500);
return;
}
returnWithStatus(httpExchange, 401);
}
private byte[] readBody(HttpExchange httpExchange) throws IOException {
// Use the raw bytes so the signature matches what Goodstack sent.
return httpExchange.getRequestBody().readAllBytes();
}
private void returnWithStatus(HttpExchange httpExchange, int httpStatusCode) throws IOException {
httpExchange.sendResponseHeaders(httpStatusCode, 0);
httpExchange.getResponseBody().close();
}
}
Respond to events
Return a 2xx status code within 20 seconds to tell Goodstack you've received the webhook. Any other status code, a timeout, or a connection error counts as a failed delivery.
Respond as soon as you've verified and stored the event. Do slower work, such as fulfilment, emails, and API calls, in a background job. See Build your handler.
Retries
If a delivery fails, Goodstack retries it up to 4 times. Each retry waits longer than the one before:
| Retry | Sent after the previous attempt fails |
|---|---|
| 1 | 10 minutes |
| 2 | 1 hour |
| 3 | 4 hours |
| 4 | 8 hours |
Retry times are approximate. The last retry happens about 14 hours after the first attempt. If it fails, Goodstack stops sending that event, and it can't be resent on request. Use reconciliation to pick up anything you missed.
Build your handler
Split the work in two. Your endpoint verifies the webhook, stores it, and responds. A worker then picks up each stored event, retrieves the latest state from the API, and updates your system. That way a slow database or a problem further down your stack doesn't cause failed deliveries, and you can run the worker again for any event you've stored.
The steps below are written as pseudo-code, so you can use them in any language.
ENDPOINT POST /webhooks/goodstack
signature = the Goodstack-Signature header
if signature doesn't match the raw request body:
respond 401 and stop
event = the "data" object from the request body
// Use a unique constraint on the event ID so a duplicate does nothing.
insert the event as unprocessed, unless event.id is already stored
if the insert fails:
respond 500 and stop, so Goodstack retries
// Respond once the event is stored. Do this for duplicates too.
respond 200
WORKER process the next unprocessed event
resourceType = the part of event.eventType before the first "."
if your code doesn't handle resourceType:
mark the event processed and stop
resource = GET the retrieve endpoint for resourceType, using event.eventData.id
if the request fails:
stop, and leave the event unprocessed so the worker tries it again
// Do these in one database transaction.
if resource is different from your stored state:
save the new state
mark the event processed
Use the verification code for your language to check the signature. To find the retrieve endpoint for each event type, see Handle events out of order.
The worker reads unprocessed events from your database, so an event can't get lost between being stored and being processed. If you hand events to a separate job queue instead, make sure a job can't be lost if your server stops after storing the event but before queueing it. For example, have a scheduled job queue any event that's still unprocessed after a few minutes.
If acting on an event calls another system, such as granting a benefit through a billing provider, see Grant and remove benefits.
Handle duplicates
You can receive the same event more than once, for example when your 2xx response doesn't reach Goodstack and the event is retried. A retry has the same data.id. Store each event ID with a unique constraint, skip any you've already stored, and still return a 2xx so Goodstack stops retrying.
Checking the event ID also protects you against a replayed request, because the signature doesn't include a timestamp.
Some changes send more than one event, each with its own ID. For example, on a program where verification results can change, a submission that changes to succeeded sends validation_submission.succeeded and validation_submission.updated. Make the update in your system safe to repeat: compare the latest state with the state you've stored, and only act when they're different.
Handle events out of order
Goodstack doesn't guarantee the order of events. Several deliveries can be in flight at once, and a retried event can arrive hours after a newer one. This applies to every event type.
Don't use the order events arrive in, the event name, or data.createdAt to work out the current state. When an event arrives, retrieve the resource by its ID and act on what the API returns:
| Events | Retrieve the latest state with |
|---|---|
validation_submission.* | Retrieve a Validation Submission |
agent_verification.* | Retrieve an Agent Verification |
validation_request.* | Retrieve a Validation Request |
eligibility_subscription.updated | Retrieve an Eligibility Subscription |
monitoring_subscription.updated | Retrieve a Monitoring Subscription |
donation.* | Retrieve a Donation |
monetary_donation_verification.* | Get a Monetary Donation Verification by ID |
There's no endpoint to retrieve a country compliance check, so the worker above can't handle country_compliance_check.updated. If you grant benefits from validation submissions, use the submission events and the submission's status instead, and don't let a country compliance event override that status. If you use country compliance events on their own, store each one you receive.
Run more than one worker
If you run more than one worker, or your scheduled check can run at the same time as your worker, two of them can process the same resource at once. One can retrieve an older state, pause, and then save it over a newer one.
To prevent this, take a lock on the resource ID before you retrieve the resource, and release it after you've saved the result. Use a lock every process can see, such as a database lock. A lock held in one process's memory doesn't stop another process. If your lock can expire, check that you still hold it before you save.
If you run a single worker, and your scheduled check sends its work through that worker, you don't need a lock.
Reconcile missed events
Don't rely on webhooks alone. If your endpoint is down for longer than the retry window, or a bug drops an event after you've stored it, your system falls behind. Run a scheduled job that uses the same retrieve and list endpoints to check anything you're still waiting on a result for, such as a verification that's in progress.
Pass each resource to the same code your webhook worker uses, so a webhook and a scheduled check always produce the same result. If your worker takes a lock, the scheduled check needs to take the same one. See Run more than one worker.
Verification results
If you verify organisations or applicants with validation submissions, read Handle verification results. It covers statuses, which events you receive, how results can change after they're decided, and how to grant and remove benefits.
Event types
Each event links to its payload reference. Goodstack can add event types over time, so ignore any you don't recognise.
| Event | Sent when |
|---|---|
validation_submission.created | A validation submission was created |
validation_submission.succeeded | A validation submission's status changed to succeeded |
validation_submission.failed | A validation submission's status changed to failed |
validation_submission.updated | A validation submission's status changed |
validation_request.approved | A validation request was approved |
validation_request.rejected | A validation request was rejected |
agent_verification.pending_review | An agent passed email verification and is awaiting Goodstack review |
agent_verification.pending_user_verification | An agent passed Goodstack review and is awaiting email verification |
agent_verification.approved | An agent verification was approved |
agent_verification.rejected | An agent verification was rejected |
monitoring_subscription.updated | A monitoring subscription was updated |
eligibility_subscription.updated | An eligibility subscription was updated |
country_compliance_check.updated | A country compliance check result was stored or changed |
donation.hosted.payment_received | A hosted donation payment was received |
donation.created | A donation was created |
donation.payment_successful | A donation payment was successful |
donation.settled | A donation was settled |
donation.disbursed | A donation was disbursed |
donation.cancelled | A donation was cancelled |
monetary_donation_verification.received | A monetary donation verification was received |
monetary_donation_verification.progressed | A monetary donation verification progressed |
If you verify organisations with validation submissions, subscribe to the validation_submission events and not the events for each check. The submission's status already combines them.
Webhook source IP addresses
The full list of IP addresses that webhook notifications may come from.
Production environment:
54.228.234.20454.76.67.16834.248.188.8934.243.152.218
Sandbox environment:
79.125.45.12454.76.168.24099.81.243.14554.220.118.167