Integration guide

Fulfillment API

For a store that is not on WooCommerce: send each paid order to the warehouse, keep stock quantities in sync, and get tracking back by a signed webhook. Base URL https://peptiport.com/api/v1.

Build it with AI

Built your site with Lovable, Bolt, v0 or an AI assistant? Open it with the integration already described, or copy the full prompt. It covers sending orders, stock, the webhook and keeping your key off the browser.

Or open it in

Works with Cursor, Replit, Windsurf, Gemini, Copilot or anything that writes code: copy the prompt and paste it. No key is in the prompt or the links; your AI will ask you for it.

Agent with MCP (Claude Code, Cursor)?

Your API key is server-side only. In a browser it is readable by anyone who opens the page, and this API refuses browser calls anyway. If a key leaks, revoke it in the dashboard (Store connection) and make a new one.

Authentication

Every request sends the brand's API key:

example
Authorization: Bearer pp_live_...

The brand creates keys in the PeptiPort dashboard, Store connection page. A key is shown once; it is stored only as a hash. Each key has scopes:

ScopeAllows
orders:writePOST /orders, POST /orders/{order_number}/cancel
orders:readGET /orders, GET /orders/{order_number}
stock:readGET /stock, GET /catalog
catalog:writePUT /catalog, POST /catalog

GET /me and GET /status work with any valid key.

Rules:

  • Server-side only. Keep the key in an environment variable named PEPTIPORT_API_KEY on your server or serverless function. Never ship it to the browser: the API sends no CORS headers, so browser calls fail anyway.
  • A key reaches only its own brand's orders and stock.
  • 120 requests per minute per key (each MCP tool call counts); over that you get 429. Back off and retry. A brand can hold up to 10 active keys.
  • Revoked keys answer 401 immediately.

Errors

Every error has the same shape and a stable code:

json
{ "error": { "code": "missing_fields", "message": "Missing or empty: address.zip.", "fields": ["address.zip"] } }
HTTPcodeMeaning
400invalid_json, invalid_body, invalid_status, invalid_sinceThe request is malformed
401unauthorizedMissing, wrong or revoked key
403forbidden, api_disabledThe key lacks the scope this call needs, or PeptiPort has switched the brand's API off
404not_foundNo such order for this brand
409order_locked, not_cancellableThe warehouse has already labeled (or you cancelled) the order
409test_mismatchA test order and a real order cannot share an order number
409mass_delistA full catalog would delist most products; nothing changed
413too_large, too_many, catalog_fullBody over 64 KB (512 KB for /catalog), over 2,000 products in one call, or over 20,000 SKUs in all
415unsupported_media_typeSend Content-Type: application/json
422missing_fields, unsupported_destination, empty_catalogThe request cannot be used as sent
429rate_limitedToo many requests. Wait a few seconds and retry
500server_errorOurs. Retrying is safe

Check your key: GET /me

example
curl https://peptiport.com/api/v1/me -H "Authorization: Bearer $PEPTIPORT_API_KEY"

{ "brand": { "slug": "your-brand", "name": "Your Brand" }, "scopes": ["orders:write", "orders:read", "stock:read", "catalog:write"] }

Send an order: POST /orders

Call this once an order is paid. Never for unpaid or abandoned carts.

example
curl -X POST https://peptiport.com/api/v1/orders \
  -H "Authorization: Bearer $PEPTIPORT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "order_number": "1042",
    "customer": { "name": "Ada Lovelace", "email": "ada@example.com", "phone": "+1 410 555 0100" },
    "address": { "street1": "12 Main St", "street2": "Apt 4", "city": "Baltimore", "state": "MD", "zip": "21201", "country": "US" },
    "items": [ { "sku": "BPC-157-5", "name": "BPC-157 5mg", "quantity": 2, "price": 79.98, "list_price": 39.99 } ],
    "total": 89.98,
    "shipping_total": 10.00
  }'

