# Guide to TutorCruncher Webhooks: Features and Best Practices

TutorCruncher is business management software for tutoring agencies, covering tutors, clients, lessons, billing and payroll. Its webhooks push every action on the account to another system, which agencies use with Zapier or their own apps to sync a CRM, alert staff to new enquiries or start payroll when lessons are completed.

This guide covers how TutorCruncher integrations send webhooks, the action list and payload, verification with the private API key, and the retry and auto-disable rules that should shape your responses.

## What are TutorCruncher webhooks?

A TutorCruncher webhook belongs to an **integration** on your account. You add the integration's URL, and from then on TutorCruncher posts a webhook every time an action involves a user or a Job: a tutor is created, a client submits an enquiry, a lesson is marked complete, an invoice is raised. There's no per-action subscription, so your handler receives everything and decides what to ignore.

Each request carries an `events` array, and each element describes one action.

## TutorCruncher webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `webhook-signature` |
| Body | JSON |
| Event routing | Body field `events[].action` |

From TutorCruncher's [help article](https://help.tutorcruncher.com/en/articles/14182937-webhooks) and the webhooks section of its [API documentation](https://app.tutorcruncher.com/api/docs/#description/webhooks):

* Each integration has webhook logs showing when each webhook fired, whether it succeeded, and the request and response data.
* Timeouts, no response and `5xx` responses are retried up to 4 times, after 1, 10, 100 and 1,000 seconds.
* Redirects and `4xx` responses other than `429` aren't retried.
* An endpoint whose failure rate stays above 20% over a sustained period is disabled automatically.

## Events and payloads

Route on `action` in each element of `events`. TutorCruncher lists over 200 action types. Some common ones:

| Event routing | Body field `events[].action` |

### Event Payload Structure

* `action`: the upper-snake event type. `verb`: a readable description.
* `actor`: who performed the action (`url` is an empty string for administrators).
* `subject`: the item the action was performed on. Its fields depend on `subject.model`.
* `extra_msg`: how the action happened, e.g. "Created via the API." (generally empty for UI actions).
* Always loop over **every** element of `events`.

Actions TutorCruncher triggers by itself have `extra_msg` set to "Processed automatically via TutorCruncher". Each event also has a `timestamp` and a `branch` ID. The help article's example additionally shows a top-level `_request_time` in Unix seconds and a much fuller `subject` for a tutor, including `user`, `skills` and `extra_attrs`.

The `CREATED_A_CONTRACTOR` example from the API documentation:

```json
{
  "events": [
    {
      "action": "CREATED_A_CONTRACTOR",
      "verb": "Created a Tutor",
      "timestamp": "2025-03-05T15:26:20.558919Z",
      "branch": 590,
      "actor": {
        "name": "Jane Smith",
        "id": 1621,
        "user_id": 1611,
        "url": ""
      },
      "extra_msg": "Created via the API.",
      "subject": {
        "model": "Tutor",
        "url": "https://app.tutorcruncher.com/api/contractors/1623/",
        "id": 1623,
        "status": "approved",
        "last_name": "Gallagher",
        "email": "liam@example.com"
      }
    }
  ]
}

```

"Contractor" is TutorCruncher's API name for a tutor, and "service" and "appointment" are its names for a job and a lesson.

## Setting up TutorCruncher webhooks

1. In TutorCruncher, open **System > Settings > Integrations**.
2. Click **Add Integration**.
3. Enter your webhook URL, for example `https://example.com/webhooks/tutorcruncher`, and save.
4. Copy the integration's private API key and store it as `TUTORCRUNCHER_PRIVATE_KEY`. It's the signing key, as there's no separate webhook secret.

Paste your TutorCruncher private API key. TutorCruncher signs webhooks with it.

To route through Hookdeck, create a TutorCruncher source with the private API key and use its URL as the integration's webhook URL. For local development:

```bash
npx hookdeck-cli listen 3000 tutorcruncher --path /webhooks/tutorcruncher

```

### HTTP method

Neither TutorCruncher page names the HTTP method or Content-Type. Hookdeck's TutorCruncher source pre-selects `POST` without managing the method, so allow other methods on the source if you see them. The receiver below uses `app.all`, verifies whatever method arrives, and acknowledges a verified empty body with `200`.

## Securing TutorCruncher webhooks

`Webhook-Signature` is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the integration's private API key. TutorCruncher's API docs show a Python check with `hmac.compare_digest`. The help article's sample uses `assert` with `==`, which isn't constant-time and raises an error instead of returning `401`.

Despite the header's name, this isn't the Standard Webhooks scheme: there are no `webhook-id` or `webhook-timestamp` headers, and nothing time-based is signed.

