For IMAGE campaigns, ZLinkPage first creates an imageCandidateSession, polls for the multi-model candidate set (OpenAI / Nano-Banana / Flux), the brand picks one, and only then submitOffer fires with imageCandidateSessionId, selectedModelKey, and the chosen imageKind. VOICE campaigns do the same through a voiceCandidateSession (voiceCandidateSessionId + selectedVoiceModelKey); VIDEO requires both image and voice. Brands can skip image generation entirely with skipImageGeneration: true. Drafts can be saved before any candidate is picked (saveAsDraft: true).
If the offer comes in below the talent's effective minimum and the talent has chosen the auto_reject or auto_counter below-threshold policy, submit short-circuits: the offer is written straight from DRAFT to REJECTED or COUNTERED (skipping admin review) and both parties are notified.
Unauthenticated users visiting a Z-link are redirected to login by App.tsx. After login, returnTo brings them back to the Z-link page.
A talent (or the brand) can propose changes to more than the price — usage types, sharing options, image kind, script text, the chosen sample image, or the chosen voice read — using the counterChanges whitelist (offerCounterChangesSchema).
The original offer is admin-reviewed once (at submit). After that, counters between brand and talent stay private: respond routes a counter to ADMIN_REVIEW only when adminReviewedAt is still null; otherwise it goes straight to COUNTERED and notifies the other party directly — it does not re-enter the bouncer queue.
The two counter paths differ in when the deltas hit the canonical columns:
ADMIN_REVIEW (only while adminReviewedAt is null): the proposed deltas are applied straight onto the canonical columns and fees recomputed before the offer re-enters review.COUNTERED (holding state): the proposal is stored in counter_changes jsonb only; the canonical columns and fees are left untouched until the other side accepts (COUNTERED → ACCEPTED), at which point the stored deltas are merged and fees recomputed.Either way the raw proposal stays in counter_changes jsonb for audit and brand-side diff display.
The full FSM lives in packages/types/src/types/Offer.ts (OFFER_TRANSITIONS). It has 14 states. The diagram below mirrors OFFER_TRANSITIONS exactly.
Notable behaviour:
DRAFT is the buyer-only working state. saveAsDraft: true on submit keeps the row here. Below-threshold auto_reject / auto_counter policies transition straight out of DRAFT to REJECTED / COUNTERED, skipping admin review.ADMIN_REVIEW is the bouncer queue. The original offer passes through it once on submit (admins get sendAdminOfferSubmittedEmail; the brand gets an OFFER_SUBMITTED notification). A first counter while adminReviewedAt is still null also routes here and notifies admins with an in-app OFFER_COUNTERED; each row of a blast offer enters here too.APPROVED/COUNTERED → COUNTERED does not re-enter admin review. COUNTERED → COUNTERED lets each side counter repeatedly until one accepts.ACCEPTED → PENDING_PAY_CAPTURE → PAID is a Stripe authorize-then-capture flow. The brand's card is authorized (a hold is placed, payment_authorized_at recorded) on the amount_capturable_updated webhook, then captured to reach PAID. A declined capture or a voided authorization (e.g. the expire-pending-captures cron approaching Stripe's 7-day auth limit) returns the offer to ACCEPTED.EXPIRED is a terminal state reached from APPROVED/COUNTERED by the auto-expiry cron when expires_at elapses and the offer's expire_policy allows it. See Auto-expiry.auto_release_at on DELIVERED lets a background worker (/api/offers/cron/auto-release) promote the offer to COMPLETED if the brand never marks it complete.Offers waiting on a counter-party (APPROVED / COUNTERED) carry an expires_at deadline derived from expires_in_days (brand override, default 30). The hourly /api/offers/cron/expire worker:
expires_at (getOffersForExpiry).remind_talent policy, fires one OFFER_STALE_REMINDER, bumps the deadline, and records stale_reminder_sent_at (the dedup signal).expire policy — transitions it to EXPIRED and emits OFFER_EXPIRED.ping_brand is schema-only for now; the cron logs and skips it. Entering APPROVED or COUNTERED resets the clock (re-derives expires_at, clears stale_reminder_sent_at).
All offer endpoints use getCorsHeaders(origin) based on ALLOWED_DOMAINS_CORS. The zooly-app2 origin must be in that env var for cross-origin requests to succeed.