Webhook endpoints

A webhook endpoint is a URL Plugipay POSTs events to. You register the endpoint, scope it to one or more event types, and Plugipay handles delivery (with retries, signatures, and at-least-once semantics) until the endpoint returns 2xx. The Python SDK exposes three methods behind plugipay.webhook_endpoints — listing, creating, and deleting endpoints. For the delivery model, retry policy, and signature recipe, see API → Webhook endpoints and the Webhooks SDK guide.

Namespace

plug.webhook_endpoints      # _WebhookEndpoints

Methods

list

plug.webhook_endpoints.list() -> list[WebhookEndpoint]

Returns every endpoint registered against this workspace. Not paginated — the catalog is small (you probably have one or two). Returns a plain Python list, not a PageResult.

for endpoint in plug.webhook_endpoints.list():
    print(endpoint["id"], endpoint["url"], endpoint.get("events"))

create

plug.webhook_endpoints.create(
    *,
    url: str,
    events: list[str] | None = None,
    description: str | None = None,
) -> WebhookEndpoint

Registers a new endpoint. url must be https in live mode (http allowed in test). events is the list of event types to subscribe to — omit or pass None to receive all events (the broad default; usually you want narrower). description is for your own bookkeeping. SDK auto-generates an idempotency key.

The response includes the endpoint's signing secret exactly once — store it in your secret manager immediately; you can't retrieve it later (only rotate via delete + recreate).

endpoint = plug.webhook_endpoints.create(
    url="https://api.example.com/plugipay/webhooks",
    events=[
        "plugipay.checkout_session.completed.v1",
        "plugipay.invoice.paid.v1",
        "plugipay.invoice.payment_failed.v1",
        "plugipay.subscription.created.v1",
        "plugipay.subscription.cancelled.v1",
        "plugipay.refund.succeeded.v1",
    ],
    description="Production app — billing handlers",
)
print(endpoint["id"])           # "whe_01HX..."
print(endpoint["secret"])       # "whsec_..." — STORE THIS NOW

delete

plug.webhook_endpoints.delete(endpoint_id: str) -> None

Removes the endpoint. Plugipay stops delivering immediately — any in-flight retries are abandoned. Returns None on success. Rotate signing secrets by delete + create (the new endpoint gets a fresh secret; coordinate the cutover with your endpoint code).

plug.webhook_endpoints.delete("whe_01HX...")

The signing secret is in endpoint["secret"] on create only. Subsequent list calls do not return the secret. If you lose it, your only path is delete + create. Treat it like a database password — secret manager, not source control.

Types

from plugipay import WebhookEndpoint

WebhookEndpoint is a Resource subclass. Likely-read fields:

  • endpoint["id"]whe_ + ULID.
  • endpoint["url"] — your registered URL.
  • endpoint.get("events") — list of subscribed event types, or None for "all".
  • endpoint.get("description") — your label.
  • endpoint.get("secret") — present only on the create response.
  • endpoint.get("status")"active" or "disabled" (Plugipay may disable an endpoint after repeated failures).
  • endpoint["createdAt"].

Full reference at API → Webhook endpoints.

Common patterns

Idempotent endpoint registration during deploy. Don't blindly create — check first to avoid duplicates:

def upsert_endpoint(plug, *, url, events, description):
    for endpoint in plug.webhook_endpoints.list():
        if endpoint["url"] == url:
            # Already registered. To change events, you must delete + recreate.
            return endpoint
    return plug.webhook_endpoints.create(
        url=url, events=events, description=description,
    )

Secret rotation flow. Plugipay doesn't support in-place rotation; do this:

# 1. Create the new endpoint
new_ep = plug.webhook_endpoints.create(
    url="https://api.example.com/plugipay/webhooks",
    events=[...],
    description="Rotation 2026-05-13",
)
new_secret = new_ep["secret"]

# 2. Roll the new secret out to your handler — accept BOTH secrets temporarily
#    (verify_webhook tries each until one succeeds).

# 3. Once verified the new endpoint is delivering, delete the old one
plug.webhook_endpoints.delete(old_endpoint_id)

# 4. Drop the old secret from your verifier.

Dev-tunnel endpoints. For local development, register an ngrok / cloudflared URL but scope it tight so production keys don't leak events:

plug.webhook_endpoints.create(
    url="https://dev-adi.ngrok.app/webhooks",
    events=["plugipay.checkout_session.completed.v1"],
    description="DEV ONLY — ngrok tunnel",
)

Use a test-mode key for this — live-mode events shouldn't fire to a laptop.

Handling auto-disable. If your endpoint returns 5xx for too long, Plugipay disables it. Detect on list:

for endpoint in plug.webhook_endpoints.list():
    if endpoint.get("status") == "disabled":
        alert(f"endpoint {endpoint['id']} ({endpoint['url']}) is disabled — re-create to re-enable")

The re-enable path is delete + create (or use the portal to manually flip status, where supported).

Verifying deliveries in your handler. Pair with verify_webhook from the top-level SDK:

from plugipay import verify_webhook, PlugipaySignatureError

@app.post("/plugipay/webhooks")
def handle_webhook(request):
    raw_body = request.get_data()
    sig = request.headers["Plugipay-Signature"]
    try:
        event = verify_webhook(raw_body, sig, secret=os.environ["PLUGIPAY_WEBHOOK_SECRET"])
    except PlugipaySignatureError:
        return "", 401
    dispatch(event)
    return "", 200

See Webhooks for the full verification + dispatch flow.

Errors

err.status err.code Cause
400 validation_error Bad url (not https in live), unknown event type in events.
404 not_found Endpoint id doesn't exist or is in another workspace.
409 conflict Endpoint with this url already exists.
409 idempotency_key_reused Same Idempotency-Key against a different body.
403 insufficient_scope Key lacks plugipay:webhook:write.

All raise PlugipayError. See Errors.

Next

Plugipay — Payments that don't tax your success