Webhooks
Plugipay delivers state changes (a checkout succeeded, an invoice was paid, a subscription renewed) by POSTing a signed JSON payload to a URL you control. The Go SDK ships two verification helpers and a typed WebhookEvent struct so you can branch on the event type with switch and unmarshal the inner payload into a concrete resource.
ev, err := plugipay.VerifyWebhook(
body,
r.Header.Get("X-Plugipay-Signature"),
os.Getenv("PLUGIPAY_WEBHOOK_SECRET"),
nil,
)
If the signature is bad, err is *plugipay.Error with one of signature_missing, signature_malformed, signature_stale, or signature_invalid. If the JSON is malformed, invalid_payload. Otherwise, ev is a parsed *plugipay.WebhookEvent.
The signature recipe itself is documented at API → Webhooks. This page is about the Go integration.
The helpers
func VerifyWebhook(
rawBody []byte,
signatureHeader string,
secret string,
options *VerifyWebhookOptions, // nil → defaults
) (*WebhookEvent, error)
func VerifyWebhookSignature(
rawBody []byte,
signatureHeader string,
secret string,
options *VerifyWebhookOptions,
) bool
Two flavors:
VerifyWebhook— verifies the signature and unmarshals the body into a*WebhookEvent. Returns a typed error on failure. Use this in normal handlers.VerifyWebhookSignature— bool-only. Returnstrueiff the signature is valid. Use this when you've already parsed the body, or when you want to forward the raw bytes elsewhere.
Both take the exact bytes on the wire. If a framework parses JSON first and re-stringifies it, whitespace drift breaks the signature. Read the body with io.ReadAll(r.Body) before anything else touches it.
net/http handler
A minimal verified handler:
package main
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
plugipay "github.com/hachimi-cat/saas-plugipay/sdk/go"
)
func plugipayWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
ev, err := plugipay.VerifyWebhook(
body,
r.Header.Get("X-Plugipay-Signature"),
os.Getenv("PLUGIPAY_WEBHOOK_SECRET"),
nil,
)
if err != nil {
var pe *plugipay.Error
if errors.As(err, &pe) {
log.Printf("webhook verify failed: code=%s msg=%s", pe.Code, pe.Message)
}
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
switch ev.Type {
case "plugipay.checkout_session.completed.v1":
var sess plugipay.CheckoutSession
if err := json.Unmarshal(ev.Data.Object, &sess); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
log.Printf("checkout %s completed for %s", sess.ID, derefStr(sess.CustomerID))
case "plugipay.invoice.paid.v1":
var inv plugipay.Invoice
_ = json.Unmarshal(ev.Data.Object, &inv)
log.Printf("invoice %s paid: %d %s", inv.ID, inv.AmountPaid, inv.Currency)
case "plugipay.subscription.renewed.v1":
var sub plugipay.Subscription
_ = json.Unmarshal(ev.Data.Object, &sub)
log.Printf("subscription %s renewed", sub.ID)
default:
// Unknown event types: log + 204 so we don't appear "dead" to retry.
log.Printf("unhandled webhook type: %s", ev.Type)
}
w.WriteHeader(http.StatusNoContent)
}
func derefStr(p *string) string {
if p == nil {
return ""
}
return *p
}
func main() {
http.HandleFunc("/webhooks/plugipay", plugipayWebhook)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Four things this does right:
io.ReadAll(r.Body)first. No framework touches the JSON before verification.defer r.Body.Close()— on some transports, leaving the body open leaks the connection.errors.Asto inspect the failure. Logs the specific code; responds 400 to the caller without echoing details.switchonev.Type, thenjson.Unmarshalev.Data.Object. The typed unmarshal happens after you know which type to deserialize into.
The event shape
type WebhookEvent struct {
ID string `json:"id"` // evt_01H...
Type string `json:"type"` // "plugipay.invoice.paid.v1"
AccountID string `json:"accountId"` // acc_01H...
OccurredAt string `json:"occurredAt"` // ISO 8601
Data WebhookEventData `json:"data"`
}
type WebhookEventData struct {
Object json.RawMessage `json:"object"` // resource snapshot
}
Data.Object is intentionally json.RawMessage — you unmarshal it into the concrete type after the switch. This is the discriminated-union pattern Go can express most cleanly without code generation.
The mapping from Type to the struct you unmarshal into:
Type |
Unmarshal target |
|---|---|
plugipay.checkout_session.completed.v1 |
plugipay.CheckoutSession |
plugipay.checkout_session.expired.v1 |
plugipay.CheckoutSession |
plugipay.invoice.paid.v1 |
plugipay.Invoice |
plugipay.invoice.voided.v1 |
plugipay.Invoice |
plugipay.subscription.created.v1 |
plugipay.Subscription |
plugipay.subscription.renewed.v1 |
plugipay.Subscription |
plugipay.subscription.canceled.v1 |
plugipay.Subscription |
plugipay.refund.succeeded.v1 |
plugipay.Refund |
plugipay.payout.paid.v1 |
plugipay.Payout |
Full list at API → Webhooks → Event types.
Tolerance window
Signatures include a unix timestamp. The default tolerance is 300 seconds — older signatures are rejected with signature_stale. To tighten or relax:
ev, err := plugipay.VerifyWebhook(body, sig, secret, &plugipay.VerifyWebhookOptions{
ToleranceSeconds: 60, // 1 minute
})
Setting ToleranceSeconds: 0 (or omitting it) means "use the default 300". There's no way to disable the check entirely — replay protection isn't optional.
For tests, inject a fake clock:
opts := &plugipay.VerifyWebhookOptions{
Now: func() time.Time { return time.Unix(1715526783, 0) },
}
ev, err := plugipay.VerifyWebhook(body, sig, secret, opts)
Why VerifyWebhookSignature (bool form)
If you want to forward the raw body to a queue and verify in the consumer, you only need a bool at the HTTP boundary:
func plugipayWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !plugipay.VerifyWebhookSignature(
body,
r.Header.Get("X-Plugipay-Signature"),
os.Getenv("PLUGIPAY_WEBHOOK_SECRET"),
nil,
) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// Push raw bytes onto SQS/Kafka/whatever for async processing.
enqueue(body)
w.WriteHeader(http.StatusNoContent)
}
The consumer then json.Unmarshal(body, &ev) directly — the signature has already been verified. Don't re-verify with a different timestamp in the consumer; you'll trip signature_stale once your queue lag exceeds 5 minutes.
Behind a body-parsing middleware
Some frameworks (gin, echo, certain Express-equivalent libraries) parse JSON before your handler sees it. Two ways out:
Read the raw body in a pre-parse middleware and stash it on the request context:
func captureBody(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) r.Body = io.NopCloser(bytes.NewReader(body)) ctx := context.WithValue(r.Context(), rawBodyKey{}, body) next.ServeHTTP(w, r.WithContext(ctx)) }) } func plugipayWebhook(w http.ResponseWriter, r *http.Request) { body := r.Context().Value(rawBodyKey{}).([]byte) // ... }Mount the webhook route on a separate
http.ServeMuxthat doesn't have the parsing middleware. Cleanest if your framework allows it.
Returning the right status
| Response | When | Plugipay's behavior |
|---|---|---|
204 No Content |
success | marks the delivery succeeded |
200 OK |
success (also fine) | same |
4xx |
unrecoverable (bad signature, bad payload) | logs the failure, does not retry |
5xx |
transient (DB down, downstream slow) | retries with exponential backoff up to 24 hours |
Choose carefully. Returning 4xx for what's actually a transient outage means you lose the event — it won't be retried. Returning 5xx for a permanent failure (we sent a type your code doesn't know) means we'll hammer your endpoint for a day. The default: case in the switch example above logs and returns 204 — you accept the event and silently drop the body. That's usually the right call.
Idempotency on your side
We deliver each event at least once. Retries (after a 5xx or a network blip) can deliver the same event multiple times. Use ev.ID as the dedupe key:
if err := db.Exec(`
INSERT INTO plugipay_events (event_id, type, processed_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING
`, ev.ID, ev.Type); err != nil {
// process the event only when the insert actually happened
}
The unique constraint on event_id does the work. If your service is Plugipay-sourced and follows the Forjio outbox/inbox pattern, this is what processed_events(event_id) is for — see your service's CLAUDE.md.
Configuring the endpoint
Register the URL in Settings → Webhooks — or via the SDK:
ep, err := c.WebhookEndpoints.Create(ctx, plugipay.WebhookEndpointCreateInput{
URL: "https://yourapp.com/webhooks/plugipay",
Events: []string{"plugipay.invoice.paid.v1", "plugipay.checkout_session.completed.v1"},
})
if err != nil {
log.Fatal(err)
}
log.Printf("webhook secret (save this): %s", ep.Secret)
The Secret field is returned only on create. Save it — this is the value you pass to VerifyWebhook as the third argument. Subsequent List calls don't echo it back.
Next
- API → Webhooks — the wire-level signature recipe and the full event-type catalog.
- Reference —
WebhookEndpointsandEventsresource methods. - Errors — the
signature_*codes and the rest.