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 Nov 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. An AI agent without an account can start in the agent sandbox.
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. The Go testing guide walks through a whole suite: handler tests with httptest, retries, emails and CI.
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, ? one character), MatchHeader and MatchHeaderPresent, MatchQueryParam and MatchQueryParamPresent, MatchBodyPath, MatchBodySubset, MatchContentType, MatchVerified, MatchUnverified and MatchEmail. See Matchers & Helpers for what each one matches.
Preview a response
Endpoints.PreviewResponse shows what a captured request would get back from the endpoint's response rules, with placeholders filled in, without sending anything. ResponseRules and MockResponse take Value to try unsaved settings and Null to try none; left unset, the saved ones apply.
preview, err := client.Endpoints.PreviewResponse(ctx, endpoint.Slug, webhookscc.PreviewResponseOptions{})
if err != nil {
return err
}
if preview.Rule != nil {
fmt.Println("rule", preview.Rule.Index, "answers")
}Favourite endpoints
Mark an endpoint, your own or one a team shares with you, as a favourite: the dashboard lists favourites first and opens the first one by default.
favorite, err := client.Endpoints.Favorite(ctx, endpoint.Slug) // true
favorite, err = client.Endpoints.Unfavorite(ctx, endpoint.Slug) // false
endpoints, err := client.Endpoints.List(ctx)
for _, item := range endpoints {
if item.Favorite {
fmt.Println(item.Slug)
}
}Both are idempotent and return the endpoint's favourite state afterwards. A favourite is yours alone, so marking a shared endpoint changes nothing for anyone else. Endpoint.Favorite is set on what Endpoints.List and Endpoints.Get return, and always false on what Create and Update return. They need an account: a sandbox has neither method, and a sandbox or pre-claim agent token gets a 403 *webhookscc.Error.
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.
Email fields and reply text
An endpoint can pick up to 20 named fields out of every email it captures. Each reads the subject, text, html, from, to (To and Cc) or one header, and the first group of its RE2 pattern (the whole match without a group) is the value:
_, err := client.Endpoints.Update(ctx, endpoint.Slug, webhookscc.UpdateEndpointOptions{
EmailFields: webhookscc.Value([]webhookscc.EmailField{
{Name: "invoice", From: "subject", Pattern: `(INV-\d+)`},
{Name: "mailer", From: "header", Header: "X-Mailer", Pattern: ".+"},
}),
})
if err != nil {
t.Fatal(err)
}
email, err := client.Emails.WaitFor(t.Context(), endpoint.Slug, webhookscc.EmailQuery{Tag: run})
if err != nil {
t.Fatal(err)
}
invoice := email.EmailFields["invoice"] // "INV-2041"
reply := email.ReplyText // "Thanks, paid." without the quoted message and the signatureEvery email the client reads carries the values in EmailFields, by name ("" for a field that found nothing, and a nil map when the endpoint has no fields), and forwarding templates read them as {{ fields.invoice }}. endpoint.EmailFields lists the saved fields, and webhookscc.Null[[]webhookscc.EmailField]() removes them. Update refuses a list that webhookscc.EmailFieldIssues finds fault with (names, sources, headers, pattern lengths) before sending it; the API checks the patterns themselves. ReplyText is the new text of a reply, or "" when the email quotes no earlier message.
Layouts and stored messages
An endpoint's layouts read each email before it is delivered: fields, line items and attachments. Set Layouts in UpdateEndpointOptions (webhookscc.Null[[]webhookscc.Layout]() removes them), try them on a captured email first with Endpoints.PreviewLayouts, and read the result as Request.Parsed:
preview, err := client.Endpoints.PreviewLayouts(ctx, endpoint.Slug, webhookscc.PreviewLayoutsOptions{})
if err != nil {
t.Fatal(err)
}
fmt.Println(preview.Problems, preview.Parsed) // the saved layouts on the newest email
email, err = client.Requests.Get(ctx, email.ID)
if err == nil && email.Parsed != nil {
fmt.Println(email.Parsed.LineItems)
}The preview saves nothing and uses no parsed pages. For an email kept in storage (EmailCapture.Stored), client.Requests.Raw(ctx, id) returns the exact message and client.Requests.Attachment(ctx, id, index) one attachment (Body, Filename, ContentType).
Test an inbound email handler
If your app already receives email through Postmark, SendGrid, Mailgun or CloudMailin, render a captured email in that provider's shape and post it to the handler you wrote for it, on localhost or anywhere else:
rendered, err := client.Emails.Render(ctx, email.ID, webhookscc.RenderEmailOptions{Format: webhookscc.FormatPostmark})
if err != nil {
t.Fatal(err)
}
response, err := client.SendTo(ctx, "http://localhost:8080/inbound/postmark", webhookscc.SendOptions{
Body: rendered.Body,
Headers: rendered.Headers,
})
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK {
t.Errorf("status = %d", response.StatusCode)
}Format is FormatPostmark (Postmark's inbound JSON), FormatSendGrid (an Inbound Parse form), FormatMailgun (a route's form; set MailgunSigningKey, your Mailgun HTTP webhook signing key, and its signature verifies) or FormatCloudMailin (JSON Normalized), or FormatJSON for the email.received JSON, unsigned. rendered.Headers holds the content type and user agent to send with rendered.Body. To post every captured email this way, give a destination that format; see In a mail provider's shape.
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.
Forwarding destinations
An endpoint forwards what it captures to up to 10 destinations, each with its own kinds, filter, format, headers, retries, throttle, delay and signing secret. client.Destinations manages them. A DestinationInput sends only the fields set with Value (or Null, which clears Name, Filter, BodyTemplate, MessageTemplate, SentField and MailgunSigningKey):
destination, err := client.Destinations.Create(ctx, endpoint.Slug, webhookscc.DestinationInput{
URL: webhookscc.Value("https://hooks.example.com/invoices"),
Email: webhookscc.Value(false),
Filter: webhookscc.Value(webhookscc.DestinationFilter{
Conditions: []webhookscc.ResponseRuleCondition{{Field: "body_path", Path: "type", Op: "eq", Value: "invoice.paid"}},
}),
Format: webhookscc.Value(webhookscc.FormatTemplate),
BodyTemplate: webhookscc.Value(json.RawMessage(`{"invoice":"{{ json.data.object.id }}","amount":"{{ json.data.object.amount_paid }}"}`)),
})
if err != nil {
return err
}
preview, err := client.Destinations.Preview(ctx, endpoint.Slug, webhookscc.PreviewDestinationOptions{
DestinationID: destination.ID,
})
if err != nil {
return err
}
fmt.Println(preview.FilterPasses, preview.Error)A DestinationFilter takes the conditions of response rules, plus kind, email (with Name subject, from, to, text or tag), spam_score, email_auth (with Name spf, dkim or dmarc) and signature; see Send only some captures. FormatTemplate sends your own JSON, and FormatChat a message for Slack or Discord, with your own MessageTemplate if you like. FormatPostmark, FormatSendGrid, FormatMailgun and FormatCloudMailin post emails in that mail provider's shape and HTTP requests as received; MailgunSigningKey signs the Mailgun form as Mailgun does. The key is stored encrypted and never returned (HasMailgunSigningKey says whether one is saved). Retry, Throttle and Delay are time.Duration values in whole seconds.
Preview is a dry run: whether the filter lets a captured request through (FilterPasses) and the exact request the destination would send (Delivery), or why it would send nothing (Error). Set a saved DestinationID, unsaved fields in Destination, or both, and RequestID to run it against a particular request. Nothing is sent.
Update(ctx, slug, id, input) changes only the fields you set. Test sends one delivery now and reports the answer, its filter reported in FilterPasses rather than applied. List with Summary adds each destination's last 24 hours, and Get, Delete, Reorder, Secret and RotateSecret do what they say.
Verify what webhooks.cc forwards
When webhooks.cc forwards requests or email to your server, check that they came from it with the destination's signing secret (client.Destinations.Secret(ctx, slug, id), or client.Forwarding.Secret(ctx, slug) for the endpoint's primary destination):
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)The event's Data also carries replyText and, when the endpoint has named fields, fields. 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.
A destination that sends your own JSON is checked with verify.ForwardedJSON, which makes the same checks and returns the body, whatever its shape, to unmarshal into a type of your own:
payload, err := verify.ForwardedJSON(body, r.Header, secret)
if err != nil {
http.Error(w, "invalid delivery", http.StatusUnauthorized)
return
}
var paid struct {
Invoice string `json:"invoice"`
Amount int `json:"amount"`
}
_ = json.Unmarshal(payload, &paid)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.
No account: the agent sandbox
An AI agent can capture webhooks before anyone signs up. webhookscc.NewSandbox registers through auth.md, solves a short proof of work (a few seconds of CPU) and returns a sandbox: up to 3 endpoints, 25 captured requests each and 100 in all, for 24 hours. Sandbox endpoints only capture: no mock responses, forwarding or email. See AI agents for the limits and how a person connects an agent.
sandbox, err := webhookscc.NewSandbox(ctx, webhookscc.WithClientName("my-agent"))
if err != nil {
return err
}
endpoint, err := sandbox.Endpoints.Create(ctx)
if err != nil {
return err
}
triggerCheckout(endpoint.URL)
request, err := sandbox.Requests.WaitFor(ctx, endpoint.Slug, webhookscc.WaitOptions{Timeout: time.Minute})
// To keep the endpoints, a human connects the agent to their account.
attempt, err := sandbox.Claim(ctx, "dev@example.com")
if err != nil {
return err
}
fmt.Printf("Open %s and enter %s\n", attempt.VerificationURI, attempt.UserCode)
client, err := sandbox.WaitForClaim(ctx) // a *webhookscc.Client for that accountThe human opens the link, signs in (or signs up) as that email and types the code; the endpoints and what they captured move into their account, and the sandbox stops working. WaitForClaim fails with a *webhookscc.AgentAuthError whose Code is expired_token when the 15-minute code runs out (call Claim again) or access_denied when the human declines.
The registration is saved in the agent credential file that the MCP server, the TypeScript and Python SDKs and the CLI share:
| Platform | Path |
|---|---|
| Linux | ~/.config/whk/agent.json, or $XDG_CONFIG_HOME/whk/agent.json when set |
| macOS | ~/Library/Application Support/whk/agent.json |
| Windows | %APPDATA%\whk\agent.json |
WHK_AGENT_FILE names another path. The next NewSandbox, in this process or another, reuses a live sandbox instead of registering again; pass webhookscc.WithAgentFile("") to leave the file alone, or a path to use another file. The claim token is never saved, so only the process that registered moves the sandbox endpoints into an account. A sandbox reused from the file starts a new code for the email instead (attempt.MovesEndpoints is false): the agent is connected to the account, and the sandbox endpoints stay where they are until they expire.
A connection lasts 90 days, and every process picks it up:
client, err := webhookscc.Connected(ctx) // webhookscc.ErrNotConnected when no agent is connected
client, err := webhookscc.New() // without WHK_API_KEY, acts for the connected accountTo connect an agent without a sandbox, start with the human's email:
connection, err := webhookscc.Connect(ctx, "dev@example.com", webhookscc.WithClientName("my-agent"))
if err != nil {
return err
}
attempt := connection.Attempt()
fmt.Printf("Open %s and enter %s\n", attempt.VerificationURI, attempt.UserCode)
client, err := connection.Wait(ctx)connection.NewCode(ctx) gets a new code when the old one ran out. An API key, passed or in WHK_API_KEY, always wins over the file. A refused sandbox call returns a *webhookscc.SandboxError with the server's Code (sandbox_endpoint_limit, sandbox_full, ...), and a rate limit an error matching webhookscc.ErrRateLimited. Each step of the protocol is a method of its own in the webhooks.cc/go/agent package.
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 and no connected agent |
webhookscc.ErrNotConnected | Connected found no connected agent |
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, unless an agent is connected |
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 | |
WithAgentFile | WHK_AGENT_FILE | whk/agent.json in the config directory |
WithClientName | webhooks-cc-go |
The source is in packages/sdk-go, MIT licensed, and the API reference is on pkg.go.dev.