Webhooks
Webhooks push events to your server as they happen, so you do not have to poll for new clicks. Subscribe an HTTPS endpoint to one or more event types, and Link Squeeze POSTs a signed JSON payload to it every time one of those events fires.
| Method | Path | Scope |
|---|---|---|
GET | /v1/webhooks | webhooks:manage |
POST | /v1/webhooks | webhooks:manage |
DELETE | /v1/webhooks/{id} | webhooks:manage |
There is no update endpoint. To change a subscription’s URL or event types, delete it and create a new one — which also issues a new signing secret.
Events
| Event | Fires when | data payload |
|---|---|---|
link.created | A short link is created, via the API or the dashboard | The link object |
link.clicked | A short link is visited | The click object |
pixel.fired | A retargeting pixel fires on a link’s redirect page | A pixel-firing object (below) |
The webhook object
{
"id": "b8e3f0a2-5d61-4c93-8a47-6f2e1b9d0c54",
"url": "https://acme.com/hooks/linksqueeze",
"event_types": ["link.created", "link.clicked"],
"created_at": "2026-08-28T10:14:02.000000Z",
"updated_at": "2026-08-28T10:14:02.000000Z"
}| Field | Type | Description |
|---|---|---|
id | uuid | The subscription’s identifier |
url | string | Where deliveries are sent |
event_types | array of strings | Events this subscription receives |
created_at | timestamp | When the subscription was created |
updated_at | timestamp | When the subscription was last modified |
Create a subscription
POST /v1/webhooksScope: webhooks:manage
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | An HTTPS URL on a publicly reachable host, maximum 2048 characters. |
event_types | array of strings | Yes | At least one of link.created, link.clicked, pixel.fired. |
Returns 201 Created. The response is the only place the signing secret ever appears:
{
"data": {
"id": "b8e3f0a2-5d61-4c93-8a47-6f2e1b9d0c54",
"url": "https://acme.com/hooks/linksqueeze",
"event_types": ["link.created", "link.clicked"],
"created_at": "2026-08-28T10:14:02.000000Z",
"updated_at": "2026-08-28T10:14:02.000000Z",
"secret": "yGq2v9NfC3xJ8bT1sR6wE4dP7hK0mZaLuVnQiOe5"
}
}curl -X POST https://app.linksqueeze.io/api/v1/webhooks \
-H "Authorization: Bearer $LINKSQUEEZE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"url": "https://acme.com/hooks/linksqueeze",
"event_types": ["link.created", "link.clicked"]
}'Store the secret now. It is returned once, on creation, and never again
— not by GET /v1/webhooks, not by anything in the dashboard. Without it you
cannot verify deliveries, and the only recovery is to delete the subscription
and create a new one.
URL requirements
The URL must use https and must resolve to a publicly routable address. URLs pointing at loopback, private, or link-local ranges are rejected with 422 — a webhook that could reach internal infrastructure would be a server-side request forgery vector.
This is enforced again at delivery time, against the address the request actually connects to, so a hostname that is re-pointed at a private address after registration is blocked rather than followed. Redirects are never followed for the same reason.
List subscriptions
GET /v1/webhooksScope: webhooks:manage
Returns your subscriptions, newest first, in the standard paginated shape. Supports page and per_page (default 15, maximum 100). Secrets are not included.
curl https://app.linksqueeze.io/api/v1/webhooks \
-H "Authorization: Bearer $LINKSQUEEZE_API_KEY" \
-H "Accept: application/json"Delete a subscription
DELETE /v1/webhooks/{id}Scope: webhooks:manage
Returns 204 No Content. Deliveries stop immediately.
curl -X DELETE https://app.linksqueeze.io/api/v1/webhooks/b8e3f0a2-5d61-4c93-8a47-6f2e1b9d0c54 \
-H "Authorization: Bearer $LINKSQUEEZE_API_KEY" \
-H "Accept: application/json"The delivery payload
Every delivery is a POST with a JSON body in this envelope:
{
"id": "e7d41c98-2a06-4f35-9b8e-3c5a7d1f6024",
"api_version": "v1",
"event": "link.created",
"created_at": "2026-08-28T10:14:02+00:00",
"data": { }
}| Field | Type | Description |
|---|---|---|
id | uuid | Unique id for this delivery. Use it to deduplicate — retries reuse the same id. |
api_version | string | Envelope version, currently v1. |
event | string | The event type that fired. |
created_at | timestamp | When the event was recorded. |
data | object | The event payload, described below. |
Headers
| Header | Example | Description |
|---|---|---|
X-Signature | t=1756377242,v1=3a1f... | Timestamped HMAC signature. See Verifying. |
X-Webhook-Timestamp | 1756377242 | Unix timestamp the delivery was signed at. |
X-Delivery-Id | e7d41c98-2a06-4f35-9b8e-... | Same value as id in the body. |
X-Event-Type | link.created | Same value as event in the body. |
Content-Type | application/json |
The data object is the same shape the REST API returns for that
resource. A link.created payload is byte-for-byte the link object you would
get from GET /v1/links/{id}, so you can parse both with one model. Internal
columns and numeric primary keys are never included — id is always the
public UUID.
link.created
data is the link object.
{
"id": "e7d41c98-2a06-4f35-9b8e-3c5a7d1f6024",
"api_version": "v1",
"event": "link.created",
"created_at": "2026-08-28T10:14:02+00:00",
"data": {
"id": "9b1f4e2a-6c3d-4a17-9f2e-7c8d5a1b3e40",
"title": "Summer Sale",
"destination_url": "https://example.com/summer-sale",
"short_url": "go.acme.com/summer",
"slug": "summer",
"hostname": "go.acme.com",
"utm_params": [],
"tags": [],
"expires_at": null,
"pixel_ids": [],
"clicks": 0,
"status": true,
"created_at": "2026-08-28T10:14:02.000000Z",
"updated_at": "2026-08-28T10:14:02.000000Z"
}
}Fires for links created through the API and through the dashboard.
link.clicked
data is the click object, with an additional link_id naming the link that was clicked:
{
"id": "a3c7b219-8e04-4d61-95f2-7b1c6a0d3e58",
"api_version": "v1",
"event": "link.clicked",
"created_at": "2026-08-28T10:16:31+00:00",
"data": {
"id": "c41d8b30-9e57-4a22-b6f8-1d0e7a94c2b5",
"link_id": "9b1f4e2a-6c3d-4a17-9f2e-7c8d5a1b3e40",
"ip": "203.0.113.42",
"country": "US",
"visitor_id": "visitor-9f2c1b",
"referrer": "https://twitter.com/",
"device": "mobile",
"browser": "Safari",
"platform": "iOS",
"created_at": "2026-08-28T10:16:31.000000Z"
}
}link_id is the link’s public UUID, so you can correlate the click back to a link you fetched from GET /v1/links.
pixel.fired
Fires once per pixel on a link that has retargeting pixels attached. A link with two pixels produces two deliveries per click.
{
"id": "f2b90d47-6c15-4e83-a071-9d3e2c5b8a16",
"api_version": "v1",
"event": "pixel.fired",
"created_at": "2026-08-28T10:16:31+00:00",
"data": {
"pixel_id": "3f9c8a71-2b4d-4e60-8a15-9d7c6b3e21f8",
"platform": "facebook",
"link_id": "9b1f4e2a-6c3d-4a17-9f2e-7c8d5a1b3e40",
"visit_id": "c41d8b30-9e57-4a22-b6f8-1d0e7a94c2b5"
}
}| Field | Type | Description |
|---|---|---|
pixel_id | uuid | The Link Squeeze pixel that fired — not the platform’s own pixel identifier |
platform | string | The pixel’s platform |
link_id | uuid | The link that was clicked |
visit_id | uuid | null | The click that triggered it, matching id on the corresponding link.clicked |
Join visit_id against a link.clicked delivery’s data.id to tie a pixel firing to its click.
Verifying a delivery
Every delivery is signed with the subscription’s secret. Verify the signature before trusting a payload — anyone can POST to your endpoint.
The X-Signature header carries two comma-separated fields:
X-Signature: t=1756377242,v1=3a1f9c...e42bt— the Unix timestamp the delivery was signed atv1— a hex-encoded HMAC-SHA256 of{t}.{raw request body}, keyed with your webhook’s secret
The timestamp is inside the signed string, not merely alongside it, so a captured request cannot be replayed indefinitely.
Read the raw body
Capture the request body as raw bytes, before any JSON parsing. Re-serialising parsed JSON produces different bytes and the signature will not match.
Recompute the HMAC
Concatenate the timestamp, a literal ., and the raw body, then HMAC-SHA256 it with your secret.
Compare in constant time
Compare your result to the v1 field using a timing-safe comparison.
Check the timestamp
Reject deliveries whose t is more than 5 minutes (300 seconds) away from your current time, to bound the replay window.
Node.js
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody must be a Buffer or string of the unparsed request body.
export function verifyLinkSqueezeWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=")),
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return false;
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const received = Buffer.from(parts.v1 ?? "", "utf8");
const computed = Buffer.from(expected, "utf8");
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
);
}With Express, get the raw body using express.raw({ type: "application/json" }) on the webhook route rather than express.json().
Retries and failures
| Behaviour | Value |
|---|---|
| Success condition | Any 2xx response |
| Request timeout | 10 seconds |
| Attempts | Up to 5 |
| Backoff between tries | 10s, 30s, 60s, 5m, 15m |
| Redirects | Not followed — a 3xx counts as a failure |
A delivery that returns a non-2xx status or times out is retried on the schedule above. After the fifth attempt it is marked failed and no further attempts are made.
A delivery whose URL resolves into private address space at send time is marked failed immediately, without consuming any attempts — retrying cannot make a private address public.
Building a reliable consumer
- Respond fast. Acknowledge with
2xxas soon as you have stored the payload, then process it out of band. Anything slower than 10 seconds is treated as a failure and retried. - Deduplicate on
id. Retries reuse the same deliveryid, and a slow-but-successful handler can be retried after it has already done the work. Treat handlers as idempotent. - Do not rely on ordering. Retries and backoff mean a
link.clickeddelivery can arrive after a later one. Order by the payload’screated_at, not arrival time. - Verify before parsing. Signature-check the raw body first, and reject anything that fails, before acting on its contents.