SaaSFunnelsDocs
Developer Tools

Payment Action Connector

Connect and verify your backend payment actions before creating a Funnel.

The customer action connector lets SaaSFunnels ask a customer's backend to perform one already-authorized commercial action. It uses one fixed HTTPS destination, bounded JSON, HMAC signatures, stable idempotency, and a separate signed completion callback.

The connector is an execution boundary, not a second decision engine. The customer endpoint must execute only the exact action.operation supplied by SaaSFunnels and must never let browser input replace the bound account, offer, version, action, or safe terms.

Complete Setup → Connect your app, then use Setup → Verify payments. A workspace administrator can register the endpoint and its two distinct secrets under Connect payment backend, then run Check payment connection. The browser and Funnel session keys do not authorize these administrative operations.

State boundary

The acknowledgement and business outcome are deliberately separate:

EventMeaning
Signed accepted acknowledgementThe customer backend durably recorded the request and owns it under the provider idempotency key. It is not billing success, conversion, or entitlement delivery.
Signed rejected acknowledgementThe customer backend did not accept the action.
Completion succeededThe customer backend reports that execution completed and supplies evidence. The Funnel stays pending while SaaSFunnels verifies that evidence.
Completion failedExecution reached a known failure.
Completion unknownExecution may have happened, but its result cannot yet be established.
Completion reversedA previously completed effect was reversed.
Later SaaSFunnels state verifiedSaaSFunnels received and matched authoritative evidence after completion. verified is not a connector completion outcome.

Evidence capable of moving an action to verified must use an allowed catalog evidence type: authenticated_completion, provider_event, customer_server_event, billing_change, subscription_state, entitlement_state, credit_balance, or approval_recorded.

The embedded runtime follows an action success destination only after the later SaaSFunnels state is verified. accepted and connector succeeded never advance the customer journey by themselves.

Configuration

Configure exactly one public HTTPS endpoint for the workspace, for example:

https://customer.example/prevenue/customer-actions

SaaSFunnels validates the host, refuses credentials/query strings/fragments/private addresses, and disables redirects. The execution payload cannot select or override the destination.

Use two independent secrets of at least 32 characters:

  • Action signing secret: SaaSFunnels signs handshake and execution requests with it; the customer signs handshake and execution responses with it.
  • Completion secret: the customer signs completion callbacks to SaaSFunnels with it.

Store both only in server-side secret storage, rotate them through the connection configuration, and do not place either secret in browser code, source control, logs, payloads, or the connector database row.

A connection revision is one exact endpoint-and-secret generation. The handshake tests and activates that same pending revision; it does not copy the secrets into another generation. Reconfiguration creates a new pending revision, so a concurrent or delayed handshake for the prior revision fails before it can activate or overwrite anything.

SaaSFunnels retains an older signing/completion pair only while an action bound to that revision can still complete or reconcile. Once every bound reconciliation window has ended, both credentials are retired together. A partial historical credential write is treated as an orphan and retired with its own immutable audit evidence.

Signed headers

Every signed request and response uses these headers:

HeaderValue
content-typeapplication/json
x-saasfunnels-customer-action-version1
x-saasfunnels-customer-action-timestampISO-8601 timestamp
x-saasfunnels-customer-action-nonceUnique 16–160 character nonce
x-saasfunnels-customer-action-signaturev1= followed by a lowercase SHA-256 HMAC

Execution requests also contain:

x-saasfunnels-customer-action-idempotency-key: <the providerIdempotencyKey in the body>

Compute the signature over the exact raw body bytes received or sent:

saasfunnels-customer-action-v1
<purpose>
<timestamp>
<nonce>
<raw JSON body>

Then calculate HMAC-SHA256(secret, input) and format the result as v1=<64 lowercase hex characters>.

The purpose is part of the signature domain:

MessagePurpose
Handshake requesthandshake_request
Handshake responsehandshake_response
Execution requestdispatch_request
Execution responsedispatch_response
Completion callbackcompletion

