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.

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/health returns {"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.created outbound webhook fires for every successful create (visible in Settings → Webhooks → Deliveries).

Step 1 — mint a key

  1. Sign in and go to Settings → Connectors.
  2. Click New integration key.
  3. Pick a store — the key is permanently pinned here.
  4. 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 = true and switches the HMAC scheme to WC’s.
  5. Pick scopes. Add only what the integration needs:
    • orders:write — create orders
    • orders:cancel — cancel orders
    • payments:write — record payments
    • payments:refund — refund payments
    • clients:write — upsert clients without an order
  6. (Optional) Add an IP allowlist (CIDR) and toggle Require signature.
  7. Click Create. We show you the key value once. Copy it now — we only store a hash.
The signing secret

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/health
  • GET /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.

Why store the signing secret in plaintext?

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 body409 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.

Recommended combo

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 statusWhat we doOur status
pendingCreate order, no paymentinvoiced
on-holdCreate order, no paymentinvoiced
failedCreate order, no paymentinvoiced
processingCreate order + inline paymentpaid
completedCreate order + inline paymentpaid
cancelledLookup or create, then cancelcancelled
refundedFind 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:

  1. By external_id. If the payload includes client.external_id and we’ve seen that external id from this same key before, we attach to that record. Done.
  2. 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_id for next time and stamp the source.
  3. Create new. No match → brand-new client row. We stamp source_label, source_external_id, and source_integration_key_id so 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

CodeHTTPMeaning
missing_authorization401No Authorization header.
invalid_api_key401Unknown, revoked, or expired key.
signature_missing401Key requires signature; none provided.
signature_malformed401Header was present but didn’t parse.
signature_expired401Timestamp skew >5 minutes.
signature_invalid401HMAC mismatch.
signature_unconfigured401Key requires signature but has no secret on file (rare; rotate it).
ip_not_allowed403Source IP not in the key’s allowlist.
insufficient_scope403Key lacks the scope this endpoint requires.
not_found404Order / payment / external id not found.
idempotency_conflict409Same Idempotency-Key, different body, within 24h.
invalid_json422Body wasn’t valid JSON.
external_id_required422Create endpoint missing external_id.
lines_required422Create order missing lines.
partial_refund_not_supported422v1 refunds are full only.
wrong_format400Endpoint doesn’t accept this key’s payload_format (e.g. WC key posting to /orders).
rate_limited429Per-key cap exceeded.

Troubleshooting

The full symptom-to-fix table lives at Troubleshooting → Integration API. Quick links: