IncentiveSwift β€” Admin Guide

Agency Administration & Configuration
v2.1 · Admin

Managing IncentiveSwift

This guide covers administrative configuration β€” managing tenants, plans, portfolio companies, integration provider keys, webhook security, and cross-app sync.

1 Admin Access

Admin Section

The Admin section appears in the sidebar for users with the agency_admin role. It expands as an accordion section showing:

  • 🏒 My Portfolio β€” Manage sister companies under your agency
  • πŸ‘₯ Tenants β€” View all tenant accounts, assign plans, manage users
  • πŸ’³ Plan Management β€” Create, edit, and delete subscription plans

Super Admin Role

Super admins have full access to all tenants and portfolio companies. They can:

  • View any tenant's data without impersonation
  • Assign or change plans for any tenant
  • Create and delete plans including feature sets
  • Impersonate tenants for troubleshooting (audited)

2 Tenants

Viewing All Tenants

The Tenants page lists all registered accounts in a searchable table. Each row shows:

  • Name β€” Tenant company or display name
  • Plan β€” The plan the workspace is on, as a picker: choose a plan and it is assigned immediately
  • Billing β€” What that workspace is actually charged on its current plan: the plan tier's monthly price, or free for a zero-priced tier. A dash means the workspace has no plan at all. The figure comes from the same row the picker writes, so assigning a plan updates this column at the next load.
  • Status β€” Active (●) or Suspended (●)
  • Last sign-in β€” When anyone in that workspace last signed in successfully, recorded on every login. not recorded means the app has no sign-in on file for it: this app only began recording sign-ins on 27 October 2026, so a workspace whose owner has used it before that date reads this way too. It is not a claim that the workspace is unused.
  • Email β€” The account's sign-in address. When the first password was minted but the email carrying it never went out, a red no password delivered β€” issue one button sits beside the address (see A customer who never received their first password below).
  • Users β€” Number of user accounts under this tenant
  • Created β€” Account creation date
  • Actions β€” View details (GET /admin/tenants/:id), read/write the credits rate, read a purchase PIN, adjust credits, and delete the tenant
GET /api/v1/admin/tenants          β€” List all tenants
GET /api/v1/admin/tenants/:id      β€” Tenant details

Assigning Plans

The Plan column on the Tenants table is a picker. Open the console's Operator Console β†’ 4 Β· Tenants panel, choose a plan in the row you want to change, and confirm: the change applies immediately and needs no action from the tenant. The row's cell then reads the new plan, and a line above the table confirms what was assigned to whom. A workspace with no plan shows β€” no plan β€” until you give it one.

The picker writes the catalogue plan, not the account's tier id: the two are different ids for the same plan (the server maps them by slug), so an operator never has to know which is which. The same endpoint is available to scripts.

POST /api/v1/admin/plans/assign   β€” Assign plan (fields: plan_id, account_id)

Clearing Test Workspaces

A workspace a test run minted is not a customer, so the list leaves it out. Tick show probe workspaces above the table and those rows come back wearing their probe badge β€” the address that minted them, or the test harness the signup named. Select probes (n) then ticks exactly those rows and Delete selected retires them; the tenancy-canary monitors keep their badge but are never ticked, because a monitor would only re-seed what you deleted. Untick the switch and you are back to customers only.

Why the switch exists: the list has always hidden harness-minted workspaces, and nothing offered a way to look at them, so the rows the hide covered could not be reviewed or cleared from this screen at all.

Finding the workspaces that never got in

A workspace is minted by a signup and its credentials are mailed, but whether the person ever came back was invisible: the list is ordered by creation date, so a workspace nobody has used sits beside one in daily use. The last sign-in column answers it per row, and the only never signed in (n) switch above the table narrows the list to just those rows β€” the number on the switch is how many are on file.

Use it before chasing a customer who says he never received his login: if his workspace appears under that switch, he has never completed a sign-in, so the problem is the message or the address rather than anything he did on the app. If it does not appear, he has signed in and the workspace is in use.

The switch only ever narrows what is drawn, and the bulk delete acts on the rows you tick, so filtering can never widen a destructive action.

A customer who never received their first password

Signing up mints the first password and emails it. When that message never leaves β€” the mail provider refuses it, or the address is a reserved test address the sender deliberately withholds β€” the workspace still exists but nobody has ever read the password. The customer cannot sign in, and forgot password only mails the same address that just failed.

The row says so instead of staying silent: a red no password delivered β€” issue one button appears beside that workspace's address. Press it and the console mints a new password, ends every session the account had, clears the warning and emails the new password through the same credentials email a signup uses β€” so the branding, the template wording and the reserved-address rules behave exactly as they do at signup. The previous password stops working at that moment.

If the new message cannot be sent either, the console does not claim it was: it shows the new password on the screen so you can pass it to the customer yourself. It is shown once β€” the app stores only its hash β€” so hand it over before you leave the screen.

POST /api/v1/admin/tenants/:id/resend-credentials
  β†’ { "credentials_emailed": true,  "withheld": false, "handover_password": null }
  β†’ { "credentials_emailed": false, "withheld": true,  "handover_password": "…12 characters…" }

Impersonating Tenants

Admins can impersonate a tenant for troubleshooting. In the console, the Portfolio Companies table carries a πŸ”‘ Impersonate button per row, and the Support impersonation section has Impersonate user (POST /api/v1/admin/impersonate) and Stop impersonation (POST /api/v1/admin/stop-impersonation).

Security: All impersonation actions are logged in the audit trail with admin identity and timestamp.

3 Plan Management

Payment link (optional). Each plan form in this console carries a Payment link box. Paste the checkout address your payment provider gives you for that price (a Stripe Payment Link, for example) and a buyer who picks that plan is sent straight to it. Leave the box empty when the plan is free, or when you take payment some other way β€” the plan then behaves exactly as it did before. The link is stored with the plan and is never used to charge anyone: it only decides where the buyer is sent.

Two separate surfaces

The console keeps the marketing catalogue and entitlements apart, and so should you:

  • πŸ’³ Plans β€” the subscription rows the marketing site and checkout read. The panel edits only Name, Monthly price and Yearly price. It has no feature fields; whatever a customer is allowed to do is not set here.
  • πŸ₯‡ Plan Tiers β€” the Features & Plans panel. The entitlement model (plan_tiers → tier_features → features), and the only thing every runtime gate reads.