Verify the protocol version, timestamp freshness, nonce, and HMAC with a constant-time comparison before using a parsed payload. Sign or verify the raw string; parsing and re-serializing first can change the signature input. SaaSFunnels accepts at most five minutes of clock skew.

Handshake

SaaSFunnels sends the handshake to the configured action endpoint:

{
  "challenge": "7d90dd6c-883a-4b0a-9a0c-232be88de79f",
  "connectionId": "2f439292-4063-456e-ad61-eef4ec76f74b",
  "protocolVersion": 1,
  "sentAt": "2026-08-04T20:00:00.000Z",
  "type": "saasfunnels.customer_action.handshake",
  "workspaceId": "77777777-7777-4777-8777-777777777777"
}

The challenge equals the request nonce. After verifying the handshake_request signature and exact payload, return HTTP 200 with this exact response shape and a handshake_response signature:

{
  "challenge": "7d90dd6c-883a-4b0a-9a0c-232be88de79f",
  "connectionId": "2f439292-4063-456e-ad61-eef4ec76f74b",
  "protocolVersion": 1,
  "status": "ready",
  "type": "saasfunnels.customer_action.handshake_ack",
  "workspaceId": "77777777-7777-4777-8777-777777777777"
}

Connection tests are safe to retry. They do not execute a commercial action.

Billing-owner informed approval

A request-authority viewer creates only a durable approval request. The billing owner must then use a live, verified, same-account runtime session with funnels.actions.execute authority to review it:

POST /api/funnels/v1/runtime/actions/approvals/prepare

The prepare request contains the opaque approval ID, private approval token, and runtime token. Its response never returns the approval token or a mutation payload. It returns a bounded reviewProjection containing the exact display-safe action and optional offer terms, timing, proration, requester role, approval expiry, and an opaque commercial dependency hash. It also returns the current approval revision, reviewValidUntil, and projectionHash.

To approve, submit that exact projectionHash as reviewedProjectionHash with the same revision:

POST /api/funnels/v1/runtime/actions/approvals/resolve

Approval resolution creates a new fresh preflight and recomputes the commitment over the safe projection plus the immutable workspace, account, requester provenance, action/offer binding versions and fingerprints, approval revision/expiry, and current dependency hash. Any changed terms, stale dependency, revision drift, wrong account, expired capability, or hash mismatch fails closed before authorization. Denial does not require or accept a review hash and never authorizes an action. The original requester session is retained as immutable provenance only; execution authority comes from the live billing-owner session.

Execution request

After SaaSFunnels has revalidated the current published version, decision, account state, authority, catalog references, transition, offer, and frequency policy, it sends an exact server-approved request:

{
  "accountId": "account_customer_0001",
  "action": {
    "id": "11111111-1111-4111-8111-111111111111",
    "key": "extend_trial",
    "operation": {
      "extensionDays": 14,
      "family": "extend_trial",
      "trialRef": {
        "entityKey": "growth_trial",
        "entityKind": "trial",
        "version": 3
      }
    },
    "safeTerms": {
      "confirmation": "Extend the Growth trial by 14 days.",
      "label": "Extend trial"
    }
  },
  "actionRequestId": "22222222-2222-4222-8222-222222222222",
  "actor": {
    "authority": "billing_owner",
    "roleKey": "billing_owner",
    "subjectId": "customer_user_0001"
  },
  "binding": {
    "decisionId": "33333333-3333-4333-8333-333333333333",
    "journeyId": "44444444-4444-4444-8444-444444444444",
    "offerId": "55555555-5555-4555-8555-555555555555",
    "publishedVersionId": "66666666-6666-4666-8666-666666666666"
  },
  "providerIdempotencyKey": "pva_<stable SHA-256 identity>",
  "protocolVersion": 1,
  "requestedAt": "2026-08-04T20:00:00.000Z",
  "requestNonce": "connector_nonce_00000001",
  "type": "saasfunnels.customer_action.execute",
  "workspaceId": "77777777-7777-4777-8777-777777777777"
}

