Webhooks

Receive HTTPS POST callbacks when a job's status changes instead of polling GET /v1/jobs/{jobId}. Configure a default webhook URL on the project (or override per job at create time). Webhooks work without a signing secret; for production integrations, rotate a secret and verify signatures on your server so only Perchat can trigger your handler.

See also Jobs for job creation and the public Job object shape included in webhook payloads.

Events

Perchat sends a webhook for each meaningful job status transition:

  • queued — job entered the queue (pending queued on first dispatch only; internal retry re-queues do not emit).
  • processing — a worker claimed the job.
  • cancelling — cancel requested while the job was processing.
  • completed, failed, cancelled — terminal outcomes. failed is sent only after retries are exhausted, not on intermediate worker failures that will retry.
  • test — synthetic payload from POST /v1/project-webhooks/test (not a job lifecycle event).

pending and internal worker retry transitions are not delivered as webhooks.

Callback request

Perchat POSTs JSON to your configured HTTPS URL. The body matches:

  • event — event name (see above).
  • timestamp — ISO-8601 time when the event was emitted.
  • job — public job snapshot (same fields as GET /v1/jobs/{jobId}), including userMetadata.customerJobRef when set.

Request headers:

  • X-Perchat-Event — duplicates event.
  • X-Perchat-Delivery-Id — delivery id (whd_…) for idempotency and support.
  • X-Perchat-Timestamp — Unix seconds used for signing.
  • X-Perchat-Signature — present only when a project signing secret is configured (see Signing secret). Format: t={unix},v1={hex}.

Respond with any 2xx status to acknowledge delivery. Non-2xx responses trigger retries (see below).

Signing secret

A signing secret is optional. Webhooks are delivered as soon as you configure an HTTPS webhookUrl — you do not need a secret to receive callbacks or to use POST /v1/project-webhooks/test.

Without a secret: Perchat POSTs JSON with X-Perchat-Event, X-Perchat-Delivery-Id, and X-Perchat-Timestamp. There is no X-Perchat-Signature header. That is fine for local smoke tests (e.g. inspecting requests in a public inbox), but anyone who discovers your URL could send fake job events to your endpoint.

Why rotate a signing secret in production? Your webhook URL is a public HTTPS endpoint. Without verification, an attacker could POST forged completed events and trick your systems into treating jobs as done, fetching the wrong artifacts, or updating the wrong records. The secret lets your server prove each callback was signed by Perchat before you act on job data.

Setup

  1. Call POST /v1/project-webhooks/rotate-secret (or use the dashboard Rotate signing secret button).
  2. Copy data.webhookSecret immediately — it is shown once. Later reads return only hasWebhookSecret and webhookSecretPreview (last four characters).
  3. Store the secret on your server (environment variable, secrets manager). Do not paste it into third-party inbox tools — they cannot verify signatures for you; only your application should hold the secret.
  4. Implement verification on your webhook route before processing the JSON body (see Verify signatures).

Rotating a secret invalidates the previous secret for new deliveries. Update your server config before or immediately after rotation to avoid rejecting live traffic.

Verify signatures

When a signing secret is configured, every delivery includes X-Perchat-Signature. Your handler must verify it using the same secret you stored at rotation time. If verification fails, respond with a non-2xx status (typically 401) and do not trust the payload — Perchat will retry until you fix verification or the delivery exhausts attempts.

Algorithm

  1. Read X-Perchat-Timestamp (Unix seconds) and the raw request body as received on the wire (before JSON parsing).
  2. Build the signed string: timestamp + "." + rawBody (UTF-8).
  3. Compute HMAC-SHA256(webhookSecret, signedString) and encode as lowercase hex.
  4. Parse X-Perchat-Signature (format t={unix},v1={hex}) and compare your digest to the v1= value using a constant-time comparison.
Use the raw body. If your framework parses JSON and you re-serialize it, key order or whitespace may differ and verification will fail. Configure your route to read raw bytes (e.g. Express express.raw({ type: 'application/json' }), Flask request.get_data() before request.json).

Recommended checks

  • Reject requests missing X-Perchat-Signature when your server expects a secret to be configured.
  • Optionally reject timestamps too far from the current time (e.g. > 5 minutes) to limit replay of captured requests.
  • Use X-Perchat-Delivery-Id for idempotency — store processed delivery ids so retries do not double-apply side effects.
  • Respond with any 2xx only after verification succeeds.

Node.js

import crypto from 'node:crypto'

const secret = process.env.PERCHAT_WEBHOOK_SECRET // from rotate-secret (shown once)

export function verifyPerchatWebhook(req: {
  headers: Record<string, string | string[] | undefined>
  rawBody: Buffer | string
}): boolean {
  const timestamp = String(req.headers['x-perchat-timestamp'] ?? '')
  const sigHeader = String(req.headers['x-perchat-signature'] ?? '')
  if (!timestamp || !sigHeader) return false

  let v1 = ''
  for (const part of sigHeader.split(',')) {
    const trimmed = part.trim()
    if (trimmed.startsWith('v1=')) v1 = trimmed.slice(3)
  }
  if (!v1) return false

  const rawBody = typeof req.rawBody === 'string' ? req.rawBody : req.rawBody.toString('utf8')
  const signedPayload = `${timestamp}.${rawBody}`
  const expected = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex')

  try {
    return (
      expected.length === v1.length &&
      crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'))
    )
  } catch {
    return false
  }
}

// Express: use express.raw({ type: 'application/json' }) so req.body is the raw bytes.
// Reject with 401 when verifyPerchatWebhook returns false; respond 2xx only after verification.