Upgrade requests β€” workspaces that tried to pay

When a workspace presses Upgrade while this installation has no payment provider connected, nothing is charged and the customer is told their request has been recorded for the platform team. The Plans panel now carries an Upgrade requests card listing exactly those attempts: when, which workspace, the plan they asked for, the amount, and why it did not go through ("No payment provider yet" on an installation with no provider).

Nothing was charged on any of those rows. Each open row carries a Give them <plan> button: pressing it seats that workspace on exactly the plan it asked for, credits the affiliate the same way the customer's own plan request does, and flips the row to Switched by our team β€” which is the record that the plan was given by hand while no money moved. The button acts on the plan named in its own row, so a workspace with two open requests keeps the other one open. You can also connect a provider under Payment providers, after which the next attempt charges normally. The card is hidden when there is nothing to show, and a completed purchase never appears here.

GET /api/v1/admin/checkout-requests?limit=50   β€” operator only; tenant tokens get 403

Loyalty plan requests β€” switching one on

A loyalty plan is a different catalogue from the workspace plans above: it is priced per month on its own and it gives the workspace a monthly credit pool for its loyalty programme. When a customer presses Choose <plan> on their dashboard while this installation has no card payment provider, the dashboard files a Loyalty plan request in Support Requests instead of charging them.

Open that request and the panel shows Switch this loyalty plan on with the plans that are on sale. Pick the plan the customer asked for and press the button: the workspace's loyalty plan and its credit pool go live immediately, and the customer's own dashboard shows the plan from then on. The button answers in the server's own words β€” a plan that is no longer on sale is refused by name and nothing is written.

Resolve on its own does not switch a loyalty plan: Resolve moves workspace plans only. Use the switch for a loyalty request.

Plan Tiers β€” reading the screen

  • Left column β€” one card per tier: name, slug, $<price>/mo, an Active/Off badge, and Edit tier (name, slug, monthly/annual price, active, max_campaigns, max_entries_per_month).
  • Right column β€” <Tier> β€” Features with that tier's Campaigns / Entries/month / Annual numbers, then one card per feature category, in the order mechanic, delivery, branding, surface, module, loyalty, limits, billing.
  • One row per registry key β€” a checkbox (tier_features.enabled) with a green ADDED or red REMOVED badge, the bold label, the monospace key, and a number box (tier_features.limit_value).
  • Every registry key is listed on every tier β€” a key the tier has no row for still renders (unticked, REMOVED). Nothing is hidden from you.

Plan Tiers β€” saving

  • Ticking or unticking the checkbox saves immediately and the list reloads: "Changes hit the live runtime gate immediately."
  • The number box saves when you click away from it, and it sends the checkbox state as it is right then. On a key that has no row yet: tick the box first, then type the number β€” typing a number into an unticked row saves enabled = false alongside that number.

limit_value means the same thing in every reader:

ValueMeaning
-1Unlimited
0Not included on this tier
N > 0Cap of N
blankNo number configured (a plain capability flag)

The top tier gets everything (2026-10-02)

The top tier β€” highest price, then highest sort order: Enterprise, $199/mo β€” carries a row for every key in the features registry. It read 38 of 38 on 2026-10-02 (up from 18): the 20 missing keys were seated by migration 20261002_enterprise_top_tier_all_features.sql, which fills gaps only (it never overwrites a grant or a number you set) and gives every limits-category key -1. Free (5) and Pro (17) are untouched. Later the same day the registry itself was flattened from 38 rows to 31 β€” 7 of them were duplicate names for 5 capabilities β€” and every grant the retired rows carried was re-pointed onto the surviving name first, so Enterprise reads 31 of 31 with no capability lost.

When a new feature key is registered later, keep the rule true the same way: open this tab, click the Enterprise card and tick the new key's box. There is no "grant everything" button.

Which keys the runtime actually reads

The panel lists a key whether or not any code reads it, so a full grid is not the same thing as a fully enforced product:

  • all_mechanics and each mechanic_<type> β€” 402 at play time.
  • max_leads, max_tags β€” 402 on the create path.
  • max_campaigns, max_entries_per_month β€” the tier's own columns, set with Edit tier.
  • industry_limit β€” the industry-dashboard cap (402 when the value is 0; a "maximum N industries" refusal when the cap is N).
  • custom_domains β€” the plan verdict on GET /api/v1/admin/plans/:id/domains.
  • credits_monthly, credits_overdraft β€” the allowances returned by GET /api/v1/credits/balance.
  • module_loyalty_program β€” the loyalty plan check (no row means allowed).
Note (2026-10-02, kanban t_c307f7a7): the surface_* / branding_* duplicate rows were flattened β€” this panel now shows ONE name per capability (custom_domains, white_label, tablet_mode, widget_embed, full_page), because one switch per row means a duplicate row was a switch that looked real and moved nothing. Every grant the retired row carried was re-pointed onto the survivor first, so no tier lost a capability (Enterprise 38 → 31 rows, Free 5, Pro 17 unchanged). Still catalogue-only β€” they render and they grant, but no code path reads them yet: delivery_webhook, delivery_direct_api, limit_unlimited_campaigns, stripe_credit_topups, and the four surviving surface keys above. Wiring those is a pricing decision, not an oversight.

Deleting Plans

Click the delete button on any plan. Tenants assigned to a deleted plan are moved to the default free plan. Active tenants are never left unassigned.

4 Portfolio Companies

Managing Sister Companies

The Portfolio section lets admin-level users manage multiple business entities under one account. Each portfolio company can have its own:

  • Separate campaign and survey data
  • Own integration provider keys (fallback chain)
  • Independent analytics and reporting
  • Custom branding and SEO settings

Adding Portfolio Companies

From Admin β†’ My Portfolio, click + Add Company. Enter a name and slug. The company is created as a sub-entity under your account. You can switch between companies from the portfolio view.

GET  /api/v1/portfolio-companies      β€” List companies
POST /api/v1/portfolio-companies      β€” Create company

Cross-Company Copy

Share integration targets, templates, and campaigns across portfolio companies. Data isolated by company ID unless explicitly shared.

5 Integration Provider Keys

Provider Keys Overview

The Integration Hub relies on provider API keys stored in the Provider Keys section. These keys enable third-party service integration for campaign dispatch:

  • Marketing Boost β€” API key for email/SMS dispatch on wins and entries

Key Resolution Chain

When a campaign fires an integration, the system resolves provider keys in this priority order:

  1. Integration Target's own key β€” Key stored directly on the target record
  2. Portfolio company settings β€” Fallback to the portfolio company's provider keys
  3. User-level Provider Keys β€” Global keys stored in the Provider Keys table
Best practice: Store keys at the most specific level. Use user-level keys as a catch-all default.

Managing Keys

Navigate to Integrations β†’ Provider Keys. Keys are encrypted at rest and masked in the UI. You can add, update, or delete keys. Key changes take effect immediately for subsequent integration dispatches. A stored endpoint must be a public destination: a base URL that resolves to a private, loopback or link-local address is refused when it is saved and is never contacted by a dispatch. The platform's own CoreSwift bridge is the one exception β€” it is the preset endpoint those accounts already use.

Chat Funnel AI replies

The Chat Funnel mechanic answers a visitor's typed message with an LLM when the account has connected an LLM provider key (resolved per account, DeepSeek preferred, OpenAI fallback), and otherwise replies with the campaign's scripted chat_flow. Both providers are in the catalogue and are connected from the same Provider Keys screen; their public vendor endpoints are the default, so no base URL is required. A tenant-supplied base URL follows the same public-destination rule as every other provider β€” a private relay needs a platform preset for that provider, which the platform seats, not the tenant.

Payment providers (Stripe / PayPal)

The keys your own checkout and inbound payment webhooks run on live on the Payment Providers screen (admin section) — not on the integration Provider Keys screen above. That one holds the third-party tools this app calls out to; these are the credentials your customers' money comes in through.

  • What to enter — a provider (stripe and paypal are the two with a webhook receiver in this app), the secret key, and the webhook signing secret from that provider's own dashboard. The base URL is optional and only needed for a non-standard endpoint.
  • What is stored — both secrets are encrypted before they are written and never come back in full: the screen shows abc...xyz (the first and last three characters), so you can tell one key from another without the value leaving the server.
  • Test the key before you rely on it — press Test key beside the Save button and the server asks Stripe directly whether the key in the box works, then answers in Stripe's own words; nothing is saved by the test, so a typo cannot overwrite a key that already works. Every saved provider row also carries a Test stored key button. The answer also says whether the key is a live key or a test-mode key, because Stripe accepts a test-mode key and will still never charge anybody — that is the mistake this button exists to catch before a customer finds it for you. A test that cannot run says so by name: no key stored, the stored provider is a different one, or the provider is not one this app can check out on (only stripe today, which is also the only one the buy button sells through).
  • Editing keeps what you do not retype — leave a secret box blank and the stored value is kept; type a new one and it replaces it. Each row reads Active or Inactive, and the inbound webhook lookup reads every active row of that provider — one per workspace — so a provider can be taken out of use without deleting its keys, and taking yours out of use changes nothing for anyone else's deliveries.
  • How an inbound payment event is authenticated — this app has one Stripe address, /api/v1/webhooks/stripe, and every Stripe event is delivered there — a plan purchase and a loyalty-plan purchase alike. Register one Stripe webhook endpoint per Stripe account and point it at that address: Stripe issues a different signing secret for each endpoint you register, while this screen stores one signing secret per workspace, so a second endpoint could never be verified by the key you paste. The address is shared, so the signature on every delivery is checked against every active webhook signing secret and the event is accepted when a workspace's own secret verifies it. Stripe signs t=…,v1=… over the timestamp plus the raw body, and exactly those bytes are verified. The workspace an event acts on comes from the event's own session id, never from the key that matched and never from an account id in the body, so a verified event can only ever move the session it names. An event no active signing secret signed is refused (400) and changes nothing; an install with no signing secret at all is refused with 503 stripe_webhook_secret_not_configured, not a server error.
  • The loyalty-plan checkout — a business subscribing to a loyalty plan is checked out on its own stripe row: the secret key here creates the Checkout session, the signing secret here authenticates the completion delivery at /api/v1/webhooks/stripe — the same address as every other Stripe event, because the separate loyalty address this app used to serve is retired — and the plan and points pool the workspace receives come from this server's own record of that session, not from anything the caller sends. A delivery naming a session this server did not create, or one that was already completed, moves nothing.
  • Deleting removes only this workspace's configuration — the Delete button deletes your own account's row and nothing belonging to any other workspace. Your checkout and the inbound webhook lookup stop using that provider at once, so a delete is the same as taking it out of use and dropping its keys (marking it Inactive takes it out of use but keeps the keys).
  • The dashboard tells you which door is open — the first screen states whether this installation can take money at all. It reads Upgrades are open when a provider row above carries a usable key, Upgrades work on N paid plans when those plans carry their own payment link, and This installation cannot take money yet when neither is true — with the two places to fix it. A workspace that presses Upgrade with no door open is never charged; its ask is filed for you instead.
  • The Payment Providers screen states the same verdict from the server itself — every time you open it, the screen asks whether a press of Upgrade would really reach a payment provider and answers in one line: buyers can pay, buyers cannot pay yet (with the sentence a customer would otherwise only see after pressing), or that the check did not answer — which is never reported as broken, because a check that failed has told you nothing about the till.

6 Webhook Security

Admin Controls

Webhook security settings are configurable per integration target:

  • Allowed Domains β€” Restrict webhook dispatch to approved domains only. Enter comma-separated domains in the integration form. An empty list allows any public domain; private, loopback and link-local addresses are refused for every target.
  • Daily Limit β€” Set max webhook dispatches per day per integration target. Prevents runaway dispatch loops. The window is the UTC day, so the count resets at midnight UTC.
  • Delivery Log β€” Admins can view the delivery log showing each dispatch attempt to an integration target, its status and its error message. A dispatch refused by a security check is never sent, so it appears in the platform log rather than the delivery log.

Enforcement

When a webhook is dispatched, the destination host is resolved and refused if it points at a private, loopback or link-local address, then the target's domain is checked against its allowlist. If it is not allowed, the dispatch is refused before the request is made and the reason is logged. If the daily limit is reached, further dispatches are refused until midnight UTC. Redirects are not followed. The production delivery paths apply the same checks as the Test Webhook panel.

