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 instead of reaching the talent as a normal offer, 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).
Every counter stays between brand and talent: respond routes it to COUNTERED and notifies the other party directly.
COUNTERED is a 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. 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 13 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 instead of reaching the talent.APPROVED means the offer is live with the talent. There is no moderation step: submit takes the offer straight from DRAFT to APPROVED (the talent gets OFFER_RECEIVED, the brand gets OFFER_SUBMITTED), and each row of a blast offer is inserted here directly.APPROVED/COUNTERED → COUNTERED, and 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.