> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tolk.every-day.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving webhooks

> Payloads, headers and verifying the HMAC signature, with examples in PHP and Node.

For every event a webhook is subscribed to, Tolk sends a `POST` with a JSON body to the URL of the webhook. How to create a webhook is described in the [user guide](/guide/webhooks).

## The body

Every delivery has the same envelope:

```json theme={null}
{
  "id": "0f9c2b5e-…",
  "event": "keys.synced",
  "createdAt": "2026-10-07T12:00:00Z",
  "project": { "slug": "webshop", "name": "Webshop" },
  "data": { "created": 12, "updated": 3, "restored": 0, "archived": 1 }
}
```

The payloads contain counters and language codes, never texts or keys. Fetch the content through the API.

## Headers

| Header | Content |
| - | - |
| `X-Tolk-Event` | The event, for example `language.completed` |
| `X-Tolk-Delivery` | The id of the delivery, equal to `id` in the body. A retry keeps the same id: use it to recognize duplicate deliveries |
| `X-Tolk-Timestamp` | The time of this attempt, in Unix seconds |
| `X-Tolk-Signature` | `sha256=` followed by the HMAC-SHA256 in lowercase hexadecimal, with the secret of the webhook, over `<timestamp>.<body>` |

## Verifying the signature

1. Reject a timestamp that differs more than 300 seconds from your own clock.
2. Compute the HMAC-SHA256 with the secret over the timestamp, a dot and the **raw** body. Do not parse and reformat the JSON first.
3. Compare the result with `X-Tolk-Signature` in constant time.

<CodeGroup>
  ```php PHP (Laravel) theme={null}
  use Illuminate\Http\Request;

  Route::post('/tolk/webhook', function (Request $request) {
      $secret = config('services.tolk.webhook_secret');
      $timestamp = (int) $request->header('X-Tolk-Timestamp');
      $signature = (string) $request->header('X-Tolk-Signature');
      $body = $request->getContent(); // the raw body

      abort_if(abs(time() - $timestamp) > 300, 401);

      $expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
      abort_unless(hash_equals($expected, $signature), 401);

      $event = $request->json('event');
      $deliveryId = $request->header('X-Tolk-Delivery');
      // Handle the event in a job, and store $deliveryId to skip duplicate deliveries.

      return response()->noContent();
  });
  ```

  ```javascript Node (Express) theme={null}
  import express from 'express';
  import { createHmac, timingSafeEqual } from 'node:crypto';

  const app = express();

  app.post('/tolk/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const secret = process.env.TOLK_WEBHOOK_SECRET;
    const timestamp = Number(req.get('X-Tolk-Timestamp'));
    const signature = req.get('X-Tolk-Signature') ?? '';
    const body = req.body.toString('utf8'); // the raw body

    if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
      return res.sendStatus(401);
    }

    const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
    const a = Buffer.from(expected);
    const b = Buffer.from(signature);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(body);
    // Handle payload.event asynchronously; use req.get('X-Tolk-Delivery') to skip duplicate deliveries.
    res.sendStatus(204);
  });

  app.listen(3000);
  ```
</CodeGroup>

<Note>
  In Laravel, a route in `routes/web.php` is behind CSRF protection. Put the webhook route in `routes/api.php`, or exclude it from CSRF.
</Note>

## Responses and retries

* Respond with a `2xx` within 5 seconds. Do the real work afterwards, for example in a queue.
* Any other response, or no response, gets up to 5 retries with an increasing delay (10 s, 30 s, 90 s, 270 s and 810 s).
* A delivery can arrive more than once. Use `X-Tolk-Delivery` to recognize duplicate deliveries.
* Tolk does not follow redirects. Every attempt has its own timestamp and therefore its own signature.

## Events and payloads

### `keys.synced`

After a sync that created, updated, restored or archived keys.

```json theme={null}
{ "created": 12, "updated": 3, "restored": 0, "archived": 1 }
```

### `language.completed`

A target language is 100% translated. Bundled: at most one message per 5 minutes per project.

```json theme={null}
{ "language": { "locale": "de", "name": "Deutsch", "translated": 2412, "total": 2412 } }
```

### `translations.updated`

Translations have changed. Bundled: at most one message per 5 minutes per project. `since` is the start of the bundle.

```json theme={null}
{
  "contentVersion": 1234,
  "since": "2026-10-07T12:00:00Z",
  "languages": [{ "locale": "de", "translated": 1544, "total": 2412, "percent": 64 }]
}
```

### `import.finished`

An import succeeded or failed. `job` is the job of `GET /jobs/{jobId}`. `result` is `null` if the import failed; `error.code` is then the code of the job.

```json theme={null}
{
  "job": {
    "id": 42,
    "type": "import",
    "status": "succeeded",
    "error": null,
    "finishedAt": "2026-10-07T12:00:00Z"
  },
  "import": {
    "id": 7,
    "locales": ["de"],
    "result": {
      "newKeys": 3,
      "newTranslations": 412,
      "changedTranslations": 0,
      "unchanged": 1544,
      "skipped": 2,
      "placeholderErrors": 1
    }
  }
}
```

### `ai_job.finished`

An AI translation has finished, without costs. `status` is `completed`, `failed`, `cancelled` or `budget_exceeded`.

```json theme={null}
{
  "job": {
    "id": 17,
    "locale": "de",
    "status": "completed",
    "mode": "direct",
    "scope": "untranslated",
    "error": null,
    "totalItems": 960,
    "translatedItems": 950,
    "flaggedItems": 3,
    "failedItems": 0,
    "skippedItems": 7,
    "startedAt": "2026-10-07T11:58:00Z",
    "finishedAt": "2026-10-07T12:00:00Z"
  }
}
```

### `ping`

The test message of **Send test** (*Test versturen*). You cannot subscribe to it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.