Integration Tutorial

Collect Your First Payment: End-to-End Tutorial

This tutorial walks you through a complete UrbanPayX integration — from authenticating to receiving a webhook confirming a successful payment. By the end, you will have production-ready code that handles the entire payment lifecycle.

What you will build: A backend service that creates a verified user, generates a payment link, and processes the payment result via webhooks.

Time: ~30 minutes

Prerequisites: An UrbanPayX sandbox account with API credentials. If you do not have one, contact the UrbanPayX team.


Architecture overview

Your Backend                    UrbanPayX                     User's Bank
     |                              |                              |
     |  1. Authenticate             |                              |
     |----------------------------->|                              |
     |  2. Create OpCo + Project    |                              |
     |----------------------------->|                              |
     |  3. Create & verify user     |                              |
     |----------------------------->|                              |
     |  4. Generate payment link    |                              |
     |----------------------------->|                              |
     |  5. Send checkout URL        |                              |
     |         to your user --------|------ 6. User pays --------->|
     |                              |<----- 7. Bank confirms ------|
     |  8. Webhook: status_changed  |                              |
     |<-----------------------------|                              |
     |  9. Fulfill order            |                              |

Step 0: Set up your project

Python

mkdir urbanpayx-integration && cd urbanpayx-integration
python -m venv venv && source venv/bin/activate
pip install requests flask

Create a .env file (never commit this):

UPX_API_KEY=your-sandbox-api-key
UPX_API_SECRET=your-sandbox-api-secret
UPX_BASE_URL=https://api-sandbox.urbanpayx.com
UPX_WEBHOOK_SECRET=  # You will get this in Step 6

Node.js

mkdir urbanpayx-integration && cd urbanpayx-integration
npm init -y
npm install axios express dotenv

Create a .env file:

UPX_API_KEY=your-sandbox-api-key
UPX_API_SECRET=your-sandbox-api-secret
UPX_BASE_URL=https://api-sandbox.urbanpayx.com
UPX_WEBHOOK_SECRET=

Step 1: Authenticate

Exchange your API key for an access token. API key tokens are valid for 2 hours and carry the operations role.

Python

import os
import requests

BASE_URL = os.environ["UPX_BASE_URL"]
API_KEY = os.environ["UPX_API_KEY"]
API_SECRET = os.environ["UPX_API_SECRET"]

def authenticate():
    """Get an access token using API key credentials."""
    resp = requests.post(f"{BASE_URL}/api/v1/auth/api-token", json={
        "api_key": API_KEY,
        "api_secret": API_SECRET
    })
    resp.raise_for_status()
    token = resp.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}

headers = authenticate()
print("Authenticated successfully")

Node.js

const axios = require("axios");
require("dotenv").config();

const BASE_URL = process.env.UPX_BASE_URL;
const API_KEY = process.env.UPX_API_KEY;
const API_SECRET = process.env.UPX_API_SECRET;

async function authenticate() {
  const resp = await axios.post(`${BASE_URL}/api/v1/auth/api-token`, {
    api_key: API_KEY,
    api_secret: API_SECRET,
  });
  return { Authorization: `Bearer ${resp.data.access_token}` };
}

What can go wrong:

ErrorCauseFix
401 Invalid API key or secretWrong credentialsCheck your .env file
429 Too many attempts5+ failuresWait 5 minutes, then fix credentials

Step 2: Create an Operating Company (OpCo)

An OpCo is your registered business entity in the payment network. In sandbox, approval takes minutes.

Python

def create_opco(headers):
    """Create and submit an OpCo for regulatory approval."""
    # 1. Create in DRAFT status
    resp = requests.post(f"{BASE_URL}/api/v1/opcos/create", headers=headers, json={
        "opco_type": "licensed_tpp_sole_trader",
        "name": "Acme Properties",
        "legal_entity_name": "Acme Properties Ltd",
        "company_registration_number": "12345678",
        "domain": "acme-properties.com",
        "merchant_jurisdiction": "DE",
        "merchant_iban": "DE89370400440532013000",
        "mcc_code": "6513",
        "sole_trader": {
            "fullName": "Jane Smith",
            "address": "123 Main St, Berlin, Germany",
            "dateOfBirth": "1985-06-15T00:00:00Z"
        }
    })
    resp.raise_for_status()
    opco = resp.json()
    opco_id = opco["id"]
    print(f"OpCo created: {opco_id} (status: {opco['status']})")

    # 2. Submit for approval
    resp = requests.post(f"{BASE_URL}/api/v1/opcos/{opco_id}/submit", headers=headers)
    resp.raise_for_status()
    opco = resp.json()
    print(f"OpCo submitted: status is now '{opco['status']}'")

    # 3. Poll until activated (sandbox: ~1-3 minutes)
    import time
    for attempt in range(20):
        time.sleep(10)
        resp = requests.get(f"{BASE_URL}/api/v1/opcos/{opco_id}", headers=headers)
        resp.raise_for_status()
        status = resp.json()["status"]
        print(f"  Polling attempt {attempt + 1}: status = {status}")
        if status == "activated":
            print("OpCo activated!")
            return opco_id
        elif status == "rejected":
            raise Exception(f"OpCo rejected: {resp.json().get('status_reason')}")

    raise Exception("OpCo approval timed out. Check dashboard for status.")

