# Webhooks

Signed outbound webhooks from Links and Sign, and email delivery for Forms submissions.

Canonical: https://easerix.com/docs/developers/webhooks

<Answer label="How Easerix webhooks work">
Links and Sign can POST JSON to your HTTPS endpoint when events happen — link
clicks and conversions, document signatures and completions. Every delivery is
signed with HMAC-SHA256 in an `X-Easerix-Signature` header so you can verify
it came from Easerix. Forms delivers submissions to email destinations today.
</Answer>

## Registering a webhook

Links and Sign share the same registration API:

```bash
curl -s https://api.easerix.com/links/v1/webhooks \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/easerix", "events": ["link.created", "link.clicked"]}'
```

The response includes the webhook and a signing `secret` (`whsec_...`) —
shown **once**, so store it immediately. The URL must be HTTPS. Manage
webhooks with `GET /v1/webhooks`, `PATCH /v1/webhooks/:id` (change `url`,
`events`, or `active`), and `DELETE /v1/webhooks/:id`.

| | Links | Sign |
|---|---|---|
| Base URL | `https://api.easerix.com/links` | `https://api.easerix.com/sign` |
| Default events | `link.created`, `link.updated`, `link.deleted` | `*` (all events) |
| Wildcard `*` subscription | No | Yes |
| Test delivery | `POST /v1/webhooks/:id/test` sends a `ping` | Not available |
| Webhooks per account | Unlimited | 10 |

## Verifying signatures

Every delivery carries these headers:

| Header | Value |
|---|---|
| `X-Easerix-Event` | The event name, e.g. `link.clicked` |
| `X-Easerix-Signature` | `sha256=<hex HMAC-SHA256 of the raw request body>` |
| `X-Easerix-Delivery` | Unique delivery ID, stable across retries (Links only) |

Compute the HMAC of the **raw request body** with your `whsec_...` secret and
compare:

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

function verify(rawBody, header, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
```

Reject anything that doesn't verify. On Links, use `X-Easerix-Delivery` to
deduplicate retried deliveries.

## Links events

The payload envelope is always:

```json
{ "event": "link.clicked", "at": "2026-08-09T17:30:00Z", "data": { ... } }
```

| Event | `data` contains |
|---|---|
| `link.created` / `link.updated` / `link.deleted` | `{id, shortCode, destinationUrl, title, active, archived}` |
| `link.clicked` | `{link: {id, shortCode}, clicks, totalClicks}` |
| `link.converted` | `{link: {id, shortCode}, conversion: {id, name, amountCents, currency}, clickId, clickedAt, convertedAt}` |

`link.clicked` is **aggregated**: rapid clicks on the same link are batched
into one event with a `clicks` count, so don't assume one event per click —
`totalClicks` is the link's running total.

## Sign events

Sign payloads are flat (no `data` wrapper):

```json
{
  "event": "document.signed",
  "documentId": "doc_...",
  "document": { "id": "doc_...", "name": "MSA — Acme", "status": "in_progress" },
  "occurredAt": "2026-08-09T17:30:00Z",
  "recipient": { "id": "...", "name": "...", "email": "...", "role": "signer", "status": "signed" }
}
```

| Event | Fires when |
|---|---|
| `document.sent` | A document is sent for signature |
| `document.viewed` | A recipient opens the document |
| `document.signed` | A recipient signs |
| `document.declined` | A recipient declines |
| `document.completed` | Everyone has signed |
| `document.voided` | The sender voids the document |
| `document.expired` | The document passes its expiry |

`recipient` is present only on the recipient-scoped events (`viewed`,
`signed`, `declined`).

## Delivery and retries

Both tools deliver with a 10-second timeout and treat any 2xx as success. A
failed delivery is retried twice (Links waits ~1s then ~5s; Sign waits 5s then
20s). After **20 consecutive failures** a webhook is automatically disabled —
re-enable it with `PATCH /v1/webhooks/:id {"active": true}`, which also resets
the failure count. Respond quickly (queue the work, return `200` immediately)
to stay under the timeout.

## Forms: email destinations

Forms doesn't send outbound webhooks yet — **email is the delivery channel**.
Each form has destinations managed at
`https://f.easerix.com/v1/forms/:id/destinations`; an `email` destination with
config `{"to": ["ops@example.com"]}` receives a branded notification for every
non-spam submission, with `Reply-To` set to the submitter when their email is
detected. Forms can also send an autoresponder to the submitter if you enable
it on the form.

To get form submissions into your own systems today, poll
`GET /v1/forms/:id/submissions` on the [Forms API](/developers/api/forms).

## Frequently asked questions

### Are deliveries replayed in order?

No ordering guarantee — treat each event independently and use the payload's
timestamp (`at` / `occurredAt`) rather than arrival order.

### My endpoint was down — did I lose events?

Each delivery is retried twice, then dropped. After 20 consecutive failures
the webhook is disabled entirely, so re-enable it and reconcile via the REST
API after an outage.

### Can I rotate the signing secret?

Delete the webhook and create a new one — a fresh secret is generated and
returned once on creation.
