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 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_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 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) 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": "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
  }
}
FieldTypeDescription
transaction_idUUIDThe UrbanPay transaction ID. The stable key for the payment; key your records on it
ref_idstringReference 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_idstring or nullPayment 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
amountstringTransaction amount as a decimal string, for example "125.00", never a float. Compare it and currency against your own order before fulfilling on success
currencystringISO 4217 currency code, for example EUR
user_idUUID or nullThe end user who initiated the payment. Exactly one of user_id and business_customer_id is set
business_customer_idUUID or nullThe business customer, when the payer is a business rather than an individual
project_idUUIDThe project the payment belongs to
contract_request_idUUID or nullThe contract request this payment was initiated for, when the payment is contract-backed
previous_payment_statusenumStatus before this change
current_payment_statusenumStatus after this change
reconciliation_stateenumnone, awaiting_provider_confirmation or manual_review_required. See "Reconciliation state" below
status_detailstring or nullFine-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_reasonstring or nullHuman-readable explanation of the change when one is available, most useful on failed and expired
actor_typestring or nullclient_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_idstring or nullThe 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

Fired 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"
  }
}
FieldTypeDescription
user_idUUIDThe user whose verification changed
emailstringUser email address
verification_modeenumurbanpayx_kyc or external_attestation
previous_kyc_statusenumPrevious UrbanPay KYC status: none, pending, approved, rejected
current_kyc_statusenumCurrent UrbanPay 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",
    "sub_tpp_id": "sub-tpp-identifier",
    "name": "Acme Properties Ltd",
    "previous_opco_status": "awaiting_approval",
    "current_opco_status": "activated",
    "opco_status_reason": null
  }
}
FieldTypeDescription
opco_idUUIDThe OpCo that changed status
sub_tpp_idstring or nullThe OpCo identifier in the payment network. null until the OpCo is activated
namestringOpCo display name
previous_opco_statusenumdraft, pending_superadmin_review, awaiting_approval, activated, rejected, deactivated
current_opco_statusenumSame enum as above
opco_status_reasonstring or nullExplanation (especially useful for rejections)

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

kyb.status_changed

Fired 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"
  }
}
FieldTypeDescription
business_customer_idUUIDThe business customer whose verification changed
company_namestringRegistered company name
registration_numberstring or nullCompany registration number
countrystringCompany country
verification_modeenumurbanpayx_kyc, urbanpayx_kyb, or external_attestation
previous_kyb_statusenumPrevious KYB status: none, pending, approved, rejected, on_hold
current_kyb_statusenumCurrent KYB status
previous_external_statusenumPrevious external attestation status: pending, verified, rejected
current_external_statusenumCurrent external attestation status
previous_verification_sourceenumnone, urbanpayx, or external
current_verification_sourceenumSame enum as above

monitoring.transaction.status_changed

Fired 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
  }
}
FieldTypeDescription
monitored_transaction_idUUIDThe monitored transaction record
client_referencestringYour reference for the monitored transaction
previous_decisionstringDecision before this change
decisionstringCurrent decision: APPROVED, IN_REVIEW, DECLINED, or AWAITING_USER
decision_reasonstring or nullExplanation for the decision, when available
sanctions_hitbooleanWhether the transaction matched a sanctions list
remediation_urlstring or nullLink for the payer to resolve a flagged transaction, when applicable
remediation_statusstring or nullStatus of any remediation flow, when applicable

workflow.run.started / workflow.run.completed / workflow.run.failed

Fired 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"
  }
}
FieldTypeDescription
run_idUUIDThe workflow run
workflow_definition_idUUIDThe workflow definition being run
error_messagestring or nullworkflow.run.failed only. Failure reason, with vendor names masked

workflow.step.completed

Fired 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": {}
  }
}
FieldTypeDescription
run_idUUIDThe workflow run this step belongs to
step_idstringIdentifier of the step within the workflow definition
node_typeenumStep type: kyc, kyb, contract, transfer, condition, opco, project, http, opco_create, set, notify, email, wait, create_user, or create_business_customer
outputobjectStep 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

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": "UrbanPay webhook test event",
    "requested_by_account_id": "acc-11111111-2222-3333-4444-555555555555"
  }
}
FieldTypeDescription
messagestringAlways "UrbanPay 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, 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
        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_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 typeDescription
payment.status_changedTransaction status transitions
kyc.status_changedEnd-user verification status changes
kyb.status_changedBusiness customer verification status changes
opco.status_changedOperating company approval status changes
project.frozen_changedProject freeze/unfreeze events
contract.request.status_changedSigning request status transitions
contract.participant.status_changedAn individual signer completed or declined
contract.request.completedAll signers finished a signing request
monitoring.transaction.status_changedTransaction monitoring decision changed
workflow.run.startedA workflow run started
workflow.run.completedA workflow run finished successfully
workflow.run.failedA workflow run failed
workflow.step.completedA 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