django-oscar-redsys
Redsys (TPV Virtual) payment integration for django-oscar, using Redsys's redirection integration method (the customer's browser is sent to a Redsys-hosted payment page, per Redsys's own EMV 3DS/SCA support for that flow).
Supports Django 5.2 (LTS) and django-oscar 4.2 only — no support for older/unmaintained versions.
What this package does, and doesn't, do
It builds and signs the Ds_MerchantParameters/Ds_Signature payload
for a redirect (oscar_redsys.facade.RedsysFacade), provides the view
that receives and verifies Redsys's async notification
(oscar_redsys.views.NotificationView), and fires a
payment_confirmed/payment_declined Django signal
(oscar_redsys.signals) for the host project to hook into its own order
flow.
It does not decide what "payment confirmed" means for your Order
model, doesn't place orders, and doesn't ever trust the browser-redirect
(Ds_Merchant_UrlOK/Ds_Merchant_UrlKO) path to confirm a payment — only
the signed, server-to-server notification does that. See
oscar_redsys/views.py's module docstring for why.
Installation
uv add django-oscar-redsys
Add to INSTALLED_APPS:
INSTALLED_APPS = [
...,
"oscar_redsys",
]
Required settings (from Redsys's merchant portal — "Configuración del Comercio" → "Ver clave de firma"):
REDSYS_MERCHANT_CODE = "999008881"
REDSYS_TERMINAL = "1"
REDSYS_SECRET_KEY = "..." # keep this out of version control
REDSYS_SANDBOX = True # False in production
REDSYS_MERCHANT_URL = "https://example.com/redsys/notify/"
REDSYS_URL_OK = "https://example.com/redsys/return/?ok=1"
REDSYS_URL_KO = "https://example.com/redsys/return/?ok=0"
# REDSYS_CURRENCY defaults to "978" (EUR, ISO 4217)
Wire the notification/return endpoints:
urlpatterns = [
path("redsys/", include("oscar_redsys.urls")),
]
Run this package's migrations (oscar_redsys.RedsysNotification, the
notification audit/idempotency log, and oscar_redsys.RedsysOperation,
the refund/cancellation audit log) as part of the host project's own
migrate.
Building the redirect
Subclass oscar_redsys.views.PaymentRedirectView:
from decimal import Decimal
from oscar_redsys.views import PaymentRedirectView
class CheckoutPaymentView(PaymentRedirectView):
def get_order_number(self) -> str:
return self.request.basket.order_number # whatever generates yours
def get_amount(self) -> Decimal:
return self.request.basket.total_incl_tax
This renders an auto-submitting form pointed at Redsys's payment page.
get_order_number() must return Redsys's own format: at most 12
characters, the first 4 numeric, the rest (if any) plain ASCII
digits/letters — validated up front (ValueError if not), rather than
letting a malformed order fail opaquely at Redsys's end.
Integrating with Oscar's checkout flow
Oscar's stock checkout wizard is built around gateways that collect card
details on your own site — its payment-details step (URL name
checkout:payment-details) exists to render that form, before the
preview step. A redirect-only gateway like Redsys collects nothing on
that step at all, so if you leave Oscar's default PaymentDetailsView
behaviour in place, customers see Oscar's own "This page needs
implementing within your project" placeholder on payment-details for
no reason, and the checkout breadcrumb shows "Payment" before
"Preview" even though nothing payment-related actually happens until
"Place order" is clicked on Preview.
Skip that step outright with Oscar's own skip-condition mechanism
(oscar.apps.checkout.session.CheckoutSessionMixin), rather than
overriding get()/post() yourself:
from django.urls import reverse
from oscar.apps.checkout.views import PaymentDetailsView as OscarPaymentDetailsView
from oscar.core.loading import get_class
PassedSkipCondition = get_class("checkout.exceptions", "PassedSkipCondition")
class PaymentDetailsView(OscarPaymentDetailsView):
def get_skip_conditions(self, request):
conditions = super().get_skip_conditions(request)
if not self.preview:
# Oscar's own skip_unless_payment_is_required (inherited
# above) only skips this step for free orders. A redirect
# gateway collects nothing here regardless of order total,
# so always skip straight to preview.
conditions = [*conditions, "skip_payment_details_no_form_required"]
return conditions
def skip_payment_details_no_form_required(self, request):
raise PassedSkipCondition(url=reverse("checkout:preview"))
def handle_payment(self, order_number, total, **kwargs):
... # build/render the redirect, e.g. via CheckoutPaymentView above
With this in place the customer-visible flow becomes: shipping address → shipping method → preview (the real, shipping-inclusive total) → redirect to Redsys → confirmation on return — matching what actually happens, instead of an empty "Payment" step sitting in front of the total the customer is about to be charged.
EMV3DS / SCA (optional, but improves checkout friction)
PSD2 requires Strong Customer Authentication (SCA) on most card payments; Redsys's EMV 3DS flow decides per transaction whether the issuer can authenticate the cardholder "frictionlessly" or must "challenge" them. Neither of the following is required for a payment to work, but supplying them lets more transactions go frictionless:
from oscar_redsys.emv3ds import Emv3dsData, MobilePhone, ScaExemption
class CheckoutPaymentView(PaymentRedirectView):
...
def get_emv3ds(self) -> Emv3dsData:
return Emv3dsData(
ship_addr_country="724", # numeric ISO 3166-1, e.g. Spain
cardholder_name=self.request.user.get_full_name(),
email=self.request.user.email,
mobile_phone=MobilePhone(country_code="34", subscriber="600123456"),
)
def get_sca_exemption(self) -> str | None:
if self.get_amount() <= Decimal("30.00"):
return ScaExemption.LOW_VALUE
return None
ScaExemption only defines the three values confirmed against Redsys's
own PSD2/SCA documentation (LWV, TRA, MIT) — pass any other code
Redsys documents as a plain string, it isn't restricted to these.
Consumer language (cosmetic)
get_consumer_language() sets what language Redsys's own hosted pages
(the payment form, the "Recibo Redsys" confirmation screen) render in —
it has no effect on the payment itself:
from oscar_redsys.language import ConsumerLanguage
class CheckoutPaymentView(PaymentRedirectView):
...
def get_consumer_language(self) -> str | None:
return ConsumerLanguage.SPANISH if self.request.LANGUAGE_CODE == "es" else ConsumerLanguage.ENGLISH
Handling the outcome
from django.dispatch import receiver
from oscar_redsys.signals import payment_confirmed, payment_declined
@receiver(payment_confirmed)
def on_payment_confirmed(sender, order_number, notification, **kwargs):
# place the order / trigger the Dolibarr sync chain / etc.
# See "Common pitfall" below before reading anything session-backed here.
...
@receiver(payment_declined)
def on_payment_declined(sender, order_number, notification, **kwargs):
...
Both signals fire at most once per order number — a resent Redsys
notification (Redsys explicitly documents that it can resend) is a no-op
the second time, per RedsysNotification's uniqueness on order_number.
notification.transaction_type (Ds_TransactionType) tells you what
kind of operation this notification is about — "0" for a payment (see
oscar_redsys.transaction_types), not a refund/cancellation/confirmation.
This matters because Ds_Merchant_MerchantURL can be configured at the
terminal level in Redsys's own portal rather than sent per-request, so
the same endpoint could in principle also receive a notification for an
operation oscar_redsys.rest triggered — check this field if your
receiver needs to be sure it's reacting to an actual new payment.
Common pitfall: payment_confirmed has no browser session
payment_confirmed fires from NotificationView handling Redsys's async
server-to-server POST — there is no browser, no cookies, no session
attached to that request at all. If your receiver's "place the order"
logic reads anything from your checkout framework's session-backed
state (e.g. django-oscar's own checkout_session — the shipping
address, billing address and shipping method a customer chose only ever
live there until an order is actually placed, per
CheckoutSessionData.get_shipping_address's own docstring), it will find
nothing there. Concretely: a customer who pays and then closes their
browser before your own return/thank-you view ever runs would have a
confirmed Redsys payment with no corresponding order — Redsys's own
"síncrona" notification default (the async notification is delivered
before the browser redirect, per the redirection manual's section 4.1)
doesn't rescue this, since it only orders the two deliveries relative
to each other; it can't force the customer's browser to actually
follow the redirect back.
This isn't something this package can fix for you — it doesn't know anything about your order model or checkout framework — but it's a real trap worth avoiding deliberately rather than discovering in production:
- Before redirecting to Redsys (wherever you build the
PaymentRequest/raise your framework's redirect), write everything your order-placement logic will need to a durable model of your own — not just the session. At minimum: the shipping/billing address, the shipping method, and the exact amount you're about to charge. - Snapshot that amount rather than planning to recompute it later. A frozen/locked basket's contents usually can't change once payment starts, but its prices often aren't frozen too — recomputing the total from the basket at confirmation time (which can be minutes after the redirect) risks placing an order for a different amount than what Redsys actually charged, if a price changed in between.
- In your
payment_confirmedreceiver, read from that durable model, never from a session. Make the resulting "place the order" function idempotent and callable from more than one place — the signal is the normal trigger, but keeping a return-view fallback that calls the same function is what makes a delayed or lost webhook harmless rather than silently dropping the order.
Refunds and cancellations — staff/admin only, never storefront
Unlike a payment, a refund or cancellation has no browser redirect at
all: it's a direct server-to-server call (oscar_redsys.rest, Redsys's
separate REST channel) referencing the original order, with no customer
involved. This package deliberately exposes it only as two Django
admin actions on RedsysNotification — "Refund selected payments" and
"Cancel selected payments" — never as a storefront view or URL a shopper
could reach.
Both actions are gated by the oscar_redsys.can_refund_or_cancel
permission on top of Django admin's own is_staff requirement — a
plain staff user can view payment records but can't trigger either
action without that permission explicitly granted (grant it via Django's
own admin, Permission model, or a group). Every attempt, successful or
not, is logged to RedsysOperation (who triggered it, when, the amount,
the outcome) — nothing here fires silently.
Refunds default to the full originally-authorized amount (recovered from
the stored notification's own Ds_Amount/Ds_Currency); there's no
partial-refund UI yet — see oscar_redsys.rest.build_refund_request if
you need a different amount from your own code.
Signature algorithm
Two versions, both verified byte-exact against Redsys's own published
worked examples in tests/test_signature.py — see
oscar_redsys/signature.py's module docstring for the full detail:
- V2 (
HMAC_SHA512_V2) for the redirection/browser flow, per "TPV-Virtual Manual de Integración - Redirección" (v4.1, 24/09/2025). - V1 (
HMAC_SHA512_V1) for the server-to-server REST channel (confirm/refund/cancel), per "TPV-Virtual Manual Integración-REST" (v4.0.1.1, 17/10/2025).
Both derive a per-operation key via AES-128-CBC (merchant secret key forced to 16 bytes, zero IV, diversified by the order number, then base64-encoded — that base64 string's ASCII bytes are the actual HMAC key, not the raw ciphertext, a detail easy to get wrong silently since a self-consistent sign/verify round trip "works" either way and only breaks against Redsys itself). They differ only in the final encoding: V2 uses URL-safe base64 with padding stripped, V1 uses standard base64 with padding kept.
Signature comparison: strict by default
Verifying an incoming signature (a notification, or a REST confirm/
refund/cancel response) uses a strict, constant-time comparison by
default. Redsys's own REST manual documents a related transport quirk
(SIS0042: a merchant's own outgoing request signature can get corrupted
by cURL/Safari turning + into a space — fixed by percent-encoding +
as %2B before sending, not by comparing loosely on receipt), but that's
about signing an outgoing request, not verifying one Redsys sends to
you, and V2's URL-safe alphabet has no + to begin with. So this package
doesn't loosen verification by default — nothing in Redsys's own
documentation says that's needed.
If you've observed real-world signature corruption on the receiving
side despite that (e.g. an intermediate proxy mis-decoding form-encoded
+), set:
REDSYS_LENIENT_SIGNATURE_COMPARISON = True
This strips non-alphanumeric characters from both sides before
comparing. Don't enable it speculatively — see
oscar_redsys/signature.py's "Lenient comparison" docstring for exactly
what it does and doesn't defend against.
Translations
The package ships a Spanish translation (src/oscar_redsys/locale/es/). Both
the .po source and the compiled .mo are committed, so the wheel includes
the .mo and nothing needs compiling on install. After editing the .po,
recompile before releasing:
msgfmt -o src/oscar_redsys/locale/es/LC_MESSAGES/django.mo \
src/oscar_redsys/locale/es/LC_MESSAGES/django.po
New strings are extracted with django-admin makemessages -l es run from
src/oscar_redsys/. A project can override any single string in its own
LOCALE_PATHS without copying the whole catalogue.
Development
uv sync
uv run pytest
uv run mypy src
uv run black --check src tests
uv run isort --check src tests
License
BSD-3-Clause.
Metadata
Release files for django-oscar-redsys 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_oscar_redsys-0.3.0.tar.gz | 106.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_oscar_redsys-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 145.8 kB
Release files / django_oscar_redsys-0.3.0.tar.gz
| Download URL | django_oscar_redsys-0.3.0.tar.gz |
|---|---|
| Size | 106.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c8b03e0501bd391ec2ce116ceba861548b0d5fd5cae2a91c6f0bf27082060d0f
|
|
BLAKE2b-256 checksum How to use checksums |
879efdb9115275f82568b14e3c0fd79c320381acbc11c16e1429feb62eab6e6f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / django_oscar_redsys-0.3.0-py3-none-any.whl
| Download URL | django_oscar_redsys-0.3.0-py3-none-any.whl |
|---|---|
| Size | 39.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2c28ea2081f4939f6480489980e82d206c286890df26a84bd31b86b7fb1c619b
|
|
BLAKE2b-256 checksum How to use checksums |
bc6b3b20553d71ac4f33298c6d970a82107a64e3e5975aa02f270c3b7cedbe67
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|