payriff
Python client for the Payriff payment gateway. Sync and async, typed
throughout, with httpx as the only dependency.
pip install payriff
Quick start
from payriff import PayriffClient
client = PayriffClient("your-secret-key")
order = client.create_order(
amount=25.50,
description="Order 1024",
callback_url="https://shop.example/payriff/callback",
)
print(order.payment_url)
Send the customer to order.payment_url. Payriff posts to your callback_url as the status
changes.
A whole checkout
The shape of a real integration, here with Flask. Two routes: one to start the payment, one to receive the result.
from flask import Flask, jsonify, redirect, request
from payriff import GatewayError, PaymentStatus, PayriffClient
app = Flask(__name__)
client = PayriffClient.from_env()
@app.post("/checkout/<cart_id>")
def checkout(cart_id):
cart = load_cart(cart_id)
try:
order = client.create_order(
amount=cart.total,
description=f"Order {cart_id}",
callback_url="https://shop.example/payriff/callback",
)
except GatewayError as exc:
app.logger.warning("payriff refused the order: %s %s", exc.code, exc.message)
return jsonify(error="could not start the payment"), 502
cart.payriff_order_id = order.order_id
cart.save()
return redirect(order.payment_url)
@app.post("/payriff/callback")
def callback():
event = client.parse_callback(request.get_json(force=True))
# The callback is unsigned, so nothing here is trusted until the API agrees.
order = client.get_order(event.order_id)
cart = load_cart_by_order(event.order_id)
if order.settled:
cart.mark_paid()
elif order.payment_status in {PaymentStatus.DECLINED, PaymentStatus.CANCELED}:
cart.mark_failed(order.payment_status)
return "", 200
Two details worth copying. The callback handler decides on order, the value it fetched, never
on event, the value it was sent. And it answers 200 whatever the outcome, because a non-2xx
tells Payriff to deliver the same event again.
Callbacks can arrive more than once for one order, so make mark_paid idempotent.
Two things that catch people out
A success code doesn't mean the payment worked
The code field tells you whether Payriff accepted the call. Whether money actually moved is a
separate field, paymentStatus. auto_pay is where this hurts most: a declined card still comes
back as 00000.
charge = client.auto_pay(card_uuid, 10.00, "Subscription")
charge.ok # True
charge.payment_status # "CANCELED"
charge.settled # False
Gate your fulfilment on settled.
A hold is different again. A genuine pre-authorisation reports PREAUTH_APPROVED, and that is
the only status where money is reserved rather than taken. settled stays False for it and
authorized turns True.
hold = client.get_order(order_id)
hold.authorized # True, the funds are reserved
hold.settled # False, nothing has moved yet
Watch out for accounts without pre-authorisation enabled. There, an order sent with
operation=PRE_AUTH is charged like an ordinary sale and comes back APPROVED, which settled
correctly reports as paid. Check authorized, not the operation you asked for.
Callbacks aren't signed
There is no HMAC and no shared secret in the body, so there's nothing to verify. Anyone who learns your callback URL can post to it. Treat the callback as a nudge to go ask the API what happened:
callback = client.parse_callback(request.json)
order = client.get_order(callback.order_id)
if order.settled:
fulfil(callback.order_id)
parse_callback reads the body and nothing more. The get_order call is what makes it safe.
Configuration
from payriff import Currency, Language, PayriffClient
client = PayriffClient(
"your-secret-key",
merchant_id="ES100000",
currency=Currency.AZN,
language=Language.EN,
callback_url="https://shop.example/payriff/callback",
)
PayriffClient.from_env() reads PAYRIFF_SECRET_KEY, PAYRIFF_MERCHANT_ID, PAYRIFF_BASE_URL
and PAYRIFF_CALLBACK_URL instead.
The secret goes into the Authorization header raw, with no Bearer prefix. The client handles
that for you.
Async
Every method has an awaitable twin:
from payriff import AsyncPayriffClient
async with AsyncPayriffClient("your-secret-key") as client:
order = await client.create_order(25.50, "Order 1024")
status = await client.get_order(order.order_id)
Methods
| Method | Endpoint |
|---|---|
create_order |
POST /api/v3/orders |
reserve |
POST /api/v3/orders with operation=PRE_AUTH |
get_order |
GET /api/v3/orders/{id} |
complete |
POST /api/v3/complete |
refund |
POST /api/v3/refund |
auto_pay |
POST /api/v3/autoPay |
save_card, refund_card_save |
the two step card save flow |
transfer |
POST /api/v3/payout |
create_invoice |
POST /api/v2/invoices |
get_invoice |
POST /api/v2/get-invoice |
A couple of things worth knowing. The docs describe order information as a POST, but the API
answers that with a 405 and only takes GET, which is what this does. And /api/v3/payout moves
money between Payriff merchant wallets, so transfer seemed the honest name for it; if you were
hoping for a bank payout, this isn't it.
The v2 invoice endpoints want merchant set and refuse the call without it. The v3 order
endpoints do not care, so set merchant_id on the client if you touch invoices.
Bulk invoice and bulk payout are dashboard features driven by spreadsheet upload. There is no API behind them, so there's nothing here for them either.
For anything else, go straight at it:
client.request("POST", "/api/v3/directPay", {...})
Pre-authorisation
reserve puts a hold on the card instead of charging it. You have 30 days to capture with
complete, after which the hold expires on its own. Capture less than you held and Payriff
releases the difference.
hold = client.reserve(100.00, "Hotel booking", three_ds=True)
client.complete(hold.order_id, 80.00)
Saving a card
To verify a card, Payriff charges 0.01 AZN and expects you to hand it straight back. Skipping the refund leaves the customer out of pocket, so treat it as step two rather than tidying up:
verification = client.save_card(callback_url="https://shop.example/payriff/callback")
# the customer completes verification.payment_url, then
client.refund_card_save(verification.order_id)
The cardUuid you need for auto_pay arrives in the callback.
Responses
Branch on code rather than the HTTP status. The interesting fields live under payload, and
Response reads through to them:
order.code # "00000"
order.code_name # "SUCCESS"
order.ok # the call was accepted
order.settled # money actually moved
order.authorized # funds held by a pre-authorisation, not yet captured
order.operation_type # "PURCHASE", "PRE_AUTH", ...
order.order_id, order.session_id, order.transaction_id, order.payment_url
order.payment_status # "PAID", "CANCELED", ...
order.transactions # per attempt: card mask, RRN, channel
order.payload # raw payload, occasionally a plain string such as "APPROVED"
order.raw # untouched body
Enums
Values are case sensitive. Every enum member is a str, so it goes over the wire unchanged and
compares equal to the raw value; you can pass a plain string anywhere an enum is accepted, but
the enum is what stops a typo reaching the gateway.
from payriff import Currency, Language, PaymentStatus
client.create_order(10, "x", currency=Currency.USD, language=Language.AZ)
if order.payment_status == PaymentStatus.APPROVED:
...
| Enum | Values |
|---|---|
Operation |
PURCHASE, PRE_AUTH, COMPLETE, REFUND, REVERSE |
Language |
AZ, EN, RU, AR |
Currency |
AZN, USD, EUR, PKR, AED, SAR |
PaymentType |
ONETIME, DAILY, WEEKLY, MONTHLY, ANNUALLY |
InstallmentProduct |
BIRKART, ALBALI, BOLKART, TAMKART |
InvoiceStatus |
PENDING, ERROR, EXPIRED, PARTIAL, COMPLETE, CASH |
PaymentStatus |
CREATED, APPROVED, CANCELED, DECLINED, REFUNDED, PREAUTH_APPROVED, EXPIRED, REVERSE, PARTIAL_REFUND, PARTIAL, ACCEPTED, REFUND_IN_PROGRESS, CASH, PENDING, PREAUTH_EXPIRED |
ResultCode |
SUCCESS, WARNING, ERROR, INVALID_PARAMETERS, UNAUTHORIZED, TOKEN_NOT_PRESENT, INVALID_TOKEN, INVALID_ORIGIN, CHECKING |
GatewayResult |
00, APPROVED, PREAUTH-APPROVED |
Members hash by value, so a raw string off the wire and an enum member reach the same dict entry.
CURRENCY_NUMERIC and PAYMENT_STATUS_CODES give you the ISO 4217 and internal numeric codes.
PaymentStatus carries two extras, PAID and COMPLETED. Payriff's enum reference leaves them
out, but the order information and autoPay examples both return them, so they are in here.
SETTLED_PAYMENT_STATUSES and HELD_PAYMENT_STATUSES are the sets behind settled and
authorized.
Errors
GatewayError means Payriff refused the call: any result code other than 00000 or 01000, or
a 4xx that still carried an envelope. It gives you code, message, internal_message and
response_id.
TransportError covers the rest, so network failures and responses that either weren't JSON or
carried no envelope at all.
Both subclass PayriffError if you want to catch broadly.
from payriff import GatewayError, PayriffError, TransportError
try:
order = client.create_order(25.50, "Order 1024")
except GatewayError as exc:
# Payriff answered and said no. exc.code tells you why.
log.warning("refused: %s %s", exc.code, exc.message)
except TransportError as exc:
# Nothing came back. Safe to retry.
log.error("payriff unreachable: %s", exc)
Retry TransportError if you like, but not GatewayError: the gateway already made up its mind
and the same call will be refused again.
The 01000 code is a bit odd. On a 2xx it's a warning and comes back as a normal response. On
a 4xx it's a refusal and raises, which is how Payriff answers an unknown application key.
Testing
Payriff runs a sandbox. Point PAYRIFF_BASE_URL at it, use a sandbox key, and pay with the
published test cards:
| Brand | Number | Expiry | CVV | OTP |
|---|---|---|---|---|
| Visa | 4000007546012078 | 04/29 | 893 | 123456 |
| Mastercard | 5100007346013947 | 04/29 | 783 | 123456 |
tests/test_sandbox.py runs when PAYRIFF_SECRET_KEY is set and skips when it isn't, so a
checkout without a key still passes.
What has been checked against the live gateway
Payriff's docs turned out to disagree with the API in a few places, so the flows below were run against a real account and the parsing is built from the recorded bodies.
| Verified | Inferred from the docs only |
|---|---|
| create order, and the CREATED to PENDING to APPROVED or DECLINED lifecycle | complete, a capture |
| that a pre-auth order settles outright on an account without pre-auth | auto pay |
| order lookup, which is a GET despite the docs saying POST | card save and its refund |
| refund, which answers with a null payload, on both a purchase and a pre-auth | invoice create and lookup |
| the callback envelope, for a declined purchase | wallet transfer |
| result codes 15400, 15000 and 01000, and the 4xx behaviour |
Pre-authorisation, autopay and invoicing are not switched on for the account used, so those paths could not be exercised. Treat the right hand column as untested and please report anything that looks wrong.
Licence
MIT. Not affiliated with Payriff.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file payriff-0.1.1.tar.gz.
File metadata
- Download URL: payriff-0.1.1.tar.gz
- Upload date:
- Size: 57.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fbed22c2159e6beb3834ca0305ac632ece5adad2f028ec4575e1cfb8ac0a20c0
|
|
| MD5 |
f3b522c4ade80c092bf9f09402d458a1
|
|
| BLAKE2b-256 |
bc61c17f815c4ecf34541fa917ea99542bdcdf2df6e09750a69504d17a80fd2a
|
Provenance
The following attestation bundles were made for payriff-0.1.1.tar.gz:
Publisher:
release.yml on martian56/payriff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
payriff-0.1.1.tar.gz -
Subject digest:
fbed22c2159e6beb3834ca0305ac632ece5adad2f028ec4575e1cfb8ac0a20c0 - Sigstore transparency entry: 2216844260
- Sigstore integration time:
-
Permalink:
martian56/payriff@d142e27ffcafd36d16fcbeb89b78d17a624e8faf -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/martian56
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d142e27ffcafd36d16fcbeb89b78d17a624e8faf -
Trigger Event:
push
-
Statement type:
File details
Details for the file payriff-0.1.1-py3-none-any.whl.
File metadata
- Download URL: payriff-0.1.1-py3-none-any.whl
- Upload date:
- Size: 18.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fbdf9378466abd403eb598a49239bc17b2ede13bdc695b5b454e9f4944c3a462
|
|
| MD5 |
6bc81bb19943f398304026dba793861f
|
|
| BLAKE2b-256 |
139c5e136693bb2fa860b746a1cea04b28f2578325f652f46fd5e5d60407fbb0
|
Provenance
The following attestation bundles were made for payriff-0.1.1-py3-none-any.whl:
Publisher:
release.yml on martian56/payriff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
payriff-0.1.1-py3-none-any.whl -
Subject digest:
fbdf9378466abd403eb598a49239bc17b2ede13bdc695b5b454e9f4944c3a462 - Sigstore transparency entry: 2216844297
- Sigstore integration time:
-
Permalink:
martian56/payriff@d142e27ffcafd36d16fcbeb89b78d17a624e8faf -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/martian56
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d142e27ffcafd36d16fcbeb89b78d17a624e8faf -
Trigger Event:
push
-
Statement type: