This guide covers administrative configuration β managing tenants, plans, portfolio companies, integration provider keys, webhook security, and cross-app sync.
The Admin section appears in the sidebar for users with the agency_admin role. It expands as an accordion section showing:
Super admins have full access to all tenants and portfolio companies. They can:
The Tenants page lists all registered accounts in a searchable table. Each row shows:
GET /admin/tenants/:id), read/write the credits rate, read a purchase PIN, adjust credits, and delete the tenantGET /api/v1/admin/tenants β List all tenants GET /api/v1/admin/tenants/:id β Tenant details
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)
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.
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.
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β¦" }
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).
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.
The console keeps the marketing catalogue and entitlements apart, and so should you:
plan_tiers → tier_features → features), and the only thing every runtime gate reads.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
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.
$<price>/mo, an Active/Off badge, and Edit tier (name, slug, monthly/annual price, active, max_campaigns, max_entries_per_month).<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.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).REMOVED). Nothing is hidden from you.enabled = false alongside that number.limit_value means the same thing in every reader:
| Value | Meaning |
|---|---|
-1 | Unlimited |
0 | Not included on this tier |
N > 0 | Cap of N |
| blank | No number configured (a plain capability flag) |
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.
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).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.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.
The Portfolio section lets admin-level users manage multiple business entities under one account. Each portfolio company can have its own:
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
Share integration targets, templates, and campaigns across portfolio companies. Data isolated by company ID unless explicitly shared.
The Integration Hub relies on provider API keys stored in the Provider Keys section. These keys enable third-party service integration for campaign dispatch:
When a campaign fires an integration, the system resolves provider keys in this priority order:
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.
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.
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.
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.abc...xyz (the first and last three characters), so you can tell one key from another without the value leaving the server.stripe today, which is also the only one the buy button sells through)./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.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.Webhook security settings are configurable per integration target:
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.
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.
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.
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.
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)
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.
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.
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:
cap_dollars field must always be greater than 0. Negative or zero caps can break the discount calculation.discount_percent field is validated server-side to 1β100, but review extreme values (90%+) for business appropriateness.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.Admin-level fraud monitoring is essential for maintaining the integrity of the loyalty program. Common patterns to watch for:
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.
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.
float_coverage_pct% of the value of the points members are holding. Default 100. Accounting view: never redeem points nobody funded.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.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.
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).
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.
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.
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.
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.
| Direction | Ledger / Column | Timestamp Used |
|---|---|---|
| Earn | loyalty_checkins.points_awarded | checked_in_at |
| Earn | loyalty_online_actions.points_earned | created_at |
| Earn | point_issuance_log.points_issued | created_at |
| Spend | point_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.
points_balance.| Field | Meaning |
|---|---|
success | true on a completed run. |
total_points_expired | Points actually removed across all members this run; 0 when nothing changed. |
members_affected | How many members had a balance changed. |
cutoff_date | The default (365-day) cutoff for this run. |
message | Human-readable summary of the run. |
treasury_liability_reduction | Dollar value removed from outstanding liability (1 point = $0.01). |
program_cutoffs | Per-program list of { program_id, expire_days, cutoff_date } β the window actually applied to that program's members. |
audit_id | UUID of this run's row in point_expiry_audit. Keep it: it is the handle for auditing and reversing. |
default_expire_days | 365, the fallback window. |
skipped_conflicts | Members whose balance moved between planning and writing (for example a concurrent award) and were left untouched this 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.
| Column | Meaning |
|---|---|
id | Run id β the audit_id returned by the API. |
run_at / completed_at | When the run started, and when it finished reconciling. |
status | pending while planned, then applied if any balance changed, or noop if none did. |
default_expire_days | Fallback window used for the run (365). |
members_affected, total_points_expired, treasury_liability_reduction | Run totals, matching the API response. |
details | JSONB 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.
status first. A noop row changed nothing and needs no reversal.skipped_conflicts, and the next run picks them up.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.
config.rules exactly as they did before sections existed. Grouping a question differently, or not at all, cannot change who passes.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.
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.
config.rules. Tick a key to place it in that section, untick to take it out.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.
| Case | Result |
|---|---|
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 section | Still shown, in a trailing untitled group. A question can never be dropped by grouping. |
| The same question is listed in two sections | Rendered once, in the first section that names it. |
| A section's keys are all unknown | That 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 sections | Identical to previous behaviour: a flat list, and the GET /api/v1/play/<slug> payload contains no form_sections key at all. |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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β:
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>.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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