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/jsonuser-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 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-idto skip duplicates. - Events can arrive out of order. Compare
data.link.updatedAtto 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.
- 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. The other ways to use Flared are in the documentation.