Switchyard

Webhooks

Two webhook surfaces: signed inbound (your sources → Switchyard) and outbound callbacks from ClickUp. Both use HMAC; the schemes are deliberately different so operators don't accidentally cross them up.

Inbound — sources → Switchyard

URL: POST /webhooks/inbound/{workspace_id}/{source_slug}

Signature header (Stripe/Inkwell-style):

X-Switchyard-Signature: t=<unix_seconds>,v1=<hex_sha256>

where v1 = hmac_sha256(source.inbound_secret, t + "." + raw_body)

Replay tolerance is 5 minutes from t. The signed payload includes the timestamp, so a replay can't swap it in.

Signing recipe (Node)

import { createHmac } from 'node:crypto';

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

await fetch('https://api.switchyard.philiprehberger.com/webhooks/inbound/' + workspaceId + '/scopeforged-form', {
  method: 'POST',
  headers: {
    'X-Switchyard-Signature': sign(SOURCE_SECRET, JSON.stringify(payload)),
    'Idempotency-Key': crypto.randomUUID(),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload),
});

Idempotency

Include an Idempotency-Key header (any unique string, ≤64 chars). A repeated key within 24 hours returns the original 202 response and does not enqueue a second ingest. After 24 hours the key expires and the next POST proceeds.

Error responses

  • 401 — signature missing, malformed, expired, or mismatched
  • 404 — workspace or source slug not found
  • 409 — source exists but is disabled

Outbound — ClickUp → Switchyard

URL: POST /webhooks/clickup/{workspace_id}

Subscribed on connect for taskStatusUpdated, taskUpdated, and taskDeleted events. ClickUp signs with X-Signature: <hex> where the hex is hmac_sha256(workspace.webhook_secret, raw_body).

On a valid signature, Switchyard dispatches MirrorClickUpStatusJob which looks up the local lead by clickup_task_id, translates the ClickUp status via status_mapping, and updates the lead row. Unmapped status names emit a mirroredevent for audit but don't change the local status column.