Offers API Reference

API endpoints for the offers system

Overview

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.


Submit Offer

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:

  • IMAGE — needs imageCandidateSessionId + selectedModelKey (unless skipImageGeneration: true).
  • VOICE — needs voiceCandidateSessionId + selectedVoiceModelKey.
  • VIDEO — needs both image and voice ("video = image + voice"; the final clip is composed off-platform).

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:

FieldTypeRequiredNotes
slugstringyesSeller's Z-link slug
offerIdstring | nullnoWhen set, finalizes / updates the buyer's existing DRAFT offer instead of creating a new one
saveAsDraftbooleannoWhen true, leave the offer in DRAFT instead of advancing to ADMIN_REVIEW. Brand can resume from /dashboard/drafts.
campaignDescriptionstringyesNon-empty
buyerBrandNamestring | nullnoUp to 120 chars
campaignTypestringyesMust exist in CAMPAIGN_TYPES registry and be enabled
campaignTypesstring[]noLegacy multi-select chips. Defaults to []
scriptTextstring | nullnoRadio / voice-over script
productImageUrlsstring[] (URL, max 3)noBrand-supplied reference photos for image generation
skipImageGenerationbooleannoIMAGE-only escape hatch: submit without a candidate session when the brand already knows what they want (skips the image requirement)
imageCandidateSessionIdstring | nullconditionalRequired for non-draft IMAGE / VIDEO campaigns (unless skipImageGeneration)
selectedModelKey"openai" | "nano_banana" | "flux" | nullconditionalRequired for non-draft IMAGE / VIDEO campaigns
imageKind"DRAFT" | "FINAL" | nullnoOptional 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.
voiceCandidateSessionIdstring | nullconditionalRequired for non-draft VOICE / VIDEO campaigns
selectedVoiceModelKeystring | nullconditionalRequired for non-draft VOICE / VIDEO campaigns. Free string (preset key like "soft" for cloned voices, or a raw ElevenLabs voice id)
usageTypesstring[]noDefaults to []
customUsagestring | nullno
wantsSharingboolean | nullno
sharingDescriptionstring | nullno
offerAmountMinorUnitnumberyesPositive integer in the chosen currency's minor unit
currencystring (3-char)noISO 4217. Defaults to "USD". Upper-cased on the server.
expiresInDaysnumbernoBrand override for the auto-expiry window, 1–365. Defaults to 30.
expirePolicy"expire" | "remind_talent" | "ping_brand"noAuto-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 draft
  • 401 Unauthorized
  • 403 Forbidden — brand's account is blocked (rate-limit check)
  • 404 NotFound — no account for the given slug, or offerId draft not found
  • 409 TALENT_BLOCKED_BRANDbuyerBrandName is on the talent's blocklist
  • 429 RateLimited — daily/weekly cap reached. details: { currentCount, limit }

Side effects on a non-draft submit:

  • The brand receives an 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).
  • A first counter routed to ADMIN_REVIEW (via respond, while adminReviewedAt is null) sends every admin an in-app OFFER_COUNTERED notification.
  • Below-threshold 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


List Offers

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:

ParamTypeDefaultDescription
perspective"seller" | "buyer" | "admin""seller"Which side of the marketplace to list from
statuscomma-separated OfferStatus valuesFilter, e.g. PAID,DELIVERED. Invalid values → 400 BadRequest
limitnumber20Range 1–100
offsetnumber0Non-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 offset
  • 401 Unauthorized
  • 403 Forbiddenperspective=admin requested by non-admin

Implementation: apps/zooly-app/app/api/offers/list/route.ts


Respond to Offer

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 Unauthorized
  • 403 Forbidden — role does not permit the requested action on this offer
  • 404 NotFound — offer not found
  • 422 UnprocessableEntity — offer is not in an APPROVED or COUNTERED status

Implementation: apps/zooly-app/app/api/offers/respond/route.ts


Get Offer by ID

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 id
  • 401 Unauthorized
  • 403 Forbidden — user is not an admin, participant, or the seller's agent
  • 404 NotFound — offer not found

Implementation: apps/zooly-app/app/api/offers/[id]/route.ts


Deliver Offer

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


Complete Offer

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 only403 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


Other endpoints

Beyond the core lifecycle routes above, apps/zooly-app/app/api/offers/ also hosts:

RouteMethodAuthPurpose
/api/offers/revisionPOSTuser (buyer)DELIVERED → REVISION_REQUESTED with a reason (requestRevisionBodySchema)
/api/offers/disputePOSTuser (buyer)Open a dispute from DELIVERED/COMPLETED. See Disputes
/api/offers/cancelPOSTuserBuyer/seller cancels an offer (→ CANCELLED)
/api/offers/[id]GETuserSingle offer + history (above)
/api/offers/[id]/suggest-similarGET/POSTuserBRAND_SUGGEST_SIMILAR re-engagement card state
/api/offers/[id]/blast-similarPOSTuserFan a similar offer out to multiple talents
/api/offers/[id]/action/[token]GETsigned tokenBelow-threshold "counter / reject" email CTAs
/api/offers/image-candidates/*, /api/offers/voice-candidates/*varioususerStart / poll / process multi-model candidate sessions
/api/offers/match-talentsPOSTuserSuggest talents for a brand brief (matchTalentsBodySchemafindTalentsForBrief)
/api/offers/infer-flowPOSTuserLLM classifier that resolves a polymorphic chip's brief to IMAGE or VOICE
/api/offers/generate-previewPOSTuserQueue an ElevenLabs voice preview job for an offer (createAiGenerationJob)
/api/offers/generation-statusGETuserPoll an AI generation job by jobId
/api/offers/upload-product-image, /api/offers/upload-deliverablePOSTuserS3 upload helpers
/api/offers/blast/*varioususerBlast-offer create / confirm / cancel
/api/offers/admin/queueGETadminBouncer queue
/api/offers/admin/approve, /api/offers/admin/rejectPOSTadminADMIN_REVIEW → APPROVED / REJECTED
/api/offers/admin/resolve-disputePOSTadminResolve a dispute. See Disputes
/api/offers/admin/pending-captures, /api/offers/admin/release-capture, /api/offers/admin/decline-captureGET/POSTadminAuthorize-then-capture review queue (PENDING_PAY_CAPTURE)
/api/offers/admin/terms, /api/offers/admin/statsvariousadminPer-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-generationGETcronScheduled auto-expiry, reminders, auto-release, auth voiding, AI preview job processing

DB Access Layer

Located in packages/db/src/access/offers.ts:

FunctionDescription
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