Overview

Outbound webhooks let your downstream systems react to events in your {{COMPANY_SHORT_NAME}} workspace without polling. Register up to 10 HTTPS endpoints in Settings → Webhooks; we'll POST a signed JSON payload to each one whenever a subscribed event fires. Deliveries are signed with HMAC-SHA256, retried on failure with exponential backoff, and visible in the per-endpoint deliveries drawer.

This is the inverse of the Public read API. Where the public API is “ask us for data when you need it”, outbound webhooks are “we'll tell you when something happens”.

What working correctly looks like

If your endpoint is wired up, you should see all of these. If any one is missing, jump to Troubleshooting.

  • The endpoint shows up as Enabled in Settings → Webhooks.
  • Synthetic test events (the “Send test” button) land at the endpoint with status 200 within a second.
  • Every delivery in the per-endpoint drawer is marked succeeded (green); failures appear in clay.
  • Your receiver verifies X-PC-Signature against the endpoint secret and rejects tampered bodies.
  • Failed deliveries auto-retry on the 1m / 5m / 30m / 2h / 8h schedule; after 5 attempts they're marked dropped.
  • An X-PC-Delivery-Id you've already processed never causes a duplicate side-effect on your side (you dedupe on it).

Setup — register an endpoint

  1. Sign in and go to Settings → Webhooks.
  2. Click New endpoint.
  3. Paste your HTTPS URL. HTTP is rejected; we need TLS for the signature to mean anything.
  4. Pick the event types you want this endpoint to receive. See the event catalog below.
  5. Click Create. We'll show you the endpoint's signing secret — copy it now, it's only displayed once.
  6. Drop the secret into your receiver's config (env var, secrets manager, whatever).
  7. Use the Send test button to fire a synthetic webhook.test event and confirm the receiver responds with 200.
One-time secret reveal

The signing secret is shown once at creation time. If you lose it, rotate the endpoint (one click in the endpoint detail panel) and re-paste the new secret into your receiver. Old signatures stop verifying immediately.

Headers we send

Every delivery carries the following headers:

Content-Type:      application/json
User-Agent:        PeptideClients-Webhook/1.0
X-PC-Event-Type:   order.created
X-PC-Event-Id:     ce5f7c0a-...           # null for synthetic test events
X-PC-Delivery-Id:  c0bb1e9f-...
X-PC-Timestamp:    1779249317
X-PC-Signature:    t=1779249317,v1=<hex hmac-sha256(secret, ts + "." + body)>
  • X-PC-Event-Type — the event name (see catalog). Useful for routing in your receiver.
  • X-PC-Event-Id — stable across retries of the same logical event. Null for synthetic test events.
  • X-PC-Delivery-Id — unique to this physical delivery attempt. Use it to dedupe inside your receiver if you can't reach idempotency another way.
  • X-PC-Timestamp — unix seconds. Part of the signed material. Reject deliveries older than ~5 minutes to guard against replay.
  • X-PC-Signature — Stripe-style. See verification below.

Signature verification

The signature scheme is Stripe-style:

X-PC-Signature: t=<unix-seconds>,v1=<hex hmac-sha256(secret, ts + "." + raw_body)>

To verify, your receiver must:

  1. Parse the t= and v1= parts out of X-PC-Signature.
  2. Build the signed material: <t> + "." + <raw_request_body>. The raw bytes, not parsed JSON.
  3. Compute hex(hmac_sha256(endpoint_secret, signed_material)).
  4. Compare against the received v1 value with a constant-time comparison.
  5. Reject if abs(now - t) > 300 seconds — the timestamp is part of the signed material so we know it isn't a replay.

Node

import crypto from 'node:crypto';