opco_id = create_opco(headers)

Node.js

async function createOpco(headers) {
  // 1. Create in DRAFT
  let resp = await axios.post(`${BASE_URL}/api/v1/opcos/create`, {
    opco_type: "licensed_tpp_sole_trader",
    name: "Acme Properties",
    legal_entity_name: "Acme Properties Ltd",
    company_registration_number: "12345678",
    domain: "acme-properties.com",
    merchant_jurisdiction: "DE",
    merchant_iban: "DE89370400440532013000",
    mcc_code: "6513",
    sole_trader: {
      fullName: "Jane Smith",
      address: "123 Main St, Berlin, Germany",
      dateOfBirth: "1985-06-15T00:00:00Z",
    },
  }, { headers });

  const opcoId = resp.data.id;
  console.log(`OpCo created: ${opcoId} (status: ${resp.data.status})`);

  // 2. Submit
  resp = await axios.post(
    `${BASE_URL}/api/v1/opcos/${opcoId}/submit`, null, { headers }
  );
  console.log(`OpCo submitted: status is now '${resp.data.status}'`);

  // 3. Poll until activated
  for (let attempt = 0; attempt < 20; attempt++) {
    await new Promise((r) => setTimeout(r, 10000));
    resp = await axios.get(`${BASE_URL}/api/v1/opcos/${opcoId}`, { headers });
    const status = resp.data.status;
    console.log(`  Polling attempt ${attempt + 1}: status = ${status}`);
    if (status === "activated") return opcoId;
    if (status === "rejected")
      throw new Error(`OpCo rejected: ${resp.data.status_reason}`);
  }
  throw new Error("OpCo approval timed out");
}

Tip: In production, use the opco.status_changed webhook instead of polling. Polling is fine for this tutorial and initial testing.


Step 3: Create a Project

A Project groups payments under an OpCo. For a real estate company, each property might be its own Project.

Python

def create_project(headers, opco_id):
    resp = requests.post(f"{BASE_URL}/api/v1/projects/create", headers=headers, json={
        "project_name": "Sunrise Apartments - Rent Collection",
        "opco_id": opco_id
    })
    resp.raise_for_status()
    project_id = resp.json()["id"]
    print(f"Project created: {project_id}")
    return project_id

project_id = create_project(headers, opco_id)

Node.js

async function createProject(headers, opcoId) {
  const resp = await axios.post(`${BASE_URL}/api/v1/projects/create`, {
    project_name: "Sunrise Apartments - Rent Collection",
    opco_id: opcoId,
  }, { headers });
  console.log(`Project created: ${resp.data.id}`);
  return resp.data.id;
}

Step 4: Create and verify a user

Every payer must be a verified user. We will use external attestation mode (the simpler path for testing).

Python

def create_and_verify_user(headers):
    # 1. Create user with external attestation mode
    resp = requests.post(f"{BASE_URL}/api/v1/kyc/users", headers=headers, json={
        "email": "[email protected]",
        "full_name": "Alice Johnson",
        "verification_mode": "external_attestation"
    })
    resp.raise_for_status()
    user = resp.json()
    user_id = user["id"]
    print(f"User created: USER_ID (external_status: {user['external_verification_status']})")

    # 2. Mark as verified (you have done your own KYC)
    resp = requests.post(
        f"{BASE_URL}/api/v1/kyc/external/status/USER_ID",
        headers=headers,
        json={
            "status": "verified",
            "provider": "internal-kyc",
            "reference": f"tutorial-USER_ID",
            "reason": "Identity verified via document check"
        }
    )
    resp.raise_for_status()
    print(f"User verified: {resp.json()['status']}")
    return user_id

user_id = create_and_verify_user(headers)

Node.js

