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. Subsequentlistcalls do not return the secret. If you lose it, your only path isdelete+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, orNonefor "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
- Webhooks — verify signatures and dispatch event types.
- API → Webhook endpoints — retry policy, delivery semantics, signature recipe.
- Events — the pull-based counterpart for replay.
- API → Events catalog — every event type you can subscribe to.