# Task: publish a Peptides·Index catalog feed (spec v2.0)

You are helping a peptide vendor publish a read-only catalog feed that Peptides·Index (peptides-index.com), a price-comparison site, will read daily. Your goal is one HTTPS endpoint, `GET /catalog` (any path), that returns this store's catalog as JSON in the exact shape of the spec below, and answers only requests that carry the key Peptides·Index issued this vendor — and a second endpoint, `GET /orders`, that returns the orders Peptides·Index sent them, with no customer data (Phase 6). Both are required.

Everything you need is in this one message: these instructions, **Appendix A** (the full v2.0 spec) and **Appendix B** (the current list of Peptides·Index compound slugs). Read Appendix A in full before you write any code. Work through the phases in order.

**One exception: a Shopify store whose storefront is a Shopify theme.** It builds no endpoints and sets no key — ask which plan it is on (Phase 2, item 7), then go straight to **Shopify theme stores** at the end; nothing there is code. A headless Shopify store (its storefront is its own app) follows the phases like any custom store.

## Hard rules

1. **Never invent a price, discount, shipping cost, delivery time, compound amount or lab link.** If you cannot read it from the store's own data, ask the human. A missing field is fine; a made-up one gets published.
2. **Read-only.** Both endpoints only read. They never write to the store, never create orders, never change prices or stock. The only order in this whole task is the one test order the human places themselves in Phase 8.
3. **Never expose customer data** — no names, emails, phones, addresses, IPs, customer ids, carts or accounts, on any endpoint. The catalog endpoint carries only the catalog fields in Appendix A, and no orders. Orders are a separate endpoint (Phase 6) with its own allow-list of fields.
4. **Omit anything that is not a research compound** — bacteriostatic water, syringes, supplies, merch, gift cards. A product with no `compounds` must not appear in the feed.
5. **The key lives in configuration, not in code.** Both endpoints read it from the environment variable `PEPTIDES_INDEX_KEY` — except on **WordPress**, where the plugin's own settings page holds it (Phase 4). Never hard-code it, put it in a URL or commit it to a public repository. It is not a secret from you: in Phase 7 you ask the human for it, set it yourself and use it for the live checks.
6. **Build and test locally or on staging first** when you can, then deploy it yourself — there is no approval step. Ask before installing third-party software or packages.

## Tools

If you have a browser (through MCP or similar), use it for everything a person would check by eye: opening each `coa_url`, comparing the feed against the live storefront, creating and removing the Phase 8 test product in the store admin, and walking through checkout with the referral link. Use real HTTP requests (`curl` / `fetch`) for the API checks, never the browser, so status codes and headers are the real ones.

In a browser: **the human logs in themselves** — you never type a password, a card number or any payment detail; if checkout asks for payment, stop and hand it to them. Touch only the test product, the test order and the plugin's settings page. If you have no browser, tell the human exactly what to click and what to paste back, and do the rest yourself.

## Phase 1 — Detect, don't ask

Inspect the codebase and environment before asking the human anything. Determine:

- **Platform.** WooCommerce: `wp-config.php`, `wp-content/plugins/woocommerce`. Shopify: theme files (`layout/theme.liquid`, `sections/`), `shopify.app.toml`, or a Shopify API client in the code. Otherwise Next.js, another Node framework, PHP, Python, or something else — read `package.json`, `composer.json`, `requirements.txt` / `pyproject.toml`.
- **Where products live** — the database tables, CMS collections or API that hold products, variants, prices, stock and any bundle / quantity pricing. Find where a variant's own price lives, not just the parent product's.
- **Hosting** — what serves the site and how environment variables are set there.

If you have **no code access** (for example you are a chat assistant), say so plainly and take the matching path in Phase 4. A Shopify store with no repository is usually a theme store: confirm, then follow **Shopify theme stores**. On WordPress that means handing the human a ready-to-upload plugin zip, never code to paste into the theme.

Report what you found in a few lines before moving on.

## Phase 2 — Ask only what you cannot detect

Ask the human **once**, as one short numbered list, and only for what Phase 1 could not answer. Typically:

1. The store's currency (ISO 4217, e.g. USD).
2. Which countries they ship to, and for each group of countries: shipping cost, the order total above which shipping is free (if any), and delivery time in business days (min–max).
3. Payment methods accepted, and any percentage off for a particular method (e.g. 5% off for crypto).
4. Any volume discount: the tiers (quantity → % off); whether it counts across the whole cart (mix-and-match) or only the same product; and whether it combines with a product's own bundle/pack price at checkout.
5. Which products are research compounds to list, if that is not obvious from the catalog.
6. Where each product's current-batch lab COA link is — the lab's own verify page or lab-hosted PDF (see Appendix A, "Lab certificates").
7. **Shopify only:** which Shopify plan the store is on (Basic, Grow, Advanced or Plus — shown in the admin under Settings → Plan), and whether its storefront is a Shopify theme or headless (its own app). Note the plan in your final report; follow the setup below whatever the plan.

Do not ask for anything you already found. Do not guess answers to fill a gap.

## Phase 3 — Map compounds to our slugs

Fetch https://peptides-index.com/api/compounds if you can make web requests; otherwise use the embedded list in **Appendix B**. Match on name and aliases; ask the human about anything you can't match. Then, for every product you will list, build its `compounds` array from those slugs only:

- One entry per compound in one unit (per vial, per capsule). A blend is one entry per component, with the amounts printed on the label — never a sum, never percentages.
- A slug names the compound, not the form: capsules of a compound use the same slug as its vials, with `form` set accordingly.
- **Form.** `vial`, `capsule`, `tablet`, `spray`, `nasal`, `topical`, `kit` or `other`. Nasal drops are `nasal` (compared with `spray`); serums, creams and gels are `topical` (listed, price comparison coming soon). Take it from the store's category or product name; if you can't tell, ask.
- **Blends.** Some entries are named blends (`"blend": true` in the endpoint, marked "blend" in Appendix B). For a blend product, set the product's `blend` field to the matching blend slug if one exists, and list each component in `compounds[]` with its amount. If no listed blend matches, omit `blend` and list the components. **Never put a blend slug in `compounds[]`.**
- `amount` is per unit and `unit` is `mg`, `mcg` or `iu`. Take both from the store's data or the product name; if they disagree or are missing, ask.

Show the human a table — product · SKU · your `compounds` mapping — and list separately every product you could **not** match, with your best guess marked as a guess. Ask them to confirm or correct. A compound genuinely not in Appendix B gets `"slug": null` plus its `"name"`.

## Phase 4 — Build it for this platform

**The key.** Peptides·Index issues the key; you do not generate one. Both endpoints read it **only** from the environment variable `PEPTIDES_INDEX_KEY` — no other name, no default, no fallback value in code. **WordPress is the exception:** the plugin's settings page stores it (see WooCommerce below), and a `PEPTIDES_INDEX_KEY` constant in `wp-config.php`, if one is defined, overrides the setting. Trim surrounding whitespace from the variable's value; a pasted newline is common. Every request must carry `Authorization: Bearer <key>`; compare in constant time; answer `401` with no body detail when the header is missing or wrong. **If the key is missing or empty, answer `503` with the body `{"error":"key_not_configured"}`** — check this first, before reading the header: never answer `200`, and never compare the header against an empty string. That 503 is what tells the human, and the Peptides·Index checker, that the key simply has not been set yet.

**Where `PEPTIDES_INDEX_KEY` is set** — you set it in Phase 7, on the host you found in Phase 1. Every host needs a redeploy or restart before a new value is live.

- **Vercel** — the project's Environment Variables settings, for the Production environment; then redeploy.
- **Netlify** — the site's Environment Variables settings; then trigger a new deploy.
- **Cloudflare Workers / Pages** — the Worker's or Pages project's variables and secrets settings (for a Worker, `wrangler secret put PEPTIDES_INDEX_KEY`); then redeploy.
- **Render / Railway / Heroku** — the service's Environment Variables settings (Heroku calls them Config Vars); saving usually restarts the service — if not, redeploy.
- **cPanel / plain PHP** — a `.env` file outside the web root that the app loads, or `SetEnv PEPTIDES_INDEX_KEY "…"` in the site's Apache config or `.htaccess`; read it with `getenv('PEPTIDES_INDEX_KEY')`, falling back to `$_SERVER['PEPTIDES_INDEX_KEY']`.
- **WordPress / WooCommerce** — **Settings → Peptides Index → API key → Save**, in the WordPress admin. No redeploy. (A developer may instead `define('PEPTIDES_INDEX_KEY', '…');` in `wp-config.php`, which overrides the setting.)
- **Shopify headless app** — the environment settings of wherever the app runs (one of the hosts above), never in theme code, a metafield or an app setting the storefront can read.

**WooCommerce.** Build a **small standalone plugin**, `peptides-index-feed` — never code in the theme's `functions.php` (a theme change or update wipes it) and never a code-snippets plugin. It:

- registers `GET /wp-json/peptides-index/v2/catalog` with `register_rest_route`, a `permission_callback` that performs the Bearer check (use `hash_equals`) on the header from `$request->get_header('authorization')`, falling back to `$_SERVER['HTTP_AUTHORIZATION']` and `$_SERVER['REDIRECT_HTTP_AUTHORIZATION']` (some hosts move or strip it), and a handler that reads published products through WooCommerce's own functions (`wc_get_products`, `WC_Product_Variable::get_children`). Each **variation** becomes its own product in the feed, with its own SKU, price and stock;
- declares compatibility with WooCommerce's order storage (HPOS), in the main plugin file (it passes `__FILE__`): `add_action('before_woocommerce_init', function () { if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true); } });` — and reads orders only through `wc_get_orders` and `WC_Order` getters (Phase 6);
- adds a `coa_url` meta field on products and variations (with an admin input) if the store has no field for the COA link yet, so the human can fill it in;
- adds a settings page as a **submenu under Settings** — **Settings → Peptides Index**, registered with `add_options_page` — never a top-level item in the admin sidebar, which is already crowded on most stores. Visible only to administrators (`manage_options`, with a nonce on save). Add a **Settings** link to the plugin's row on the Plugins screen that opens it. It has:
  - **API key** — a plain text field, stored with `update_option` (autoload off) and never output on the storefront. If `PEPTIDES_INDEX_KEY` is defined in `wp-config.php`, that wins and the page says so.
  - **Feed status** — both endpoints active (yes/no), how many products the feed lists, and **when Peptides·Index last fetched it**: record the time of each request that passes the key check (a single option, updated at most once a minute), and show it in words — "today", "yesterday", "a few hours ago", "3 days ago", or "not yet" — never a raw timestamp or an hour count. Nothing more: no calls out to Peptides·Index, no prices, no orders.

**Installing it.** If you can write to the site's files, put it in `wp-content/plugins/peptides-index-feed/` and activate it (`wp plugin activate peptides-index-feed`, or ask the human if you have no WP-CLI). If you **cannot** (you are a chat assistant, or you have no file access), give the human the complete plugin as **one PHP file** and tell them exactly: save it as `peptides-index-feed.php` in a folder called `peptides-index-feed`, zip that folder, then in the WordPress admin go to **Plugins → Add New → Upload Plugin**, choose the zip, **Install Now**, then **Activate**. To update it later, they upload the new zip and choose **Replace current with uploaded**.

**Shopify.** Ask which plan the store is on (Phase 2) and note it in your report. **If the storefront is a Shopify theme**, build nothing — go to **Shopify theme stores** at the end. **If the store is headless** (its storefront is its own app — Hydrogen, Next.js or similar — on Oxygen, Vercel, Netlify or the like), put both routes **inside that app**, like any custom store — each route checks the Bearer key before doing any work; it already talks to Shopify. Each **variant** becomes a product in the feed. Store `coa_url` in a product or variant **metafield**.

**Admin API access, if the headless app has none yet** (the orders route needs it): since **1 January 2026** new custom apps can no longer be created in the Shopify admin ("Develop apps"); an existing one keeps working. For a new one, the human creates an app in the **Shopify Dev Dashboard** under the same organization as the store and installs it on the store, with **read-only** scopes (`read_products`, `read_inventory`, plus `read_orders` for Phase 6). The app's server gets its Admin API token with the **client credentials grant** using that app's client ID and secret; those tokens expire after about 24 hours, so fetch a new one when it expires rather than storing one. The Dev Dashboard app's client secret and `PEPTIDES_INDEX_KEY` both go in the headless app's environment settings, never in code. Walk the human through the Dev Dashboard screens they see; you cannot click them yourself unless you have a browser and they have logged in.

**Custom (Next.js / Node / PHP / Python / other).** Add one route in the existing framework that reads the store's own database or data layer — the same source the product pages use — and applies the Bearer check before doing any work.

**On every platform:**

- `price` is one unit at quantity 1. Bundles go in `packs`, with the pack's **total** price.
- Leave out any product you cannot price. Never send `0` or `null` as a price.
- `in_stock` is `null` where the platform genuinely cannot tell.
- Build absolute product URLs, exactly as a customer would reach them.
- Do not send derived numbers (per-mg prices, totals) — Peptides·Index computes them.
- On a data-layer error, return `503` with `Retry-After`, never `200` with an empty product list.
- Send `Cache-Control: private, no-store` and `Vary: Authorization` on every response from both endpoints, including `401` and `503`.

## Phase 5 — Validate the catalog

1. Run the response through **every** row of the "Check it yourself before you send it" table in Appendix A and fix what fails.
2. Test the key: no `Authorization` header → `401` (or `403`); a wrong key → `401` (or `403`); the right key → `200` (the real key if you have it, or a throwaway local value); with `PEPTIDES_INDEX_KEY` unset or empty (locally, or on staging) → `503` `{"error":"key_not_configured"}`, never `200`.
3. Show the human a sample response — the vendor block and two or three products, including a blend or a pack if the store has one — and walk them through anything you were unsure of.
4. If you could not run this locally (you are a chat assistant, or the plugin goes in as a zip), deploy both endpoints and, **before Phase 7 sets the key**, check the live URLs answer `503` `{"error":"key_not_configured"}` — the only moment that can be tested live; never ask anyone to unset a live key. Run every other Phase 5 and Phase 6 check at the start of Phase 8.

Continue to Phase 6 — the orders endpoint is required too.

## Phase 6 — Orders endpoint

Always build this: it is required to be listed, and it is how commission is reconciled without anyone logging into the store's affiliate portal. Read Appendix A, "Orders", in full. Detect what you can from the code and the platform; ask the human only for what you cannot detect — typically where the store records our referral link, and how any custom order statuses map. Then:

1. **Find how this store attributes an order to Peptides·Index.** Use your Peptides·Index referral code and link, shown in the vendor portal (usually `PEPTIDES` and `?ref=PEPTIDES`). The coupon: orders where that code was applied. The ref link: orders that arrived through that link — find where the store records that (an affiliate plugin's or app's referral record, or order meta / note attributes). If the store records no ref at all, report `"coupon"` attribution only and tell the human. Ask about anything you cannot find; never guess which orders are ours.
2. **Build a second route beside the catalog**, behind the SAME Bearer check and the SAME `PEPTIDES_INDEX_KEY`. It reads orders server-side, keeps only the ones attributed to us, and pages them (at most 500, sorted by `updated_at` oldest first, then `id`). Make `next_cursor` encode the last order's `updated_at` and `id`, and fetch the next page strictly after that pair (keyset pagination) — never a page number or offset: an order updated mid-pull shifts the rows and one gets skipped. Compare `id` the way you sort it (numeric ids as numbers). If you keep only our orders after the query (as on WooCommerce), build the cursor from the last order you **scanned**, not the last you returned, and send it whenever more rows remain — a page can hold fewer than 500 orders, or none, and still carry a `next_cursor`. It stays opaque to us.
3. **Build every order from an allow-list** of exactly the fields in Appendix A — `id`, `created_at`, `updated_at`, `status`, `currency`, `subtotal`, `discount`, `commissionable`, `attribution`, `coupon_code`, `click_id`, and `items` with `sku` and `qty`. `coupon_code` is optional: send `null` or leave it out when the store has nothing for it. `click_id`: build it if the platform can carry it (below); otherwise, and on an order that arrived without a `pi_click`, it is `null`. Never serialise the platform's own order object and delete fields from it: a field the platform adds next year would leak. **No names, emails, phones, addresses, IPs or customer ids, anywhere.**
4. **`since` filters on the last-modified time**, so refunds and cancellations come back through it.
5. **Map statuses** to the six in Appendix A and show the human your mapping. Ask about any custom status.