async function createAndVerifyUser(headers) {
  // 1. Create user
  let resp = await axios.post(`${BASE_URL}/api/v1/kyc/users`, {
    email: "[email protected]",
    full_name: "Alice Johnson",
    verification_mode: "external_attestation",
  }, { headers });

  const userId = resp.data.id;
  console.log(`User created: $USERID`);

  // 2. Verify
  resp = await axios.post(
    `${BASE_URL}/api/v1/kyc/external/status/$USERID`,
    {
      status: "verified",
      provider: "internal-kyc",
      reference: `tutorial-$USERID`,
      reason: "Identity verified via document check",
    },
    { headers }
  );
  console.log(`User verified: ${resp.data.status}`);
  return userId;
}

Step 5: Set your callback URL

The callback URL is where users are redirected after checkout. Set it before generating payment links.

Python

def set_callback_url(headers):
    resp = requests.put(f"{BASE_URL}/api/v1/auth/client/settings", headers=headers, json={
        "callback_url": "https://your-app.com/payment/complete"
    })
    resp.raise_for_status()
    print(f"Callback URL set: {resp.json()['callback_url']}")

set_callback_url(headers)

Node.js

async function setCallbackUrl(headers) {
  const resp = await axios.put(`${BASE_URL}/api/v1/auth/client/settings`, {
    callback_url: "https://your-app.com/payment/complete",
  }, { headers });
  console.log(`Callback URL set: ${resp.data.callback_url}`);
}

Step 6: Configure webhooks

Webhooks are how you learn about payment outcomes. Set up your webhook endpoint before creating payments.

Python

def configure_webhooks(headers):
    resp = requests.put(f"{BASE_URL}/api/v1/webhooks/subscription", headers=headers, json={
        "webhook_url": "https://your-app.com/webhooks/urbanpayx",
        "webhook_enabled": True,
        "webhook_events": [
            "payment.status_changed",
            "kyc.status_changed",
            "opco.status_changed",
            "project.frozen_changed"
        ]
    })
    resp.raise_for_status()
    config = resp.json()
    print(f"Webhooks enabled: {config['webhook_enabled']}")
    print(f"Subscribed events: {config['webhook_events']}")

    # Get your signing secret (for signature verification)
    resp = requests.post(
        f"{BASE_URL}/api/v1/webhooks/subscription/rotate-secret",
        headers=headers
    )
    resp.raise_for_status()
    secret = resp.json()
    print(f"Webhook secret: {secret}")
    print("IMPORTANT: Save this secret securely. It is only shown once.")
    return secret

webhook_config = configure_webhooks(headers)

Important: The webhook secret is returned only once when generated or rotated. Save it to your .env file immediately.


Step 7: Generate a payment link

This is the core operation — creating a payment that your user can complete.

Python

import uuid

def generate_payment_link(headers, user_id, project_id):
    idempotency_key = str(uuid.uuid4())  # Unique per payment intent

    resp = requests.post(
        f"{BASE_URL}/api/v1/checkout/paylink",
        headers={
            **headers,
            "X-Idempotency-Key": idempotency_key
        },
        json={
            "amount": 1250.00,
            "currency": "EUR",
            "user_id": user_id,
            "project_id": project_id,
            "description": "March 2026 rent - Apartment 4B"
        }
    )
    resp.raise_for_status()
    tx = resp.json()
    print(f"Transaction created: {tx['id']}")
    print(f"Status: {tx['status']}")
    print(f"Checkout URL: {tx['checkout_url']}")
    return tx

transaction = generate_payment_link(headers, user_id, project_id)

Node.js

const { v4: uuidv4 } = require("uuid");

async function generatePaymentLink(headers, userId, projectId) {
  const resp = await axios.post(`${BASE_URL}/api/v1/checkout/paylink`, {
    amount: 1250.0,
    currency: "EUR",
    user_id: userId,
    project_id: projectId,
    description: "March 2026 rent - Apartment 4B",
  }, {
    headers: { ...headers, "X-Idempotency-Key": uuidv4() },
  });

  console.log(`Transaction created: ${resp.data.id}`);
  console.log(`Checkout URL: ${resp.data.checkout_url}`);
  return resp.data;
}

What you get back:

{
  "id": "tx-uuid",
  "amount": 1250.00,
  "currency": "EUR",
  "status": "pending",
  "checkout_url": "https://pay.urbanpayx.com/...",
  "checkout_url_v2": "https://pay.urbanpayx.com/...",
  "user_id": "usr-uuid",
  "project_id": "prj-uuid",
  "created_at": "2026-03-26T10:00:00Z"
}

