# Integration API recipes

Copy-pasteable cookbooks for the PeptideClients inbound write API
(`pck_int_*` keys). Every example uses peptide-industry product names
and clearly-synthetic dollar amounts.

- Base URL: `https://api.peptideclients.com`
- Path prefix: `/integrations/v1/*`
- Machine-readable spec:
  [`/api/integration-openapi.yaml`](./integration-openapi.yaml) &middot;
  also served live at `/integrations/v1/openapi.json`
- Mint keys: **PeptideClients &rarr; Settings &rarr; Connectors**

> Each `pck_int_*` key is pinned to a single `(org_id, store_id)`
> and carries scopes (`orders:write`, `orders:cancel`,
> `payments:write`, `payments:refund`, `clients:write`). Keys can
> additionally require HMAC body signatures &mdash; always-on for
> WooCommerce-format keys.

## Table of contents

1. [Quickstart in 5 minutes](#quickstart-in-5-minutes)
2. [WooCommerce paste-and-go](#woocommerce-paste-and-go)
3. [Zapier recipe (Square to PeptideClients)](#zapier-recipe-square-to-peptideclients)
4. [Custom site (Node Express)](#custom-site-node-express)
5. [Custom site (Python requests)](#custom-site-python-requests)
6. [Handling outbound webhooks](#handling-outbound-webhooks)
7. [Status reference](#status-reference)
8. [Idempotency-Key semantics](#idempotency-key-semantics)
9. [HMAC signing (`PC-Signature`)](#hmac-signing-pc-signature)
10. [Error codes reference](#error-codes-reference)

---

## Quickstart in 5 minutes

1. Sign in to PeptideClients and open
   **Settings &rarr; Connectors &rarr; New integration key**.
2. Pick a store, tick the scopes you need (`orders:write` plus
   `payments:write` is the most common combo), and click **Create**.
3. Copy the `pck_int_*` value &mdash; it is shown once.
4. Fire the cURL below. Replace `<YOUR_PCK_INT_KEY>` with the key
   you just copied.

```bash
curl -sS -X POST \
  'https://api.peptideclients.com/integrations/v1/orders' \
  -H 'Authorization: Bearer <YOUR_PCK_INT_KEY>' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: quickstart-2026-05-21-001' \
  -d '{
    "external_id": "quickstart-001",
    "currency": "USD",
    "client": {
      "external_id": "quickstart-cust-1",
      "email": "buyer@example.com",
      "display_name": "Jane Buyer",
      "client_type": "retail"
    },
    "lines": [
      { "description": "BPC-157 5mg (vial)", "quantity": 2, "unit_price_cents": 4999, "metadata": { "sku": "BPC-157-5mg" } },
      { "description": "TB-500 5mg (vial)",  "quantity": 1, "unit_price_cents": 5999, "metadata": { "sku": "TB-500-5mg" } }
    ],
    "shipping_cents": 999,
    "payment": {
      "external_id": "quickstart-txn-1",
      "amount_cents": 11997,
      "method": "card",
      "provider": "Stripe",
      "provider_payment_id": "pi_quickstart"
    }
  }'
```

Expected response:

```json
{
  "id": "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01",
  "number": "INV-2026-0042",
  "status": "paid",
  "total_cents": 11997,
  "amount_paid_cents": 11997,
  "client_id": "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44",
  "external_id": "quickstart-001",
  "payment_id": "f0a93b9a-1234-4cba-9d8f-bcde0123abcd",
  "duplicate": false
}
```

Replay the exact same request and you get back the same envelope
with `duplicate: true` &mdash; nothing in the database moves.

---

## WooCommerce paste-and-go

The headline path. We accept WooCommerce's native Order shape and
HMAC scheme verbatim, so the entire integration is "paste two URLs
into WooCommerce settings".

### Step 1 &mdash; mint a WooCommerce-format key

1. **PeptideClients &rarr; Settings &rarr; Connectors &rarr; New integration key**
2. Set **Payload format** = **WooCommerce** (this auto-toggles
   `require_signature = true` and switches the receiver to the
   WC HMAC scheme).
3. Scopes&colon; `orders:write` and `payments:write`. Tick
   `clients:write` if you want CRM updates without an order to
   also push through.
4. Pick the destination **store** &mdash; the key is pinned to it.
5. Click **Create**. Copy two values&colon;
   - **Signing secret** (the WooCommerce "Secret" field).
   - **`pck_int_*` key** (not used by WooCommerce itself, but
     handy for cURL debugging).

### Step 2 &mdash; configure the WooCommerce webhook

In WordPress admin:

- Go to **WooCommerce &rarr; Settings &rarr; Advanced &rarr; Webhooks**.
- Click **Add webhook**.
- Fill in:
  - **Name**&colon; "PeptideClients &mdash; order created"
  - **Status**&colon; **Active**
  - **Topic**&colon; **Order created**
  - **Delivery URL**&colon;
    `https://api.peptideclients.com/integrations/v1/webhook/woocommerce`
  - **Secret**&colon; paste the **Signing secret** from step 1.
  - **API version**&colon; **WP REST API Integration v3**
- **Save webhook**.
- Click **Add webhook** again and repeat for **Topic = Order updated**
  with the same URL and the same secret.

### Step 3 &mdash; test it

1. In your storefront, place a real test order (or use
   **Webhooks &rarr; (your webhook) &rarr; Webhook delivery log** in
   WooCommerce and click "send a test ping").
2. In PeptideClients, open
   **Settings &rarr; Connectors &rarr; (your key) &rarr; Request log**.
   You should see a `200 OK` entry within a couple of seconds.
3. Open **Orders** in PeptideClients &mdash; the new order is
   there, status `paid` if WC marked it paid, `invoiced` otherwise.

### Step 4 &mdash; secret rotation

When you rotate the signing secret in PeptideClients, also paste
the new value into the **Secret** field on both webhooks. WooCommerce
will start signing with the new value on the next delivery; deliveries
already in flight signed with the old secret will be rejected with
`signature_invalid` &mdash; WC will retry them per its own backoff,
so this is usually a non-event.

---

## Zapier recipe (Square to PeptideClients)

Use this pattern for any source platform Zapier supports &mdash;
Stripe, Square, BigCommerce, Wix, Acuity, Calendly, Airtable, etc.

### Trigger

- App&colon; **Square**
- Event&colon; **New paid order**
- Connect your Square account; let Zapier pull a sample.

### Action

- App&colon; **Webhooks by Zapier**
- Event&colon; **Custom Request**
- **Method**&colon; `POST`
- **URL**&colon;
  `https://api.peptideclients.com/integrations/v1/orders`
- **Data Pass-Through?**&colon; **No**
- **Data**&colon; paste the JSON template below.
- **Wrap Request In Array**&colon; **No**
- **Unflatten**&colon; **Yes**
- **Headers**&colon;
  - `Authorization`&colon; `Bearer <YOUR_PCK_INT_KEY>`
  - `Content-Type`&colon; `application/json`
  - `Idempotency-Key`&colon; map to Square's order id Zap field
    (e.g. `{{order__id}}`). Same Square order &rarr; same Zapier
    run &rarr; same Idempotency-Key &rarr; safe replays.

### JSON template (Square &rarr; SDF)

Wherever you see `{{...}}`, drop in the matching Square field from
the Zapier picker. Items like `{{line_items__name}}` are repeated
across line items; use **Looping by Zapier** if Square sends them
as a delimited list.

```json
{
  "external_id": "square-{{order__id}}",
  "currency": "{{order__total_money__currency}}",
  "client": {
    "external_id": "square-cust-{{order__customer_id}}",
    "email": "{{customer__email_address}}",
    "display_name": "{{customer__given_name}} {{customer__family_name}}",
    "first_name": "{{customer__given_name}}",
    "last_name": "{{customer__family_name}}",
    "phone": "{{customer__phone_number}}",
    "client_type": "retail",
    "tags": ["square-import"],
    "billing_address": {
      "address_line1": "{{customer__address__address_line_1}}",
      "city": "{{customer__address__locality}}",
      "state": "{{customer__address__administrative_district_level_1}}",
      "postal_code": "{{customer__address__postal_code}}",
      "country": "{{customer__address__country}}"
    }
  },
  "lines": [
    {
      "description": "{{line_items__name}}",
      "quantity": "{{line_items__quantity}}",
      "unit_price_cents": "{{line_items__base_price_money__amount}}",
      "metadata": { "sku": "{{line_items__catalog_object_id}}" }
    }
  ],
  "shipping_cents": 0,
  "metadata": {
    "source": "square",
    "square_location_id": "{{order__location_id}}"
  },
  "payment": {
    "external_id": "square-txn-{{tender__id}}",
    "amount_cents": "{{order__total_money__amount}}",
    "method": "card",
    "provider": "Square",
    "provider_payment_id": "{{tender__id}}",
    "paid_at": "{{order__closed_at}}"
  }
}
```

Square returns its money fields already in minor units, so no math
needed. Stripe and Shopify also return cents-amounts. For platforms
that hand you dollar strings (`"49.99"`), multiply by 100 in a
**Formatter by Zapier** step before this action.

---

## Custom site (Node Express)

Forward an arbitrary checkout webhook from your own site to
PeptideClients. Uses `node:crypto` for the Idempotency-Key hash,
`fetch()` (native in Node 18+) for the outbound call, and an
exponential-backoff retry loop for 5xx responses.

```js
// webhook-forwarder.mjs
import express from 'express';
import crypto from 'node:crypto';

const PCK_INT_KEY = process.env.PCK_INT_KEY;
const PCK_BASE = 'https://api.peptideclients.com/integrations/v1';

const app = express();
app.use(express.json({ limit: '2mb' }));

app.post('/webhook/my-checkout', async (req, res) => {
  const body = mapToSdf(req.body);
  const raw = JSON.stringify(body);
  const idemKey = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);

  try {
    const envelope = await postWithRetry(`${PCK_BASE}/orders`, raw, idemKey);
    if (envelope.duplicate) {
      console.log(`duplicate replay for order ${envelope.id}`);
    }
    res.json(envelope);
  } catch (err) {
    console.error('pck push failed', err);
    res.status(502).json({ error: 'upstream_failed', detail: String(err) });
  }
});

async function postWithRetry(url, raw, idemKey, attempt = 0) {
  const resp = await fetch(url, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${PCK_INT_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idemKey,
    },
    body: raw,
  });
  if (resp.ok) return resp.json();
  if (resp.status >= 500 && attempt < 4) {
    const wait = 250 * 2 ** attempt; // 250ms, 500ms, 1s, 2s
    await new Promise((r) => setTimeout(r, wait));
    return postWithRetry(url, raw, idemKey, attempt + 1);
  }
  throw new Error(`pck ${resp.status}: ${await resp.text()}`);
}

function mapToSdf(checkout) {
  return {
    external_id: `mysite-${checkout.id}`,
    currency: checkout.currency ?? 'USD',
    client: {
      external_id: `mysite-cust-${checkout.customer.id}`,
      email: checkout.customer.email,
      display_name: checkout.customer.name,
      client_type: 'retail',
    },
    lines: checkout.items.map((it) => ({
      description: it.name,
      quantity: it.qty,
      unit_price_cents: it.unit_price_cents,
      metadata: { sku: it.sku },
    })),
    shipping_cents: checkout.shipping_cents ?? 0,
    payment: checkout.paid_at
      ? {
          external_id: `mysite-txn-${checkout.txn_id}`,
          amount_cents: checkout.total_cents,
          method: checkout.payment_method ?? 'card',
          provider: 'Stripe',
          provider_payment_id: checkout.txn_id,
          paid_at: checkout.paid_at,
        }
      : undefined,
  };
}

app.listen(3000, () => console.log('listening on :3000'));
```

Notes:

- We compute the `Idempotency-Key` from a SHA-256 of the *outbound*
  body. That way, replays of the same upstream webhook produce
  byte-identical bodies and reuse PeptideClients' cached response.
- 4xx responses fail fast &mdash; retrying is pointless and will trip
  rate limits. Only 5xx retries.
- The `duplicate: true` branch logs and returns the same envelope to
  the original caller, so downstream systems (your shop UI, email
  triggers, etc.) get the same shape every time.

---

## Custom site (Python requests)

Same forwarder, Python flavor. Drop into Flask, FastAPI, or a plain
`requests` script.

```python
# webhook_forwarder.py
import hashlib
import json
import os
import time
from typing import Any

import requests
from flask import Flask, jsonify, request

PCK_INT_KEY = os.environ["PCK_INT_KEY"]
PCK_BASE = "https://api.peptideclients.com/integrations/v1"

app = Flask(__name__)


@app.post("/webhook/my-checkout")
def handle():
    body = map_to_sdf(request.get_json(force=True))
    raw = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
    idem_key = hashlib.sha256(raw).hexdigest()[:32]

    try:
        envelope = post_with_retry(f"{PCK_BASE}/orders", raw, idem_key)
    except RuntimeError as exc:
        return jsonify(error="upstream_failed", detail=str(exc)), 502

    if envelope.get("duplicate"):
        app.logger.info("duplicate replay for order %s", envelope["id"])
    return jsonify(envelope)


def post_with_retry(url: str, raw: bytes, idem_key: str) -> dict[str, Any]:
    for attempt in range(5):
        resp = requests.post(
            url,
            data=raw,
            headers={
                "Authorization": f"Bearer {PCK_INT_KEY}",
                "Content-Type": "application/json",
                "Idempotency-Key": idem_key,
            },
            timeout=20,
        )
        if resp.ok:
            return resp.json()
        if resp.status_code >= 500 and attempt < 4:
            time.sleep(0.25 * (2 ** attempt))  # 250ms, 500ms, 1s, 2s
            continue
        raise RuntimeError(f"pck {resp.status_code}: {resp.text}")
    raise RuntimeError("pck retries exhausted")


def map_to_sdf(checkout: dict[str, Any]) -> dict[str, Any]:
    payload: dict[str, Any] = {
        "external_id": f"mysite-{checkout['id']}",
        "currency": checkout.get("currency", "USD"),
        "client": {
            "external_id": f"mysite-cust-{checkout['customer']['id']}",
            "email": checkout["customer"]["email"],
            "display_name": checkout["customer"]["name"],
            "client_type": "retail",
        },
        "lines": [
            {
                "description": it["name"],
                "quantity": it["qty"],
                "unit_price_cents": it["unit_price_cents"],
                "metadata": {"sku": it["sku"]},
            }
            for it in checkout["items"]
        ],
        "shipping_cents": checkout.get("shipping_cents", 0),
    }
    if checkout.get("paid_at"):
        payload["payment"] = {
            "external_id": f"mysite-txn-{checkout['txn_id']}",
            "amount_cents": checkout["total_cents"],
            "method": checkout.get("payment_method", "card"),
            "provider": "Stripe",
            "provider_payment_id": checkout["txn_id"],
            "paid_at": checkout["paid_at"],
        }
    return payload
```

The hash is computed against the canonicalized body (sorted keys, no
whitespace) so that replays of the same upstream event &mdash; even if
your framework re-serializes the dict in a different order &mdash;
still produce the same Idempotency-Key.

---

## Handling outbound webhooks

The inbound API does not replace outbound webhooks &mdash; they are
complementary. Whenever an inbound call lands data in your workspace,
the existing outbound webhook system (configured in
**Settings &rarr; Webhooks**) emits one or more of:

- `order.created` &mdash; on a successful `POST /v1/orders` that
  created a new row.
- `payment.recorded` &mdash; for the inline `payment` on
  `POST /v1/orders` and for every `POST /v1/orders/{id}/payments`.
- `payment.refunded` &mdash; on a successful
  `POST /v1/payments/{id}/refund`.
- `order.cancelled` &mdash; on a successful
  `POST /v1/orders/{id}/cancel`.

If your downstream needs to react to inbound writes (e.g. push the
new PeptideClients order id back into Shopify as an order note),
subscribe to the relevant outbound event in
[**Settings &rarr; Webhooks**](https://app.peptideclients.com/settings/webhooks)
instead of polling the read API.

`duplicate: true` responses do **not** emit outbound events &mdash;
they are pure replays.

---

## Status reference

The resulting `orders.status` after a `POST /v1/orders` is a pure
function of the request body:

| Inbound payment scenario                          | Resulting `status` |
| ------------------------------------------------- | ------------------ |
| `payment.amount_cents == total_cents`             | `paid`             |
| `0 < payment.amount_cents < total_cents`          | `partially_paid`   |
| no `payment` block, `total_cents > 0`             | `invoiced`         |
| no `payment` block, `total_cents == 0` (zero-dollar fallback) | `paid` |

`POST /v1/orders/{id}/payments` re-runs the same comparison after
adding the new payment, so `invoiced` &rarr; `partially_paid` &rarr;
`paid` is a one-way ratchet driven by `sum(payments) vs total`.

`POST /v1/orders/{id}/cancel` overrides to `cancelled` regardless of
payment state. A full refund of the only payment on a `paid` order
flips status to `refunded`; partial state changes flow back through
the same `sum(payments) vs total` rule.

---

## Idempotency-Key semantics

Two independent dedupe layers, Stripe-compatible:

### Layer 1 &mdash; `Idempotency-Key` header (transport-level replay safety)

- Pass any string up to 255 chars (UUIDs are conventional).
- **Same key + byte-identical request body**&colon; we return the
  cached response (status + body) for up to **24 hours**. Safe to
  retry network errors blindly.
- **Same key + different body**&colon; `409 idempotency_conflict`.
  This is almost always a bug on the caller side &mdash; you reused a
  key for a different request.
- **No key at all**&colon; the write goes through with no replay
  protection. We still apply layer 2.

Recommended sources for the key, in priority order:

1. The upstream platform's event id (Shopify `X-Shopify-Webhook-Id`,
   Stripe `request.id`, WooCommerce `X-WC-Delivery-ID`, Zapier
   `{{zap_id}}`). Idempotent at the source &rarr; idempotent at our
   side.
2. A deterministic hash of the canonicalized request body (see the
   Node/Python recipes above).
3. A fresh UUIDv4 per attempt &mdash; only if you absolutely cannot
   compute one of the above. This is **not** retry-safe.

### Layer 2 &mdash; `external_id` entity dedupe (resource-level)

- `(key_id, external_id)` is unique per resource type.
- A repeat write of an existing `external_id` returns
  `{ id, duplicate: true }` and does **not** mutate the row.
- This is what makes "fire and forget" upstream webhooks safe even
  when you don't (or can't) pass `Idempotency-Key`. Always set
  `external_id` to something stable from the source platform.

Both layers run on every write &mdash; transport replay is checked
first, then entity dedupe. They are independent&colon; a new
`Idempotency-Key` with an existing `external_id` correctly returns
the `duplicate: true` envelope.

---

## HMAC signing (`PC-Signature`)

When the key has `require_signature = true` (always-on for the
WooCommerce path; opt-in elsewhere), every write must carry a
`PC-Signature` header:

```
PC-Signature: t=<unix>,v1=<hex hmac-sha256(t + "." + raw_body, signing_secret)>
```

- `t` is the current Unix time in seconds.
- `v1=` is `hex(hmac_sha256(secret, t + "." + raw_body))` where
  `raw_body` is the literal bytes you send on the wire.
- We reject `|now - t| > 300` (5 minutes) with `signature_expired`.
  This protects against replay attacks even if TLS terminates at a
  proxy that doesn't reject stale requests.

This is the same scheme the read API's *outbound* webhooks use, so
the verification helpers below also drop into your own inbound
webhook handlers.

### Node verification helper

```js
import crypto from 'node:crypto';

export function sign(rawBody, secret, now = Math.floor(Date.now() / 1000)) {
  const v1 = crypto
    .createHmac('sha256', secret)
    .update(`${now}.${rawBody}`)
    .digest('hex');
  return `t=${now},v1=${v1}`;
}

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

### PHP verification helper

```php
<?php
function pck_sign(string $rawBody, string $secret, ?int $now = null): string {
    $now ??= time();
    $v1 = hash_hmac('sha256', $now . '.' . $rawBody, $secret);
    return "t={$now},v1={$v1}";
}

function pck_verify(string $header, string $rawBody, string $secret, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
        $parts[$k] = $v;
    }
    $t  = (int) ($parts['t']  ?? 0);
    $v1 = (string) ($parts['v1'] ?? '');
    if ($t === 0 || $v1 === '') return false;
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $v1);
}
```

### WooCommerce signatures

WooCommerce signs differently &mdash;
`X-WC-Webhook-Signature: base64(hmac_sha256(raw_body, secret))`,
no timestamp. The `/integrations/v1/webhook/woocommerce` endpoint
expects exactly that scheme; **do not** send `PC-Signature` on
WooCommerce-format keys.

---

## Error codes reference

Every endpoint returns the same envelope on failure:

```json
{ "error": "snake_case_code", "detail": "human readable" }
```

| HTTP | `error`                       | When it happens |
| ---- | ----------------------------- | --------------- |
| 400  | `invalid_json`                | Body isn't valid JSON. |
| 401  | `missing_authorization`       | `Authorization` header is absent. |
| 401  | `invalid_api_key`             | Bearer token isn't a known `pck_int_*` key, or it targets a different store than the path implies. |
| 401  | `integration_key_revoked`     | Key was revoked in the Connectors UI. |
| 401  | `integration_key_expired`     | Key passed its `expires_at`. |
| 401  | `signature_invalid`           | `PC-Signature` (or `X-WC-Webhook-Signature`) HMAC didn't verify. |
| 401  | `signature_expired`           | `PC-Signature` timestamp skew exceeded 5 minutes. |
| 403  | `insufficient_scope`          | Key is valid but lacks the scope required for the endpoint. |
| 404  | `not_found`                   | Path-targeted row isn't in this key's `(org, store)` scope. |
| 409  | `idempotency_conflict`        | `Idempotency-Key` reused with a different request body. |
| 422  | `external_id_required`        | Missing `external_id` on an order or other resource that requires it. |
| 422  | `lines_required`              | `POST /v1/orders` body had no `lines`. |
| 422  | `partial_refund_not_supported`| `POST /v1/payments/{id}/refund` cannot do partial amounts in v1. Issue a negative-amount payment from the UI. |
| 429  | `rate_limited`                | Per-key rate limit. Honor the `Retry-After` header. |

When in doubt, hit `GET /integrations/v1/health` (no auth required)
to isolate "is the gateway up?" from "is my key wrong?".
