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

# Webhooks

> Register an endpoint, verify every delivery, and deduplicate on the event id.

andteam POSTs an [employee event](https://docs.andteam.com/concepts/employee-events.md) to your endpoint when a person joins,
changes or leaves. An administrator adds an endpoint under **Settings**, **Integrations**,
**Webhooks**, chooses its events and companies, and copies its signing secret. A [sandbox
tenant](https://docs.andteam.com/concepts/sandbox.md) cannot add one.

A receiver does three things, in this order: **verify the signature** against the raw body, **store the
event and answer 2xx**, then **do the work**.

## Events

| Event | Sent when |
| - | - |
| `employee.hired` | The hire is final: the offer is signed, or a sync or import brought in a person with a start date. |
| `employee.started` | 00:00 on the start date, company time. |
| `employee.changed` | A change takes effect. `data.changedFields` names the fields. |
| `employee.leaving` | A termination is recorded. `data.lastDay` is the last day of work. |
| `employee.left` | Access has ended. |
| `employee.hire_withdrawn` | A hire was withdrawn before the start date. |
| `employee.termination_cancelled` | A recorded termination was cancelled. |

A dated change is sent when it takes effect, not when it was entered. `GET /v1/events` returns the same
events for a consumer that cannot receive them.

## The payload

A delivery is **thin by default**: ids, dates and the names of changed fields, never values. Read the
record from the API when you need it.

```json theme={null}
{
  "id": "evt_2Nq8sXc41",
  "type": "employee.changed",
  "occurredAt": "2027-03-18T09:16:02+02:00",
  "sequence": 14,
  "data": {
    "employeeId": "emp_3kTq…",
    "employeeNumber": "EX10518",
    "companyId": "co_exampleco_sa",
    "effectiveDate": "2027-03-18",
    "changedFields": ["managerId"]
  },
  "links": { "employee": "https://api.v3.andteam.com/v1/employees/emp_3kTq…" }
}
```

`data.startDate` and `data.lastDay` are added to the events they belong to.

**Include details.** An endpoint can switch on Include details. The payload then carries `details`: the
employee resource, limited to the endpoint's [field groups](https://docs.andteam.com/concepts/field-groups.md), exactly as
`GET /v1/employees/{id}` would return it for a key with those groups. Fields outside the groups are
left out, not sent as null, and `changedFields` names only fields the groups open. Special categories
are delivered only after the Information Officer approves them for the endpoint.

## Signing

Every request carries three headers, as in the [Standard Webhooks](https://www.standardwebhooks.com)
specification:

```text theme={null}
webhook-id: evt_2Nq8sXc41
webhook-timestamp: 1773825362
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

* `webhook-id` is the event id. It is the same on every attempt and on a resend.
* `webhook-timestamp` is Unix time in seconds, of this attempt.
* `webhook-signature` is `v1,` followed by the base64 of an HMAC-SHA256.

The signed content is the three values joined by dots: `id.timestamp.body`, where `body` is the **raw
request body, byte for byte**. Parse the JSON only after it verifies; a body re-serialised from parsed
JSON will not match.

The signing secret looks like `whsec_` followed by base64. **The HMAC key is the base64-decoded part
after `whsec_`**, not the string itself.

### Rolling a secret

When an administrator rolls an endpoint's secret, **the old one keeps signing for 24 hours**. During that
time the header carries both signatures, separated by a space: `v1,<new> v1,<old>`. Accept the request if
any one matches, so you can swap the secret on your side at any moment in the 24 hours.

### Reject old requests

Refuse a request whose `webhook-timestamp` is **more than 5 minutes from your clock, either way**. This
stops a captured request from being replayed.

## Verifying a delivery

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

const TOLERANCE_SECONDS = 5 * 60;

/**
 * Verify one andteam webhook delivery.
 *
 * secret   the endpoint's signing secret, "whsec_" followed by base64
 * headers  the request headers, names in lower case
 * body     the RAW request body, exactly as received (do not re-serialise it)
 *
 * Returns the parsed event, or throws.
 */
export function verifyAndteamWebhook(secret, headers, body, nowMs = Date.now()) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) throw new Error("Missing webhook headers");

  // Reject anything more than 5 minutes from now, either way.
  if (!/^\d+$/.test(timestamp) || Math.abs(nowMs / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    throw new Error("Timestamp out of range");
  }

  // The HMAC key is the base64-DECODED part after "whsec_", not the string itself.
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();

  // While a secret is being rolled the header holds two signatures, "v1,<a> v1,<b>": any one may match.
  for (const part of signatures.split(" ")) {
    const [version, value] = part.split(",");
    if (version !== "v1" || !value) continue;
    const given = Buffer.from(value, "base64");
    if (given.length === expected.length && timingSafeEqual(given, expected)) return JSON.parse(body);
  }
  throw new Error("No matching signature");
}
```

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

TOLERANCE_SECONDS = 5 * 60


def verify_andteam_webhook(secret: str, headers: dict, body: bytes, now: float | None = None):
    """Return the parsed event, or raise ValueError. `body` is the raw request body."""
    id_ = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not id_ or not timestamp or not signatures:
        raise ValueError("Missing webhook headers")
    if not timestamp.isdigit() or abs((now or time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("Timestamp out of range")

    key = base64.b64decode(secret[len("whsec_"):])
    signed = f"{id_}.{timestamp}.".encode() + body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    for part in signatures.split(" "):
        version, _, value = part.partition(",")
        if version != "v1" or not value:
            continue
        if hmac.compare_digest(base64.b64decode(value), expected):
            return json.loads(body)
    raise ValueError("No matching signature")
```

Libraries for most languages do the same: see
[standardwebhooks](https://github.com/standard-webhooks/standard-webhooks) (npm `standardwebhooks`, and
Python, Go, Ruby, Java, Rust, PHP and C# packages). Give them the whole `whsec_…` secret.

## Delivery

* **Answer with any 2xx within 15 seconds.** A redirect is not followed and counts as a failure.
* **Retries.** A failed delivery is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5
  hours, 10 hours and 10 hours: eight attempts over about 27 and a half hours. After the eighth it is
  marked failed and kept in the delivery log, where an administrator can resend it.
* **410 Gone** disables the endpoint at once. Use it to retire an endpoint.
* **Five days of nothing but failures** disables the endpoint too.
* **At least once.** The same event can arrive more than once: ignore an `id` you have already handled.
* **Not ordered.** Retries mean events can arrive out of order. Order by `occurredAt`, and for one person
  by `sequence`: drop an event older than the last `sequence` you saw for that person.
* The delivery log keeps 30 days.

```javascript theme={null}
// A receiver, in order: verify, store, answer, then work.
app.post("/andteam", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verifyAndteamWebhook(process.env.ANDTEAM_WEBHOOK_SECRET, req.headers, req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }
  if (await alreadyHandled(event.id)) return res.sendStatus(200);
  await store(event);        // durable first
  res.sendStatus(200);       // then answer
  queue.push(event);         // then do the work
});
```


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