Open the checkout_url in your browser to test the payment flow. In sandbox, select the test bank and authorize the payment.


Step 8: Handle the webhook

When the user completes (or abandons) the payment, UrbanPayX sends a payment.status_changed webhook to your endpoint.

Python (Flask webhook handler)

import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get("UPX_WEBHOOK_SECRET", "")

@app.route("/webhooks/urbanpayx", methods=["POST"])
def handle_webhook():
    # 1. Verify signature
    signature = request.headers.get("X-UPX-Signature", "")
    timestamp = request.headers.get("X-UPX-Timestamp", "")
    body = request.get_data()

    signing_payload = f"{timestamp}.".encode("utf-8") + body
    expected = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"), signing_payload, hashlib.sha256
    ).hexdigest()
    received = signature.removeprefix("sha256=")

    if not hmac.compare_digest(expected, received):
        return jsonify({"error": "Invalid signature"}), 401

    if abs(time.time() - int(timestamp)) > 300:
        return jsonify({"error": "Stale event"}), 401

    # 2. Process the event
    event = json.loads(body)
    event_type = event["type"]
    data = event["data"]

    if event_type == "payment.status_changed":
        tx_id = data["transaction_id"]
        status = data["current_status"]
        print(f"Payment {tx_id}: {data['previous_status']} -> {status}")

        if status == "success":
            print("Payment confirmed! Fulfilling order...")
            # TODO: Update your database, send confirmation email
        elif status == "failed":
            print("Payment failed. Notifying customer...")
            # TODO: Notify user, offer retry with new payment link
        elif status == "expired":
            print("Payment link expired.")
            # TODO: Generate new payment link if still needed
        elif status == "canceled":
            print("Payment canceled by user.")

    return jsonify({"status": "ok"}), 200

if __name__ == "__main__":
    app.run(port=3000)

Node.js (Express webhook handler)

const express = require("express");
const crypto = require("crypto");
require("dotenv").config();

const app = express();
const WEBHOOK_SECRET = process.env.UPX_WEBHOOK_SECRET;

app.post("/webhooks/urbanpayx",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-upx-signature"] || "";
    const timestamp = req.headers["x-upx-timestamp"] || "";
    const body = req.body.toString();

    // Verify signature
    const signingPayload = `${timestamp}.${body}`;
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(signingPayload)
      .digest("hex");
    const received = signature.replace("sha256=", "");

    try {
      if (!crypto.timingSafeEqual(
        Buffer.from(expected, "hex"),
        Buffer.from(received, "hex")
      )) {
        return res.status(401).json({ error: "Invalid signature" });
      }
    } catch {
      return res.status(401).json({ error: "Invalid signature" });
    }

    // Process event
    const event = JSON.parse(body);
    if (event.type === "payment.status_changed") {
      const { transaction_id, previous_status, current_status } = event.data;
      console.log(`Payment ${transaction_id}: ${previous_status} -> ${current_status}`);

      switch (current_status) {
        case "success":
          console.log("Payment confirmed! Fulfilling order...");
          break;
        case "failed":
          console.log("Payment failed. Notifying customer...");
          break;
        case "expired":
          console.log("Payment link expired.");
          break;
        case "canceled":
          console.log("Payment canceled by user.");
          break;
      }
    }

    res.status(200).json({ status: "ok" });
  }
);

app.listen(3000, () => console.log("Webhook handler on port 3000"));

Step 9: Verify the result

After the payment completes, you can also check the transaction status via the API:

def check_transaction(headers, transaction_id):
    resp = requests.get(
        f"{BASE_URL}/api/v1/transactions/{transaction_id}",
        headers=headers
    )
    resp.raise_for_status()
    tx = resp.json()
    print(f"Transaction {tx['id']}: status={tx['status']}, amount={tx['amount']} {tx['currency']}")
    return tx

Putting it all together

Here is the complete setup script that runs Steps 1-7 in sequence:

Python (complete)

#!/usr/bin/env python3
"""UrbanPayX integration setup - run once to create your payment infrastructure."""

import os
import uuid
import time
import requests

# Load config
BASE_URL = os.environ["UPX_BASE_URL"]
API_KEY = os.environ["UPX_API_KEY"]
API_SECRET = os.environ["UPX_API_SECRET"]

