---
title: "Webhooks for short link events · Flared"
description: "Get a signed HTTPS request when a Flared short link is created, changed, turned off or on, or reaches a click count. Events, payloads, signatures, and retries."
---

Documentation

# Webhooks

A webhook sends a signed HTTPS `POST` request to your endpoint when a link changes or reaches a click count. The workspace owner adds endpoints in the dashboard under Settings, Webhooks: enter the URL, choose the events, and copy the signing secret. Flared shows the secret once. Send test sends a `webhook.test` event to the endpoint.

## Events

**`link.created`**

A link was created in the dashboard, the CLI, the API, MCP, or an import.

**`link.updated`**

The destination, the title, or an app link of a link changed. UTM parameters are part of the destination.

**`link.disabled`**

A link was turned off. When one edit also turns the link off, the event is link.disabled.

**`link.enabled`**

A link was turned back on.

**`link.click_milestone`**

A link reached a number of counted clicks. Each endpoint chooses its interval: a multiple of 100, from 100 to 1,000,000. The default is 1,000.

A click milestone counts the clicks Flared recorded for the link since tracking began, starting from the click history that Flared still keeps. A link that passed a count before you subscribed does not send that count. Flared sends milestones within about 10 minutes.

## Payload

Each request has a JSON body with the event `id`, its `type`, the `timestamp` of the change, the `workspace`, and `data`. `data.link` is the link as the API returns it.

```
{
  "id": "evt_4f1c2a9b8d7e4c3fa1b2c3d4e5f60718",
  "type": "link.created",
  "timestamp": "2026-10-17T09:12:30.123Z",
  "workspace": {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
  },
  "data": {
    "link": {
      "id": "3b241101-e2bb-4255-8caf-4136c566a962",
      "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "hostname": "go.example.com",
      "slug": "launch",
      "shortUrl": "https://go.example.com/launch",
      "destination": "https://example.com/launch",
      "title": "Launch page",
      "iosDestination": null,
      "androidDestination": null,
      "enabled": true,
      "blocked": null,
      "createdAt": "2026-10-17T09:12:30.123Z",
      "updatedAt": "2026-10-17T09:12:30.123Z"
    }
  }
}
```

`link.updated`, `link.disabled`, and `link.enabled` add `previous`: the destination, title, app links, and state from before the change.

```
{
  "id": "evt_9a8b7c6d5e4f40312a1b2c3d4e5f6071",
  "type": "link.disabled",
  "timestamp": "2026-10-18T14:03:11.402Z",
  "workspace": {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
  },
  "data": {
    "link": {
      "id": "3b241101-e2bb-4255-8caf-4136c566a962",
      "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "hostname": "go.example.com",
      "slug": "launch",
      "shortUrl": "https://go.example.com/launch",
      "destination": "https://example.com/launch",
      "title": "Launch page",
      "iosDestination": null,
      "androidDestination": null,
      "enabled": false,
      "blocked": null,
      "createdAt": "2026-10-17T09:12:30.123Z",
      "updatedAt": "2026-10-18T14:03:11.402Z"
    },
    "previous": {
      "destination": "https://example.com/launch",
      "title": "Launch page",
      "iosDestination": null,
      "androidDestination": null,
      "enabled": true
    }
  }
}
```

A click milestone adds `clicks`. The link shows its state when Flared queued the event, up to about 10 minutes after the click. Retries send the same payload.

```
{
  "id": "link.click_milestone:3b241101-e2bb-4255-8caf-4136c566a962:1000",
  "type": "link.click_milestone",
  "timestamp": "2026-10-21T08:40:00.517Z",
  "workspace": {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
  },
  "data": {
    "link": {
      "id": "3b241101-e2bb-4255-8caf-4136c566a962",
      "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "hostname": "go.example.com",
      "slug": "launch",
      "shortUrl": "https://go.example.com/launch",
      "destination": "https://example.com/launch",
      "title": "Launch page",
      "iosDestination": null,
      "androidDestination": null,
      "enabled": true,
      "blocked": null,
      "createdAt": "2026-10-17T09:12:30.123Z",
      "updatedAt": "2026-10-17T09:12:30.123Z"
    },
    "clicks": 1000
  }
}
```

The test event carries a message only.

```
{
  "id": "evt_0b1c2d3e4f5a46b7c8d9e0f1a2b3c4d5",
  "type": "webhook.test",
  "timestamp": "2026-10-17T09:00:02.250Z",
  "workspace": {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
  },
  "data": {
    "message": "Test event from Flared."
  }
}
```

## Headers

The headers follow the Standard Webhooks specification.

- `webhook-id`: the event ID. Every attempt of one event has the same ID.
- `webhook-timestamp`: the time of this attempt, in Unix seconds.
- `webhook-signature`: `v1,` and the base64 signature. For 24 hours after you rotate the secret, it has two signatures separated by a space: one from the old secret and one from the new.
- `content-type`: `application/json`
- `user-agent`: `Flared-Webhooks/1.0`

## Verify the signature

The signature is an HMAC-SHA256 of the `webhook-id`, the `webhook-timestamp`, and the raw body, joined by periods. The key is the part of the secret after `whsec_`, decoded from base64. Check the raw body before you parse it, and reject a timestamp more than 5 minutes from your clock. This example works on Cloudflare Workers, Node.js 20 and later, Deno, and browsers.

```
// Returns true when a signature in the header matches. body is the raw request body.
export async function verifyWebhook(secret, headers, body) {
  const id = headers.get('webhook-id');
  const timestamp = headers.get('webhook-timestamp');
  const header = headers.get('webhook-signature');
  if (!id || !timestamp || !header || !/^\d+$/.test(timestamp)) return false;
  // Reject old requests, so a copied request cannot be sent again later.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = await crypto.subtle.importKey(
    'raw',
    base64Bytes(secret.slice('whsec_'.length)),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
  const expected = new Uint8Array(await crypto.subtle.sign('HMAC', key, signed));
  return header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    return version === 'v1' && signature !== undefined && sameBytes(base64Bytes(signature), expected);
  });
}

function base64Bytes(text) {
  try {
    return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
  } catch {
    return new Uint8Array();
  }
}

// Compares every byte, so the time taken does not show where a forged signature differs.
function sameBytes(a, b) {
  if (a.length !== b.length) return false;
  let difference = 0;
  for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i];
  return difference === 0;
}
```

In a request handler:

```
const body = await request.text();
if (!(await verifyWebhook(env.FLARED_WEBHOOK_SECRET, request.headers, body)))
  return new Response('Invalid signature', { status: 401 });
const event = JSON.parse(body);
```

The [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) libraries, such as the `standardwebhooks` package, also verify Flared requests. If the secret leaks, rotate it under Settings, Webhooks. Both secrets sign requests for 24 hours, so you can update your endpoint without lost events.

## Retries and order

- Flared sends the first attempt right after a link change. Any 2xx response within 10 seconds is a success. A redirect, another status, a timeout, or a network error is a failure.
- A scheduled job sends retries every 10 minutes. The wait before each retry starts at 1 minute and doubles after each attempt. After a failure, Flared pauses the endpoint for a time that also grows; a success ends the pause.
- A delivery fails after 10 attempts, or when it has not succeeded after 72 hours.
- Delivery is at least once, so an event can arrive more than once. Use `webhook-id` to skip duplicates.
- Events can arrive out of order. Compare `data.link.updatedAt` to keep the latest state of a link.
- Settings, Webhooks shows the recent deliveries of each endpoint. Flared keeps events and deliveries for 30 days.

## Limits

- Plus and Founding Lifetime include 5 endpoints per workspace. Free does not include webhooks. See [pricing](https://flared.page/pricing).
- A self-hosted installation allows 10 endpoints by default. Its owner changes the number on the Limits page.
- The URL uses https on the default port, 443, and a public hostname, with no user name or password, in at most 2,048 characters.
- Flared waits 10 seconds for a response.

## Support

Email [support@flared.page](mailto:support@flared.page). The other ways to use Flared are in the [documentation](https://flared.page/docs).
