Skip to main content

Paddle Webhook Observability

Fyso records inbound Paddle webhook deliveries so superadmins and support operators can diagnose payment, subscription, and app-entitlement incidents without reading raw server logs.

This observability layer is separate from paddle_webhook_events, which remains the idempotency ledger for processed app-entitlement events. The ingress log records every request that reaches POST /api/webhooks/paddle, including missing signatures, invalid signatures, valid billing events, and valid app-entitlement events.

What Is Logged

The public Paddle webhook endpoint records metadata in paddle_webhook_ingress_logs. Migration 0088_create_paddle_webhook_ingress_logs.sql creates the table and indexes the fields used by the support views.

FieldDescription
routeWebhook route, currently /api/webhooks/paddle
event_id, event_type, notification_idPaddle event identifiers when available
scopedata.custom_data.scope, for example app_entitlement
signature_statusmissing, invalid, or valid
handlernone, app_entitlement, billing, or ignored
http_statusResponse status returned to Paddle
errorIngress or handler error captured during processing
org_id, source_tenant_id, app_keyApp-entitlement context from custom_data
customer_id, subscription_id, transaction_idPaddle object identifiers
payloadPersisted for valid billing and app-entitlement events
received_atIngress timestamp

The record step is non-blocking for the webhook delivery: if the ingress log insert fails, the webhook handler logs the persistence error and continues processing the delivery.

Delivery Outcomes

Deliverysignature_statushandlerResponseNotes
Missing paddle-signature headermissingnone400No event payload is trusted.
Body read failureinvalidnone400Logged before Paddle signature verification.
HMAC verification failureinvalidnone400Parsed JSON context is logged if the body can be parsed.
Valid signature, invalid JSONvalidnone400Signature passed, but the raw body is not usable JSON.
custom_data.scope = "app_entitlement"validapp_entitlement200Handler errors are captured in error; Paddle still receives 200.
Other normalized Paddle eventvalidbilling200Uses the existing billing handler.
Valid request with no normalized eventvalidignored200Logged for diagnostics.

After HMAC verification succeeds, Fyso returns 200 even when business processing fails. Those failures should be investigated from the ingress log and paddle_webhook_events.processing_error, not by waiting for Paddle retries.

Superadmin Console

Open the superadmin console and go to Paddle Webhooks:

/superadmin/paddle-webhooks

The page is only available to superadmins. It displays:

  • Total deliveries.
  • Valid signed deliveries.
  • Rejected deliveries split by invalid and missing signatures.
  • Deliveries with ingress or processing errors.
  • Latest delivery cards with event type, event ID, timestamp, signature status, handler, HTTP status, app context, Paddle IDs, and error text.

Console Filters

FilterValuesNotes
SearchFree textSearches event ID, notification ID, customer ID, subscription ID, transaction ID, org ID, and source tenant ID.
Signature statusvalid, invalid, missingEmpty value shows all signatures.
Handlerapp_entitlement, billing, ignored, noneEmpty value shows all handlers.

Use the search box with the Paddle event_id, subscription_id, transaction_id, or customer ID from a support ticket. Combine it with handler = app_entitlement when the incident is about Fyso Teams access.

Superadmin API

The console calls the superadmin API:

GET /api/admin/platform/paddle-webhooks
Authorization: Bearer <superadmin-session-token-or-fyso_sa_key>

X-Admin-Secret: <ADMIN_SECRET> is still accepted as a legacy fallback where configured.

Query Parameters

ParameterTypeDefaultDescription
pagenumber1Page number. Values below 1 fall back to 1.
limitnumber50Page size. Maximum 100.
signatureStatusstringnoneOne of valid, invalid, missing. Invalid values are ignored.
handlerstringnoneOne of app_entitlement, billing, ignored, none. Invalid values are ignored.
eventTypestringnoneExact event type filter, for example subscription.created.
qstringnoneFree-text search across Paddle and Fyso context IDs.

Example:

curl "https://api.fyso.dev/api/admin/platform/paddle-webhooks?limit=50&signatureStatus=valid&handler=app_entitlement&q=sub_01h" \
-H "Authorization: Bearer fyso_sa_..."

Response shape:

{
"success": true,
"data": {
"logs": [
{
"id": "log-uuid",
"route": "/api/webhooks/paddle",
"event_id": "evt_01h...",
"event_type": "subscription.created",
"notification_id": "ntf_01h...",
"scope": "app_entitlement",
"signature_status": "valid",
"handler": "app_entitlement",
"http_status": 200,
"error": null,
"processing_error": null,
"org_id": "org-uuid",
"source_tenant_id": "65422493-73d8-4e74-a879-defa3a9771f1",
"app_key": "fyso_teams",
"customer_id": "ctm_01h...",
"subscription_id": "sub_01h...",
"transaction_id": null,
"received_at": "2026-05-02T16:07:49.002Z"
}
],
"summary": {
"totalLogs": 1,
"validCount": 1,
"invalidSignatureCount": 0,
"missingSignatureCount": 0,
"appEntitlementCount": 1,
"billingCount": 0,
"errorCount": 0
},
"pagination": {
"page": 1,
"limit": 50,
"total": 1
}
}
}

