Errors

Every failure in the Plugipay Python SDK funnels through one exception class — PlugipayError — with three subclasses that let you distinguish transport-level failures from API-level ones. Catch the base class for "anything went wrong"; catch a subclass when you want to retry only specific failure modes.

from plugipay import PlugipayClient, PlugipayError

plug = PlugipayClient(key_id="…", secret="…")

try:
    customer = plug.customers.get("cus_does_not_exist")
except PlugipayError as exc:
    print(exc.status, exc.code, exc.message, exc.request_id)
    # 404 not_found 'Customer cus_does_not_exist not found' 'req_01HX…'

The exception hierarchy

Exception
└── PlugipayError              (base — every SDK failure)
    ├── PlugipayNetworkError   (DNS, connect refused, TLS, socket reset)
    ├── PlugipayTimeoutError   (request exceeded the configured timeout)
    └── PlugipaySignatureError (verify_webhook only)

All four are importable from the top-level package:

from plugipay import (
    PlugipayError,
    PlugipayNetworkError,
    PlugipayTimeoutError,
    PlugipaySignatureError,
)

PlugipayError

The base class — raised for any failure that reaches the server and comes back as a non-2xx response, or any parsing failure on the response envelope.

Attributes:

Attribute Type Meaning
status int HTTP status code. 0 for client-side failures (timeout, network, bad signature) that never reached the server.
code str Short machine-readable code — e.g. not_found, invalid_request, insufficient_scope. The full catalog is at API → Errors.
message str Human-readable message. Same as str(exc).
request_id str | None The meta.requestId echoed by the API. Always present on server responses; absent on client-side failures.

PlugipayError extends Exception, so str(exc) returns message. You can also repr(exc) for the full attribute dump.

PlugipayNetworkError

Subclass of PlugipayError raised when httpx reports a transport-level failure: DNS resolution failed, connection refused, TLS handshake aborted, socket reset mid-response.

exc.status      # 0  (request never reached the server)
exc.code        # 'network_error'
exc.message     # the underlying httpx error message
exc.request_id  # None

These are usually transient and worth retrying with backoff. See Retry patterns.

PlugipayTimeoutError

Subclass of PlugipayError raised when a request exceeds the client's timeout (default 30 seconds).

exc.status      # 0
exc.code        # 'timeout'
exc.message     # 'Plugipay request timed out after 30.0s'
exc.request_id  # None

Like network errors, these are usually transient. But before retrying, ask yourself if the request was likely idempotent — otherwise you risk a duplicate. The SDK auto-attaches Idempotency-Key on writes that need it, so retrying a POST is usually safe; see Idempotency.

PlugipaySignatureError

Raised by verify_webhook() when an inbound webhook fails verification — missing header, malformed header, stale timestamp, signature mismatch. Status is 401, code is one of signature_missing, signature_malformed, signature_stale, signature_invalid. Reject the delivery with a 4xx so Plugipay retries (or doesn't — signature failures are usually a deliberate attack and shouldn't trigger retries from your side).

See Webhooks for the full handler pattern.

Distinguishing network from API errors

Most of the time you just want to log the failure and move on, in which case the base class is enough:

try:
    plug.invoices.create(customer_id="cus_…", currency="IDR", lines=[…])
except PlugipayError as exc:
    log.error("plugipay failed: status=%s code=%s req=%s", exc.status, exc.code, exc.request_id)
    raise

But for retry logic you'll want to split:

  • PlugipayTimeoutError, PlugipayNetworkErrortransient. Retry with backoff.
  • PlugipayError with status >= 500transient. Retry with backoff.
  • PlugipayError with status in 400-499permanent for this payload. Fix the input; don't retry the same request.

A clean split:

def is_retryable(exc: PlugipayError) -> bool:
    if isinstance(exc, (PlugipayTimeoutError, PlugipayNetworkError)):
        return True
    return exc.status >= 500

Inspecting validation errors

When the API returns 400 invalid_request because of a bad payload, message contains a human-readable summary — usually pointing at the offending field:

try:
    plug.customers.create(email="not-an-email")
except PlugipayError as exc:
    if exc.code == "invalid_request":
        print(exc.message)
        # "email: must be a valid email address"

