Skip to main content

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​

  1. You create a webhook subscription with the events you want and an https URL.
  2. When one of those events happens, Goodstack sends a POST request to your URL.
  3. Your endpoint checks the signature, stores the event, and returns a 2xx response.
  4. 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": {}
}
}
FieldTypeDescription
objectstringAlways event.
data.idstringThe event ID. If Goodstack retries a webhook, the retry has the same ID.
data.createdAtstringWhen Goodstack recorded the event (ISO 8601). This isn't the delivery time.
data.eventTypestringThe event name, for example validation_submission.succeeded.
data.eventDataobjectThe 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:

  1. Read the raw request body. Don't parse and re-serialise it first.
  2. Compute the HMAC-SHA256 of the raw body with the subscription secret as the key, and hex encode it.
  3. Compare your result with the Goodstack-Signature header using a constant-time comparison.
  4. 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.

info

Get in contact for support with webhook verification on any systems not listed below

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()
},
)

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:

RetrySent after the previous attempt fails
110 minutes
21 hour
34 hours
48 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:

EventsRetrieve the latest state with
validation_submission.*Retrieve a Validation Submission
agent_verification.*Retrieve an Agent Verification
validation_request.*Retrieve a Validation Request
eligibility_subscription.updatedRetrieve an Eligibility Subscription
monitoring_subscription.updatedRetrieve 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.

EventSent when
validation_submission.createdA validation submission was created
validation_submission.succeededA validation submission's status changed to succeeded
validation_submission.failedA validation submission's status changed to failed
validation_submission.updatedA validation submission's status changed
validation_request.approvedA validation request was approved
validation_request.rejectedA validation request was rejected
agent_verification.pending_reviewAn agent passed email verification and is awaiting Goodstack review
agent_verification.pending_user_verificationAn agent passed Goodstack review and is awaiting email verification
agent_verification.approvedAn agent verification was approved
agent_verification.rejectedAn agent verification was rejected
monitoring_subscription.updatedA monitoring subscription was updated
eligibility_subscription.updatedAn eligibility subscription was updated
country_compliance_check.updatedA country compliance check result was stored or changed
donation.hosted.payment_receivedA hosted donation payment was received
donation.createdA donation was created
donation.payment_successfulA donation payment was successful
donation.settledA donation was settled
donation.disbursedA donation was disbursed
donation.cancelledA donation was cancelled
monetary_donation_verification.receivedA monetary donation verification was received
monetary_donation_verification.progressedA 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.204
  • 54.76.67.168
  • 34.248.188.89
  • 34.243.152.218

Sandbox environment:

  • 79.125.45.124
  • 54.76.168.240
  • 99.81.243.145
  • 54.220.118.167