Support Replay For App Entitlements

Use the support replay script only when support has confirmed that a Paddle subscription exists but Fyso did not activate the app entitlement. The script builds and signs an app_entitlement Paddle event, resolves the target org from an admin email, and posts to the same public webhook route.

By default it is a dry run. It prints the resolved org, source tenant, event ID, and payload context without sending the webhook.

PADDLE_WEBHOOK_SECRET="..." \
SUPPORT_ACCOUNT_EMAIL="customer@example.com" \
SUPPORT_PADDLE_SUBSCRIPTION_ID="sub_01h..." \
SUPPORT_PADDLE_CUSTOMER_ID="ctm_01h..." \
SUPPORT_PERIOD_END="2026-06-01T00:00:00.000Z" \
bun run --cwd packages/api support:replay-app-entitlement

Send the replay only after reviewing the dry-run output:

PADDLE_WEBHOOK_SECRET="..." \
SUPPORT_ACCOUNT_EMAIL="customer@example.com" \
SUPPORT_PADDLE_SUBSCRIPTION_ID="sub_01h..." \
SUPPORT_PADDLE_CUSTOMER_ID="ctm_01h..." \
SUPPORT_PERIOD_END="2026-06-01T00:00:00.000Z" \
bun run --cwd packages/api support:replay-app-entitlement --send

Optional variables:

VariableDefaultDescription
PADDLE_WEBHOOK_URLhttps://api.fyso.dev/api/webhooks/paddleTarget webhook URL.
PADDLE_REPLAY_SENDunsetSet to 1 as an alternative to --send.
SUPPORT_ORG_IDunsetRequired when the admin email belongs to multiple orgs.
SUPPORT_APP_KEYfyso_teamsApp key used to resolve source_tenant_id.
SUPPORT_SOURCE_TENANT_IDapp catalog lookupExplicit source tenant ID override.
SUPPORT_EVENT_TYPEsubscription.createdMust be subscription.created or subscription.trialing.
SUPPORT_SUBSCRIPTION_STATUStrialingSubscription status written into the replay payload.
SUPPORT_PERIOD_STARTcurrent timestampCurrent billing period start.
SUPPORT_EVENT_IDgeneratedExplicit event ID for traceability.

After sending, confirm the replay in Paddle Webhooks by searching the generated event_id, subscription ID, or customer ID. The delivery should show:

  • signature_status = valid
  • handler = app_entitlement
  • http_status = 200
  • no ingress error
  • no processing_error

Manual Fyso Teams Payment Smoke

The manual smoke test is opt-in and skipped unless FYSO_TEAMS_PAYMENT_SMOKE=1. Use it during staging or production diagnosis to verify public routes, app catalog config, webhook tables, authenticated entitlement reads, and optionally real Paddle checkout creation.

Minimal route/config smoke:

FYSO_TEAMS_PAYMENT_SMOKE=1 \
bun run --cwd packages/api smoke:fyso-teams-payment

Read-only database diagnostics:

FYSO_TEAMS_PAYMENT_SMOKE=1 \
FYSO_TEAMS_SMOKE_DATABASE_URL="postgresql://readonly:...@localhost:5460/fyso?sslmode=prefer" \
FYSO_TEAMS_SMOKE_TARGET_EMAIL="customer@example.com" \
bun run --cwd packages/api smoke:fyso-teams-payment

Authenticated entitlement diagnostics:

FYSO_TEAMS_PAYMENT_SMOKE=1 \
FYSO_TEAMS_SMOKE_EMAIL="smoke@example.com" \
FYSO_TEAMS_SMOKE_PASSWORD="..." \
bun run --cwd packages/api smoke:fyso-teams-payment

Creating a real Paddle transaction is intentionally separate:

FYSO_TEAMS_PAYMENT_SMOKE=1 \
FYSO_TEAMS_SMOKE_CREATE_CHECKOUT=1 \
FYSO_TEAMS_SMOKE_EMAIL="smoke@example.com" \
FYSO_TEAMS_SMOKE_PASSWORD="..." \
bun run --cwd packages/api smoke:fyso-teams-payment

Additional variables:

VariableDefaultDescription
FYSO_TEAMS_SOURCE_IDFyso Teams source tenant UUIDSource app under test.
FYSO_TEAMS_SMOKE_API_URLhttps://api.fyso.dev/apiDirect API base.
FYSO_TEAMS_SMOKE_WORLD_URLhttps://fyso.worldProxy host smoke target.
FYSO_TEAMS_SMOKE_AUTH_API_URL${FYSO_TEAMS_SMOKE_WORLD_URL}/apiAuthenticated API base.
FYSO_TEAMS_SMOKE_EXPECT_ACTIVE_ENTITLEMENTunsetSet to 1 to assert the target has an active entitlement.
FYSO_TEAMS_SMOKE_EXPECT_WEBHOOK_EVENTSunsetSet to 1 to assert Fyso Teams webhook events exist.

Do not enable FYSO_TEAMS_SMOKE_CREATE_CHECKOUT=1 unless the account and Paddle environment are intended to create a real checkout transaction.