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

# Webhooks

> Let HocDoc call your server when an offer is sent, opened, signed, accepted, withdrawn or expired.

HocDoc can call your server when something happens to an offer. You add an endpoint (an HTTPS address) in the app,
choose the events you want, and HocDoc sends a signed `POST` request with a JSON body for each event.

Webhooks use the same objects as the [API](/api/introduction): the body contains the offer as returned by
`GET /api/v1/offers/{id}` and the timeline entry as returned by `GET /api/v1/offers/{id}/events`. The machine-readable
contract is the `webhooks` section of the OpenAPI description at `https://app.hocdoc.com/api/v1/openapi.json`.

## Set up an endpoint

In the app: **Einstellungen**, then the **Integrationen** tab, card **Webhooks**, button **Endpunkt hinzufügen**. Owners and admins can manage endpoints. A
workspace can have up to 5 endpoints. One account can set up endpoints in up to 20 workspaces.

* Enter the address and choose the events.
* The secret (`whsec_…`) is shown exactly once. Store it safely, you need it to verify signatures. If you lose it,
  generate a new one ("Geheimnis neu erzeugen"). The old secret stops working immediately.
* "Test senden" sends a `ping` event to the endpoint, so you can check the address and your signature check.

### Requirements for the address

* `https` only, on the default port 443.
* A public host name. IP addresses, credentials in the address (`user:password@`) and internal names are rejected.
* The name must resolve to public addresses only.
* Redirects are not followed. A `3xx` response counts as a failure.
* Hosts on `hocdoc.com`, `vercel.app` and `supabase.co` are not accepted. The same goes for a custom domain that a
  workspace uses for its offer pages.

## Events

| Type | Sent when |
| - | - |
| `offer.sent` | An offer is sent, including when it is sent again as a new version |
| `offer.first_viewed` | The public page of a sent version is opened for the first time |
| `offer.signed` | A signer signs an offer with several signers. The last signature is followed by `offer.accepted` |
| `offer.accepted` | The recipient accepts the offer, or the last signer has signed |
| `offer.withdrawn` | The sender withdraws a sent offer |
| `offer.expired` | The deadline of a sent offer has passed |
| `ping` | Someone presses "Test senden" for the endpoint. Cannot be subscribed to |

`offer.expired` is detected every 15 minutes and sent out about two minutes later, so it arrives up to about 20
minutes after `expires_at`.

There is no `offer.declined` event: recipients cannot decline an offer in the product today.

New event types may be added. Ignore types you do not know, and answer them with `2xx`.

## Request

```http theme={null}
POST /your/path HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: HocDoc-Webhooks/1.0
webhook-id: 6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f
webhook-timestamp: 1791533730
webhook-signature: v1,fvvg2fi8IO5CucqsptXcBbC5Kg+ia9x/PzREjEcYENI=
```

| Header | Meaning |
| - | - |
| `webhook-id` | ID of the event. The same as `id` in the body. Identical for every retry and every resend of the same event |
| `webhook-timestamp` | Unix time in seconds of this delivery attempt. Changes with every attempt |
| `webhook-signature` | One or more signatures, separated by spaces, each as `v1,<base64>` |

These three headers, `Content-Type` and `User-Agent` are the contract. HocDoc adds no tracing or other headers of its
own.

### Body

```json theme={null}
{
  "id": "e16a4b7c-3d82-4c95-a7f0-5b6c7d8e9f44",
  "type": "offer.accepted",
  "created_at": "2026-10-02T14:07:45.912Z",
  "api_version": "v1",
  "data": {
    "offer": {
      "id": "5d2c8a40-7b19-4e6f-a3d8-1c9e0f7b2a64",
      "title": "Brand identity package",
      "status": "accepted",
      "created_at": "2026-09-28T09:00:12.481Z",
      "updated_at": "2026-10-02T14:07:46.001Z",
      "sent_at": "2026-09-29T10:15:00.642Z",
      "expires_at": "2026-10-13T21:59:59.999Z",
      "accepted_at": "2026-10-02T14:07:45.912Z",
      "expired_at": null,
      "version": 1,
      "recipient": { "name": "John Smith", "company": "Sample AG", "email": "john@example.com" },
      "accepted_by": { "name": "John Smith", "email": "john@example.com" },
      "value": { "amount": 1250000, "currency": "EUR" }
    },
    "event": {
      "sequence": 7,
      "type": "accepted",
      "occurred_at": "2026-10-02T14:07:45.912Z",
      "version": 1,
      "data": { "signer_email": "john@example.com" }
    }
  }
}
```