Monitoring

Check the delivery log from the Integration Hub for blocked or failed dispatches. Failed dispatches include the HTTP response code and error message from the target server.

7 Credits & Billing

Plan-Based Credit System

Credits are held per account (accounts.credits_balance). A plan tier can grant a monthly allowance and an overdraft entitlement (tier_features → credits_monthly / credits_overdraft, set per tier on the Plan Tiers tab β€” -1 = unlimited, 0 or no limit assigned = none included); with no limit assigned, the allowance reads 0 (none included). Credits are earned, not burned: purchase verification credits the account at that tenant's own credit rate ($1 = 10 credits by default), and the loyalty win and offer flows move them. No platform action deducts credits today.

Admin Credit Management

Admins can view every account's credit state in one place β€” the Operator Console's Credits (all accounts) panel lists each account with its plan, balance, lifetime used, ZC pool and credit rate. Use Adjust credits to change one account's balance, and the credits-rate call under Loyalty Program Management to change what that tenant earns per dollar. There is no overage policy to configure: an exhausted allowance is neither blocked, warned nor billed.

Free Tier vs Paid

The free tier provides access to basic campaign types and IQS surveys with limited features. Paid plans unlock premium campaign types, integrations, webhook security and analytics export.

GET  /api/v1/credits/balance  β€” View current credit balance
GET  /api/v1/admin/credits    β€” View all tenant credits (admin)

8 βš–οΈ Loyalty Program Management

Credit Rate Management

Admins can view and update credit rates for each tenant. The credit rate determines how many credits a customer earns per $1 spent during purchase verification. Rates can range from 1 (minimum) to 1000 (maximum) credits per dollar, with a default of 10.

To update a tenant's credit rate:

PATCH /api/v1/admin/tenants/:id/credits-rate
{
  "credits_rate": 15
}

The auth guard ensures that a company_admin can only change the credit rate for their own tenant. Agency admins can change rates for any tenant under their portfolio. Super admins have unrestricted access.

Tip: Rate changes take effect immediately for future purchase verifications. Historical transactions are not recalculated.

Purchase PIN Administration

Purchase PINs are auto-generated sequentially (format Z100, Z101, etc.) when a tenant signs up. They are read-only in the tenant's UI β€” tenants can view but not modify their PIN.

Admins can view any tenant's PIN:

GET /api/v1/admin/tenants/:id/purchase-pin

If a PIN reset is required (e.g., compromised PIN), this is a manual database operation performed by a super admin. Every reset is logged in the audit trail with the admin identity, timestamp, old PIN, and new PIN.

Security: PIN resets should be rare. Require written authorization from the tenant's company admin before proceeding. Verify identity through a secondary channel (phone call or verified email).

Offer Oversight

Admins can view all offers across all tenants from the admin panel. This provides visibility into what discounts tenants are offering and helps maintain quality control.

Key monitoring responsibilities:

  • Check for negative caps β€” The cap_dollars field must always be greater than 0. Negative or zero caps can break the discount calculation.
  • Watch for >100% discount β€” The discount_percent field is validated server-side to 1–100, but review extreme values (90%+) for business appropriateness.
  • Deactivate problematic offers β€” If an offer violates policy or appears abusive, set it to Inactive. This prevents it from appearing during purchase verification while preserving the offer record.
Note: The server enforces that discount_percent must be between 1 and 100 and cap_dollars must be greater than 0. These validations prevent many common issues at the API level.

Fraud Monitoring

Admin-level fraud monitoring is essential for maintaining the integrity of the loyalty program. Common patterns to watch for:

  • Same PIN at multiple locations simultaneously β€” A Purchase PIN used at different merchant locations within seconds or minutes may indicate a shared or compromised PIN.
  • Self-referral loops β€” A customer creating multiple accounts and referring themselves to earn bonus credits repeatedly. Watch for referral patterns where multiple accounts share the same device, IP, or contact info.
  • Inflated purchase amounts β€” Unusually high purchase amounts compared to typical transactions at a merchant. This could be an attempt to earn excessive credits.

All admin actions are logged. Every view of a tenant's PIN, every credit rate change, every impersonation, and every offer status change is recorded in the audit trail with admin identity, timestamp, and action details.

Important: If you suspect fraud, gather evidence (screenshots, timestamps, affected account IDs) before taking action. Contact the tenant's company admin to verify legitimate use before locking accounts.

The float rule — the three conditions the guard enforces

IncentiveSwift’s loyalty programme has its own float: the businesses fund the rewards their members earn, and the platform holds a redemption that would leave the float short. The rule is three independent conditions, and a redemption is held if any of them fails. The console shows all three with the numbers that decided each one, and the hold record names the condition that was short.

  • Coverage — the float covers at least float_coverage_pct% of the value of the points members are holding. Default 100. Accounting view: never redeem points nobody funded.
  • Burn — the float holds at least float_burn_months months of trailing-30-day redemptions (measured from point_redemption_log, the same rows the programme actually paid). Default 1. Operational view: a fully covered programme can still run dry when several members redeem at once.
  • Floor — the float never falls below minimum_float, the safety balance. A new programme has near-zero liability and near-zero burn, so the floor is what stops an empty programme paying out.

On breach the programme holds the payment to the business for that redemption: the customer has already been given their reward at the counter, nothing is confiscated, and the held payment waits in the Ledger tab until the float is back above all three conditions. The hold record names the condition that was short.

Where the knobs live

Admin console → Ledger tab → Change the rule. It prompts for the breach behaviour, the floor (safety balance), the coverage % and the burn months. The same three thresholds are readable on Settings (Min Treasury Float / Float Coverage / Float Burn) and through GET /api/v1/admin/treasury/state, GET /api/v1/admin/clearinghouse/config and PUT /api/v1/admin/treasury/rule (fields minimum_float, float_coverage_pct, float_burn_months). A zero or negative coverage/burn is refused with 400 rather than saved and ignored — a programme protecting 0% is not protected at all, and a malformed stored value falls back to the safe default (100% / 1 month).

