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

EventSentWhen
link.clickYesA 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.createYesA 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.updateYesA link was changed with PATCH /api/links/:id or edited in the dashboard. Moving links between folders in the dashboard sends nothing.
link.deleteYesA link was deleted with DELETE /api/links/:id. Single and bulk delete in the dashboard use the same route.
webhook.testPing onlySent 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 active and subscribed to the event receive it. Events are never fanned out to teammates' webhooks, even though GET /api/webhooks lists 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

FieldTypeDescription
Content-TypestringAlways "application/json"
X-Flyn-Signaturestringt={timestamp in ms},v1={hex HMAC-SHA256}. See Verifying Signatures below.
X-Flyn-EventstringThe event name, for example "link.click", or "webhook.test" for a test ping
User-AgentstringAlways "Flyn-Webhook/1.0"

link.click

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"
  }
}
FieldTypeDescription
data.linkIdstringThe link id (UUID)
data.slugstringThe short link slug
data.destinationUrlstringWhere 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.countrystringTwo-letter country code, or "Unknown"
data.citystring | nullCity name, or null when unknown
data.devicestring"mobile", "desktop" or "tablet"
data.referrerstringThe 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.variantstringOnly 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):

link.create (API)
{
  "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.

link.create (dashboard)
{
  "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:

link.update (API)
{
  "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.

link.update (dashboard)
{
  "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:

link.delete
{
  "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:

webhook.test
{
  "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:

Signature Algorithm
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 ===.
  • t is in milliseconds. Compare it with Date.now(), not with a value in seconds, and reject deliveries outside your tolerance (5 minutes below) to limit replays.
Node.js (Express) verification
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 inactive and it stops receiving events.
  • Reactivate with PATCH /api/webhooks/:id and {"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

POST/api/webhooks

Requires Pro, Lifetime or Team. The webhook is added to your current workspace.

Request Body

ParameterTypeRequiredDescription
urlstringYesThe endpoint that receives deliveries, http or https. It must resolve to a public address.
eventsstring[]YesEvents to subscribe to: link.click, link.create, link.update or link.delete. Unknown names are silently dropped; at least one valid name must remain.
Request Example
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)

Response
{
  "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

GET/api/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

FieldTypeDescription
webhooks[].idstringWebhook UUID
webhooks[].urlstringEndpoint URL
webhooks[].eventsstring[]Subscribed events
webhooks[].secretstringWith 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[].statusstring"active" or "inactive"
webhooks[].createdAtnumberUnix timestamp (ms)
webhooks[].lastTriggeredAtnumber | nullTime of the last 2xx delivery (Unix ms), or null
webhooks[].failureCountnumberConsecutive 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

PATCH/api/webhooks/:id

Works only on a webhook you created. Send any combination of fields.

Request Body

ParameterTypeRequiredDescription
urlstringNoNew endpoint URL, http or https, resolving to a public address
eventsstring[]NoReplaces 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.
statusstringNo"active" or "inactive". Setting "active" also resets the failure count to 0.

Response (200)

Response
{
  "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 webhook when 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.

POST/api/webhooks/:id

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 found when 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 exceeded after 5 pings per minute, without a Retry-After header

Delete a Webhook

DELETE/api/webhooks/:id

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?