Skip to documentation

Webhooks

Last updated: September 24, 2026.

Palette sends signed JSON events to one endpoint you control, configured in Workspace settings → Other → Outbound event webhook. You supply both the HTTPS endpoint URL and the signing secret (generate it yourself, for example openssl rand -base64 32). The secret is stored encrypted, never shown again, and every delivery is signed with it so your server can prove the event came from Palette. The URL must be public HTTPS; redirects are never followed.

This webhook secret is separate from the identity signing secret: webhook signing proves events sent by Palette, while identity signing proves user IDs sent by your server.

Event types

participant.reward_earned: sent when a verified participant recruited from your site completes a reward-bearing study and the reward is granted, either automatically or after you approve it in the reward queue. Grant the credit in your own system when you receive it. reward.amount is a decimal string; built-in rewards are USD credit, while custom rewards specify their denomination in reward.group. user_id is the userId your site passed to the snippet.

{
  "event": "participant.reward_earned",
  "test": false,
  "study_id": "3ba16efb-304b-4070-a782-ad13eca4fc6d",
  "user_id": "user-4821",
  "reward": { "type": "credit", "amount": "5.00", "currency": "USD" },
  "verification": { "completed": true, "quality_passed": true },
  "completed_at": "2026-07-28T04:18:30+00:00",
  "idempotency_key": "3ba16efb-304b-4070-a782-ad13eca4fc6d:user-4821"
}

panel.invite: sent when you launch a recontact campaign to your standing panel. Palette never contacts your users directly: deliver invite_copy through your own channel (email, in-app), link the user to return_url (it carries the invite token), and include unsubscribe_url so they can opt out of future invites.

{
  "event": "panel.invite",
  "test": false,
  "campaign_id": "6f2f9f3e-8d1e-4f6a-9d5b-a1c2d3e4f5a6",
  "delivery_id": "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f",
  "study_id": "3ba16efb-304b-4070-a782-ad13eca4fc6d",
  "user_id": "user-4821",
  "audience": "Pro users",
  "invite_copy": "We shipped the flow you asked about - 5 minutes to try it?",
  "return_url": "https://app.example.com/research?palette_panel_invite=<token>",
  "unsubscribe_url": "https://api.palettelabs.ai/api/recruit/panel/opt-out/?token=<token>",
  "idempotency_key": "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
}

New event types and new fields on existing events may be added over time. Ignore events and fields you do not recognize instead of failing on them.

Verifying signatures

Every request carries one header:

X-Palette-Signature: t=1753673910,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t is the Unix timestamp of the send; v1 is lowercase-hex HMAC-SHA256 of {t}.{raw_body} keyed with your signing secret. To verify: read the raw request bytes, check t is within 5 minutes of your clock, compute the HMAC over {t}. plus those exact bytes, and compare to v1 with a constant-time comparison. The timestamp is inside the signed content, so one check covers both integrity and freshness. The most common mistake is hashing re-serialized JSON instead of the raw request body; key order and whitespace differ and verification fails.

const crypto = require("node:crypto");

// rawBody MUST be the exact request bytes (e.g. express.raw()).
// Re-serializing parsed JSON produces different bytes and fails verification.
function verifyPaletteSignature(secret, header, rawBody) {
  const parts = Object.fromEntries(
    header.split(",").map((pair) => pair.split(/=(.*)/s).slice(0, 2)),
  );
  const timestamp = Number.parseInt(parts.t, 10);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; // ±5 min
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib, hmac, time

# raw_body MUST be the exact request bytes (request.body / request.get_data()).
# Re-serializing parsed JSON produces different bytes and fails verification.
def verify_palette_signature(secret: str, header: str, raw_body: bytes) -> bool:
    pairs = dict(part.split("=", 1) for part in header.split(","))
    timestamp = int(pairs["t"])
    if abs(time.time() - timestamp) > 300:  # ±5 min
        return False
    message = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, pairs["v1"])

Responding, retries, and duplicates

Return any 2xx status to acknowledge; the response body is ignored. On 5xx, 408, 429, timeouts, or connection errors Palette retries up to 8 attempts over roughly a day, with exponential backoff (1m, 4m, 16m, 64m, ~4h, then 8h between attempts) and honoring a Retry-After header. Any other 4xx is treated as permanent and not retried.

Delivery is at-least-once: the same event can arrive twice, so deduplicate on idempotency_key (for rewards it is {study_id}:{user_id}, one reward per participant per study; for invites it is the delivery id). Process each key once, and return 2xx for keys you have already processed.

The Test webhook button in Workspace settings sends a real signed delivery with test: true. Verify it and return 2xx like any other event, but do not grant credits or send invites for test events.