Best-practice values

  • Coverage 100%. Lower it only when deliberately extending credit to a business, and record the decision.
  • Burn 1 month, raised toward 2–3 for seasonal programmes (holiday/event-driven redemption).
  • Floor: a real number for the smallest business onboarded.
  • Review after every large campaign — coverage self-corrects as points are redeemed; burn does not, because a campaign raises redemptions immediately.

What to tell a business

They fund before launch and top up before growing; a held payment is not a loss (the customer keeps their reward; the business is reimbursed after topping up); and they will always be told which condition is short and by how much. The businesses’ own guide (User Guide → Loyalty Program → “How the programme stays funded”) and the generated rules page (GET /api/v1/treasury/rules) carry the same conditions, written from the settings actually in force.

Tip: The published rules are GENERATED from the rule in force, so a business can never read a rule the software is not applying — change a knob and the sentence they read changes with it.

9 ⏳ Points Expiry

What the Sweep Does

POST /api/v1/admin/treasury/expire-points expires loyalty points that have fallen outside their program's expiry window. It works by recomputing each member's unexpired balance from the point ledgers and setting the balance to that number β€” it does not decrement.

unexpired_balance = GREATEST(0, unexpired_earned - redeemed)

Because the inputs are the (immutable) ledgers, a second run computes the same number, sees the stored balance already matches, and writes nothing. Re-running the sweep is a no-op, so it is safe to call on a schedule β€” or twice by mistake.

The sweep only ever lowers a balance. Only members with a positive balance are considered, and a member whose ledger total is already at or above their stored balance is left untouched.

Running It

From the admin app: open the Operator Console (πŸ› , admin-only) and find the panel 8 · Treasury, clearinghouse & site ops. Click Expire points now. That control calls the endpoint with your session token and prints the raw JSON response inline with an OK / ERROR flag.

POST /api/v1/admin/treasury/expire-points

The endpoint takes no parameters and no request body, so there is nothing to fill in before running it.

Expiry Window (Per Program)

The cutoff is resolved per program, not globally, from loyalty_programs.points_expire_days. A program with no value set falls back to 365 days. Each member is cut off using their own program's window.

To shorten or extend a program's window, set points_expire_days on that program row. Nothing moves until the sweep runs β€” the window only affects the next expiry run, not existing balances.

Which Ledgers Count

DirectionLedger / ColumnTimestamp Used
Earnloyalty_checkins.points_awardedchecked_in_at
Earnloyalty_online_actions.points_earnedcreated_at
Earnpoint_issuance_log.points_issuedcreated_at
Spendpoint_redemption_log.points_redeemedβ€” (all-time total, not windowed)

Redemptions are treated as consuming the oldest points first (FIFO) β€” the points a member still holds are their newest. That is why the full redemption total is subtracted from the unexpired earn total rather than allocated against individual earn rows.

loyalty_activity is deliberately excluded: it mirrors the check-in rows, so counting it would double-count every check-in.

All three earn ledgers matter. Because the balance is set to the recomputed figure, a ledger left out of the sum would not merely go un-expired β€” those points would be deleted. There are exactly three ledgers that credit points_balance.

Reading the Response

FieldMeaning
successtrue on a completed run.
total_points_expiredPoints actually removed across all members this run; 0 when nothing changed.
members_affectedHow many members had a balance changed.
cutoff_dateThe default (365-day) cutoff for this run.
messageHuman-readable summary of the run.
treasury_liability_reductionDollar value removed from outstanding liability (1 point = $0.01).
program_cutoffsPer-program list of { program_id, expire_days, cutoff_date } β€” the window actually applied to that program's members.
audit_idUUID of this run's row in point_expiry_audit. Keep it: it is the handle for auditing and reversing.
default_expire_days365, the fallback window.
skipped_conflictsMembers whose balance moved between planning and writing (for example a concurrent award) and were left untouched this run.
Treasury liability is reduced once, after every member has been processed β€” never inside the per-member loop β€” and it is floored at zero.

Auditing and Reversing a Run

Every run writes exactly one row to point_expiry_audit, and it is written before any balance is changed. The row carries the previous balance of every member the run intended to change, so a run is inspectable and reversible even if it dies halfway through.

ColumnMeaning
idRun id β€” the audit_id returned by the API.
run_at / completed_atWhen the run started, and when it finished reconciling.
statuspending while planned, then applied if any balance changed, or noop if none did.
default_expire_daysFallback window used for the run (365).
members_affected, total_points_expired, treasury_liability_reductionRun totals, matching the API response.
detailsJSONB array, one entry per member. Planned before the write: member_id, program_id, expire_days, cutoff, previous_balance, new_balance, expired_points, unexpired_earned, expired_earned, redeemed. Once the run completes it is rewritten with the outcome per member β€” applied (carrying previous_balance and new_balance) or skipped_conflict.

To reverse a run: read details for the audit_id, set each listed member's points_balance back to its previous_balance, and add that run's treasury_liability_reduction back onto point_treasury.outstanding_liability. Because the sweep only ever sets a balance from the ledgers, the next run simply re-derives the current state.

Before you reverse: check the status first. A noop row changed nothing and needs no reversal.
Concurrent awards are not clobbered. Each member's write is guarded on the balance the plan observed; if a member's balance moved in between, that member is skipped and counted in skipped_conflicts, and the next run picks them up.

10 🧩 Grouped Form Sections

What Sections Are

Campaigns of Mechanic Type Long-Form Qualifier can group their questions into titled sections (for example About you β†’ Your business β†’ Your goals). Sections live in campaigns.config.sections:

[
  { "title": "About you",     "questions": ["full_name", "role"] },
  { "title": "Your business", "questions": ["company", "monthly_revenue"] }
]

This is an ordering-only overlay. It decides the grouping and presentation order of the questions on the public form, and nothing else.

Sections cannot change qualification. Scoring, outcomes, tags, redirects and the outcome webhook all keep reading config.rules exactly as they did before sections existed. Grouping a question differently, or not at all, cannot change who passes.

Your Own Questions on the Entry Form

On the Campaigns screen each row has a Fields button. It lets you add your own questions β€” a label your customer reads, a type (text, email, phone, textarea, select or checkbox), whether it is required, and, for a select or checkbox, the choices as a comma-separated list.

They appear on that campaign's public entry page, under the name and email, in the order you set with the ↑ and ↓ buttons. A required question left blank stops the visitor with one message instead of a half-answered entry.

