# n8n recipes

> Use TimeTracking with n8n today: Webhook node with raw body, Crypto node HMAC verification, and HTTP Request nodes with header auth.

URL: https://timetracking.tech/developers/integrations/ipaas/n8n
Last updated: 2026-10-24

## 1. Summary

**Status:** Recipe. A community/native TimeTracking node is **Planned**.

**Direction:** Both.

n8n is a good fit for self-hosted agencies: the workflow can run in your own infrastructure next to your ATS or payroll export folder.

## 2. Mechanism

- **Trigger:** *Webhook* node (POST), option *Raw Body* on, so the exact bytes are available as binary/`rawBody` for verification.
- **Verify:** *Crypto* node, action *Hmac*, type *SHA256*, encoding *hex*, secret = webhook secret, value = `{{$json.headers["tt-signature"].split(",")[0].slice(2)}}.{{raw body}}`; then an *IF* node comparing with `v1`. A *Code* node is simpler (example below).
- **Action:** *HTTP Request* node with *Header Auth* credential (`Authorization` = `Bearer tt_live_…`).

## 3. Prerequisites

- n8n 1.x (cloud or self-hosted) reachable over public HTTPS for webhooks.
- An admin user (dashboard *Regels & koppelingen*) or an API key with scope `admin` to create webhooks; a `write` key for the workflow.

## 4. Entity mapping

| Workflow | Source | TimeTracking call |
|---|---|---|
| ATS → placements | ATS node / HTTP | `PUT /{clients,people,placements}/by-external/{source}/{ref}` or `POST /…/batch` |
| Client spreadsheet in a folder → claims | Spreadsheet File node | `POST /claims` `{source:"import", sourceRef, id}` |
| `timesheet.approved` → billing | `data.dayIds` | `GET /days?from&to&status=approved&updated_since=…` |
| `export.completed` → SFTP to payroll | `data.runId` | `GET /exports/runs/{runId}/file` (exact committed CSV), then the SFTP node |

Upserts by external ID are full replaces. Add header `Idempotency-Key` on writes so *Retry On Fail* is safe.

## 5. Step-by-step setup

1. Create credential *Header Auth*: name `Authorization`, value `Bearer tt_live_…`.
2. Webhook node: method POST, path `tt-events`, *Respond*: immediately, options *Raw Body* = on.
3. Code node (JavaScript) right after it:

```js
const crypto = require("crypto"); // self-hosted: set NODE_FUNCTION_ALLOW_BUILTIN=crypto
const item = $input.first();
const raw = Buffer.from(item.binary.data.data, "base64").toString("utf8"); // raw body
const parts = Object.fromEntries(String(item.json.headers["tt-signature"] || "").split(",").map((p) => p.trim().split("=", 2)));
const expected = crypto.createHmac("sha256", $env.TT_WEBHOOK_SECRET).update(`${parts.t}.${raw}`).digest("hex");
if (!parts.v1 || expected !== parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
  throw new Error("Invalid TimeTracking signature");
}
return [{ json: JSON.parse(raw) }];
```

4. *Switch* node on `{{$json.type}}`.
5. Example branch `timesheet.approved`: HTTP Request GET `https://app.timetracking.tech/api/v1/days?from={{$now.minus({days:35}).toFormat('yyyy-MM-dd')}}&to={{$now.toFormat('yyyy-MM-dd')}}&status=approved&limit=500` with the Header Auth credential (enable *Pagination*: next URL adds `cursor={{$response.body.data.next_cursor}}`, stop when it is empty), then *Split Out* on `data.items`.

Where the raw body lives (`binary.data` vs `json.body`) depends on the n8n version; inspect one execution and adjust.

Example action branch, record hours:

```json
{
  "method": "POST",
  "url": "https://app.timetracking.tech/api/v1/claims",
  "authentication": "genericCredentialType",
  "genericAuthType": "httpHeaderAuth",
  "sendBody": true,
  "contentType": "json",
  "jsonBody": "={{ JSON.stringify({ id: $json.claimId, placementId: $json.placementId, workDate: $json.date, minutes: Math.round($json.hours * 60), source: 'import', sourceRef: 'n8n-' + $json.row }) }}"
}
```

Generate `claimId` once per source row (for example a UUIDv5 of the row key) so re-runs are idempotent.

## 6. Sync schedule and triggering events

Webhook-driven (5-minute delivery cycle) plus *Schedule Trigger* polls with `updated_since` for reconciliation.

## 7. Corrections, conflicts and locked periods

Treat `409` and `423` as business errors (route to an *Error Trigger* workflow or a notification). Conflicting sources produce `conflict.created`; resolve in TimeTracking.

## 8. Testing checklist

- [ ] Wrong secret throws in the Code node.
- [ ] Re-running an execution creates no duplicate claim.
- [ ] Error workflow fires on `423`.

## 9. Troubleshooting

| Problem | Fix |
|---|---|
| `Cannot find module 'crypto'` | Self-hosted: `NODE_FUNCTION_ALLOW_BUILTIN=crypto`, or use the Crypto node. |
| Signature mismatch | Raw Body option off, or body re-serialised. |
| `403` | Read-only key on a write endpoint, or webhook management with a non-admin key. |
| `429` | Add *Wait* 60 s; enable *Retry On Fail* with 60 s interval. |

## 10. Limits and known gaps

No native node (Planned); ID-only payloads; webhook management needs an admin user or `admin`-scope key.

## 11. Build it with Claude

```text
Generate an importable n8n workflow JSON: Webhook (POST, raw body) → Code node verifying TimeTracking
tt-signature (v1 = hex HMAC-SHA256(secret, `${t}.${raw}`), 300 s tolerance) → Switch on type →
export.completed: email the accountant with startsOn/endsOn/rows; conflict.created: GET
https://app.timetracking.tech/api/v1/placements/{placementId} (Header Auth "Authorization: Bearer …"), post to Slack;
export.completed: GET /exports/runs/{runId}/file and upload via SFTP. Second workflow: Schedule → read new rows from Google Sheets (placement_id, date, hours) → POST /claims
with source "import", sourceRef "sheet-<row>", id = UUIDv5(row key). Explain where to put credentials.
```
