Skip to content

Developers SDKs

Python SDK

The REST API for Python 3.9 and newer: an idempotency key on every POST, bounded retries, typed exceptions and webhook verification. The standard library only, no dependencies. Writing TypeScript or PHP? Use the TypeScript or PHP SDK.

Install

pip install payshen

On PyPI, with its source on GitHub (MIT). Typed (py.typed); responses are plain dicts, shaped as the reference describes.

Configuration

One client per process, made with your secret key. A ps_test_ key reaches the sandbox, a ps_live_ key production. Keep the key on your server: never in a page or an app.

Python
import os
from payshen import Payshen

payshen = Payshen(
    os.environ["PAYSHEN_API_KEY"],
    timeout=20.0,       # seconds per attempt
    max_retries=3,
    base_url="https://www.payshen.com",
)

base_url must be https; plain http is accepted for localhost only, and anything else is refused when the client is made.

Your first payment

Python
import os
from payshen import Payshen

payshen = Payshen(os.environ["PAYSHEN_API_KEY"])  # ps_test_... in the sandbox

payment = payshen.payments.create(
    {
        "amount": 5000,              # minor units: EUR 50.00
        "currency": "EUR",
        "country": "DE",
        "reference": "order-1024",
        "capture_method": "manual",  # hold now, capture when you ship
        "return_url": "https://yourshop.com/checkout/return",
    },
    idempotency_key="order-1024",
)

# REQUIRES_ACTION: send the customer to the provider's page.
redirect_to = payment["next_action"]["url"]

The customer pays on the provider's page and comes back to your return_url. The return is not the outcome: the outcome is the webhook (payment.authorized, payment.captured, payment.declined, ...). Amounts are integers in the currency's minor unit; how the scale changes per currency.

Capture, void, refund

Python
from payshen import PayshenError

payshen.payments.capture(payment_id, idempotency_key="capture-order-1024")               # in full
payshen.payments.capture(payment_id, amount=3000, idempotency_key="capture-order-1024")  # or in part
payshen.payments.void(payment_id, idempotency_key="void-order-1024")                     # before capture

try:
    payshen.payments.refund(payment_id, amount=2000, idempotency_key="refund-order-1024-1")
except PayshenError as e:
    # 409 refund_pending: accepted, not failed. The provider confirms a moment later and
    # payment.refunded or payment.refund_failed follows. Do not send it again under a new key.
    if e.code != "refund_pending":
        raise

A capture or void a provider only accepted answers with the payment PENDING; it becomes CAPTURED or CANCELLED when the provider confirms, and the webhook follows.

Reading payments and events

Python
payment = payshen.payments.get(payment_id)
timeline = payshen.payments.events(payment_id)

page = payshen.payments.list(limit=50, status="captured")
for payment in payshen.payments.all(status="captured"):
    ...  # every page, newest first

for event in payshen.events.all(type="payment.captured"):
    ...  # the same events webhooks deliver, for polling

list returns one page; all is an iterator that follows the cursor for you.

Idempotency

Every POST carries an Idempotency-Key. Pass your own as idempotency_key (an order or refund reference is a good one); without one the client makes one and reuses it across its own retries. The same key with a different body is refused with 422, so a retry can never charge twice.

Verifying webhooks

Verify the signature against the raw body, then act on the event.

An empty secret never verifies anything: missing_secret is raised instead, so an unset environment variable fails loudly rather than letting a forged event through (0.1.2).

import os
from flask import Flask, request
from payshen import SignatureVerificationError, parse_webhook_event

app = Flask(__name__)

@app.post("/payshen/webhooks")
def payshen_webhook():
    raw = request.get_data()  # the RAW body: one re-encoded from json never verifies
    try:
        event = parse_webhook_event(
            raw, request.headers.get("X-Payshen-Signature"), os.environ["PAYSHEN_WEBHOOK_SECRET"]
        )
    except SignatureVerificationError:
        return "", 400

    if event["type"] == "payment.captured":
        fulfil(event["data"]["reference"])
    return "", 204

The signature proves a delivery came from Payshen and is recent (5 minutes by default); it does not make it new. Deliveries are retried, so the same event can arrive twice. claim_webhook_event verifies, parses and takes each event once, with a store you back with your database:

from payshen import claim_webhook_event

event = claim_webhook_event(raw, header, secret, store)
if event is None:
    return "", 204  # already handled

Errors

  • AuthenticationError: 401 or 403: a missing, wrong or revoked key, or one not allowed this call.
  • ValidationError: 422: refused as sent. code names the rule.
  • RateLimitError: 429 after the client's own retries. retry_after_seconds when the API said.
  • NetworkError: No answer at all. On a POST, retry with the same idempotency key.
  • PayshenError: Everything else, and the parent of all of the above.

Every exception has status, code (refund_pending, not_capturable, payment_result_unknown, ...) and request_id. Branch on code, never on the message, and log request_id so support can find the call. Every code is in the reference.

Retries and timeouts

The client retries 408, 425, 429, 500, 502, 503 and 504 answers with backoff and jitter, up to max_retries (3), honouring Retry-After. It never retries an answer the API says a retry cannot improve, such as payment_result_unknown: there the payment may have happened, so read it before doing anything else. timeout is per attempt, 20 seconds by default.

Rotating a webhook secret

During a rotation pass both secrets, and drop the old one once every delivery is signed with the new:

Python
event = parse_webhook_event(raw, header, [new_secret, old_secret])

Everything else

payshen.insights, payshen.suggestions and, for white-label partners, payshen.merchants cover the rest of the API. payshen.request("GET", "/api/v1/...") calls any endpoint and returns the whole answer.

Before going live

The checklist is the same in every language: the go-live list in the TypeScript guide.