Seller Endpoints
All seller endpoints require authentication via one of:
si_seller_API key:Authorization: Bearer si_seller_xxx(programmatic access)- Privy session cookie (web UI)
A si_seller_ key cannot access buyer endpoints, and vice versa. Session cookies work for both.
Everything you can do in the seller dashboard, you can do here over the API. The dashboard is a pure client of this API, so managing keys, offers, pricing, health, earnings, and payouts are all HTTP calls you can script with no browser. Seller keys support create, list, and revoke; offers have a full lifecycle (create, read, update, deactivate, bulk create, bulk cancel, bulk update, reset health, rotate credential).
Authentication — SIWE (Sign-In With Ethereum)
Get a seller API key using wallet signature auth. No browser needed.
Get Challenge
Returns a SIWE message with nonce and 5-minute expiry. The message must be signed and returned within 5 minutes. The server validates domain, uri, chainId, issuedAt (must be within ±5min of server time), and expirationTime (max 10min from issuedAt).
GET /v1/seller/auth/challenge?address=0xYourWallet
{
"message": "www.surplusintelligence.ai wants you to sign in...",
"nonce": "abc123def456",
"expires_at": 1713312000000
}Issue Key
Exchange the signed SIWE message for a seller API key. Save the returned key — it will not be shown again.
POST /v1/seller/auth/keys
{
"message": "<SIWE message from challenge>",
"signature": "0x...",
"label": "my-bot", // optional
"expires_at": 1713398400000 // optional, ms timestamp
}{
"key": "si_seller_abc123def456...",
"id": "01JQ8G7YV3M4N5P6Q7R8S9T0AB",
"wallet": "0xYourWallet",
"label": "my-bot",
"created_at": 1713312000000
}Manage Keys
Once you hold one seller key (or a web session), you can mint and manage more programmatically, no browser required. Seller keys support create, list, and revoke. (Rename and per-key routing preferences are buyer-only, since a seller key carries no routing config.)
Create another si_seller_… key (up to 25 active per wallet). Requires an Idempotency-Key header. The secret is in the key field of the response, shown once.
List keys, redacted (id, key_prefix, label, created_at, plus last_used_at / revoked_at when set). last_used_at is kept to within about five minutes of the key's latest use. An existing seller key or web session authorizes this.
Revoke a key. Irreversible; returns 204.
POST /v1/seller/keys
Authorization: Bearer si_seller_xxx
Idempotency-Key: 9f8c1e2b-... # required
{ "label": "batch-bot" }
# → {
# "id": "01JQ8G7YV3M4N5P6Q7R8S9T0AB",
# "key": "si_seller_9f8c1e2b...", # shown once
# "key_prefix": "si_seller_9f8c1e2b",
# "label": "batch-bot",
# "created_at": 1713312000000
# }GET /v1/seller/keys Authorization: Bearer si_seller_xxx
DELETE /v1/seller/keys/{keyId}
Authorization: Bearer si_seller_xxxOffers
Create offer.
Update price/caps.
Deactivate / soft-delete (encrypted key is retained; revoke at provider for immediate key invalidation).
List my offers (filter with ?model=, ?status=active|paused|inactive; paginate with max_items / next_token).
Read one offer (status, health, price, caps).
Every offer returned (list, read, update) also carries:
discount_pct: its discount off the model's live reference price, percent to two decimals;nullwhen the model has no reference price, never 0.provider: the provider family of its base URL (anthropic,openrouter, …;unknownwhen unmapped), androute_kind:direct,aggregator, orcloud(Vertex, Bedrock),nullwhen not stated.credential_kind(such asanthropic_api_key, elseapi_key) and its displaycredential_prefix(sk-ant). Absent on older offers until their key is rotated.- The last recorded failure:
last_error_category(auth,rate_limit,server_error,timeout, orbad_response),last_status_code, andlast_error_at, pluslast_probe_atand, while unhealthy,unhealthy_since. The last error stays after the offer recovers;healthysays whether it applies now.
To deactivate every active offer at once, use POST /v1/seller/offers/bulk-cancel; to deactivate a selection, use POST /v1/seller/offers/bulk-update with action: "cancel" (see Offer operations); otherwise remove them one at a time.
One offer per credential, base URL and model
The same API key can back as many different models as you like — that is the normal case and nothing about it changes. What is refused is a second active offer for the same model on the same key and base URL, whoever owns it: the rule is global across workspaces and organizations.
A conflict returns 409 duplicate_offer_for_credential. When the existing offer is your own, the response
carries its offer_id in error.detail so you can update or delete it instead of creating a duplicate; when it
belongs to someone else, no identifier is returned.
The same rule applies to anything that brings an offer back into the active set — reactivating a paused offer, or rotating a credential onto a key that already serves that model. Deleting or deactivating an offer frees its slot, so the key can be relisted for that model straight away; if a slot ever appears stuck, the next listing attempt reclaims it automatically.
When the conflicting offer is your own, its id is returned as error.detail.existing_offer_id.
In bulk creation, a conflicting row comes back as status: "error" with duplicate_offer_for_credential in the
message; retrying will not change the outcome.
Offers Surplus has delisted
An operator can delist an offer whose provider endpoint cannot serve the model it is listed for — for example a video model listed against a host that exposes no video API at all. Such an offer can never complete a job: every request routed to it fails after the buyer has been quoted.
Automatic refusal of these at creation time is currently running in shadow — it is logged, not enforced, so listing one still succeeds today. Routing already declines to send async jobs to a host that cannot serve them, so buyers are protected either way.
A delisted offer stays visible and editable, but setting status: "active" on it returns
400 offer_delisted_by_operator and it is not restored by rotating its credential. It is not a pause you can
undo. List the model against a provider that serves it, or contact support if you believe the endpoint does work.
POST /v1/seller/offers
Authorization: Bearer si_seller_xxx
Idempotency-Key: 9f8c1e2b-... # required
{
"model": "claude-opus-4.6",
"api_key": "your-provider-api-key",
"seller_base_url": "https://api.venice.ai/api/v1",
"pricing_mode": "cost_multiplier",
"cost_multiplier": 0.50,
"cap_daily_usd": 100.00,
"payout_address": "0xOptional..."
}PATCH /v1/seller/offers/{id}
Authorization: Bearer si_seller_xxx
{ "cost_multiplier": 0.45 }DELETE /v1/seller/offers/{id}
Authorization: Bearer si_seller_xxxGET /v1/seller/offers Authorization: Bearer si_seller_xxx
GET /v1/seller/offers/{id}
Authorization: Bearer si_seller_xxxBulk Create
Create multiple offers from discovery results.
Send many offers in one request (no fixed cap is enforced; keep batches reasonable). reuse_key_fingerprint reuses an already-stored credential, and save_config defaults to true — pasting a key silently creates a saved credential card. Bulk-created offers support the same cap_daily_usd field as single-offer creation.
POST /v1/seller/offers/bulk
Authorization: Bearer si_seller_xxx
{
"api_key": "your-provider-api-key",
"base_url": "https://api.venice.ai/api/v1",
"offers": [
{ "model": "claude-opus-4.6", "cost_multiplier": 0.5 },
{ "model": "gpt-5.4", "cost_multiplier": 0.5 }
]
}Offer operations
Deactivate ALL your active offers in one call. Send no body (or {}). It takes no selection: a body with offer_ids or provider returns 400 selector_not_supported; to cancel a selection, use bulk-update with action: "cancel" below. Fire-and-forget: returns 202 with an operation_id; poll it for progress.
Run one action over a selection of your active offers, asynchronously; returns 202 with an operation to poll. Select with offer_ids (up to 1,000) and/or provider (a provider family such as openrouter); an offer must match both when both are given, and ids that aren't yours are ignored.
canceldeactivates the selected offers. It needs a selection (400 selection_requiredwithout one); to cancel every offer, usebulk-cancel.reset_healthreturns each selected unhealthy offer to rotation and queues an immediate verification probe, so a key that is still bad is demoted again; healthy offers are left alone, and a media offer still waiting for (or failing) its render check is not re-admitted (it counts infailed_count). With no selection it covers every active offer.set_discount(withdiscount_pct, 0 to 100) is bulk repricing and is rolling out behind a flag: until it is enabled it returns404. With no selection it covers every active offer. It reprices the offers you priced as a discount off the reference, media and text alike, at exactly the prices setting that discount on one offer would give. Offers you priced per token, and any text offer whose model has no current reference price, are left as they are and counted infailed_count.
One bulk operation runs at a time: repeating the same request returns the running operation, and a different one returns 409 operation_in_progress until it finishes.
Clear an offer's failure backoff and return it to rotation immediately.
Swap an offer's upstream provider key in place, without recreating it.
Poll an async operation (such as a bulk cancel) for status, processed, total, and failed_count. total is the number of offers the operation covers, filled in once it starts running (absent while it is queued).
POST /v1/seller/offers/bulk-cancel
Authorization: Bearer si_seller_xxx
# no body: cancels every active offer
# → 202 { "operation_id": "...", "object": "operation", "status": "queued", "created": 1713312000 }POST /v1/seller/offers/bulk-update
Authorization: Bearer si_seller_xxx
{ "action": "cancel", "provider": "openrouter" }
# or { "action": "reset_health", "offer_ids": ["01JQ8G7YV3M4N5P6Q7R8S9T0AB"] }
# → 202 { "operation_id": "...", "object": "operation", "status": "queued", "created": 1713312000 }POST /v1/seller/offers/{id}/reset-health
Authorization: Bearer si_seller_xxxPOST /v1/seller/offers/{id}/rotate-credential
Authorization: Bearer si_seller_xxx
{ "api_key": "new-provider-key" }GET /v1/seller/operations/{id}
Authorization: Bearer si_seller_xxx
# → { "id": "...", "status": "succeeded", "processed": 12, "total": 12, "failed_count": 0 }Model Discovery
Auto-detect models + pricing from a provider. Requires seller auth; streams NDJSON, one line per model.
Parses Venice, OpenRouter, Bankr, and generic pricing formats automatically.
POST /v1/seller/discover
Authorization: Bearer si_seller_xxx
{ "api_key": "your-provider-api-key", "base_url": "https://api.venice.ai/api/v1" }Test Connection
Verify endpoint is reachable and returns valid responses.
POST /v1/seller/test-connection
Authorization: Bearer si_seller_xxx
{ "api_key": "your-provider-api-key", "base_url": "https://api.venice.ai/api/v1", "model": "claude-opus-4.6" }Earnings
Precomputed earnings for a range: total_earned_usdc, pending_usdc, in_flight_usdc, paid_usdc (integer micro-USD strings), plus daily, by_model, share, a recent_sales teaser (up to 20), and any payout_hold.
Query param: range = today, 7d (default), 30d, 90d, mtd (month to date, UTC), or lifetime. Team orgs can add ?workspace_id=. If today or mtd isn't available yet, the response has range_days: 0 and empty arrays.
Also returned: computed_at (when the figures were computed, epoch ms; null before anything is published), top_models_by_earnings (top 25 by earnings; by_model keeps its token order), cache_savings_usdc (the lifetime cache-affinity savings your offers' cache reuse produced, a micro-USD string), and, for today, 7d, 30d, and mtd, by_offer (top 50 offers) and by_provider (by provider family). share adds input_tokens, output_tokens, cache tokens, avg_discount_given_pct (the discount you gave, weighted by tokens sold), model_count, offer_count (the same four ranges as by_offer), and prior, the equal period before the range (for mtd, the same days of the previous month; not on lifetime). daily and by_model rows add cache tokens, and by_model adds avg_discount_pct. A figure not yet measured for the whole range is omitted, never 0.
Each recent sale adds discount_pct (the discount the buyer got off the reference price), provider (the provider family that served it), sold_at (when it was served), and confirmed_at (when its settlement confirmed). Sales carry no buyer identity: recent_sales[].buyer has been removed.
GET /v1/seller/earnings Authorization: Bearer si_seller_xxx
GET /v1/seller/earnings?range=mtd Authorization: Bearer si_seller_xxx
Earnings History
Cursor-paginated sale history, deeper than the 20-row earnings teaser. Params: limit (default 25, max 50), cursor, workspace_id. Returns recent_sales[] (the same row shape as the teaser, with no buyer) + next_token.
Filters, each combined with the others: from / to (inclusive; an ISO-8601 instant or a YYYY-MM-DD UTC day, which to covers whole), model (comma-separated, up to 20), offer_id (comma-separated, up to 10 of your own offers; any other is 404 offer_not_found), and settlement (comma-separated: pending, owed for credit-rail sales not yet paid out, paid, or confirmed for every settled sale, owed and paid together). A filtered page reads a bounded amount of history per call, so it can come back short, even empty, while next_token is still present; it then says truncated: true. Keep paging until next_token is absent. Several offers come back as one list, newest first, and their next_token only works with the same offer_id set (another set is 400 invalid_cursor). Filtered reads never return totals.
Usage Export
Your sales record as CSV. Redirects (302) to a short-lived presigned download. Params: from, to, limit, format=csv, workspace_id.
Bounded, not complete. A single export returns at most 50,000 rows (or a lower limit you pass). When rows remain beyond the cap the response carries x-si-export-truncated: true, and the CSV you downloaded is a partial record. Check that header on every export and narrow the from/to window until it is absent, especially before using the output as a reconciliation or tax ledger.
Columns: request_id, settled_at, model, input_tokens, output_tokens, seller_earned_usd, settlement_status, settlement_type, tx_hash. Rows are not filtered by outcome: pending and failed settlements are exported too, so within the row cap this is your raw ledger rather than an earnings summary. The settlement_status column is per-request: under batched payouts most rows stay accrued even after their earnings were paid, so read Settlement and payout lifecycle before treating an accrued count as unpaid exposure.
Health Log
Returns recent health events for your offers (failures, backoffs, recoveries).
Query params: offer_id (filter to one offer), since / until (ms epoch), max_items, next_token, sort_order (ASC | DESC).
GET /v1/seller/health-log Authorization: Bearer si_seller_xxx
Payouts
Your payout position: available_usdc, in_flight_usdc, paid_usdc, the threshold_usdc, a batched_enabled flag, mode (the payout mode that actually applies to you: batched whenever batched payouts are live, else instant, where each sale pays as it settles and there is nothing to claim), per-recipient groups, and executed-payout history. All amounts are integer micro-USD strings ("5000000" is 5 USDC). Each group carries its recipient (the destination frozen when those earnings accrued), recipient_source (resolved when that destination is a payable address, unresolved when none could be resolved), its own balances, and a next_trigger explaining why it will or will not pay next. Each history entry carries a status (claimed | submitting | paid | failed | needs_operator), its amount_usdc, type (payout_now when you asked for it, batch for the scheduled run, instant for the legacy per-sale rail), method (sponsored when we broadcast it and paid the Base fee, self_submit when you did), paid_at once it is paid, and, once broadcast, the Base tx_hash for that specific payout. See Settlement and payout lifecycle for what each field means.
Request a withdrawal of your available balance. With batched payouts enabled — the production configuration — a withdraw goes through the obligation planner and the scheduled drain settles the pending balance (status: scheduled), and the response reports mode: "batched", the same mode as GET /v1/seller/payouts; self_submit: true instead requests a signed EIP-3009 authorization for you to broadcast yourself (paying your own gas). A withheld balance returns status: held with a reason.
Report the on-chain tx_hash for a payout you broadcast yourself.
GET /v1/seller/payouts Authorization: Bearer si_seller_xxx
POST /v1/seller/payouts/withdraw
Authorization: Bearer si_seller_xxx
{ "self_submit": false }
# `destination` (optional) DELIBERATELY OVERRIDES the recipient frozen at accrual and
# consolidates every pending payout group into one transfer to that address. It is
# authorized by the API key alone — treat a seller key that can call this as able to
# redirect your earnings, and scope it accordingly.
# Response `status` is one of: scheduled | authorized | nothing_pending | held
# scheduled - claimed; the drain broadcasts it and we pay the gas
# authorized - `self_submit: true` only; carries the signed EIP-3009 `authorization`
# for you to broadcast yourself
# nothing_pending- nothing is payable right now
# held - eligible but withheld; carries a `hold_reason`, and a `releases_at`
# (epoch ms) only when every withheld group clears on a clock
# See Settlement and payout lifecycle below.POST /v1/seller/payouts/submitted
Authorization: Bearer si_seller_xxx
{ "payout_id": "...", "tx_hash": "0x..." }Settlement and payout lifecycle
Your earnings and your payouts are two separate tracks, and conflating them is what produces the "my sales are up but my balance is not" question. Earnings accrue per request the moment each response completes. Payouts are batched: they are grouped by payout recipient and sent when a group becomes eligible, not once per request. So a request can be fully earned and fully paid while its individual row still reads accrued.
Request-level status vs payout position
The CSV from Usage Export carries a per-request settlement_status. Your payout position carries account-level amounts. They answer different questions.
Per-request settlement_status (one value per row in your usage CSV):
| Status | Meaning |
|---|---|
accrued | Credit-rail sale. Your earnings are recorded in the payout ledger and are (or have been) payable. A credit-rail row stays here unless a paid batch is joined back to it, which includes rows whose earnings were already paid. |
pending | On-chain or x402 sale whose settlement transaction has not confirmed yet. Nothing has moved on chain for this row. |
submitted | x402 sale broadcast on chain whose receipt we could not read in time. It may still have landed; the row is left open rather than guessed at. |
confirmed | This specific row carries an on-chain hash. On the direct on-chain rail that is the settlement itself. On the credit rail under batched payouts it is a best-effort display writeback, not a per-row guarantee (see below). |
failed | Settlement did not complete for this row. These are exported deliberately so a gap in your ledger is visible rather than silently dropped. |
fallback | Legacy. No current code path writes it, but historical rows carry it and it is excluded from buyer-side analytics. If you see it, it is an old row, not a new state. |
accruedMeaning · Credit-rail sale. Your earnings are recorded in the payout ledger and are (or have been) payable. A credit-rail row stays here unless a paid batch is joined back to it, which includes rows whose earnings were already paid.pendingMeaning · On-chain or x402 sale whose settlement transaction has not confirmed yet. Nothing has moved on chain for this row.submittedMeaning · x402 sale broadcast on chain whose receipt we could not read in time. It may still have landed; the row is left open rather than guessed at.confirmedMeaning · This specific row carries an on-chain hash. On the direct on-chain rail that is the settlement itself. On the credit rail under batched payouts it is a best-effort display writeback, not a per-row guarantee (see below).failedMeaning · Settlement did not complete for this row. These are exported deliberately so a gap in your ledger is visible rather than silently dropped.fallbackMeaning · Legacy. No current code path writes it, but historical rows carry it and it is excluded from buyer-side analytics. If you see it, it is an old row, not a new state.Payout position (account-level, from GET /v1/seller/payouts):
| Field | Meaning |
|---|---|
available_usdc | Earned, net of fee, and claimable now. |
in_flight_usdc | Claimed and broadcasting, not yet confirmed on chain. |
paid_usdc | Confirmed paid on chain, lifetime. |
held (a state, surfaced as a group's next_trigger or a withdraw status) | Eligible but withheld. Carries a reason. Exceptional, not the normal cycle. |
available_usdcMeaning · Earned, net of fee, and claimable now.in_flight_usdcMeaning · Claimed and broadcasting, not yet confirmed on chain.paid_usdcMeaning · Confirmed paid on chain, lifetime.held (a state, surfaced as a group's next_trigger or a withdraw status)Meaning · Eligible but withheld. Carries a reason. Exceptional, not the normal cycle.Why so many rows stay accrued
Because a batched payout pays a group, not a request, there is no one-for-one accrued to confirmed transition for most rows. When the system can cheaply join a paid batch back to an individual row it will flip that row to confirmed and stamp the hash, but that is a display-only writeback and it does not cover every row. Rows paid as part of a group legitimately remain accrued.
A large accrued count is not proof that you are unpaid. Under batched payouts a row is only rewritten when a paid batch can be joined back to it, so accrued is what an unrewritten row reads whether or not it was paid. To check whether money actually moved, read your payout history, not the per-request status.
Worked example. Say you made tens of thousands of credit-rail sales worth 250 USDC. Your usage CSV shows tens of thousands of rows at settlement_status = accrued, and almost none at confirmed. Your GET /v1/seller/payouts history shows a handful of rows with status: "paid" that sum to ~250 USDC, each with its own Base tx_hash. Those batches paid the accrued rows. The rows were never rewritten one by one, so their status still reads accrued. Nothing is missing.
When earnings get paid
A payout group becomes eligible for an automatic payout when either of these is true:
- its available balance reaches 5 USDC, or
- its oldest available earning reaches 72 hours (the
age_due_aton the group).
The 5 USDC floor is an operational knob rather than a contractual number, so read the current figure from threshold_usdc on the payout response rather than hard-coding it in your own tooling.
A scheduler evaluates eligibility on a roughly 10-minute cadence and pays the groups that qualify. Manual review is not part of the normal cycle. It applies only to exceptional states (a payout hold, or an attempt that needs operator recovery), covered below. A group's next_trigger tells you which rule it is waiting on:
next_trigger | What it means |
|---|---|
threshold | The balance is at or above the payout floor, so the next scheduled run pays it. |
age | The balance is below the floor, so it waits on the 72-hour trigger. age_due_at is when that fires. This says which rule the group is waiting on, not that the wait is over. |
below_threshold | Has a balance the planner did not select this run. Rare, and normally clears by itself on the next one. |
manual | Nothing available to pay in this group. Not a hold, and not a review: there is simply no balance. |
unresolved_recipient | No payable destination could be resolved, so no amount of earning will make this group pay. Contact support. |
new_seller_hold | The ordinary first-payout waiting period. Clears on a clock; the group carries hold_release_at. |
risk_hold | Withheld pending review. Carries no countdown. |
thresholdWhat it means · The balance is at or above the payout floor, so the next scheduled run pays it.ageWhat it means · The balance is below the floor, so it waits on the 72-hour trigger. age_due_at is when that fires. This says which rule the group is waiting on, not that the wait is over.below_thresholdWhat it means · Has a balance the planner did not select this run. Rare, and normally clears by itself on the next one.manualWhat it means · Nothing available to pay in this group. Not a hold, and not a review: there is simply no balance.unresolved_recipientWhat it means · No payable destination could be resolved, so no amount of earning will make this group pay. Contact support.new_seller_holdWhat it means · The ordinary first-payout waiting period. Clears on a clock; the group carries hold_release_at.risk_holdWhat it means · Withheld pending review. Carries no countdown.Both triggers above belong to the automatic run. "Payout now" (POST /v1/seller/payouts/withdraw) has neither: a manual claim takes any group with an available balance and a payable recipient, however small and however recent. manually_claimable on each group is that same decision, so the button and the claim can never disagree. It is still subject to a hold.
How to confirm you were paid
The authoritative record of payment is your payout history and its Base transaction hash, never the per-request accrued count.
- In
GET /v1/seller/payouts, ahistoryentry withstatus: "paid"and atx_hashis a completed payment. There is exactly one hash per seller per payout, so you can point at your own transaction on Base without it being entangled with any other seller's payment. - The recipient wallet for each group is the group's
recipient, frozen when those earnings accrued.recipient_sourcetells you only whether that destination is payable (resolved) or could not be resolved to an address (unresolved); it does not name which rule produced it.
Payout groups and recipient overrides
Balances are split across payout-recipient groups. You have one group per distinct payout recipient, so a seller whose offers all pay to the same wallet has exactly one. If some offers set a payout_address override, the recipient is frozen at accrual and those earnings form their own group with their own trigger and their own transaction. Merging them would pay one recipient money owed to another, so each group is claimed and paid independently. A seller with an offer-level override and an org default therefore sees two rows in groups, each reaching the 5 USDC or 72-hour trigger on its own.
If a payout looks stuck
These are exceptional and are not the normal cycle. If you see one, it is a signal to look closer, not to assume everything is fine.
held(a group whosenext_triggerisrisk_holdornew_seller_hold, or a withdraw that returnsstatus: held). Anew_seller_holdis the ordinary first-payout waiting period and clears on its own: the group carrieshold_release_at(ISO-8601) and a withdraw response carriesreleases_at(epoch ms). Arisk_holdis a manual review and carries no countdown. If a hold does not lift when you expect it to, contact support.- In-flight far longer than a cycle. A balance that sits in
in_flight_usdcwell beyond a normal payout window, rather than settling topaid, is worth reporting. Inhistorythat shows up as an entry stuck atclaimedorsubmitting. needs_operatorin your payout history. A payout attempt whose outcome could not be determined automatically (for example an on-chain broadcast with no readable receipt) is parked for operator recovery. The safe design is to hold it rather than risk paying twice, so this status is terminal for automation and will not self-resolve. Contact support with thepayout_id.failedin your payout history. A recoverable failure, carrying anerror. A later run re-drives it; if the same one persists across days, report it.
In any of these cases, contact support with your wallet address and, if you have it, the payout_id. Do not assume a stuck payout is normal batching latency.
Saved Provider Configs
Reusable saved provider-key cards (base URL + key fingerprint + hint, plus credential_kind / credential_prefix when known; the key itself is never returned). Cards are created as a side effect of creating an offer, so there is no create endpoint here: management is list + delete. List them, or DELETE /v1/seller/configs/{id} to remove one. Bulk offer creation, discovery, and health probes can reuse a saved key via reuse_key_fingerprint instead of re-sending api_key.
Rate Limits
Seller routes are metered in per-minute buckets, scoped to your API key (or your session, or your IP when neither is present). The bucket is aggregate per caller, not per path: every route in a class shares one counter.
| Route class | Limit | Covers |
|---|---|---|
| Seller management | 1,200 req/min | Everything under /v1/seller not listed below: offers CRUD, bulk create, discovery, test connection, earnings, usage export, health log, payouts, configs. |
| Earnings history | 120 req/min | /v1/seller/earnings-history, carved out because each page is a real query rather than a precomputed read. Use the CSV export for a full pull. |
| Filtered log reads | 60 req/min | /v1/seller/earnings-history with model, offer_id, or settlement (paging, from / to, and workspace_id alone stay on the 120/min bucket). Shared with filtered request-log and audit reads. |
| SIWE auth | 120 req/min | /v1/seller/auth/challenge and /v1/seller/auth/keys. Scoped per IP, since there is no key yet. |
/v1/seller not listed below: offers CRUD, bulk create, discovery, test connection, earnings, usage export, health log, payouts, configs./v1/seller/earnings-history, carved out because each page is a real query rather than a precomputed read. Use the CSV export for a full pull./v1/seller/earnings-history with model, offer_id, or settlement (paging, from / to, and workspace_id alone stay on the 120/min bucket). Shared with filtered request-log and audit reads./v1/seller/auth/challenge and /v1/seller/auth/keys. Scoped per IP, since there is no key yet.429 responses include Retry-After (seconds), and every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix timestamp).
A separate edge WAF rule also blocks any single IP past 6,000 requests per 5-minute window (about 1,200/min), before the application sees the request. This ceiling is shared by all keys and routes using that source address.
These route limits are API-abuse controls. They are not a marketplace-side spend ceiling for provider usage. For provider-quota protection, use cap_daily_usd and provider-side API-key limits where available. There is no per-week or per-month USD cap on marketplace seller offers today.
Key Storage and Deletion
Seller provider API keys are encrypted at rest with AES-256-GCM under AWS KMS envelope encryption: a per-secret data key is requested from KMS, used to encrypt the plaintext, and the ciphertext plus the KMS-encrypted data key are stored in a private S3 bucket. The server decrypts keys only when it needs to call the seller's provider endpoint or run seller-owned management actions such as discovery/test-connection/health reset.
Deleting an offer is currently a soft delete: active = false on the offer record, removed from routing, encrypted API key retained in storage. There is no self-serve hard-delete endpoint yet. Sellers should revoke deleted offer keys at their upstream provider if immediate invalidation is required.
See Security & Privacy for the full current trust model.
Error Responses
All errors follow the format:
{
"error": {
"message": "Human-readable description",
"type": "authentication_error | authorization_error | invalid_request_error | rate_limit_error | server_error"
}
}| Status | Meaning |
|---|---|
| 400 | Invalid request (bad JSON, missing fields, unsupported model) |
| 401 | Invalid or missing API key |
| 403 | Wrong key type (e.g., buyer key on seller endpoint) |
| 404 | Offer not found or doesn't belong to your wallet |
| 409 | This model is already listed on this credential and base URL (duplicate_offer_for_credential) |
| 429 | Rate limit exceeded |
| 500 | Server error |
duplicate_offer_for_credential)