Validate all bindings and require the idempotency header, body providerIdempotencyKey, and locally derived key to match. The stable key is:

pva_ + SHA256("customer-action-idempotency-v1\n<workspaceId>\n<actionRequestId>")

Persist that key with a unique constraint in the same transaction that claims the work. Concurrent duplicates must return the original acknowledgement and must not execute the business operation twice. A retry uses a fresh nonce, timestamp, and signature while keeping the provider idempotency key unchanged.

Once the request is durably owned, return HTTP 202 with an exact signed acknowledgement:

{
  "actionRequestId": "22222222-2222-4222-8222-222222222222",
  "protocolVersion": 1,
  "providerIdempotencyKey": "pva_<stable SHA-256 identity>",
  "providerReference": "provider_request_0001",
  "requestNonce": "connector_nonce_00000001",
  "state": "accepted",
  "type": "saasfunnels.customer_action.ack",
  "workspaceId": "77777777-7777-4777-8777-777777777777"
}

providerReference is optional. A durable refusal uses the same shape with state: "rejected" and may add a bounded errorCode. Sign either result with the dispatch_response purpose. Never acknowledge accepted merely because a process placed work in volatile memory.

Optional embedded payment continuation

An accepted, server-authorized Payment action may add exactly one continuation object:

{
  "actionRequestId": "22222222-2222-4222-8222-222222222222",
  "continuation": {
    "allowRedirects": "never",
    "clientSecret": "seti_..._secret_...",
    "connectedAccountId": "acct_...",
    "expiresAt": "2026-08-04T20:30:00.000Z",
    "intent": "setup",
    "kind": "stripe_payment_element",
    "publishableKey": "pk_live_..."
  },
  "protocolVersion": 1,
  "providerIdempotencyKey": "pva_<stable SHA-256 identity>",
  "providerReference": "provider_request_0001",
  "requestNonce": "connector_nonce_00000001",
  "state": "accepted",
  "type": "saasfunnels.customer_action.ack",
  "workspaceId": "77777777-7777-4777-8777-777777777777"
}

Requirements:

  • intent is payment for a PaymentIntent client secret and setup for a SetupIntent client secret.
  • allowRedirects must be "never". The customer backend must create the PaymentIntent or SetupIntent with Stripe dynamic payment methods and automatic_payment_methods.allow_redirects=never; do not pass payment_method_types.
  • expiresAt must be in the future and no more than one hour after SaaSFunnels receives the acknowledgement.
  • connectedAccountId may be null for platform-owned intents.
  • The continuation is allowed only with state: "accepted"; an acknowledgement with unknown fields or an invalid continuation is rejected as an invalid response.
  • A published V2 Payment block must bind the same action and, when priced, the same Offer. Existing recovery continuations without a Payment block retain their published behavior. The allowed operation keys are payment.existing_subscription_change, payment.new_subscription, payment.one_time_purchase, payment.setup_only, and recover_payment. Each requires its matching workspace capability and current connector revision.

SaaSFunnels encrypts the continuation before storage and exposes it only through the existing actor-bound action-status route. Before mounting, the runtime retrieves the Intent with its client secret and verifies that Stripe also reports automatic_payment_methods.allow_redirects=never. Stripe Elements owns every raw payment field. The embedded runtime may call Stripe confirmation, but it keeps the action pending until the completion and provider evidence produce later SaaSFunnels state verified. A blocked script, CSP failure, expiry, authentication failure, or reconciliation delay preserves the host-native billing/support path and never produces a success claim.

Independent Payment reconciliation

For a Payment action, a signed completion reporting succeeded is a claim. SaaSFunnels queues a separate read through the same fixed, authenticated connector endpoint. Sign this request with purpose payment_reconciliation_request and the current action signing secret. The body type is saasfunnels.customer_action.payment_reconciliation and includes the immutable workspace, account, action request, action version, Offer version, mode, idempotency key, provider reference hash, nonce, requested time, and exact price amount, currency, and reference hash snapshotted at authorization. The customer backend must verify the signature and nonce, read current provider and customer state independently of the completion callback, and compare all bindings before responding.