Python

import hashlib
import hmac
import os

SECRET = os.environ["PERCHAT_WEBHOOK_SECRET"].encode("utf-8")

def verify_perchat_webhook(headers, raw_body: bytes) -> bool:
    timestamp = headers.get("X-Perchat-Timestamp", "")
    sig_header = headers.get("X-Perchat-Signature", "")
    v1 = None
    for part in sig_header.split(","):
        part = part.strip()
        if part.startswith("v1="):
            v1 = part[3:]
    if not timestamp or not v1:
        return False
    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected = hmac.new(SECRET, signed_payload.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

# Use request.get_data() before parsing JSON. Return 401 when verification fails.

Delivery and retries

Delivery is asynchronous. Failed attempts (network error, timeout, or non-2xx HTTP status) are retried up to maxAttempts (5) with exponential backoff: approximately 1 min, 5 min, 15 min, 1 hour, then 4 hours between attempts.

Inspect delivery history via GET /v1/project-webhooks/deliveries or GET /v1/jobs/{jobId}/webhook-deliveries. Each record includes attempt count, HTTP status, error summary, and next retry time.

Which URL is used

  1. If the job was created with webhookUrl, that URL is used for all events on that job.
  2. Otherwise Perchat uses the project default from PATCH /v1/project-webhooks/settings.
  3. If neither is set, no webhooks are sent for that job.

All webhook URLs must use HTTPS.

Project webhook settings

webhookUrlstring | null, optional
Default HTTPS callback URL for jobs in this project.
hasWebhookSecretboolean
True when a signing secret is stored on the project.
webhookSecretPreviewstring | null, optional
Last four characters of the secret for confirmation (never the full secret).

The Webhook delivery object

deliveryIdstring
Stable delivery id (whd_…).
eventstring
Event type delivered (or test).
targetUrlstring
URL that received (or will receive) the POST.
statusstring
pending, delivering, succeeded, failed, or retrying.
attemptCountinteger
Delivery attempts so far.
maxAttemptsinteger
Maximum attempts before marking failed.
lastHttpStatusinteger | null, optional
HTTP status from the most recent attempt.
lastErrorSummarystring | null, optional
Short error from the most recent failed attempt.

Webhook callback payload

eventstring
Status event name.
timestamptimestamp
When the event was emitted.
jobobject
Public Job object — same shape as GET /v1/jobs/{jobId}.

Retrieve webhook settings

GET/v1/project-webhooks/settings
Returns the project default webhook URL and whether a signing secret is configured. Authenticate with a project API key (sk_prj_…) that includes webhook:read and/or webhook:update, or with a dashboard user session (Authorization: Bearer <jwt>) plus X-Workspace-Id and X-Project-Id and matching workspace role permissions. Workspace API keys cannot access project webhook endpoints.

Requires permission

webhook:read

Update webhook settings

PATCH/v1/project-webhooks/settings
Set or clear the project default webhook URL. Send webhookUrl: null or an empty string to remove it. Authenticate with a project API key (sk_prj_…) that includes webhook:read and/or webhook:update, or with a dashboard user session (Authorization: Bearer <jwt>) plus X-Workspace-Id and X-Project-Id and matching workspace role permissions. Workspace API keys cannot access project webhook endpoints.

Requires permission

webhook:update

Parameters

webhookUrlstring | null, optional
HTTPS URL for job status callbacks. Must use https://.

Rotate signing secret

POST/v1/project-webhooks/rotate-secret
Generates a new HMAC signing secret for webhook verification. The full secret is returned once in data.webhookSecret — store it on your server immediately; Perchat never shows it again. After rotation, new deliveries include X-Perchat-Signature; your handler must verify it (see Verify signatures) before trusting the payload. Rotating invalidates the previous secret for new deliveries. Authenticate with a project API key (sk_prj_…) that includes webhook:read and/or webhook:update, or with a dashboard user session (Authorization: Bearer <jwt>) plus X-Workspace-Id and X-Project-Id and matching workspace role permissions. Workspace API keys cannot access project webhook endpoints.

Requires permission

webhook:update

Send test webhook

POST/v1/project-webhooks/test
Queues a test event to the configured project webhookUrl. Requires a default URL; returns deliveryId for tracking in the delivery log. Authenticate with a project API key (sk_prj_…) that includes webhook:read and/or webhook:update, or with a dashboard user session (Authorization: Bearer <jwt>) plus X-Workspace-Id and X-Project-Id and matching workspace role permissions. Workspace API keys cannot access project webhook endpoints.

Requires permission

webhook:update

List project webhook deliveries

GET/v1/project-webhooks/deliveries
Paginated delivery log for the project — recent attempts, HTTP status, and retry state. Authenticate with a project API key (sk_prj_…) that includes webhook:read and/or webhook:update, or with a dashboard user session (Authorization: Bearer <jwt>) plus X-Workspace-Id and X-Project-Id and matching workspace role permissions. Workspace API keys cannot access project webhook endpoints.

Requires permission

webhook:read

Query parameters

pageinteger, optional
Page number (default 1).
limitinteger, optional
Page size (default 20, max 100).

List job webhook deliveries

GET/v1/jobs/{jobId}/webhook-deliveries
Delivery history for a single job. Authenticate with a project API key (sk_prj_…) with job:read, or a user session with X-Workspace-Id and X-Project-Id.

Requires permission

job:read

Query parameters

pageinteger, optional
Page number (default 1).
limitinteger, optional
Page size (default 20, max 100).