All offer API endpoints live under apps/zooly-app/app/api/offers/. CORS is applied per-endpoint based on ALLOWED_DOMAINS_CORS.
Endpoints built on the shared route() builder (see util-srv) return errors in a structured envelope:
{ "error": "Human-readable message", "code": "Unauthorized" }
See the contracts package for the full ErrorCode list and the current migration status of each endpoint.
Creates a new offer in DRAFT and (unless saveAsDraft is set) immediately transitions it to ADMIN_REVIEW. When offerId is supplied it instead finalizes the buyer's existing DRAFT. The buyer is resolved from the authenticated user's account; the seller is resolved from the slug in the request body. Enforces the brand's daily / weekly offer limits before advancing a non-draft submit.
Candidate requirements on a non-draft submit depend on the campaign type's category:
imageCandidateSessionId + selectedModelKey (unless skipImageGeneration: true).voiceCandidateSessionId + selectedVoiceModelKey.Drafts are exempt from all of the above so brands can save partial state before generation finishes.
If the amount is below the talent's effective minimum and the talent's belowThresholdPolicy is auto_reject or auto_counter, submit short-circuits — it writes straight to REJECTED / COUNTERED (skipping admin review) and returns the offerId.
Endpoint: POST /api/offers/submit
Auth: Required (cookie-based, auth: "user")
CORS: Opt-in for POST
Idempotency: No. Submit is not wired through route()'s idempotency replay (the offers.idempotency_key column is unused by this endpoint). Pass offerId to re-finalize an existing DRAFT instead of relying on a key.
Built on: route() from @zooly/util-srv, schema from @zooly/contracts (submitOfferBodySchema)
Request Body:
| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | yes | Seller's Z-link slug |
offerId | string | null | no | When set, finalizes / updates the buyer's existing DRAFT offer instead of creating a new one |
saveAsDraft | boolean | no | When true, leave the offer in DRAFT instead of advancing to ADMIN_REVIEW. Brand can resume from /dashboard/drafts. |
campaignDescription | string | yes | Non-empty |
buyerBrandName | string | null | no | Up to 120 chars |
campaignType | string | yes | Must exist in CAMPAIGN_TYPES registry and be enabled |
campaignTypes | string[] | no | Legacy multi-select chips. Defaults to [] |
scriptText | string | null | no | Radio / voice-over script |
productImageUrls | string[] (URL, max 3) | no | Brand-supplied reference photos for image generation |
skipImageGeneration | boolean | no | IMAGE-only escape hatch: submit without a candidate session when the brand already knows what they want (skips the image requirement) |
imageCandidateSessionId | string | null | conditional | Required for non-draft IMAGE / VIDEO campaigns (unless skipImageGeneration) |
selectedModelKey | "openai" | "nano_banana" | "flux" | null | conditional | Required for non-draft IMAGE / VIDEO campaigns |
imageKind | "DRAFT" | "FINAL" | null | no | Optional override of the kind set on the candidate session — lets the brand flip framing right before submit without re-running generation. Server falls back to session.imageKind. |
voiceCandidateSessionId | string | null | conditional | Required for non-draft VOICE / VIDEO campaigns |
selectedVoiceModelKey | string | null | conditional | Required for non-draft VOICE / VIDEO campaigns. Free string (preset key like "soft" for cloned voices, or a raw ElevenLabs voice id) |
usageTypes | string[] | no | Defaults to [] |
customUsage | string | null | no | |
wantsSharing | boolean | null | no | |
sharingDescription | string | null | no | |
offerAmountMinorUnit | number | yes | Positive integer in the chosen currency's minor unit |
currency | string (3-char) | no | ISO 4217. Defaults to "USD". Upper-cased on the server. |
expiresInDays | number | no | Brand override for the auto-expiry window, 1–365. Defaults to 30. |
expirePolicy | "expire" | "remind_talent" | "ping_brand" | no | Auto-expiry behaviour. Defaults to remind_talent. |
Response (201 when creating a new offer, 200 when finalizing an existing offerId draft):
{
offerId: string;
}
Errors:
400 BadRequest — schema validation failure, unknown / disabled campaignType, missing candidate session / model for a non-draft image, voice, or video campaign, offer is no longer a draft401 Unauthorized403 Forbidden — brand's account is blocked (rate-limit check)404 NotFound — no account for the given slug, or offerId draft not found409 TALENT_BLOCKED_BRAND — buyerBrandName is on the talent's blocklist429 RateLimited — daily/weekly cap reached. details: { currentCount, limit }Side effects on a non-draft submit:
OFFER_SUBMITTED notification, and the admin team gets an email via sendAdminOfferSubmittedEmail (recipients from OFFER_ADMIN_NOTIFY_ACCOUNT_IDS). Per-offer draft IP terms are seeded (seedOfferTermsForOffer, best-effort).ADMIN_REVIEW (via respond, while adminReviewedAt is null) sends every admin an in-app OFFER_COUNTERED notification.ask_talent policy additionally sends the talent an OFFER_BELOW_THRESHOLD_ASK email with signed "counter / reject" CTAs.Implementation: apps/zooly-app/app/api/offers/submit/route.ts
Returns paginated offers from one of three perspectives.
Endpoint: GET /api/offers/list
Auth: Required (cookie-based, auth: "user"). perspective=admin additionally requires the admin role; non-admins receive 403 Forbidden.
CORS: Opt-in for GET
Built on: route() from @zooly/util-srv, schema from @zooly/contracts (listOffersQuerySchema, listOffersResponseSchema)
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
perspective | "seller" | "buyer" | "admin" | "seller" | Which side of the marketplace to list from |
status | comma-separated OfferStatus values | — | Filter, e.g. PAID,DELIVERED. Invalid values → 400 BadRequest |
limit | number | 20 | Range 1–100 |
offset | number | 0 | Non-negative |
Response (200):
{
offers: Offer[];
buyerAccounts: Record<string, { displayName: string; imageUrl: string | null; slug: string }>;
sellerAccounts: Record<string, { displayName: string; imageUrl: string | null; slug: string }>;
total: number;
limit: number;
offset: number;
}
Offer.status is one of the 14 FSM states:
DRAFT | ADMIN_REVIEW | APPROVED | COUNTERED | ACCEPTED | PENDING_PAY_CAPTURE
PAID | DELIVERED | REVISION_REQUESTED | COMPLETED | DISPUTED | REJECTED
CANCELLED | EXPIRED
The full Offer shape is defined by offerSchema in @zooly/contracts and includes the multi-currency, image-candidate, counter-changes, delivery and payment fields described in the database schema.
Errors:
400 BadRequest — invalid status, limit, or offset401 Unauthorized403 Forbidden — perspective=admin requested by non-adminImplementation: apps/zooly-app/app/api/offers/list/route.ts
Transitions an offer based on a role-appropriate action.
reject: seller only, valid from APPROVED or COUNTERED.accept: seller from APPROVED / COUNTERED, or buyer from COUNTERED (the brand accepting the talent's counter). When the seller accepts, the route first asserts the seller's Stripe Connect payouts are ready (CONNECT_ONBOARDING_REQUIRED otherwise).counter: seller or buyer, valid from APPROVED or COUNTERED. The first counter while adminReviewedAt is null routes to ADMIN_REVIEW and notifies admins; every counter after the offer has been admin-reviewed stays private (→ COUNTERED) and notifies the other party directly — no admin re-review.A counter must carry a counterNote plus at least one proposed change — either the legacy counterAmountMinorUnit, or one or more keys inside the new counterChanges object (multi-field counters, shipped in 3b6f6cbf). When both are present, counterChanges wins.
Endpoint: POST /api/offers/respond
Auth: Required (cookie-based, auth: "user")
CORS: Opt-in for POST
Idempotency: Yes
Built on: route() from @zooly/util-srv, schema from @zooly/contracts (respondToOfferBodySchema)
Request Body:
{
offerId: string; // required
action: "accept" | "reject" | "counter"; // required
// Legacy single-field counter — still accepted so old clients don't break.
counterAmountMinorUnit?: number; // positive integer, only on action=counter
counterNote?: string; // required on action=counter
// New multi-field counter. Whitelist enforced by offerCounterChangesSchema.
counterChanges?: {
offerAmountMinorUnit?: number;
usageTypes?: string[];
customUsage?: string | null;
wantsSharing?: boolean | null;
sharingDescription?: string | null;
imageKind?: "DRAFT" | "FINAL" | null;
scriptText?: string | null;
selectedSampleUrl?: string | null; // must be a URL
selectedVoiceUrl?: string | null; // must be a URL
};
}
When the brand accepts a COUNTERED offer, the keys present in counter_changes are merged onto the canonical columns. Brand-side fields (campaignDescription, productImageUrls, campaignType, …) are intentionally not in the whitelist — talents can't rewrite the brief.
Response (200):
{
offer: Offer;
}
Errors:
400 BadRequest — schema validation failure (e.g. counter missing note or any proposed change)401 Unauthorized403 Forbidden — role does not permit the requested action on this offer404 NotFound — offer not found422 UnprocessableEntity — offer is not in an APPROVED or COUNTERED statusImplementation: apps/zooly-app/app/api/offers/respond/route.ts
Returns a single offer plus its history timeline and account snippets. Visible to the offer's buyer, seller, an agent linked to the seller, or any admin.
Endpoint: GET /api/offers/:id
Auth: Required (cookie-based, auth: "user")
CORS: Opt-in for GET
Built on: route() from @zooly/util-srv, schema from @zooly/contracts (getOfferParamsSchema, getOfferResponseSchema)
Response (200):
{
offer: Offer;
history: OfferHistoryEntry[];
buyerAccounts: Record<string, { displayName: string; imageUrl: string | null; slug: string }>;
sellerAccounts: Record<string, { displayName: string; imageUrl: string | null; slug: string }>;
}
Errors:
400 BadRequest — empty id401 Unauthorized403 Forbidden — user is not an admin, participant, or the seller's agent404 NotFound — offer not foundImplementation: apps/zooly-app/app/api/offers/[id]/route.ts
Talent uploads the deliverable file(s) and transitions the offer from PAID (or REVISION_REQUESTED) to DELIVERED. Idempotent.
Endpoint: POST /api/offers/deliver
Auth: Required (seller only)
Built on: route(), deliverOfferBodySchema / deliverOfferResponseSchema
Request Body:
{
offerId: string; // required
fileUrls: string[]; // required, ≥ 1, each a URL
notes?: string; // optional, trimmed, ≤ 2000 chars
}
Response (200):
{
offer: Offer;
}
The route writes delivery_file_urls, delivery_notes, and delivery_submitted_at, then transitions to DELIVERED and sends the brand a DELIVERY_READY notification (the talent's note is included when present). Valid only from PAID or REVISION_REQUESTED; other statuses return 422 UnprocessableEntity. Seller-only — 403 Forbidden otherwise. (auto_release_at is set earlier, when the offer is paid.)
Implementation: apps/zooly-app/app/api/offers/deliver/route.ts
Brand marks a DELIVERED offer as COMPLETED, releasing escrowed payment to the talent (releaseOfferEscrowForOffer). Idempotent — re-calling on an already-COMPLETED offer returns it without side effects.
Endpoint: POST /api/offers/complete
Auth: Required. Buyer only — 403 Forbidden for anyone else (including admins; admins resolve via the dispute flow). Before releasing, the route asserts the talent's Stripe Connect account is onboarded + KYC-verified, returning CONNECT_ONBOARDING_REQUIRED otherwise.
Built on: route(), completeOfferBodySchema / completeOfferResponseSchema
Request Body:
{
offerId: string;
}
Response (200):
{
offer: Offer;
}
On success both parties get an OFFER_COMPLETED notification.
Implementation: apps/zooly-app/app/api/offers/complete/route.ts
Beyond the core lifecycle routes above, apps/zooly-app/app/api/offers/ also hosts:
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/offers/revision | POST | user (buyer) | DELIVERED → REVISION_REQUESTED with a reason (requestRevisionBodySchema) |
/api/offers/dispute | POST | user (buyer) | Open a dispute from DELIVERED/COMPLETED. See Disputes |
/api/offers/cancel | POST | user | Buyer/seller cancels an offer (→ CANCELLED) |
/api/offers/[id] | GET | user | Single offer + history (above) |
/api/offers/[id]/suggest-similar | GET/POST | user | BRAND_SUGGEST_SIMILAR re-engagement card state |
/api/offers/[id]/blast-similar | POST | user | Fan a similar offer out to multiple talents |
/api/offers/[id]/action/[token] | GET | signed token | Below-threshold "counter / reject" email CTAs |
/api/offers/image-candidates/*, /api/offers/voice-candidates/* | various | user | Start / poll / process multi-model candidate sessions |
/api/offers/match-talents | POST | user | Suggest talents for a brand brief (matchTalentsBodySchema → findTalentsForBrief) |
/api/offers/infer-flow | POST | user | LLM classifier that resolves a polymorphic chip's brief to IMAGE or VOICE |
/api/offers/generate-preview | POST | user | Queue an ElevenLabs voice preview job for an offer (createAiGenerationJob) |
/api/offers/generation-status | GET | user | Poll an AI generation job by jobId |
/api/offers/upload-product-image, /api/offers/upload-deliverable | POST | user | S3 upload helpers |
/api/offers/blast/* | various | user | Blast-offer create / confirm / cancel |
/api/offers/admin/queue | GET | admin | Bouncer queue |
/api/offers/admin/approve, /api/offers/admin/reject | POST | admin | ADMIN_REVIEW → APPROVED / REJECTED |
/api/offers/admin/resolve-dispute | POST | admin | Resolve a dispute. See Disputes |
/api/offers/admin/pending-captures, /api/offers/admin/release-capture, /api/offers/admin/decline-capture | GET/POST | admin | Authorize-then-capture review queue (PENDING_PAY_CAPTURE) |
/api/offers/admin/terms, /api/offers/admin/stats | various | admin | Per-offer IP terms editing, dashboard stats |
/api/offers/cron/expire, /api/offers/cron/reminders, /api/offers/cron/auto-release, /api/offers/cron/expire-pending-captures, /api/offers/process-generation | GET | cron | Scheduled auto-expiry, reminders, auto-release, auth voiding, AI preview job processing |
Located in packages/db/src/access/offers.ts:
| Function | Description |
|---|---|
createOffer(data) | Insert offer with DRAFT status; computes zoolyFeeMinorUnit / totalBrandPriceMinorUnit |
getOfferById(id, tx?) | Fetch single offer (excludes soft-deleted) |
updateOfferStatus(id, status, actorId, counterData?, historyNote?, tx?) | Guarded transition against OFFER_TRANSITIONS; writes a history row. On the talent-counter (→ ADMIN_REVIEW) path the deltas in counterData.counterChanges are applied to the canonical columns and fees recomputed; on COUNTERED → ACCEPTED the stored proposal is applied. Resets expires_at on APPROVED/COUNTERED. |
updateOfferDraft(id, buyerId, fields) | Edit a DRAFT (brief, campaign, images/voice, usage, price); recomputes fees |
updateOfferAdminReview(offerId, adminId, approved, rejectReason?) | Admin approve/reject; sets admin_reviewed_* then transitions |
updateOfferDelivery(offerId, fileUrls, actorId, notes?) | Write delivery fields, then → DELIVERED |
updateOfferRevision(offerId, actorId, reason?) | Bump revision_count, then → REVISION_REQUESTED |
applyOfferPaymentEscrow(tx, offerId, stripeId, buyerId) | → PAID, sets payment_stripe_id + auto_release_at (+30d). updateOfferPayment wraps it in a transaction |
applyOfferPendingCapture(offerId, stripeId, authorizedAt, actorId) | ACCEPTED → PENDING_PAY_CAPTURE; records payment_authorized_at |
revertPendingCaptureToAccepted(offerId, actorId) | PENDING_PAY_CAPTURE → ACCEPTED (declined / voided auth) |
markOfferCompletedFromDelivery(tx, offerId, buyerId) | → COMPLETED (idempotent), used by escrow release |
applyAutoCounter(offerId, sellerId, thresholdMinorUnit) / applyAutoReject(offerId, sellerId) | Below-threshold policies: DRAFT → COUNTERED / REJECTED, skipping admin review |
markOfferExpired(offerId, actorId) | APPROVED/COUNTERED → EXPIRED via the cron |
markStaleReminderSent(offerId, extraDays) / markSuggestSimilarSent(offerId) / markSuggestSimilarDismissed(offerId) | Re-engagement cron dedup markers |
listOffersBySellerAccount(sellerId, filters?, limit=500, offset=0) | Seller list (hides DRAFT / ADMIN_REVIEW / admin-rejected), newest first |
listOffersByBuyerAccount(buyerId, limit=50, offset=0, filters?) | Buyer list, newest first |
listAllOffers(filters?, limit=50, offset=0) | Admin list |
countOffersByStatus(status) / listOffersByStatus(status, limit, offset) | Status rollups |
getOffersForAutoRelease() / getOffersForExpiry() / getDeliveredOffersForReminder(days) / listExpiringPendingCaptures(before) | Cron query helpers |