Return HTTP 200 with the exact saasfunnels.customer_action.payment_observation shape and sign its raw JSON bytes with purpose payment_reconciliation_response. It must echo every binding, nonce, and expected term, plus providerReference, eventId, eventSignatureVerified, stateReadAt, customerBound, intent, paymentStatus, subscriptionState, invoicePaid, methodAttached, chargedAmountMinor, refundedAmountMinor, and outcome. A verified result requires a matching signed provider event and a fresh state read. For a one-time purchase the successful charge must equal the fixed amount and currency. A full refund may reverse it; a partial refund remains inconclusive. A new subscription requires active or trialing state and a paid initial invoice or valid trial. Setup-only requires an attached method and records no revenue. SaaSFunnels hashes the provider event and reference; it does not store the raw reference in the outcome ledger. Failed, missing, late, or mismatched responses stay inconclusive and cannot promote a payment claim.

A signed reversed completion for an already verified Payment action is also only a notice. It queues a fresh signed reconciliation read. A verified refund or reversal appends a reversal to the existing outcome and reporting history.

Completion callback

After the business operation reaches a known outcome, POST its signed completion to:

https://<prevenue-host>/api/funnels/v1/runtime/actions/customer-complete

Use the completion secret and completion signature purpose:

{
  "actionRequestId": "22222222-2222-4222-8222-222222222222",
  "completedAt": "2026-08-04T20:00:02.000Z",
  "completionId": "customer_completion_0001",
  "evidence": {
    "entitlement": "growth",
    "reasonCode": "provider.confirmed",
    "source": "customer_server"
  },
  "outcome": "succeeded",
  "protocolVersion": 1,
  "providerIdempotencyKey": "pva_<stable SHA-256 identity>",
  "providerReference": "provider_request_0001",
  "type": "saasfunnels.customer_action.completion",
  "workspaceId": "77777777-7777-4777-8777-777777777777"
}

Allowed completion outcomes are succeeded, failed, unknown, and reversed. Do not send accepted or verified as a completion outcome.

Persist the completion body and completionId before delivery. If callback delivery must be retried, reuse the same completion body and ID, but generate a fresh nonce, timestamp, and signature. Treat a 2xx response as the durable completion acknowledgement.

Timeouts and reconciliation

Once an execution request may have reached the customer endpoint, a timeout, connection reset, invalid response, or non-success acknowledgement produces an unknown outcome at the connector boundary. The operation may already have run.

Do not blindly repeat the business operation after an ambiguous transport result. Instead:

  1. Look up the durable customer-side record by providerIdempotencyKey.
  2. Reconcile with the billing, subscription, entitlement, or approval system that owns the effect.
  3. Return the prior signed acknowledgement for a duplicate request without rerunning the action.
  4. Send a signed completion with the observed succeeded, failed, unknown, or reversed outcome.

Use a durable queue/outbox for both execution and completion delivery. Store used request nonces for at least the five-minute signature window, and keep the idempotency ledger for at least the full reconciliation and reversal window.

Stripe payment outcomes

Stripe authorization creates SaaSFunnels' managed billing-data webhook. It does not install the customer's payment execution or payment-result handling. Inspect the customer's existing billing webhook before creating another: reuse or extend a suitable endpoint and preserve its existing subscriptions and entitlement behavior. Create a new endpoint only when needed.

For the standard Setup purposes (setup_only, one_time_purchase, new_subscription), cover these outcomes in the app's payment handler:

Stripe eventsHandler responsibility
setup_intent.succeeded, setup_intent.canceled, setup_intent.setup_failedConfirm saved payment methods, cancellation, and recovery without claiming revenue.
payment_intent.succeeded, payment_intent.canceled, payment_intent.payment_failedReconcile the bound one-time payment and report its outcome.
invoice.payment_succeeded, invoice.payment_failedReconcile the exact initial subscription invoice; later renewals must not create another Funnel conversion.
charge.refundedReconcile refunds and applicable reversals against the original payment.

