Integration example
Simple example on how do integration with NHP. Covers authentication, webhooks, pay-js-sdk and calling the api directly.
readme
Minimum example for Nordhealth Pay API / webhooks (Subhub) integration
If you want to see it running:
create & use virtual python environment: python -m venv .venv; source .venv/bin/activate
install dependencies: pip install -r requirements.txt
Check & modify the config.py file for configuration values
especially JWK_PASSPHRASE and SUBHUB_CURRENT_KEY and INTEGRATION_EXAMPLE_BASE_URL
Running the script first time will generate you RSA keypair (id_example) which will be used to sign JWTs
To run server to publish jwk and webhook receiver:
flask --app main run --host=0.0.0.0 --port=8000 --debug
Urls:
http://localhost:8000/etrust/jwks.json (jwk public key)
http://localhost:8000/incoming-webhooks/ (receive webhooks)
http://localhost:8000/example/ Call NH Pay api
To manage webhooks:
Run the script with following (optional) params (python main.py) for event subscription operations.
Specify ern, if you targeting speciric product/tenant (e.g. ern:dummypms/tenants/859c7f6b-90a4-43b4-a83c-24f9f8e2866d)
--subscribe [ern]
--events [comma separated list of events to susbscribe to] (if omitted, uses default values)
--unsubscribe [ern] (unsubcribe from all events)
Running without --subscribe or --unsubcribe will list subscribed events
In order this to actually work, you need to configure the public key to NHP tenant authorizer with the help of NHP Team.
requirements.txt
Flask==3.0.0
jwcrypto==1.5.0
requests==2.31.0
main.py
from flask import Flask, request
import auth
import api
import events
import subhub
import pay_js_sdk
import helpers
import pprint
from config import config
app = Flask(__name__)
# Expose public JWK to public url so NH Pay tenant-authority can access it
@app.route("/etrust/jwks.json")
def get_public_key():
return auth.get_public_key()
# Call NH Pay api
@app.route("/example/")
def get_info():
return api.get_info()
# Receive events from SUBHUB via HTTP POST
@app.route("/incoming-webhooks/", methods=["POST"])
def incoming():
return events.process_event()
# PAY-JS-SDK
@app.route("/etrust/tenant-token/", methods=["POST", "GET"])
def tenant_token():
return pay_js_sdk.tenant_token_for_pay_js_sdk()
# PAY-JS-SDK
@app.route("/management/", methods=["GET"])
def management():
return pay_js_sdk.management_page()
# PAY-JS-SDK
@app.route("/checkout/", methods=["GET"])
def checkout():
return pay_js_sdk.checkout_page()
# Subhub subscription stuff
if __name__ == "__main__":
pprint.pprint(config)
command, cmd_events, ern = helpers.parse_command_line_arguments()
if command == "subscribe":
subhub.subscribe(cmd_events, ern)
elif command == "unsubscribe":
subhub.unsubscribe(ern)
else:
subhub.show_subscriptions(ern)
auth.py
from jwcrypto.jwk import JWK
from jwcrypto.jwt import JWT
import pprint
import time
from config import config
# Try loading existing RSA keys from file, or generate new ones and save them. In prod, keys should be recycled regularly.
def _get_rsa_keypair():
private_key_pem = None
public_key_pem = None
try:
f = open("id_example", "r")
private_key_pem = f.read()
f.close()
f = open("id_example.pub", "r")
public_key_pem = f.read()
f.close()
print("Using existing keys")
except:
print("Private/public keys not found - generating new keypair")
key = JWK.generate(kty="RSA", size=2048)
passphrase = config["JWK_PASSPHRASE"].encode("utf-8")
private_key_pem = key.export_to_pem(
private_key=True, password=passphrase
).decode("utf-8")
public_key_pem = key.export_to_pem(private_key=False).decode("utf-8")
f = open("id_example", "w")
f.write(private_key_pem)
f.close()
f = open("id_example.pub", "w")
f.write(public_key_pem)
f.close()
return private_key_pem, public_key_pem
def _get_jwk_based_on_rsa_key():
private_key_pem, _ = _get_rsa_keypair()
jwk = JWK.from_pem(
private_key_pem.encode("utf-8"), password=config["JWK_PASSPHRASE"].encode("utf-8")
)
print("JWK:")
pprint.pprint(jwk)
return jwk
def get_public_key():
jwk = _get_jwk_based_on_rsa_key()
jwk_public_key = jwk.export_public(as_dict=True)
jwk_public_key.update({"use": "sig", "alg": "RS256"}) # Add needed metadata
return jwk_public_key
def generate_tenant_token(
aud=None,
scope=None,
tenant_ern=None,
tenant_name=None,
user_ern=None,
validity=180,
leeway=60,
):
jwk = _get_jwk_based_on_rsa_key()
iss = config["INTEGRATION_EXAMPLE_ISSUER"]
header = {"typ": "JWT", "alg": "RS256", "kid": jwk.thumbprint()}
claims = {
"iss": iss,
"sub": iss,
"aud": aud,
"iat": int(time.time()),
"exp": int(time.time()) + validity,
"nbf": int(time.time()) - leeway,
"scope": scope,
"tenant_name": tenant_name,
"tenant_ern": tenant_ern,
"user_ern": user_ern,
}
jwt_obj = JWT(header=header, claims=claims)
print("Signing JWT {}.{}".format(header, claims))
jwt_obj.make_signed_token(key=jwk)
print("Token generated:")
pprint.pprint(jwt_obj.serialize())
return jwt_obj
events.py
import json
import hashlib
import binascii
import hmac
import pprint
from config import config
def process_event(request):
pprint.pprint(request.headers)
pprint.pprint(request.data)
# Verify request signature, Subhub-Hmac header must be present
subhub_hmac = request.headers.get("Subhub-Hmac")
if not subhub_hmac:
print(f"Unsigned POST to {request.path} from ")
return "Unsigned POST"
binary_key = binascii.unhexlify(config["SUBHUB_CURRENT_KEY"])
sig = hmac.new(binary_key, request.data, hashlib.sha256).hexdigest()
if sig != subhub_hmac:
print(f"Invalid Subhub signature {sig}")
return "Invalid Subhub signature"
# Verify the publisher value is what we expect ourselves before processing the notification.
event = json.loads(request.data)
publisher = event["header"]["publisher"]
if publisher != config["PAYMENT_GATEWAY_PRODUCT_AUD"]:
print("Unknown publisher {}".format(event["header"]["publisher"]))
return "Unknown publisher"
# Process actual event
pprint.pprint(event)
return f'processed ok {event["header"]["event_id"]}'
subhub.py
from config import config
import pprint
import requests
import auth
def _get_auth_headers(tenant_ern):
return auth.generate_tenant_token(
aud=config["SUBHUB_PRODUCT_AUD"],
scope="subhub:subscribe",
tenant_ern=tenant_ern,
user_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public", # Act as a system
).serialize()
def subscribe(events, ern):
assert config["INTEGRATION_EXAMPLE_BASE_URL"], "You need publicly accessible url to receive webhooks defined."
subscription = {
"publisher": config["PAYMENT_GATEWAY_PRODUCT_AUD"],
"events": [
"transaction.pending",
"transaction.approved",
"transaction.failed",
"payment_link.created",
"payment_link.expired",
"payment_link.completed",
"tokenization.succeeded",
"tokenization.failed",
"company_status.changed",
"department_status.changed",
]
if not events
else events,
"destination": f"{config['INTEGRATION_EXAMPLE_BASE_URL']}/incoming-webhooks/",
"protocol": "http",
"signing_key": config["SUBHUB_CURRENT_KEY"],
}
subscription_url = f"{config['SUBHUB_URL']}/my/subscriptions/{config['SUBHUB_SUBSCRIPTION_ID']}"
pprint.pprint(subscription)
resp = requests.put(subscription_url, json=subscription, headers=_get_auth_headers(ern))
pprint.pprint(resp)
def unsubscribe(ern):
subscription_url = f"{config['SUBHUB_URL']}/my/subscriptions/{config['SUBHUB_SUBSCRIPTION_ID']}"
resp = requests.delete(subscription_url, headers=_get_auth_headers(ern))
pprint.pprint(resp)
def show_subscriptions(ern):
print("Auth headers:")
auth_headers = _get_auth_headers(ern)
pprint.pprint(auth_headers)
response = requests.get(f"{config['SUBHUB_URL']}/my/subscriptions", headers=auth_headers)
print("Response:")
pprint.pprint(response.json())
api.py
import auth
from config import config
import requests
def get_info():
token = auth.generate_tenant_token(
aud=config["PAYMENT_GATEWAY_PRODUCT_AUD"],
scope="pay:processPayments",
tenant_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public",
user_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public", # Act as a system
)
header = {"Authorization": f"Bearer {token.serialize()}"}
resp = requests.get(f"{config['PAYMENT_GATEWAY_API_URL']}/api/v1/info/", headers=header)
return resp.json()
pay_js_sdk.py
import auth
from config import config
def tenant_token_for_pay_js_sdk():
# Define scope based on logged in user access rights. Only admin users should have access to manageIntegration (opening management-ui)
scope = "pay:manageIntegration pay:processPayments pay:chargeToken",
token = auth.generate_tenant_token(
aud=config["PAYMENT_GATEWAY_PRODUCT_AUD"],
scope=scope,
tenant_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/1",
tenant_name="integration example",
user_ern="//users/1", # authenticated user
)
return {"token": token.serialize(), "validity": 180}
def management_page():
return """
<html>
<head>
<script src="https://nordcdn-stage.net/pay/pay-js-sdk/2.0.x/pay-js-sdk.min.js"></script>
<script>
var PAYMENT_GATEWAY_FRONTEND_URL = 'https://pay.nordhealth-test.com';
var PAYMENT_GATEWAY_API_URL = 'https://api.pay.nordhealth-test.app';
</script>
</head>
<title>PMS page open management</title>
<body>
<div class="col-md mx-auto">
<button id="payment-integration-settings" class="btn btn-lg btn-primary text-left w-50">
Payment integration settings
</button>
</div>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script>
(function ($, et) {
var auth = new et.Auth({tokenEndpoint: '/etrust/tenant-token/'});
var organizationStructure = [{"name": "integration-test", "ern": "//companies/1", "reference": 1, "departments": [{"ern": "//departments/1", "reference": 1, "name": "test department", "active": true}], "country": "FI", "email": "joe@example.com", "mcc": "0742"}];
console.log(organizationStructure)
$('#payment-integration-settings').click(function () {
(new eTrust.Pay.Management({
auth: auth,
apiUrl: PAYMENT_GATEWAY_API_URL,
baseUrl: PAYMENT_GATEWAY_FRONTEND_URL,
organizationStructure: organizationStructure
})).openWindow();
});
})(jQuery, eTrust);
</script>
</body>
</html>
"""
def checkout_page():
return """
<html>
<head>
<script src="https://nordcdn-stage.net/pay/pay-js-sdk/2.0.x/pay-js-sdk.min.js"></script>
<script>
var PAYMENT_GATEWAY_FRONTEND_URL = 'https://pay.nordhealth-test.com';
var PAYMENT_GATEWAY_API_URL = 'https://api.pay.nordhealth-test.app';
</script>
</head>
<title>PMS page open checkout</title>
<body>
<div class="col-md mx-auto">
<button id="payment-checkout" class="btn btn-lg btn-primary text-left w-50">
Checkout
</button>
</div>
<div class="col-md mx-auto">
<button id="payment-refund" class="btn btn-lg btn-primary text-left w-50">
Refund
</button>
</div>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script>
(function ($, et) {
function statusCallback(paymentStatus) {
console.log('paymentStatus', paymentStatus)
if (paymentStatus.notification) {
console.log('notification', paymentStatus.notification);
}
}
function openCheckout(data) {
var auth = new et.Auth({
tokenEndpoint: '/etrust/tenant-token/',
});
new et.Pay.Checkout({
paymentStatusCallback: data.paymentStatusCallback,
auth: auth,
apiUrl: PAYMENT_GATEWAY_API_URL,
baseUrl: PAYMENT_GATEWAY_FRONTEND_URL,
locale: 'fi-FI',
timezone: 'Europe/Helsinki',
shopperErn: '//customers/1',
storeErn: '//departments/1',
shopperEmail: 'shopper@local.dev',
shopperPhone: '+3584012344566',
metaData: {
invoice_id: "234"
},
}).openModal(data.modalData);
}
$('#payment-checkout').click(function () {
openCheckout({
paymentStatusCallback: statusCallback,
modalData: {
uiType: 'payment',
amount: "10.00",
currency: 'EUR',
onClose: function(data) {
console.log('payment onClose', data)
if (data.wasCharged)
window.location.reload()
},
tokenization: undefined,
paymentDetails: {
lineItems:[
{
quantity: 1,
amountExcludingTax: '10.00',
taxPercentage: "0",
description: 'product description',
id: "1234-5678",
taxAmount: '0',
amountIncludingTax: '10.00',
}
]
},
}
})
});
$('#payment-refund').click(function() {
openCheckout({
paymentStatusCallback: statusCallback,
modalData: {
uiType: 'refund',
extReference: 'payment-reference',
currency: 'EUR',
onClose: function(data) {
console.log('refund onClose', data)
if (data.wasCharged)
window.location.reload()
}
}
})
})
})(jQuery, eTrust);
</script>
</body>
</html>
"""
config.py
# These need to be configured to tenant-authority by NH Pay team along with public jwk (url)
# In order to acceess api / notifications for another product/tenant, it needs to be explicitly configured in tenant-authority
config = {
"INTEGRATION_EXAMPLE_PRODUCT_NAME": "integrationexample-dev",
"INTEGRATION_EXAMPLE_ISSUER": "https://integrationexample-dev.etrust.health",
# This public url is used by subhub to deliver notifications and Payment gateway to fetch public jwk. Provide url (or the response) for tenant-authority
"INTEGRATION_EXAMPLE_BASE_URL": "https://c88e-2a00-1190-c034-aba-00-1016.ngrok-free.app", #e.g. https://4ecc-195-181-204-114.ngrok-free.app
# Secrets:
# Generate unqiue values e.g. with:
# binascii.hexlify(os.urandom(32)).decode("ascii")
"JWK_PASSPHRASE": "121100b3e6ba1f5776a7de3bfcd3fcc8554074618b35c89503e125a02d9940cd",
"SUBHUB_CURRENT_KEY": "74e567c8baedb91899c9bb59971fda93c108584e9323fa211d114e75e51bc807",
# Target NH PAY environment and names
"SUBHUB_URL": "https://subhub.nordhealth-test.app/v1",
"PAYMENT_GATEWAY_API_URL": "https://api.pay.nordhealth-test.app",
"SUBHUB_PRODUCT_AUD": "https://subhub.etrust.health",
"PAYMENT_GATEWAY_PRODUCT_AUD": "https://pay.etrust.health",
}
# Other configurations
config["SUBHUB_SUBSCRIPTION_ID"] = f"pay_{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}"
assert config.get("JWK_PASSPHRASE")
assert config.get("SUBHUB_CURRENT_KEY")
helpers.py
import argparse
from config import config
def parse_command_line_arguments():
parser = argparse.ArgumentParser(
description="Example script to 1. generate JWK keys, 2. sign token to make api calls, 3. manage subhub subscriptions"
)
parser.add_argument(
"--tenant",
help="Target ern, e.g. ern:dummypms/tenants/859c7f6b-90a4-43b4-a83c-24f9f8e2866d",
)
parser.add_argument("--subscribe", action="store_true", default=False)
parser.add_argument("--unsubscribe", action="store_true", default=False)
parser.add_argument(
"--events",
help="Events to be subscribed to. Defaults to all if omitted. e.g. 'payment_link.created,payment_link.expired,payment_link.completed'",
)
args = parser.parse_args()
ern = (
args.tenant
if args.tenant
else f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public"
) # target tenant or own system ern
events = args.events.split(",") if args.events else []
if args.subscribe:
command = "subscribe"
elif args.unsubscribe:
command = "unsubsribe"
else:
command = "show"
return command, events, ern