Crowdee

Webhooks

Get notified in real time about a crowd job's lifecycle and answers — configure outbound webhooks, verify deliveries, and manage retries.

A crowd job webhook is an outbound HTTP callback you configure to notify one of your own endpoints whenever something happens on a job — a status change, or a worker answer being received, accepted, or rejected. A single job can have any number of independent webhooks, each subscribed to its own subset of event types, so you can route different events to different systems (for example, lifecycle events to a Slack integration and full answer payloads to your own data pipeline).

Webhooks are configured per job, either at creation time or afterwards, and can be managed through the API or from the job's Webhooks tab in the platform UI.

Event Types

EventFires when
job.createdThe job is created.
job.pausedThe job is manually paused.
job.resumedA paused job is manually resumed.
job.no_tasksEvery remaining slot is either an in-flight claim or an answer awaiting review — nothing is currently claimable, but the job is not yet finished.
job.finishedThe total number of accepted (and pending-review) answers reaches maxSlots.
job.expiredvalidUntil passes while the job is still assignable.
job.archivedThe job is archived (soft-deleted).
job.extendedmaxRepetitionsPerVariant is raised via the extend endpoint.
answer.receivedA worker submits an answer. Lightweight payload — identifiers only.
answer.received.fullThe same moment as answer.received, with the complete answer payload — identical in shape to a row of the JSON answer export.
answer.acceptedAn answer is accepted through manual review — the accept endpoint, or the "accept all pending" choice when archiving a job.
answer.rejectedAn answer is rejected.

answer.accepted only fires for manually-reviewed acceptances. It does not fire for an answer that was accepted instantly at submission (qualification-test jobs, or a survey job with autoAcceptHours: 0), nor for the automatic sweep that accepts a pending answer once autoAcceptHours elapses. If you need to know about those too, subscribe to answer.received/answer.received.full instead and track review state yourself, or poll GET /v2/crowd-jobs/:jobId/answers.

answer.received and answer.received.full are independent subscriptions, not two severities of the same thing — subscribe to whichever (or both) you need. Use the lightweight variant if you only need to know that something happened and will fetch details yourself later; use the full variant if you want the complete answer pushed to you immediately.

Creating a Webhook

Webhook URLs must be https:// and publicly reachable from the internet — Crowdee rejects URLs that resolve to a private, loopback, or link-local address (for example 127.0.0.1, 10.0.0.0/8, or the common cloud metadata address 169.254.169.254) as a safety measure.

curl -X POST https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks \
  -H "X-API-Key: crw_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/crowdee",
    "description": "Slack notifier",
    "eventTypes": ["job.finished", "job.archived", "answer.accepted", "answer.rejected"]
  }'

The 201 Created response includes the webhook object and its plaintext signing secret:

{
  "id": "whk_a1b2c3d4e5f6g7h8i9j0k1l2",
  "jobId": "job_abc123def456ghi789jkl01",
  "url": "https://example.com/webhooks/crowdee",
  "description": "Slack notifier",
  "eventTypes": ["job.finished", "job.archived", "answer.accepted", "answer.rejected"],
  "status": "active",
  "secret": "whsec_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c"
}

The secret field is returned only in this response (and again if you later rotate it) — Crowdee does not store the plaintext secret and cannot show it to you again. Save it immediately in a secrets manager. If you lose it, rotate the webhook to generate a new one — see Managing Webhooks.

You can also provision one or more webhooks in the same request as job creation, by including a webhooks array on the job creation payload:

{
  "name": "Q3 Image Authenticity Review",
  "title": "...",
  "description": "...",
  "surveyTemplateVersionId": "tmplv_abc123def456ghi789jkl01",
  "webhooks": [
    { "url": "https://example.com/webhooks/crowdee", "eventTypes": ["job.finished", "answer.accepted"] }
  ]
}

The job-creation response then includes a webhooks array, each entry carrying its own one-time secret, in addition to the created job's own fields.

Payload and Delivery

Each delivery is an HTTP POST with a Content-Type: application/json body. The event type and delivery identity travel in headers, not in the body — the body is just the plain payload object for that event:

HeaderDescription
X-Crowdee-EventThe event type, e.g. answer.rejected.
X-Crowdee-Delivery-IdUnique id for this delivery. Stable across automatic retries of the same delivery — a manual redelivery gets a new id. Use it to deduplicate if your endpoint might receive the same delivery more than once.
X-Crowdee-Webhook-IdThe id of the webhook configuration that sent this delivery.
X-Crowdee-SignatureHMAC signature — see Verifying the Signature.

Job lifecycle events (job.created, job.paused, job.resumed, job.archived) share this shape:

{
  "jobId": "job_abc123def456ghi789jkl01",
  "title": "Assess whether this image has been digitally manipulated",
  "status": "archived",
  "category": "survey",
  "projectId": "proj_xyz987stu654rqp321onm09"
}

job.finished, job.no_tasks, and job.expired additionally include the status the job transitioned from:

{
  "jobId": "job_abc123def456ghi789jkl01",
  "title": "...",
  "status": "finished",
  "category": "survey",
  "projectId": "proj_xyz987stu654rqp321onm09",
  "previousStatus": "no_tasks"
}

job.extended additionally includes the extension amount:

{
  "jobId": "job_abc123def456ghi789jkl01",
  "title": "...",
  "status": "assignable",
  "category": "survey",
  "projectId": "proj_xyz987stu654rqp321onm09",
  "additionalRepetitions": 5,
  "newMaxRepetitionsPerVariant": 15
}

answer.received (lightweight):

{
  "answerId": "ans_abc123",
  "jobId": "job_abc123def456ghi789jkl01",
  "taskId": "tsk_xyz987",
  "workerId": "usr_ghi789",
  "guestWorkerId": null,
  "submittedAt": "2025-09-14T10:23:41Z",
  "variantIndex": 7
}

answer.received.full — identical in shape to a row from the JSON answer export, including the survey answers object, worker metadata, and input data snapshot. feedback is always null here, since worker feedback (if any) is only submitted after this point.

answer.accepted / answer.rejected:

{
  "answerId": "ans_abc123",
  "jobId": "job_abc123def456ghi789jkl01",
  "taskId": "tsk_xyz987",
  "workerId": "usr_ghi789",
  "guestWorkerId": null,
  "reviewedById": "usr_reviewer01",
  "rejectionReason": "Response does not address the required criteria.",
  "rejectionCategory": "failed_instructions"
}

(rejectionReason/rejectionCategory are null on answer.accepted; acceptanceNote is included instead when one was provided.)

Verifying the Signature

X-Crowdee-Signature follows the same scheme Stripe uses: t=<unix_timestamp>,v1=<hex_hmac_sha256>. The signature is an HMAC-SHA256, using your webhook's secret as the key, over the string {timestamp}.{raw request body}.

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

function isValidCrowdeeSignature(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => part.split("=")),
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(parts.v1, "hex"),
    Buffer.from(expected, "hex"),
  );
}

Compute the HMAC over the raw, unparsed request body — not a re-serialized version of the parsed JSON, which can differ in whitespace or key order and will not match. Reject any delivery whose signature does not match, and treat t as a hint to reject deliveries older than whatever replay window makes sense for your integration — Crowdee itself does not enforce one.

Retries, Delivery Log, and Redelivery

A delivery that doesn't get a 2xx response is retried automatically with exponential backoff, up to 8 attempts spread over roughly 2 hours — long enough to ride out a brief outage on your end without needing to intervene. Redirects (3xx) are treated as failures rather than followed.

View recent deliveries for a webhook:

curl "https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks/{webhookId}/deliveries?limit=50" \
  -H "X-API-Key: crw_YOUR_API_KEY"
{
  "total": 12,
  "items": [
    {
      "id": "whd_1a2b3c4d5e6f7g8h9i0j1k2l",
      "event": "answer.rejected",
      "status": "succeeded",
      "attempt": 1,
      "responseStatusCode": 200,
      "durationMs": 184,
      "createdAt": "2025-09-14T10:23:42Z",
      "deliveredAt": "2025-09-14T10:23:42Z"
    }
  ]
}

status is one of pending, succeeded, failed (will retry), or exhausted (every attempt failed). Manually retry an exhausted (or otherwise failed) delivery:

curl -X POST https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver \
  -H "X-API-Key: crw_YOUR_API_KEY"

Redelivery creates a new delivery with its own id and its own 8-attempt retry budget — it does not reuse the original delivery's id or attempt count.

Auto-Disable

If a webhook accumulates 10 consecutive fully-exhausted deliveries — every retry of every one of those ten deliveries failed, with no successful delivery in between — Crowdee automatically flips it to status: "disabled" and emails the job's watchers and reviewers. A disabled webhook stops receiving new deliveries until you re-enable it.

Re-enable it with an update request:

curl -X PUT https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks/{webhookId} \
  -H "X-API-Key: crw_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'

Re-enabling resets the consecutive-failure count to zero, so a fresh run of ten is needed before it would auto-disable again.

Before re-enabling, use Send a Test Event to confirm your endpoint is reachable again — otherwise the next batch of real events may simply run the counter back up to ten.

Managing Webhooks

List webhooks for a job:

GET https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks
X-API-Key: crw_YOUR_API_KEY

Update a webhook — change its URL, description, subscribed events, or status:

curl -X PUT https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks/{webhookId} \
  -H "X-API-Key: crw_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "eventTypes": ["job.finished", "job.archived"] }'

Rotate the signing secret — invalidates the old secret immediately and returns a new one (shown once, exactly like at creation):

curl -X POST https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks/{webhookId}/rotate-secret \
  -H "X-API-Key: crw_YOUR_API_KEY"

Send a test event — delivers a synthetic webhook.test payload immediately and synchronously (not retried, and not recorded in the delivery log), so you get an instant pass/fail result while setting up your endpoint:

curl -X POST https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks/{webhookId}/test \
  -H "X-API-Key: crw_YOUR_API_KEY"
{ "ok": true, "statusCode": 200, "errorMessage": null, "durationMs": 142 }

Delete a webhook — stops it from receiving any further deliveries. This cannot be undone; recreate it (and update your receiver's secret) if you need it back:

curl -X DELETE https://api.crowdee.ai/v2/crowd-jobs/{jobId}/webhooks/{webhookId} \
  -H "X-API-Key: crw_YOUR_API_KEY"

Everything above is also available from the platform UI on a job's Webhooks tab — create/edit/delete webhooks, rotate secrets, send test events, and browse or redeliver from the delivery log, without touching the API directly.

How is this guide?

© 2026 Crowdee GmbH. All rights reserved.

On this page