Go SDK
The webhooks.cc Go module captures webhooks and email in your tests, waits for them, sends signed webhooks to your own handlers, and verifies provider and forwarding signatures. Includes testing.TB helpers.
Updated Oct 2026
The webhooks.cc/go module brings the TypeScript SDK's testing tools to Go: capture webhooks and email, wait for them, send signed webhooks to your own handler, and verify signatures. It passes the same conformance tests as the TypeScript SDK, so signatures and matchers behave identically in both. It has no dependencies outside the standard library.
Install
go get webhooks.cc/goGo 1.25 or later. Create an API key on your API keys page and set WHK_API_KEY, or pass webhookscc.WithAPIKey. The package is named webhookscc.
Wait for a webhook
import (
"testing"
"time"
webhookscc "webhooks.cc/go"
"webhooks.cc/go/whktest"
)
func TestCheckoutSendsAWebhook(t *testing.T) {
client := whktest.Client(t) // skipped when WHK_API_KEY is not set
endpoint := whktest.Endpoint(t, client) // deleted when the test ends
triggerCheckout(endpoint.URL)
request, err := client.Requests.WaitFor(t.Context(), endpoint.Slug, webhookscc.WaitOptions{
Timeout: 30 * time.Second,
Match: webhookscc.MatchBodyPath("type", "checkout.session.completed"),
})
if err != nil {
t.Fatal(err)
}
var event struct {
Data struct {
Object struct {
AmountTotal int `json:"amount_total"`
} `json:"object"`
} `json:"data"`
}
if err := request.JSON(&event); err != nil || event.Data.Object.AmountTotal != 2000 {
t.Errorf("amount_total = %d, %v", event.Data.Object.AmountTotal, err)
}
}WaitFor looks back five minutes, so a webhook that arrived just before you started waiting still counts. WaitForAll(ctx, slug, 3, opts) collects several, oldest first. When time runs out the error matches webhookscc.ErrTimeout; when your context ends first, you get the context's error. Times such as ReceivedAt are time.Time.
Outside tests, create a client with webhookscc.New(), and use client.WithTemporaryEndpoint(ctx, opts, fn) for an endpoint that is deleted when fn returns.
Matchers
A Matcher is a func(*webhookscc.Request) bool. Combine them with MatchAll and MatchAny, or write your own.
match := webhookscc.MatchAll(
webhookscc.MatchMethod("POST"),
webhookscc.MatchPath("/webhooks/**"),
webhookscc.MatchHeader("X-GitHub-Event", "push"),
)
request, err := client.Requests.WaitFor(ctx, endpoint.Slug, webhookscc.WaitOptions{Match: match})Available: MatchMethod, MatchPath (globs: * within a segment, ** across segments), MatchHeader and MatchHeaderPresent, MatchQueryParam and MatchQueryParamPresent, MatchBodyPath, MatchBodySubset, MatchContentType, MatchVerified, MatchUnverified and MatchEmail. See Matchers & Helpers for what each one matches.
Capture what your code sends
CaptureDuring creates a temporary endpoint, runs your code with it, waits for what it sent, and deletes the endpoint:
requests, err := client.CaptureDuring(ctx, func(endpoint *webhookscc.Endpoint) error {
return notify(endpoint.URL)
}, webhookscc.CaptureOptions{})
if err != nil {
t.Fatal(err)
}
fmt.Println(requests[0].Body)Test email: codes and links
Every endpoint receives email at {slug}@mailhooks.cc, and at {slug}+{tag}@mailhooks.cc for any tag. Use a fresh tag per test run to find your own email. webhooks.cc finds the one-time codes and links in each email, the same ones the dashboard shows, and the SDK reads them from the API.
run := fmt.Sprintf("signup-%d", time.Now().UnixNano())
address, err := client.Emails.Address(endpoint.Slug, run)
if err != nil {
t.Fatal(err)
}
signUp(address)
email, err := client.Emails.WaitFor(t.Context(), endpoint.Slug, webhookscc.EmailQuery{
Tag: run,
Subject: "Confirm your email",
})
if err != nil {
t.Fatal(err)
}
code, _ := webhookscc.ExtractCode(email) // "482913"
link, _ := webhookscc.ExtractLink(email) // "https://app.example.com/confirm?token=..."email.Email holds the parsed message: Subject, From, To, Text, HTML, attachments, and the SPF, DKIM and DMARC results. ExtractCode returns an error when the endpoint's Show codes and links found in emails setting is off. See Email capture for how capture works.
Send signed webhooks to your handler
Test your own webhook handler, on localhost or anywhere else, with requests signed the way the provider signs them:
response, err := client.SendTo(ctx, "http://localhost:8080/webhooks/stripe", webhookscc.SendOptions{
Provider: "stripe",
Secret: "whsec_test_secret",
Template: "checkout.session.completed",
})
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK {
t.Errorf("status = %d", response.StatusCode)
}Leave out Body to send the provider's sample payload, or pass your own: a string, []byte or json.RawMessage goes as it is, anything else as JSON. Timestamp and MessageID make the signature repeatable. webhookscc.BuildRequest returns the method, URL, headers and body without sending them. Signed requests never follow redirects, even with an http.Client of your own that does, so they only reach the host you named. client.Endpoints.Send(ctx, slug, opts) sends the same to a webhooks.cc endpoint, and webhookscc.TemplateProviders() lists the 34 providers.
Verify signatures
The webhooks.cc/go/verify package checks the signatures of 32 providers. It does not need a client or an API key, so you can use it in production handlers:
import "webhooks.cc/go/verify"
func stripeWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) // the exact bytes, before any JSON parsing
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
valid, err := verify.Signature("stripe", body, r.Header, verify.Options{Secret: secret})
if err != nil || !valid {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// handle the event
}Headers can be an http.Header, a captured request's Headers, or a verify.Map. Twilio, Square and HubSpot also need URL; HubSpot also needs Method. Discord takes PublicKey. An invalid signature is false with no error; the error is for a call that cannot be checked, such as a missing secret or an unknown provider. See Signature Verification for each provider's scheme.
Verify what webhooks.cc forwards
When webhooks.cc forwards requests or email to your server, check that they came from it with the endpoint's forwarding secret (client.Forwarding.Secret(ctx, slug)):
event, err := verify.ForwardedEmail(body, r.Header, secret)
if err != nil {
status := http.StatusUnauthorized
var verifyErr *verify.Error
if errors.As(err, &verifyErr) && verifyErr.Code == verify.CodeInvalidPayload {
status = http.StatusBadRequest
}
http.Error(w, "invalid delivery", status)
return
}
var email struct {
Subject string `json:"subject"`
}
_ = json.Unmarshal(event.Data, &email)A *verify.Error says why a delivery was refused (Code); any other error means the call itself was wrong, such as an empty secret. verify.ForwardedRequest checks a request forwarded as JSON, and verify.RelayedRequest one relayed as it was received.
Stream requests as they arrive
stream := client.Requests.Subscribe(ctx, endpoint.Slug, webhookscc.SubscribeOptions{
Timeout: 10 * time.Minute,
Reconnect: true,
})
for request, err := range stream {
if err != nil {
return err
}
fmt.Println(request.Method, request.Path)
}The stream ends at Timeout, when the endpoint is deleted, or when you break out of the loop. With Reconnect it follows the server's 30-minute connection rotation and recovers from dropped connections without missing requests.
Errors
API errors are a *webhookscc.Error with a StatusCode. Test them with errors.Is:
| Error | When |
|---|---|
webhookscc.ErrUnauthorized | 401: the API key is missing or invalid |
webhookscc.ErrNotFound | 404: the endpoint, request or email does not exist |
webhookscc.ErrRateLimited | 429; the *Error carries RetryAfter when the server sends it |
webhookscc.ErrTimeout | A request, wait or stream ran out of time |
webhookscc.ErrMissingAPIKey | New found no API key |
Network failures return the net/http error. Retries are off unless you pass webhookscc.WithRetry(webhookscc.RetryOptions{MaxAttempts: 3}). They repeat GET, PUT and DELETE calls after a network error, a timeout or a status in RetryOn (429, 500, 502, 503 and 504 by default), and any call after a 429; a POST or PATCH that failed any other way may have gone through, so it is not repeated, and neither is Requests.Clear without Before.
Configuration
| Option | Environment variable | Default |
|---|---|---|
WithAPIKey | WHK_API_KEY | (required) |
WithBaseURL | WHK_BASE_URL | https://webhooks.cc |
WithWebhookURL | WHK_WEBHOOK_URL | https://go.webhooks.cc |
WithTimeout | 30 seconds per API call | |
WithHTTPClient | a new http.Client |
The source is in packages/sdk-go, MIT licensed, and the API reference is on pkg.go.dev.