The answer is stored with the entry that visitor created, keyed by the field's own key, so a campaign's entries and its questions can never drift apart. Deleting a question does not delete the answers already collected.

Where you read the answers: open Entries & Answers in the sidebar, directly under Wins & Redemptions. Pick a campaign and every entry is listed with the participant’s name and address beside the questions you wrote, in the order the form asks them β€” not as a block of raw data. The line above the table says how many entries the campaign has and how many of them answered your questions, so you can see at a glance whether the question is being answered.

A deleted question keeps its answers. Entries & Answers still shows the value a visitor gave under the question’s key after the question itself is removed, so nothing a customer told you is lost by tidying the form.
Cloning a campaign: the Clone button copies the campaign β€” configuration, theme, delivery settings and these questions β€” as a draft with its own fresh tag namespace. Nothing is live until you publish the copy; the two campaigns never share a tag.

Editing Sections

Open a Long-Form Qualifier campaign in the campaign editor. A 🧩 Sections block appears under the Config (JSON) field, annotated presentation order only β€” scoring, outcomes, tags, redirects and the webhook are unchanged.

  • + Add section β€” appends a new section with an editable title and a βœ• remove button.
  • Question checkboxes β€” the list is built from the question keys already present in config.rules. Tick a key to place it in that section, untick to take it out.
  • If rules is still empty the block says so: No question keys in config.rules yet β€” add rules above, then group them here.

campaigns.config is JSONB and is replaced wholesale on save, so the editor merges sections into the config object alongside rules and outcomes. Saving sections never wipes the rules or outcomes.

Guaranteed Behaviour

CaseResult
A key in sections is not a question in rules (a typo)Ignored. No error and no partial render.
A question in rules is named by no sectionStill shown, in a trailing untitled group. A question can never be dropped by grouping.
The same question is listed in two sectionsRendered once, in the first section that names it.
A section's keys are all unknownThat section is omitted rather than rendered as a bare heading.
sections is malformed (not an array)Degrades to the flat rules order.
The campaign defines no sectionsIdentical to previous behaviour: a flat list, and the GET /api/v1/play/<slug> payload contains no form_sections key at all.

Where It Renders

The public form page (https://incentiveswift.com/long-form-qualifier.html?slug=<campaign>) consumes form_sections from GET /api/v1/play/<slug> and prints one heading per non-empty section, with that section's questions beneath it. When the key is absent the page falls back to the flat config.rules order.

11 Tag to free account

This is the switch that makes the affiliate tag system actually create accounts. When an affiliate tags a lead with one of this app's tags β€” for example "IncentiveSwift β€” Free" β€” IncentiveSwift is told to create a free account for that lead, using the same name and email the lead was captured with in FunnelSwift. IncentiveSwift then emails the new user their login details automatically, so the lead can sign in and upgrade here whenever they are ready.

Where the switch is

In this console, in the left sidebar under Provisioning (the tag icon). That screen shows the current state, flips the switch and picks the entry tier for a minted account. The setting is stored as provision_from_tags_enabled.

What happens when it is ON

What happens when it is OFF

The tagged lead is still recorded here as a contact, so nothing is lost, but no account is created and no login email is sent. Use this if you want to review leads before accounts exist for them.

Why it works this way

FunnelSwift is the hub where affiliates and leads live. Tagging is how a lead is routed to the software they are interested in. The account is created here, in the destination app, so the lead signs in at the app they actually want - and upgrades on this app's own plans and pricing.

12 Email branding (per account)

Every transactional email is rendered through the app's one funnel (the account's email_branding document + the tenant_logos row), so the mail a business's users receive can carry that business's own logo and display name. The tenant manages it in the console at Settings → Email — your branding (name, colour, logo upload/preview/remove); the picker checks the same 2,031,616 byte ceiling the server enforces.

The card also shows what your recipients see: the header block the app actually builds (Branding::header_html) — the logo at most 56 px high and 240 px wide, otherwise the name in 20 px, then a 3 px rule in the brand colour — and it follows the fields as you type, so you can judge a colour or name before saving. It sends nothing and saves nothing; a colour that is not a hex value previews as the default #4f46e5, exactly as the mail would paint it.

That block is drawn by the browser, so it can only ever be a drawing of the mail. The button beside it — Show the exact email — asks the server for the real thing: GET /api/v1/settings/branding/preview-body (authenticated, no body) runs the same renderer every send uses — the account's template, the merge fields, the branded header — and returns the message without mailing it. The card shows the subject line and the body in a frame, again following nothing as you type (press the button after saving), so an operator judges the actual bytes a customer would receive: the uploaded logo, the display name, the colour rule. Nothing is mailed, the recipient is always the caller's own address, and a reserved test address changes nothing here because no send is attempted. Use Send me a preview email when the proof you need is an inbox.

