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 flaskCreate 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 6Node.js
mkdir urbanpayx-integration && cd urbanpayx-integration
npm init -y
npm install axios express dotenvCreate 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:
| Error | Cause | Fix |
|---|---|---|
401 Invalid API key or secret | Wrong credentials | Check your .env file |
429 Too many attempts | 5+ failures | Wait 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_changedwebhook 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
.envfile 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 txPutting 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:
-
Add token refresh logic — API key tokens expire after 2 hours. Re-authenticate before expiration. See Authentication Guide.
-
Implement idempotent webhook handling — Store processed
event_idvalues in your database to prevent duplicate processing. See Webhook Event Catalog. -
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.
-
Use UrbanPayX KYC — This tutorial used external attestation for simplicity. For production, consider using UrbanPayX's hosted KYC verification. See KYC Verification Guide.
-
Run the Launch Checklist — Before going to production, verify every item on the Launch Checklist.
Related guides
- Getting Started — Quick reference for all API calls
- Core Concepts — How OpCos, Projects, Users, and Transactions connect
- Transaction Lifecycle — Full state machine and webhook patterns
- Webhook Event Catalog — Every event type with exact payloads
- Sandbox and Testing — Testing strategies and test bank details
- Launch Checklist — Production readiness verification