Getting set up

Payment logs — inbound, outbound & cross-app router traffic

The Payment logs screen is a SuperAdmin-only audit of every payment-related HTTP message that crosses BusinessSensor — both inbound (provider webhooks pushed to us via the shared BusinessSensor WebhookRouter), outbound (calls we make to a gateway when starting a payment or verifying one), and router traffic (the cross-app view of EVERY webhook the router forwarded, including those bound for Marketplace, QuestionForge, and HotelBooking).

Open it from the topbar — green shield icon, next to the maintenance wrench.

Tenant admins have a related but separate view at /payment-logs (no /admin/ prefix) that shows ONLY their own tenant's Inbound + Outbound activity — see the payment-logs-tenant manual for that.

What it shows

Inbound webhooks

Every webhook the router forwards to /api/webhooks/{paystack|stripe|flutterwave}. For each you see:

  • Received / Provider / Reference — when the webhook landed, which gateway, and the acct-… reference it carried (links the row to a PosPaymentIntent).
  • Tenant — first 8 characters of the tenant ID resolved from metadata.tenant_subdomain. Empty if the metadata was missing or the subdomain wasn't in our registry.
  • Amount — what the provider says was paid. The validator already cross-checked this against the expected_amount_minor we stamped at initialize-time; a mismatch is the most common Failed reason.
  • Status — the row's outcome:
    • Received — landed, processing not started (transient state, you'll rarely see it stuck here).
    • Processed — applied to a PosPaymentIntent successfully.
    • Skipped — duplicate idempotency key; the original is already processed and we ignored the redelivery.
    • Failed — verification or validation rejected the row; see the Error column for the reason.
    • Replayed — a SuperAdmin re-ran the payload manually.
  • Sig — green shield = the provider's signature verified against the raw body. Red = it didn't, and the row was rejected.
  • Replays — how many times a SuperAdmin has hit "Replay payload".

Click any row to see the full raw payload (JSON), the signature header that came with it, and any error message. For Failed or Replayed rows there's a Replay payload button — it re-runs the original bytes through the processor exactly as the router first delivered them.

Outbound API calls

Every call Account.Api makes to a gateway endpoint (initialize, charge, verify, future subaccount operations). For each:

  • Operation — the logical thing we did (InitializeTransaction, ChargeBankTransfer, Verify).
  • Reference / Tenant — the matching acct- reference + the tenant the call was made on behalf of. Use these to correlate an outbound init with the inbound webhook it eventually triggers (sort the Inbound list by the same Reference column).
  • HTTP status / Latency / OK — what the provider returned. OK means business success (provider's status: true); a 200 HTTP with status: false body still shows OK = no.
  • Error — exception message if the call threw before completing.

Click any row for the full request body (sensitive fields like secret_key, cvv, password are automatically redacted before storage) and the truncated response body (capped at 64KB).

Router traffic (CROSS-APP)

Every webhook the shared BusinessSensor.WebhookRouter Azure Function forwarded, across all four consumer apps: acct (Account.Api / POS), hb (HotelBooking), mp (Marketplace), qf (QuestionForge). Plus "unknown" rows when the router got a payload it couldn't route.

For each row:

  • Received — when the router got the request from the provider.
  • Provider / App — provider + which downstream consumer app it was forwarded to (coloured chip).
  • Reference — the reference parsed from the body (acct-…, hb-…, etc.).
  • Downstream — host the router POSTed to (full URL on hover).
  • Status — HTTP status the downstream consumer returned. 200 means accepted; 5xx / 0 means they failed or didn't respond.
  • Latency — how long the downstream took to ack.
  • Outcome — what the router did:
    • Forwarded — successful POST to a downstream consumer.
    • Skipped-NoPrefix / -UnknownApp / -NoDownstreamUrl — config-gap shapes: the router got the webhook but couldn't route it. See the Router config in the Function App App Settings.
    • Skipped-EmptyBody — provider sent an empty POST (rare, sometimes a healthcheck).
    • Rejected-IpNotAllowed — request didn't come from a configured provider IP. Worth investigating if you see these — possibly someone probing.
    • Failed-ForwardException — the downstream consumer was unreachable (DNS, timeout, connection refused).

Unlike Inbound/Outbound, this tab paginates via Azure Tables continuation tokens; there's no row count up front. Filter by App or Outcome to narrow results.

When the Router traffic tab is empty / "not configured"

The router-traffic ledger lives in an Azure Table that the WebhookRouter writes to. If you see the amber "Router traffic ledger not configured" notice instead of a grid, the WebhookRouterTraffic:ConnectionString appsetting isn't set on Account.Api. The Inbound and Outbound tabs still work — only this cross-app view requires the shared storage connection.

How to use it

Cashier says "I took the payment but the till shows Pending"

  1. Filter Inbound to Provider: Paystack (or Stripe / Flutterwave).
  2. Search for the cashier's reference (the acct-… string from the payment dialog).
  3. If there's no row at all, the webhook never reached us — check the WebhookRouter's Application Insights for delivery attempts.
  4. If there's a Failed row, click into it. The Error column tells you whether it was a signature problem (rotated key out of sync), a metadata mismatch (cross-environment delivery, amount drift), or a tenant-resolution miss (subdomain typo).
  5. If the row is Processed but the till still shows Pending, the cashier needs to refresh — the receiver updates the intent server-side but the SPA polls every 10s.

A whole tenant's payments are failing

  1. Filter Inbound to that tenant (paste the tenant GUID into the URL's ?tenantId=… query). All recent failures will surface.
  2. If they all share the same Error message, check the Outbound tab for the matching initialize calls — the gateway might be rejecting OUR requests before any webhook fires.

Replay a webhook

Click a Failed (or already Replayed) row → Replay payload. The original bytes get re-run through WebhookProcessingService with the original signature header preserved. The dedup gate sees the same idempotency key and short-circuits; the existing row's status changes to Replayed and its replay count goes up by one. The PosPaymentIntent gets updated if the re-run succeeded.

Replays are safe — the idempotency guard means a successful re-run does NOT double-settle the intent.

Common problems

  • A row shows "Tenant not found" → the marketplace tenant registry doesn't know about the subdomain we got. Check metadata.tenant_subdomain in the raw payload and confirm the tenant is actually in the registry.
  • "Signature did not verify" → the per-environment webhook secret in Key Vault doesn't match the one in the provider dashboard. Rotate both to the same value.
  • "Reported amount X != expected Y" → either the gateway rejected our amount and dispatched a different one, or we recorded the wrong expected amount on initialize. Cross-check the matching outbound InitializeTransaction row in the Outbound tab.
  • "Foreign reference" → the router forwarded a webhook whose reference starts with something other than acct-. Either an acct config entry got copy-pasted to the wrong app's URL in the router's variable group, or another app's prefix is mis-routed. Check Routing:Apps in the router's Azure DevOps variable group.

Limits

  • The webhook detail dialog renders the raw payload as JSON. If the body isn't valid JSON it falls back to plain text.
  • Response bodies on Outbound are truncated at 64KB. If you need more detail than that, check Application Insights — the full body is available there for the retention window.
  • Replay does not re-fetch the provider's current state; it replays only the stored payload. For a fresh server-to-server pull, use the intent's GET /api/pos/payment-intents/{id} (which triggers a defensive verify) from the cashier-side SPA.

See it working on your own business

Start a 45-day free trial — no card needed — and follow this guide in your own practice company.