Webhooks
Subscribe to real-time HTTP callbacks for link lifecycle events. Flyn signs every webhook delivery with HMAC-SHA256 so you can verify authenticity.
A webhook is an HTTPS (or HTTP) endpoint on your server that Flyn calls with a signed JSON POST when one of your links is clicked, created, updated or deleted. Webhooks are also the feature most link platforms gate hardest; see the comparison.
Plan and authentication
Creating a webhook needs a paid plan: Pro, Lifetime or Team (members of a paying owner's workspace count as Team). On Free, POST /api/webhooks returns 403 with "code": "UPGRADE_REQUIRED". The endpoints on this page take an API key, also created on Pro, Lifetime or Team, in the Authorization: Bearer flyn_sk_live_... header (see Authentication). MCP OAuth access tokens are not accepted here. The same endpoints are described in the OpenAPI spec.
Events
| Event | Sent | When |
|---|---|---|
| link.click | Yes | A person opened one of your short links. Requests whose user agent matches Flyn's list of bots, crawlers, link-preview fetchers and HTTP libraries, or that send no user agent, are filtered out of redirects and send nothing. The password unlock for a protected link is not filtered. |
| link.create | Yes | A link was created with POST /api/links (which the flyn-sdk package and the MCP server also use) or in the Flyn dashboard. Not sent for POST /api/shorten, POST /api/links/bulk-create (Create Many Links), or links created by the migration importer (Import links on the Links page), which sends no webhook events for the links it creates. |
| link.update | Yes | A link was changed with PATCH /api/links/:id or edited in the dashboard. Moving links between folders in the dashboard sends nothing. |
| link.delete | Yes | A link was deleted with DELETE /api/links/:id. Single and bulk delete in the dashboard use the same route. |
| webhook.test | Ping only | Sent only by the test ping (POST /api/webhooks/:id). You cannot subscribe to it. |
Who receives each event
- link.click goes to the webhooks of the user who created the link.
- link.create, link.update and link.delete go to the webhooks of the user who made the change. If a workspace admin edits or deletes a teammate's link, the admin's webhooks receive the event, not the creator's.
- Only that user's own webhooks that are
activeand subscribed to the event receive it. Events are never fanned out to teammates' webhooks, even thoughGET /api/webhookslists the whole workspace.
Webhook Payloads
Every delivery is an HTTP POST whose JSON body has three keys: event, timestamp (Unix time in milliseconds, the same value as t in the signature header) and data. The shape of data depends on the event and, for link.create and link.update, on where the change was made.
Delivery Headers
| Field | Type | Description |
|---|---|---|
| Content-Type | string | Always "application/json" |
| X-Flyn-Signature | string | t={timestamp in ms},v1={hex HMAC-SHA256}. See Verifying Signatures below. |
| X-Flyn-Event | string | The event name, for example "link.click", or "webhook.test" for a test ping |
| User-Agent | string | Always "Flyn-Webhook/1.0" |
link.click
{
"event": "link.click",
"timestamp": 1790157600000,
"data": {
"linkId": "3f0c2a9e-7b1d-4c55-9a0e-2d8f1b6c4e11",
"slug": "summer-sale",
"destinationUrl": "https://shop.example.com/sale",
"country": "US",
"city": "Austin",
"device": "mobile",
"referrer": "https://www.instagram.com/",
"variant": "B"
}
}| Field | Type | Description |
|---|---|---|
| data.linkId | string | The link id (UUID) |
| data.slug | string | The short link slug |
| data.destinationUrl | string | Where the visitor was sent after device, country, A/B and rotation rules, before any query parameters the visitor added are passed through. When the link has reached its click limit, this is the fallback URL. For password-protected, mailto:, tel: and sms: links, it is the link's stored destination. |
| data.country | string | Two-letter country code, or "Unknown" |
| data.city | string | null | City name, or null when unknown |
| data.device | string | "mobile", "desktop" or "tablet" |
| data.referrer | string | The referring URL the browser sent, or "Direct" when there is none. A referrer on flyn.to or on the link's own short domain is also reported as "Direct". For a password-protected link, this is the raw Referer header of the password form request. |
| data.variant | string | Only present when the link uses A/B testing ("A" or "B") or rotation (the id of the destination that was served). Omitted for password-protected, mailto:, tel: and sms: links and for click-limit fallbacks. |
link.click does not include the browser or operating system. Use Click Analytics for those.
link.create
From POST /api/links, data is the full link object, the same one the API returns (see Links for every field):
{
"event": "link.create",
"timestamp": 1790157600000,
"data": {
"id": "3f0c2a9e-7b1d-4c55-9a0e-2d8f1b6c4e11",
"slug": "summer-sale",
"url": "https://shop.example.com/sale",
"shortUrl": "https://flyn.co/summer-sale",
"domain": "flyn.co",
"title": "shop.example.com",
"clicks": 0,
"botClicks": 0,
"qrScans": 0,
"totalClicks": 0,
"status": "active",
"tags": [
"q3"
],
"createdAt": "2026-09-23T10:00:00.123456+00:00",
"expiresAt": null,
"folderId": null,
"password": false,
"ogTitle": null,
"ogDescription": null,
"ogImage": null,
"targeting": null,
"cloaking": false,
"noIndex": false,
"notes": null,
"abUrlB": null,
"abSplit": null,
"rotation": null,
"deepLink": false,
"pixelIds": [],
"forwardParams": true,
"clickLimit": null,
"fallbackUrl": null,
"conversionTracking": false,
"conversions": 0,
"conversionValueCents": 0,
"conversionCurrency": null
}
}From the dashboard (a single new link), data has exactly four fields: id, slug, destinationUrl and domain. Note the name destinationUrl, where the API object uses url.
{
"event": "link.create",
"timestamp": 1790157600000,
"data": {
"id": "3f0c2a9e-7b1d-4c55-9a0e-2d8f1b6c4e11",
"slug": "summer-sale",
"destinationUrl": "https://shop.example.com/sale",
"domain": "flyn.co"
}
}Events from the dashboard (link.create and link.update) are limited to 30 per minute per IP address, so creating or editing many links in quick succession can skip some of them. Links created by the migration importer send no link.create at all.
link.update
From PATCH /api/links/:id, data is the full link object after the change:
{
"event": "link.update",
"timestamp": 1790161200000,
"data": {
"id": "3f0c2a9e-7b1d-4c55-9a0e-2d8f1b6c4e11",
"slug": "summer-sale",
"url": "https://shop.example.com/sale-v2",
"shortUrl": "https://flyn.co/summer-sale",
"domain": "flyn.co",
"title": "Summer sale",
"clicks": 42,
"botClicks": 3,
"qrScans": 5,
"totalClicks": 45,
"status": "active",
"tags": [
"q3"
],
"createdAt": "2026-09-23T10:00:00.123456+00:00",
"expiresAt": null,
"folderId": null,
"password": false,
"ogTitle": null,
"ogDescription": null,
"ogImage": null,
"targeting": null,
"cloaking": false,
"noIndex": false,
"notes": null,
"abUrlB": null,
"abSplit": null,
"rotation": null,
"deepLink": false,
"pixelIds": [],
"forwardParams": true,
"clickLimit": null,
"fallbackUrl": null,
"conversionTracking": false,
"conversions": 0,
"conversionValueCents": 0,
"conversionCurrency": null
}
}From the dashboard, data is id plus only the settings changed in that save, under the dashboard's field names (destinationUrl rather than url). Read-only fields such as shortUrl, clicks and createdAt are never included. Fetch GET /api/links/:id if you need the whole link. A password change appears as password: true (set or replaced) or false (removed); the password itself is never sent.
{
"event": "link.update",
"timestamp": 1790161200000,
"data": {
"id": "3f0c2a9e-7b1d-4c55-9a0e-2d8f1b6c4e11",
"destinationUrl": "https://shop.example.com/sale-v2"
}
}link.delete
data is the full link object as it was just before it was deleted, whether the delete came from the API or the dashboard:
{
"event": "link.delete",
"timestamp": 1790164800000,
"data": {
"id": "3f0c2a9e-7b1d-4c55-9a0e-2d8f1b6c4e11",
"slug": "summer-sale",
"url": "https://shop.example.com/sale-v2",
"shortUrl": "https://flyn.co/summer-sale",
"domain": "flyn.co",
"title": "Summer sale",
"clicks": 42,
"botClicks": 3,
"qrScans": 5,
"totalClicks": 45,
"status": "active",
"tags": [
"q3"
],
"createdAt": "2026-09-23T10:00:00.123456+00:00",
"expiresAt": null,
"folderId": null,
"password": false,
"ogTitle": null,
"ogDescription": null,
"ogImage": null,
"targeting": null,
"cloaking": false,
"noIndex": false,
"notes": null,
"abUrlB": null,
"abSplit": null,
"rotation": null,
"deepLink": false,
"pixelIds": [],
"forwardParams": true,
"clickLimit": null,
"fallbackUrl": null,
"conversionTracking": false,
"conversions": 0,
"conversionValueCents": 0,
"conversionCurrency": null
}
}webhook.test
Sent only by the test ping, signed the same way as real deliveries:
{
"event": "webhook.test",
"timestamp": 1790157600000,
"data": {
"message": "This is a test webhook from Flyn"
}
}Verifying Signatures
Verify every delivery before you trust it. The X-Flyn-Signature header carries a timestamp and one signature:
X-Flyn-Signature: t=1790157600000,v1=<64 hex characters> v1 = hex( HMAC-SHA256( key, t + "." + raw_body ) ) // key = your full webhook secret, INCLUDING the "whsec_" prefix // t = the "t" value from the header: Unix time in MILLISECONDS // raw_body = the request body bytes exactly as received, before any JSON parsing
- Use the whole secret as the HMAC key. Stripping
whsec_produces a different signature. - Sign the raw body. Parsing the JSON and serializing it again can change the bytes, and then the signature will not match.
- Compare signatures with a constant-time function such as
crypto.timingSafeEqual, not===. tis in milliseconds. Compare it withDate.now(), not with a value in seconds, and reject deliveries outside your tolerance (5 minutes below) to limit replays.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
// The FULL secret, including the "whsec_" prefix. Do not strip it.
const SECRET = process.env.FLYN_WEBHOOK_SECRET;
const TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
function verifyFlynSignature(rawBody, header, secret) {
if (!header) return false;
// Header format: t=<unix time in MILLISECONDS>,v1=<hex HMAC-SHA256>
const parts = {};
for (const piece of header.split(',')) {
const i = piece.indexOf('=');
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1 || !/^\d+$/.test(t)) return false;
// t is in milliseconds, so compare it with Date.now() directly.
if (Math.abs(Date.now() - Number(t)) > TOLERANCE_MS) return false;
// Sign t + "." + the raw body bytes exactly as they arrived.
const expected = createHmac('sha256', secret)
.update(t + '.')
.update(rawBody)
.digest();
const received = Buffer.from(v1, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
// express.raw keeps the body as a Buffer. Do not use express.json() on this
// route: re-serializing parsed JSON can change the bytes that were signed.
app.post('/webhooks/flyn', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyFlynSignature(req.body, req.get('x-flyn-signature'), SECRET)) {
return res.status(400).send('Invalid signature');
}
const { event, data } = JSON.parse(req.body.toString('utf8'));
// Answer within 10 seconds, then do slow work. Flyn does not retry.
res.sendStatus(200);
handleEvent(event, data); // your own code
});Delivery
- One attempt per event. Flyn sends each event once to every matching endpoint, in parallel.
- No retries. If your endpoint is down, slow or returns an error, that event is not sent again. To catch up after an outage, read your links with
GET /api/links. - 10 second timeout for the whole request, including any redirects. Respond first and do slow work afterwards.
- Success is any 2xx response. A success resets the failure count to 0 and updates
lastTriggeredAt. - Failure is a final response outside 2xx, which adds 1 to
failureCount. A delivery Flyn skips because the URL's hostname no longer resolves, or now resolves to a private address, also counts as one. - Not counted: timeouts, refused connections and other network errors, and redirects Flyn refuses to follow. They neither add to nor reset the failure count.
- Auto-disable: on the 10th consecutive counted failure the webhook's status becomes
inactiveand it stops receiving events. - Reactivate with
PATCH /api/webhooks/:idand{"status": "active"}, which also resets the failure count to 0. - Redirects: Flyn follows up to 5 redirects, sending the same POST, body and signature to each hop. A redirect to a private address, an invalid Location header or a sixth redirect ends the delivery.
- Private or internal URLs are refused when you create or update a webhook, and checked again at every delivery and every redirect hop.
Limits
- Maximum 10 webhooks per user account. Inactive webhooks count toward the limit; delete one to add another.
- Webhook URLs must be public HTTP or HTTPS endpoints (no private or internal addresses).
- Rate limits per IP address: list 30 per minute; create, update and delete 10 per minute each; test ping 5 per minute. See Rate Limits.
Create a Webhook
Requires Pro, Lifetime or Team. The webhook is added to your current workspace.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | The endpoint that receives deliveries, http or https. It must resolve to a public address. |
| events | string[] | Yes | Events to subscribe to: link.click, link.create, link.update or link.delete. Unknown names are silently dropped; at least one valid name must remain. |
curl -X POST https://www.flyn.to/api/webhooks \
-H "Authorization: Bearer flyn_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yoursite.com/webhooks/flyn",
"events": ["link.click", "link.create", "link.delete"]
}'Response (201)
{
"webhook": {
"id": "8b3c1f6e-2a4d-4f0b-9c1e-5d7a2b9e0c11",
"url": "https://api.yoursite.com/webhooks/flyn",
"events": ["link.click", "link.create", "link.delete"],
"secret": "whsec_3f9a0c7d1e2b4a5c6d7e8f901a2b3c4d5e6f7a8b9c0d1e2f",
"status": "active",
"createdAt": 1790157600000,
"failureCount": 0
}
}Save the secret now. Through the API, this is the only response that returns it in full; list and update calls made with an API key return it masked. If you created the webhook, you can also reveal and copy the secret in the dashboard under Settings, Webhooks.
Errors
- 400:
Webhook URL is required,Only HTTP/HTTPS URLs allowed,Webhook URL must be a public address,Invalid URL,Select at least one event,Maximum 10 webhooks allowed - 403 on Free:
{"error": "Webhooks requires a paid plan. ...", "code": "UPGRADE_REQUIRED", "upgradeUrl": "/pricing"} - 429:
Too many requests, with a Retry-After header
List Webhooks
Returns every webhook in your current workspace, including ones your teammates created, plus any of your own webhooks that are not tied to a workspace, newest first. You can only update, ping or delete webhooks you created.
Response Fields
| Field | Type | Description |
|---|---|---|
| webhooks[].id | string | Webhook UUID |
| webhooks[].url | string | Endpoint URL |
| webhooks[].events | string[] | Subscribed events |
| webhooks[].secret | string | With an API key, and for webhooks a teammate created: masked, the first 8 characters followed by 24 bullet characters. In full only in the dashboard, to the user who created the webhook. |
| webhooks[].status | string | "active" or "inactive" |
| webhooks[].createdAt | number | Unix timestamp (ms) |
| webhooks[].lastTriggeredAt | number | null | Time of the last 2xx delivery (Unix ms), or null |
| webhooks[].failureCount | number | Consecutive counted failures (non-2xx responses, or deliveries skipped because the URL no longer resolves to a public address) since the last success or reactivation. Timeouts and connection errors are not counted. At 10 the webhook is set inactive. |
Update a Webhook
Works only on a webhook you created. Send any combination of fields.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | No | New endpoint URL, http or https, resolving to a public address |
| events | string[] | No | Replaces the subscribed events. Must be a non-empty array, and every name must be one of link.click, link.create, link.update or link.delete, or the call returns 400. |
| status | string | No | "active" or "inactive". Setting "active" also resets the failure count to 0. |
Response (200)
{
"webhook": {
"id": "8b3c1f6e-2a4d-4f0b-9c1e-5d7a2b9e0c11",
"url": "https://api.yoursite.com/webhooks/flyn",
"events": ["link.click"],
"secret": "whsec_3f...",
"status": "active",
"createdAt": 1790157600000,
"lastTriggeredAt": 1790161200000,
"failureCount": 0
}
}With an API key, secret is the first 8 characters followed by ....
Errors
- 400:
Only HTTP/HTTPS URLs allowed,Webhook URL must be a public address,Invalid URL,Events must be a non-empty array,Invalid events: ...,Status must be active or inactive - 500
Failed to update webhookwhen the id does not match a webhook you created (not 404) - 429:
Too many requests, with a Retry-After header
Send Test Ping
Sends a signed webhook.test delivery to a webhook you created, with X-Flyn-Event: webhook.test. It works on inactive webhooks and does not change failureCount or lastTriggeredAt. The result of the delivery is reported in a 200 response body.
Response (200)
{ "success": true, "status": 200 }If your endpoint answers with a status outside 2xx:
{ "success": false, "status": 500, "statusText": "Internal Server Error" }If the request times out after 10 seconds or fails on the network:
{ "success": false, "error": "Failed to send test ping" }Errors
- 404
Webhook not foundwhen the id does not match a webhook you created - 400
Webhook URL must be a public address(also returned when the webhook's hostname does not resolve) - 429
Rate limit exceededafter 5 pings per minute, without a Retry-After header
Delete a Webhook
Permanently deletes a webhook you created. The response is the same when the id matches nothing you created, so a 200 does not prove a webhook was deleted; check GET /api/webhooks if you need to be sure.
Response (200)
{ "success": true }Was this page helpful? Spotted something wrong?