Strong Customer Authentication for the Wise API in Python

This might be of interest if you are interfacing with the Wise (formerly TransferWise) API from a Python project. Note that you need to have regulatory approval to be able to access 2FA/SCA-protected endpoints - speak to your contact at Wise for more info. But once you get your private key enrolled, this code should work.

import typing
from base64 import b64encode
from datetime import datetime
from enum import StrEnum
from typing import List

import niquests
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from niquests import PreparedRequest, Response

TRANSFERWISE_BASE_URL = "https://api.transferwise.com"


class StatementType(StrEnum):
    COMPACT = "COMPACT"  # a single statement line per transaction
    FLAT = "FLAT"  # accounting statements where transaction fees are on a separate line


class TransferWise2FASession(niquests.Session):
    """
    TransferWise 2FA session implementation.
    """

    def __init__(self, *args, tfa_private_key: typing.Optional[PrivateKeyTypes] = None, **kwargs):
        super().__init__(*args, **kwargs)

        self.tfa_private_key = tfa_private_key

    def send(self, request: PreparedRequest, **kwargs: typing.Any) -> Response:
        resp = super().send(request, **kwargs)

        needs_2fa = (
            resp.status_code == 403
            and "x-2fa-approval" in resp.headers
            and resp.headers.get("x-2fa-approval-result") == "REJECTED"
        )

        if needs_2fa:
            if not self.tfa_private_key:
                raise RuntimeError("2FA required by no private key provided.")

            tfa_flow_id = resp.headers["x-2fa-approval"]

            tfa_signature = self.tfa_private_key.sign(
                tfa_flow_id.encode("utf-8"),
                padding.PKCS1v15(),
                hashes.SHA256(),
            )

            request.headers.update(
                {
                    "x-2fa-approval": tfa_flow_id,
                    "X-Signature": b64encode(tfa_signature).decode("utf-8"),
                }
            )

            return super().send(request, **kwargs)

        return resp


class TransferwiseClient:
    def __init__(self, access_token: str, private_key: PrivateKeyTypes):
        self.session = TransferWise2FASession(tfa_private_key=private_key)
        self.session.headers.update({"Authorization": f"Bearer {access_token}"})

    def get_balance_by_profile_and_balance_id(self, profile_id: str, balance_id: str) -> dict:
        resp = self.session.get(
            TRANSFERWISE_BASE_URL + f"/v4/profiles/{profile_id}/balances/{balance_id}",
        )

        resp.raise_for_status()

        return resp.json()

    def get_statement_by_profile_and_balance_id(
        self,
        profile_id: str,
        balance_id: str,
        currency: str,
        from_: datetime,
        to: datetime,
        type: StatementType = StatementType.FLAT,
        locale: str = "en",
    ) -> List[dict]:
        resp = self.session.get(
            TRANSFERWISE_BASE_URL + f"/v1/profiles/{profile_id}/balance-statements/{balance_id}/statement.json",
            params={
                "currency": currency,
                "intervalStart": from_.isoformat(),
                "intervalEnd": to.isoformat(),
                "type": type,
                "statementLocale": locale,
            },
        )

        resp.raise_for_status()

        return resp.json().get("transactions", [])


class TransferWiseProfileClient(TransferwiseClient):
    def __init__(self, *args, profile_id: str, **kwargs):
        super().__init__(*args, **kwargs)

        self.profile_id = profile_id


class TransferWiseBalanceClient(TransferWiseProfileClient):
    def __init__(self, *args, balance_id: str, **kwargs):
        super().__init__(*args, **kwargs)

        self.balance_id = balance_id

    def get_balance(self):
        return self.get_balance_by_profile_and_balance_id(self.profile_id, self.balance_id)

    def get_statement(
        self,
        currency: str,
        from_: datetime,
        to: datetime,
        type: StatementType = StatementType.FLAT,
        locale: str = "en",
    ):
        return self.get_statement_by_profile_and_balance_id(
            profile_id=self.profile_id,
            balance_id=self.balance_id,
            currency=currency,
            from_=from_,
            to=to,
            type=type,
            locale=locale,
        )

Like what you see? I may be available for fintech & open-banking-related consulting or implementation work. Reach out on LinkedIn or via e-mail if you need assistance with technical matters!

 
1
Kudos
 
1
Kudos

Now read this

The “backend” pattern in Django settings

In the context of Django reusable apps, I often need to provide a way for the user to configure a connection (or set of) to some database or third-party service, and optionally be able to substitute the driver/adapter class with a custom... Continue →