Quick check
Before you go deeper, eyeball these four things. A 30-second sanity pass catches the majority of integration breakage.
GET /integrations/v1/healthreturns{"ok": true}in <200 ms.- The key in your
Authorization: Bearer ...header starts withpck_int_and matches what Settings → Connectors shows as active. - The failing request shows up in Settings → Connectors → your key → Request log within a second of being sent.
- The
error_codecolumn on that log row matches one of the sections below.
No row in the request log within ~5 seconds means the request never reached us. That is a DNS / proxy / firewall problem on your side — not an integration-api error. Try curl -v https://api.peptideclients.com/integrations/v1/health from the same network.
401 invalid_api_key
The symptom
HTTP/1.1 401
Content-Type: application/json
{ "error": "invalid_api_key", "detail": "key not found, revoked, or expired" }
Why it happens
- The key is real but has been revoked (Settings → Connectors → key → Revoke). Once revoked it cannot be brought back — mint a new one.
- The key has a typo: extra space, line break, copied with the surrounding quote characters from a chat message, or one character dropped.
- You used the wrong prefix. The Integration API only accepts
pck_int_*keys.pck_live_*(Public read API) andpck_test_*won’t work. - You typed
Authorization: pck_int_xxxxxinstead ofAuthorization: Bearer pck_int_xxxxx— the bearer keyword is required.
How to fix it
- Open Settings → Connectors and confirm a row exists for your key, status Active, with the right scopes.
- Re-copy the key from your secrets vault. The plaintext only existed once at create time — if you can’t find it, you must mint a new key (we only store a SHA-256 hash).
- Re-paste into the
Authorizationheader. No quotes, no trailing newline. Format:Authorization: Bearer pck_int_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. - Retry. If the request log now shows a different
error_code, jump to that section.
401 missing_authorization
The symptom
{ "error": "missing_authorization", "detail": "Authorization header required" }
Why it happens
The Authorization header was not on the request at all. The two endpoints that don’t need it are GET /v1/health and GET /v1/openapi.json; everything else does.
Common culprits: a proxy stripping the header (CloudFront, some API gateways), a curl command with -H "Auth: ..." (note the wrong header name), or the framework you’re using sending the bearer as a query parameter.
How to fix it
- Run a raw
curl -vfrom your machine and confirm the header is on the wire: look for> Authorization: Bearer pck_int_...in the trace. - If curl works but your app doesn’t, the proxy or middleware is dropping it. Check forwarded-headers config.
- Remember: bearer goes in the
Authorizationheader, never in the URL.
401 signature_missing
The symptom
{ "error": "signature_missing", "detail": "key requires signature; header absent" }
Why it happens
The key has require_signature = true but the request had no signature header. WooCommerce keys always require a signature; peptideclients-format keys only do if you toggled the switch when minting.
How to fix it
- Decide which scheme applies. For
payload_format = peptideclientsthe header isPC-Signature. Forpayload_format = woocommercethe header isX-WC-Webhook-Signature. - Sign the raw request body using the secret we showed you at key-creation time (it only displayed once; if you don’t have it, rotate the secret in Settings → Connectors → key → Rotate signing secret).
- Set the matching header on the request. See Integration API → HMAC signing for code samples in Node and Python.
401 signature_malformed
The symptom
{ "error": "signature_malformed", "detail": "header present but did not parse" }
Why it happens
The signature header was present but didn’t match the expected format.
- peptideclients format: expected
t=<unix-seconds>,v1=<hex>. Common breakage: missing the comma, missing thev1=prefix, putting the timestamp in milliseconds, or pasting a JSON object in place of the header string. - woocommerce format: expected a single base64 string. Common breakage: passing hex instead of base64, or accidentally wrapping the value in quotes.
How to fix it
- Log the exact bytes you put in the header before sending. Make sure there’s no leading/trailing whitespace and no surrounding quotes.
- For PC-Signature, your timestamp must be Unix seconds (10 digits, like
1716309600), not milliseconds. - For X-WC-Webhook-Signature, run
echo -n "$body" | openssl dgst -sha256 -hmac "$secret" -binary | base64on a Mac to sanity-check what the value should look like.
401 signature_expired
The symptom
{ "error": "signature_expired", "detail": "timestamp drift > 300 seconds" }
Why it happens
The t= field in your PC-Signature header is more than 5 minutes off from our server clock. (WC signatures don’t include a timestamp and won’t hit this code.)
This is almost always a clock-skew issue on the sending side — a container missing NTP, a developer machine whose clock drifted, or a queued job being signed once and replayed hours later.
How to fix it
- Run
date -uon the sending host and compare tocurl -sI https://api.peptideclients.com/integrations/v1/health | grep -i date. If they differ by more than a minute, fix NTP on the sender. - If you queue requests and sign them ahead of time, re-sign at the moment of send, not at enqueue.
- Do not compensate by inventing a larger window on your side — the 5-minute window is fixed server-side.
401 signature_invalid
The symptom
{ "error": "signature_invalid", "detail": "HMAC mismatch" }
WooCommerce will interpret this as a delivery failure and retry forever; see the WC retry loop note below.
Why it happens
The HMAC you computed doesn’t match what we computed. Both sides have the same secret in mind, but one of the following four things diverged:
- Different secret. The value in your sender code is not the same as the one stored on the key row. Each rotate generates a brand-new value — if you rotated and forgot to update one side, this is the failure.
- Different body bytes. You signed a pretty-printed version of the body but sent the minified one, or vice versa. Always sign the exact bytes you put on the wire. Specifically, do not call
JSON.parse(body)+JSON.stringify(...)between signing and sending. - Different header name / scheme. WC keys must use
X-WC-Webhook-Signature(base64). peptideclients keys must usePC-Signature(t=...,v1=...hex). Mixing them producessignature_invalid. - (WC only) Secret encoding. Our WC verifier reads the signing secret as a hex string and uses the UTF-8 bytes of that hex string as the HMAC key — not the raw bytes the hex represents. This matches what WooCommerce itself does. If you decode the hex to raw bytes first, your signature won’t match.
If the secret we showed you is a3f1c9... (64 hex chars), the HMAC key is the literal string "a3f1c9..." (64 bytes of ASCII). Many crypto helpers will silently decode that to 32 binary bytes if you ask for hex input — don’t. WooCommerce’s native webhook code does hash_hmac('sha256', $body, $secret, true), treating $secret as a plain string. Match that.
How to fix it
- On the WC side: WooCommerce → Settings → Advanced → Webhooks → your webhook. Copy the Secret field value. Confirm it is byte-identical to the value Settings → Connectors → key shows under Signing secret (plaintext). If you can’t see plaintext on our side (we don’t store it after creation), rotate the signing secret and paste the new value into WC.
- On a custom
peptideclients-format integration: print the body bytes immediately before signing and immediately before sending. Hash them both withsha256and confirm the digests match. Mismatch = something is reformatting your body in flight. - For WC keys, double-check the secret-encoding gotcha above by running this sanity check locally:
// Node.js -- this is what WC sends, and what we verify against
const crypto = require('crypto');
const body = '... raw request body ...';
const secret = 'a3f1c9...'; // <-- the hex string, as a plain string
const expected = crypto
.createHmac('sha256', secret) // ← secret used as a UTF-8 string
.update(body, 'utf8')
.digest('base64');
// Set on the request:
// X-WC-Webhook-Signature: <expected>
If WC is stuck in a retry loop because of this error, see WooCommerce 401 retry loop.
403 ip_not_allowed
The symptom
{ "error": "ip_not_allowed", "detail": "source ip not in allowlist" }
Why it happens
You set an IP allowlist (CIDR) on this key, and the request came from an IP outside that range. The check runs before auth, so even a perfectly valid bearer + signature won’t pass.
How to fix it
- Find the source IP we saw. The request log row has a
client_ipcolumn — that’s the IP we evaluated against the allowlist. - Decide whether to widen the allowlist or move the source. Production stores usually want to add the new IP. Local development usually wants to remove the allowlist entirely.
- Settings → Connectors → key → IP allowlist. Add the IP or CIDR (e.g.
203.0.113.42/32). Save. - Retry the call. The next request log row should be
200.
If your hosting provider rotates outbound IPs (Heroku, Render, Vercel serverless), the allowlist is a poor fit — you’d have to whitelist the provider’s entire egress range. Lean on HMAC signing instead and leave the allowlist empty.
403 insufficient_scope
The symptom
{ "error": "insufficient_scope", "detail": "key missing scope: orders:write" }
Why it happens
The endpoint requires a scope your key doesn’t have. For example, you POSTed an order to /v1/orders with a key that only has clients:write.
How to fix it
Scopes are not editable after creation — this is by design (an existing leaked key cannot silently gain new powers). Mint a new key with the correct scope set, swap it in, then revoke the old one.
- Settings → Connectors → New integration key.
- Pick the same store, the same payload format, the same IP allowlist policy.
- Tick every scope the integration actually needs. For a full WooCommerce sync that is usually
orders:write,orders:cancel,payments:write,payments:refund, andclients:write. - Copy the new key value (and signing secret if applicable). Update your sender.
- Revoke the old key once the cut-over is confirmed in the request log.
429 rate_limited
The symptom
HTTP/1.1 429
Retry-After: 60
Content-Type: application/json
{ "error": "rate_limited", "detail": "600 req/min cap" }
Why it happens
You went over your per-key cap. Default is 600 requests per minute, enforced as a Postgres-backed sliding 60-second window. The cap is per-key, not per-org — two keys on the same store each get their own budget.
How to fix it
- Honor
Retry-After. The simplest correct behavior is “sleep N seconds, then retry the same request with the sameIdempotency-Key” — we’ll replay the cached response if the original eventually succeeded. - If you legitimately need more throughput, raise the cap: Settings → Connectors → key → Rate limit. Maximum is 6,000 req/min.
- If you need more than 6,000 req/min you almost certainly want batching, not a higher cap. Open a thread with {{CONTACT_EMAIL}} describing the use case.
- One-time backfill of historical data? Run it serially with a small
sleepbetween calls instead of raising the cap.
409 idempotency_conflict
The symptom
{ "error": "idempotency_conflict", "detail": "same key, different body, within 24h window" }
Why it happens
You reused an Idempotency-Key with a different body within 24 hours. Same key + byte-identical body returns the cached response; same key + different body is a conflict by design (it prevents accidentally overwriting one event with another).
How to fix it
- Pick a new
Idempotency-Keyfor the new payload. The recommended shape is<source>-<event-id>-<attempt>— e.g.woo-12345-1,woo-12345-2. Bumping the attempt suffix on a deliberate replay makes the dedupe boundary explicit. - If you’re generating the key from a hash of the body, the conflict is benign — it just means the body changed between attempts. Recompute and retry.
- If you can’t change the key and need to force the new write through, wait out the 24-hour window or call the endpoint without an
Idempotency-Keyat all (you lose HTTP-retry dedupe, butexternal_idin the body still prevents long-term duplicates).
422 lines_required
The symptom
{ "error": "lines_required", "detail": "create order missing 'lines' array" }
Why it happens
You POSTed to /v1/orders without a non-empty lines array. Orders must have at least one line for totals to be meaningful.
How to fix it
- Add a
linesarray with at least one{ description, quantity, unit_price_cents }object. - If the source order is line-less (a flat charge), use a single line with
description: "Order total"and the full amount inunit_price_cents. - If you’re mapping from WooCommerce, POST to
/v1/webhook/woocommerceinstead of/v1/orders— the WC adapter constructslinesfrom the WCline_itemsfor you.
422 external_id_required
The symptom
{ "error": "external_id_required", "detail": "create endpoints require 'external_id'" }
Why it happens
Every create endpoint needs an external_id so we can dedupe replays forever (via core.external_entity_refs). It is not optional.
How to fix it
- Pick a stable identifier from your source system — the order id, the txn id, the contact id. Anything that won’t change.
- Pass it as
external_idat the top level of the create payload. For WC orders it’s typically"woo-; for a custom site it might be" "shop-." - If the next call from the same source repeats the same
external_id, we’ll return the original record with"duplicate": true. That’s the correct shape — not an error.
422 invalid_json
The symptom
{ "error": "invalid_json", "detail": "request body did not parse as JSON" }
Why it happens
- The body is empty (you forgot the
--dataflag in curl). - The body has a trailing comma, single quotes instead of double quotes, or unquoted keys.
- Your framework set
Content-Type: application/x-www-form-urlencodedinstead ofapplication/jsonand posted form data. - The body is double-encoded (a JSON string containing JSON).
How to fix it
- Pipe the body through
jq .locally to confirm it parses. - Set
Content-Type: application/jsonexplicitly. - If your HTTP client auto-encodes, make sure you’re handing it an object to encode — not a pre-stringified JSON blob (that produces the double-encoded case).
400 wrong_format
The symptom
{ "error": "wrong_format", "detail": "endpoint does not accept this key's payload_format" }
Why it happens
Routes are pinned to a payload_format. A woocommerce key cannot POST to /v1/orders (it would skip the WC->SDF translation). A peptideclients key cannot POST to /v1/webhook/woocommerce (it has no WC payload to translate).
How to fix it
- If you have a WC key: POST to
/v1/webhook/woocommercewith the raw WC Order JSON. - If you have a generic
peptideclientskey: POST to/v1/orderswith our SDF JSON shape. - If you need both, mint two keys — one per format. They can point at the same store; they’ll just translate different inputs.
Reading the diagnostics
Every request — success or failure — lands in Settings → Connectors → your key → Request log. The drawer table shows the most recent 200 rows; click a row to see the full payload.
| Column | What to look at |
|---|---|
method | Confirm it matches what you sent (POST for create endpoints). |
path | e.g. /v1/orders, /v1/webhook/woocommerce. If this shows the wrong path, your client is hitting a different endpoint than you think. |
status_code | Anything in the 4xx range maps to one of the sections above. |
error_code | The exact code string. Jump to the section with the same id on this page. |
client_ip | The IP we evaluated against the allowlist. If ip_not_allowed, this is the value to add (or remove the allowlist). |
idempotency_key | What you passed in Idempotency-Key. Cross-reference for replay debugging. |
external_id | Extracted from the body. If empty on a create call, that’s probably your external_id_required failure. |
request_received_at → request_completed_at | End-to-end latency including DB writes. P95 should be <800 ms; sustained >2 s is worth reporting. |
If a call doesn’t appear in the request log, it never reached us. Don’t spend time debugging signatures or scopes — debug the network path between your sender and api.peptideclients.com.
When to write to support
If the request log shows a row whose error_code doesn’t match any section on this page, or you see status_code = 500, email {{CONTACT_EMAIL}} with:
- The key id (the public part, not the secret) — visible in Settings → Connectors as
pck_int_followed by the first 8 chars. - The timestamp of the failing request, in ISO-8601 UTC, copied from
request_received_at. - The full request log row — click Copy as text on the drawer. That gives us the path, error_code, client_ip, and external_id we need.
- The cURL you ran, with the bearer redacted to
pck_int_XXXX…. We don’t need (and don’t want) the secret. - What you expected to happen vs what actually happened — one sentence each.
Related: Integration API feature guide · WooCommerce order didn’t import · Troubleshooting hub.