| Field | Meaning |
| - | - |
| `id` | ID of the event (UUID). Use it to ignore duplicates |
| `type` | Event type, see the table above |
| `created_at` | When the event happened. ISO 8601 in UTC with milliseconds |
| `api_version` | Version of the objects in `data`. Always `v1`, matching the API path `/api/v1` |
| `data.offer` | The offer, with the same fields as `GET /api/v1/offers/{id}` |
| `data.event` | The timeline entry that caused the webhook, with the same fields as `GET /api/v1/offers/{id}/events` |

Things to know:

* `data.offer` is read shortly after the event, when the webhook is prepared. It can already reflect a later change.
  For `offer.sent` followed quickly by `offer.accepted`, both bodies may show `status: accepted`. Rely on `type` and
  `data.event` for what happened.

* `data.event.type` is the type of the timeline entry. For `offer.first_viewed` it is `viewed`.

* `data.event.data` depends on the type:

  | Webhook | `data.event.data` |
  | - | - |
  | `offer.sent` | `recipient_email` |
  | `offer.first_viewed` | empty |
  | `offer.signed` | `signer_email`, `signer_role`, `signing_order`, `signer_total` |
  | `offer.accepted` | `signer_email` |
  | `offer.withdrawn` | empty |
  | `offer.expired` | `expires_at`, truncated to whole seconds |

* For `ping`, `data` is an empty object.

* New fields may be added at any time. Ignore fields you do not know.

* The body is at most 64 KiB.

### What is never sent

The token of the public link, acceptance codes, IP addresses and browser identifiers of recipients, the content of an
offer, the message to the recipient, signatures, internal IDs, and anything about other workspaces.

## Verify the signature