Routes: POST|DELETE /api/v1/settings/branding/logo (authenticated, the caller's own account) and the public GET /api/v1/branding/logo/:tenant_id — public by design, because a mail client renders <img src> with no credential. The card's Preview button calls POST /api/v1/settings/branding/preview (authenticated, no body): it mails the caller's own address one sample of the branded mail through the platform mailer, so an account with no mail server of its own still sees the real header. It has no recipient field and cannot be used as a relay, and a caller whose address cannot receive mail is told the preview was withheld instead of being shown a false “sent”. Storage: the text lives in tenant_settings key email_branding; the logo bytes live in tenant_logos (migration zz_is11_tenant_email_branding.sql), one row per account, ON DELETE CASCADE. An account with no branding renders byte-identical mail to before the feature.

13 Email provider (platform-wide)

The mail credential every account's transactional mail goes out with — a new signup's credentials, password resets, purchase confirmations, and the lifecycle mail the queue sends — is stored once for the whole installation (admin_settings.email) and is set in the console at Email Provider (nav: πŸ“§ Email Provider): the provider choice, the from name and address, the provider's endpoint and credentials, then Save provider. Secrets are never shown back in full, and the screen states whether the saved copy is encrypted at rest; leaving a secret box as it is keeps the stored value. An account that has saved its own mail server under Settings sends through that server instead.

Send test email on the same screen sends one real message through the saved provider — leave the box empty to send it to your own signed-in address, or type an address to aim it at an inbox you can check. An address that provably cannot receive mail is withheld: nothing is handed to the provider and the screen says so.

Last transactional email is the answer the log cannot give: whether the last mail actually left. It is written by the app's own send funnel, so it reflects real customer mail (not only your test presses): a green “βœ… Last email accepted by the provider” line with the time, subject and recipient, a red “❌ Last email was NOT delivered — the customer received nothing” line with the provider's own reason, or an explicit withheld line for an address that was never sent to. Routes: GET|PUT /api/v1/admin/email-settings and POST /api/v1/admin/email-settings/test (optional ?to=address).

Recent sends is the history beside that single line: the last 50 messages this installation genuinely attempted to a real address, so a credentials mail that failed at signup stays readable after any later mail succeeds. Addresses that can never receive mail are not listed there and are counted separately instead, because those were never attempts. When a customer says they never got in, each row carries Send reset: it issues a fresh one-hour reset link for that address and sends the app's own password-reset mail, closing the case where “forgot password” cannot help because the customer receives no mail from us at all. The button reports the server's own answer in a sentence — no account on that address, reserved test address so nothing was sent, the reset link is on its way, or the provider's refusal — and pressing it twice retires the earlier link so only one is ever live. Route: POST /api/v1/admin/email-log/send-reset with {"email":"<address>"}.

Operator alert inbox is where the app emails you the moment a customer asks for a paid plan — whether they pressed Upgrade on their dashboard or picked a tier on the pricing page before signing up. One message per new request, carrying the workspace, the tier, who asked, and a link to the console, so a request does not wait for somebody to happen to open the Plan requests list. It leaves through the system provider above, never through the customer's own mail server. Leave the box empty and no alert is sent — the request still appears in Plans → Plan requests. An address that is not an address is refused with a sentence instead of being saved, so a typo cannot silently swallow every alert.

14 Your own profile picture

Every account can carry a picture, set from the console itself: open the account menu at the top right, choose Profile, and use Upload picture in the profile card. The picture then appears wherever the signed-in user is shown. Remove picture deletes it and the card falls back to the account's initials.

Accepted are PNG, JPEG, GIF and WebP up to 2 MB. The type is read from the file's own bytes rather than from its name, so renaming a file cannot get it past the check, and an oversized image is refused with the limit in the message instead of being stored cut in half. Uploading again simply replaces the previous picture.

Routes: POST|DELETE /api/v1/auth/avatar and GET /api/v1/auth/avatar/<account id>. The profile answer carries avatar_url with a version marker, so a replaced picture shows immediately instead of being served from the browser's cache.

The same Profile screen edits Name (required), Username and Company, and changes the password. Username is optional: it is a short name the console can show in place of the full name, and clearing the box and pressing Save removes it again. What the form shows is read back from the server each time the screen is opened, so it never displays a stale copy.

Below the password card is Signed-in devices. If you think someone else is signed in to your account — an old phone you sold, a shared computer, a password somebody saw — press Sign out every other device. Every other session is ended at its next request and the tab you pressed it from stays signed in, so you do not have to sign yourself back in. You do not need to know or change your password to do this. Route: POST /api/v1/auth/logout-all; it answers a fresh token for the pressing device.

15 πŸ“Š Campaign analytics

What the Analytics panel shows

Open Analytics in the console to see the account's own totals: active and total campaigns, total entries, total and average wins, the win rate, redemptions, loyalty members and points issued. Every number is read from your own account's rows β€” nothing is estimated or filled in with a placeholder.

Opening one campaign's numbers

Below the totals is a table of your campaigns. Click the campaign's name to open its own detail card, which answers β€œwhich campaign is working”:

  • Entries β€” the total for that campaign alone, not the account.
  • Redemptions and redemption rate β€” how many wins were actually claimed, and what share of entries that is.
  • Average score β€” the mean entry score for that campaign.
  • Loyalty members β€” how many members that campaign brought in.
  • Top sources β€” where the entries came from, with counts and shares.
  • Prizes won β€” how often each prize was actually won.
  • Loyalty bridge β€” whether automatic enrolment is on and how many points a play awards.

Close dismisses the card. A campaign with no data yet says so (β€œno entries yet”, β€œno wins yet”) instead of showing a made-up zero.

If the detail read fails, the card shows the reason the route gave instead of an empty panel β€” so a number that is missing always has a stated cause.

Routes: GET /api/v1/analytics/overview, GET /api/v1/analytics/campaigns and GET /api/v1/analytics/campaigns/<slug>.

16 🧭 The account modules

Beyond campaigns and loyalty, every workspace carries the same set of working screens. Each one lists real rows for your own account; none of them is a preview or a mock-up.

🎁 Prize Pools

The prizes a campaign can hand out, and how likely each one is. The table lists every prize with its weight (its share of the draw), the resulting chance, its type, the stock left and its colour. Change a weight to change the odds β€” the chance column follows from the weights, so the numbers you see are the odds you are actually offering.

πŸ† Wins & Redemptions

Every prize that has been won, in one list: when it was won, the winner, the prize and its status. This is where a win stops being a number and becomes something to hand over.

πŸ“ Entries & Answers

The raw record of play: when an entry was made, the participant, the result of the play, and its delivery state. Use it to check a disputed entry β€” the row is the source of truth, not the summary counts.

πŸ‘₯ Contacts

Everyone your business has captured or imported β€” one row per person, and the same person can belong to more than one business. Each row carries their name, email, phone, the business they belong to, their tags, how many entries they have made, when they were last seen, and their history.

Three CSV exports sit on this screen β€” Contacts (every person captured or imported), Campaigns (one row per campaign with its status, total entries, unique contacts, wins and win rate) and Entries (every participation record, so the ledger can be reconciled). Each one downloads under the server's own file name, and a report that cannot be produced says why instead of saving an empty file.

🎫 Support Tickets

Requests raised from any workspace or from the operator side: subject, workspace, status, priority, category and when it was updated. + New Ticket opens one on a customer's behalf.

Feedback is the chip beside Plan requests. It holds the notes a customer sends from the Feedback button inside their own dashboard: an idea, a problem or a complaint, carrying the screen it came from and the workspace that sent it. They are ordinary tickets, so you answer them the same way β€” the written answer reaches the customer by email.

⭐ Reviews & Ratings

What customers said, with the rating, the reviewer, the title, its status and the date. Approve publishes a review and Reject holds it back, so nothing appears on a public page until someone here says so. Delete removes it outright.

πŸ“… Calendar

Events scheduled for the account: create one, give it a date, and it appears in the list so the team can see what is coming.

πŸ“š Knowledge Base

Help articles, grouped by group so a set of related answers stays together. + New article writes one; the title and group are what a reader browses by.

πŸ“ž Call Logs

A record of conversations: when, the direction, who it was with, the outcome, how many minutes it ran and any notes. + Log a call adds one after the fact, so a phone conversation leaves a trail like every other contact.

🀝 Deals

Agreed arrangements per business: the business, the type, the offer behind it, the points involved, how long it is valid and whether it has been redeemed. + New deal records one.

🎟 Offers

The offers themselves: name, discount, any cap, and whether it is active. An offer can be edited, then Deactivated to stop it being handed out and Reactivated later β€” deactivating keeps the history instead of deleting it.

🎟 Promo Codes

Codes a customer can type on a campaign to earn that campaign's points. It works one campaign at a time: pick the campaign at the top of the screen, then fill in the code, how many points it is worth, a cap on how many times it can be used (blank means unlimited) and an expiry date (blank means it never expires). Create code files the row, and the table below lists every code for that campaign with its points, uses so far, expiry and status β€” so a promotion that has been scrapped can be read rather than guessed at. A campaign that has no codes yet says so plainly.

⭐ Loyalty Programs

The point programme itself β€” the thing the QR code at your counter points at. + Add Program creates one, and the table shows each programme's currency, points per check-in, whether streaks are on, whether it is active, and its check-in link. A programme with streaks on really pays them: a member who checks in on consecutive days earns the streak bonus once for every complete run of the day count you set β€” with a 25-point bonus and 7 days that is 25 extra points on day 7, then again on day 14, 21 and so on while the run continues β€” and the bonus comes out of the same monthly points credit as the check-in itself, so it can never overspend it. A run that lapses starts again from one. Members β†’ Details shows each customer's current run. That link is what the counter QR encodes: scanning it opens the customer check-in page and awards points to that programme, and it works as soon as the programme is active. Linking a programme to a campaign (from the campaign editor) is optional β€” it adds that campaign's milestones and delivery to the same points. The tabs across the top (Programs, Members, Tiers, Rewards, Pledges, Referrals, Credits, Ledger, Settings) are the rest of the programme's own records: who has joined, what they have earned, and the ledger behind every point. Members β†’ Details opens one customer's card: their balance, streak and check-in history, and under that Rewards earned β€” every reward tier that customer has unlocked, its point value, whether it is still awaiting your approval, and the day it was awarded. A member who has not reached a tier yet says so instead of showing an empty table.

πŸ”— Integration Center

Where a workspace connects the outside tools it wants told about its leads and rewards. + Add Integration creates a webhook target and the list under it holds every target on the account. Targets live here, at account level, and a campaign picks one in its own editor β€” removing a target from a campaign disconnects that campaign only, and the target stays here for the next one. A campaign editor with nothing to pick says there are no webhook targets on the account yet, so this is the screen to visit first.

🌐 Site Config (operators only)

The operator's control over the public face of this installation: SEO and social metadata (page title, meta description, keywords, canonical URL, the Open Graph title/description/image and the favicon), analytics (Google Analytics and Tag Manager ids, plus head and body scripts), the homepage hero (logo text, headline, subheadline and both buttons), and the legal pages β€” terms, privacy and refund policy β€” which render at /terms.html, /privacy.html and /refunds.html. The last box carries the Schema.org JSON for structured data. One Save Site Configuration applies all of it.

πŸ’³ Plan & Pricing

What the workspace is really on, and what the other plans cost. The top card is your current plan: its name, the monthly price, the yearly price where annual billing is offered, the limits it carries and the features it includes. Below it every other plan is listed in a table with its monthly price, its yearly price and the saving against twelve months, what it includes, and a Choose button. The Monthly / Yearly switch above the table decides the term the button asks for: on the yearly term the press asks for a full year, and a plan the catalogue carries no yearly price for is not offered at all rather than quietly sold a month. Choose does the upgrade in one press β€” a connected payment provider opens a secure checkout, and when this installation has no provider connected the same press files the upgrade request instead, so the ask is never lost. The Plan Tiers link under your current plan is where features are added to or removed from a tier; a change there takes effect at the runtime gate immediately.

πŸ“§ Email Templates (operators only)

The wording of every email this platform can send: account mail, campaign lifecycle and the per-mechanic winner messages. Each row shows the template, its key (the name the code uses, for example application_received), its subject line and whether it is still the platform default. A platform default is inherited by every account until that account saves its own copy β€” so editing the default here changes what every workspace that has never customised it will send. Edit opens the subject and the body, and the body can be previewed before it is saved.

17 Plan requests (the operator queue)

No card is taken on this install until a payment provider is connected, so the customer's own buying path is a request. Their Plan & billing screen files the plan they want as a ticket the moment they press the button — one press, one request — and the ticket is stamped source = plan_request along with the plan it names. A move to a cheaper plan arrives the same way, so the queue is not only upgrades.

Where it lands

Admin console → Support Tickets. Every request carries a Plan request badge beside the workspace that filed it, and the Plan requests button above the list filters the queue down to exactly those rows, so a request cannot hide among ordinary support questions. A request for a retired tier is refused at filing time (the billing screen lists only plans on sale), and a second request for the plan already open is folded into that one instead of filling the queue with duplicates.

Working one

Open the row to read what the customer asked for and reply — the reply emails the customer, so they are not left guessing. Switch the workspace by hand on the Plans screen (see Plan Management above), then set the ticket to resolved. The customer's own Plan & billing screen reads that state back, which is what makes its "our team will confirm here" sentence true rather than a promise.

Who sees what

A platform role (admin / super_admin) reads and works the whole install's queue. A customer stays scoped to their own workspace's tickets, and another workspace's request answers 404 rather than 403, so a refusal never confirms that the row exists.

IncentiveSwift β€” Admin Guide v2.1

Campaign & Survey Platform Administration