Webhook Event Catalog
This page documents the exact payload structure for every webhook event type, including headers, signature verification, and retry behavior.
Envelope format
Every webhook event follows this structure:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "payment.status_changed",
"version": "v1",
"occurred_at": "2026-03-26T10:15:00+00:00",
"client_id": "your-client-uuid",
"data": {
// Event-specific fields - see below
}
}| Field | Type | Description |
|---|---|---|
id | UUID | Unique event identifier. Use this for idempotent processing. |
type | string | Event type (see catalog below) |
version | string | Always v1 |
occurred_at | ISO 8601 | When the event occurred |
client_id | UUID | Your client identifier |
data | object | Event-specific payload |
HTTP headers
Every webhook delivery includes these headers:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON |
X-UPX-Event-Id | 550e8400-... | Same as the id in the body. Use for deduplication. |
X-UPX-Event-Type | payment.status_changed | Event type |
X-UPX-Event-Version | v1 | Webhook format version |
X-UPX-Timestamp | 1711350420 | Unix timestamp (seconds) when the event was signed |
X-UPX-Signature | sha256=abc123... | HMAC-SHA256 signature for verification |
Signature verification
Every webhook is signed so you can verify it came from UrbanPayX and was not tampered with.
How the signature is computed
signing_payload = "{timestamp}.{raw_json_body}"
signature = HMAC-SHA256(signing_payload, your_webhook_secret)
header_value = "sha256={hex_digest}"
The timestamp is the value from the X-UPX-Timestamp header. The raw JSON body is the exact JSON body as sent (compact format, no extra whitespace).
Verification in Python
import hmac
import hashlib
import time
def verify_webhook(payload_body: bytes, signature_header: str,
timestamp_header: str, webhook_secret: str,
max_age_seconds: int = 300) -> bool:
"""Verify an UrbanPayX webhook signature."""
# 1. Check timestamp freshness to prevent replay attacks
try:
event_time = int(timestamp_header)
except (ValueError, TypeError):
return False
if abs(time.time() - event_time) > max_age_seconds:
return False
# 2. Compute expected signature
signing_payload = f"{timestamp_header}.".encode("utf-8") + payload_body
expected = hmac.new(
webhook_secret.encode("utf-8"),
signing_payload,
hashlib.sha256
).hexdigest()
# 3. Compare signatures (constant-time to prevent timing attacks)
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)Verification in Node.js
const crypto = require("crypto");
function verifyWebhook(payloadBody, signatureHeader, timestampHeader,
webhookSecret, maxAgeSeconds = 300) {
// 1. Check timestamp freshness
const eventTime = parseInt(timestampHeader, 10);
if (isNaN(eventTime)) return false;
if (Math.abs(Date.now() / 1000 - eventTime) > maxAgeSeconds) return false;
// 2. Compute expected signature
const signingPayload = `${timestampHeader}.${payloadBody}`;
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(signingPayload)
.digest("hex");
// 3. Compare signatures (constant-time)
const received = signatureHeader.replace("sha256=", "");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex")
);
}Verification best practices
- Always verify — reject any webhook that fails signature verification.
- Check timestamp freshness — reject events older than 5 minutes to prevent replay attacks.
- Use constant-time comparison —
hmac.compare_digest(Python) orcrypto.timingSafeEqual(Node.js) prevents timing side-channel attacks. - Use the raw body — parse the JSON after verifying the signature. If you parse first and re-serialize, whitespace differences will break verification.
Event types
payment.status_changed
payment.status_changedFired when a transaction changes status. This is the most common event and the one you will use to track payment outcomes.
{
"id": "evt-a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "payment.status_changed",
"version": "v1",
"occurred_at": "2026-03-26T10:15:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"transaction_id": "tx-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"ref_id": "abc123def456",
"transfer_id": "token-io-payment-id-789",
"user_id": "usr-11111111-2222-3333-4444-555555555555",
"project_id": "prj-11111111-2222-3333-4444-555555555555",
"previous_status": "pending",
"current_status": "processing",
"source": "provider_webhook",
"provider_status": "COMPLETED",
"actor_type": null,
"actor_account_id": null
}
}| Field | Type | Description |
|---|---|---|
transaction_id | UUID | The UrbanPayX transaction ID |
ref_id | string | Short reference ID for the transaction |
transfer_id | string or null | Payment provider transfer ID (useful for debugging) |
user_id | UUID | The user who initiated the payment |
project_id | UUID | The project the payment belongs to |
previous_status | enum | Status before this change |
current_status | enum | Status after this change |
source | string | What triggered the change: provider_webhook (automatic), client_manual_refresh (your polling), system |
provider_status | string or null | Raw status from the payment provider (for debugging) |
actor_type | string or null | If manually refreshed: client_user or client_api |
actor_account_id | string or null | If manually refreshed: the account that triggered it |
Status values: pending, processing, success, failed, expired, canceled
Deduplication key: payment:{transaction_id}:{current_status} — you will receive at most one event per transaction per status.
kyc.status_changed
kyc.status_changedFired when a user's verification status changes. You receive this after UrbanPayX KYC completes (automated) or when an external attestation status is updated (your API call).
{
"id": "evt-b2c3d4e5-f6a7-8901-bcde-f12345678901",
"type": "kyc.status_changed",
"version": "v1",
"occurred_at": "2026-03-26T10:20:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"user_id": "usr-11111111-2222-3333-4444-555555555555",
"email": "[email protected]",
"verification_mode": "urbanpayx_kyc",
"previous_kyc_status": "pending",
"current_kyc_status": "approved",
"previous_external_status": "pending",
"current_external_status": "pending",
"previous_verification_source": "urbanpayx",
"current_verification_source": "urbanpayx",
"source": "provider_webhook"
}
}| Field | Type | Description |
|---|---|---|
user_id | UUID | The user whose verification changed |
email | string | User email address |
verification_mode | enum | urbanpayx_kyc or external_attestation |
previous_kyc_status | enum | Previous UrbanPayX KYC status: none, pending, approved, rejected |
current_kyc_status | enum | Current UrbanPayX KYC status |
previous_external_status | enum | Previous external attestation status: pending, verified, rejected |
current_external_status | enum | Current external attestation status |
previous_verification_source | enum | none, urbanpayx, external |
current_verification_source | enum | none, urbanpayx, external |
source | string | What triggered the change: provider_webhook, client_api, system |
opco.status_changed
opco.status_changedFired when an Operating Company changes status — typically after submission for regulatory approval.
{
"id": "evt-c3d4e5f6-a7b8-9012-cdef-123456789012",
"type": "opco.status_changed",
"version": "v1",
"occurred_at": "2026-03-26T10:25:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"opco_id": "opc-11111111-2222-3333-4444-555555555555",
"provider_entity_id": "provider-entity-identifier",
"name": "Acme Properties Ltd",
"previous_status": "awaiting_approval",
"current_status": "activated",
"status_reason": null,
"source": "opco_status_poll"
}
}| Field | Type | Description |
|---|---|---|
opco_id | UUID | The OpCo that changed status |
provider_entity_id | string | The OpCo identifier assigned by the upstream provider |
name | string | OpCo display name |
previous_status | enum | draft, awaiting_approval, activated, rejected, deactivated |
current_status | enum | Same enum as above |
status_reason | string or null | Explanation (especially useful for rejections) |
source | string | opco_status_poll (automatic polling), admin (manual change) |
project.frozen_changed
project.frozen_changedFired when a project is frozen or unfrozen. A frozen project cannot accept new payments.
{
"id": "evt-d4e5f6a7-b8c9-0123-defa-234567890123",
"type": "project.frozen_changed",
"version": "v1",
"occurred_at": "2026-03-26T10:30:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"project_id": "prj-11111111-2222-3333-4444-555555555555",
"project_name": "Sunrise Apartments",
"previous_is_frozen": false,
"current_is_frozen": true,
"source": "admin",
"actor_type": "superadmin",
"actor_id": "admin-uuid"
}
}| Field | Type | Description |
|---|---|---|
project_id | UUID | The project that was frozen/unfrozen |
project_name | string | Project display name |
previous_is_frozen | boolean | Previous frozen state |
current_is_frozen | boolean | New frozen state |
source | string | What triggered the change |
actor_type | string or null | Who made the change |
actor_id | UUID or null | Identifier of the actor |
webhook.test
webhook.testFired when you call POST /webhooks/subscription/test. Use this to verify your endpoint is reachable and processing events correctly.
{
"id": "evt-e5f6a7b8-c9d0-1234-efab-345678901234",
"type": "webhook.test",
"version": "v1",
"occurred_at": "2026-03-26T10:35:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"message": "UrbanpayX webhook test event",
"requested_by_account_id": "acc-11111111-2222-3333-4444-555555555555"
}
}| Field | Type | Description |
|---|---|---|
message | string | Always "UrbanpayX webhook test event" |
requested_by_account_id | UUID | The team member who triggered the test |
Delivery and retries
Success criteria
Your endpoint must return an HTTP 2xx status code within 5 seconds. Any other response (or a timeout) is treated as a delivery failure.
Retry schedule
Failed deliveries are retried with exponential backoff:
| Attempt | Approximate delay | Cumulative time |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | ~10 seconds | ~10 seconds |
| 3 | ~20 seconds | ~30 seconds |
| 4 | ~40 seconds | ~1 minute |
| 5 | ~80 seconds | ~2.5 minutes |
| 6 | ~160 seconds | ~5 minutes |
| 7 | ~320 seconds | ~10 minutes |
| 8 | ~640 seconds | ~21 minutes |
After 8 failed attempts, the event is marked as dead_letter and no further retries are attempted. Dead-lettered events remain visible in your delivery history (GET /webhooks/deliveries).
If your endpoint returns 429 Too Many Requests or 503 Service Unavailable with a Retry-After header, UrbanPayX respects that value instead of the default backoff.
Deduplication
Each event is deduplicated at the source — UrbanPayX will not create duplicate events for the same state change. However, retries mean the same event may be delivered more than once. Use the X-UPX-Event-Id header (or id field in the body) as an idempotency key in your handler.
Building a webhook handler
Here is a complete webhook handler that verifies signatures, deduplicates events, and routes to type-specific handlers:
Python (Flask)
import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = "upxwhsec_your_secret_here"
processed_events = set() # Use a database in production
@app.route("/webhooks/urbanpayx", methods=["POST"])
def handle_webhook():
# 1. Get headers
signature = request.headers.get("X-UPX-Signature", "")
timestamp = request.headers.get("X-UPX-Timestamp", "")
event_id = request.headers.get("X-UPX-Event-Id", "")
# 2. Verify signature
payload_body = request.get_data()
signing_payload = f"{timestamp}.".encode("utf-8") + payload_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
# 3. Check timestamp freshness (5 minutes)
if abs(time.time() - int(timestamp)) > 300:
return jsonify({"error": "Event too old"}), 401
# 4. Deduplicate
if event_id in processed_events:
return jsonify({"status": "already_processed"}), 200
# 5. Parse and route
event = json.loads(payload_body)
event_type = event["type"]
data = event["data"]
if event_type == "payment.status_changed":
handle_payment_status(data)
elif event_type == "kyc.status_changed":
handle_kyc_status(data)
elif event_type == "opco.status_changed":
handle_opco_status(data)
elif event_type == "project.frozen_changed":
handle_project_frozen(data)
elif event_type == "webhook.test":
pass # Acknowledge test events
processed_events.add(event_id)
return jsonify({"status": "ok"}), 200
def handle_payment_status(data):
status = data["current_status"]
if status == "success":
# Mark order as paid, trigger fulfillment
pass
elif status == "failed":
# Notify customer, offer retry
pass
elif status == "expired":
# Offer new payment link
pass
elif status == "canceled":
# Update order status
pass
def handle_kyc_status(data):
if data["current_kyc_status"] == "approved":
# User is verified - they can now make payments
pass
elif data["current_kyc_status"] == "rejected":
# Notify user, explain next steps
pass
def handle_opco_status(data):
if data["current_status"] == "activated":
# OpCo is ready - you can now create projects and payments
pass
elif data["current_status"] == "rejected":
# Check status_reason, create a new OpCo with corrections
pass
def handle_project_frozen(data):
if data["current_is_frozen"]:
# Stop creating payments for this project
passNode.js (Express)
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = "upxwhsec_your_secret_here";
const processedEvents = new Set(); // Use a database in production
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 eventId = req.headers["x-upx-event-id"] || "";
const payloadBody = req.body.toString();
// 1. Verify signature
const signingPayload = `${timestamp}.${payloadBody}`;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signingPayload)
.digest("hex");
const received = signature.replace("sha256=", "");
if (!crypto.timingSafeEqual(
Buffer.from(expected, "hex"), Buffer.from(received, "hex")
)) {
return res.status(401).json({ error: "Invalid signature" });
}
// 2. Check timestamp freshness
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(401).json({ error: "Event too old" });
}
// 3. Deduplicate
if (processedEvents.has(eventId)) {
return res.status(200).json({ status: "already_processed" });
}
// 4. Parse and route
const event = JSON.parse(payloadBody);
const { type, data } = event;
switch (type) {
case "payment.status_changed":
handlePaymentStatus(data);
break;
case "kyc.status_changed":
handleKycStatus(data);
break;
case "opco.status_changed":
handleOpcoStatus(data);
break;
case "project.frozen_changed":
handleProjectFrozen(data);
break;
case "webhook.test":
break;
}
processedEvents.add(eventId);
res.status(200).json({ status: "ok" });
}
);
function handlePaymentStatus(data) {
switch (data.current_status) {
case "success": /* Mark order paid */ break;
case "failed": /* Notify customer */ break;
case "expired": /* Offer new link */ break;
case "canceled": /* Update status */ break;
}
}
function handleKycStatus(data) {
if (data.current_kyc_status === "approved") {
// User verified
} else if (data.current_kyc_status === "rejected") {
// Notify user
}
}
function handleOpcoStatus(data) {
if (data.current_status === "activated") {
// OpCo ready
}
}
function handleProjectFrozen(data) {
if (data.current_is_frozen) {
// Pause payment creation
}
}
app.listen(3000, () => console.log("Webhook handler running on port 3000"));Subscribable event types
When configuring your webhook subscription, you can subscribe to any combination of these four event types:
| Event type | Description |
|---|---|
payment.status_changed | Transaction status transitions |
kyc.status_changed | User verification status changes |
opco.status_changed | Operating company approval status changes |
project.frozen_changed | Project freeze/unfreeze events |
The webhook.test event is a system event — it is always delivered when you call the test endpoint, regardless of your subscription settings.
Related guides
- Transaction Lifecycle — Status state machine and handling patterns
- Sandbox and Testing — Testing webhooks with the test endpoint
- Authentication Guide — Securing your webhook secret
- Error Reference — Webhook configuration errors