If you need structured per-field errors, hit the API directly — the SDK currently surfaces only the top-level error.message. Per-field error structures land in a future release; until then, parse the message or rely on client-side validation upstream.

Idempotency & safe retries

The SDK auto-attaches an Idempotency-Key header to every write method that creates a fresh resource (customers.create, checkout_sessions.create, invoices.pay, subscriptions.create, etc.). On retry, the same key on the same body returns the original response without re-creating.

The auto-generated key changes per call — meaning if your network blip happens between the SDK sending the request and you seeing the response, a naive retry would create a duplicate. The fix: pass your own idempotency key for high-stakes operations.

The SDK doesn't expose idempotency_key= as a kwarg on every method yet (tracked in the repo). For now, drop to the lower-level client.request() if you need explicit control:

plug.request(
    method="POST",
    path="/api/v1/checkout-sessions",
    body={"amount": 250_000, "currency": "IDR", "methods": ["qris"], …},
    idempotency_key="order-2026-05-12-001",
)

See Idempotency for the full contract.

Retry patterns

The SDK does not retry on its own — we treat that as application policy. Here's a minimal exponential-backoff wrapper for transient failures:

import time
import random
from plugipay import PlugipayError, PlugipayNetworkError, PlugipayTimeoutError


def with_retry(fn, *, attempts=3, base_delay=0.5):
    """Retry transient failures with jittered exponential backoff."""
    for i in range(attempts):
        try:
            return fn()
        except (PlugipayTimeoutError, PlugipayNetworkError) as exc:
            if i == attempts - 1:
                raise
            sleep = base_delay * (2 ** i) + random.uniform(0, 0.25)
            time.sleep(sleep)
        except PlugipayError as exc:
            if exc.status >= 500 and i < attempts - 1:
                sleep = base_delay * (2 ** i) + random.uniform(0, 0.25)
                time.sleep(sleep)
                continue
            raise


# Usage
session = with_retry(lambda: plug.checkout_sessions.create(
    amount=125_000, currency="IDR", methods=["qris"],
    success_url="…", cancel_url="…",
))

For production use, consider tenacity — same logic with cleaner ergonomics, retry-budgets, and observability:

from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter

def is_transient(exc):
    if isinstance(exc, (PlugipayTimeoutError, PlugipayNetworkError)):
        return True
    return isinstance(exc, PlugipayError) and exc.status >= 500

@retry(
    retry=retry_if_exception(is_transient),
    stop=stop_after_attempt(3),
    wait=wait_exponential_jitter(initial=0.5, max=4),
)
def create_session():
    return plug.checkout_sessions.create(
        amount=125_000, currency="IDR", methods=["qris"],
        success_url="…", cancel_url="…",
    )

Don't retry on 4xx. A 400/401/403/404/422 means the request is permanently wrong — retrying it as-is wastes API quota and triggers rate limits. Fix the payload (or credentials) first.

Logging the request id

When you file a support ticket, include request_id. It uniquely identifies the request on our side and is the fastest way for us to find what happened.

except PlugipayError as exc:
    log.error(
        "plugipay %s %s (req_id=%s)",
        exc.code, exc.message, exc.request_id,
    )

The request_id field is populated on every server response — success or failure. None only on client-side failures (timeout, network, bad signature) that never reached the server.

Common error codes

The full catalog is at API → Errors. The codes you'll see most often from the Python SDK:

status code What it means
0 timeout Request exceeded timeout. Likely transient; retry.
0 network_error DNS / connect / TLS / reset. Likely transient; retry.
400 invalid_request Body or query is malformed. Fix the payload.
401 invalid_signature The HMAC didn't verify. Almost always wrong secret.
401 invalid_timestamp Your clock is more than 5 minutes off.
403 insufficient_scope Key exists but lacks permission. Mint a wider-scoped key.
404 not_found Resource doesn't exist, or belongs to a different workspace.
409 idempotency_conflict Same Idempotency-Key, different body.
429 rate_limited Too many requests. Back off.
500-599 varies Server-side issue. Retry with backoff.

Next

Plugipay — Payments that don't tax your success