Always verify the signature before you trust a request. HocDoc signs following the
[Standard Webhooks](https://www.standardwebhooks.com/) specification, so you can use one of its libraries or the few
lines below.

1. Take the secret of the endpoint, remove the prefix `whsec_`, and base64-decode the rest. These bytes are the key.
2. Build the signed content: `<webhook-id>.<webhook-timestamp>.<raw body>`. Use the raw bytes of the request body,
   exactly as received. Do not parse and re-serialize the JSON first.
3. Compute the HMAC-SHA256 of the signed content with the key and base64-encode it.
4. `webhook-signature` contains one or more entries separated by spaces. Accept the request if any entry with the
   version `v1` equals your result. Compare in constant time.
5. Reject requests whose `webhook-timestamp` is more than 5 minutes away from your clock. This limits replays.

The header is a list so that a secret can be replaced later without breaking receivers. Today it has one entry.

### Node.js

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

/**
 * @param {string} secret   The endpoint secret, "whsec_..."
 * @param {Record<string, string>} headers  Request headers, lower-cased names
 * @param {string | Buffer} rawBody  The request body exactly as received
 */
export function verifyHocDocWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

With Express, read the raw body for this route (`express.raw({ type: "application/json" })`) and parse the JSON only
after the check.

### Python

```python theme={null}
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_hocdoc_webhook(secret: str, headers: dict[str, str], raw_body: bytes) -> bool:
    """secret: the endpoint secret, "whsec_...". headers: lower-cased names. raw_body: bytes as received."""
    webhook_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not webhook_id or not timestamp or not signatures:
        return False

    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1" or not value:
            continue
        try:
            given = base64.b64decode(value)
        except ValueError:
            continue
        if hmac.compare_digest(given, expected):
            return True
    return False
```

### Test vectors

Use these to check your implementation. The timestamps are in the past, so skip the tolerance check for them.

A `ping` as HocDoc sends it:

| Input | Value |
| - | - |
| Secret | `whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=` |
| `webhook-id` | `6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f` |
| `webhook-timestamp` | `1791533730` |
| Body | `{"id":"6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f","type":"ping","created_at":"2026-10-09T08:15:30.123Z","api_version":"v1","data":{}}` |
| `webhook-signature` | `v1,fvvg2fi8IO5CucqsptXcBbC5Kg+ia9x/PzREjEcYENI=` |

The reference vector of the Standard Webhooks specification:

| Input | Value |
| - | - |
| Secret | `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` |
| `webhook-id` | `msg_p5jXN8AQM9LWM0D4loKWxJek` |
| `webhook-timestamp` | `1614265330` |
| Body | `{"test": 2432232314}` |
| `webhook-signature` | `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` |

## Respond

Answer with any `2xx` status within 10 seconds. The response body is ignored. Do slow work after you have answered.

Everything else counts as a failed attempt: any other status (including `3xx`), no response within 10 seconds, a
connection or TLS error.

## Retries

A failed delivery is retried with growing pauses, 8 attempts in total over about a day:

| Attempt | Pause after the previous attempt | Time since the first attempt |
| - | - | - |
| 1 | | 0 |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 | 2 hours | 2 hours 36 minutes |
| 6 | 5 hours | 7 hours 36 minutes |
| 7 | 8 hours | 15 hours 36 minutes |
| 8 | 8 hours | 23 hours 36 minutes |

After the 8th failed attempt the delivery counts as failed. You can send it again from the delivery log in the app
("Erneut senden"), up to 5 times. A resend carries the same `webhook-id` and the same body.

### Automatic pause

After 5 deliveries in a row that failed for good, the endpoint is paused automatically and its open deliveries are
cancelled. The app shows "Automatisch pausiert". Fix the receiver, then resume the endpoint in the app. Events that
happen while an endpoint is paused are not delivered later. Use the API to catch up.

## Order and duplicates

* **Order is not guaranteed.** Events can arrive out of order, for example when one of them is retried. Use
  `data.event.sequence` (the position in the timeline of the offer) or `created_at` if you need the order.
* **Events can arrive more than once.** Store the `webhook-id` values you have processed and ignore repeats.
* Each endpoint receives each event independently. One failing endpoint does not hold back the others.

## Limits

| Limit | Value |
| - | - |
| Endpoints per workspace | 5 |
| Time to respond | 10 seconds |
| Attempts per delivery | 8 |
| Deliveries started per minute | 60 per workspace |
| Open deliveries per endpoint | 1,000 |
| Events waiting to be sent | 1,000 per workspace |
| Workspaces with endpoints | 20 per account |
| Test events | 10 per 10 minutes |
| Delivery log | kept for 30 days |

Deliveries above 60 per minute are not dropped, they wait. Each workspace has its own share: the volume of another
workspace does not delay your deliveries. Workspaces take turns, and the one that has waited longest goes first. A
workspace that sets up its first endpoint joins at the back of the line, never ahead of workspaces that are already
waiting.

This is not a hard guarantee on delivery time. Sending goes round-robin per workspace, so the time your workspace
waits grows at most with the number of workspaces that are waiting at the same moment. No workspace is skipped, but
when very many workspaces have events waiting at once, all of them wait longer.

The limit of 20 workspaces counts the workspaces in which your account created endpoints, paused ones included. If
you need webhooks in more workspaces, another owner or admin of that workspace can set them up. When you delete the
last endpoint of a workspace, events of that workspace that were still waiting to be sent out are dropped.

An endpoint that already has 1,000 open deliveries (waiting or being retried) does not get more queued. Further
deliveries to it are recorded as failed in the delivery log without an attempt ("Nicht eingereiht") and can be sent
again from there. If 1,000 events of a workspace are waiting to be sent out at the same moment, further events are not
turned into webhooks. Neither limit is reached in normal use. Use the API to catch up.


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