Skip to main content

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:

  1. 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.
  2. 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.
  3. In your payment_confirmed receiver, 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.

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.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-oscar-redsys 0.2.1
File Size Uploaded
django_oscar_redsys-0.2.1.tar.gz 102.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-oscar-redsys 0.2.1
File Interpreter ABI Platform
django_oscar_redsys-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 136.7 kB

Release files / django_oscar_redsys-0.2.1.tar.gz

Download URL django_oscar_redsys-0.2.1.tar.gz
Size 102.7 kB
Tags Source
SHA-256 checksum
How to use checksums
0f17fe50cd357fcba96e0c13568065ca47356b2428c41e8fc419acea9e631b89
BLAKE2b-256 checksum
How to use checksums
426129f1b5f9c7dc852481e79f82ab8474e29cbdbb8c4b03fec5ef36185d40b3
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.2.1-py3-none-any.whl

Download URL django_oscar_redsys-0.2.1-py3-none-any.whl
Size 34.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c5818f65c44dbb5a6a929b1dab8e7d66644aec65dcec55d28b542db85f37c841
BLAKE2b-256 checksum
How to use checksums
7033f36a7550405dc2f1cfe128508793af5ee696821bf9a5b16903071508b4bf
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 history Release notifications | RSS feed

0.3.1

2 release files

0.3.0

2 release files

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page