Webhook endpoints

A webhook endpoint is a URL Plugipay POSTs events to as they happen. You register one per environment (staging, production, the per-PR preview), pick which event types it cares about, and Plugipay signs every delivery with the endpoint's secret. The Go SDK exposes the three webhook-endpoint methods behind client.WebhookEndpoints. To verify an inbound delivery, see Webhooks; to read the persisted event log directly, see Events. For wire shapes, retry semantics, and the full event-type catalog, see API → Webhook endpoints.

Field on the Client

client.WebhookEndpoints — type *plugipay.WebhookEndpointsResource. Installed by NewClient; shares the parent *http.Client, base URL, key, and OnBehalfOf default. Safe for concurrent use.

Methods

List

Signature. func (r *WebhookEndpointsResource) List(ctx context.Context) ([]WebhookEndpoint, error)

Returns every webhook endpoint registered in the workspace. The list is small — typically one or two endpoints per environment — so the method returns a plain slice rather than a paginated Page[T].

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

endpoints, err := client.WebhookEndpoints.List(ctx)
if err != nil {
    return err
}
for _, ep := range endpoints {
    log.Printf("%s active=%t url=%s events=%v",
        ep.ID, ep.Active, ep.URL, ep.Events)
}

The returned Secret field is empty on list — you only see it once, on Create.

Create

Signature. func (r *WebhookEndpointsResource) Create(ctx context.Context, in WebhookEndpointCreateInput) (*WebhookEndpoint, error)

Registers a new endpoint. URL is required (must be https:// in live mode); Events is an optional allow-list (omit to receive every event); Description is free-text. Auto-keyed for idempotency.

ep, err := client.WebhookEndpoints.Create(ctx, plugipay.WebhookEndpointCreateInput{
    URL: "https://api.example.com/plugipay/webhooks",
    Events: []string{
        "checkout_session.completed",
        "invoice.paid",
        "refund.succeeded",
    },
    Description: ptr("production handler"),
})
if err != nil {
    return err
}

// Save ep.Secret right now — this is the only time it's exposed.
storeSecret(ep.ID, ep.Secret)

Save Secret on create — it's never returned again. Subsequent List calls echo every field except Secret (it comes back as the empty string). If you lose it, delete and re-create the endpoint — rotation is delete-and-recreate, not patch.

Delete

Signature. func (r *WebhookEndpointsResource) Delete(ctx context.Context, id string) error

Removes an endpoint. Future events stop delivering to that URL immediately. In-flight deliveries (already sent, awaiting your 2xx) continue their retry schedule unless your server stops responding. Returns nil on success; *plugipay.Error{Status: 404} if the id doesn't exist.

if err := client.WebhookEndpoints.Delete(ctx, "we_01HX..."); err != nil {
    var pe *plugipay.Error
    if errors.As(err, &pe) && pe.Code == "not_found" {
        return nil // already gone — treat as success
    }
    return err
}

There's no Update — rotation, URL changes, and event-list changes all happen by deleting the old endpoint and creating a new one. This keeps the secret-handling story simple (every secret has a single, unambiguous creation moment).

Types

type WebhookEndpoint struct {
    ID          string   `json:"id"`         // "we_..."
    AccountID   string   `json:"accountId"`
    URL         string   `json:"url"`
    Events      []string `json:"events"`     // empty slice means "all"
    Description *string  `json:"description"`
    Active      bool     `json:"active"`
    Secret      string   `json:"secret,omitempty"` // only present on Create
    CreatedAt   string   `json:"createdAt"`
    UpdatedAt   string   `json:"updatedAt"`
}

For the per-event payload shapes and the event-type catalog: API → Webhook endpoints and API → Events.

Common patterns

One-shot bootstrap script

Wire your CI to provision the endpoint on first deploy, then store the secret in your secret manager:

func provision(ctx context.Context, c *plugipay.Client, url string) error {
    eps, err := c.WebhookEndpoints.List(ctx)
    if err != nil { return err }
    for _, ep := range eps {
        if ep.URL == url {
            log.Printf("endpoint already exists: %s", ep.ID)
            return nil
        }
    }
    ep, err := c.WebhookEndpoints.Create(ctx, plugipay.WebhookEndpointCreateInput{
        URL: url,
        Events: []string{
            "checkout_session.completed",
            "invoice.paid",
            "subscription.canceled",
        },
    })
    if err != nil { return err }
    return writeSecretToManager(ep.ID, ep.Secret)
}

The List-then-Create idiom is safe: webhook endpoints aren't unique on URL server-side, so a duplicate Create would happily mint a second one. Check first.

Rotating a secret

Delete the old endpoint, create a new one at the same URL, store the new secret, then update your handler:

old := "we_01HX..."

ep, err := c.WebhookEndpoints.Create(ctx, plugipay.WebhookEndpointCreateInput{
    URL: "https://api.example.com/plugipay/webhooks",
    Events: []string{ /* same set as the old endpoint */ },
})
if err != nil { return err }
if err := storeSecret(ep.ID, ep.Secret); err != nil { return err }

// Once your handler is reading the new secret, drop the old endpoint:
if err := c.WebhookEndpoints.Delete(ctx, old); err != nil { return err }

For a brief overlap, have your handler accept signatures from either secret; flip exclusively to the new one once you've drained the old endpoint's deliveries.

Per-environment provisioning

If you want CI to manage a per-PR endpoint that auto-cleans on PR close:

// On PR open:
ep, _ := c.WebhookEndpoints.Create(ctx, plugipay.WebhookEndpointCreateInput{
    URL: fmt.Sprintf("https://pr-%d.staging.example.com/plugipay/webhooks", prNum),
    Description: ptr(fmt.Sprintf("pr-%d", prNum)),
})

// On PR close:
_ = c.WebhookEndpoints.Delete(ctx, ep.ID)

Errors with errors.As

err := c.WebhookEndpoints.Delete(ctx, id)
var pe *plugipay.Error
if errors.As(err, &pe) {
    switch pe.Code {
    case "not_found":
        // already gone — fine
    case "insufficient_scope":
        // key lacks plugipay:webhook:write
    }
}

Context cancellation

ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
ep, err := c.WebhookEndpoints.Create(ctx, in)

Create is auto-keyed for idempotency — retrying after *plugipay.Error{Code: "timeout"} is safe; the server returns the same endpoint and secret.

Errors

Code Status Cause
validation_error 400 Bad URL (non-https in live mode), unknown event type.
not_found 404 Delete on a missing id.
insufficient_scope 403 Key lacks plugipay:webhook:write.

Full mechanics: Errors.

Next

Plugipay — Payments that don't tax your success