Overview
The Integration API is the inbound write half of the {{COMPANY_SHORT_NAME}} REST API. Anything that creates or mutates data — an order, a payment, a client — goes through here. It’s deliberately separate from the read-only Public API: different key prefix, different scopes, different rate-limit and signature middleware.
Every key is pinned to one (workspace, store) pair. There is no “write to any store” key. This matters when you run multiple stores: one Shopify install per store, one WooCommerce site per store, one Zapier zap per store.
- Base URL:
https://api.peptideclients.com - Path prefix:
/integrations/v1/* - Auth:
Authorization: Bearer pck_int_xxxxxxxxxxxxxxxxxxxxxxxxx - OpenAPI spec: /api/integration-openapi.yaml · live at
/integrations/v1/openapi.json - Recipes (paste-and-go code): /api/integration-recipes.md
What working correctly looks like
If everything is wired up, you should see these things. If any one of them is missing, jump to Troubleshooting.
GET /integrations/v1/healthreturns{"ok": true}in <200 ms.- Every POST returns a row in Settings → Connectors → Request log within a second.
- New orders show a imported source badge in the orders list and detail pages.
- New clients show a imported from <source> chip in the CRM.
- Duplicate POSTs return
{"duplicate": true}; no second row gets inserted. - An
order.createdoutbound webhook fires for every successful create (visible in Settings → Webhooks → Deliveries).
Step 1 — mint a key
- Sign in and go to Settings → Connectors.
- Click New integration key.
- Pick a store — the key is permanently pinned here.
-
Choose a payload format:
- peptideclients (default): our generic JSON shape. Use this for custom sites, Zapier, Make, internal scripts.
- woocommerce: accepts WC’s native Order webhook payload verbatim. Auto-enables
require_signature = trueand switches the HMAC scheme to WC’s.
-
Pick scopes. Add only what the integration needs:
orders:write— create ordersorders:cancel— cancel orderspayments:write— record paymentspayments:refund— refund paymentsclients:write— upsert clients without an order
- (Optional) Add an IP allowlist (CIDR) and toggle Require signature.
- Click Create. We show you the key value once. Copy it now — we only store a hash.
If you toggled Require signature (mandatory for WC keys), we also show you the signing secret on the same modal. Copy both. The secret is what you HMAC every request body with.
Authentication
Every request needs an Authorization header.
Authorization: Bearer pck_int_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Missing or malformed → 401 missing_authorization. Unknown / revoked / expired → 401 invalid_api_key.
The two endpoints that don’t need auth:
GET /integrations/v1/healthGET /integrations/v1/openapi.json
HMAC signing
If the key has require_signature = true, the body must be HMAC-signed and the signature passed in a header. We support two schemes depending on payload_format.
peptideclients format (Stripe pattern)
Header name: PC-Signature. Format: t=<unix-seconds>,v1=<hex>.
// Node.js
const ts = Math.floor(Date.now() / 1000);
const signed = `${ts}.${rawBody}`;
const v1 = crypto.createHmac('sha256', Buffer.from(signingSecretHex, 'hex'))
.update(signed)
.digest('hex');
const header = `t=${ts},v1=${v1}`;
The timestamp must be within 5 minutes of our clock or we reject with signature_expired.
woocommerce format
Header name: X-WC-Webhook-Signature. Value: base64(hmac_sha256(secret, rawBody)). The “secret” is whatever string you pasted into WC’s Secret field; on our side, that’s the signing_secret_plain we showed you at key-creation time.
Base64 is case-sensitive. We do not lowercase either side of the comparison.
HMAC verification requires both sides to know the secret. Hashes don’t work because we need to re-compute the HMAC server-side. The plaintext is stored in a bytea column that only the service-role context can read — never the browser, never a JWT-authenticated session.
Idempotency
There are two kinds of replay protection, and you usually want both.
HTTP retry dedupe — Idempotency-Key header
Pass any string up to 255 chars. Recommended shape: <source>-<event-id> (e.g. woo-12345, zap-ABC123XYZ).
The first request with that key gets executed normally. The cache row lives 24 hours. Within that window:
- Same key + byte-identical body → we replay the cached response. Status, body, everything.
- Same key + different body →
409 idempotency_conflict.
If you don’t pass the header, we don’t cache. A retried POST without an Idempotency-Key can create a duplicate row.
Entity-level dedupe — external_id in the body
Every create endpoint accepts an external_id. We track (integration_key_id, entity_type, external_id) → internal_id in core.external_entity_refs. Forever, not 24 hours.
The second create with the same external_id returns the original record with "duplicate": true. No second row, no second event fired.
Use both. Idempotency-Key: <source>-<event-id>-<attempt> for the HTTP retry path, and external_id in the body for the long-term entity guarantee.
Endpoints
POST /integrations/v1/orders
Create an order. Optionally records an inline payment in the same call. Requires orders:write (plus payments:write if you include a payment block).
Only for payload_format = peptideclients keys; WC keys must POST to /webhook/woocommerce instead.
POST /integrations/v1/orders
Authorization: Bearer pck_int_xxxxx
Content-Type: application/json
Idempotency-Key: woo-12345
{
"external_id": "woo-12345",
"currency": "USD",
"client": {
"external_id": "woo-cust-99",
"email": "buyer@example.com",
"display_name": "Jane Buyer"
},
"lines": [
{
"description": "BPC-157 5mg (vial)",
"quantity": 2,
"unit_price_cents": 4999,
"metadata": { "sku": "BPC-157-5mg" }
}
],
"shipping_cents": 999,
"tax_cents": 0,
"payment": {
"external_id": "woo-txn-789",
"amount_cents": 10997,
"method": "card",
"provider": "Stripe",
"provider_payment_id": "pi_xxxxx"
}
}
Success response:
{
"id": "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01",
"number": "INV-2026-0042",
"status": "paid",
"total_cents": 10997,
"amount_paid_cents": 10997,
"client_id": "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44",
"external_id": "woo-12345",
"payment_id": "f0a93b9a-1234-4cba-9d8f-bcde0123abcd",
"duplicate": false
}
POST /integrations/v1/orders/{id}/payments
Record a payment against an existing order. Use this when payment comes in after the order was created (the most common case for “awaiting payment” flows). Requires payments:write.
POST /integrations/v1/orders/{id}/cancel
Cancel an order. Idempotent — calling it on an already-cancelled order returns {"id": "...", "already_cancelled": true} with status 200. Requires orders:cancel.
POST /integrations/v1/payments/{id}/refund
Mark a payment as refunded. v1 supports full refunds only; partial refunds return 422 partial_refund_not_supported. Requires payments:refund.
POST /integrations/v1/clients
Upsert a CRM client without creating an order. Most integrations don’t need this — client info on a POST /orders already upserts. Requires clients:write.
POST /integrations/v1/webhook/woocommerce
The native WC receiver. Accepts WC’s Order resource verbatim (the same JSON WC POSTs to your webhook URL). Translates internally into the SDF and creates the order + optional payment.
Status mapping:
| WC status | What we do | Our status |
|---|---|---|
pending | Create order, no payment | invoiced |
on-hold | Create order, no payment | invoiced |
failed | Create order, no payment | invoiced |
processing | Create order + inline payment | paid |
completed | Create order + inline payment | paid |
cancelled | Lookup or create, then cancel | cancelled |
refunded | Find payment, refund it | (payment row marked refunded) |
GET /integrations/v1/orders/by-external/{external_id}
Look up an order by your external_id. Useful for reconciliation jobs — you can confirm we have a row before deciding whether to re-create. Returns 404 not_found if we haven’t seen this external id.
Client resolution — how upsert works
Whenever an endpoint receives client info (either in POST /clients or nested in POST /orders), we walk three steps:
-
By
external_id. If the payload includesclient.external_idand we’ve seen that external id from this same key before, we attach to that record. Done. -
By email + (org, store). If no external match, we look for a client in the same store with the same email (case-insensitive). If found, we attach the new
external_idfor next time and stamp the source. -
Create new. No match → brand-new client row. We stamp
source_label,source_external_id, andsource_integration_key_idso you (and we) can see it came from this integration.
In the operator UI, imported clients/orders show a small imported · <source> chip next to their name / number.
Order lifecycle
Same lifecycle as orders created in-app, with one twist: integration-created orders start at invoiced (not draft), because there’s no proposal-approval step from an external system.
pay 0 cents pay partial pay full
draft --send--> invoiced --> partially_paid --> paid
| ^
+------- pay full at create ---+
invoiced ---cancel---> cancelled (cannot uncancel)
paid ---refund---> refunded (full only in v1)
If you POST an order with total_cents == 0, we go straight to paid — no need for a zero-dollar payment row.
Rate limits
The default cap is 600 requests per minute per key, enforced as a Postgres-backed sliding 60-second window. Over the cap returns:
HTTP/1.1 429
Retry-After: 60
Content-Type: application/json
{ "error": "rate_limited", "detail": "600 req/min cap" }
You can raise the cap per-key in Settings → Connectors → your key → Rate limit (max 6,000/min). If you need more, write to {{CONTACT_EMAIL}} — sustained >100 req/sec is usually a sign you want batching instead.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
missing_authorization | 401 | No Authorization header. |
invalid_api_key | 401 | Unknown, revoked, or expired key. |
signature_missing | 401 | Key requires signature; none provided. |
signature_malformed | 401 | Header was present but didn’t parse. |
signature_expired | 401 | Timestamp skew >5 minutes. |
signature_invalid | 401 | HMAC mismatch. |
signature_unconfigured | 401 | Key requires signature but has no secret on file (rare; rotate it). |
ip_not_allowed | 403 | Source IP not in the key’s allowlist. |
insufficient_scope | 403 | Key lacks the scope this endpoint requires. |
not_found | 404 | Order / payment / external id not found. |
idempotency_conflict | 409 | Same Idempotency-Key, different body, within 24h. |
invalid_json | 422 | Body wasn’t valid JSON. |
external_id_required | 422 | Create endpoint missing external_id. |
lines_required | 422 | Create order missing lines. |
partial_refund_not_supported | 422 | v1 refunds are full only. |
wrong_format | 400 | Endpoint doesn’t accept this key’s payload_format (e.g. WC key posting to /orders). |
rate_limited | 429 | Per-key cap exceeded. |
Troubleshooting
The full symptom-to-fix table lives at Troubleshooting → Integration API. Quick links:
- 401
invalid_api_key - 401
signature_invalid - 429
rate_limited - 409
idempotency_conflict - WC order didn’t import
Related
- Settings → Connectors UI walkthrough — minting, rotating, revoking, reading the request log.
- Integration recipes cookbook — WC, Zapier, Node, Python paste-and-go code.
- OpenAPI 3.0.3 spec — canonical machine-readable contract.
- Outbound webhooks — subscribe to events the integration-created data emits.
- Public read API — the other half (pulling data out).