Client Webhooks

UrbanPay supports outbound webhooks so each client can receive asynchronous business events. All webhook management endpoints require a client dashboard access token.


Supported Event Types

EventDescription
payment.status_changedPayment status changed
kyc.status_changedKYC status changed
kyb.status_changedKYB status changed
opco.status_changedOpco status changed
project.frozen_changedProject frozen state changed
contract.participant.status_changedContract participant status changed
contract.request.status_changedContract request status changed
contract.request.completedContract request completed
monitoring.transaction.status_changedTransaction monitoring decision changed
workflow.run.startedWorkflow run started
workflow.run.completedWorkflow run completed
workflow.run.failedWorkflow run failed
workflow.step.completedWorkflow step completed
webhook.test*Webhook test

* webhook.test is a system event, not part of the subscribable catalog above — it is always sent when you call the test endpoint, regardless of your webhook_events subscription. See Webhook Event Catalog for full payload schemas of every event type, including kyb.status_changed, monitoring.transaction.status_changed, and the workflow.* events.


Authentication

Security scheme: HTTP Bearer

Authorization: Bearer <access_token>

Role Permissions

OperationRequired Role
Read endpointsAuthenticated client users
Update / Rotate / Test endpointsOwner, Developer

Outbound Webhook Request Format

We send an HTTP POST to your configured webhook_url.

Headers

Content-Type: application/json
X-UPX-Event-Id: <uuid>
X-UPX-Event-Type: <event_type>
X-UPX-Event-Version: v1
X-UPX-Timestamp: <unix_seconds>
X-UPX-Signature: sha256=<hex_hmac>

Body Envelope

{
  "id": "event_uuid",
  "type": "payment.status_changed",
  "version": "v1",
  "occurred_at": "2026-02-26T12:45:00.123456+00:00",
  "client_id": "client_uuid",
  "data": {}
}

Event Data Schemas

Each event type populates the data field with a specific schema.

payment.status_changed

{
  "transaction_id": "string",
  "ref_id": "string",
  "transfer_id": "string|null",
  "amount": "string",
  "currency": "string",
  "user_id": "string|null",
  "business_customer_id": "string|null",
  "project_id": "string",
  "contract_request_id": "string|null",
  "previous_payment_status": "pending|processing|success|failed|canceled|expired",
  "current_payment_status": "pending|processing|success|failed|canceled|expired",
  "reconciliation_state": "none|awaiting_provider_confirmation|manual_review_required",
  "status_detail": "string|null",
  "status_reason": "string|null",
  "actor_type": "string|null",
  "actor_account_id": "string|null"
}

amount is a decimal string, never a float. Exactly one of user_id and business_customer_id is set. Field meanings, the manual_review_required semantics and ordering guidance are in the Webhook Event Catalog.

kyc.status_changed

{
  "user_id": "string",
  "email": "string",
  "verification_mode": "urbanpayx_kyc|external_attestation",
  "previous_kyc_status": "string",
  "current_kyc_status": "string",
  "previous_external_status": "string",
  "current_external_status": "string",
  "previous_verification_source": "string",
  "current_verification_source": "string"
}

opco.status_changed

{
  "opco_id": "string",
  "sub_tpp_id": "string|null",
  "name": "string",
  "previous_opco_status": "string",
  "current_opco_status": "string",
  "opco_status_reason": "string|null"
}

project.frozen_changed

{
  "project_id": "string",
  "project_name": "string",
  "previous_is_frozen": false,
  "current_is_frozen": true,
  "actor_type": "string|null",
  "actor_id": "string|null"
}

contract.participant.status_changed

{
  "contract_request_id": "string",
  "participant_id": "string",
  "participant_key": "string",
  "signing_submitter_id": "string|null",
  "participant_status": "pending|completed|declined"
}

contract.request.status_changed

{
  "contract_request_id": "string",
  "request_status": "pending|completed|declined|canceled|expired",
  "current_stage_index": "integer|null",
  "current_participant_key": "string|null"
}

contract.request.completed

{
  "contract_request_id": "string",
  "completed_at": "2026-02-26T12:45:00.123456+00:00|null"
}

webhook.test

{
  "message": "UrbanPay webhook test event",
  "requested_by_account_id": "string|null"
}

Signature Verification

All webhook requests are signed so you can verify they originate from UrbanPay.

ParameterValue
AlgorithmHMAC SHA-256
Signing payload<X-UPX-Timestamp>.<raw_request_body>
Header formatX-UPX-Signature: sha256=<hex_digest>

Steps

  1. Read the raw request body bytes exactly as received.
  2. Read X-UPX-Timestamp and reject if too old (recommended: 5 minutes).
  3. Build the signing payload: <timestamp>.<raw_body>.
  4. Compute HMAC-SHA256 using your webhook secret.
  5. Compare against X-UPX-Signature using a constant-time comparison.
  6. Deduplicate events using X-UPX-Event-Id.

Python Example

import hmac
import hashlib

def verify(sig_header: str, ts: str, raw_body: bytes, secret: str) -> bool:
    payload = ts.encode() + b"." + raw_body
    digest = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    expected = f"sha256={digest}"
    return hmac.compare_digest(sig_header or "", expected)

Recommended security practices:

  • Reject requests with a missing or invalid signature.
  • Reject stale timestamps (e.g., older than 5 minutes).
  • Process events idempotently using the X-UPX-Event-Id header.

Delivery & Retry Semantics

  • Success — any 2xx response from your server.
  • Retry — non-2xx responses or network failures trigger automatic retries with exponential backoff and jitter.
  • Rate limiting — for 429 or 503 responses, the Retry-After header (in seconds) is respected if present.
  • Dead letter — after max retry attempts, the event moves to a dead-letter state. Events that fail due to misconfiguration (disabled webhook, missing secret, invalid URL, or unsubscribed event type) are dead-lettered immediately without retry.

URL Validation Rules

When saving a webhook_url, we enforce the following rules:

  • Scheme must be https.
  • localhost and internal hostnames are not allowed.
  • Host resolution cannot point to private, loopback, link-local, or reserved IP ranges.

Error Responses

Status CodeDescription
400Invalid request payload, webhook URL, or event type
401Unauthorized — missing or invalid access token
403Authenticated but insufficient role for update, rotate, or test operations
409Duplicate webhook event key (internal deduplication edge case)

Related guides