# Guide to Taskrabbit Webhooks: Features and Best Practices

Taskrabbit webhooks tell partners when something happens to a project they booked through Taskrabbit's Partner API: the Tasker finishes the work, the project is cancelled, or it moves to a new start time. If you sell assembly, mounting or delivery services through Taskrabbit, webhooks keep your order status in step without polling the API.

This guide covers Taskrabbit's three webhook events, how deliveries are signed, retries and replay, and the best practices for running a Taskrabbit receiver in production.

## What are Taskrabbit webhooks?

Taskrabbit delivers webhooks through [Svix](/webhooks/platforms/guide-to-svix-webhooks-features-and-best-practices). During partner onboarding you get access to a Svix Partner Dashboard, where you add your endpoint URL, choose the events to subscribe to, view delivery logs and replay events.

Each delivery is a JSON `POST` signed by Svix with a secret unique to your endpoint.

## Taskrabbit webhook features

| Feature | Details |
| --- | --- |
| Availability | Partner API, set up during onboarding |
| Delivery platform | Svix |
| Configuration | Svix Partner Dashboard |
| Event field | Body `event` |
| Events | `project.completed`, `project.canceled`, `project.rescheduled` |
| Signature | Svix scheme, with a secret unique to each endpoint |
| Retries | Exponential backoff on a non-`2xx` response or timeout |
| Manual replay | Svix Partner Dashboard |

## Common events

Every payload has `event`, `project_uuid` and `timestamp`, plus `agreement_external_reference` if you gave the project your own reference when you created it.

| Event | When it fires | Extra fields |
| --- | --- | --- |
| `project.completed` | The Tasker has completed the project's work | None |
| `project.canceled` | The project has been cancelled | `cancellation_reason` |
| `project.rescheduled` | A task in the project has a new start time | `new_start_time`, `previous_start_time` |

`cancellation_reason` is one of `order_canceled` (cancelled by the partner or client), `delivery_delayed`, `changed_mind` (the client changed their mind) or `canceled_by_cs` (cancelled by Taskrabbit Customer Support).

Here is Taskrabbit's `project.canceled` example:

```json
{
  "event": "project.canceled",
  "project_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "cancellation_reason": "order_canceled",
  "timestamp": "2025-01-22T10:00:00Z",
  "agreement_external_reference": "REF-12345"
}

```

Event names use the American spelling `canceled`, so match on that exactly.

## Setting up Taskrabbit webhooks

1. Open the Svix Partner Dashboard you were given during onboarding.
2. Add your HTTPS endpoint and subscribe it to the events you need.
3. Copy the endpoint's signing secret and store it server-side, for example as `TASKRABBIT_WEBHOOK_SECRET`.
4. Check the dashboard's delivery log once events start arriving.

## Securing Taskrabbit webhooks

Taskrabbit says Svix signs every payload and that unverified webhooks should be discarded, and points to Svix's libraries. It doesn't restate the scheme or name the headers. Svix's defaults are `svix-id`, `svix-timestamp` and `svix-signature`, with a `whsec_` secret: the signature is a base64 HMAC-SHA256 over `{id}.{timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`, and sent as `v1,<signature>`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyTaskrabbitSignature(rawBody, headers, secret) {
  const msg_id = headers['svix-id'];
  const msg_timestamp = headers['svix-timestamp'];
  const signature_header = headers['svix-signature'];
  if (!msg_id || !msg_timestamp || !signature_header) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${msg_timestamp}.`, 'utf8'), rawBody]);
  const expected = Buffer.from(
    crypto.createHmac('sha256', key).update(signed_content).digest('base64'),
    'utf8'
  );

  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/taskrabbit', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.TASKRABBIT_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyTaskrabbitSignature(req.body, req.headers, secret)) {
    return res.sendStatus(400); // Taskrabbit: discard unverified webhooks
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the svix-id header, queue the event, then respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_taskrabbit_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    msg_timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and msg_timestamp and signature_header):
        return False
    if not secret or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{msg_timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_taskrabbit_signature(raw_body, request.headers, os.environ["TASKRABBIT_WEBHOOK_SECRET"])

```

The Svix libraries do the same and also check the timestamp.

> **Make Taskrabbit webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Taskrabbit's Svix signature, queues every completion, cancellation and reschedule, and lets you replay any of them after a failure.

Hookdeck's Taskrabbit source type verifies Svix signatures with the secret you enter and accepts either `svix-` or `webhook-` header names. Verification is optional on the source, so leave it off if you didn't set a secret. Hookdeck does not apply a timestamp window.

## Taskrabbit webhook limitations and pain points

### No event ID in the body

**The Problem:** Retries and replays produce duplicate updates, and there's no obvious field to deduplicate on.

**Why It Happens:** The payload has `project_uuid` and `timestamp` but no event ID. A project can be rescheduled several times, so `project_uuid` and `event` together aren't unique either.

**Workarounds:**

* Deduplicate on the Svix message ID header, which identifies the message across retries.
* Make handlers idempotent, as Taskrabbit recommends: setting a status to "completed" twice should have the same result as once.

**How Hookdeck Can Help:** Hookdeck's deduplication can key on a header or on a combination of body fields, so repeats are dropped before they reach your service.

### `timestamp` means different things per event

**The Problem:** Sorting events by `timestamp` puts reschedules in the wrong place.

**Why It Happens:** For `project.completed` and `project.canceled`, `timestamp` is when the event was processed. For `project.rescheduled`, Taskrabbit sets it to `new_start_time`, which can be in the future.

**Workarounds:**

* For reschedules, compare `previous_start_time` with the start time you have stored, and apply the change only if it matches.
* Don't order events by `timestamp` across event types.

**How Hookdeck Can Help:** Hookdeck records when each request arrived, so you have a receive time for every event, including reschedules.

### No published retry schedule

**The Problem:** You can't tell how long Taskrabbit will keep retrying during an outage.

**Why It Happens:** Taskrabbit says Svix retries with exponential backoff on a non-`2xx` response or timeout, but doesn't give the schedule or the timeout.

**Workarounds:**

* Respond quickly and process in the background.
* After an outage, replay failed messages from the Partner Dashboard.

**How Hookdeck Can Help:** Hookdeck accepts deliveries while your service is down and holds them for replay, so recovery doesn't depend on Svix's retry window.

## Best practices

### Verify against the raw body

Use `express.raw()` in Express, `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or `await request.body()` in FastAPI, and parse only after verification.

### Store `agreement_external_reference`

Pass your own order reference when you create a project. It comes back on every event, so you can find the order without a lookup on `project_uuid`.

### Route on `event` and handle cancellation reasons

Switch on `event`, and for cancellations act on `cancellation_reason`: a `delivery_delayed` cancellation may need a different customer message from `changed_mind`.

### Make handlers idempotent

See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Return a `2xx` as soon as the event is stored. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Taskrabbit sends three project events through Svix: completed, cancelled and rescheduled. Verify each delivery with your endpoint's secret and discard anything that fails, deduplicate on the Svix message ID because the body has no event ID, and treat `timestamp` on reschedules as the new start time.

[Hookdeck Event Gateway](https://hookdeck.com) verifies Taskrabbit's signatures and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Taskrabbit webhooks reliably in minutes.