VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Groups
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
Go library
Check access
Receive webhooks
Persons and events
Custom payment providers

Receive webhooks

Verify and handle Voidhash webhook events in a Go server.

voidhash.ConstructWebhookEvent verifies a webhook request and parses its body. It needs only the endpoint's signing secret (whsec_…), here VOIDHASH_WEBHOOK_SECRET; no client or secret key.

Create an endpoint

In Studio, open Settings → Webhooks, select New webhook, and enter your endpoint's public HTTPS URL, its environment and the events it receives. A Production endpoint receives production purchases; a Test endpoint receives sandbox, simulated and test mode purchases. Studio shows the signing secret (whsec_…) once; store it in your secret manager, here as VOIDHASH_WEBHOOK_SECRET. See Endpoints for every field, or create one from code.

Verify a request

Pass the raw body bytes, never a re-encoded body, with r.Header. Any router that hands you an *http.Request, such as chi or gorilla/mux, works the same way.

webhooks.go
package main

import (
	"context"
	"database/sql"
	"io"
	"log"
	"net/http"
	"os"

	_ "github.com/jackc/pgx/v5/stdlib"
	voidhash "github.com/voidhashcom/voidhash-go"
)

func voidhashWebhook(db *sql.DB) http.HandlerFunc {
	secret := os.Getenv("VOIDHASH_WEBHOOK_SECRET")

	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}

		event, err := voidhash.ConstructWebhookEvent(body, r.Header, secret)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		w.WriteHeader(http.StatusOK)
		go func() {
			if err := handleEvent(context.WithoutCancel(r.Context()), db, event); err != nil {
				log.Printf("handle %s: %v", event.Type, err)
			}
		}()
	}
}

func main() {
	db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	mux := http.NewServeMux()
	mux.Handle("POST /webhooks/voidhash", voidhashWebhook(db))
	log.Fatal(http.ListenAndServe(":8080", mux))
}

The handler answers 200 before it processes the event. In production, store the event first, so a crash after answering doesn't lose it.

Handle events

ConstructWebhookEvent returns a *voidhash.WebhookEvent:

FieldTypeDescription
TypestringThe X-Webhook-Event header, such as purchase.refunded.
Payloadjson.RawMessageThe JSON body. Unmarshal the fields you need.
Timestamptime.TimeWhen the attempt was signed.

Switch on Type and skip duplicates:

handle_event.go
package main

import (
	"context"
	"database/sql"
	"encoding/json"

	voidhash "github.com/voidhashcom/voidhash-go"
)

type lifecyclePayload struct {
	PersonID       string  `json:"personId"`
	DistinctID     string  `json:"distinctId"`
	SubscriptionID string  `json:"subscriptionId"`
	PurchaseID     *string `json:"purchaseId"`
	OccurredAt     string  `json:"occurredAt"`
	ErasedAt       string  `json:"erasedAt"`
}

func handleEvent(ctx context.Context, db *sql.DB, event *voidhash.WebhookEvent) error {
	var payload lifecyclePayload
	if err := json.Unmarshal(event.Payload, &payload); err != nil {
		return err
	}
	subject := payload.PersonID
	if payload.PurchaseID != nil {
		subject = *payload.PurchaseID
	}
	if payload.SubscriptionID != "" {
		subject = payload.SubscriptionID
	}
	key := event.Type + ":" + subject + ":" + payload.OccurredAt + payload.ErasedAt

	// Deliveries are at least once: skip one you have already processed.
	claimed, err := db.ExecContext(ctx, `INSERT INTO processed_webhooks (key) VALUES ($1) ON CONFLICT DO NOTHING`, key)
	if err != nil {
		return err
	}
	if rows, _ := claimed.RowsAffected(); rows == 0 {
		return nil
	}

	switch event.Type {
	case voidhash.WebhookEventSubscriptionRenewed, voidhash.WebhookEventSubscriptionExpired, voidhash.WebhookEventPurchaseRefunded:
		_, err = db.ExecContext(ctx, `DELETE FROM access_cache WHERE distinct_id = $1`, payload.DistinctID)
	case voidhash.WebhookEventPersonDeleted:
		_, err = db.ExecContext(ctx, `DELETE FROM users WHERE voidhash_person_id = $1`, payload.PersonID)
	default:
		// Includes test.ping and event types added after this code.
	}
	return err
}

The library exports a WebhookEvent… constant for each event type, and WebhookTestPing for test.ping.

Verification errors

ConstructWebhookEvent returns only *voidhash.WebhookVerificationError errors. Read one with errors.As: its Reason says why, and Detail adds a message for your logs.