### Node.js verification

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['webhook-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler loops over every event, builds a key from the action, time and subject, and queues the ones it handles. Unknown actions still get a `200`.

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

const app = express();
const HANDLED = new Set(['CLIENT_ENQUIRY', 'MARKED_AN_APPOINTMENT_AS_COMPLETE', 'RAISED_INVOICE']);

app.all('/webhooks/tutorcruncher', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.TUTORCRUNCHER_PRIVATE_KEY;
  if (!secret) return res.sendStatus(500);
  const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
  if (!verifySignature(body, req.headers, secret)) return res.sendStatus(401);
  if (!body.length) return res.sendStatus(200);

  let payload;
  try {
    payload = JSON.parse(body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!payload || typeof payload !== 'object' || !Array.isArray(payload.events)) {
    return res.sendStatus(400);
  }

  const jobs = payload.events
    .filter((e) => e && typeof e === 'object' && HANDLED.has(e.action))
    .map((e) => {
      const subject = e.subject && typeof e.subject === 'object' ? e.subject : {};
      return {
        key: [e.action, e.timestamp, subject.model, subject.id].map(String).join('|'),
        action: e.action,
        model: subject.model,
        subjectId: subject.id,
        branch: e.branch,
      };
    });

  try {
    for (const job of jobs) {
      if (await alreadyHandled(job.key)) continue;
      await queue.add('tutorcruncher-action', job);
      await markHandled(job.key);
    }
  } catch {
    return res.sendStatus(503);
  }
  res.sendStatus(200);
});

```

`alreadyHandled` and `markHandled` stand in for whatever dedupe store you use, and `queue` for your job queue. Fetch full records through the TutorCruncher API from `subject.url` in the worker when you need more than the webhook carries.

The TutorCruncher source type in Hookdeck applies this same verification. Hookdeck answers TutorCruncher with `200` and marks a mismatched request as failed verification. No timestamp is signed, and Hookdeck imposes no time window of its own.

> **Make TutorCruncher webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers TutorCruncher straight away, so your endpoint's failures never count towards the 20% rate that gets an integration disabled.

## TutorCruncher webhook limitations and pain points

### A high failure rate disables the endpoint

**The Problem:** An outage, or a handler that returns errors for some actions, can get the integration's endpoint disabled.

**Why It Happens:** TutorCruncher disables an endpoint whose failure rate stays above 20% over a sustained period.

**Workarounds:**

* Return `200` for actions you ignore, never a `4xx`.
* Watch the integration's webhook logs for rising failures.

**How Hookdeck Can Help:** Hookdeck accepts every request, so TutorCruncher sees successes while Hookdeck retries your service.

### 4xx responses aren't retried

**The Problem:** A validation bug that returns `400` or `404` loses the event for good.

**Why It Happens:** TutorCruncher doesn't retry redirects or `4xx` responses other than `429`.

**Workarounds:**

* Return `503` for temporary problems on your side, so TutorCruncher retries.
* Avoid redirects on the webhook URL, including HTTP to HTTPS.

**How Hookdeck Can Help:** Hookdeck stores each request even when your service rejects it, so you can fix the bug and replay.

### Every action, no filter

**The Problem:** Your endpoint receives all of the account's activity, most of which you may not need.

**Why It Happens:** Integrations have no per-action subscription.

**Workarounds:**

* Keep a set of handled actions and acknowledge the rest.
* Respond quickly, since volume tracks everything happening on the account.

**How Hookdeck Can Help:** Hookdeck filters can pass only the actions each service needs, such as `RAISED_INVOICE` to accounting.

### One example for hundreds of actions

**The Problem:** You can't tell from the docs what `subject` holds for most actions.

**Why It Happens:** TutorCruncher publishes a single API example and one fuller example in the help article, and `subject` changes shape with `subject.model`.

**Workarounds:**

* Use the webhook logs to see real request bodies for the actions you handle.
* Read `subject.url` and fetch the full object through the API.

**How Hookdeck Can Help:** Hookdeck keeps every request body for you to inspect, search and replay.

## Best practices

### Loop over events

A request can hold more than one event. Process each element and don't assume `events[0]` is the only one.

### Build a key per event

There's no event ID. Combine `action`, `timestamp`, `subject.model` and `subject.id`, and store it before acting. The [idempotency guide](/webhooks/guides/implement-webhook-idempotency) has more on this.

### Respond fast

The timeout isn't documented, so queue first and do the work afterwards. The [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks) walks through queue design.

### Protect the private API key

The key that signs webhooks also authenticates API calls, so keep it out of logs and client-side code.

## Conclusion

TutorCruncher sends every action on an account to an integration's URL, as an `events` array signed with a hex HMAC-SHA256 keyed by the private API key. Loop over the events, answer `200` for actions you ignore, and keep your failure rate low, because errors on your side can get the endpoint disabled.

[Hookdeck Event Gateway](https://hookdeck.com) verifies TutorCruncher webhooks, filters actions and retries delivery to your services.

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