A self-hosted replacement for Auctiva's listing archive and sales manager. One small Flask app + one SQLite file that:
- archives every listing you've ever created — titles, descriptions, photos, item specifics — so eBay purging old listings never costs you the work again
- mirrors your order history permanently (eBay's API only retains ~2 years; this keeps everything it can still reach, forever)
- shows Active / Sold-out / Ended state for the whole archive, synced from eBay every 15 minutes, including Good-Til-Cancelled listings sitting at zero quantity earning nothing
- relists any archived item in one click — uploads your photos to eBay, fills item specifics, and publishes either immediately or as an eBay Scheduled listing you can review in Seller Hub before it goes live
- restocks / zeroes quantity on live listings without ending them
- leaves buyer feedback from a queue of exactly what eBay says you owe
- prints packing slips and deep-links each order to eBay's label flow
- handles eBay's marketplace account deletion notifications (the compliance gate for API keys): when a buyer deletes their eBay account, their data is purged from your database automatically and the purge is logged
Everything lives on your hardware. No third party sees your data, your tokens, or your buyers.
Status: extracted from a working single-seller deployment. It runs a real store daily, but expect rough edges — read Hard-won eBay API notes before filing a bug that's actually an eBay behavior.
| File | Role |
|---|---|
app.py |
Flask UI: archive browser, sales manager, feedback queue, relist pages |
build_db.py |
Auctiva export JSON → archive.db (lossless: raw EAV + flat columns) |
fetch_images.py |
Downloads every listing photo locally (resumable, checksummed) |
verify.py |
12-point completeness audit — run before cancelling Auctiva |
ebay_auth.py |
OAuth: consent URL, code exchange, token storage, notify-endpoint setup |
ebay_client.py |
Sell-API client (user + application tokens, retries, paging) |
ebay_trading.py |
The few Trading-API (XML) calls with no stable REST equivalent |
ebay_sync.py |
Orders + fulfillments + feedback-awaiting + live-listing sync |
ebay_notify.py |
Account-deletion endpoint, OAuth landing pages, privacy page |
ebay_relist.py |
Media upload → inventory item → offer → publish pipeline |
resolve_aspects.py |
Recovers item-specific names by matching values against eBay Taxonomy |
make_fixture.py |
Synthetic database so tests and demos run without your data |
test_*.py |
Five suites, ~135 checks, all against a faked eBay — nothing real is sent |
Requires Python 3.11+ and Flask (pip install flask). No other dependencies.
Auctiva has no export feature, but its own catalog UI is fed by a JSON API that returns everything at once. Log into Auctiva in your browser, open the Product Catalog page, open devtools (F12) → Console, and run:
const r = await fetch('/api/Products/Get', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({PageIndex: 1, PageSize: 2000,
FilterCollection: {archived: 0}, AttributeSubset: 2,
FlattenAndRegroupVariations: false})});
const t = await r.text();
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([t])); a.download = 'auctiva-products-raw.json';
document.body.appendChild(a); a.click();AttributeSubset: 2 is the key — it returns all ~50 attributes per product
including the full description HTML. (0 and 1 return stripped subsets;
3 is a server error.) Also check the archived: 1 view and your standalone
Auctiva image library for anything extra.
python build_db.py # auctiva-products-raw.json -> archive.db
python fetch_images.py # downloads every photo (public CDN, no auth needed)
python verify.py # 12 checks; cancel Auctiva only on 12/12Images live at img.auctiva.com/imgdata/<account-digits>/webimg/<id>_o.jpg —
_o is the original upload, not a thumbnail. They stop resolving when your
subscription lapses, which is why fetch_images.py exists.
python app.py # http://localhost:8770Or as a service (systemd):
[Unit]
Description=Listing Archive
After=network-online.target
[Service]
WorkingDirectory=/opt/listing-archive
Environment=BRAND=yourstore
Environment="DEFAULT_FEEDBACK_COMMENT=Great buyer, fast payment. Thank you!"
Environment=MERCHANT_LOCATION_KEY=main-warehouse
Environment="MERCHANT_CITY=Yourtown"
Environment=MERCHANT_STATE=ST
Environment=MERCHANT_POSTAL=00000
Environment=CONTACT_EMAIL=you@example.com
Environment=EBAY_SECRETS=/etc/listing-archive/ebay-api.json
ExecStart=/usr/bin/python3 /opt/listing-archive/app.py
Restart=always
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.targetThe archive browser works with no eBay credentials at all. Everything below is for the live half (orders, feedback, relisting).
This is the part where everyone bleeds. In order:
- Developer account (developer.ebay.com) — use a business-domain email that is not attached to any eBay account. Free-mail and privacy domains get auto-rejected by a risk filter, and the registration form refuses an email that any eBay account already uses. The developer account is deliberately separate from your seller account; they only meet later, at OAuth consent.
- Production keyset — created disabled until you complete the marketplace account deletion requirement.
- Account-deletion endpoint — this app ships one (
/ebay/account-deletion). Put it on a public HTTPS hostname (a Cloudflare tunnel works well), then:python ebay_auth.py notify(generates the verification token), restart the service, and paste endpoint + token into Application Keys → Notifications. eBay sends a challenge (sha256(code + token + endpoint)) which the endpoint answers; then "Send Test Notification" activates the key. - RuName (User Tokens → Get a Token via Your Application → Add eBay
Redirect URL). The app serves
/ebay/oauth/accepted,/ebay/oauth/declinedand/privacyfor the three URLs the form wants. eBay'sredirect_uriparameter takes the RuName string, not your URL. - Credentials + consent:
python ebay_auth.py init # creates the secrets file; fill in app_id/cert_id/ru_name
python ebay_auth.py consent # prints the consent URL; sign in as YOUR SELLER account
python ebay_auth.py exchange # paste the ?code= from the redirect (5-min expiry)
python ebay_auth.py test # mints an access token to prove it worksThe Cert ID is on the Application Keys page (~36 chars, PRD-…) — not
the "OAuth Application Token" link, which produces a 2000-char JWT you don't
want. Scopes requested: sell.fulfillment(+ro), sell.inventory(+ro),
sell.account.readonly, commerce.feedback. A refresh token's scopes are
fixed at consent — widening them later means consenting again.
- Sync:
python ebay_sync.py backfill # walks order history to eBay's retention floor
python ebay_sync.py update # incremental; run from cron/systemd-timer every 15 minEvery archived listing gets a Relist on eBay page: review title/price/ condition, pick your business policies from live dropdowns, fill any item specifics eBay requires for the category, then:
- Dry run — sends nothing, shows the exact API payloads
- Create draft offer — uploads photos, creates an unpublished offer (invisible in Seller Hub — that's an eBay limitation; the app's Relists page is where drafts live)
- Publish as scheduled — publishes with a future
listingStartDate; the listing appears in Seller Hub → Listings → Scheduled, reviewable and deletable in eBay's own UI, and goes live only when the clock hits - Publish live now — immediate
Item-specific names come from resolve_aspects.py, which matches your archived
values against eBay Taxonomy value lists per category (~94% recovery in the
original deployment; the rest are free-text fields the page prompts for).
| Env var | Meaning | Default |
|---|---|---|
BRAND |
Store name shown in UI, packing slips, OAuth pages, privacy page | listing archive |
ARCHIVE_DB |
Path to the SQLite database | ./archive.db |
EBAY_SECRETS |
Path to the credentials JSON | ~/.claude/secrets/ebay-api.json or /root/.ebay-api.json |
PORT |
HTTP port | 8770 |
DEFAULT_FEEDBACK_COMMENT |
Pre-filled buyer feedback (≤80 chars) | generic |
MERCHANT_LOCATION_KEY |
Inventory-location key created on first publish | primary-location |
MERCHANT_CITY/STATE/POSTAL/COUNTRY |
Ship-from address (required to publish) | unset |
CONTACT_EMAIL |
Shown on the privacy page | unset |
Multiple sellers = multiple instances (own DB, port, secrets, BRAND) — by
design; there is no multi-tenant mode and cross-account isolation is the point.
python make_fixture.py # synthetic data; your archive.db is used if present
python test_notify_endpoint.py
python test_sync_safety.py
python test_feedback.py
python test_relist.py
python test_listings.pyAll eBay traffic in tests is faked. test_sync_safety.py proves the property
the whole sales manager rests on: a resync can never destroy locally-entered
notes, folders, or flags.
Facts this codebase paid for, so you don't have to:
- The Media API lives on
apim.ebay.com(notapi.ebay.com) and the method iscreate_image_from_file. The wrong host 404s with an empty body. The image id comes back in theLocationresponse header. createOrReplaceInventoryItemrequiresContent-Language: en-USand reports its absence as "25709 Invalid value for header Content-Language".GET /sell/inventory/v1/offer?sku=Xreturns 404 (25713) when the SKU has no offers — not an empty list.- publishOffer enforces Brand/MPN ("25002 …
<BrandMPN>") in many categories even where Taxonomy marks them merely recommended.MPN: "Does Not Apply"is accepted for items without one. - Once published, eBay drops
listingStartDatefrom the offer record — a scheduled listing is indistinguishable from a live one via the Inventory API. TradingGetItem'sListingDetails/StartTimeis authoritative. - Unpublished API offers never appear in Seller Hub ("Drafts" there is the web form's save state — a different system).
GetItemsAwaitingFeedbackreturns items awaiting feedback in both roles — sales and your purchases (no<Buyer>element). Filter purchases out.- The REST
commerce/feedback/v1API returned bare Tomcat 500s on every request shape (Aug 2026). The Trading feedback calls are stable and not on any decommission list. - The Taxonomy API needs an application token (client-credentials grant), not the user token — user tokens get 403.
ReviseInventoryStatusinterpretsQuantityinconsistently across listing states (sometimes available, sometimes available+sold) — read back after writing and correct (seeebay_trading.set_available_quantity).- eBay retains ended-listing details ~90 days and order history ~2 years. Nothing here can recover what eBay already deleted — archive while your listings (or your Auctiva subscription) still exist.
Your data never leaves your machine except in calls you make to eBay's API.
The account-deletion endpoint exists so holding buyer data (orders, addresses)
is compliant: deletion notices purge that buyer and log the purge
(ebay_deletion_log). If you store nothing (archive-only use), eBay's opt-out
path may suit you instead — but don't certify "not persisting eBay data" and
then run the order sync.
Not affiliated with, endorsed by, or supported by eBay Inc. or Auctiva.
Trademarks belong to their owners. Export only your own account's data; check
the terms of any service you point this at. No warranty — verify (verify.py)
before cancelling anything you can't get back.