def main():
    # Step 1: Authenticate
    resp = requests.post(f"{BASE_URL}/api/v1/auth/api-token", json={
        "api_key": API_KEY, "api_secret": API_SECRET
    })
    resp.raise_for_status()
    headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
    print("[1/7] Authenticated")

    # Step 2: Create and submit OpCo
    resp = requests.post(f"{BASE_URL}/api/v1/opcos/create", headers=headers, json={
        "opco_type": "licensed_tpp_sole_trader",
        "name": "Acme Properties",
        "legal_entity_name": "Acme Properties Ltd",
        "company_registration_number": "12345678",
        "domain": "acme-properties.com",
        "merchant_jurisdiction": "DE",
        "merchant_iban": "DE89370400440532013000",
        "mcc_code": "6513",
        "sole_trader": {
            "fullName": "Jane Smith",
            "address": "123 Main St, Berlin, Germany",
            "dateOfBirth": "1985-06-15T00:00:00Z"
        }
    })
    resp.raise_for_status()
    opco_id = resp.json()["id"]

    requests.post(f"{BASE_URL}/api/v1/opcos/{opco_id}/submit", headers=headers).raise_for_status()
    print(f"[2/7] OpCo submitted: {opco_id}")

    # Wait for activation
    for i in range(30):
        time.sleep(10)
        status = requests.get(f"{BASE_URL}/api/v1/opcos/{opco_id}", headers=headers).json()["status"]
        if status == "activated": break
        if status == "rejected": raise Exception("OpCo rejected")
    else:
        raise Exception("OpCo activation timed out")
    print(f"[2/7] OpCo activated")

    # Step 3: Create project
    resp = requests.post(f"{BASE_URL}/api/v1/projects/create", headers=headers, json={
        "project_name": "Sunrise Apartments", "opco_id": opco_id
    })
    resp.raise_for_status()
    project_id = resp.json()["id"]
    print(f"[3/7] Project created: {project_id}")

    # Step 4: Create and verify user
    resp = requests.post(f"{BASE_URL}/api/v1/kyc/users", headers=headers, json={
        "email": "[email protected]",
        "full_name": "Alice Johnson",
        "verification_mode": "external_attestation"
    })
    resp.raise_for_status()
    user_id = resp.json()["id"]

    requests.post(f"{BASE_URL}/api/v1/kyc/external/status/USER_ID", headers=headers, json={
        "status": "verified", "provider": "internal-kyc",
        "reference": f"tutorial-USER_ID"
    }).raise_for_status()
    print(f"[4/7] User created and verified: USER_ID")

    # Step 5: Set callback URL
    requests.put(f"{BASE_URL}/api/v1/auth/client/settings", headers=headers, json={
        "callback_url": "https://your-app.com/payment/complete"
    }).raise_for_status()
    print("[5/7] Callback URL configured")

    # Step 6: Configure webhooks
    requests.put(f"{BASE_URL}/api/v1/webhooks/subscription", headers=headers, json={
        "webhook_url": "https://your-app.com/webhooks/urbanpayx",
        "webhook_enabled": True,
        "webhook_events": ["payment.status_changed", "kyc.status_changed",
                           "opco.status_changed", "project.frozen_changed"]
    }).raise_for_status()
    print("[6/7] Webhooks configured")

    # Step 7: Generate payment link
    resp = requests.post(f"{BASE_URL}/api/v1/checkout/paylink", headers={
        **headers, "X-Idempotency-Key": str(uuid.uuid4())
    }, json={
        "amount": 1250.00, "currency": "EUR",
        "user_id": user_id, "project_id": project_id,
        "description": "March 2026 rent - Apartment 4B"
    })
    resp.raise_for_status()
    tx = resp.json()
    print(f"[7/7] Payment link generated!")
    print(f"  Transaction ID: {tx['id']}")
    print(f"  Checkout URL:   {tx['checkout_url']}")
    print(f"\nOpen the checkout URL in your browser to complete the test payment.")

if __name__ == "__main__":
    main()

What to build next

Now that you have the basics working:

  1. Add token refresh logic — API key tokens expire after 2 hours. Re-authenticate before expiration. See Authentication Guide.

  2. Implement idempotent webhook handling — Store processed event_id values in your database to prevent duplicate processing. See Webhook Event Catalog.

  3. Handle edge cases — What if the OpCo is rejected? What if KYC fails? What if the payment expires? Your production integration should handle every status. See Error Reference.

  4. Use UrbanPayX KYC — This tutorial used external attestation for simplicity. For production, consider using UrbanPayX's hosted KYC verification. See KYC Verification Guide.

  5. Run the Launch Checklist — Before going to production, verify every item on the Launch Checklist.


Related guides