Fields:

  • order_number (required, string, up to 64 chars): your store's order number. Idempotent: sending the same number again updates that order (201 the first time, 200 after) until the warehouse labels it; after that you get 409 order_locked. Safe to retry on a timeout.
  • customer.name (required), customer.email, customer.phone. A phone is required by carriers for Canada.
  • address: street1, city, state (2 letters), zip required; street2 optional; country is US (default) or CA. Other countries answer 422 unsupported_destination.
  • items (required, 1 to 100): sku (required: your store's own SKU, exactly as your catalog lists it, matched to PeptiPort's SKU through /catalog. A bundle is one line under the bundle's own SKU, and the warehouse picks its parts), name, quantity (whole number, up to 10,000), price (the line total the customer paid, after discounts; required for Canada, for customs), list_price (regular price per unit, before discounts), subtotal (line before coupons).
  • total and shipping_total: what the customer paid in all, and for shipping, in USD.
  • test (optional, default false): see Testing below.

Response: { "created": true, "order": { ...order } } (see the order object below).

If an item's SKU is not matched to PeptiPort stock, the order is still kept (the customer has paid), and the response carries a warning so you see it straight away:

example
"warnings": [ { "code": "unknown_sku", "sku": "NEW-THING-10", "message": "..." } ]

Log these. The warehouse cannot pick that line until the SKU is matched, so send your catalog with its matches (below) before the first order.

If the brand set an order release delay, a new order waits that many minutes (order.release_at) before the warehouse can pick it; cancel before then and it never ships.

If a paid order is later cancelled or refunded in your store, call POST /orders/{order_number}/cancel straight away, or the warehouse ships it.

Testing

While you build, send orders with "test": true and an order number of your own (for example TEST-1001). A test order goes through every check, is stored, and reads back like any other ("test": true in the order object), but it is never packed, shipped, billed or taken off stock, and nobody at the warehouse is notified. A test and a real order cannot share an order number (409 test_mismatch). Test orders never ship, so they never fire a webhook: use "Send test event" on the Store connection page for that. Remove the flag before going live; every real order must be sent without it.

Read orders: GET /orders/{order_number} and GET /orders

example
curl https://peptiport.com/api/v1/orders/1042 -H "Authorization: Bearer $PEPTIPORT_API_KEY"

curl "https://peptiport.com/api/v1/orders?status=shipped&since=2026-10-01&limit=50" -H "Authorization: Bearer $PEPTIPORT_API_KEY"

The order object:

json
{
  "order_number": "1042",
  "status": "shipped",
  "test": false,
  "created_at": "2026-10-06T14:03:11.000Z",
  "release_at": null,
  "customer": { "name": "Ada Lovelace", "email": "ada@example.com", "phone": "+1 410 555 0100" },
  "address": { "street1": "12 Main St", "street2": "Apt 4", "city": "Baltimore", "state": "MD", "zip": "21201", "country": "US" },
  "items": [ { "sku": "BPC-157-5", "name": "BPC-157 5mg", "quantity": 2, "price": 79.98 } ],
  "total": 89.98,
  "shipping_total": 10,
  "tracking": { "carrier": "USPS", "service": "Ground Advantage", "tracking_number": "9400111...", "tracking_url": "https://...", "status": "transit" }
}

status moves pending -> packed -> labeled -> shipped -> delivered, or to cancelled. tracking is null until a label is bought. tracking.status is the carrier's: pre_transit, transit, delivered, returned, failure, unknown. List filters: status (any of the above), since (ISO date or time), limit (1 to 100, default 50). Newest first.

Cancel: POST /orders/{order_number}/cancel

Works while the order is pending or packed. Once labeled, 409 not_cancellable. Cancelling twice is fine (200, cancelled: true).

Your product list: PUT /catalog and POST /catalog

Send the store's products and say which PeptiPort SKU each one is, using the sku values from GET /stock. Send it once at setup, then whenever products are added, renamed or removed (or simply daily).

example
curl -X PUT https://peptiport.com/api/v1/catalog \
  -H "Authorization: Bearer $PEPTIPORT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "products": [ { "sku": "BPC-157-5", "name": "BPC-157 5mg", "price": 39.99, "peptiport_sku": "PP-BPC5" }, { "sku": "TB-500-10", "name": "TB-500 10mg" } ] }'

{
  "received": 2, "listed": 2,
  "delisted": [], "relisted": [],
  "matched": 1, "match_removed": 0, "match_rejected": [],
  "unmapped": ["TB-500-10"],
  "rejected": [], "duplicates": []
}
  • PUT is the whole catalog. A product listed before and missing now is *delisted*. POST only adds and updates; to remove one product with POST, send it with "active": false.
  • Nothing is ever deleted. A delisted SKU keeps its warehouse match, so an order placed just before you removed the product still ships. Sending it again relists it.
  • Safety: a PUT with no products is refused (422 empty_catalog), and a PUT that would delist more than half of what is listed (10 or more products) is refused with 409 mass_delist and changes nothing, since that is almost always a broken or partial export. Resend with "allow_mass_delist": true if you really mean it.
  • SKUs are trimmed and upper-cased, the same as on orders. Duplicates: the last one wins and is listed in duplicates. Up to 2,000 products and 512 KB per call; page a bigger catalog with POST.
  • peptiport_sku matches the product to your stock, using a sku from the stock list. Leave it out or blank to keep the match as it is; send null to remove it (match_removed counts these). Several of your SKUs may point at one PeptiPort SKU. It can be sent with POST as well as PUT.
  • match_rejected lists matches that were not saved, each with a reason, while the rest of the list still goes through: unknown_peptiport_sku (not in your GET /stock), target_is_bundle (bundles match under their own SKU), sku_in_use (it is on orders already, so ask PeptiPort to change it), is_bundle, or is_peptiport_sku.
  • unmapped lists SKUs with no match yet. Orders for them come back with an unknown_sku warning and wait until they are matched. Orders may also use the PeptiPort SKU itself.

GET /catalog returns what PeptiPort holds:

json
{ "products": [ { "sku": "BPC-157-5", "name": "BPC-157 5mg", "listed": true, "delisted_at": null, "warehouse": "matched", "peptiport_sku": "PP-BPC5", "bundle": false } ] }

Renaming a SKU in your store is a remove plus an add: the old one is delisted, so send the new one with its peptiport_sku.

Connection status: GET /status

How PeptiPort sees your integration, part by part. Call it when you finish building, and any time something looks off.

example
curl https://peptiport.com/api/v1/status -H "Authorization: Bearer $PEPTIPORT_API_KEY"

{
  "brand": { "slug": "your-brand", "name": "Your Brand" },
  "state": "partial",
  "checks": [
    { "key": "api_key",  "label": "API key",          "state": "ok",      "detail": "Used by the store.", "at": "2026-10-31T23:44:06.798Z" },
    { "key": "orders",   "label": "Orders",           "state": "missing", "detail": "No order received through the API yet.", "at": null },
    { "key": "stock",    "label": "Stock sync",       "state": "ok",      "detail": "Checking hourly.", "at": "..." },
    { "key": "catalog",  "label": "Catalog",          "state": "warn",    "detail": "45 listed; 2 not matched to warehouse stock: ...", "at": "..." },
    { "key": "webhook",  "label": "Tracking webhook", "state": "missing", "detail": "No endpoint set, so tracking is not pushed back.", "at": null }
  ],
  "next_steps": ["Send each paid order to POST /api/v1/orders.", "..."]
}

state is connected once the key is in use, an order has arrived and the webhook's last delivery succeeded; partial while some of it works; not_connected before anything does. Stock sync turns warn after 3 hours without a check. The same view is on the dashboard's Store connection page.

Stock: GET /stock

Everything PeptiPort holds for you, under PeptiPort's own SKUs, with names and quantities:

example
curl https://peptiport.com/api/v1/stock -H "Authorization: Bearer $PEPTIPORT_API_KEY"

{
  "stock": [
    { "sku": "PP-RT10", "name": "Retatrutide 10 mg", "quantity": 4980, "in_stock": true, "store_skus": ["RT10"] },
    { "sku": "PP-KPV10", "name": "KPV 10 mg", "quantity": 0, "in_stock": false, "store_skus": [] }
  ],
  "bundles": [
    { "sku": "RT-DUO", "quantity": 2490, "in_stock": true, "parts": [ { "sku": "PP-RT10", "units": 2 } ] }
  ]
}
  • sku is PeptiPort's code. Match it to your own products with peptiport_sku on /catalog (below). store_skus shows what is matched to it already.
  • quantity is what you can sell now: on hand, less units on open orders and any safety stock PeptiPort holds back, never below zero. It falls as you sell. A product PeptiPort has set up but holds none of yet is listed at 0, so you can match it early. Set each matched product's stock from this number.
  • bundles are products of yours that PeptiPort set up as several units, listed under your own SKU; quantity is how many full bundles the parts make. They are matched already: send them on /catalog without peptiport_sku. If you sell a bundle that is not listed here, ask PeptiPort to set it up.
  • ?skus=A,B narrows the list, by PeptiPort SKU or your matched SKU.
  • Goods a manufacturer ships directly are not listed here, and PeptiPort sets up their SKUs for you.

Check on a schedule (hourly is plenty) and cache, rather than on every page view.

Webhooks: hearing that an order shipped

Set an endpoint in the dashboard, Store connection page, and copy the signing secret (whsec_...) into PEPTIPORT_WEBHOOK_SECRET. Each tracking change POSTs:

json
{
  "id": "evt_...",
  "type": "order.tracking",
  "created_at": "2026-10-07T10:00:00.000Z",
  "data": {
    "order_number": "1042",
    "order_status": "shipped",
    "tracking_status": "transit",
    "tracking_number": "9400111...",
    "tracking_url": "https://...",
    "carrier": "USPS"
  }
}

The first one arrives when the carrier first scans the parcel (order_status shipped); the last when it is delivered. The dashboard's "Send test event" sends "type": "test".

Verify every request before trusting it. The header is X-PeptiPort-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256, keyed with your secret, of <t>.<raw request body>. Compare in constant time and reject a t more than 300 seconds old. Use the raw body bytes, before any JSON parsing.

example
import crypto from "node:crypto";

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Answer 2xx quickly (within 10 seconds) and do the work after. The endpoint must be public https; redirects are not followed. Events are best effort: if you miss one, GET the order.

MCP (for AI agents)

The same operations as tools for an AI agent, over MCP (streamable HTTP) at:

example
https://peptiport.com/api/mcp

with the same Authorization: Bearer pp_live_... header and the same scopes. Tools: whoami, connection_status, create_order, get_order, list_orders, cancel_order, check_stock, sync_catalog, get_catalog. Example for Claude Code:

example
claude mcp add --transport http peptiport https://peptiport.com/api/mcp --header "Authorization: Bearer $PEPTIPORT_API_KEY"

And for Cursor (.cursor/mcp.json) or any client that takes JSON:

json
{ "mcpServers": { "peptiport": { "url": "https://peptiport.com/api/mcp", "headers": { "Authorization": "Bearer ${PEPTIPORT_API_KEY}" } } } }

OpenAPI

Machine-readable: https://peptiport.com/api/v1/openapi.json

Support

support@peptiport.com

Stuck on something

Send us the reference id and roughly when it happened and we can see the same payment you are looking at. If a key has leaked, say so first and explain second — rotating it takes a minute.

support@peptiport.com