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 change actually happened, not when it was delivered. For payment events this is the status transition time as reported by the payment network when available. Retries can deliver events out of order; order by this field rather than by arrival |
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 UrbanPay 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 UrbanPay 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": "payment-network-transfer-id-789",
"amount": "125.00",
"currency": "EUR",
"user_id": "usr-11111111-2222-3333-4444-555555555555",
"business_customer_id": null,
"project_id": "prj-11111111-2222-3333-4444-555555555555",
"contract_request_id": null,
"previous_payment_status": "pending",
"current_payment_status": "processing",
"reconciliation_state": "none",
"status_detail": "awaiting_bank_authorization",
"status_reason": null,
"actor_type": null,
"actor_account_id": null
}
}| Field | Type | Description |
|---|---|---|
transaction_id | UUID | The UrbanPay transaction ID. The stable key for the payment; key your records on it |
ref_id | string | Reference carried by the current payment session on the payment network. Session-scoped: it changes when the payer restarts an expired session, so do not key on it |
transfer_id | string or null | Payment network identifier of the current payment session; null until the payer has picked a bank on the bank-selection page. Session-scoped like ref_id. Quote it to UrbanPay support to locate a payment quickly |
amount | string | Transaction amount as a decimal string, for example "125.00", never a float. Compare it and currency against your own order before fulfilling on success |
currency | string | ISO 4217 currency code, for example EUR |
user_id | UUID or null | The end user who initiated the payment. Exactly one of user_id and business_customer_id is set |
business_customer_id | UUID or null | The business customer, when the payer is a business rather than an individual |
project_id | UUID | The project the payment belongs to |
contract_request_id | UUID or null | The contract request this payment was initiated for, when the payment is contract-backed |
previous_payment_status | enum | Status before this change |
current_payment_status | enum | Status after this change |
reconciliation_state | enum | none, awaiting_provider_confirmation or manual_review_required. See "Reconciliation state" below |
status_detail | string or null | Fine-grained status in UrbanPay's own vocabulary, the same values as provider_status on GET /api/v1/transactions/{transaction_id}/status-events, for example awaiting_bank_authorization, processing, completed, rejected, rejected_insufficient_funds, settlement_incomplete, expired, canceled. New values may be added over time; branch on current_payment_status and treat this field as informational |
status_reason | string or null | Human-readable explanation of the change when one is available, most useful on failed and expired |
actor_type | string or null | client_user or client_api when someone on your team triggered the change; urbanpayx_internal when UrbanPay staff did; null when it happened automatically |
actor_account_id | string or null | The account that triggered it. Always null when actor_type is urbanpayx_internal |
Status values: pending, processing, success, failed, expired, canceled
Reconciliation state: manual_review_required means the outcome of this payment could not be confirmed with the bank and is being reviewed by UrbanPay operations. That change is delivered as a payment.status_changed event whose previous_payment_status equals current_payment_status (the status is still pending); reconciliation_state carries the signal. Do not treat the payment as failed or as paid until a later event or GET /api/v1/transactions/{transaction_id} says so. awaiting_provider_confirmation is the short window after a payment session was requested and before the bank has acknowledged it; none is the normal case.
Ordering: the envelope occurred_at is the time the status actually changed, as reported by the payment network when available, not the time UrbanPay noticed or delivered it. Retries can deliver events out of order, so order by occurred_at and the status pair rather than by arrival.
Deduplication key: payment:{transaction_id}:{current_payment_status}:{status_event_id}. The trailing status-event id makes every real transition unique, so a status that returns to a previous value (for example failed to pending to failed after a payment link is reminted) is delivered again rather than suppressed as a duplicate. Deduplicate on the id field / X-UPX-Event-Id header, not on the transaction/status pair.
kyc.status_changed
kyc.status_changedFired when a user's verification status changes. You receive this after UrbanPay 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 UrbanPay KYC status: none, pending, approved, rejected |
current_kyc_status | enum | Current UrbanPay 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",
"sub_tpp_id": "sub-tpp-identifier",
"name": "Acme Properties Ltd",
"previous_opco_status": "awaiting_approval",
"current_opco_status": "activated",
"opco_status_reason": null
}
}| Field | Type | Description |
|---|---|---|
opco_id | UUID | The OpCo that changed status |
sub_tpp_id | string or null | The OpCo identifier in the payment network. null until the OpCo is activated |
name | string | OpCo display name |
previous_opco_status | enum | draft, pending_superadmin_review, awaiting_approval, activated, rejected, deactivated |
current_opco_status | enum | Same enum as above |
opco_status_reason | string or null | Explanation (especially useful for rejections) |
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 |
kyb.status_changed
kyb.status_changedFired when a business customer's KYB verification status changes. Mirrors kyc.status_changed but for business/KYB verification.
{
"id": "evt-f6a7b8c9-d0e1-2345-fabc-456789012345",
"type": "kyb.status_changed",
"version": "v1",
"occurred_at": "2026-03-26T10:40:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"business_customer_id": "bcu-11111111-2222-3333-4444-555555555555",
"company_name": "Acme Holdings Ltd",
"registration_number": "12345678",
"country": "GB",
"verification_mode": "urbanpayx_kyb",
"previous_kyb_status": "pending",
"current_kyb_status": "approved",
"previous_external_status": "pending",
"current_external_status": "pending",
"previous_verification_source": "urbanpayx",
"current_verification_source": "urbanpayx"
}
}| Field | Type | Description |
|---|---|---|
business_customer_id | UUID | The business customer whose verification changed |
company_name | string | Registered company name |
registration_number | string or null | Company registration number |
country | string | Company country |
verification_mode | enum | urbanpayx_kyc, urbanpayx_kyb, or external_attestation |
previous_kyb_status | enum | Previous KYB status: none, pending, approved, rejected, on_hold |
current_kyb_status | enum | Current KYB 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, or external |
current_verification_source | enum | Same enum as above |
monitoring.transaction.status_changed
monitoring.transaction.status_changedFired when a transaction submitted for ongoing monitoring gets a new decision.
{
"id": "evt-a7b8c9d0-e1f2-3456-abcd-567890123456",
"type": "monitoring.transaction.status_changed",
"version": "v1",
"occurred_at": "2026-03-26T10:45:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"monitored_transaction_id": "mtx-11111111-2222-3333-4444-555555555555",
"client_reference": "order-4821",
"previous_decision": "IN_REVIEW",
"decision": "APPROVED",
"decision_reason": null,
"sanctions_hit": false,
"remediation_url": null,
"remediation_status": null
}
}| Field | Type | Description |
|---|---|---|
monitored_transaction_id | UUID | The monitored transaction record |
client_reference | string | Your reference for the monitored transaction |
previous_decision | string | Decision before this change |
decision | string | Current decision: APPROVED, IN_REVIEW, DECLINED, or AWAITING_USER |
decision_reason | string or null | Explanation for the decision, when available |
sanctions_hit | boolean | Whether the transaction matched a sanctions list |
remediation_url | string or null | Link for the payer to resolve a flagged transaction, when applicable |
remediation_status | string or null | Status of any remediation flow, when applicable |
workflow.run.started / workflow.run.completed / workflow.run.failed
workflow.run.started / workflow.run.completed / workflow.run.failedFired at the start and end of a workflow engine run. All three share the same base payload; workflow.run.failed adds an error_message field.
{
"id": "evt-b8c9d0e1-f2a3-4567-bcde-678901234567",
"type": "workflow.run.completed",
"version": "v1",
"occurred_at": "2026-03-26T10:50:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"run_id": "wfr-11111111-2222-3333-4444-555555555555",
"workflow_definition_id": "wf-11111111-2222-3333-4444-555555555555"
}
}| Field | Type | Description |
|---|---|---|
run_id | UUID | The workflow run |
workflow_definition_id | UUID | The workflow definition being run |
error_message | string or null | workflow.run.failed only. Failure reason, with vendor names masked |
workflow.step.completed
workflow.step.completedFired after each successfully completed step within a workflow run.
{
"id": "evt-c9d0e1f2-a3b4-5678-cdef-789012345678",
"type": "workflow.step.completed",
"version": "v1",
"occurred_at": "2026-03-26T10:52:00+00:00",
"client_id": "cl-11111111-2222-3333-4444-555555555555",
"data": {
"run_id": "wfr-11111111-2222-3333-4444-555555555555",
"step_id": "create-project",
"node_type": "project",
"output": {}
}
}| Field | Type | Description |
|---|---|---|
run_id | UUID | The workflow run this step belongs to |
step_id | string | Identifier of the step within the workflow definition |
node_type | enum | Step type: kyc, kyb, contract, transfer, condition, opco, project, http, opco_create, set, notify, email, wait, create_user, or create_business_customer |
output | object | Step output, shape depends on node_type. Vendor-identifying content is redacted |
Only fired for steps that complete successfully; a failed step does not emit this event (the run-level workflow.run.failed event covers failures).
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": "UrbanPay webhook test event",
"requested_by_account_id": "acc-11111111-2222-3333-4444-555555555555"
}
}| Field | Type | Description |
|---|---|---|
message | string | Always "UrbanPay 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, UrbanPay respects that value instead of the default backoff.
Deduplication
Each event is deduplicated at the source — UrbanPay 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_payment_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":
# Update your own records. The API itself never blocks payments on
# verification status -- this check is purely your own policy, if any.
pass
elif data["current_kyc_status"] == "rejected":
# Notify user, explain next steps
pass
def handle_opco_status(data):
if data["current_opco_status"] == "activated":
# OpCo is ready - you can now create projects and payments
pass
elif data["current_opco_status"] == "rejected":
# Check opco_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_payment_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_opco_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 event types. Call GET /api/v1/webhooks/events for the authoritative list available to your account.
| Event type | Description |
|---|---|
payment.status_changed | Transaction status transitions |
kyc.status_changed | End-user verification status changes |
kyb.status_changed | Business customer verification status changes |
opco.status_changed | Operating company approval status changes |
project.frozen_changed | Project freeze/unfreeze events |
contract.request.status_changed | Signing request status transitions |
contract.participant.status_changed | An individual signer completed or declined |
contract.request.completed | All signers finished a signing request |
monitoring.transaction.status_changed | Transaction monitoring decision changed |
workflow.run.started | A workflow run started |
workflow.run.completed | A workflow run finished successfully |
workflow.run.failed | A workflow run failed |
workflow.step.completed | A single workflow step finished |
The payload shapes for the events not detailed above follow the same envelope; the kyb.status_changed data block mirrors kyc.status_changed with business_customer_id, company_name, registration_number, country, and previous_kyb_status / current_kyb_status.
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