ReasonCause
missing_headerA signing header is missing, empty or sent more than once.
timestamp_out_of_toleranceThe timestamp is malformed or more than 300 seconds from your clock.
invalid_signatureThe signature doesn't match: wrong secret, or a modified or re-serialized body.
invalid_payloadThe signature matches but the body isn't valid JSON.

Answer 400 for each; see Troubleshooting.

Options

ConstructWebhookEvent always accepts a clock difference of 300 seconds and verifies against the current time. voidhash.VerifyWebhookSignature(payload, signature, timestamp, secret, tolerance, now) takes both as arguments and returns a boolean, for tests or when you read the headers yourself.

Respond and retries

Answer any 2xx within 30 seconds. A redirect, a 4xx or 5xx, a timeout or a connection error is a failed attempt. Voidhash retries it 5 minutes, 30 minutes, 2 hours and 24 hours after the attempt before, signing each retry again with a new timestamp, then marks the delivery exhausted. After 20 consecutive failed attempts it sets the endpoint to Failed and stops sending to it until you enable it again. See Retries and the delivery log.

Duplicates and ordering

Delivery is at least once: a timeout after you processed an event, or a manual retry, sends it again. Payloads carry no event ID, so the handler above builds a key from the type, the subject's ID and occurredAt (erasedAt for person.deleted). Events can also arrive out of order; compare occurredAt before you overwrite newer state, or check access for the current answer.

Rotate the secret

Rotate a secret that may have leaked with Rotate secret in the endpoint's actions menu, voidhash webhook rotate, or client.Webhooks.Endpoints.RotateSecret. The old secret stops working at once, including for retries of earlier events, so deploy the new one right away.

Test locally

Voidhash can't reach localhost, so expose your local server through a tunnel such as ngrok http 8080 or cloudflared tunnel --url http://localhost:8080, and create a Test endpoint with the tunnel's HTTPS URL. Then:

  • Select Send test in the endpoint's actions menu, or run voidhash webhook test <webhook>, which waits for your server's answer and prints it. A test only goes to an endpoint that is Active. Its test.ping payload has no type field.
  • Make a sandbox or simulated purchase to receive real lifecycle events. See Test purchases (Help center).

Troubleshooting

  • Every signature fails. Something read or decoded r.Body before the handler, such as a middleware that decodes JSON. Pass the exact bytes you read. Or you are using another endpoint's secret: each endpoint has its own, and rotating replaces it at once.
  • Signatures fail only sometimes. Check your server's clock. Drift beyond 300 seconds rejects valid requests; keep clocks synced with NTP.
  • A proxy rewrites the body. Some proxies re-encode JSON or change line endings. Verify against the bytes your application receives, and turn off body rewriting on the webhook route.

Manage endpoints from code

client.Webhooks.Endpoints manages endpoints with a secret key. Only Create and RotateSecret return the signing secret. Event and environment values are typed in the api package:

cmd/create-webhook/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	voidhash "github.com/voidhashcom/voidhash-go"
	"github.com/voidhashcom/voidhash-go/api"
)

func main() {
	client, err := voidhash.New(os.Getenv("VOIDHASH_SECRET_KEY"))
	if err != nil {
		log.Fatal(err)
	}

	environment := api.IntegrationEnvironmentProduction
	webhook, err := client.Webhooks.Endpoints.Create(context.Background(), voidhash.CreateWebhookEndpointParams{
		Name: "Orders backend",
		Url:  "https://api.example.com/webhooks/voidhash",
		Events: []api.CreateWebhookBodyJsonEncodingEvents{
			api.CreateWebhookBodyJsonEncodingEventsSubscriptionCreated,
			api.CreateWebhookBodyJsonEncodingEventsSubscriptionRenewed,
			api.CreateWebhookBodyJsonEncodingEventsPurchaseCompleted,
		},
		Environment: &environment,
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(webhook.Id, webhook.Secret) // wh_ep_… whsec_…
}

Other endpoint methods: List, Get, Update, Delete, RotateSecret and Test. client.Webhooks.Deliveries has List(ctx, endpointID), Get(ctx, deliveryID) and Retry(ctx, deliveryID).

Next steps

  • Webhook events: every event type and its payload.
  • Check access
  • Persons and events

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousCheck accessNextPersons and events

On this page

Create an endpointVerify a requestHandle eventsVerification errorsOptionsRespond and retriesDuplicates and orderingRotate the secretTest locallyTroubleshootingManage endpoints from codeNext steps