Webhook Event Catalog

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
  }
}
FieldTypeDescription
idUUIDUnique event identifier. Use this for idempotent processing.
typestringEvent type (see catalog below)
versionstringAlways v1
occurred_atISO 8601When the event occurred
client_idUUIDYour client identifier
dataobjectEvent-specific payload

HTTP headers

Every webhook delivery includes these headers:

HeaderExampleDescription
Content-Typeapplication/jsonAlways JSON
X-UPX-Event-Id550e8400-...Same as the id in the body. Use for deduplication.
X-UPX-Event-Typepayment.status_changedEvent type
X-UPX-Event-Versionv1Webhook format version
X-UPX-Timestamp1711350420Unix timestamp (seconds) when the event was signed
X-UPX-Signaturesha256=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 comparisonhmac.compare_digest (Python) or crypto.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

Fired 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
  }
}
FieldTypeDescription
transaction_idUUIDThe UrbanPayX transaction ID
ref_idstringShort reference ID for the transaction
transfer_idstring or nullPayment provider transfer ID (useful for debugging)
user_idUUIDThe user who initiated the payment
project_idUUIDThe project the payment belongs to
previous_statusenumStatus before this change
current_statusenumStatus after this change
sourcestringWhat triggered the change: provider_webhook (automatic), client_manual_refresh (your polling), system
provider_statusstring or nullRaw status from the payment provider (for debugging)
actor_typestring or nullIf manually refreshed: client_user or client_api
actor_account_idstring or nullIf 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

Fired 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"
  }
}
FieldTypeDescription
user_idUUIDThe user whose verification changed
emailstringUser email address
verification_modeenumurbanpayx_kyc or external_attestation
previous_kyc_statusenumPrevious UrbanPayX KYC status: none, pending, approved, rejected
current_kyc_statusenumCurrent UrbanPayX KYC status
previous_external_statusenumPrevious external attestation status: pending, verified, rejected
current_external_statusenumCurrent external attestation status
previous_verification_sourceenumnone, urbanpayx, external
current_verification_sourceenumnone, urbanpayx, external
sourcestringWhat triggered the change: provider_webhook, client_api, system

opco.status_changed

Fired 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"
  }
}
FieldTypeDescription
opco_idUUIDThe OpCo that changed status
provider_entity_idstringThe OpCo identifier assigned by the upstream provider
namestringOpCo display name
previous_statusenumdraft, awaiting_approval, activated, rejected, deactivated
current_statusenumSame enum as above
status_reasonstring or nullExplanation (especially useful for rejections)
sourcestringopco_status_poll (automatic polling), admin (manual change)

project.frozen_changed

Fired 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"
  }
}
FieldTypeDescription
project_idUUIDThe project that was frozen/unfrozen
project_namestringProject display name
previous_is_frozenbooleanPrevious frozen state
current_is_frozenbooleanNew frozen state
sourcestringWhat triggered the change
actor_typestring or nullWho made the change
actor_idUUID or nullIdentifier of the actor

webhook.test

Fired 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"
  }
}
FieldTypeDescription
messagestringAlways "UrbanpayX webhook test event"
requested_by_account_idUUIDThe 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:

AttemptApproximate delayCumulative time
1Immediate0
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
        pass

Node.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 typeDescription
payment.status_changedTransaction status transitions
kyc.status_changedUser verification status changes
opco.status_changedOperating company approval status changes
project.frozen_changedProject 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