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 APPROVED, sending it to the talent. 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 instead of sending the offer, 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 sending it to the talent. 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_RECEIVED notification and the brand an OFFER_SUBMITTED confirmation. Per-offer draft IP terms are seeded (seedOfferTermsForOffer, best-effort).ask_principal policy additionally sends the seller 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 13 FSM states:
DRAFT | 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). Talent accept does not require Stripe Connect (CONNECT_ONBOARDING_REQUIRED is a payout / complete concern, not Accept). Talent accept from APPROVED requires a current brand_approved_at (canAcceptOfferAsTalent).counter: seller or buyer, valid from APPROVED or COUNTERED. Routes to COUNTERED and notifies the other party directly.The conversation Offer tab does not call this endpoint for Counter or
Reject — those buttons switch to Conversation as chat lines. Z asks
last-look, and a confirmed counter then reaches the same COUNTERED
transition through the agent layer, still attributed to the countering side
and still notifying the other party. Talent Approve on that
tab uses accept here directly (and requires a current brand_approved_at
on APPROVED) — Approve is the only click that writes the offer without the
chat. See Offer approval and brand payment.
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.
Sets brand_approved_at on an APPROVED offer without changing status. This is
the brand-first Approve on the conversation document: thanks / Approval
sent, not payment. Send Payment stays hidden until the talent accepts
(ACCEPTED). A later material rewrite of the written terms clears the stamp;
a second call is then a new acknowledgement, not a no-op. See
Offer approval and brand payment.
Endpoint: POST /api/offers/approve
Auth: Required (cookie-based, auth: "user"). Buyer only.
Idempotency: Yes. A second call returns the same row if brand_approved_at
is already set.
Implementation: apps/zooly-app/app/api/offers/approve/route.ts →
approveOfferAsBrand in @zooly/db. Wakes the talent-side agent with
action: "brand-approve".
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. That check is for payout / escrow release, not for talent Accept or brand pay-in.
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/approve | POST | user (buyer) | Brand acknowledgement (brand_approved_at); status stays APPROVED |
/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]/agreement | GET/POST | user | Load or generate the AgreementAg draft (lazy / retry). Parties and admins only |
/api/offers/[id]/agreement/download | GET | user | PDF of the current draft (409 if the draft is missing or stale) |
/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/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. A counter (→ COUNTERED) stores counterData.counterChanges without touching the canonical columns; on COUNTERED → ACCEPTED the stored proposal is applied and fees recomputed. Resets expires_at on APPROVED/COUNTERED. |
updateOfferDraft(id, buyerId, fields) | Edit a DRAFT (brief, campaign, images/voice, usage, price); recomputes fees |
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 |
approveOfferAsBrand(offerId, buyerAccountId) | Sets brand_approved_at on APPROVED without changing status (brand-first Approve). A material rewrite clears the stamp (BRAND_APPROVAL_CLEARED). |
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 |
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 and auto-rejected offers the talent never saw), newest first |
listOffersByBuyerAccount(buyerId, limit=50, offset=0, filters?) | Buyer list, newest first |
listAllOffers(filters?, limit=50, offset=0) | Admin list |
listOffersByStatus(status, limit, offset) | Status rollup (used by the capture-review queue) |
getOffersForAutoRelease() / getOffersForExpiry() / getDeliveredOffersForReminder(days) / listExpiringPendingCaptures(before) | Cron query helpers |
Agreement drafts live in packages/db/src/access/offerAgreement.ts:
| Function | Description |
|---|---|
getOfferAgreement(offerId) | Fetch the current draft for an Offer, or null |
hasOfferAgreement(offerId) | Whether a draft row exists (used to skip eager generation on manual/blast offers) |
upsertOfferAgreement({ offerId, document, sourceFingerprint }) | Insert or replace the one current draft |