Integration guide
Fulfillment API
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.
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:
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:
| Scope | Allows |
|---|---|
| orders:write | POST /orders, POST /orders/{order_number}/cancel |
| orders:read | GET /orders, GET /orders/{order_number} |
| stock:read | GET /stock, GET /catalog |
| catalog:write | PUT /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_KEYon 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:
{ "error": { "code": "missing_fields", "message": "Missing or empty: address.zip.", "fields": ["address.zip"] } }| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_body, invalid_status, invalid_since | The request is malformed |
| 401 | unauthorized | Missing, wrong or revoked key |
| 403 | forbidden, api_disabled | The key lacks the scope this call needs, or PeptiPort has switched the brand's API off |
| 404 | not_found | No such order for this brand |
| 409 | order_locked, not_cancellable | The warehouse has already labeled (or you cancelled) the order |
| 409 | test_mismatch | A test order and a real order cannot share an order number |
| 409 | mass_delist | A full catalog would delist most products; nothing changed |
| 413 | too_large, too_many, catalog_full | Body over 64 KB (512 KB for /catalog), over 2,000 products in one call, or over 20,000 SKUs in all |
| 415 | unsupported_media_type | Send Content-Type: application/json |
| 422 | missing_fields, unsupported_destination, empty_catalog | The request cannot be used as sent |
| 429 | rate_limited | Too many requests. Wait a few seconds and retry |
| 500 | server_error | Ours. Retrying is safe |
Check your key: GET /me
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.
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 409order_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),ziprequired;street2optional;countryisUS(default) orCA. Other countries answer 422unsupported_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).totalandshipping_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:
"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
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:
{
"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).
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 409mass_delistand changes nothing, since that is almost always a broken or partial export. Resend with"allow_mass_delist": trueif 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_skumatches the product to your stock, using askufrom thestocklist. Leave it out or blank to keep the match as it is; sendnullto remove it (match_removedcounts these). Several of your SKUs may point at one PeptiPort SKU. It can be sent with POST as well as PUT.match_rejectedlists 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, oris_peptiport_sku.unmappedlists SKUs with no match yet. Orders for them come back with anunknown_skuwarning and wait until they are matched. Orders may also use the PeptiPort SKU itself.
GET /catalog returns what PeptiPort holds:
{ "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.
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:
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 } ] }
]
}skuis PeptiPort's code. Match it to your own products withpeptiport_skuon /catalog (below).store_skusshows what is matched to it already.quantityis 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.bundlesare products of yours that PeptiPort set up as several units, listed under your own SKU;quantityis how many full bundles the parts make. They are matched already: send them on /catalog withoutpeptiport_sku. If you sell a bundle that is not listed here, ask PeptiPort to set it up.?skus=A,Bnarrows 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:
{
"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.
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:
https://peptiport.com/api/mcpwith 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:
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:
{ "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