Skip to content

Vouchers (redeemable codes) API

A voucher is an admin-managed redeemable code. Redeeming one grants the tenant a one-time spend credit ("try it with real usage on us") — a bonus on the tenant's effective spend cap for its first billing period. Two code types:

  • unique — single redemption (max_redemptions forced to 1); invite / referral style.
  • generic — many redemptions, capped by max_redemptions; campaign style.

Grant mechanism (why the credit is durable but one-time)

The credit is stored on the tenant as bonus_credit_usd with a bonus_credit_expires_at (unix seconds, snapshotted to the tenant's first-period end at grant). The effective spend cap every consumer reads is budget_usd + (now < bonus_credit_expires_at ? bonus_credit_usd : 0) — one shared formula for enforcement (quota / managed-model gate) and the allowance displays, so the enforced cap and the shown cap never drift. This is deliberately not posted to the spend_ledger meter (which is deleted on the initial subscription_create invoice — it would wipe the credit at signup) nor added to budget_usd (which a tier change overwrites). After the first-period boundary the bonus is dropped permanently — not a recurring monthly re-grant.

Redemption is atomic and fail-closed: it locks the voucher row (FOR UPDATE), re-checks eligibility under the lock, bumps a conditional counter that can never exceed max_redemptions, dedups per account (a canonicalised email — lowercased with any +tag stripped — may redeem a given code at most once), and posts the credit — all in one transaction. A concurrent double-redeem can neither double-grant nor exceed the cap. Redemption runs after the paid tenant is committed, best-effort: a bad / exhausted / contended code never blocks or delays the paid signup. The outcome then reaches the customer in the welcome email, which is the only surface that can carry it (the sign-up form's check ran before payment, and the grant happens after): a redeemed code is confirmed with its amount and the date the credit expires, and a code that turned out not to be redeemable is reported as such. An errored redemption is the exception — a failed commit may still have landed server-side, so the email says nothing about the voucher and an operator alert (voucher_redeem_error) is raised for manual reconciliation instead of risking a false "no credit" to a customer who may have one.

When a code is redeemed — two moments, one implementation

The sign-up form offers the voucher field on the paid (pay-first) funnel only — AGF-2751 R3 made registration email-only, so the no-card free trial no longer collects a voucher at sign-up. The code is redeemed at the moment the tenant starts paying:

Funnel Redeemed Bounded to
Pay-first at provisioning, immediately after the paid tenant is committed the tenant's first billing period
Free trial no self-serve voucher entry (AGF-2751 R3) — the deferred trial→paid redemption below is retained as the backend mechanism but is not reachable from the self-serve trial the first paid period

A trial start consumes no campaign slot. Every counter bump, the single-use decrement and the per-account dedup row happen inside the one atomic redemption, which a trial never reaches. This is deliberate: a trial already carries its own credit, so redeeming there would turn any valuable code into a faucet — sign up, take the credit, never convert, repeat. N trials may hold the same single-use code; whichever converts first redeems it, the rest are told it could not be redeemed.

Between trial start and conversion the code is held on the tenant as pending_voucher_code, together with pending_voucher_email — the OTP-verified sign-up address, frozen at trial start, which is the per-account dedup key when the redemption finally runs. The address is frozen rather than re-read from the tenant's current owner because the owner's email is tenant-mutable: with a mutable key an owner could set it to someone else's address just before converting and, through the per-account uniqueness rule, permanently burn that address's eligibility for the campaign. Freezing it also keeps the redemption record — which is the campaign's measurement — attributed to the account that actually signed up.

The deferred redemption is claimed atomically (compare-and-set on pending_voucher_code) in the same transaction as the grant, so a Stripe redelivery, a concurrent delivery, or a retry after a crash between the conversion and the grant all converge on exactly one redemption. A code still pending after a conversion means the grant has not happened yet and the next delivery completes it.

Nothing reserves the code in the meantime. Between trial start and conversion it can be deactivated, expire, or be exhausted by other redeemers, so the trial funnel's copy is deliberately conditional ("applied when you subscribe, if still redeemable then"). An abandoned trial keeps its pending code indefinitely — a trial tenant has no subscription row and is therefore never purged by the billing reaper — so the code stays redeemable on a much later conversion, bounded only by the voucher's own expires_at.

The customer is told the outcome on both funnels: the pay-first welcome email, the trial-start email (which states that the code is on file and applied at subscription), and the trial-conversion email (which reports the redemption exactly as the welcome email does). An errored redemption is the one case that says nothing and pages an operator instead — see above.

redemptions_used is a monotonic lifetime counter (measurement + cap guard); it is never decremented, and a GDPR tenant purge deletes that tenant's redemption rows but leaves the counter (the grant still happened).

Public checker — POST /admin/auth/signup/voucher

Public, advisory, display-only. The signup form calls it to show an inline "credit up to $X" / reason hint. It is not an authorization boundary — redemption re-validates authoritatively under lock at provisioning, and the client never decides the credit amount (it is read from the DB row). 404s when self-serve is disabled (like every signup route).

Accepted body: { "code": string, "mode"?: "paid"|"trial" }. The code is normalized to trimmed-uppercase and must match ^[A-Z0-9-]{4,64}$ before any DB lookup.

mode says when the code would actually be redeemed, because that differs by funnel (see above). "trial" evaluates eligibility at the end of the trial window instead of now, so a code that is live today but expires before the customer could subscribe is reported as expires_before_redemption rather than shown as valid. Any value other than the exact string "trial" is treated as the pay-first funnel. mode is display-only and untrusted: it can only make this advisory answer stricter — a later horizon never turns an invalid code valid — and redemption re-validates authoritatively under lock regardless.

Response (200): { "valid": boolean, "credit_usd"?: number, "reason"?: "invalid"|"inactive"|"expired"|"exhausted"|"already_redeemed"|"expires_before_redemption" }. Because the checker is code-only (no email, anti-enumeration), a valid:true means "eligible on this code", not a promise — the SPA copy says "up to $X if eligible".

expires_before_redemption is reachable only with mode: "trial": the code is valid now but expires before the deferred redemption could happen. It is a distinct value from expired on purpose — telling a customer that a still-valid code has expired would be false in the present tense.

Rejected (400): a malformed / wrong-charset / too-short (<4) / too-long (>64) / non-string code. The body is { "error": "malformed voucher code", "code": "invalid_voucher" } — the code field is a stable consumer contract, not just prose: the sign-up form keys its localised "this code doesn't look right" message off it, so it must not be renamed or dropped without changing that consumer. Rejected (429): more than 15 checks/hour per IP or a coarse global ceiling (anti distributed brute-force); the body is { "error": "too many requests, try again later" }. Rejected (404): self-serve disabled.

The shape check runs before the rate limiter, deliberately: a malformed code is rejected without touching the limiter budget (so a customer fixing a mangled code cannot lock themselves out), and a 429 therefore always implies a well-formed code — which is what lets the sign-up form say "we could not check this code right now; it is checked again when your workspace is set up" rather than casting doubt on the code itself.

Because the checker is advisory, the consuming client must fail closed on the response as well: the sign-up form treats valid, credit_usd and reason as untrusted, renders a hint only for a strict valid:true with a finite positive credit_usd (never "$0" for a missing amount) or a strict valid:false, allow-lists reason against the values above before using it to select a message, and stays silent on anything else. A non-2xx other than 400 invalid_voucher / 429 is also silent — the code still rides along and is re-validated authoritatively at redemption.

The paid checkout step (signup/checkout) accepts an optional voucher_code (same shape). AGF-2751 R3: the no-card trial collects no voucher (registration is email-only; signup/trial takes no body), so the paid funnel is the only self-serve voucher entry. A malformed value is dropped (the checkout still proceeds); a well-formed code is stored on the signup intent and redeemed authoritatively post-provision. See Authentication → Self-serve sign-up.

Admin management — /admin/v1/vouchers

Base URL: https://<your-gateway-host>/admin/v1. Every route is platform-admin only (vouchers are global campaign resources, not tenant-scoped) — a non-admin session is rejected 403.

Method Path Description
GET /admin/v1/vouchers List all vouchers (newest first), each with redemptions_used / max_redemptions for measurement.
GET /admin/v1/vouchers/{id}/redemptions Per-code redemption rows (top-of-funnel measurement).
POST /admin/v1/vouchers Create a voucher.
PATCH /admin/v1/vouchers/{id} Deactivate / reactivate ({ "active": boolean }).

Each row of GET /admin/v1/vouchers also carries pending_claims: the number of trial tenants currently holding this code whose redemption is still deferred to conversion. It is the campaign's outstanding liability — a code reading "3 / 50 redeemed" may have another 900 trials able to claim it — and is counted live from the tenant table rather than stored, so it cannot drift. A client that does not receive the field should treat it as unknown, not as zero.

GET /admin/v1/vouchers/{id}/redemptions returns the per-code redemption rows (id, voucher_id, account_email, tenant_id, credit_usd, redeemed_at) that the admin console's redemptions view renders. account_email is personal data on a platform-admin-only surface, gated by the same VOUCHERS_MANAGE permission as the rest of this API.

POST /admin/v1/vouchers

Accepted body: { "code": string, "type": "unique"|"generic", "credit_usd": number, "max_redemptions"?: integer, "expires_at"?: integer|null }.

  • code — normalized to trimmed-uppercase ^[A-Z0-9-]{4,64}$.
  • type — exactly "unique" or "generic".
  • credit_usd — a finite number in (0, 10000] (rejects NaN / Infinity / string / null: a non-finite cap would disable the spend hard-stop).
  • max_redemptions — integer in [1, 1000000]; ignored for type:"unique" (forced to 1).
  • expires_at — optional unix-seconds integer in the future, or null / omitted for never.
  • created_by is taken from the authenticated admin session, never the request body.

Response (201): { "id": string }.

Rejected (400 / 409): every validation rejection carries a stable machine-readable code next to the prose error — the sibling top-level convention in Error codes. The code is the consumer contract; the error string is the operator/log text and must not be parsed or shown to a user (the admin console keys its own localised, per-field message off the code).

code status meaning field
code_format 400 code is not 4-64 chars of A-Z 0-9 - after trim + uppercase code
type_invalid 400 type is not unique or generic type
credit_range 400 credit_usd is non-finite, <= 0, or > 10000 credit_usd
max_redemptions_range 400 max_redemptions is missing, fractional, or outside [1, 1000000] (for type:"generic") max_redemptions
expires_not_integer 400 expires_at is not a unix-seconds integer expires_at
expires_past 400 expires_at is not in the future expires_at
duplicate_code 409 a voucher with this code already exists code

A malformed request body (not JSON, or not an object) is rejected 400 without a code — there is nothing field-specific to say. A 500 likewise carries no code and a generic body: an infrastructure failure is not the caller's input, and the driver detail is logged server-side rather than returned.

Rejected (403): not a platform admin.

The admin console additionally refuses an expiry of today before it sends anything: such a voucher stops working at 23:59:59 the same evening and a voucher cannot be edited afterwards. That is a console rule, not an API one — this endpoint still accepts any future instant.

PATCH /admin/v1/vouchers/{id}

PATCH does two jobs, and a request does one or the other.

Activation toggle — accepted body: { "active": boolean }. Deactivating a code makes it non-redeemable immediately (re-checked under the redemption lock, so it serializes correctly against an in-flight redeem).

Edit — accepted body: any of { "credit_usd": number, "max_redemptions": integer, "expires_at": integer|null }. Partial: only the keys present are written, so a campaign's cap can be raised without restating its credit. expires_at accepts an explicit null to clear the expiry (never expires), which is distinct from omitting the key (leave it untouched). Unknown keys are ignored rather than reflected.

code and type are not editable. The code is the identity a customer is already holding — the value printed on campaign material and the thing redemption records are about — and type drives the max_redemptions forcing rule, which has no coherent meaning to flip once redemptions exist. Both are create-time identity.

Editing money is safe on the past: voucher_redemption.credit_usd is snapshotted per row at redemption, so a later credit change affects only future redemptions.

The edit reuses the create endpoint's field validators, so the two cannot drift on what a legal voucher is. Rejected (400) with the same stable code values as create — credit_range, max_redemptions_range, expires_not_integer, expires_past — plus three the edit path can produce on its own:

code Meaning
max_redemptions_below_used the new cap is below the number of redemptions already granted — the row would read as over-redeemed and no counter could repair it
max_redemptions_unique a unique voucher is always limited to one redemption; raising its cap would silently turn an invite code into a campaign one under the same name
no_fields neither active nor any editable field was supplied

max_redemptions is validated against redemptions_used under a row lock, because that counter moves under concurrent redeemers — an unlocked read could let an edit slip the cap below what was already granted.

Rejected (404): no such voucher. Rejected (403): not a platform admin. A 500 returns a generic body — as on create, the driver detail is logged rather than returned, so it cannot end up in an operator's toast; a nil internal code is the hinge that separates an infrastructure 500 from a client-fixable 400.

DELETE /admin/v1/vouchers/{id}

Deletes a voucher that has never granted anything. voucher_redemption rows are money records, so a voucher with any redemption is deactivated (PATCH active=false), never deleted — tidying the campaign list must not be able to destroy an audit trail. This covers the real need: a typo'd or test code cluttering the catalogue.

The precondition is enforced server-side inside a transaction with the voucher row locked (FOR UPDATE), never trusted from the client and never read unlocked — a redemption landing between a check and the DELETE would otherwise orphan a redemption row against a deleted voucher. Both the counter and the presence of an actual redemption row are checked, so a counter that disagrees with the rows still refuses.

Rejected (409): { "error": …, "code": "has_redemptions" } — the console keys its localised "deactivate it instead" message off this stable code, never the prose. Rejected (404): no such voucher. Rejected (403): not a platform admin.