**WooCommerce.** Register `GET /wp-json/peptides-index/v2/orders` in the same plugin, with the same `permission_callback`. Read with `wc_get_orders` filtered on `date_modified` ≥ `since`, and keep an order only if `$order->get_coupon_codes()` contains our referral code (compare case-insensitively, as WooCommerce does) or it carries the ref marker. Build each entry from the getters you need (`get_id`, `get_date_created`, `get_date_modified`, `get_subtotal`, `get_discount_total`, `get_total_refunded`, the items' `get_product()->get_sku()` and `get_quantity()`) — never `get_data()`, never the REST API's own order response. A typical mapping: `pending` / `on-hold` → `pending`, `processing` → `paid`, `completed` → `fulfilled`, `cancelled` / `failed` → `cancelled`, `refunded` → `refunded`, and any order with a partial refund → `partially_refunded`.

**Shopify (headless).** A second route in the same app. Orders need the `read_orders` scope on the Dev Dashboard app — tell the human it is a wider permission than the catalog needed when they add it. Query orders by `updated_at_min` = `since`, keep those whose `discount_codes` include our referral code or that carry the ref, and copy across only the allow-listed fields: never `customer`, `email`, `phone`, `billing_address`, `shipping_address`, `browser_ip`, `client_details` or `note`. Without the `read_all_orders` scope Shopify only returns the last 60 days, which is fine for a feed that is polled.

**The store's admin API is never exposed.** The route calls it server-side with fixed queries you wrote. It does not pass our query string, a path, a field list or a GraphQL query through to it, never returns its response as-is, and never reveals its token. The only input it reads from us is `since` and `cursor`: validate both (a bad `since` is a `400`), and ignore anything else in the query string rather than forwarding it.

**`click_id` — you decide, and if it can be built, build it.** Our outbound links add `pi_click=<value>` to the landing URL. Work out from the code whether this store can carry a value from the landing page to the order. **If it can, build it as part of this endpoint without asking** — keep the value in a first-party cookie (about 30 days) or the session when a visitor lands, attach it to the order they place (order meta in WooCommerce, set at checkout; a cart attribute in Shopify), and return it as `click_id`, unchanged. Treat it as opaque, but store it only if it is 1–64 characters of printable ASCII, otherwise store nothing (a longer one fails the whole order); a later landing with a valid value replaces the earlier one. Page caches (WP Rocket, Cloudflare APO) can skip PHP on the landing page, so capture it where code actually runs — a small script, or at add-to-cart or checkout — not only on page load. **If it cannot** (or only with a risky change to checkout), skip it, send `null`, and say why in one line of your report — it does not hold up the listing. Either way, an order that arrived without a `pi_click` sends `null`.

**Validate.** Run every row of Appendix A's "What the orders checker runs" table: no key and a wrong key → `401` or `403`; `PEPTIDES_INDEX_KEY` unset → `503` `{"error":"key_not_configured"}`; `since` set to the current time → `"orders": []`; `commissionable` ≤ `subtotal` − `discount` on every order; every SKU found in the catalog. Show the human one real response, and search it for anything that looks like a person — an `@`, a phone number, a street, an IP. Deploy both endpoints. Then continue to Phase 7.

## Phase 7 — The key

An endpoint whose server never received the key rejects Peptides·Index's real key, and nobody can see why — so this phase is not done until the self-test below passes. The key is not a secret from you: it only keeps other sites out of the feed.

1. **Get it.** Ask the human to open https://peptides-index.com/vendors/portal → Integration kit → copy their API key, and paste it here — always, even if they also set it themselves, because you need it to run the checks.
2. **Set it** where this platform reads it (Phase 4, "Where `PEPTIDES_INDEX_KEY` is set"): the host's environment variable, then redeploy — or, on WordPress, **Settings → Peptides Index → API key → Save**. Never in source code or a public repository.
3. **Test it** on **each** live endpoint URL, with the key in `PEPTIDES_INDEX_KEY` in your shell. If you cannot make HTTP requests, give the human the commands and ask for the output:

```bash
curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $PEPTIDES_INDEX_KEY" <url>
```

Expected: `200`. And the same command without the header:

```bash
curl -s -o /dev/null -w '%{http_code}' <url>
```

Expected: `401` (or `403`). If the first prints `503`, the key is not set on the server yet, or the site was not redeployed (on WordPress: not saved in Settings → Peptides Index); if it prints `401`, the value on the server differs from the portal's — usually extra spaces or quotes. On WordPress, if the right key still gets `401`, the host may be stripping the `Authorization` header before PHP sees it: check the plugin reads `HTTP_AUTHORIZATION` and `REDIRECT_HTTP_AUTHORIZATION`, and add `CGIPassAuth On` (or `SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1`) to `.htaccess`.

**If you cannot set it yourself** — a host settings screen you cannot reach — give the human this block verbatim, then run the test once they say it is done:

> 🔑 One step for you: open https://peptides-index.com/vendors/portal → Integration kit → Copy your API key → paste it into your hosting's environment settings as `PEPTIDES_INDEX_KEY` → redeploy (on WordPress: **Settings → Peptides Index → API key → Save** instead). Then tell me it's done.

Once both endpoints print `200` and `401`, continue to Phase 8.

## Phase 8 — Verify it live, then one test order (last)

Now prove it on the live URLs, before the human submits in the portal. If you could not test locally (Phase 5, step 4), run the remaining Phase 5 and Phase 6 checks here first. **Run every check for real** — make the request, open the page — and never report one as passing because the code looks right.

**Requests that need the key.** Run them yourself, with the key from Phase 7. If you cannot make HTTP requests at all, give the human the exact commands and ask them to paste back the output.

1. **The gate.** Both live URLs: the key → `200` and valid JSON; no header, and a wrong key → `401` (or `403`). Then the same URL with no header right after a keyed `200` must still get `401` (or `403`) — not a cached `200`. Every response, `401` and `503` included, carries `Cache-Control: private, no-store` and `Vary: Authorization` (`curl -sD - -o /dev/null <url>` prints the headers).
2. **The catalog.** Top-level `updated_at` is present; every field matches Appendix A exactly — names, types, forms, units; every row of "Check it yourself before you send it" passes on the live response.
3. **Against the storefront.** For a sample of products (every blend and pack, plus at least five others), the feed's price, SKU, stock and URL match the live product page.
4. **Certificates.** Every `coa_url` opens on the lab's own site and is for that product's current batch.
5. **Orders.** `since` set to now returns `"orders": []`; results run oldest `updated_at` first; `next_cursor` pages through without repeats; search the whole response for `@`, a phone number, a street, an IP and the words email, name, address, phone — none may appear.

Fix anything that fails, redeploy, and check again.

**The test order — required to submit.** A real order is the only thing that proves a sale through Peptides·Index reaches the orders endpoint. You guide; **the human places it**, and checks each step in the vendor portal's **Test order** step.

1. Set up a hidden test product, left out of the catalog and search, priced low — $5–$10, or whatever the payment processor accepts. Not a free or near-free order through a full-discount coupon: many processors and affiliate plugins skip those, and the order must be attributable to Peptides·Index. Give it a SKU, such as `PI-TEST` — an order line with no SKU fails the order.
2. The human opens this store's referral link from the vendor portal (usually `?ref=PEPTIDES`) with `&pi_click=testorder234` added, goes to the test product by its direct URL, checks at checkout that the referral code (usually `PEPTIDES`) is applied — entering it if not — and pays normally.
3. Confirm it appears in `GET /orders` (with `since` set before the order time) with the right `status`, `subtotal`, `discount`, `commissionable`, `attribution` and `items` (`sku`, `qty`), `click_id` exactly `testorder234` (or `null` if you could not build the capture), and no customer data. Then the human enters that `id` in the portal's **Test order** step and presses **Check** — it must pass before the refund.
4. They refund it in full through the payment gateway, from the store admin, so the money actually goes back. Confirm the same `id` comes back with the new `status`, `commissionable` at `0` and a later `updated_at`, then the human presses **Check refund** in the same step.
5. Delete or unpublish the test product.

**Report.** End with a short pass/fail list covering every check in this phase, the test order's `id`, and both endpoint URLs. Then tell the human: in the vendor portal, paste **both endpoint URLs**, catalog and orders, run the checker, use the portal's **Test order** step — **Check** after placing the order, **Check refund** after refunding it; both must pass, it is required — and submit, and ask them to tell you what the checker shows. Peptides·Index already holds the key. The pass/fail list is theirs to keep, or to send Peptides·Index if anything failed.

## Shopify theme stores — no code

For a Shopify store whose storefront is a **Shopify theme** (not headless), Phases 3–8 do not apply: nothing is built, no key is set and no test order is placed. Peptides·Index reads the catalog from the store itself. Ask which plan the store is on (Phase 2, item 7) and note it in your report. Then guide the human through four steps in their Shopify admin — you cannot click them yourself unless you have a browser and they have logged in:

1. **Create a Headless storefront.** Install Shopify's free **Headless** sales channel (apps.shopify.com/headless). **Sales channels → Headless → Add storefront.** Under **Storefront API permissions**, turn on product listings and product inventory, then copy the **private access token**.
2. **Publish every product to it.** **Products** → select all → **More actions → Include in sales channels** → the Headless storefront. Every product added later needs the same.
3. **Add a COA link field.** **Settings → Metafields and metaobjects → Products** (and/or **Variants**) → **Add definition**: name "COA URL", namespace and key `custom.coa_url`, type **URL**, tick **Storefronts** access, **Save**. Then paste each product's lab COA link — the lab's own verify page or lab-hosted PDF (Appendix A, "Lab certificates"). It will be used for verified COAs.
4. **Verify in the portal.** At https://peptides-index.com/vendors/portal choose **My storefront is a Shopify theme**, enter the store's domain (the .myshopify.com domain or the public store domain) and the private access token, and press **Verify**.

**Then help with what Verify shows.** It lists every product it could not match to a compound. For each one, compare the title with Appendix B: suggest a title that names the compound and its amount the way Appendix B does (for example "BPC-157 10mg"), or — when the title is fine but unusual — give the matching slug, so the human can rename the product or send the list to Peptides·Index. Products that are not research compounds (bacteriostatic water, supplies, merchandise) can stay unmatched. Never rename anything without the human's say-so. If Verify says products are not published to the Headless storefront, or finds no COA links, walk them back through step 2 or step 3. The referral step in the portal is the human's.

**Report.** The plan, which of the four steps are done, the unmatched products with your suggested titles or slugs, and what Verify shows.

---

# Appendix A — the spec (v2.0), verbatim

# Vendor catalog API

The Peptides·Index vendor catalog API spec, version 2.0.

**Two read-only endpoints, behind one key we issue you** — your catalog, which gets your prices, pack deals, shipping and lab certificates listed accurately, and the orders our readers place with you, which is how commission is reconciled.

## The entire requirement

`sku` · `name` · `url` · `price` · `compounds`

Five fields per product, plus your vendor name, URL and currency — that is the catalog. **And an orders endpoint**, behind the same key: the orders our readers placed with you, with no customer data in them (see "Orders"). Both are required to be listed. Everything else in this document is optional.

**`compounds` says what is in the product, in our words**: a `slug` from our compound list, the `amount` in one unit, and its `unit` — `{ "slug": "bpc-157", "amount": 10, "unit": "mg" }`. A product with no compounds — bacteriostatic water, syringes, merch — is not something we list. Leave it out.

Every field below is annotated with its **type** — `string`, `number`, `boolean`, `array`, `object`, or an `enum` with its allowed values — and whether it is required. If you are handing this to an AI coding assistant, copy the **AI onboarding prompt** from the top of the guide instead: it is this spec plus our current compound list plus step-by-step instructions, in one paste.

---

Today we read most vendors by scraping product pages. That works until it doesn't, and when it breaks, *your* listing is the one that goes wrong: a stale price, a missing pack deal, a vial whose size we couldn't parse and so couldn't rank at all. This spec is the fix. Expose two endpoints — your catalog and your orders — check our key on both, and your listing stays in step with your store, refreshed daily.

## Why bother — what it fixes, from real cases

| What happens without it | What this gives you |
| --- | --- |
| Your pack deal is printed on the page but not anywhere we can read, so we publish your single-vial price as your best price. One vendor's BPC-157 showed as **$3.50/mg** when their real floor was **$2.45/mg** — they looked 30% more expensive than they are. | `packs[]` and `volume_discount` — every deal, in the comparison. |
| A product's size lives only in prose ("a full 10mg per vial"). No size means no cost per mg, which means **the listing cannot be ranked at all**. | `compounds[].amount` — explicit. |
| A blend titled `10/3mg` gets read as 3 mg, pricing it at **$26.66/mg instead of $6.15**. | One `compounds` entry per component — no parsing, no guess. |
| A vial dosed in **international units** has no milligram figure, so it ranks against nothing — or a number meant as IU is read as mg. | `unit: "iu"` — the unit travels with the number, and IU is compared only with IU. |
| A spray labelled `50mg/30mL` gets read as 30. | `amount: 50` — the peptide, not the solvent. |
| Your product is called "RT-3" or "Reta 10". To you that is obviously retatrutide; to a scraper it is a guess, and a wrong guess puts your listing in someone else's comparison. | `compounds[].slug` — you name the compound from our list, so nobody guesses. |
| A reader outside the US sees your US shipping cost, or none at all, and finds out at checkout that delivery is $40 and three weeks. | `shipping[]` — cost **and** duration, per destination. |
| You re-slug a product; our old row keeps serving its last price with a link to a 404. | `sku` — stable, so a rename is a rename, not a phantom. |
| Your COA is a PDF we must OCR, or one we never find — or, worse, one we find by name that belongs to someone else. We once attributed 46 certificates to two vendors because a similarly-named company shared a word with them. | `coa_url` — a link to the **lab's own copy**. We read the certificate from the lab, so it is yours and nobody else's. |

We do not charge for a listing and we do not rank by commission. Accurate data is the entire benefit: a vendor whose prices and deals are right shows up correctly in every comparison a reader runs.

## The API

Two required endpoints, both behind the same key. Put them wherever they fit your stack and send us the full URLs. The catalog:

```
https://yourdomain.com/api/peptides-index/v2/catalog
```

| Endpoint | Required | Returns |
| --- | --- | --- |
| `GET /catalog` | Yes | Your vendor block and every product you want listed, in one response. |
| `GET /orders` | Yes | The orders our readers placed with you, without any customer data — see "Orders" at the end. |

**HTTPS only.** The catalog has no pagination, no incremental sync and no webhooks: we fetch the whole catalog every time, and a few hundred products is a few hundred kilobytes. A product that is in one response and missing from the next has been delisted, and we act on that — so a full catalog is self-describing in a way a partial one never is.

### The key

Both endpoints are gated with a **read-only key that we issue you** — one unique key per vendor, in the vendor portal's Integration kit. We send it on every request as a header:

```
Authorization: Bearer <your key>
```

1. **Store** it as an environment variable named `PEPTIDES_INDEX_KEY` on your server, and read it from there — or, on WordPress, in the Peptides·Index plugin's settings page (**Settings → Peptides Index**; a `PEPTIDES_INDEX_KEY` constant in `wp-config.php` overrides it). Keep it out of your source code and never commit it to a public repository: it is what keeps other sites out of your feed. A new value usually takes effect only after a redeploy.
2. **Check** it on every request. Missing or wrong key: answer `401` (a `403` is accepted too) and nothing else. Compare in constant time if your language offers it.
3. **Refuse to run without it.** If `PEPTIDES_INDEX_KEY` is missing or empty on your server, answer `503` with the body `{"error":"key_not_configured"}` — never `200`, and never compare our header against an empty string. Our checker reads that answer and tells you, in words, that the key is not set yet, instead of reporting a rejected key.
4. **Send** us the two endpoint URLs, catalog and orders, through the vendor portal. We already hold the key.

> **Don't let a cache serve it.** Send `Cache-Control: private, no-store` and `Vary: Authorization` on every response from both endpoints, including `401` and `503`. A page cache or CDN in front of your site can otherwise store the keyed response and hand it to the next visitor with no key at all — our checker requests the same URL without the key to catch exactly that, and fails the endpoint if a cache answers.

> **Never put the key in the URL.** We only ever send it as a header, and a `?key=` query string is written into your access logs, your CDN's logs, analytics and every proxy in between.

The catalog endpoint should serve your catalog and nothing else — not orders, not customers, not your store's admin API. Orders get their own endpoint, described at the end. We can rotate or revoke the key at any time: when we issue a new one, swap it into your environment variable, and the old one stops working the moment you do.

### HTTP behaviour we rely on

Mostly things your web server already does.

- **We fetch daily** and always fetch the full catalog. Rate limit us however you like; a handful of requests a day is the whole load.
- **`ETag` and/or `Last-Modified` — welcome, not required.** Return either and we send back `If-None-Match` / `If-Modified-Since`; a **304 Not Modified** with an empty body is a perfect answer when nothing changed.
- **Status codes that mean what they say.** `200` with data, `304` unchanged, `401` (or `403`) bad or missing key, `429` when we are asking too often, `503` during maintenance — and `503` with `{"error":"key_not_configured"}` when your own `PEPTIDES_INDEX_KEY` is not set. Please send `Retry-After` with `429` and `503`; we will back off rather than hammer you or, worse, conclude your catalog is empty.

> **Never return `200` with an empty or partial `products[]` during an outage.** A successful-looking empty catalog reads as "this vendor delisted everything". We guard against it on our side — a vendor yielding zero products is skipped, never pruned — but a `503` is unambiguous and safer for both of us.

### A pre-generated file is fine

If it is simpler, build the JSON on a schedule and serve that file from the endpoint — **as long as the same Bearer check guards it**. A public file at a guessable URL is not gated, and anyone can scrape your whole catalog from it. A WordPress/WooCommerce plugin, a route in a headless Shopify app, a small serverless function — anything that answers the URL with the right JSON, behind the key, is a correct implementation. **A Shopify store on a theme builds none of this:** the vendor portal connects the store directly instead (its Shopify steps).

## What we actually need

Per product: `sku`, `name`, `url`, `price` and `compounds`. At the top: your vendor `name`, `url` and `currency`. Without `compounds` we do not know what the product is or how much of it you sell, so we cannot compare it with anything.

That is the whole catalog requirement; the other required half is the orders endpoint (see "Orders"). Every catalog field below this is optional and improves what we can show; send what you have and omit the rest. **A missing field is read as unknown and handled. A wrong one gets published.**

A complete, working response can be this short:

```jsonc
{
  "schema_version": "2.0",
  "vendor": { "name": "Your Company", "url": "https://yourdomain.com", "currency": "USD" },
  "products": [
    { "sku": "BPC10", "name": "BPC-157 10mg", "url": "https://yourdomain.com/product/bpc-157/",
      "price": 39.00, "compounds": [ { "slug": "bpc-157", "amount": 10, "unit": "mg" } ] }
  ]
}
```

A blend, with its composition — the shape that is easy to get wrong. `blend` names it as one of our listed blends; `compounds` still lists every component with its amount, because that is what the price per mg is computed from:

```jsonc
{ "sku": "KLOW80", "name": "KLOW 80mg",
  "url": "https://yourdomain.com/product/klow/",
  "price": 120.00, "form": "vial",
  "blend": "klow",
  "compounds": [
    { "slug": "ghk-cu",  "amount": 50, "unit": "mg" },
    { "slug": "bpc-157", "amount": 10, "unit": "mg" },
    { "slug": "tb-500",  "amount": 10, "unit": "mg" },
    { "slug": "kpv",     "amount": 10, "unit": "mg" }
  ] }
```

Ship that first if you like — it already gets your prices right. The rest of this document is what you can add afterwards, in any order.

## Everything you can add

```jsonc
{
  "schema_version": "2.0",                // string, REQUIRED — the version of THIS SPEC, not of your catalog
  "updated_at": "2026-09-22T21:05:17Z",   // string | null — ISO 8601, when your catalog last changed
  "vendor": {
    "name": "Example Research",           // string, REQUIRED
    "url": "https://example.com",         // string, REQUIRED — your storefront
    "currency": "USD",                    // string, REQUIRED — ISO 4217, uppercase.
                                          // Applies to EVERY price in the feed.
    "ships_to": ["US", "CA"],             // array of strings — ISO 3166-1 alpha-2, uppercase
    // array of objects — what delivery costs and how long it takes, PER DESTINATION.
    // A country MUST NOT appear in more than one entry.
    //   countries : array of strings, REQUIRED — ISO alpha-2
    //   cost      : number — in `currency`; 0 means free
    //   free_over : number | null — order subtotal that makes it free; null = never
    //   days_min  : number | null — business days, low end
    //   days_max  : number | null — business days, high end
    "shipping": [
      { "countries": ["US"], "cost": 12, "free_over": 150, "days_min": 2, "days_max": 4 },
      { "countries": ["CA"], "cost": 25, "free_over": null, "days_min": 7, "days_max": 14 }
    ],
    // array of objects — EVERY method you accept, or omit the field entirely.
    //   method  : string, REQUIRED — "card" | "crypto" | "zelle" | "venmo" | "cashapp" | anything else you take
    //   pct_off : number | null — ONLY where paying that way costs less
    "payment_methods": [
      { "method": "card" },
      { "method": "crypto", "pct_off": 5 }
    ],
    // object | null — a percentage off for buying more. Omit or null if you have none.
    //   scope             : enum, REQUIRED — "cart" (mix and match across products)
    //                                      | "product" (N of the same item)
    //   tiers             : array of objects, REQUIRED — { min_qty: number, pct_off: number }
    //   stacks_with_packs : boolean — default false: we take whichever of this and
    //                       a product's `packs` is cheaper
    "volume_discount": {
      "scope": "cart",
      "tiers": [ { "min_qty": 3, "pct_off": 10 }, { "min_qty": 5, "pct_off": 20 } ],
      "stacks_with_packs": false
    }
  },
  "products": [
    {
      "sku": "BPC10",                        // string, REQUIRED — unique, and never changes
      "name": "BPC-157 10mg",                // string, REQUIRED — exactly as listed on your site
      "url": "https://example.com/p/bpc-157", // string, REQUIRED — absolute, on your own site
      // array of objects, REQUIRED — what is in ONE unit. One entry per compound.
      //   slug   : string | null, REQUIRED — from OUR compound list; null if it is not on it
      //   name   : string — REQUIRED when slug is null; what you call the compound
      //   amount : number, REQUIRED — per unit: per vial, per capsule, per bottle of spray
      //   unit   : enum, REQUIRED — "mg" | "mcg" | "iu"
      "compounds": [ { "slug": "bpc-157", "amount": 10, "unit": "mg" } ],
      // string | null — ONLY for a blend we list: the slug of that named blend,
      // one flagged "blend": true in /api/compounds. Omit it for a single
      // compound and for a blend we do not list. Never inside `compounds`.
      "blend": null,
      "form": "vial",                        // enum — "vial" | "capsule" | "tablet" | "spray" | "nasal" | "topical" | "kit" | "other"; default "vial"
      "unit_count": 1,                       // number — vials per unit sold, capsules per bottle; default 1
      "price": 39.00,                        // number, REQUIRED — ONE unit at quantity 1, in `currency`
      "min_qty": 1,                          // number | null — smallest quantity you sell; default 1
      "in_stock": true,                      // boolean | null — null means your platform cannot tell
      // array of objects | null — this product's own bundles.
      //   qty   : number, REQUIRED — units in the pack
      //   price : number, REQUIRED — the TOTAL for the pack, as your checkout shows it
      "packs": [ { "qty": 10, "price": 350.00 } ],
      // string | null — the LAB's own copy of the current batch's certificate:
      // its verify page or a lab-hosted PDF. Never a copy on your own site.
      "coa_url": "https://portal.ils-lab.com/verify/atZrY5XXDL3dPY0B"
    }
  ]
}
```

## Our compound list

Every `compounds[].slug` comes from one public endpoint — no key needed:

```
GET https://peptides-index.com/api/compounds
```

```jsonc
{
  "updated_at": "2026-09-22T21:05:17Z",
  "count": 74,
  "compounds": [
    { "slug": "bpc-157", "name": "BPC-157", "aliases": ["BPC-157", "Oral BPC-157"] },
    { "slug": "klow", "name": "KLOW", "aliases": ["KLOW"], "blend": true }
  ]
}
```

- **Match on `name` and `aliases`**, then send the `slug`. Aliases are the names vendors actually use, so your product name is often already in there.
- **Named blends carry `"blend": true`**; every other entry omits the key. A blend slug goes in a product's `blend` field, never in `compounds`.
- **Slugs are permanent.** Each one is the last part of that compound's public URL on our site (`/peptide/bpc-157`), so it does not change. Map once and the mapping holds.
- **Not on the list?** Send `"slug": null` with your `"name"` for the compound. We review it and either map it to an existing slug or add a new one; until then that product is not ranked. Never invent a slug — one we do not have matches nothing.

The list is also embedded in the AI onboarding prompt on this page, for assistants that cannot make web requests.

## Field notes, in order of how often they go wrong

### `price` is ONE unit, at quantity 1

> **`price` is what one unit costs when a customer adds it to the cart at quantity 1. Never a pack total, never a derived per-mg figure.** Ten vials for $350 is a `packs` entry, not `price: 350`.

This is the single easiest field to get wrong, because most ecommerce systems have exactly one "price" and for a bundle product it is the bundle's. Exporting that straight through multiplies your cost per mg by the bundle size and drops you to the bottom of every comparison you should be winning.

"One unit" is whatever your cart counts as one:

| You sell | Send |
| --- | --- |
| One 10 mg vial for $39 | `price: 39`, `unit_count: 1` |
| A box of 5 vials as a single item, $175 | `price: 175`, `unit_count: 5` — your cart counts one box |
| Single vials, but only in fives, $35 each | `price: 35`, `min_qty: 5` — your cart counts vials, and five is the minimum |
| A bottle of 60 capsules for $60 | `price: 60`, `form: "capsule"`, `unit_count: 60` |

`price` is always present. **If you cannot price a product, leave the product out** rather than sending `0` or `null` — a zero price is the cheapest listing on the site.

### `compounds` — what is in it, in our words

`compounds` is an **array of objects**, one per compound in the product. Each has a `slug`, an `amount` and a `unit`.

- **`slug` comes from our compound list**, not from your product names — see "Our compound list" above. A slug names the compound, not the form: BPC-157 capsules are `bpc-157` with `form: "capsule"`.
- **`amount` is per unit** — per vial, per capsule, per bottle of spray. Not the package total (we multiply by `unit_count`), not a concentration, and not a dose. A 10 mL vial holding 600 mg is `amount: 600`. A bottle of 60 × 0.5 mg capsules is `amount: 0.5` with `unit_count: 60`.
- **`unit` is `"mg"`, `"mcg"` or `"iu"`.** IU and milligrams measure different things and there is no conversion between them. An IU listing is compared with other IU listings, never with a milligram one.
- **A blend is one entry per component**, with the amounts you already print on the label. A `10/3mg` Tesamorelin/Ipamorelin is two entries, `tesamorelin` at `10` and `ipamorelin` at `3`. Do not send the sum, and do not send percentages — you know the vial holds 10 mg and 3 mg, and round-tripping that through 76.9% / 23.1% loses precision in both directions. Mixed units are fine (`5` mg and `500` mcg); we normalise them.
- **A compound that is not on our list** gets `"slug": null` and your `"name"` for it. We review it and either map it to an existing compound or add it; until then it is not ranked.

### `blend` — naming one of our blends

Some blends are compounds on our list in their own right — `klow`, `wolverine`, `tesamorelin-ipamorelin` — flagged `"blend": true` in [/api/compounds](https://peptides-index.com/api/compounds). If your product is one of them, set `"blend"` to that slug **and** still list every component in `compounds` with its amount. The `blend` field decides which blend page you appear on; the components decide your price per mg.

- **A blend we do not list:** omit `blend` and just list the components. Any product with two or more compounds is treated as an unnamed blend.
- **Never put a blend slug inside `compounds`.** `{ "slug": "klow", "amount": 80 }` hides the composition and gives us nothing to compute from.
- We do not infer `blend` from components: two vendors' "KLOW" can differ, so the named blend is yours to state.

**A size you cannot state honestly is better left out** — send the product without it and tell us. A missing size costs you a ranking; a wrong one costs a reader's trust.

### `form` decides what you are compared against

Cost per mg is only meaningful within a route: capsules and vials are never ranked against each other, because oral use needs far more material and mg-for-mg would make capsules look 2–37× better. Getting `form` right protects you from a flattering comparison a reader would rightly distrust.

We group the forms into routes — injectable (`vial`), oral (`capsule`, `tablet`), spray (`spray`, and `nasal` for nasal drops, which is compared with spray), and `other` — and compare only within one. A wrong `form` is not a small error: it is your listing in someone else's table.

**Topical: accepted, price comparison coming soon.** Serums, creams and gels are `form: "topical"`. We list them, but do not yet compare them on cost per mg; when topical comparison arrives, your listings join it with no change on your side.

**Ten identical vials sold as one item is not a kit.** Many shops call that a "kit"; for us it is `form: "vial"` with `unit_count: 10`, or a `packs` entry. `form: "kit"` is for a bundle that is not one thing — different compounds in one box, or a vial sold with its water and syringes. We list a kit, but we do not rank it on cost per mg.

### `url` must be on your own site

Every product `url` has to be on the store you applied with: the same host, a subdomain of it, or its parent (`shop.example.com` for `example.com`, or the other way round). A sibling subdomain (`store.example.com` when you applied with `shop.example.com`) or any other site is refused: the checker reports it as `product-domain` and that product is not imported. Our Buy button sends shoppers to this link with our referral code added, so it must be the product page itself.

### `sku` must be unique and stable

It is how we know your renamed product is the same product. **A changed SKU is a new product**: the old listing looks delisted, the new one looks new, and its price history starts again from zero. Two products sharing a SKU is worse — one of them silently disappears.

### Shipping is per destination, and duration counts as much as cost

`shipping` is an **array of objects**, and **a country must not appear in more than one entry**. We could have said "the first matching entry wins", which would have made the order of a JSON array into business logic — reordering your entries would change the price a reader sees. An overlap is a validation error instead: we tell you, you fix it.

Two vendors at the same cost per mg are not the same offer to someone in Toronto. One ships for $12 in two days; the other charges $40 and takes three weeks through customs. `days_min` / `days_max` are **business days**, and a range is fine — most readers want to know whether it is "this week" or "next month".

A country in `ships_to` with no `shipping` entry is shown as "ships here, cost unknown". That is honest, but a real number is better.

### `free_over` is worth more than it looks

A $12 flat fee on a $39 vial is almost a third of the price again, and a reader comparing two vendors at $3.90/mg is really choosing between very different totals. Free-over thresholds also decide whether buying three is cheaper than buying one somewhere else.

### `payment_methods` changes the real price

Several vendors take 5–10% off for crypto or a bank transfer and say so only at checkout — by which point a reader has already chosen someone else on a headline number that was never the number. If you do this, tell us: it is one of the few things that genuinely moves where you land.

> **Send the whole list or none of it.** A partial list reads as the complete set and sends a reader to a checkout that wanted something else. We tried reading payment methods off storefronts and a scan of one vendor's page found "crypto, Venmo, Cash App" and missed "card" — from a single sentence that listed all four. So we publish nothing unless we have the lot.

### `packs` vs `volume_discount`

`packs` is **this product**: "10 vials of BPC-157 for $350". Its `price` is the **total for the pack**, exactly as your checkout shows it — not a per-vial figure; we divide. `volume_discount` is a percentage off for quantity: with `scope: "cart"` it is mix-and-match ("any 3 vials, 10% off"); with `scope: "product"` it counts only the same item ("3 of the same, 10% off"). Both are welcome; they display differently because they are different offers.

### How the discounts combine

You can express three price reductions — `packs`, `volume_discount` and a `payment_methods` percentage — and we compute a best achievable price from them. The rule we apply:

1. **Quantity discounts do not stack with each other.** `packs` and `volume_discount` are alternatives and we take whichever is cheaper at that quantity. Set `volume_discount.stacks_with_packs: true` **only** if your checkout genuinely applies both — if it is on and your checkout does not honour it, we publish a price nobody can buy at.
2. **A payment-method discount applies on top**, because it is nearly always a percentage off the final total. If yours is either/or with a quantity discount, tell us when you send the endpoint.

So ten vials on a pack, paid in crypto at 5% off, is the pack price less 5%.

### `min_qty` when you do not sell singles

Some vendors' smallest purchase is several of an item. Keep `price` as the one-unit figure and set `min_qty` to the number that has to be bought; we multiply. Omit the field if you sell singles. A spec with nowhere to say "you cannot buy one of these" invites a per-vial price nobody can actually pay, which is what we once published for one vendor until we caught it.

### `in_stock`

`true` or `false` if you know. **`null` if your platform genuinely cannot tell** — some cannot. `null` is shown as "unknown", never as out of stock, so it costs you nothing; `true` on something you cannot ship costs a reader a wasted checkout.

## Lab certificates: send the link, not the numbers

We do not want purity figures, test panels or lab names from you. **We read them from the lab.** Send `coa_url` — a link to the laboratory's own copy of the certificate for the batch you are selling now — and we identify the lab from the domain, parse the certificate reference out of the link, and fetch the result from the lab directly.

| Lab | What a `coa_url` looks like |
| --- | --- |
| ILS | `https://portal.ils-lab.com/verify/<id>` |
| Freedom Diagnostics | `https://coas.freedomdiagnosticstesting.com/<code>.pdf` |
| Janoshik | `https://verify.janoshik.com/tests/<task>_<key>` |
| Vanguard | `https://verifiedbyvanguard.com/verify/<code>` |

Another lab is fine — send its verify page or lab-hosted PDF and we will add it.

Why the lab's copy and not yours:

- **It is a stronger claim on your behalf.** A certificate we fetched from the lab is one a reader does not have to take your word for. A PDF on your own server could be anyone's.
- **It cannot be attributed to the wrong vendor.** Matching certificates by name is how 46 of them once ended up on two vendors that had never commissioned them. A link you give us, on the lab's domain, is yours by construction.
- **It carries the whole panel.** Purity barely separates vendors — across everyone we list it runs from about 99.3% to 99.8%. Endotoxin, sterility, heavy metals and fentanyl screening do, and only about one certificate in five we hold carries a fentanyl screen. Reading the lab's record means everything you paid to have tested gets shown.

> **A `coa_url` on your own domain is treated as unverified.** We will link it, labelled as vendor-hosted, but we do not read results from it. If your lab has no public verify page or hosted PDF, send what you have and name the lab when you contact us.

Keep it current: when a new batch is tested, update the link. An old batch's certificate on a new batch's listing is the one thing in this section that can actually hurt you.

## What we compute ourselves

Do not send derived numbers. From `price`, `unit_count`, `packs` and `compounds` we compute:

- **per-vial price** (or per capsule) — `price ÷ unit_count`, and the same for every pack;
- **package total** — `amount × unit_count`, summed across components for a blend;
- **cost per mg** (or per IU) — within one form only;
- **best achievable price** — packs, volume discount and payment discount combined as described above.

If you send your own versions of these, they will disagree with ours on rounding at the very least, and a reader will see two numbers.

## What we do with it

1. **Fetch on schedule** — daily, with your key, respecting `Retry-After`. We fetch only the URL you give us. It is not permission to crawl the rest of your site, and your `robots.txt` does not apply to it.
2. **Publish** price, cost per mg, stock, packs, shipping and the lab's certificate on each listing, matched to our compound pages by `slug`.
3. **Never** alter your prices, and never rank by what we earn.

## Check it yourself before you send it

Every feed is validated on our side, and a failure is a conversation rather than a silent drop. These are the same checks — run them first and there will be nothing to talk about:

| Check | Why it exists |
| --- | --- |
| Every product has `sku`, `name`, `url`, `price` and `compounds` | They are the whole requirement. |
| `sku` is unique within the response | Two products on one SKU and one of them disappears. |
| `price` is present and greater than 0 on every product | A zero price is the cheapest listing on the site. Omit what you cannot price. |
| `vendor.currency` is an ISO 4217 code (`USD`, `EUR`, `GBP`…) | Every price in the feed is read in it. |
| Every `compounds[].amount` is positive, and `unit` is `mg`, `mcg` or `iu` | A zero or missing amount means no cost per mg. |
| Every `compounds[].slug` is on our list, or `null` with a `name` | An invented slug matches nothing. |
| `blend`, if present, is a slug flagged `"blend": true` in /api/compounds, and that product lists 2 or more `compounds` | A named blend without its components cannot be priced per mg. |
| No blend slug appears inside `compounds` | It hides the composition. |
| Every pack's price per vial (`price ÷ qty`, then `÷ unit_count`) is below the single price | A pack that costs more per vial is almost always a per-vial figure sent as a total, or the reverse. |
| No country appears in two `shipping` entries | Otherwise the rate a reader sees depends on array order. |
| `coa_url` is on the lab's domain, not yours | Only a lab-hosted certificate is read and shown as verified. |
| `stacks_with_packs` is `true` only if your checkout really applies both | Otherwise we publish a total nobody can buy at. |
| Diluents, syringes and merch are left out | Anything with no `compounds` is ignored anyway. |
| No `Authorization` header → `401` or `403`; wrong key → `401` or `403`; the key we issued → `200` | The endpoint is gated, and our key is the only way in. |
| With `PEPTIDES_INDEX_KEY` unset or empty on the server → `503` `{"error":"key_not_configured"}`, never `200` | A missing key must not open the endpoint, or match an empty header. The 503 tells both of us exactly what is wrong. |
| Right after a keyed `200`, the same URL with no header still gets `401` or `403` — not a cached `200` | Our checker makes exactly that request. A page cache or CDN that replays the keyed answer opens your feed to anyone; `Cache-Control: private, no-store` and `Vary: Authorization` prevent it. |

## Validating it

Send both endpoint URLs, catalog and orders, through the [vendor portal](https://peptides-index.com/vendors/portal) — we already hold the key, so there is nothing else to send. We run it and reply with exactly what we parsed — every product, compound, pack and certificate we read — so you can see your listing before it is live. If a field is wrong, we would rather find it in the diff than after publication.

## Orders

The second endpoint: the orders our readers placed with you, with no customer data in them. **It is required to be listed**, alongside the catalog. It does not change how you rank — ranking is on price and data, never on what we earn.

### Why it exists

We watch what we send you. Clicks alone say which links people follow, not which of them became an order, a refund or nothing at all — and the only other place to find that out is your affiliate portal, which somebody has to log into and read by hand. An orders endpoint replaces that login: we read the same figures on a schedule, and it is how commission is reconciled — when your numbers and ours disagree, we both have one record to point at.

### The request

```
GET <your orders URL>?since=<ISO-8601 UTC>&cursor=<opaque>
Authorization: Bearer <your key>
```

- **The same key as the catalog**, checked the same way: missing or wrong key → `401` or `403` and nothing else; `PEPTIDES_INDEX_KEY` unset on your server → `503` `{"error":"key_not_configured"}`. There is no second key to issue or store.
- **`since` means orders UPDATED since then, not created.** A refund or a cancellation changes an order you already sent us, and it only reaches us if its `updated_at` moves and `since` catches it. Example: `since=2026-09-22T00:00:00Z`.
- **`cursor`** is yours: an opaque string from your last page's `next_cursor`, passed back unchanged with the same `since`. The first request has no `cursor`. Build it from the last order's `updated_at` and `id`, and return the orders strictly after that pair — not a page number or an offset, which skips an order that changes during the pull.
- The status codes are the catalog's: `200` with data, `401` or `403`, `429` and `503` with `Retry-After`. Never `200` with an empty list during an outage — it reads as "no orders".

Send us the URL with the catalog's. It can be any path; `/api/peptides-index/v2/orders` beside the catalog is a sensible default.

### The response

```jsonc
{
  "schema_version": "2.0",                  // string, REQUIRED — the version of THIS SPEC, as in the catalog
  "orders": [                               // array, REQUIRED — at most 500 per page; [] when there are none
    {
      "id": "10482",                        // string, REQUIRED — your order number; stable, never reused
      "created_at": "2026-09-20T14:02:11Z", // string, REQUIRED — ISO 8601, UTC; a real date, 2000 or later
      "updated_at": "2026-09-21T09:30:00Z", // string, REQUIRED — ISO 8601, UTC; moves on every change
      // enum, REQUIRED — "pending" | "paid" | "fulfilled" | "cancelled" | "refunded" | "partially_refunded"
      "status": "paid",
      "currency": "USD",                    // string, REQUIRED — ISO 4217, uppercase
      "subtotal": 120.00,                   // number, REQUIRED — the items, before discounts; no shipping, no tax
      "discount": 12.00,                    // number, REQUIRED — the amount taken off the items; 0 if none
      "commissionable": 108.00,             // number, REQUIRED — what the agreed commission applies to
      // enum, REQUIRED — how the order reached you from us: "coupon" | "ref" | "both"
      "attribution": "both",
      "coupon_code": "PEPTIDES",            // string | null, OPTIONAL — the code as entered; null or left out when none
      "click_id": "k3v7q2mz5xab",           // string | null — our pi_click value from the visit; null only when the order came without one
      // array of objects, REQUIRED — what was bought
      //   sku : string, REQUIRED — the same sku as in your catalog
      //   qty : integer, REQUIRED — greater than 0
      "items": [ { "sku": "BPC10", "qty": 3 } ]
    }
  ],
  "next_cursor": null                       // string | null — pass it back for the next page; null on the last one
}
```

- **Only orders attributed to us** — placed with our referral code, arriving through our referral link, or both. Both are shown in the vendor portal (usually `PEPTIDES` and `?ref=PEPTIDES`). Nobody else's orders, not even with the customer removed.
- **At most 500 orders per page.** Past that, return a `next_cursor` and we ask again.
- **At most 20 pages per pull.** We follow `next_cursor` for 20 pages — 10,000 orders — and stop; the rest come on the next pull, which asks from the latest `updated_at` we hold, less a two-day overlap.
- **Sorted by `updated_at`, oldest first, across every page.** That is what makes stopping at page 20 safe: everything up to the last order we read has arrived, so the next pull resumes from there without a gap. In any other order, a pull that stops early holds a late `updated_at` while earlier orders it never reached fall behind `since`, and never arrive.
- **An order can reach us more than once** — on a later page, or again after it changes. We key on `id`, and the copy with the later `updated_at` wins, so re-sending is always safe.
- **Amounts are JSON numbers, 0 or more, and under 10 billion.** `108.00`, not `"108.00"`. A negative `subtotal`, `discount` or `commissionable` fails the order, and so does one that does not round to less than `9999999999.99`. We store cents: amounts are compared after rounding to two decimals.
- **Timestamps are real dates.** `created_at` and `updated_at` must exist on the calendar (`2026-02-30` fails, it is not rolled into March), be no earlier than `2000-01-01`, and be no more than a day ahead of our clock. A date in the far future would otherwise hold the window of every later pull.
- **One field is optional: `coupon_code`.** Leave it out or send `null` — absent reads as `null`. Our code is the one shown in the vendor portal (usually `PEPTIDES`), and `attribution` already says whether it was used.
- **`click_id`: build the capture wherever your platform can carry it (below).** It is `null` on an order that arrived without a `pi_click`, such as one where the customer typed our code, or on a platform that cannot carry it. When you send either field, it is a string within its limit below.
- **Field limits.** Longer values are rejected, not truncated — a cut-off order number would match the wrong order, or none. Lengths are counted in bytes, so a character outside plain ASCII counts as two or more:
  - `id` — at most 100 characters, and not empty
  - `coupon_code` — at most 100 characters
  - `click_id` — at most 64 characters
  - `items[].sku` — at most 120 characters, and not empty
- **A bad order is skipped, not half-stored.** An order that breaks any rule here is left out and the rest of the page is kept; the checker names it. A page that is not in the orders shape, or a `next_cursor` we have already been given, ends the pull — nothing from it is stored, and we try again next time.

> **No customer data, ever.** Anywhere in the response: no names, emails, phone numbers, addresses, IP addresses, customer ids or user ids, order notes or client details — not in a field of their own, not inside another one, not "just the city", and not beside the orders either (a top-level `"customers"` list counts). We have no use for them and do not want to hold them. **A response that contains any of them fails the check**, whatever else in it is right; an order carrying one is never stored, and a page carrying one outside its orders ends the pull.

### `commissionable` is the number the commission is worked out from

It is the amount your agreed commission applies to: **after discounts, before shipping and tax**. So it can never be more than `subtotal` − `discount`. It can be less — if some products earn no commission, leave them out of it — but never more. We send the figure rather than working it out ourselves because only your checkout knows what it excludes; if we derived it, the two sides would disagree on the first order that had an exception.

A refund lowers it: a `partially_refunded` order sends the amount that is still commissionable — commission is paid on that remaining amount — and a `refunded` or `cancelled` one sends `0`.

### `click_id` — build it with the endpoint, if your platform can

Our outbound links carry a `pi_click` parameter — `?pi_click=k3v7q2mz5xab` on the URL a reader lands on. Keep it when the reader lands (a first-party cookie or the session, for about 30 days), attach it to the order they place, and send it back as `click_id`, unchanged. Store it only if it is 1–64 characters of printable ASCII, otherwise store nothing; a later landing with a valid value replaces the earlier one. A page cache (WP Rocket, Cloudflare APO) can skip PHP on the landing page, so capture it where code actually runs — a small script, or at add-to-cart or checkout — not only on page load. It lets both sides match an order to the exact visit that produced it, rather than to a code anyone can type — and it is far easier to build now, alongside the endpoint, than to come back for later. Most stores can: WooCommerce and most custom stores easily, Shopify through a cart attribute.

Treat it as opaque — today it is 12 characters, `a`–`z` and `2`–`7`, but do not validate it against that. `click_id: null` is right for an order that arrived without one; `coupon` / `ref` attribution still works for those. If your platform cannot carry a value from the landing page to the order, send `null` — nothing fails, and it does not hold up your listing.

### What the orders checker runs

The same idea as the catalog checks: run them first and there is nothing to discuss.

| Check | Why it exists |
| --- | --- |
| No `Authorization` header → `401` or `403`; wrong key → `401` or `403`; our key → `200` | Orders are more private than a catalog. Our key is the only way in — a check that only looks for the header is not a gate. |
| With `PEPTIDES_INDEX_KEY` unset or empty on the server → `503` `{"error":"key_not_configured"}`, never `200` | A missing key must never open your orders. The checker reports it as "not set yet", not as a rejected key. |
| The response is valid JSON in the orders shape above | A shape we cannot read is a shape we cannot count. |
| No customer data anywhere in the response | Names, emails, phones, addresses, IPs, notes and customer ids — on an order or anywhere around the orders — fail it outright. |
| `commissionable` ≤ `subtotal` − `discount` on every order | A commission base larger than what the customer paid for the items is an error, whichever way it happened. |
| Every `items[].sku` is in your catalog | A warning, not a failure: an item you have since delisted is still a real sale. |
| `since` set to the current time returns `"orders": []` | Proves `since` is applied — otherwise every poll re-reads your whole order history. |
| `next_cursor`, passed back as `cursor`, returns the next page — none of the first page's orders again | A cursor that is ignored sends the first page forever, and we never see the rest. |
| Orders are sorted by `updated_at`, oldest first, on the first page and into the next | A warning, not a failure: out of order, a pull that stops at page 20 can leave orders behind for good. |
| `status`, `currency` and `attribution` use the listed values; `currency` is three uppercase letters | A status we do not recognise is an order we cannot count. |
| A `click_id` is shaped like ours | A warning, not a failure: something else in that field means the store is sending the wrong value. |

### Before you submit: one test order (required)

The checker proves the endpoint answers correctly; it cannot see a real sale. So before you submit — it is required — place one real test order: make a hidden test product priced low — $5–$10, or whatever your payment processor accepts (a free or near-free order is one many processors and affiliate plugins skip, and it must be attributed to us) — and give it a SKU, such as `PI-TEST`: an order line with no SKU fails the order. Open your referral link from the vendor portal with `&pi_click=testorder234` added (the portal's **Test order** step shows it), go to the test product by its direct URL, check at checkout that your referral code is applied (enter it if not), and pay normally. Confirm it appears in your orders endpoint (with `since` set before the order), then use the portal's **Test order** step: enter the order id and press **Check** after placing it — while it is paid, within 30 days. Then refund it through your payment gateway, confirm it comes back with the new status, `commissionable` at `0` and a later `updated_at`, and press **Check refund** after refunding it. Both must pass before you can submit. Delete or unpublish the test product afterwards. The AI prompt walks you through it (Phase 8).

---

Peptides·Index · vendor catalog API, spec version 2.0 · September 2026.

---

# Appendix B — Peptides·Index compound slugs

72 compounds — a snapshot of https://peptides-index.com/api/compounds; prefer the live endpoint if you can fetch it. Use the slug exactly as written; a compound not listed here is `"slug": null` plus its `"name"`.

| slug | compound | aliases | blend |
| --- | --- | --- | --- |
| `5-amino-1mq` | 5-Amino-1MQ | 5-Amino-1MQ |  |
| `adamax` | Adamax | Adamax |  |
| `ahk-cu` | AHK-Cu | AHK-Cu |  |
| `aod-9604` | AOD-9604 | AOD-9604 |  |
| `ara-290` | ARA-290 | ARA-290 |  |
| `b12` | B12 | B12, B12 10mg/ml |  |
| `bpc-157` | BPC-157 | BPC-157, Oral BPC-157, PR-BPC-157 |  |
| `cagrilintide` | Cagrilintide | C-10, CAGRI, Cagrilintide |  |
| `cardiogen` | Cardiogen | Cardiogen |  |
| `cartalax` | Cartalax | Cartalax |  |
| `cjc-1295` | CJC-1295 (No-DAC) | CJC-1295, CJC-1295 No DAC, CJC-1295, no DAC, Mod GRF 1-29, Modified GRF 1-29 |  |
| `cjc-1295-dac` | CJC-1295 DAC | CJC-1295 DAC, CJC-1295 W/ DAC, CJC-1295 with DAC |  |
| `cjc-1295-ipamorelin` | CJC-1295 / Ipamorelin | CJC + IPA, CJC-1295 (No DAC)/Ipamorelin Blend, CJC-1295 + Ipamorelin, CJC-1295 No DAC + Ipamorelin, CJC/Ipamorelin 5/5mg (No DAC), Combo Vial: CJC-1295 no DAC/Ipamorelin, Ipamorelin/CJC-1295, RR CJC/IPA | blend |
| `dihexa` | Dihexa | Dihexa, Dihexa Capsules - 10mg, 30 Capsules |  |
| `dsip` | DSIP | Delta Sleep-Inducing Peptide (DSIP), DSIP |  |
| `eloralintide` | Eloralintide | ELO, Elora, Eloralintide, LY3841136 |  |
| `epitalon` | Epitalon | Epitalon, Epithalon |  |
| `foxo4-dri` | FOXO4-DRI | FOX04, FOX04-DRI, FOXO4-DRI |  |
| `ghk-cu` | GHK-Cu | GHK-Cu |  |
| `ghk-cu-kpv` | GHK-Cu / KPV | GHK-Cu + KPV – Halo blend, GHK-Cu/KPV Blend, HALO | blend |
| `glow` | GLOW | GHK-CU 50mg + BPC-157 10mg + TB-500 10mg (GLOW 70mg), GHK-Cu/BPC-157/Thymosin Beta-4, GLOW, GLOW Blend, GLOW Blend: BPC-157/TB-500/GHK-Cu, GLOW Bundle | blend |
| `glutathione` | Glutathione | Glutathione, L-Glutathione |  |
| `hcg` | HCG | HCG |  |
| `hexarelin` | Hexarelin | Hexarelin |  |
| `humanin` | Humanin | Humanin |  |
| `igf-1-lr3` | IGF-1 LR3 | IGF-1 LR3 |  |
| `ipamorelin` | Ipamorelin | Ipamorelin |  |
| `kisspeptin` | Kisspeptin | Kisspeptin, Kisspeptin-10 |  |
| `klow` | KLOW | GHK-Cu / TB-500 / BPC-157 / KPV, GHK-Cu 50mg + BPC-157 10mg + TB-500 10mg + KPV 10mg (KLOW 80mg), GHK-Cu/KPV/BPC-157/Thymosin Beta-4, GHK-Cu/KPV/Thymosin Beta-4/BPC-157, KLOW, KLOW 2.0, KLOW Blend, KLOW Blend: BPC-157/TB-500/GHK-Cu/KPV, KLOW Bundle | blend |
| `kpv` | KPV | KPV |  |
| `l-carnitine` | L-Carnitine | L-Carnitine, L-Carnitine 600mg/ml |  |
| `lipo-b` | Lipo-B | Lipo-B |  |
| `livagen` | Livagen | Livagen |  |
| `ll-37` | LL-37 | LL-37 |  |
| `melanotan-i` | Melanotan I | Melanotan 1, Melanotan I, MT1 |  |
| `melanotan-ii` | Melanotan II | Melanotan 2, Melanotan II, Melanotan M2T, MT2 |  |
| `methylene-blue` | Methylene Blue | Methylene Blue |  |
| `mots-c` | MOTS-c | MOTS-c, PR-MOTS-C |  |
| `n-acetyl-selank-amidate` | N-Acetyl Selank Amidate | N-Acetyl Selank Amidate, NA Selank, NA Selank Amidate |  |
| `nad-mots-amino` | NAD+ / MOTS-c / 5-Amino-1MQ | NAD+ + MOTS-c + 5-Amino-1MQ, NAD+ Blend | blend |
| `nad-plus` | NAD+ | NAD+, NAD+ Buffered, NAD+ Unbuffered |  |
| `o-304` | O-304 | O-304 |  |
| `oxytocin` | Oxytocin | Oxytocin |  |
| `pe-22-28` | PE-22-28 | PE-22-28 |  |
| `pinealon` | Pinealon | Pinealon |  |
| `pnc-27` | PNC-27 | PNC-27 |  |
| `prostamax` | Prostamax | Prostamax |  |
| `pt-141` | PT-141 | PT-141 |  |
| `retatrutide` | Retatrutide | GLP RT, GLP-3 Retatrutide, GLP-3 RT, GLP-RT, GLP3, GLP3-R, GLP3-RETA, R-10, R-30, RETA, Reta / GLP3-RT, Retatrutide, RR-RT, RT-3, TRIPLE AGONIST |  |
| `selank` | Selank | Selank, Selank Spray |  |
| `selank-semax` | Selank / Semax | Selank + Semax | blend |
| `semaglutide` | Semaglutide | GLP SM, GLP-1 Semaglutide, GLP1-S, GLP1-Sema, S-5, SEMA, Semaglutide, Single Agonist |  |
| `semax` | Semax | Semax, Semax Spray |  |
| `sermorelin` | Sermorelin | Sermorelin |  |
| `slu-pp-332` | SLU-PP-332 Tablets | SLU-PP-332, SLU-PP-332 Capsules - 1mg, 60 Capsules |  |
| `slu-pp-915` | SLU-PP-915 | SLU-PP-915 |  |
| `snap-8` | SNAP-8 | SNAP-8 |  |
| `ss-31` | SS-31 | MTS-31, RR RR-31, RR-31, SS-31 |  |
| `super-h` | Super-H | Super-H |  |
| `tb-500` | TB-500 | PR-TB-500, TB-500, Thymosin Beta-4 |  |
| `tesamorelin` | Tesamorelin | Tesa 10mg, Tesamorelin |  |
| `tesamorelin-ipamorelin` | Tesamorelin / Ipamorelin | Ipamorelin/Tesamorelin, TESA+IPA, Tesamorelin + Ipamorelin, Tesamorelin/Ipamorelin Blend | blend |
| `tesofensine` | Tesofensine | Tesofensine |  |
| `testagen` | Testagen | Testagen |  |
| `thymogen` | Thymogen | Thymogen |  |
| `thymosin-alpha-1` | Thymosin Alpha-1 | TA1, Thymosin Alpha-1 |  |
| `thymulin` | Thymulin | Thymulin |  |
| `tirzepatide` | Tirzepatide | Double Agonist, DUAL AGONIST, GLP TZ, GLP-2, GLP-2 Tirzepatide, GLP-2 TZ, GLP-TZ, GLP2-T, GLP2-Tirz, T-10, T-30, TIRZ, Tirzepatide, TRZ-2, Twin'cretin |  |
| `vesugen` | Vesugen | Vesugen, Vesugen Capsules Box, Vezugen |  |
| `vilon` | Vilon | Vilon |  |
| `vip` | VIP | VIP, VIP (Vasoactive Intestinal Peptide) |  |
| `wolverine` | Wolverine | BPC-157 + TB-500 Blend, BPC-157 + TB-500 Wolverine Blend, BPC-157 + TB500, BPC-157/TB-500 (10/10mg) Wolverine, BPC-157/TB-500 Blend, BPC-157/Thymosin Beta-4, Combo Vial: BPC-157/TB-500, TB-500 / BPC-157 Blend, Wolverine, Wolverine Blend | blend |
