Direct API Installation
Install SaaSFunnels Direct API for backend source-of-truth events.
Direct API is the source-of-truth path for backend events that are confirmed by durable product, billing, setup, or integration state. Use it when the event should be emitted from server code after a database write, provider confirmation, billing update, OAuth completion, background job, or metered usage calculation.
This is different from the Web SDK. The Web SDK is browser-safe supplemental instrumentation for visible user intent and UI friction. Direct API uses a secret ingest key and must never run in browser, mobile, desktop, or public client code.
Create an ingest key
In SaaSFunnels, open Settings > Integrations > Direct API and create a key. The secret is shown once.
Store it only in backend or server environment variables as SAASFUNNELS_INGEST_API_KEY. For an interactive shell, use the shell-safe prompt.
Never expose this key in client code, frontend bundles, mobile apps, desktop apps, public repositories, logs, analytics tools, or AI prompts.
Use developer tools
Use SaaSFunnels CLI and SaaSFunnels MCP Server to speed up Direct API setup without exposing real keys.
Validate a payload before shipping it:
saasfunnels events validate ./saasfunnels-event.json --source direct --jsonSend one smoke event from a server-safe environment:
saasfunnels events send-test --file ./saasfunnels-event.json --jsonInstall a coding-agent handoff file if it would help. Choose the target for your tool; this example writes a portable Markdown handoff:
saasfunnels agent install --target markdownStart MCP when an agent needs SaaSFunnels context:
saasfunnels mcp serveSet a developer:read key as SAASFUNNELS_API_KEY for live MCP reads. MCP is read-only by default. Enable send_test_event only for explicit setup smoke testing.
Send a server event
Send events to:
https://app.saasfunnels.ai/api/events/ingestUse the ingest key as a bearer token:
Replace the example timestamp with the event's actual UTC time and choose a unique, stable event_id for a new event. Reuse that ID only when retrying the same event.
curl -X POST https://app.saasfunnels.ai/api/events/ingest \
-H "Authorization: Bearer ${SAASFUNNELS_INGEST_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"event_id": "evt_direct_001",
"event_name": "usage_limit_hit",
"account_id": "acct_123",
"user_id": "user_456",
"timestamp": "2026-06-20T15:00:00.000Z",
"semantic_type": "limit_friction",
"event_kind": "friction_event",
"sentiment": "negative",
"sentiment_score": -0.8,
"sentiment_confidence": 0.9,
"sentiment_source": "explicit",
"value": 1,
"properties": {
"plan": "starter",
"limit_name": "monthly_events",
"usage_ratio": 0.93
}
}'Feature-gate access and adoption
The Feature-gate upgrade Funnel waits for a server-confirmed entitlement_active event before it shows the unlocked step. Send that event only after your server verifies that the same account can use the selected Feature. Include funnel_journey_id and funnel_version_id in properties, using the identifiers from the verified Funnel action or decision context. A billing receipt alone does not prove Feature access, and a browser event cannot confirm it.
For a signed customer action connector, its execution request includes binding.journeyId and binding.publishedVersionId. After the host has applied the plan change and checked the Feature for that account, send a Direct API event like this from its backend. Reuse the same event_id when retrying delivery.
{
"event_id": "entitlement_active:action_request_123",
"event_name": "entitlement_active",
"account_id": "acct_123",
"timestamp": "2026-09-22T15:00:00.000Z",
"semantic_type": "activation",
"event_kind": "product_action",
"properties": {
"feature_key": "premium_exports",
"funnel_journey_id": "44444444-4444-4444-8444-444444444444",
"funnel_version_id": "66666666-6666-4666-8666-666666666666"
}
}Use the same SaaSFunnels account ID as the verified runtime session. The event timestamp must fall after the journey begins; a correct Price or provider result without this correlated access evidence leaves the customer in the checking state.
The server SDK can create the same event with a stable retry identity:
await funnels.sendFeatureEntitlementActive({
accountId: "acct_123",
featureKey: "premium_exports",
funnelJourneyId: actionBinding.journeyId,
funnelVersionId: actionBinding.publishedVersionId,
occurredAt: new Date().toISOString(),
verifiedAction,
});Call this only after the host's entitlement check succeeds. The helper derives the idempotent event ID and sends the required correlation properties.
Send feature_adopted separately when the account actually uses the Feature. It measures later adoption; it does not unlock the Funnel or block publication. Confirm both event names in Events after their first server-side test delivery. A setup smoke event proves intake and mapping, while a controlled upgrade test must still prove that the access event is linked to the same account, journey, and published version.
Minimum viable SaaS account facts
SaaSFunnels should learn a small account profile while events arrive. Send these as bounded properties on normal account-scoped events, especially signup, login, activation, plan change, team, and usage rollup events.
| Fact | Preferred field names | Notes |
|---|---|---|
| Customer signup date | account_created_at, signup_at, or customer_created_at | This is the customer's product signup date, not the time SaaSFunnels first saw the account. |
| Login activity | user_logged_in or session_started events with semantic_type: "login" | Include user_id when a user caused the login. SaaSFunnels can derive login counts from these events. |
| Plan and lifecycle | plan, plan_name, current_plan, account_status, or subscription_status | Keep values bounded and stable, such as starter, growth, active, trialing, or canceled. |
| Trial state | is_in_trial, trial_started_at, trial_ends_at | Use this when Stripe is not connected or before Stripe is available. Stripe is authoritative for billing and trial state when connected. |
| Team and seats | user_count, active_user_count, seat_count, or member_count | Send current counts from backend state or a scheduled rollup, not raw user lists. |
Example login event with account facts:
{
"event_id": "login:acct_123:user_456:2026-06-20T15:00:00.000Z",
"event_name": "user_logged_in",
"account_id": "acct_123",
"user_id": "user_456",
"timestamp": "2026-06-20T15:00:00.000Z",
"semantic_type": "login",
"event_kind": "login",
"properties": {
"account_created_at": "2026-06-01T12:00:00.000Z",
"plan": "starter",
"is_in_trial": true,
"trial_ends_at": "2026-06-28T12:00:00.000Z",
"user_count": 7,
"seat_count": 10,
"active_user_count": 6
}
}Refresh these facts on lifecycle changes or daily account rollups. Do not create noisy page-view events just to refresh profile data.
Usage-based and API-heavy products
For API-heavy products, do not forward every customer API request to SaaSFunnels. Send aggregate usage from a backend scheduled job instead.
The recommended unit is one aggregate event per account, product, metric, and closed time bucket:
{
"event_id": "usage_rollup:acct_123:api_platform:api_calls:2026-06-01",
"event_name": "usage_rollup_recorded",
"account_id": "acct_123",
"timestamp": "2026-06-02T00:10:00.000Z",
"semantic_type": "usage",
"event_kind": "product_action",
"value": 148253,
"properties": {
"product_key": "api_platform",
"metric_key": "api_calls",
"unit": "request",
"usage_start": "2026-06-01T00:00:00.000Z",
"usage_end": "2026-06-02T00:00:00.000Z",
"aggregation_grain": "day",
"source_system": "usage_metering"
}
}Use a cron job, queue worker, warehouse job, or metering service to calculate these rows from the customer's source-of-truth usage table. Daily buckets are enough for most revenue signals. Hourly buckets are useful when usage changes quickly, a customer is near a limit, or the revenue team needs same-day routing.
The aggregate payload should answer:
| Question | Field |
|---|---|
| Which customer used it? | account_id |
| Which product surface was used? | properties.product_key |
| Which usage meter changed? | properties.metric_key |
| How much usage happened? | value |
| What unit is the value in? | properties.unit |
| Which usage period does it cover? | properties.usage_start and properties.usage_end |
| Can the row be safely retried? | Stable event_id |
Keep product_key, metric_key, and unit stable. Treat them like a small product and metric catalog. For example, api_platform + api_calls + request should keep the same meaning over time. If pricing or packaging changes, add properties such as plan, tier, or pricing_version; do not silently change the unit behind an existing metric key.
Prevent overload
Aggregate before sending. A customer with millions of API requests should usually send hundreds or thousands of aggregate rows, not millions of Direct API calls. In the standard Direct API path, each request should represent an aggregate row; for very large row counts, use a bulk import plan instead of increasing concurrency.
Use these guardrails:
- Send only closed buckets, such as the previous hour or previous day.
- Use deterministic
event_idvalues so retries are treated as the same logical event instead of duplicating usage. - Retry failed rows with backoff and the same
event_id. - Split backfills into small date ranges and send oldest closed periods first.
- Keep each request compact. The Direct API accepts small JSON event payloads, not raw logs or large arrays.
- Start scheduled jobs with low concurrency, then increase only after Activity shows healthy accepts and low rejection rates.
- Send milestone or threshold events separately, such as
quota_blocked,limit_hit,overage_started, orplan_upgraded.
If the customer needs to send a large historical backfill or very large numbers of aggregate rows per run, coordinate a bulk import plan instead of increasing request concurrency.
Direct API event discovery
Use Direct API for events that are better known by the backend than the browser:
- Completed billing or subscription state changes.
- Checkout completed, invoice paid, payment failed, refund, downgrade completed, cancel completed.
- Durable setup milestones such as integration configured, OAuth connected, destination configured, API key created, or first event ingested.
- Metered usage, credits consumed, quota calculations, API calls, background jobs, sync results, and anything billable.
first_*milestones that must be race-safe and based on persisted state.
Use the Web SDK instead for browser-only intent before backend state changes, visible prompts, page-level UI friction, and supplemental adoption signals.
When using an AI coding tool, ask it to classify event candidates as:
| Classification | Meaning |
|---|---|
direct_api_now | Durable backend event that should be sent through Direct API now |
existing_source | Already covered by Stripe, PostHog, Segment, Direct API, warehouse, or another reliable event source |
web_sdk_follow_up | Browser-visible intent or UI friction that is better captured by the Web SDK |
Event contract
Every revenue-relevant Direct API event should include:
| Field | Required | Notes |
|---|---|---|
event_name | Yes | Static lower_snake_case name such as usage_limit_hit |
account_id | Yes | Stable customer, account, workspace, tenant, company, or organization ID |
timestamp | Yes | ISO timestamp for when the product event occurred |
event_id | Recommended | Stable idempotency key for retryable completed events |
user_id | Optional | Actor context when a user caused the event |
user | Optional | { "name": "...", "email": "..." } for the user in user_id |
semantic_type | Recommended | One of SaaSFunnels's revenue event families |
event_kind | Recommended | product_action, billing_event, friction_event, or another normalized kind |
value | Optional | Numeric amount, count, usage value, credit amount, or revenue magnitude |
properties | Optional | Bounded structured context |
Use stable event_id values for completed state changes, for example checkout_completed:<checkout_id> or integration_connected:<provider>:<account_id>.
Do not emit first_* milestones unless persisted state proves first occurrence race-safely. If first-occurrence detection is approximate, skip the event or use a non-first_* name.
Name your users
Accounts list their users by user_id. Add a user object with the person's name and email, and the Account's Users tab shows them by name:
{
"event_name": "report_exported",
"account_id": "acct_123",
"user_id": "user_456",
"user": { "name": "Ada Lovelace", "email": "ada@example.com" },
"timestamp": "2026-06-20T15:00:00.000Z"
}SaaSFunnels keeps the latest name and email for each user_id and leaves them out of event properties. Sending a field again updates it; leaving it out keeps the stored value. The same user object is accepted on Funnel runtime sessions and customer-server events. Use the same user_id everywhere so events and Funnel sessions show as one person.
Events to add first
Start with a small allowlist of 3 to 6 high-signal backend events.
| Event family | Example names | Direct API guidance |
|---|---|---|
| Activation / first value | account_activated, first_value_completed, onboarding_completed | Emit after persisted state proves completion |
| High-value usage | feature_used, workflow_completed, report_exported, api_credits_used, generation_completed, sync_completed | Best for metered, key-feature, or server-confirmed usage |
| Limit friction | limit_hit, quota_blocked, overage_prompt_viewed | Best when the backend calculates the limit |
| Checkout | checkout_started, checkout_completed, checkout_abandoned | Direct API or Stripe should be authoritative for completed checkout |
| Billing lifecycle | subscription_updated, subscription_upgraded, invoice_payment_failed, payment_succeeded, plan_changed | Prefer Stripe when connected; use Direct API only when your backend is the billing source of truth |
| Downgrade or cancel | downgrade_completed, cancel_completed, subscription_paused | Use bounded reason_code; never send free text |
| Team expansion | team_member_invited, seat_added, role_assigned | Emit after invite or seat persistence succeeds |
| Integration connected | integration_connected, oauth_connected, destination_configured | Emit after credentials/configuration are persisted |
| Product or support friction | sync_failed, setup_failed, support_needed | Use bounded error codes/statuses only |
Sentiment fields
Sentiment is optional and separate from the event family. Missing sentiment is stored as unknown, not neutral; use neutral only when the product signal is explicitly neutral.
Send explicit bounded sentiment from the backend when the source-of-truth system knows it. If sentiment is omitted, SaaSFunnels can infer a reviewable mapping default from the event name, semantic type, event kind, and bounded property keys. Explicit payload sentiment takes precedence over mapped sentiment.
| Field | Values |
|---|---|
sentiment | positive, neutral, negative, unknown |
sentiment_score | Number from -1 to 1 |
sentiment_confidence | Number from 0 to 1 |
sentiment_source | explicit, inferred, ai, integration, unknown |
Do not send free-text feedback, survey responses, chat messages, support transcripts, raw comments, or AI-generated summaries as sentiment context. Send bounded labels and scores only.
See Event KPI Signals for the canonical KPI definitions, evidence bands, and Account filters these fields power.
Data safety
Do not send:
- Passwords, tokens, cookies, signatures, auth headers, API keys, session values, invite links, webhook URLs, provider tokens, or destination secrets.
- Personal data outside the
userobject, full URLs with query strings, full request/response bodies, stack traces, raw logs, prompts, comments, notes, support messages, or descriptions. - Generic page views, auth page views, modal opens, hovers, local UI toggles, or debug logs as first-batch Direct API events.
Verify setup
Send one account-scoped activation, usage, limit, or setup event from backend code. Then check Settings > Integrations > Direct API and Events in SaaSFunnels.
A successful setup should show:
- The event is accepted by the Direct API route.
- The event normalizes successfully.
- The event attaches to the expected account.
- Rejected payloads clearly explain missing
account_id, auth, or revenue relevance. - No secrets, raw PII, query-string values, or full request/response bodies are stored.