export function verifyWebhook(req, secret) {
  const sig = req.headers['x-pc-signature'] ?? '';
  const ts  = req.headers['x-pc-timestamp'] ?? '';

  // Reject replays older than 5 minutes.
  const age = Math.abs(Date.now() / 1000 - Number(ts));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${req.rawBody}`)
    .digest('hex');

  // Constant-time compare against the v1= component.
  const v1 = sig.split(',').find((p) => p.startsWith('v1='))?.slice(3) ?? '';
  const a = Buffer.from(v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

You need the raw request body, not the parsed JSON. In Express, use express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }).

Python

import hmac, hashlib, time

def verify_webhook(headers, raw_body: bytes, secret: str) -> bool:
    sig = headers.get('x-pc-signature', '')
    ts  = headers.get('x-pc-timestamp', '')

    try:
        if abs(time.time() - int(ts)) > 300:
            return False
    except ValueError:
        return False

    signed = f'{ts}.'.encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    v1 = ''
    for part in sig.split(','):
        if part.startswith('v1='):
            v1 = part[3:]
    return hmac.compare_digest(v1, expected)

Retry policy

A delivery is a success if your receiver returns a 2xx within 10 seconds. Anything else — 3xx, 4xx, 5xx, timeout, DNS failure, TLS error — is a failure, and we re-queue the delivery for retry.

Failed deliveries retry on this schedule, relative to the first attempt:

  • +1 minute
  • +5 minutes
  • +30 minutes
  • +2 hours
  • +8 hours

After max_attempts (default 5) the delivery is marked dropped. You can manually re-fire any dropped delivery from the per-endpoint deliveries drawer with one click; that re-fire counts as attempt 1 of a fresh retry chain.

Why retries can re-deliver the same event

The X-PC-Event-Id is stable across retries; the X-PC-Delivery-Id is unique per attempt. If your receiver applies a side-effect (creates a row, charges a card, posts to Slack), dedupe on X-PC-Event-Id so a retry never double-applies.

Event catalog

Event typeWhen it fires
order.createdA new operator-side order is created (any source — UI, integration, manual).
order.shippedAn order is marked shipped.
order.deliveredThe carrier reports delivered (Shippo tracking webhook).
client.createdA client is added to the CRM (manually or via the Integration API's client upsert).
client.updatedAny client field changes.
vendor.createdA supplier is added to the supplier CRM.
vendor.updatedAny supplier field changes.
vendor.claimedA marketplace shop vendor profile is claimed via the claim-token flow.
payment.recordedA payment is recorded against an order.
shipment.tracking_addedA tracking number is attached to a shipment.
webhook.testSynthetic test event from the “Send test” button or POST /v1/webhooks/test/{id}.

Synthetic test events

Two ways to fire a synthetic webhook.test event at an endpoint without doing anything in the operator UI:

  • UI — the Send test button in the endpoint detail panel. Fires a single test delivery; the response shows up in the deliveries drawer immediately.
  • APIPOST /v1/webhooks/test/{endpoint_id} with a pck_live_* key carrying the read_write scope (see Public API → Webhook test). Useful for CI smoke tests of a downstream subscriber.

The body of a test event has X-PC-Event-Id: null so your receiver can short-circuit any production-side dedupe.

Limits

  • Endpoints per org: 10 enabled. Beyond 10, disable an existing endpoint before creating a new one.
  • Payload cap: 6 MB per delivery (Edge Function limit). Events larger than that are deliberately summarized rather than truncated.
  • Receiver timeout: 10 seconds. If your receiver is slow, return 202 immediately and process async.
  • Retry attempts: 5 (configurable per-endpoint in the detail panel, max 10).

Settings & permissions

  • Where: Settings → Webhooks (/settings/webhooks).
  • Who can register endpoints: org owners and org admins.
  • Who can rotate secrets: org owners and org admins.
  • Audit: every endpoint create / rotate / delete writes a row to core.audit_log; surfaces in Settings → Audit.

API + automation

Endpoint CRUD is exposed as SDFs:

  • core.rpc_outbound_webhook_create(...) — register a new endpoint, returns plaintext secret once.
  • core.rpc_outbound_webhook_list(...) — list endpoints in the org.
  • core.rpc_outbound_webhook_disable(p_id) / _enable(p_id) — toggle.
  • core.rpc_outbound_webhook_rotate_secret(p_id) — rotate.
  • core.rpc_outbound_webhook_delete(p_id) — soft-delete.
  • core.rpc_outbound_webhook_list_deliveries(p_id, ...) — keyset-paginated delivery history.

For synthetic test events, see Synthetic test events above.

Troubleshooting

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

  • Deliveries all failing with the same 401 / 403 from your side — your signature verification is wrong. Confirm you're feeding the raw body bytes (not parsed-then-re-serialized JSON) into the HMAC.
  • Test delivery from the UI works, but production events never arrive — the endpoint is filtered to event types that don't include the one you're triggering. Edit the endpoint and add the type.
  • Deliveries marked dropped — your receiver returned non-2xx (or timed out) for 5 attempts in a row. Fix the receiver, then click Retry in the deliveries drawer.
  • Receiver received the same event twice — that's a retry, not a bug. Dedupe on X-PC-Event-Id.
  • Signatures fail intermittently — you rotated the secret but only one of your replicas picked up the new value. Roll your deploy.
  • Public read API — the pull-side counterpart, including the POST /v1/webhooks/test/{id} endpoint.
  • Integration API — events fired by integration-created data (e.g. order.created from a WooCommerce import) flow through the same delivery pipeline.
  • Settings → Connectors — mint the write keys whose POSTs trigger most of the events catalogued here.
  • OpenAPI 3.0.3 spec — canonical machine-readable contract.