GET /api/public/ticket-vouchers?key=&email= returns 0/1/2 free tickets based on donations on/after VOUCHER_SINCE (default 2025-09-04): >= $400 -> 1, >= $1000 -> 2. Same secret/CORS/rate-limit as donor-eligibility; returns only the count. Cutoff and thresholds are env-configurable. Documented both lookup APIs (discount + vouchers) in docs/fluentforms-donor-discount.md with form snippets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.1 KiB
FluentForms → donor lookup APIs
FluentForms has no native way to query an external database from a field. These wire it up with a small Custom JS block that calls a secret-gated endpoint on the ticketing backend. Two endpoints are available (same key, CORS, and rate limit):
| Endpoint | Purpose |
|---|---|
GET /api/public/donor-eligibility |
Is this email a donor/member? → unlock a discount |
GET /api/public/ticket-vouchers |
How many free tickets has this donor earned? → 0 / 1 / 2 |
Both require ?key=<PUBLIC_LOOKUP_SECRET>, are rate-limited (30/min/IP), and
CORS-restricted to PUBLIC_LOOKUP_ORIGIN (default
https://tickets.beartariacampgrounds.com). Neither returns names or dollar
amounts. The secret is visible in page source, so treat it as deterrence, not
security; rotate it by changing PUBLIC_LOOKUP_SECRET and redeploying.
Donor discount lookup
Unlocks a discount when the entered email belongs to a donor/member.
Endpoint
GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=<email>
key= the value ofPUBLIC_LOOKUP_SECRET(set in the backend.env).- Returns minimal JSON — never names or dollar amounts:
{"eligible": true, "tier": "member"}{"eligible": true, "tier": "donor"}{"eligible": false, "tier": null}
- Rate-limited (30/min/IP) and CORS-restricted to
PUBLIC_LOOKUP_ORIGIN(defaulthttps://tickets.beartariacampgrounds.com).
The secret is visible in page source, so treat this as deterrence, not security. It only gates a discount and reveals a yes/no + tier, so the blast radius is small. Rotate the secret by changing
PUBLIC_LOOKUP_SECRETand redeploying.
Form setup (conditional pricing)
The idea: a hidden field donor_tier holds regular / donor / member.
The JS sets it from the email lookup, and your payment options are shown/hidden
by FluentForms conditional logic based on its value.
- Hidden field. Add a Hidden Field, name it exactly
donor_tier, default valueregular. - Email field. Note its name (default
email). - Payment options. Set up two payment items (or two options of a
multiple-choice payment field) — a regular price and a discounted price — and
give each conditional logic:
- Regular price: show when
donor_tierisregular - Donor price: show when
donor_tierisdonorORdonor_tierismember(add both rules with "match any").
- Regular price: show when
- Custom HTML. Add a Custom HTML element and paste the snippet below,
setting
KEYto yourPUBLIC_LOOKUP_SECRET(andEMAIL_SELECTORif your email field isn't namedemail).
FluentForms is Vue-driven, so a plain
input.value = …won't update its model and conditional logic won't fire. The snippet uses the native value setter + dispatchesinput/change, which is the reliable way to make FF notice a programmatic change. Test on your form; if conditional logic still doesn't react, tell me your FF version and I'll adapt.
Snippet
<div id="donor-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
<script>
(function () {
var API = "https://scan.beartariacampgrounds.com/api/public/donor-eligibility";
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
var EMAIL_SELECTOR = 'input[name="email"]'; // adjust if needed
var TIER_SELECTOR = 'input[name="donor_tier"]'; // the hidden field
var statusEl = document.getElementById("donor-status");
var lastChecked = "";
// Set a framework-bound input's value so Vue/React actually notice it.
function setNativeValue(el, value) {
var proto = Object.getPrototypeOf(el);
var protoSetter = Object.getOwnPropertyDescriptor(proto, "value");
var ownSetter = Object.getOwnPropertyDescriptor(el, "value");
if (ownSetter && protoSetter && ownSetter.set !== protoSetter.set) {
protoSetter.set.call(el, value);
} else if (protoSetter) {
protoSetter.set.call(el, value);
} else {
el.value = value;
}
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
function setTier(tier) {
var el = document.querySelector(TIER_SELECTOR);
if (el) setNativeValue(el, tier); // "regular" | "donor" | "member"
}
function check(email) {
if (!email || email === lastChecked) return;
lastChecked = email;
statusEl.textContent = "Checking donor status…";
statusEl.style.color = "#888";
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.eligible) {
setTier(d.tier); // "member" or "donor"
statusEl.textContent = (d.tier === "member" ? "🐻 Member" : "⭐ Donor") + " pricing unlocked!";
statusEl.style.color = "#1b7f3b";
} else {
setTier("regular");
statusEl.textContent = "";
}
})
.catch(function () { setTier("regular"); statusEl.textContent = ""; });
}
function bind() {
var el = document.querySelector(EMAIL_SELECTOR);
if (!el) { return setTimeout(bind, 500); } // form may render late
setTier("regular"); // start at regular price
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
}
bind();
})();
</script>
Test
curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=<a-real-donor-email>"
# donor/member -> {"eligible":true,"tier":"member"}
# anyone else -> {"eligible":false,"tier":null}
If member and donor get the same discounted price, simplify: set the donor
price to show when donor_tier is not regular, and you can ignore the
member/donor distinction.
Ticket-voucher entitlement
Returns how many free tickets a donor has earned from their giving, based on
donations on/after a cutoff date (default 2025-09-04 — "9/4 last year").
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
-> {"vouchers": 0} | {"vouchers": 1} | {"vouchers": 2}
Rules (donations summed on/after the cutoff):
| Total since cutoff | Vouchers |
|---|---|
| ≥ $1000 | 2 |
| ≥ $400 | 1 |
| otherwise | 0 |
Config (backend .env):
| Var | Default | Meaning |
|---|---|---|
VOUCHER_SINCE |
2025-09-04 |
Only donations on/after this date count. Bump each year. |
VOUCHER_TIER1_MIN |
400 |
Dollar total for 1 voucher |
VOUCHER_TIER2_MIN |
1000 |
Dollar total for 2 vouchers |
The count is computed from the dated transaction tables (online + offline) — the master-list rollups have no dates. Only
Paidtransactions count.
Form snippet
Displays the voucher count and (optionally) sets a hidden field / caps a
quantity. Same pattern as the discount lookup — paste into a Custom HTML element
and set KEY.
<div id="voucher-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
<script>
(function () {
var API = "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers";
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
var EMAIL_SELECTOR = 'input[name="email"]';
var statusEl = document.getElementById("voucher-status");
var last = "";
function show(n) {
if (n > 0) {
statusEl.textContent = "🎟️ You've earned " + n + " free ticket" + (n > 1 ? "s" : "") + "!";
statusEl.style.color = "#1b7f3b";
} else {
statusEl.textContent = "";
}
// Optional: write n into a hidden field named "free_tickets" for conditional
// logic / to cap a quantity. (Uses the native setter so FF's Vue model sees it.)
var el = document.querySelector('input[name="free_tickets"]');
if (el) {
var proto = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value");
(proto && proto.set ? proto.set : function (v) { el.value = v; }).call(el, String(n));
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
}
function check(email) {
if (!email || email === last) return;
last = email;
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
.then(function (r) { return r.json(); })
.then(function (d) { show(d && d.vouchers ? d.vouchers : 0); })
.catch(function () { show(0); });
}
function bind() {
var el = document.querySelector(EMAIL_SELECTOR);
if (!el) { return setTimeout(bind, 500); }
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
}
bind();
})();
</script>
Test
curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
# -> {"vouchers":2} (>= $1000 since the cutoff)
Heads-up on the cutoff: with
VOUCHER_SINCE=2025-09-04, everyone currently returns0because the donation data in NocoDB ends 2025-05-22 — there are no transactions after the cutoff yet. AdjustVOUCHER_SINCE(or wait for new donations to sync) so the window matches real giving.