Verify Stripe's signature against the unmodified raw request body using that endpoint's webhook signing secret. It is different from both SaaSFunnels connector secrets. Subscribe the endpoint to the events the implementation needs, align its API version with the Stripe object format the handler validates, and deduplicate event IDs. Preserve the original payment/account/amount binding; neither a browser success screen nor an unverified webhook is payment evidence. Persist completion delivery for retries and independently read the Stripe resource when reconciling the final outcome.

Before advertising payment capabilities, verify:

  1. The Stripe account behind the app's payment credentials matches the account connected to this workspace.
  2. The workspace connection, Stripe keys, products/prices, and webhook all use the same test/live environment.
  3. All three app-installation values are configured using the exact names read by the app, in the correct secret-manager config and deployed hosting environment. The browser key, installation ID, and server key are distinct values. Do not assume a secret-manager entry automatically reaches the host.
  4. The connector's dispatch and completion secrets are distinct and stored only on the server. The Stripe webhook secret is stored separately. Reconcile missing names securely; never print values or rotate working keys just because one config lacks an entry.
  5. The deployed HTTPS handler accepts valid signed requests, rejects tampering and stale/nonmatching identity, handles duplicate dispatch idempotently, and supports the initial connection revision zero.
  6. Payment-result delivery survives duplicate/out-of-order events and temporary completion failures. Test success, decline/cancel, retries, account isolation, and refund/reversal handling using Stripe test data without live charges.

A signed handshake checks connection readiness and supported purposes. It does not run a payment transaction. Report actual payment tests separately from handshake results, and mark unrun cases as not verified. After creating the first Funnel, test its full installed customer journey before publishing; simulated Preview/Test screens do not certify Stripe execution.

Payment capability proof

Payment modes are enabled through the existing signed connection test. The handshake request includes the optional x-saasfunnels-customer-action-connection-revision header; its original JSON shape stays compatible with existing backends. A backend that implements Payment can add this optional field to its signed handshake_ack response:

{
  "paymentCapabilities": {
    "connectedAccountReferenceHash": "<SHA-256 of JSON.stringify(the connected Stripe account ID)>",
    "connectionRevision": 2,
    "livemode": false,
    "modes": ["one_time_purchase", "setup_only"]
  }
}

The mode allowlist is existing_subscription_change, new_subscription, one_time_purchase, setup_only, and recover_payment. Advertise only modes for which the backend implements idempotent dispatch, Payment continuation binding, authoritative reconciliation, and applicable reversal handling. The existing signature, nonce, workspace, and connection checks also cover this proof. A generic successful acknowledgement enables no Payment modes.

The server checks the proof against the workspace's active Stripe account and environment, then records it in the existing capability registry with the customer connector identity/revision, handshake response fingerprint, and Stripe connection generation. Publication and authorization consume the same verified projection. Evidence expires after one hour. SaaSFunnels checks configured active or pending connections every ten minutes and refreshes checks older than twenty minutes through the same signed handshake. Keep your endpoint available; use Check payment connection in Setup to retry immediately after repairing a failure. Changing either connection invalidates the proof. A successful acknowledgement with no proof removes the previous Payment mode set; an exact replay does not extend freshness. Catalog synchronization does not issue or replace Payment evidence.

Before creating a Funnel, Setup verifies setup_only, one_time_purchase, and new_subscription support together with your app connection and current Stripe catalog.

In Studio, choose the transaction in an Offer's Payment action field, then bind Payment to that Offer and action. For a non-purchase setup, select Save payment method in the Payment inspector and choose Save payment method without a purchase. This creates an ordinary governed commercial action with no priced Offer. Both paths use the existing action publication, authoring commands, actor authority, and runtime boundaries. Direct connected Stripe Payment execution remains disabled.